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