diff --git a/README.md b/README.md
index 36424699..9b6025c7 100644
--- a/README.md
+++ b/README.md
@@ -94,7 +94,7 @@ Thanks to your donations, a brand new testing device is available, a Sonim XP380
## 💪 Privacy Policy and Philosophy
- No ads, no premium or paid features. It's all free.
- No spying, no tracking, no telemetry or reports. No nothing!
-- No network connectivity.
+- No network connectivity, except when voice input is active.
- It only does its job.
- Open-source, so you can verify all the above yourself.
- Created with help from the entire community.
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 3f6e4c8d..e4ce36c9 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -1,12 +1,19 @@
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/AbstractHandler.java b/app/src/main/java/io/github/sspanak/tt9/ime/AbstractHandler.java
index 7e73dd7f..3a38bbda 100644
--- a/app/src/main/java/io/github/sspanak/tt9/ime/AbstractHandler.java
+++ b/app/src/main/java/io/github/sspanak/tt9/ime/AbstractHandler.java
@@ -24,6 +24,7 @@ abstract public class AbstractHandler extends InputMethodService {
// UI
abstract protected void createSuggestionBar(View mainView);
+ abstract protected void resetStatus();
abstract protected InputMode getInputMode();
diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/CommandHandler.java b/app/src/main/java/io/github/sspanak/tt9/ime/CommandHandler.java
index f33d27b8..c01c016a 100644
--- a/app/src/main/java/io/github/sspanak/tt9/ime/CommandHandler.java
+++ b/app/src/main/java/io/github/sspanak/tt9/ime/CommandHandler.java
@@ -8,12 +8,18 @@ import io.github.sspanak.tt9.languages.LanguageCollection;
import io.github.sspanak.tt9.ui.UI;
import io.github.sspanak.tt9.ui.dialogs.AddWordDialog;
-abstract class CommandHandler extends TypingHandler {
+abstract public class CommandHandler extends VoiceHandler {
@Override
protected boolean onBack() {
+ if (super.onBack()) {
+ return true;
+ }
+
if (mainView.isCommandPaletteShown()) {
mainView.hideCommandPalette();
- statusBar.setText(mInputMode);
+ if (!voiceInputOps.isListening()) {
+ resetStatus();
+ }
return true;
}
@@ -39,6 +45,10 @@ abstract class CommandHandler extends TypingHandler {
@Override
protected boolean onNumber(int key, boolean hold, int repeat) {
+ if (statusBar.isErrorShown()) {
+ resetStatus();
+ }
+
if (!shouldBeOff() && mainView.isCommandPaletteShown()) {
onCommand(key);
return true;
@@ -63,16 +73,26 @@ abstract class CommandHandler extends TypingHandler {
showSettings();
break;
case 2:
- mainView.hideCommandPalette();
- statusBar.setText(mInputMode);
addWord();
break;
+ case 3:
+ toggleVoiceInput();
+ break;
+ }
+ }
+
+
+ protected void resetStatus() {
+ if (mainView.isCommandPaletteShown()) {
+ statusBar.setText(R.string.commands_select_command);
+ } else {
+ statusBar.setText(mInputMode);
}
}
public void addWord() {
- if (mInputMode.isNumeric()) {
+ if (mInputMode.isNumeric() || voiceInputOps.isListening()) {
return;
}
@@ -83,6 +103,8 @@ abstract class CommandHandler extends TypingHandler {
suggestionOps.cancelDelayedAccept();
mInputMode.onAcceptSuggestion(suggestionOps.acceptIncomplete());
+ mainView.hideCommandPalette();
+ resetStatus();
String word = textField.getSurroundingWord(mLanguage);
if (word.isEmpty()) {
@@ -95,12 +117,13 @@ abstract class CommandHandler extends TypingHandler {
public void changeKeyboard() {
suggestionOps.cancelDelayedAccept();
+ stopVoiceInput();
UI.showChangeKeyboardDialog(this);
}
protected void nextInputMode() {
- if (mInputMode.isPassthrough()) {
+ if (mInputMode.isPassthrough() || voiceInputOps.isListening()) {
return;
} else if (allowedInputModes.size() == 1 && allowedInputModes.contains(InputMode.MODE_123)) {
mInputMode = !mInputMode.is123() ? InputMode.getInstance(settings, mLanguage, inputType, InputMode.MODE_123) : mInputMode;
@@ -130,6 +153,8 @@ abstract class CommandHandler extends TypingHandler {
protected void nextLang() {
+ stopVoiceInput();
+
// select the next language
int previous = mEnabledLanguages.indexOf(mLanguage.getId());
int next = (previous + 1) % mEnabledLanguages.size();
@@ -171,6 +196,17 @@ abstract class CommandHandler extends TypingHandler {
public void showSettings() {
suggestionOps.cancelDelayedAccept();
+ stopVoiceInput();
UI.showSettingsScreen(this);
}
+
+
+ public void showCommandPalette() {
+ suggestionOps.cancelDelayedAccept();
+ suggestionOps.acceptIncomplete();
+ mInputMode.reset();
+
+ mainView.showCommandPalette();
+ resetStatus();
+ }
}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/HotkeyHandler.java b/app/src/main/java/io/github/sspanak/tt9/ime/HotkeyHandler.java
index 9918ed0d..1f2a0971 100644
--- a/app/src/main/java/io/github/sspanak/tt9/ime/HotkeyHandler.java
+++ b/app/src/main/java/io/github/sspanak/tt9/ime/HotkeyHandler.java
@@ -4,7 +4,6 @@ import android.view.KeyEvent;
import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection;
-import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.db.DictionaryLoader;
import io.github.sspanak.tt9.ime.helpers.TextField;
import io.github.sspanak.tt9.ime.modes.ModePredictive;
@@ -231,12 +230,7 @@ public abstract class HotkeyHandler extends CommandHandler {
}
if (!validateOnly) {
- suggestionOps.cancelDelayedAccept();
- suggestionOps.acceptIncomplete();
- mInputMode.reset();
-
- mainView.showCommandPalette();
- statusBar.setText(getString(R.string.commands_select_command));
+ showCommandPalette();
forceShowWindow();
}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/MainViewOps.java b/app/src/main/java/io/github/sspanak/tt9/ime/MainViewOps.java
index 48faffe2..01a93884 100644
--- a/app/src/main/java/io/github/sspanak/tt9/ime/MainViewOps.java
+++ b/app/src/main/java/io/github/sspanak/tt9/ime/MainViewOps.java
@@ -2,6 +2,7 @@ package io.github.sspanak.tt9.ime;
import androidx.annotation.Nullable;
+import io.github.sspanak.tt9.ime.voice.VoiceInputOps;
import io.github.sspanak.tt9.languages.Language;
import io.github.sspanak.tt9.preferences.settings.SettingsStore;
@@ -27,6 +28,10 @@ abstract public class MainViewOps extends HotkeyHandler {
return mInputMode.is123() && inputType.isPhoneNumber();
}
+ public boolean isVoiceInputMissing() {
+ return !(new VoiceInputOps(this, null, null, null)).isAvailable();
+ }
+
@Nullable
public Language getLanguage() {
return mLanguage;
diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/TraditionalT9.java b/app/src/main/java/io/github/sspanak/tt9/ime/TraditionalT9.java
index 10a4eb35..19f11651 100644
--- a/app/src/main/java/io/github/sspanak/tt9/ime/TraditionalT9.java
+++ b/app/src/main/java/io/github/sspanak/tt9/ime/TraditionalT9.java
@@ -139,6 +139,7 @@ public class TraditionalT9 extends MainViewOps {
@Override
protected void onStop() {
+ stopVoiceInput();
onFinishTyping();
suggestionOps.clear();
setStatusIcon(mInputMode);
diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/VoiceHandler.java b/app/src/main/java/io/github/sspanak/tt9/ime/VoiceHandler.java
new file mode 100644
index 00000000..70ea4c06
--- /dev/null
+++ b/app/src/main/java/io/github/sspanak/tt9/ime/VoiceHandler.java
@@ -0,0 +1,82 @@
+package io.github.sspanak.tt9.ime;
+
+import io.github.sspanak.tt9.R;
+import io.github.sspanak.tt9.ime.voice.VoiceInputError;
+import io.github.sspanak.tt9.ime.voice.VoiceInputOps;
+import io.github.sspanak.tt9.ui.dialogs.RequestPermissionDialog;
+import io.github.sspanak.tt9.util.Logger;
+
+abstract class VoiceHandler extends TypingHandler {
+ private final static String LOG_TAG = VoiceHandler.class.getSimpleName();
+ protected VoiceInputOps voiceInputOps;
+
+
+ @Override
+ protected void onInit() {
+ super.onInit();
+
+ voiceInputOps = new VoiceInputOps(
+ this,
+ this::onVoiceInputStarted,
+ this::onVoiceInputStopped,
+ this::onVoiceInputError
+ );
+ }
+
+ @Override
+ protected boolean onBack() {
+ stopVoiceInput();
+ return false; // we don't want to abort other operations, we just silently stop voice input
+ }
+
+ @Override
+ protected boolean onNumber(int key, boolean hold, int repeat) {
+ stopVoiceInput();
+ return super.onNumber(key, hold, repeat);
+ }
+
+ public void toggleVoiceInput() {
+ if (voiceInputOps.isListening() || !voiceInputOps.isAvailable()) {
+ stopVoiceInput();
+ return;
+ }
+
+ statusBar.setText(R.string.loading);
+ suggestionOps.cancelDelayedAccept();
+ mInputMode.onAcceptSuggestion(suggestionOps.acceptIncomplete());
+ voiceInputOps.listen(mLanguage);
+ }
+
+
+ protected void stopVoiceInput() {
+ if (voiceInputOps.isListening()) {
+ statusBar.setText(R.string.voice_input_stopping);
+ voiceInputOps.stop();
+ }
+ }
+
+
+ private void onVoiceInputStarted() {
+ statusBar.setText(voiceInputOps);
+ }
+
+
+ private void onVoiceInputStopped(String text) {
+ onText(text, false);
+ resetStatus();
+ }
+
+
+ private void onVoiceInputError(VoiceInputError error) {
+ if (error.isIrrelevantToUser()) {
+ Logger.i(LOG_TAG, "Ignoring voice input. " + error.debugMessage);
+ resetStatus();
+ } else {
+ Logger.e(LOG_TAG, "Failed to listen. " + error.debugMessage);
+ statusBar.setError(error.toString());
+ if (error.isNoPermission()) {
+ RequestPermissionDialog.show(this);
+ }
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/voice/VoiceInputError.java b/app/src/main/java/io/github/sspanak/tt9/ime/voice/VoiceInputError.java
new file mode 100644
index 00000000..43ccff21
--- /dev/null
+++ b/app/src/main/java/io/github/sspanak/tt9/ime/voice/VoiceInputError.java
@@ -0,0 +1,135 @@
+package io.github.sspanak.tt9.ime.voice;
+
+import android.content.Context;
+import android.os.Build;
+import android.speech.SpeechRecognizer;
+
+import androidx.annotation.NonNull;
+
+import io.github.sspanak.tt9.R;
+
+public class VoiceInputError {
+ public final static int ERROR_NOT_AVAILABLE = 101;
+ public final static int ERROR_INVALID_LANGUAGE = 102;
+
+ public final int code;
+ public final String message;
+ public final String debugMessage;
+
+
+ public VoiceInputError(Context context, int errorCode) {
+ code = errorCode;
+ debugMessage = codeToDebugString(errorCode);
+ message = codeToString(context, errorCode);
+ }
+
+
+ public boolean isNoPermission() {
+ return code == SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS;
+ }
+
+
+ public boolean isIrrelevantToUser() {
+ return
+ code == SpeechRecognizer.ERROR_NO_MATCH
+ || code == SpeechRecognizer.ERROR_SPEECH_TIMEOUT
+ || code == SpeechRecognizer.ERROR_AUDIO
+ || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && code == SpeechRecognizer.ERROR_CANNOT_LISTEN_TO_DOWNLOAD_EVENTS)
+ || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && code == SpeechRecognizer.ERROR_CANNOT_CHECK_SUPPORT)
+ || (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && code == SpeechRecognizer.ERROR_SERVER_DISCONNECTED);
+ }
+
+
+ @NonNull
+ @Override
+ public String toString() {
+ return message;
+ }
+
+
+ @NonNull
+ private static String codeToString(Context context, int code) {
+ switch (code) {
+ case SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS:
+ return context.getString(R.string.voice_input_error_no_permissions);
+ case SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED:
+ return context.getString(R.string.voice_input_error_language_not_supported);
+ case SpeechRecognizer.ERROR_NETWORK:
+ return context.getString(R.string.voice_input_error_no_network);
+ case ERROR_NOT_AVAILABLE:
+ return context.getString(R.string.voice_input_error_not_available);
+ case SpeechRecognizer.ERROR_NETWORK_TIMEOUT:
+ case SpeechRecognizer.ERROR_SERVER:
+ case SpeechRecognizer.ERROR_SERVER_DISCONNECTED:
+ case SpeechRecognizer.ERROR_TOO_MANY_REQUESTS:
+ return context.getString(R.string.voice_input_error_network_failed);
+ default:
+ return context.getString(R.string.voice_input_error_generic);
+ }
+ }
+
+
+ private static String codeToDebugString(int code) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && code == SpeechRecognizer.ERROR_CANNOT_LISTEN_TO_DOWNLOAD_EVENTS) {
+ return "Cannot listen to download events.";
+ }
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && code == SpeechRecognizer.ERROR_CANNOT_CHECK_SUPPORT) {
+ return "Cannot check voice input support.";
+ }
+
+ String message = codeToDebugString31(code);
+ message = message != null ? message : codeToDebugStringCommon(code);
+ message = message != null ? message : "Unknown voice input error code: " + code;
+
+ return message;
+ }
+
+
+ private static String codeToDebugString31(int code) {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ switch (code) {
+ case SpeechRecognizer.ERROR_TOO_MANY_REQUESTS:
+ return "Server overloaded. Try again later.";
+ case SpeechRecognizer.ERROR_SERVER_DISCONNECTED:
+ return "Lost connection to the server.";
+ case SpeechRecognizer.ERROR_LANGUAGE_NOT_SUPPORTED:
+ return "Language not supported.";
+ case SpeechRecognizer.ERROR_LANGUAGE_UNAVAILABLE:
+ return "Language missing. Try again later.";
+ }
+ }
+
+ return null;
+ }
+
+
+ private static String codeToDebugStringCommon(int code) {
+ switch (code) {
+ case SpeechRecognizer.ERROR_AUDIO:
+ return "Audio capture error.";
+ case SpeechRecognizer.ERROR_CLIENT:
+ return "Speech recognition client error.";
+ case SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS:
+ return "No microphone permissions.";
+ case SpeechRecognizer.ERROR_NETWORK:
+ return "No network connection.";
+ case SpeechRecognizer.ERROR_NETWORK_TIMEOUT:
+ return "Network timeout.";
+ case SpeechRecognizer.ERROR_NO_MATCH:
+ return "No match.";
+ case SpeechRecognizer.ERROR_RECOGNIZER_BUSY:
+ return "Voice input service is busy.";
+ case SpeechRecognizer.ERROR_SERVER:
+ return "Speech recognition server error.";
+ case SpeechRecognizer.ERROR_SPEECH_TIMEOUT:
+ return "No speech detected.";
+ case ERROR_NOT_AVAILABLE:
+ return "Voice input is not available.";
+ case ERROR_INVALID_LANGUAGE:
+ return "Invalid language for voice input.";
+ default:
+ return null;
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/voice/VoiceInputOps.java b/app/src/main/java/io/github/sspanak/tt9/ime/voice/VoiceInputOps.java
new file mode 100644
index 00000000..b69c92ff
--- /dev/null
+++ b/app/src/main/java/io/github/sspanak/tt9/ime/voice/VoiceInputOps.java
@@ -0,0 +1,136 @@
+package io.github.sspanak.tt9.ime.voice;
+
+import android.content.Intent;
+import android.inputmethodservice.InputMethodService;
+import android.os.Build;
+import android.speech.RecognizerIntent;
+import android.speech.SpeechRecognizer;
+
+import androidx.annotation.NonNull;
+
+import java.util.ArrayList;
+
+import io.github.sspanak.tt9.R;
+import io.github.sspanak.tt9.languages.Language;
+import io.github.sspanak.tt9.util.ConsumerCompat;
+import io.github.sspanak.tt9.util.Logger;
+
+public class VoiceInputOps {
+ private final boolean isOnDeviceRecognitionAvailable;
+ private final boolean isRecognitionAvailable;
+
+
+ private final InputMethodService ims;
+ private Language language;
+ private SpeechRecognizer speechRecognizer;
+ private final VoiceListener listener;
+
+ private final ConsumerCompat onStopListening;
+ private final ConsumerCompat onListeningError;
+
+
+ public VoiceInputOps(
+ @NonNull InputMethodService ims,
+ Runnable onStart,
+ ConsumerCompat onStop,
+ ConsumerCompat onError
+ ) {
+ isOnDeviceRecognitionAvailable = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && SpeechRecognizer.isOnDeviceRecognitionAvailable(ims);
+ isRecognitionAvailable = SpeechRecognizer.isRecognitionAvailable(ims);
+ listener = new VoiceListener(ims, onStart, this::onStop, this::onError);
+
+ onStopListening = onStop != null ? onStop : result -> {};
+ onListeningError = onError != null ? onError : error -> {};
+
+ this.ims = ims;
+ }
+
+
+ private void createRecognizer() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isOnDeviceRecognitionAvailable) {
+ speechRecognizer = SpeechRecognizer.createOnDeviceSpeechRecognizer(ims);
+ } else if (isRecognitionAvailable) {
+ speechRecognizer = SpeechRecognizer.createSpeechRecognizer(ims);
+ } else {
+ return;
+ }
+
+ speechRecognizer.setRecognitionListener(listener);
+ }
+
+
+ public boolean isAvailable() {
+ return isRecognitionAvailable || isOnDeviceRecognitionAvailable;
+ }
+
+
+ public boolean isListening() {
+ return listener.isListening();
+ }
+
+
+ public void listen(Language language) {
+ if (language == null) {
+ onListeningError.accept(new VoiceInputError(ims, VoiceInputError.ERROR_INVALID_LANGUAGE));
+ return;
+ }
+
+ if (!isAvailable()) {
+ onListeningError.accept(new VoiceInputError(ims, VoiceInputError.ERROR_NOT_AVAILABLE));
+ return;
+ }
+
+ if (isListening()) {
+ onListeningError.accept(new VoiceInputError(ims, SpeechRecognizer.ERROR_RECOGNIZER_BUSY));
+ return;
+ }
+
+ createRecognizer();
+
+ this.language = language;
+ String locale = language.getLocale().toString().replace("_", "-");
+
+ Intent intent = new Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH);
+ intent.putExtra(RecognizerIntent.EXTRA_LANGUAGE, locale);
+ intent.putExtra(RecognizerIntent.EXTRA_PROMPT, toString());
+ speechRecognizer.startListening(intent);
+ Logger.d(getClass().getSimpleName(), "SpeechRecognizer started for locale: " + locale);
+ }
+
+
+ public void stop() {
+ this.language = null;
+ if (isAvailable() && listener.isListening()) {
+ speechRecognizer.stopListening();
+ }
+ }
+
+
+ private void destroy() {
+ this.language = null;
+ if (speechRecognizer != null) {
+ speechRecognizer.destroy();
+ speechRecognizer = null;
+ Logger.d(getClass().getSimpleName(), "SpeechRecognizer destroyed");
+ }
+ }
+
+
+ private void onStop(ArrayList results) {
+ destroy();
+ onStopListening.accept(results.isEmpty() ? null : results.get(0));
+ }
+
+
+ private void onError(VoiceInputError error) {
+ destroy();
+ onListeningError.accept(error);
+ }
+
+ @NonNull
+ @Override
+ public String toString() {
+ String languageSuffix = language == null ? "" : " / " + language.getName();
+ return ims.getString(R.string.voice_input_listening) + languageSuffix;
+ }
+}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ime/voice/VoiceListener.java b/app/src/main/java/io/github/sspanak/tt9/ime/voice/VoiceListener.java
new file mode 100644
index 00000000..386a6c64
--- /dev/null
+++ b/app/src/main/java/io/github/sspanak/tt9/ime/voice/VoiceListener.java
@@ -0,0 +1,65 @@
+package io.github.sspanak.tt9.ime.voice;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.speech.RecognitionListener;
+import android.speech.SpeechRecognizer;
+
+import androidx.annotation.NonNull;
+
+import java.util.ArrayList;
+
+import io.github.sspanak.tt9.util.ConsumerCompat;
+
+class VoiceListener implements RecognitionListener {
+ private boolean listening = false;
+
+ @NonNull private final Context context;
+ private final Runnable onStart;
+ private final ConsumerCompat> onStop;
+ private final ConsumerCompat onError;
+
+ VoiceListener(
+ @NonNull Context context,
+ Runnable onStart,
+ ConsumerCompat> onStop,
+ ConsumerCompat onError
+ ) {
+ this.context = context;
+ this.onStart = onStart != null ? onStart : () -> {};
+ this.onStop = onStop != null ? onStop : (t) -> {};
+ this.onError = onError != null ? onError : (e) -> {};
+ }
+
+ public boolean isListening() {
+ return listening;
+ }
+
+ @Override
+ public void onReadyForSpeech(Bundle params) {
+ listening = true;
+ onStart.run();
+ }
+
+ @Override
+ public void onError(int error) {
+ listening = false;
+ onError.accept(new VoiceInputError(context, error));
+ }
+
+ @Override
+ public void onResults(Bundle resultsRaw) {
+ listening = false;
+
+ ArrayList results = resultsRaw.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION);
+ onStop.accept(results == null ? new ArrayList<>() : results);
+ }
+
+ // we don't care about these, but the interface requires us to implement them
+ @Override public void onPartialResults(Bundle results) {}
+ @Override public void onBeginningOfSpeech() {}
+ @Override public void onEndOfSpeech() {}
+ @Override public void onEvent(int e, Bundle b) {}
+ @Override public void onRmsChanged(float r) {}
+ @Override public void onBufferReceived(byte[] b) {}
+}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ui/dialogs/PopupDialogActivity.java b/app/src/main/java/io/github/sspanak/tt9/ui/dialogs/PopupDialogActivity.java
index 8a56ddd8..4182f854 100644
--- a/app/src/main/java/io/github/sspanak/tt9/ui/dialogs/PopupDialogActivity.java
+++ b/app/src/main/java/io/github/sspanak/tt9/ui/dialogs/PopupDialogActivity.java
@@ -3,6 +3,7 @@ package io.github.sspanak.tt9.ui.dialogs;
import android.content.Intent;
import android.os.Bundle;
+import androidx.annotation.NonNull;
import androidx.appcompat.app.AppCompatActivity;
import io.github.sspanak.tt9.ime.TraditionalT9;
@@ -51,4 +52,10 @@ public class PopupDialogActivity extends AppCompatActivity {
intent.putExtra(PopupDialog.INTENT_CLOSE, message);
startService(intent);
}
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ onDialogClose(null);
+ }
}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ui/dialogs/RequestPermissionDialog.java b/app/src/main/java/io/github/sspanak/tt9/ui/dialogs/RequestPermissionDialog.java
new file mode 100644
index 00000000..8e4469f1
--- /dev/null
+++ b/app/src/main/java/io/github/sspanak/tt9/ui/dialogs/RequestPermissionDialog.java
@@ -0,0 +1,64 @@
+package io.github.sspanak.tt9.ui.dialogs;
+
+import android.Manifest;
+import android.content.Intent;
+import android.content.pm.PackageManager;
+import android.inputmethodservice.InputMethodService;
+import android.os.Bundle;
+
+import androidx.annotation.NonNull;
+import androidx.appcompat.app.AppCompatActivity;
+
+import io.github.sspanak.tt9.R;
+import io.github.sspanak.tt9.ime.TraditionalT9;
+import io.github.sspanak.tt9.ui.UI;
+import io.github.sspanak.tt9.util.Permissions;
+
+public class RequestPermissionDialog extends AppCompatActivity {
+ private final Permissions permissions;
+
+ public RequestPermissionDialog() {
+ super();
+ permissions = new Permissions(this);
+ }
+
+ @Override
+ protected void onCreate(Bundle savedData) {
+ super.onCreate(savedData);
+
+ // currently there is only one permission to request, so we don't ovecomplicate it
+ permissions.requestRecordAudio();
+ }
+
+ @Override
+ public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
+ super.onRequestPermissionsResult(requestCode, permissions, grantResults);
+ showPermissionRequiredMessage(permissions, grantResults);
+ finish();
+ reviveMain();
+ }
+
+ private void reviveMain() {
+ Intent intent = new Intent(this, TraditionalT9.class);
+ intent.putExtra(PopupDialog.INTENT_CLOSE, "");
+ startService(intent);
+ }
+
+ private void showPermissionRequiredMessage(@NonNull String[] permissions, @NonNull int[] grantResults) {
+ if (permissions.length == 0) {
+ return;
+ }
+
+ if (permissions[0].equals(Manifest.permission.RECORD_AUDIO) && grantResults[0] == PackageManager.PERMISSION_DENIED) {
+ UI.toastLong(this, R.string.voice_input_mic_permission_is_needed);
+ }
+ }
+
+ public static void show(InputMethodService ims) {
+ Intent intent = new Intent(ims, RequestPermissionDialog.class);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
+ intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY);
+ ims.startActivity(intent);
+ }
+}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftCommandKey.java b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftCommandKey.java
index 2d17c1e9..7db77fdc 100644
--- a/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftCommandKey.java
+++ b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftCommandKey.java
@@ -4,17 +4,9 @@ import android.content.Context;
import android.util.AttributeSet;
public class SoftCommandKey extends SoftNumberKey {
- public SoftCommandKey(Context context) {
- super(context);
- }
-
- public SoftCommandKey(Context context, AttributeSet attrs) {
- super(context, attrs);
- }
-
- public SoftCommandKey(Context context, AttributeSet attrs, int defStyleAttr) {
- super(context, attrs, defStyleAttr);
- }
+ public SoftCommandKey(Context context) { super(context);}
+ public SoftCommandKey(Context context, AttributeSet attrs) { super(context, attrs);}
+ public SoftCommandKey(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr);}
@Override
protected String getTitle() {
@@ -40,4 +32,13 @@ public class SoftCommandKey extends SoftNumberKey {
return null;
}
+
+ @Override
+ public void render() {
+ if (tt9 != null && tt9.isVoiceInputMissing() && getNumber(getId()) == 3) {
+ setVisibility(GONE);
+ } else {
+ super.render();
+ }
+ }
}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftFilterKey.java b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftFilterKey.java
new file mode 100644
index 00000000..ffcb8d2e
--- /dev/null
+++ b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftFilterKey.java
@@ -0,0 +1,42 @@
+package io.github.sspanak.tt9.ui.main.keys;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+import io.github.sspanak.tt9.preferences.settings.SettingsStore;
+
+public class SoftFilterKey extends SoftKey {
+ public SoftFilterKey(Context context) { super(context); setFontSize(); }
+ public SoftFilterKey(Context context, AttributeSet attrs) { super(context, attrs); setFontSize(); }
+ public SoftFilterKey(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); setFontSize(); }
+
+ private void setFontSize() {
+ complexLabelTitleSize = SettingsStore.SOFT_KEY_COMPLEX_LABEL_TITLE_SIZE / 0.85f;
+ complexLabelSubTitleSize = SettingsStore.SOFT_KEY_COMPLEX_LABEL_SUB_TITLE_SIZE / 0.85f;
+ }
+
+ @Override
+ protected boolean handleHold() {
+ if (!validateTT9Handler()) {
+ return false;
+ }
+
+ return tt9.onKeyFilterClear(false);
+ }
+
+ @Override
+ protected boolean handleRelease() {
+ boolean multiplePress = getLastPressedKey() == getId();
+ return tt9.onKeyFilterSuggestions(false, multiplePress);
+ }
+
+ @Override
+ protected String getTitle() {
+ return "CLR";
+ }
+
+ @Override
+ protected String getSubTitle() {
+ return "FLTR";
+ }
+}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftKeyInputMode.java b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftInputModeKey.java
similarity index 69%
rename from app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftKeyInputMode.java
rename to app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftInputModeKey.java
index e7c83fa6..3525d028 100644
--- a/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftKeyInputMode.java
+++ b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftInputModeKey.java
@@ -3,16 +3,16 @@ package io.github.sspanak.tt9.ui.main.keys;
import android.content.Context;
import android.util.AttributeSet;
-public class SoftKeyInputMode extends SoftKey {
- public SoftKeyInputMode(Context context) {
+public class SoftInputModeKey extends SoftKey {
+ public SoftInputModeKey(Context context) {
super(context);
}
- public SoftKeyInputMode(Context context, AttributeSet attrs) {
+ public SoftInputModeKey(Context context, AttributeSet attrs) {
super(context, attrs);
}
- public SoftKeyInputMode(Context context, AttributeSet attrs, int defStyleAttr) {
+ public SoftInputModeKey(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftKey.java b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftKey.java
index d041497e..14b3215a 100644
--- a/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftKey.java
+++ b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftKey.java
@@ -130,6 +130,10 @@ public class SoftKey extends androidx.appcompat.widget.AppCompatButton implement
repeatHandler.removeCallbacks(this::repeatOnLongPress);
}
+ protected static int getLastPressedKey() {
+ return lastPressedKey;
+ }
+
protected boolean handlePress() {
return false;
}
@@ -144,16 +148,14 @@ public class SoftKey extends androidx.appcompat.widget.AppCompatButton implement
}
int keyId = getId();
- boolean multiplePress = lastPressedKey == keyId;
if (keyId == R.id.soft_key_add_word) { tt9.addWord(); return true; }
if (keyId == R.id.soft_key_command_palette) return tt9.onKeyCommandPalette(false);
- if (keyId == R.id.soft_key_filter_suggestions) return tt9.onKeyFilterSuggestions(false, multiplePress);
- if (keyId == R.id.soft_key_clear_filter) return tt9.onKeyFilterClear(false);
if (keyId == R.id.soft_key_left_arrow) return tt9.onKeyScrollSuggestion(false, true);
if (keyId == R.id.soft_key_right_arrow) return tt9.onKeyScrollSuggestion(false, false);
if (keyId == R.id.soft_key_language) return tt9.onKeyNextLanguage(false);
if (keyId == R.id.soft_key_settings) { tt9.showSettings(); return true; }
+ if (keyId == R.id.soft_key_voice_input) { tt9.toggleVoiceInput(); return true; }
return false;
}
@@ -186,6 +188,7 @@ public class SoftKey extends androidx.appcompat.widget.AppCompatButton implement
return null;
}
+
/**
* render
* Sets the key label using "getTitle()" and "getSubtitle()" or if they both
@@ -206,6 +209,8 @@ public class SoftKey extends androidx.appcompat.widget.AppCompatButton implement
return;
}
+ int titleLength = title.length();
+
SpannableStringBuilder sb = new SpannableStringBuilder(title);
sb.append('\n');
sb.append(subtitle);
@@ -215,10 +220,10 @@ public class SoftKey extends androidx.appcompat.widget.AppCompatButton implement
padding /= 10;
}
- sb.setSpan(new RelativeSizeSpan(complexLabelTitleSize), 0, 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
- sb.setSpan(new StyleSpan(Typeface.ITALIC), 0, 1, Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
- sb.setSpan(new RelativeSizeSpan(padding), 1, 2, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
- sb.setSpan(new RelativeSizeSpan(complexLabelSubTitleSize), 2, sb.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ sb.setSpan(new RelativeSizeSpan(complexLabelTitleSize), 0, titleLength, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ sb.setSpan(new StyleSpan(Typeface.ITALIC), 0, titleLength, Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
+ sb.setSpan(new RelativeSizeSpan(padding), titleLength, titleLength + 1, Spanned.SPAN_INCLUSIVE_INCLUSIVE);
+ sb.setSpan(new RelativeSizeSpan(complexLabelSubTitleSize), titleLength + 1, sb.length(), Spanned.SPAN_INCLUSIVE_INCLUSIVE);
setText(sb);
}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftVoiceInputKey.java b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftVoiceInputKey.java
new file mode 100644
index 00000000..e6a63bf0
--- /dev/null
+++ b/app/src/main/java/io/github/sspanak/tt9/ui/main/keys/SoftVoiceInputKey.java
@@ -0,0 +1,24 @@
+package io.github.sspanak.tt9.ui.main.keys;
+
+import android.content.Context;
+import android.util.AttributeSet;
+
+public class SoftVoiceInputKey extends SoftKey {
+ public SoftVoiceInputKey(Context context) { super(context); }
+ public SoftVoiceInputKey(Context context, AttributeSet attrs) { super(context, attrs); }
+ public SoftVoiceInputKey(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); }
+
+ @Override
+ protected String getTitle() {
+ return "🎤";
+ }
+
+ @Override
+ public void render() {
+ if (tt9 != null && tt9.isVoiceInputMissing()) {
+ setVisibility(INVISIBLE);
+ } else {
+ super.render();
+ }
+ }
+}
diff --git a/app/src/main/java/io/github/sspanak/tt9/ui/tray/StatusBar.java b/app/src/main/java/io/github/sspanak/tt9/ui/tray/StatusBar.java
index 1412dbfa..fa720e13 100644
--- a/app/src/main/java/io/github/sspanak/tt9/ui/tray/StatusBar.java
+++ b/app/src/main/java/io/github/sspanak/tt9/ui/tray/StatusBar.java
@@ -8,6 +8,7 @@ import androidx.core.content.ContextCompat;
import io.github.sspanak.tt9.R;
import io.github.sspanak.tt9.ime.modes.InputMode;
+import io.github.sspanak.tt9.ime.voice.VoiceInputOps;
import io.github.sspanak.tt9.util.Logger;
public class StatusBar {
@@ -20,6 +21,21 @@ public class StatusBar {
}
+ public boolean isErrorShown() {
+ return statusText != null && statusText.startsWith("❌");
+ }
+
+
+ public void setError(String error) {
+ setText("❌ " + error);
+ }
+
+
+ public void setText(int stringResourceId) {
+ setText(statusView.getContext().getString(stringResourceId));
+ }
+
+
public void setText(String text) {
statusText = text;
this.render();
@@ -31,6 +47,11 @@ public class StatusBar {
}
+ public void setText(VoiceInputOps voiceInputOps) {
+ setText("[ " + voiceInputOps.toString() + " ]");
+ }
+
+
public void setDarkTheme(boolean darkTheme) {
if (statusView == null) {
return;
diff --git a/app/src/main/java/io/github/sspanak/tt9/util/Permissions.java b/app/src/main/java/io/github/sspanak/tt9/util/Permissions.java
index b5035b19..6032d21e 100644
--- a/app/src/main/java/io/github/sspanak/tt9/util/Permissions.java
+++ b/app/src/main/java/io/github/sspanak/tt9/util/Permissions.java
@@ -6,13 +6,15 @@ import android.app.Activity;
import android.content.pm.PackageManager;
import android.os.Build;
+import androidx.annotation.NonNull;
+
import java.util.HashMap;
public class Permissions {
private static final HashMap firstTimeAsking = new HashMap<>();
- private final Activity activity;
+ @NonNull private final Activity activity;
- public Permissions(Activity activity) {
+ public Permissions(@NonNull Activity activity) {
this.activity = activity;
}
@@ -33,6 +35,11 @@ public class Permissions {
}
}
+
+ public void requestRecordAudio() {
+ requestPermission(Manifest.permission.RECORD_AUDIO);
+ }
+
public boolean noWriteStorage() {
return
Build.VERSION.SDK_INT < Build.VERSION_CODES.R
diff --git a/app/src/main/res/layout/main_numpad.xml b/app/src/main/res/layout/main_numpad.xml
index 3c7fada7..9a8e55e4 100644
--- a/app/src/main/res/layout/main_numpad.xml
+++ b/app/src/main/res/layout/main_numpad.xml
@@ -185,13 +185,12 @@
android:id="@+id/separator_2_2"
style="@style/numSeparator" />
-
+ android:layout_weight="@dimen/numpad_control_key_layout_weight"/>
@@ -202,7 +201,7 @@
android:layoutDirection="ltr"
tools:ignore="HardcodedText">
-
-
+ android:layout_weight="@dimen/numpad_control_key_layout_weight" />
diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml
index edcf0864..21c9b174 100644
--- a/app/src/main/res/values-bg/strings.xml
+++ b/app/src/main/res/values-bg/strings.xml
@@ -3,6 +3,7 @@
Настройки на TT9
Голям
Завършено
+ Зареждане…
Няма език
Възникна неочаквана грешка.
Неуспешно зареждане на езиковите дефиниции.
@@ -130,4 +131,13 @@
Жълт бутон
Син бутон
Заглушаване на звук
+ Говорете
+ Трябва да разрешите достъпа до микрофона, за да използвате гласовото въвеждане.
+ Неуспешно гласово въвеждане
+ Няма достъп до микрофона
+ Езикът не се поддържа
+ Не е налично въвеждане с глас
+ Няма връзка с интернет
+ Проблем с мрежовата връзка
+ Изключване на микрофона…
diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml
index 2ae3fdaa..0faab6aa 100644
--- a/app/src/main/res/values-de/strings.xml
+++ b/app/src/main/res/values-de/strings.xml
@@ -120,4 +120,13 @@
Gelber Knopf
Blauer Knopf
Stummschalttaste
+ Sprechen
+ Sie müssen dem Mikrofon die Erlaubnis erteilen, die Spracheingabe zu verwenden.
+ Fehler bei der Spracheingabe
+ Keine Mikrofonberechtigung
+ Sprache nicht unterstützt
+ Netzwerkverbindung fehlgeschlagen
+ Keine Internetverbindung
+ Spracheingabe ist nicht verfügbar
+ Mikrofon ausschalten…
diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml
index 00b37070..c9fc0b4e 100644
--- a/app/src/main/res/values-es/strings.xml
+++ b/app/src/main/res/values-es/strings.xml
@@ -130,4 +130,13 @@
Botón amarillo
Botón azul
Botón de silencio
+ Hable
+ Debe otorgar permiso al micrófono para usar la entrada de voz.
+ Error de entrada de voz
+ Sin permiso para el micrófono
+ Idioma no compatible
+ Conexión de red fallida
+ Sin conexión a Internet
+ La entrada de voz no está disponible
+ Apagando el micrófono…
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index c881ef54..61aeba0d 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -127,4 +127,13 @@
Bouton jaune
Bouton bleu
Muet
+ Parlez
+ Vous devez accorder l\'autorisation au microphone pour utiliser la saisie vocale.
+ Erreur de saisie vocale
+ Pas d\'autorisation pour le microphone
+ Langue non prise en charge
+ Échec de la connexion réseau
+ Pas de connexion Internet
+ La saisie vocale n\'est pas disponible
+ Désactivation du microphone…
diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml
index 39610a34..0c4a2661 100644
--- a/app/src/main/res/values-it/strings.xml
+++ b/app/src/main/res/values-it/strings.xml
@@ -120,5 +120,14 @@
Pulsante giallo
Pulsante blu
Muto
+ Parli
+ Devi concedere l\'autorizzazione al microfono per utilizzare l\'input vocale.
+ Errore di input vocale
+ Nessuna autorizzazione per il microfono
+ Lingua non supportata
+ Connessione di rete fallita
+ Nessuna connessione Internet
+ L\'input vocale non è disponibile
+ Spegnimento del microfono…
diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml
index b9c7a148..eaba4ff6 100644
--- a/app/src/main/res/values-iw/strings.xml
+++ b/app/src/main/res/values-iw/strings.xml
@@ -133,4 +133,13 @@
כפתור צהוב
כפתור כחול
כפתור השתק
+ האזנה
+ עליך להעניק למיקרופון הרשאה להשתמש בקלט קולי.
+ שגיאת קלט קולי
+ אין הרשאת מיקרופון
+ השפה אינה נתמכת
+ חיבור הרשת נכשל
+ אין חיבור לאינטרנט
+ קלט קולי אינו זמין
+ מכבה את המיקרופון…
diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml
index b2a5e573..aa41f6c6 100644
--- a/app/src/main/res/values-lt/strings.xml
+++ b/app/src/main/res/values-lt/strings.xml
@@ -139,4 +139,13 @@
Geltonas mygtukas
Mėlynas mygtukas
Nutildymo mygt.
+ Kalbėkite
+ Turite suteikti mikrofonui leidimą naudoti balso įvestį.
+ Balso įvesties klaida
+ Nėra mikrofono leidimo
+ Kalba nepalaikoma
+ Tinklo ryšys nepavyko
+ Nėra interneto ryšio
+ Balso įvestis nėra prieinama
+ Išjungiamas mikrofonas…
diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml
index ae9bb239..4827c808 100644
--- a/app/src/main/res/values-nl/strings.xml
+++ b/app/src/main/res/values-nl/strings.xml
@@ -118,4 +118,13 @@
Gele knop
Blauwe knop
Stilteknop
+ Spreek
+ U moet de microfoon toestemming geven om spraakopvoer te gebruiken.
+ Fout bij spraakopvoer
+ Geen microfoontoestemming
+ Taal niet ondersteund
+ Netwerkverbinding mislukt
+ Geen internetverbinding
+ Spraakopvoer is niet beschikbaar
+ Microfoon uitschakelen…
diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml
index 5e8631a1..d4cfba77 100644
--- a/app/src/main/res/values-pt-rBR/strings.xml
+++ b/app/src/main/res/values-pt-rBR/strings.xml
@@ -133,4 +133,13 @@
Botão amarelo
Botão azul
Mudo
+ Fale
+ Você deve conceder permissão ao microfone para usar a entrada de voz.
+ Erro de entrada de voz
+ Sem permissão para o microfone
+ Idioma não suportado
+ Falha na conexão de rede
+ Sem conexão com a Internet
+ A entrada de voz não está disponível
+ Desligando o microfone…
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index bf6c2ad7..d3774800 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -130,4 +130,13 @@
Жёлтая кнопка
Синяя кнопка
Выключения звука
+ Говорите
+ Вы должны предоставить микрофону разрешение на использование голосового ввода.
+ Ошибка голосового ввода
+ Нет разрешения на использование микрофона
+ Язык не поддерживается
+ Сбой сетевого подключения
+ Нет подключения к Интернету
+ Голосовой ввод недоступен
+ Отключение микрофона…
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index ebed66ae..63ee0a0d 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -130,4 +130,13 @@
Sarı düğme
Mavi düğme
Sessiz tuşu
+ Konuşun
+ Sesli giriş kullanmak için mikrofona izin vermelisiniz.
+ Sesli giriş hatası
+ Mikrofon izni yok
+ Dil desteklenmiyor
+ Ağ bağlantısı başarısız
+ İnternet bağlantısı yok
+ Sesli giriş kullanılamıyor
+ Mikrofon kapatılıyor…
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index e0aae851..6c57fe8e 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -141,4 +141,13 @@
Жовта кнопка
Синя кнопка
Вимкнення звуку
+ Диктуйте
+ Ви повинні надати мікрофону дозвіл на використання голосового введення.
+ Помилка голосового введення
+ Немає дозволу на використання мікрофона
+ Мова не підтримується
+ Помилка підключення до мережі
+ Немає підключення до Інтернету
+ Голосовий ввід недоступний
+ Вимикання мікрофона…
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 6cca4ea2..262d7d75 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -6,6 +6,7 @@
TT9 Settings
Completed
Error
+ Loading…
No Language
Unexpected error occurred.
@@ -162,4 +163,14 @@
.
New Line
Space
+
+ Speak
+ Turning off the microphone…
+ You must allow the microphone permission to use voice input.
+ Voice input error
+ No microphone permission
+ Language not supported
+ Network connection failed
+ No Internet connection
+ Voice input is not available
diff --git a/docs/user-manual.md b/docs/user-manual.md
index c83d9112..720e44a5 100644
--- a/docs/user-manual.md
+++ b/docs/user-manual.md
@@ -115,17 +115,30 @@ Many phones have only two or three "free" buttons that can be used as hotkeys. B
Below is a list of the possible commands:
- **Show the Settings Screen (Default Combo: ✱, 1-key).** On the Settings screen, you can choose languages for typing, configure the keypad hotkeys, change the application appearance, or improve compatibility with your phone.
- **Add a Word (Default Combo: ✱, 2-key).** Add a new word to the dictionary for the current language. You can also add new emojis and then access them by pressing 1-1-3. Regardless of the currently selected language, all emojis will be available in all languages.
+- **Voice Input (Default Combo: ✱, 3-key).** Activate the voice input on the phones that support it. See [below](#voice-input) for more info.
- **Select a Different Keyboard (Default Combo: ✱, 0-key).** Open the Android Change Keyboard dialog allowing you to select between all installed keyboards.
_This key does not do anything when the Screen Layout is set to "Virtual Keyboard" because all keys for all possible functions are already available on the screen._
+If you do have a hardware keypad and prefer having more screen space, disable the software keys from the Settings → Appearance.
+
+## Voice Input
+The voice input function allows for speech-to-text input, similar to Gboard. Like all other keyboards, Traditional T9 does not perform speech recognition by itself, but it asks your phone to do it.
+
+_The Voice Input button is hidden on devices that do not support it._
+
+### Supported Devices
+On devices with Google Services, it will use the Google Cloud infrastructure to convert your words to text. You must connect to a Wi-Fi network or enable mobile data for this method to work.
+
+On devices without Google, if the device has a voice assistant app or the native keyboard supports voice input, whichever is available will be used for speech recognition. Note that this method is considerably less capable than Google. It will not work in a noisy environment and will usually recognize only simple phrases, such as: "open calendar" or "play music" and similar. The advantage is that it will work offline.
+
+Other phones without Google will generally not support voice input. Chinese phones do not have speech recognition capabilities due to Chinese security policies. On these phones, it may be possible to enable voice input support by installing the Google application, package name: "com.google.android.googlequicksearchbox".
+
## On-screen Keypad
On touchscreen-only phones, a fully functional on-screen keypad is available and it will be enabled automatically. If, for some reason, your phone was not detected to have a touchscreen, enable it by going to Settings → Appearance → On-Screen Layout, and selecting "Virtual numpad".
It is also recommended to disable the special behavior of the "Back" key working as "Backspace". It is useful only for a hardware keypad. To do so, go to Settings → Keypad → Select Hotkeys → Backspace key, then select the "--" option.
-If you do have a hardware keypad and prefer having more screen space, disable the software keys from the Settings → Appearance.
-
## Settings Screen
On the Settings screen, you can choose languages for typing, configure the keypad hotkeys, change the application appearance, or improve compatibility with your phone.
@@ -243,9 +256,11 @@ To mitigate this problem, go to Settings → Appearance, and enable "Status Icon
**Long explanation.** Qin F21 Pro (and possibly F22, too), has a hotkey application that allows assigning Volume Up and Volume Down functions to number keys. By default, the hotkey manager is enabled, and holding 2-key increases the volume, holding 8-key decreases it. However, when there is no status icon, the manager assumes no keyboard is active and adjusts the volume, instead of letting Traditional T9 handle the key and type a number. So, enabling the icon just bypasses the hotkey manager and everything works fine.
#### General problems on Xiaomi phones
-
Xiaomi has introduced several non-standard permissions on their phones, which prevent Traditional T9's virtual on-screen keyboard from working properly. More precisely, the "Show Settings" and the "Add Word" keys may not perform their respective functions. To fix this, you must grant the "Display pop-up window" and "Display pop-up window while running in the background" permissions to TT9 from your phone's settings. [This guide](https://parental-control.flashget.com/how-to-enable-display-pop-up-windows-while-running-in-the-background-on-flashget-kids-on-xiaomi) for another application explains how to do it.
It is also highly recommended to grant the "Permanent notification" permission. This is similar to the "Notifications" permission introduced in Android 13. See [above](#notes-for-android-13-or-higher) for more information on why you need it.
-_The Xiaomi problems have been discussed in [this GitHub issue](https://github.com/sspanak/tt9/issues/490)._
\ No newline at end of file
+_The Xiaomi problems have been discussed in [this GitHub issue](https://github.com/sspanak/tt9/issues/490)._
+
+#### Voice Input takes a very long time to stop
+It is [a known problem](https://issuetracker.google.com/issues/158198432) on Android 10 that Google never fixed. It is not possible to mitigate it on the TT9 side. To stop the Voice Input operation, stay quiet for a couple of seconds. Android turns off the microphone automatically when it can not detect any speech.
\ No newline at end of file
diff --git a/fastlane/metadata/android/bg-BG/full_description.txt b/fastlane/metadata/android/bg-BG/full_description.txt
new file mode 100644
index 00000000..bbee1660
--- /dev/null
+++ b/fastlane/metadata/android/bg-BG/full_description.txt
@@ -0,0 +1,11 @@
+Traditional T9 е 12-клавишна клавиатура за устройства с копчета. Поддържа подскаващ текста на повече от 25 езика и бързи клавиши, а виртуалната клавиатура пресъздава доброто старо усещане за Нокия на съвременните телефони със сензорен екран. И най-хубавото е, че не ви шпионира.
+
+Поддържани езици: арабски, български, хърватски, чешки, датски, холандски, английски, финландски, френски, немски, гръцки, иврит, унгарски, индонезийски, италиански, кисуахили, норвежки, полски, португалски (европейски и бразилски), румънски, руски, испански, шведски, турски, украински, идиш.
+
+Философия и защита не личните данни:
+- Без реклами, специални или платени функции. Всичко е напълно безплатно.
+- Без шпиониране, следене, телеметрия и отчети. Без глупости!
+- Без връзка към интернет, освен когато е активно гласовото въвеждане.
+- Единствено си върши работата.
+- С отворен код, така че може да проверите горното и сами.
+- Създадена с помощта на цялата общност.
diff --git a/fastlane/metadata/android/bg-BG/short_description.txt b/fastlane/metadata/android/bg-BG/short_description.txt
new file mode 100644
index 00000000..7bc72e80
--- /dev/null
+++ b/fastlane/metadata/android/bg-BG/short_description.txt
@@ -0,0 +1 @@
+T9 клавиатура за устройства с копчета.
diff --git a/fastlane/metadata/android/bg-BG/title.txt b/fastlane/metadata/android/bg-BG/title.txt
new file mode 100644
index 00000000..0047cf60
--- /dev/null
+++ b/fastlane/metadata/android/bg-BG/title.txt
@@ -0,0 +1 @@
+Traditional T9
diff --git a/fastlane/metadata/android/en-US/full_description.txt b/fastlane/metadata/android/en-US/full_description.txt
index 3eebf60d..04047369 100644
--- a/fastlane/metadata/android/en-US/full_description.txt
+++ b/fastlane/metadata/android/en-US/full_description.txt
@@ -5,7 +5,7 @@ Supported languages: Arabic, Bulgarian, Croatian, Czech, Danish, Dutch, English,
Privacy Policy and Philosophy:
- No ads, no premium or paid features. It's all free.
- No spying, no tracking, no telemetry or reports. No nothing!
-- No network connectivity.
+- No network connectivity, except when voice input is active.
- It only does its job.
- Open-source, so you can verify all the above yourself.
- Created with help from the entire community.
\ No newline at end of file