diff --git a/.gitignore b/.gitignore index 69fc5c5..3a04603 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ Thumbs.db # Logs *.log github-release.md + +# Local Claude instructions (not checked in) +CLAUDE.local.md diff --git a/CLAUDE.md b/CLAUDE.md index 6b830cd..478ca64 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -97,7 +97,7 @@ When bumping a version, update all of these: 1. `app/build.gradle.kts` — `versionCode` and `versionName` 2. `CHANGELOG.md` — Add new version entry with date 3. `fastlane/metadata/android/en-US/changelogs/{versionCode}.txt` — Short changelog for F-Droid/store -4. `github-release.md` — Release notes for GitHub releases +4. `github-release.md` — Release notes for GitHub releases (gitignored, copy manually) 5. Tag the release: `git tag v{versionName}` 6. Update fdroiddata YAML — see rules below @@ -143,9 +143,13 @@ app/src/main/java/com/translander/ │ └── SettingsRepository.kt # DataStore preferences ├── transcribe/ # Voice message transcription │ ├── AudioDecoder.kt # Decode audio files to 16kHz PCM -│ ├── AudioMonitorService.kt # Watch folders for new audio +│ ├── AudioMonitorService.kt # Watch folders for new audio (specialUse FGS) │ ├── TranscribeActivity.kt # UI for shared/opened audio │ └── TranscribeManager.kt # Extensible trigger system +├── notification/ +│ └── ServiceAlertNotification.kt # Boot failure notification (Android 14+) +├── util/ +│ └── BootCompatHelper.kt # Android 14+ FGS restriction checks └── k2fsa/sherpa/onnx/ # Native ONNX bindings ``` @@ -220,6 +224,30 @@ RecognizerManager.transcribe() (reuses existing recognition) ### UI Location Settings → Voice Message Transcription → Monitor Downloads toggle +## Android 14+ Boot Restrictions + +Android 14+ blocks starting foreground services with restricted types (`microphone`, `dataSync`) from `BOOT_COMPLETED`. The app handles this via: + +1. **BootCompatHelper** — Checks `Build.VERSION.SDK_INT < UPSIDE_DOWN_CAKE` (API 34) +2. **ServiceAlertNotification** — Shows persistent notification prompting user to open app +3. **SettingsActivity.onResume()** — Restarts services when user opens app + +### FGS Type for AudioMonitorService + +Uses `specialUse` instead of `dataSync` because: +- `dataSync` has 6-hour timeout on Android 15+ (targetSdk 35) +- `specialUse` has no time limit + +**Google Play requirement:** When submitting, provide justification for `FOREGROUND_SERVICE_SPECIAL_USE`: +> Monitors user-selected folders for voice messages to enable offline transcription. User-initiated, runs only while enabled in Settings. + +**Manifest property:** +```xml + +``` + ## Hotwords Feature (BLOCKED) Sherpa-onnx supports hotwords boosting but **NOT for Parakeet TDT models**: @@ -247,7 +275,7 @@ OfflineRecognizerConfig( - RECORD_AUDIO - SYSTEM_ALERT_WINDOW (floating button) - FOREGROUND_SERVICE_MICROPHONE -- FOREGROUND_SERVICE_DATA_SYNC (audio monitor) +- FOREGROUND_SERVICE_SPECIAL_USE (audio monitor — no timeout on Android 15+) - BIND_ACCESSIBILITY_SERVICE - POST_NOTIFICATIONS - INTERNET (model download) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 85a2e95..a236f17 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -68,6 +68,7 @@ diff --git a/app/src/main/java/com/translander/asr/AudioRecorder.kt b/app/src/main/java/com/translander/asr/AudioRecorder.kt index e950134..9ca8690 100644 --- a/app/src/main/java/com/translander/asr/AudioRecorder.kt +++ b/app/src/main/java/com/translander/asr/AudioRecorder.kt @@ -5,6 +5,7 @@ import android.media.AudioFormat import android.media.AudioRecord import android.media.MediaRecorder import android.util.Log +import java.util.concurrent.atomic.AtomicBoolean class AudioRecorder { @@ -16,7 +17,7 @@ class AudioRecorder { } private var audioRecord: AudioRecord? = null - private var isRecording = false + private val isRecording = AtomicBoolean(false) private val audioBuffer = mutableListOf() private val bufferSize = AudioRecord.getMinBufferSize( @@ -27,7 +28,7 @@ class AudioRecorder { @SuppressLint("MissingPermission") fun startRecording() { - if (isRecording) return + if (!isRecording.compareAndSet(false, true)) return audioBuffer.clear() @@ -47,12 +48,11 @@ class AudioRecorder { return } - isRecording = true audioRecord?.startRecording() val buffer = ShortArray(bufferSize / 2) - while (isRecording) { + while (isRecording.get()) { val readCount = audioRecord?.read(buffer, 0, buffer.size) ?: 0 if (readCount > 0) { synchronized(audioBuffer) { @@ -78,12 +78,12 @@ class AudioRecorder { audioRecord?.release() audioRecord = null } finally { - isRecording = false + isRecording.set(false) } } fun stopRecording(): ShortArray { - isRecording = false + isRecording.set(false) try { audioRecord?.stop() @@ -99,7 +99,7 @@ class AudioRecorder { } fun release() { - isRecording = false + isRecording.set(false) try { audioRecord?.stop() } catch (e: IllegalStateException) { @@ -110,5 +110,5 @@ class AudioRecorder { audioBuffer.clear() } - fun isRecording(): Boolean = isRecording + fun isRecording(): Boolean = isRecording.get() } diff --git a/app/src/main/java/com/translander/asr/DictionaryManager.kt b/app/src/main/java/com/translander/asr/DictionaryManager.kt index b0fb778..bcba634 100644 --- a/app/src/main/java/com/translander/asr/DictionaryManager.kt +++ b/app/src/main/java/com/translander/asr/DictionaryManager.kt @@ -30,12 +30,22 @@ class DictionaryManager(private val context: Context) { val caseSensitive: Boolean = false ) + /** Compiled regex pattern paired with replacement string */ + private data class CompiledRule( + val pattern: Regex, + val replacement: String + ) + private val rulesFile = File(context.filesDir, RULES_FILE) private val mutex = Mutex() private val _rules = MutableStateFlow>(emptyList()) val rules: StateFlow> = _rules + // Cached compiled regex patterns for performance + @Volatile + private var compiledRules: List = emptyList() + init { // Load rules synchronously on init to ensure they're available immediately loadRulesSync() @@ -64,10 +74,24 @@ class DictionaryManager(private val context: Context) { ) } _rules.value = loadedRules + rebuildCompiledRules(loadedRules) Log.i(TAG, "Loaded ${loadedRules.size} replacement rules") } catch (e: Exception) { Log.e(TAG, "Failed to load rules", e) _rules.value = emptyList() + compiledRules = emptyList() + } + } + + /** Rebuild cached compiled regex patterns when rules change */ + private fun rebuildCompiledRules(rules: List) { + compiledRules = rules.map { rule -> + val pattern = if (rule.caseSensitive) { + "\\b${Regex.escape(rule.from)}\\b".toRegex() + } else { + "\\b${Regex.escape(rule.from)}\\b".toRegex(RegexOption.IGNORE_CASE) + } + CompiledRule(pattern, rule.to) } } @@ -106,6 +130,7 @@ class DictionaryManager(private val context: Context) { currentRules.removeAll { it.from.equals(from, ignoreCase = true) } currentRules.add(ReplacementRule(from.trim(), to.trim(), caseSensitive)) _rules.value = currentRules + rebuildCompiledRules(currentRules) saveRules() Log.i(TAG, "Added rule: '$from' -> '$to'") } @@ -118,6 +143,7 @@ class DictionaryManager(private val context: Context) { val removed = currentRules.removeAll { it.from == from } if (removed) { _rules.value = currentRules + rebuildCompiledRules(currentRules) saveRules() Log.i(TAG, "Removed rule for: '$from'") } @@ -126,18 +152,15 @@ class DictionaryManager(private val context: Context) { /** * Apply all replacement rules to the given text. * Uses whole-word matching with word boundaries. + * Uses pre-compiled regex patterns for performance. */ fun applyReplacements(text: String): String { - if (text.isBlank() || _rules.value.isEmpty()) return text + val rules = compiledRules // Capture volatile reference + if (text.isBlank() || rules.isEmpty()) return text var result = text - for (rule in _rules.value) { - val regex = if (rule.caseSensitive) { - "\\b${Regex.escape(rule.from)}\\b".toRegex() - } else { - "\\b${Regex.escape(rule.from)}\\b".toRegex(RegexOption.IGNORE_CASE) - } - result = result.replace(regex, rule.to) + for (rule in rules) { + result = result.replace(rule.pattern, rule.replacement) } return result } @@ -189,8 +212,10 @@ class DictionaryManager(private val context: Context) { currentRules.add(newRule) } _rules.value = currentRules + rebuildCompiledRules(currentRules) } else { _rules.value = importedRules + rebuildCompiledRules(importedRules) } saveRules() Log.i(TAG, "Imported ${importedRules.size} rules (merge=$merge)") @@ -204,6 +229,7 @@ class DictionaryManager(private val context: Context) { */ suspend fun clearAllRules() = mutex.withLock { _rules.value = emptyList() + compiledRules = emptyList() saveRules() Log.i(TAG, "Cleared all rules") } diff --git a/app/src/main/java/com/translander/asr/ModelManager.kt b/app/src/main/java/com/translander/asr/ModelManager.kt index fd72549..09f126d 100644 --- a/app/src/main/java/com/translander/asr/ModelManager.kt +++ b/app/src/main/java/com/translander/asr/ModelManager.kt @@ -162,29 +162,29 @@ class ModelManager(private val context: Context) { private suspend fun downloadFile(url: String, targetFile: File, onProgress: (Int) -> Unit) { val request = Request.Builder().url(url).build() - val response = httpClient.newCall(request).execute() - - if (!response.isSuccessful) { - throw java.io.IOException("Download failed: ${response.code}") - } + httpClient.newCall(request).execute().use { response -> + if (!response.isSuccessful) { + throw java.io.IOException("Download failed: ${response.code}") + } - val body = response.body ?: throw java.io.IOException("Empty response body") - val contentLength = body.contentLength() + val body = response.body ?: throw java.io.IOException("Empty response body") + val contentLength = body.contentLength() - body.byteStream().use { input -> - FileOutputStream(targetFile).use { output -> - val buffer = ByteArray(8192) - var bytesRead: Int - var totalBytesRead = 0L + body.byteStream().use { input -> + FileOutputStream(targetFile).use { output -> + val buffer = ByteArray(8192) + var bytesRead: Int + var totalBytesRead = 0L - while (input.read(buffer).also { bytesRead = it } != -1) { - coroutineContext.ensureActive() - output.write(buffer, 0, bytesRead) - totalBytesRead += bytesRead + while (input.read(buffer).also { bytesRead = it } != -1) { + coroutineContext.ensureActive() + output.write(buffer, 0, bytesRead) + totalBytesRead += bytesRead - if (contentLength > 0) { - val progress = ((totalBytesRead * 100) / contentLength).toInt() - onProgress(progress) + if (contentLength > 0) { + val progress = ((totalBytesRead * 100) / contentLength).toInt() + onProgress(progress) + } } } } diff --git a/app/src/main/java/com/translander/asr/ParakeetRecognizer.kt b/app/src/main/java/com/translander/asr/ParakeetRecognizer.kt index 67cfb8d..3ada819 100644 --- a/app/src/main/java/com/translander/asr/ParakeetRecognizer.kt +++ b/app/src/main/java/com/translander/asr/ParakeetRecognizer.kt @@ -72,7 +72,7 @@ class ParakeetRecognizer( } } - fun transcribe(audioData: ShortArray, @Suppress("UNUSED_PARAMETER") languageCode: String? = null): String? { + fun transcribe(audioData: ShortArray): String? { val rec = recognizer ?: return null if (audioData.isEmpty()) { diff --git a/app/src/main/java/com/translander/asr/RecognizerManager.kt b/app/src/main/java/com/translander/asr/RecognizerManager.kt index fb7aa71..84ef169 100644 --- a/app/src/main/java/com/translander/asr/RecognizerManager.kt +++ b/app/src/main/java/com/translander/asr/RecognizerManager.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import com.translander.TranslanderApp /** @@ -36,44 +37,39 @@ class RecognizerManager(private val context: Context, private val modelManager: * If already loading, waits for completion. */ suspend fun initialize(): Boolean { - // If already loading, wait for it to complete - if (_isLoading.value) { - Log.i(TAG, "Already loading, waiting for completion") - return waitForInitialization() - } - return mutex.withLock { + // Check loading state inside lock to prevent race + if (_isLoading.value) { + Log.i(TAG, "Already loading, waiting for completion") + // Release lock and wait for initialization + return waitForInitialization() + } + if (recognizer != null) { Log.i(TAG, "Recognizer already initialized") return true } - if (_isLoading.value) { - // Another coroutine started loading while we waited for lock - Log.i(TAG, "Loading started by another coroutine") - return@withLock false + if (!modelManager.isModelReady()) { + Log.i(TAG, "Model not ready") + return false } - if (!modelManager.isModelReady()) { - Log.i(TAG, "Model not ready") - return false - } - - _isLoading.value = true - return try { - withContext(Dispatchers.IO) { - recognizer = ParakeetRecognizer(context, modelManager.getModelPath()) - val ready = recognizer?.isReady() == true - _isReady.value = ready - Log.i(TAG, "Recognizer initialized: $ready") - ready + _isLoading.value = true + try { + withContext(Dispatchers.IO) { + recognizer = ParakeetRecognizer(context, modelManager.getModelPath()) + val ready = recognizer?.isReady() == true + _isReady.value = ready + Log.i(TAG, "Recognizer initialized: $ready") + ready + } + } catch (e: Throwable) { + Log.e(TAG, "Failed to initialize recognizer", e) + false + } finally { + _isLoading.value = false } - } catch (e: Throwable) { - Log.e(TAG, "Failed to initialize recognizer", e) - false - } finally { - _isLoading.value = false - } } } @@ -82,11 +78,12 @@ class RecognizerManager(private val context: Context, private val modelManager: * Returns true if model is ready after waiting. */ private suspend fun waitForInitialization(timeoutMs: Long = 30000): Boolean { - val startTime = System.currentTimeMillis() - while (_isLoading.value && System.currentTimeMillis() - startTime < timeoutMs) { - kotlinx.coroutines.delay(100) - } - return recognizer != null + return withTimeoutOrNull(timeoutMs) { + while (_isLoading.value) { + kotlinx.coroutines.delay(100) + } + recognizer != null + } ?: false } /** @@ -103,14 +100,14 @@ class RecognizerManager(private val context: Context, private val modelManager: * Transcribe audio data using the shared recognizer. * Applies dictionary replacements if enabled. */ - suspend fun transcribe(audioData: ShortArray, languageCode: String?): String? { + suspend fun transcribe(audioData: ShortArray): String? { val rec = recognizer if (rec == null) { Log.w(TAG, "Recognizer not initialized") return null } return withContext(Dispatchers.IO) { - val rawResult = rec.transcribe(audioData, languageCode) + val rawResult = rec.transcribe(audioData) // Apply dictionary replacements if enabled if (rawResult != null) { diff --git a/app/src/main/java/com/translander/ime/VoiceInputMethodService.kt b/app/src/main/java/com/translander/ime/VoiceInputMethodService.kt index be8e42f..642be2a 100644 --- a/app/src/main/java/com/translander/ime/VoiceInputMethodService.kt +++ b/app/src/main/java/com/translander/ime/VoiceInputMethodService.kt @@ -17,6 +17,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull /** * Voice Input Method Service - registers as an auxiliary/voice IME. @@ -96,10 +97,14 @@ class VoiceInputMethodService : InputMethodService() { serviceScope.launch { try { - val success = recognizerManager.ensureInitialized() + // Timeout after 10 seconds to avoid hanging indefinitely + val success = withTimeoutOrNull(10000L) { + recognizerManager.ensureInitialized() + } ?: false if (success) { Log.i(TAG, "Model initialized successfully") - startRecording() + // Start actual recording after successful init (non-recursive) + doStartRecording() } else { Log.w(TAG, "Failed to initialize model") statusText?.text = getString(R.string.ime_model_not_available) @@ -116,6 +121,10 @@ class VoiceInputMethodService : InputMethodService() { return } + doStartRecording() + } + + private fun doStartRecording() { Log.i(TAG, "Starting recording") isRecording = true statusText?.text = getString(R.string.state_listening) @@ -158,7 +167,7 @@ class VoiceInputMethodService : InputMethodService() { Log.i(TAG, "Audio data: ${audioData.size} samples") - val result = TranslanderApp.instance.recognizerManager.transcribe(audioData, null) + val result = TranslanderApp.instance.recognizerManager.transcribe(audioData) withContext(Dispatchers.Main) { if (!result.isNullOrBlank()) { diff --git a/app/src/main/java/com/translander/receiver/BootReceiver.kt b/app/src/main/java/com/translander/receiver/BootReceiver.kt index 32ef541..8633ac8 100644 --- a/app/src/main/java/com/translander/receiver/BootReceiver.kt +++ b/app/src/main/java/com/translander/receiver/BootReceiver.kt @@ -10,15 +10,18 @@ import com.translander.TranslanderApp import com.translander.service.FloatingMicService import com.translander.transcribe.AudioMonitorService import com.translander.util.BootCompatHelper -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull class BootReceiver : BroadcastReceiver() { companion object { private const val TAG = "BootReceiver" + // BroadcastReceiver timeout is 10s, use 8s to leave margin for finish() + private const val BOOT_TIMEOUT_MS = 8000L } override fun onReceive(context: Context, intent: Intent) { @@ -27,42 +30,15 @@ class BootReceiver : BroadcastReceiver() { val pendingResult = goAsync() - CoroutineScope(Dispatchers.IO).launch { + // Use runBlocking to ensure pendingResult.finish() is called reliably + // within the BroadcastReceiver timeout + runBlocking { try { - val app = TranslanderApp.instance - val floatingMicEnabled = app.settingsRepository.serviceEnabled.first() - val audioMonitorEnabled = app.settingsRepository.audioMonitorEnabled.first() - - // Check overlay permission for floating mic - val hasOverlayPermission = Settings.canDrawOverlays(context) - - val canStartFloatingMic = floatingMicEnabled && hasOverlayPermission - val canStartAudioMonitor = audioMonitorEnabled - - Log.i(TAG, "floatingMicEnabled=$floatingMicEnabled, audioMonitorEnabled=$audioMonitorEnabled, hasOverlay=$hasOverlayPermission") - - if (!canStartFloatingMic && !canStartAudioMonitor) { - Log.i(TAG, "No services enabled, nothing to start") - return@launch - } - - if (BootCompatHelper.canStartFgsFromBoot) { - // Pre-Android 14: can start FGS directly - if (canStartFloatingMic) { - Log.i(TAG, "Starting FloatingMicService") - val serviceIntent = Intent(context, FloatingMicService::class.java) - context.startForegroundService(serviceIntent) + withTimeoutOrNull(BOOT_TIMEOUT_MS) { + withContext(Dispatchers.IO) { + handleBootCompleted(context) } - if (canStartAudioMonitor) { - Log.i(TAG, "Starting AudioMonitorService") - val serviceIntent = Intent(context, AudioMonitorService::class.java) - context.startForegroundService(serviceIntent) - } - } else { - // Android 14+: show single unified notification - Log.i(TAG, "Android 14+: showing notification instead of starting services") - app.serviceAlertNotification.show(R.string.service_start_services) - } + } ?: Log.w(TAG, "Boot handling timed out") } catch (e: Exception) { Log.e(TAG, "Failed to check service enabled state", e) } finally { @@ -71,4 +47,46 @@ class BootReceiver : BroadcastReceiver() { } } } + + private suspend fun handleBootCompleted(context: Context) { + val app = TranslanderApp.instance + // Timeout DataStore reads to avoid blocking beyond BroadcastReceiver limit + val floatingMicEnabled = withTimeoutOrNull(5000L) { + app.settingsRepository.serviceEnabled.first() + } ?: false + val audioMonitorEnabled = withTimeoutOrNull(5000L) { + app.settingsRepository.audioMonitorEnabled.first() + } ?: false + + // Check overlay permission for floating mic + val hasOverlayPermission = Settings.canDrawOverlays(context) + + val canStartFloatingMic = floatingMicEnabled && hasOverlayPermission + val canStartAudioMonitor = audioMonitorEnabled + + Log.i(TAG, "floatingMicEnabled=$floatingMicEnabled, audioMonitorEnabled=$audioMonitorEnabled, hasOverlay=$hasOverlayPermission") + + if (!canStartFloatingMic && !canStartAudioMonitor) { + Log.i(TAG, "No services enabled, nothing to start") + return + } + + if (BootCompatHelper.canStartFgsFromBoot) { + // Pre-Android 14: can start FGS directly + if (canStartFloatingMic) { + Log.i(TAG, "Starting FloatingMicService") + val serviceIntent = Intent(context, FloatingMicService::class.java) + context.startForegroundService(serviceIntent) + } + if (canStartAudioMonitor) { + Log.i(TAG, "Starting AudioMonitorService") + val serviceIntent = Intent(context, AudioMonitorService::class.java) + context.startForegroundService(serviceIntent) + } + } else { + // Android 14+: show single unified notification + Log.i(TAG, "Android 14+: showing notification instead of starting services") + app.serviceAlertNotification.show(R.string.service_start_services) + } + } } diff --git a/app/src/main/java/com/translander/service/FloatingMicService.kt b/app/src/main/java/com/translander/service/FloatingMicService.kt index 35db86c..b98eba1 100644 --- a/app/src/main/java/com/translander/service/FloatingMicService.kt +++ b/app/src/main/java/com/translander/service/FloatingMicService.kt @@ -6,6 +6,7 @@ import android.app.PendingIntent import android.app.Service import android.util.Log import android.content.Intent +import android.content.pm.ServiceInfo import android.graphics.PixelFormat import android.os.Build import android.os.IBinder @@ -31,11 +32,11 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import com.translander.settings.SettingsRepository +import java.util.concurrent.atomic.AtomicBoolean class FloatingMicService : Service() { @@ -55,7 +56,7 @@ class FloatingMicService : Service() { private var audioRecorder: AudioRecorder? = null private var recordingJob: Job? = null - private var isRecording = false + private val isRecording = AtomicBoolean(false) private var isIntentionalStop = false private var initialX = 0 private var initialY = 0 @@ -92,7 +93,13 @@ class FloatingMicService : Service() { } try { - startForeground(TranslanderApp.NOTIFICATION_ID, createNotification()) + val notification = createNotification() + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(TranslanderApp.NOTIFICATION_ID, notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE) + } else { + startForeground(TranslanderApp.NOTIFICATION_ID, notification) + } // Dismiss any failure notification from previous boot attempt getSystemService(android.app.NotificationManager::class.java) ?.cancel(TranslanderApp.SERVICE_ALERT_NOTIFICATION_ID) @@ -163,10 +170,9 @@ class FloatingMicService : Service() { val layoutFlag = WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY - // Get saved position synchronously before adding view to prevent flicker - val (savedX, savedY) = runBlocking { - TranslanderApp.instance.settingsRepository.buttonPosition.first() - } + // Default position - will be updated async once loaded from settings + val defaultX = 100 + val defaultY = 300 layoutParams = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, @@ -176,12 +182,24 @@ class FloatingMicService : Service() { PixelFormat.TRANSLUCENT ).apply { gravity = Gravity.TOP or Gravity.START - x = if (savedX >= 0) savedX else 100 - y = if (savedY >= 0) savedY else 300 + x = defaultX + y = defaultY } windowManager.addView(floatingView, layoutParams) + // Load saved position asynchronously and update layout + serviceScope.launch { + val (savedX, savedY) = TranslanderApp.instance.settingsRepository.buttonPosition.first() + if (savedX >= 0 && savedY >= 0) { + withContext(Dispatchers.Main) { + layoutParams.x = savedX + layoutParams.y = savedY + windowManager.updateViewLayout(floatingView, layoutParams) + } + } + } + micButton.setOnTouchListener { _, event -> when (event.action) { MotionEvent.ACTION_DOWN -> { @@ -234,8 +252,8 @@ class FloatingMicService : Service() { } private fun toggleRecording() { - Log.i(TAG, "toggleRecording called, isRecording=$isRecording") - if (isRecording) { + Log.i(TAG, "toggleRecording called, isRecording=${isRecording.get()}") + if (isRecording.get()) { stopRecording() } else { startRecording() @@ -261,21 +279,21 @@ class FloatingMicService : Service() { return } - isRecording = true + isRecording.set(true) updateMicButtonState() recordingJob = serviceScope.launch(Dispatchers.IO) { - recorderMutex.withLock { - audioRecorder = AudioRecorder() + val recorder = recorderMutex.withLock { + AudioRecorder().also { audioRecorder = it } } Log.i(TAG, "Starting audio recording") - audioRecorder?.startRecording() + recorder.startRecording() } } private fun stopRecording() { Log.i(TAG, "stopRecording called") - isRecording = false + isRecording.set(false) updateMicButtonState() recordingJob?.cancel() @@ -301,10 +319,7 @@ class FloatingMicService : Service() { private suspend fun transcribeAudio(audioData: ShortArray) { Log.i(TAG, "transcribeAudio called with ${audioData.size} samples") - val language = TranslanderApp.instance.settingsRepository.preferredLanguage.first() - val langCode = if (language == "auto") null else language - - val result = TranslanderApp.instance.recognizerManager.transcribe(audioData, langCode) + val result = TranslanderApp.instance.recognizerManager.transcribe(audioData) Log.i(TAG, "Transcription result: '$result'") withContext(Dispatchers.Main) { @@ -337,11 +352,12 @@ class FloatingMicService : Service() { } private fun updateMicButtonState() { + val recording = isRecording.get() micButton.setImageResource( - if (isRecording) R.drawable.ic_mic_recording else R.drawable.ic_mic + if (recording) R.drawable.ic_mic_recording else R.drawable.ic_mic ) micButton.setBackgroundResource( - if (isRecording) R.drawable.mic_button_recording_bg else R.drawable.mic_button_bg + if (recording) R.drawable.mic_button_recording_bg else R.drawable.mic_button_bg ) } diff --git a/app/src/main/java/com/translander/service/SpeechRecognitionService.kt b/app/src/main/java/com/translander/service/SpeechRecognitionService.kt index b6d437c..b09bdc0 100644 --- a/app/src/main/java/com/translander/service/SpeechRecognitionService.kt +++ b/app/src/main/java/com/translander/service/SpeechRecognitionService.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull /** * Android Speech Recognition Service that integrates with system voice input. @@ -69,12 +70,15 @@ class SpeechRecognitionService : RecognitionService() { serviceScope.launch { try { - val success = recognizerManager.ensureInitialized() + // Timeout after 10 seconds to avoid hanging indefinitely + val success = withTimeoutOrNull(10000L) { + recognizerManager.ensureInitialized() + } ?: false if (success) { Log.i(TAG, "Model initialized successfully") startRecording(listener, languageHint) } else { - Log.w(TAG, "Failed to initialize model") + Log.w(TAG, "Failed to initialize model (timeout or error)") listener.error(SpeechRecognizer.ERROR_SERVER) } } catch (e: Exception) { @@ -103,15 +107,13 @@ class SpeechRecognitionService : RecognitionService() { // Signal that we're ready to receive audio listener.readyForSpeech(Bundle()) + // Signal beginning of speech before starting recording + listener.beginningOfSpeech() + audioRecorder = AudioRecorder() recordingJob = serviceScope.launch(Dispatchers.IO) { try { Log.i(TAG, "Starting audio recording") - - withContext(Dispatchers.Main) { - listener.beginningOfSpeech() - } - audioRecorder?.startRecording() } catch (e: Exception) { Log.e(TAG, "Recording error", e) @@ -152,7 +154,7 @@ class SpeechRecognitionService : RecognitionService() { Log.i(TAG, "Audio data size: ${audioData.size} samples") // Transcribe - val result = TranslanderApp.instance.recognizerManager.transcribe(audioData, null) + val result = TranslanderApp.instance.recognizerManager.transcribe(audioData) withContext(Dispatchers.Main) { if (!result.isNullOrBlank()) { diff --git a/app/src/main/java/com/translander/service/TextInjectionService.kt b/app/src/main/java/com/translander/service/TextInjectionService.kt index 0781455..28fd94e 100644 --- a/app/src/main/java/com/translander/service/TextInjectionService.kt +++ b/app/src/main/java/com/translander/service/TextInjectionService.kt @@ -19,11 +19,12 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.delay import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import java.util.concurrent.atomic.AtomicBoolean class TextInjectionService : AccessibilityService() { @@ -31,7 +32,7 @@ class TextInjectionService : AccessibilityService() { private val recorderMutex = Mutex() private var audioRecorder: AudioRecorder? = null private var recordingJob: Job? = null - private var isRecording = false + private val isRecording = AtomicBoolean(false) private var accessibilityButtonCallback: AccessibilityButtonController.AccessibilityButtonCallback? = null @@ -86,8 +87,8 @@ class TextInjectionService : AccessibilityService() { } private fun toggleRecording() { - Log.i(TAG, "toggleRecording called, isRecording=$isRecording") - if (isRecording) { + Log.i(TAG, "toggleRecording called, isRecording=${isRecording.get()}") + if (isRecording.get()) { stopRecording() } else { startRecording() @@ -112,21 +113,21 @@ class TextInjectionService : AccessibilityService() { return } - isRecording = true + isRecording.set(true) showToast(getString(R.string.state_recording)) recordingJob = serviceScope.launch(Dispatchers.IO) { - recorderMutex.withLock { - audioRecorder = AudioRecorder() + val recorder = recorderMutex.withLock { + AudioRecorder().also { audioRecorder = it } } Log.i(TAG, "Starting audio recording") - audioRecorder?.startRecording() + recorder.startRecording() } } private fun stopRecording() { Log.i(TAG, "stopRecording called") - isRecording = false + isRecording.set(false) showToast(getString(R.string.state_processing)) recordingJob?.cancel() @@ -152,6 +153,7 @@ class TextInjectionService : AccessibilityService() { companion object { private const val TAG = "TextInjectionService" + @Volatile var instance: TextInjectionService? = null private set @@ -160,10 +162,7 @@ class TextInjectionService : AccessibilityService() { private suspend fun transcribeAudio(audioData: ShortArray) { Log.i(TAG, "transcribeAudio called with ${audioData.size} samples") - val language = TranslanderApp.instance.settingsRepository.preferredLanguage.first() - val langCode = if (language == "auto") null else language - - val result = TranslanderApp.instance.recognizerManager.transcribe(audioData, langCode) + val result = TranslanderApp.instance.recognizerManager.transcribe(audioData) Log.i(TAG, "Transcription result: '$result'") if (!result.isNullOrBlank()) { @@ -205,11 +204,7 @@ class TextInjectionService : AccessibilityService() { val focusedNode = findFocusedEditText() if (focusedNode != null) { Log.i(TAG, "Found focused node, injecting text") - try { - insertTextIntoNode(focusedNode, text) - } finally { - focusedNode.recycle() - } + insertTextIntoNode(focusedNode, text) } else { Log.w(TAG, "No focused text field found, copying to clipboard") showToast(getString(R.string.toast_no_field_clipboard)) @@ -228,40 +223,31 @@ class TextInjectionService : AccessibilityService() { val inputFocused = rootNode.findFocus(AccessibilityNodeInfo.FOCUS_INPUT) if (inputFocused != null && inputFocused.isEditable) { Log.i(TAG, "Found input-focused editable node") - rootNode.recycle() return inputFocused } - // Recycle inputFocused if it wasn't editable - inputFocused?.recycle() - // Fallback to searching the tree - val result = findFocusedNode(rootNode) - rootNode.recycle() - return result + return findFocusedNode(rootNode) } private fun findFocusedNode(node: AccessibilityNodeInfo): AccessibilityNodeInfo? { if (node.isFocused && node.isEditable) { Log.i(TAG, "Found focused editable node: ${node.className}") - // Return a copy so caller owns it, original will be recycled by caller of this function - return AccessibilityNodeInfo.obtain(node) + return node } // Also check for isEditable without isFocused (some apps don't report focus correctly) if (node.isEditable && node.isFocusable) { Log.i(TAG, "Found editable focusable node: ${node.className}") - return AccessibilityNodeInfo.obtain(node) + return node } for (i in 0 until node.childCount) { val child = node.getChild(i) ?: continue val result = findFocusedNode(child) if (result != null) { - child.recycle() return result } - child.recycle() } return null } @@ -330,9 +316,10 @@ class TextInjectionService : AccessibilityService() { // Restore old clipboard after a short delay if (oldClip != null) { - android.os.Handler(mainLooper).postDelayed({ + serviceScope.launch { + delay(500) clipboard.setPrimaryClip(oldClip) - }, 500) + } } if (!pasteSuccess) { diff --git a/app/src/main/java/com/translander/service/VoiceInputActivity.kt b/app/src/main/java/com/translander/service/VoiceInputActivity.kt index 26f2668..665e1e4 100644 --- a/app/src/main/java/com/translander/service/VoiceInputActivity.kt +++ b/app/src/main/java/com/translander/service/VoiceInputActivity.kt @@ -1,7 +1,6 @@ package com.translander.service import android.Manifest -import android.app.Activity import android.content.Intent import android.content.pm.PackageManager import android.os.Bundle @@ -10,6 +9,8 @@ import android.speech.RecognizerIntent import android.util.Log import android.view.WindowManager import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback import androidx.core.content.ContextCompat import com.translander.R import com.translander.TranslanderApp @@ -27,7 +28,7 @@ import kotlinx.coroutines.withContext * Activity that handles voice input requests from keyboards and other apps. * Uses a system overlay for UI to avoid stealing focus from the calling app. */ -class VoiceInputActivity : Activity() { +class VoiceInputActivity : ComponentActivity() { companion object { private const val TAG = "VoiceInputActivity" @@ -50,6 +51,18 @@ class VoiceInputActivity : Activity() { WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS ) + // Handle back button + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (isRecording) { + stopRecordingAndTranscribe() + } else { + setResult(RESULT_CANCELED) + finish() + } + } + }) + // Check overlay permission if (!Settings.canDrawOverlays(this)) { Log.w(TAG, "No overlay permission") @@ -135,15 +148,6 @@ class VoiceInputActivity : Activity() { activityScope.cancel() } - @Deprecated("Deprecated in Java") - override fun onBackPressed() { - if (isRecording) { - stopRecordingAndTranscribe() - } else { - setResult(RESULT_CANCELED) - super.onBackPressed() - } - } private fun startRecording() { Log.i(TAG, "Starting recording") @@ -200,7 +204,7 @@ class VoiceInputActivity : Activity() { Log.i(TAG, "Audio data: ${audioData.size} samples") - val result = TranslanderApp.instance.recognizerManager.transcribe(audioData, null) + val result = TranslanderApp.instance.recognizerManager.transcribe(audioData) withContext(Dispatchers.Main) { if (!result.isNullOrBlank()) { diff --git a/app/src/main/java/com/translander/settings/SettingsActivity.kt b/app/src/main/java/com/translander/settings/SettingsActivity.kt index fe0856a..04cff2b 100644 --- a/app/src/main/java/com/translander/settings/SettingsActivity.kt +++ b/app/src/main/java/com/translander/settings/SettingsActivity.kt @@ -333,7 +333,6 @@ fun SettingsScreen( val modelManager = TranslanderApp.instance.modelManager val serviceEnabled by settingsRepository.serviceEnabled.collectAsStateWithLifecycle(initialValue = false) - val preferredLanguage by settingsRepository.preferredLanguage.collectAsStateWithLifecycle(initialValue = SettingsRepository.LANGUAGE_AUTO) val themeMode by settingsRepository.themeMode.collectAsStateWithLifecycle(initialValue = SettingsRepository.THEME_SYSTEM) val autoLoadModel by settingsRepository.autoLoadModel.collectAsStateWithLifecycle(initialValue = false) val downloadState by modelManager.downloadState.collectAsStateWithLifecycle() @@ -456,16 +455,6 @@ fun SettingsScreen( } } ) - - LanguageSettingItem( - selectedLanguage = preferredLanguage, - languages = settingsRepository.getSupportedLanguages(), - onLanguageSelected = { lang -> - scope.launch { - settingsRepository.setPreferredLanguage(lang) - } - } - ) } // Microphone Permission (required for all voice input) @@ -949,41 +938,6 @@ fun ModelSettingItem( } } -@Composable -fun LanguageSettingItem( - selectedLanguage: String, - languages: List, - onLanguageSelected: (String) -> Unit -) { - var expanded by remember { mutableStateOf(false) } - val selectedLangName = languages.find { it.code == selectedLanguage }?.displayName ?: stringResource(R.string.setting_language_auto) - - ListItem( - headlineContent = { Text(stringResource(R.string.setting_language)) }, - supportingContent = { Text(selectedLangName) }, - leadingContent = { Icon(Icons.Default.Language, contentDescription = null) }, - modifier = Modifier.clickable { expanded = true } - ) - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false } - ) { - languages.forEach { lang -> - DropdownMenuItem( - text = { Text(lang.displayName) }, - onClick = { - onLanguageSelected(lang.code) - expanded = false - }, - leadingIcon = if (lang.code == selectedLanguage) { - { Icon(Icons.Default.Check, null) } - } else null - ) - } - } -} - @Composable fun ThemeSettingItem( selectedTheme: String, diff --git a/app/src/main/java/com/translander/settings/SettingsRepository.kt b/app/src/main/java/com/translander/settings/SettingsRepository.kt index c46b846..1a331a0 100644 --- a/app/src/main/java/com/translander/settings/SettingsRepository.kt +++ b/app/src/main/java/com/translander/settings/SettingsRepository.kt @@ -18,7 +18,6 @@ class SettingsRepository(private val context: Context) { companion object { private val SERVICE_ENABLED_KEY = booleanPreferencesKey("service_enabled") - private val LANGUAGE_KEY = stringPreferencesKey("preferred_language") private val THEME_KEY = stringPreferencesKey("theme_mode") private val BUTTON_X_KEY = intPreferencesKey("button_x") private val BUTTON_Y_KEY = intPreferencesKey("button_y") @@ -28,8 +27,6 @@ class SettingsRepository(private val context: Context) { private val MONITORED_FOLDERS_KEY = stringSetPreferencesKey("monitored_folders") private val FLOATING_BUTTON_SIZE_KEY = stringPreferencesKey("floating_button_size") - const val LANGUAGE_AUTO = "auto" - const val BUTTON_SIZE_SMALL = "small" // 44dp const val BUTTON_SIZE_MEDIUM = "medium" // 56dp (default) const val BUTTON_SIZE_LARGE = "large" // 72dp @@ -42,10 +39,6 @@ class SettingsRepository(private val context: Context) { preferences[SERVICE_ENABLED_KEY] ?: false } - val preferredLanguage: Flow = context.dataStore.data.map { preferences -> - preferences[LANGUAGE_KEY] ?: LANGUAGE_AUTO - } - val themeMode: Flow = context.dataStore.data.map { preferences -> preferences[THEME_KEY] ?: THEME_SYSTEM } @@ -112,12 +105,6 @@ class SettingsRepository(private val context: Context) { } } - suspend fun setPreferredLanguage(language: String) { - context.dataStore.edit { preferences -> - preferences[LANGUAGE_KEY] = language - } - } - suspend fun setThemeMode(mode: String) { context.dataStore.edit { preferences -> preferences[THEME_KEY] = mode @@ -130,38 +117,4 @@ class SettingsRepository(private val context: Context) { preferences[BUTTON_Y_KEY] = y } } - - data class Language( - val code: String, - val displayName: String - ) - - fun getSupportedLanguages(): List = listOf( - Language("auto", "Auto-detect"), - Language("en", "English"), - Language("de", "German"), - Language("fr", "French"), - Language("es", "Spanish"), - Language("it", "Italian"), - Language("pt", "Portuguese"), - Language("nl", "Dutch"), - Language("pl", "Polish"), - Language("ru", "Russian"), - Language("uk", "Ukrainian"), - Language("cs", "Czech"), - Language("sk", "Slovak"), - Language("hu", "Hungarian"), - Language("ro", "Romanian"), - Language("bg", "Bulgarian"), - Language("hr", "Croatian"), - Language("sl", "Slovenian"), - Language("el", "Greek"), - Language("da", "Danish"), - Language("sv", "Swedish"), - Language("fi", "Finnish"), - Language("et", "Estonian"), - Language("lv", "Latvian"), - Language("lt", "Lithuanian"), - Language("mt", "Maltese") - ) } diff --git a/app/src/main/java/com/translander/transcribe/AudioMonitorService.kt b/app/src/main/java/com/translander/transcribe/AudioMonitorService.kt index 69c0f06..3f55663 100644 --- a/app/src/main/java/com/translander/transcribe/AudioMonitorService.kt +++ b/app/src/main/java/com/translander/transcribe/AudioMonitorService.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import java.io.File +import java.util.concurrent.CopyOnWriteArrayList import android.util.Log class AudioMonitorService : Service() { @@ -49,7 +50,7 @@ class AudioMonitorService : Service() { } private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - private var fileObservers: MutableList = mutableListOf() + private val fileObservers: MutableList = CopyOnWriteArrayList() private var debounceJob: Job? = null override fun onCreate() { @@ -161,6 +162,9 @@ class AudioMonitorService : Service() { } private fun startMonitoring() { + // Stop any existing observers before starting new ones + stopMonitoring() + serviceScope.launch { val settings = TranslanderApp.instance.settingsRepository val monitoredPaths = settings.monitoredFolders.first() @@ -200,12 +204,17 @@ class AudioMonitorService : Service() { } } } - observer.startWatching() - fileObservers.add(observer) - Log.i(TAG, "FileObserver started for: ${directory.absolutePath}") + try { + observer.startWatching() + fileObservers.add(observer) + Log.i(TAG, "FileObserver started for: ${directory.absolutePath}") + } catch (e: Exception) { + Log.e(TAG, "Failed to start FileObserver for: ${directory.absolutePath}", e) + } } private fun stopMonitoring() { + debounceJob?.cancel() for (observer in fileObservers) { observer.stopWatching() } @@ -221,12 +230,18 @@ class AudioMonitorService : Service() { val filePath = file.absolutePath Log.i(TAG, "onAudioFileDetected: $filePath") - // Small debounce to avoid rapid duplicate events from FileObserver + // Debounce to avoid rapid duplicate events from FileObserver + // and to ensure file is fully written before transcribing debounceJob?.cancel() debounceJob = serviceScope.launch { - delay(500) - Log.i(TAG, "Launching transcription for: $filePath") - launchTranscription(file) + delay(DEBOUNCE_MS) + // Verify file still exists and is readable + if (file.exists() && file.length() > 0 && file.canRead()) { + Log.i(TAG, "Launching transcription for: $filePath") + launchTranscription(file) + } else { + Log.w(TAG, "File not ready or inaccessible: $filePath") + } } } diff --git a/app/src/main/java/com/translander/transcribe/TranscribeActivity.kt b/app/src/main/java/com/translander/transcribe/TranscribeActivity.kt index a7d6757..1ad6ac0 100644 --- a/app/src/main/java/com/translander/transcribe/TranscribeActivity.kt +++ b/app/src/main/java/com/translander/transcribe/TranscribeActivity.kt @@ -1,5 +1,6 @@ package com.translander.transcribe +import android.app.NotificationManager import android.content.ClipData import android.content.ClipboardManager import android.content.Context @@ -112,6 +113,13 @@ class TranscribeActivity : ComponentActivity() { } startActivity(Intent.createChooser(shareIntent, getString(R.string.transcribe_share_title))) } + + override fun onDestroy() { + super.onDestroy() + // Dismiss the "audio detected" notification when activity closes + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.cancel(AudioMonitorService.AUDIO_DETECTED_NOTIFICATION_ID) + } } sealed class TranscribeState { @@ -188,13 +196,8 @@ fun TranscribeScreen( is AudioDecoder.DecodingState.Success -> { // Transcribe audio state = TranscribeState.Transcribing - val language = withContext(Dispatchers.IO) { - settingsRepository.preferredLanguage.first() - .takeIf { it != SettingsRepository.LANGUAGE_AUTO } - } - val result = withContext(Dispatchers.IO) { - recognizerManager.transcribe(decodeResult.audioData, language) + recognizerManager.transcribe(decodeResult.audioData) } if (result != null) { state = TranscribeState.Success(result) diff --git a/app/src/main/java/com/translander/ui/theme/Theme.kt b/app/src/main/java/com/translander/ui/theme/Theme.kt index a4eaefd..73dcb20 100644 --- a/app/src/main/java/com/translander/ui/theme/Theme.kt +++ b/app/src/main/java/com/translander/ui/theme/Theme.kt @@ -50,7 +50,8 @@ fun TranslanderTheme( val view = LocalView.current if (!view.isInEditMode) { SideEffect { - val window = (view.context as Activity).window + val activity = view.context as? Activity ?: return@SideEffect + val window = activity.window WindowCompat.getInsetsController(window, view).isAppearanceLightStatusBars = !darkTheme } } diff --git a/app/src/main/res/values-bg/strings.xml b/app/src/main/res/values-bg/strings.xml index f2d7b8e..89d3fbe 100644 --- a/app/src/main/res/values-bg/strings.xml +++ b/app/src/main/res/values-bg/strings.xml @@ -25,8 +25,6 @@ Активиране на услугата - Говорим език - Автоматично разпознаване Тема Системна Светла diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index a91e634..f309379 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -25,8 +25,6 @@ Povolit službu - Mluvený jazyk - Automatická detekce Motiv Systémový Světlý diff --git a/app/src/main/res/values-da/strings.xml b/app/src/main/res/values-da/strings.xml index f4a9f07..3730ec0 100644 --- a/app/src/main/res/values-da/strings.xml +++ b/app/src/main/res/values-da/strings.xml @@ -19,8 +19,6 @@ Ordrettelser Udseende Aktivér tjeneste - Talesprog - Automatisk registrering Tema System Lyst diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index fad0cc9..30d0938 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -25,8 +25,6 @@ Dienst aktivieren - Gesprochene Sprache - Automatisch erkennen Design System Hell diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 82201e4..12b6b85 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -19,8 +19,6 @@ Διορθώσεις λέξεων Εμφάνιση Ενεργοποίηση υπηρεσίας - Γλώσσα ομιλίας - Αυτόματη ανίχνευση Θέμα Σύστημα Φωτεινό diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c9b8fcc..534f166 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -25,8 +25,6 @@ Activar servicio - Idioma hablado - Detección automática Tema Sistema Claro diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 602450e..200c644 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -19,8 +19,6 @@ Sõnaparandused Välimus Luba teenus - Räägitav keel - Automaatne tuvastus Teema Süsteemne Hele diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index c5e153d..55177e8 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -19,8 +19,6 @@ Sanakorjaukset Ulkoasu Ota palvelu käyttöön - Puhuttu kieli - Automaattinen tunnistus Teema Järjestelmä Vaalea diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a848624..83f6177 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -25,8 +25,6 @@ Activer le service - Langue parlée - Détection automatique Thème Système Clair diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 6dd226a..2a691ef 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -25,8 +25,6 @@ Omogući uslugu - Govorni jezik - Automatsko prepoznavanje Tema Sistemska Svijetla diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 475a4b0..74ab9c1 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -25,8 +25,6 @@ Szolgáltatás engedélyezése - Beszélt nyelv - Automatikus felismerés Téma Rendszer Világos diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 37bc825..772616e 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -25,8 +25,6 @@ Attiva servizio - Lingua parlata - Rilevamento automatico Tema Sistema Chiaro diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index e9872dd..68d12dd 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -19,8 +19,6 @@ Žodžių taisymai Išvaizda Įjungti paslaugą - Kalbama kalba - Automatinis atpažinimas Tema Sistemos Šviesi diff --git a/app/src/main/res/values-lv/strings.xml b/app/src/main/res/values-lv/strings.xml index 125eb3e..f54f616 100644 --- a/app/src/main/res/values-lv/strings.xml +++ b/app/src/main/res/values-lv/strings.xml @@ -19,8 +19,6 @@ Vārdu labojumi Izskats Iespējot pakalpojumu - Runātā valoda - Automātiskā noteikšana Motīvs Sistēmas Gaišs diff --git a/app/src/main/res/values-mt/strings.xml b/app/src/main/res/values-mt/strings.xml index b1ec1d2..d6bc040 100644 --- a/app/src/main/res/values-mt/strings.xml +++ b/app/src/main/res/values-mt/strings.xml @@ -19,8 +19,6 @@ Korrezzjonijiet tal-Kliem Dehra Attiva s-Servizz - Lingwa Mitkellma - Individwa awtomatikament Tema Sistema Ċar diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 1845969..44c4c46 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -19,8 +19,6 @@ Woordcorrecties Uiterlijk Service inschakelen - Gesproken taal - Automatisch detecteren Thema Systeem Licht diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 9dca430..b1a878a 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -19,8 +19,6 @@ Korekty słów Wygląd Włącz usługę - Język mówiony - Automatyczne wykrywanie Motyw Systemowy Jasny diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index f6d631d..09a1aac 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -19,8 +19,6 @@ Correções de palavras Aparência Ativar serviço - Idioma falado - Deteção automática Tema Sistema Claro diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index bf65134..31ebeee 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -19,8 +19,6 @@ Corecții de cuvinte Aspect Activează serviciul - Limba vorbită - Detectare automată Temă Sistem Deschisă diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index de971db..d2d8d99 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -25,8 +25,6 @@ Povoliť službu - Hovorený jazyk - Automatická detekcia Motív Systémový Svetlý diff --git a/app/src/main/res/values-sl/strings.xml b/app/src/main/res/values-sl/strings.xml index 9678ab0..813efce 100644 --- a/app/src/main/res/values-sl/strings.xml +++ b/app/src/main/res/values-sl/strings.xml @@ -25,8 +25,6 @@ Omogoči storitev - Govorjeni jezik - Samodejna zaznava Tema Sistemska Svetla diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 64fc29c..b5d4b55 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -19,8 +19,6 @@ Ordrättningar Utseende Aktivera tjänst - Talat språk - Automatisk identifiering Tema System Ljust diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index a398870..65a3168 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -19,8 +19,6 @@ Виправлення слів Зовнішній вигляд Увімкнути службу - Мова мовлення - Автоматичне визначення Тема Системна Світла diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3c95189..31b3800 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -26,8 +26,6 @@ Enable Service - Spoken Language - Auto-detect Theme System Light