Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,20 @@ The NDK version must match between the fdroiddata YAML and the local SDK. `ANDRO
| fdroid build skips silently | APK already exists in `unsigned/` | Remove existing APK and src tarball |
| `Environment variable {env: serverwebroot}` | Missing fdroid config (warning, not fatal) | Ignore — only needed for `fdroid publish` |
| Lint `MissingTranslation` | New string added to `values/strings.xml` without translations | Add to all `values-{locale}/strings.xml` files |

**Translations workflow:** When adding new strings, defer translations until the feature is complete and stable. This avoids repeatedly updating 23 locale files during iterative development. Add all translations in one batch before the release.
| Lint `ExtraTranslation` | String in translation file not in default `strings.xml` | Remove from translation files |
| XML apostrophe crash | Unescaped `'` in strings.xml | Use `\'` — e.g., `there\'s` not `there's` |

## Release Checklist

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
5. Tag the release: `git tag v{versionName}`

## Architecture

### Core Components
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A fully offline voice typing app for Android. Speak into your phone and text app
- Uses [Parakeet TDT v3](https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8) neural model (~600MB) running locally via [sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx)
- Supports 25 languages with auto-detection
- No internet connection needed after model download
- **Import from folder** — load model files from local storage instead of downloading

### Works in Any App
- Uses Android Accessibility API to inject text directly into any focused text field
Expand Down Expand Up @@ -148,7 +149,7 @@ app/src/main/java/com/translander/
├── asr/ # Speech recognition
│ ├── AudioRecorder.kt # 16kHz PCM recording
│ ├── DictionaryManager.kt # Word correction rules
│ ├── ModelManager.kt # Model download from HuggingFace
│ ├── ModelManager.kt # Model download and local import
│ ├── ParakeetRecognizer.kt # ONNX inference wrapper
│ └── RecognizerManager.kt # Shared recognizer singleton
├── ime/
Expand Down Expand Up @@ -195,7 +196,7 @@ This app is provided as-is for personal use. Speech recognition accuracy depends

**Model Attribution:** Speech recognition uses [NVIDIA Parakeet TDT](https://huggingface.co/nvidia/parakeet-tdt-0.6b-v3), licensed under [CC-BY-4.0](https://creativecommons.org/licenses/by/4.0/). ONNX conversion by [csukuangfj/sherpa-onnx](https://github.com/k2-fsa/sherpa-onnx).

**Privacy:** All speech processing happens locally on your device. No audio data is ever sent to any server. After the [speech model](https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8) is downloaded, you can revoke the Network permission in Android Settings to ensure the app can never access the internet.
**Privacy:** All speech processing happens locally on your device. No audio data is ever sent to any server. You can [download the model](https://huggingface.co/csukuangfj/sherpa-onnx-nemo-parakeet-tdt-0.6b-v3-int8) directly or import it from a local folder, then revoke the Network permission in Android Settings to ensure the app can never access the internet.

---

Expand Down
41 changes: 39 additions & 2 deletions app/src/main/java/com/translander/TranslanderApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,12 @@ package com.translander
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import com.translander.settings.SettingsActivity
import com.translander.asr.DictionaryManager
import com.translander.asr.ModelManager
import com.translander.asr.RecognizerManager
Expand Down Expand Up @@ -64,6 +68,8 @@ class TranslanderApp : Application() {

private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val notificationManager = getSystemService(NotificationManager::class.java)

val channel = NotificationChannel(
NOTIFICATION_CHANNEL_ID,
"Voice Transcribe Service",
Expand All @@ -72,16 +78,47 @@ class TranslanderApp : Application() {
description = "Shows when voice transcription service is active"
setShowBadge(false)
}

val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.createNotificationChannel(channel)

val alertChannel = NotificationChannel(
SERVICE_ALERT_CHANNEL_ID,
getString(R.string.service_alert_channel_name),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
description = getString(R.string.service_alert_channel_description)
}
notificationManager.createNotificationChannel(alertChannel)
}
}

fun showServiceStartNotification(messageResId: Int) {
val intent = Intent(this, SettingsActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
}
val pendingIntent = PendingIntent.getActivity(
this, 0, intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)

val notification = NotificationCompat.Builder(this, SERVICE_ALERT_CHANNEL_ID)
.setSmallIcon(R.drawable.ic_mic)
.setContentTitle(getString(R.string.app_name))
.setContentText(getString(messageResId))
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setContentIntent(pendingIntent)
.setAutoCancel(true)
.build()

val notificationManager = getSystemService(NotificationManager::class.java)
notificationManager.notify(SERVICE_ALERT_NOTIFICATION_ID, notification)
}

companion object {
private const val TAG = "TranslanderApp"
const val NOTIFICATION_CHANNEL_ID = "voice_transcribe_service"
const val SERVICE_ALERT_CHANNEL_ID = "service_alert"
const val NOTIFICATION_ID = 1001
const val SERVICE_ALERT_NOTIFICATION_ID = 1002

lateinit var instance: TranslanderApp
private set
Expand Down
29 changes: 28 additions & 1 deletion app/src/main/java/com/translander/service/FloatingMicService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,30 @@ class FloatingMicService : Service() {
}
}

startForeground(TranslanderApp.NOTIFICATION_ID, createNotification())
try {
startForeground(TranslanderApp.NOTIFICATION_ID, createNotification())
// Dismiss any failure notification from previous boot attempt
getSystemService(android.app.NotificationManager::class.java)
?.cancel(TranslanderApp.SERVICE_ALERT_NOTIFICATION_ID)
} catch (e: android.app.ForegroundServiceStartNotAllowedException) {
// Android 14+ blocks microphone FGS from BOOT_COMPLETED or background
Log.w(TAG, "Cannot start foreground service from boot/background", e)
showFailureNotification()
stopSelf()
return START_NOT_STICKY
} catch (e: SecurityException) {
// Missing RECORD_AUDIO permission for microphone FGS type
Log.w(TAG, "Missing permission for foreground service", e)
showFailureNotification()
stopSelf()
return START_NOT_STICKY
} catch (e: IllegalStateException) {
// Race condition: app went to background before startForeground completed
Log.w(TAG, "Cannot start foreground service, app in background", e)
showFailureNotification()
stopSelf()
return START_NOT_STICKY
}
return START_NOT_STICKY // Don't auto-restart
}

Expand Down Expand Up @@ -322,6 +345,10 @@ class FloatingMicService : Service() {
)
}

private fun showFailureNotification() {
TranslanderApp.instance.showServiceStartNotification(R.string.service_start_floating_mic)
}

private fun createNotification(): Notification {
val stopIntent = Intent(this, FloatingMicService::class.java).apply {
action = ACTION_STOP
Expand Down
22 changes: 21 additions & 1 deletion app/src/main/java/com/translander/settings/SettingsActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,12 @@ class SettingsActivity : ComponentActivity() {
hasAudioFilesPermission = { hasAudioFilesPermission() },
onOpenAccessibilitySettings = { openAccessibilitySettings() },
onOpenAppSettings = { openAppSettings() },
onStartService = { startFloatingService() },
onStartService = {
requestNotificationPermissionIfNeeded()
startFloatingService()
},
onStopService = { stopFloatingService() },
onStartAudioMonitor = { requestNotificationPermissionIfNeeded() },
onRestartService = { restartFloatingService() },
onPickFolder = { callback -> openFolderPicker(callback) },
onPickModelFolder = { callback ->
Expand Down Expand Up @@ -205,6 +209,20 @@ class SettingsActivity : ComponentActivity() {
}
}

private fun hasNotificationPermission(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED
} else {
true // Not needed before Android 13
}
}

private fun requestNotificationPermissionIfNeeded() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && !hasNotificationPermission()) {
requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}

private fun requestOverlayPermission() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
val intent = Intent(
Expand Down Expand Up @@ -294,6 +312,7 @@ fun SettingsScreen(
onStartService: () -> Unit,
onStopService: () -> Unit,
onRestartService: () -> Unit,
onStartAudioMonitor: () -> Unit,
onPickFolder: ((String) -> Unit) -> Unit,
onPickModelFolder: ((Uri) -> Unit) -> Unit,
isVoiceImeEnabled: () -> Boolean,
Expand Down Expand Up @@ -589,6 +608,7 @@ fun SettingsScreen(
enabled = hasAudioPermission.value,
onCheckedChange = { enabled ->
scope.launch {
if (enabled) onStartAudioMonitor()
settingsRepository.setAudioMonitorEnabled(enabled)
transcribeManager.setAudioMonitorEnabled(enabled)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,30 @@ class AudioMonitorService : Service() {
return START_NOT_STICKY
}

startForeground(SERVICE_NOTIFICATION_ID, createServiceNotification())
try {
startForeground(SERVICE_NOTIFICATION_ID, createServiceNotification())
// Dismiss any failure notification from previous boot attempt
getSystemService(android.app.NotificationManager::class.java)
?.cancel(TranslanderApp.SERVICE_ALERT_NOTIFICATION_ID)
} catch (e: android.app.ForegroundServiceStartNotAllowedException) {
// Android 14+ blocks dataSync FGS from BOOT_COMPLETED or background
Log.w(TAG, "Cannot start foreground service from boot/background", e)
showFailureNotification()
stopSelf()
return START_NOT_STICKY
} catch (e: SecurityException) {
// Missing permission for FGS type
Log.w(TAG, "Missing permission for foreground service", e)
showFailureNotification()
stopSelf()
return START_NOT_STICKY
} catch (e: IllegalStateException) {
// Race condition: app went to background before startForeground completed
Log.w(TAG, "Cannot start foreground service, app in background", e)
showFailureNotification()
stopSelf()
return START_NOT_STICKY
}
startMonitoring()

return START_STICKY
Expand Down Expand Up @@ -103,6 +126,10 @@ class AudioMonitorService : Service() {
}
}

private fun showFailureNotification() {
TranslanderApp.instance.showServiceStartNotification(R.string.service_start_folder_monitor)
}

private fun createServiceNotification(): Notification {
val stopIntent = Intent(this, AudioMonitorService::class.java).apply {
action = ACTION_STOP
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-bg/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
<!-- Service -->
<string name="service_notification_title">TranSlander е активен</string>
<string name="service_notification_text">Докоснете плаващия микрофон за транскрибиране на реч</string>
<string name="service_alert_channel_name">Сигнали за услуги</string>
<string name="service_alert_channel_description">Известия при неуспешно стартиране на услуги</string>
<string name="service_start_floating_mic">Натиснете, за да стартирате плаващия микрофон</string>
<string name="service_start_folder_monitor">Натиснете, за да стартирате наблюдението на папки</string>
<string name="accessibility_service_label">TranSlander Текстов вход</string>
<string name="accessibility_service_description">Позволява на TranSlander да вмъква транскрибиран текст директно в текстови полета. Активирайте за безпроблемно гласово въвеждане на текст.</string>

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-cs/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
<!-- Service -->
<string name="service_notification_title">TranSlander je aktivní</string>
<string name="service_notification_text">Klepněte na plovoucí mikrofon pro přepis řeči</string>
<string name="service_alert_channel_name">Upozornění služeb</string>
<string name="service_alert_channel_description">Oznámení při selhání spuštění služeb</string>
<string name="service_start_floating_mic">Klepněte pro spuštění plovoucího mikrofonu</string>
<string name="service_start_folder_monitor">Klepněte pro spuštění monitoru složek</string>
<string name="accessibility_service_label">TranSlander Textový vstup</string>
<string name="accessibility_service_description">Umožňuje TranSlander vkládat přepsaný text přímo do textových polí. Povolte pro plynulý hlasový vstup.</string>

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-da/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
<string name="settings_title">Indstillinger</string>
<string name="service_notification_title">TranSlander aktiv</string>
<string name="service_notification_text">Tryk på den flydende mikrofon for at transskribere tale</string>
<string name="service_alert_channel_name">Serviceadvarsler</string>
<string name="service_alert_channel_description">Notifikationer når tjenester ikke kan starte</string>
<string name="service_start_floating_mic">Tryk for at starte flydende mikrofon</string>
<string name="service_start_folder_monitor">Tryk for at starte mappeovervågning</string>
<string name="accessibility_service_label">TranSlander tekstinput</string>
<string name="accessibility_service_description">Giver TranSlander mulighed for at indsætte transskriberet tekst direkte i tekstfelter. Aktivér dette for problemfri tale-til-tekst-input.</string>
<string name="section_speech_model">Talemodel</string>
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-de/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
<!-- Service -->
<string name="service_notification_title">TranSlander aktiv</string>
<string name="service_notification_text">Auf das schwebende Mikrofon tippen, um Sprache zu transkribieren</string>
<string name="service_alert_channel_name">Dienstwarnungen</string>
<string name="service_alert_channel_description">Benachrichtigungen, wenn Dienste nicht starten können</string>
<string name="service_start_floating_mic">Tippen, um schwebendes Mikrofon zu starten</string>
<string name="service_start_folder_monitor">Tippen, um Ordnerüberwachung zu starten</string>
<string name="accessibility_service_label">TranSlander Texteingabe</string>
<string name="accessibility_service_description">Ermöglicht TranSlander, transkribierten Text direkt in Textfelder einzufügen. Für nahtlose Spracheingabe aktivieren.</string>

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-el/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
<string name="settings_title">Ρυθμίσεις</string>
<string name="service_notification_title">Το TranSlander είναι ενεργό</string>
<string name="service_notification_text">Πατήστε το αιωρούμενο μικρόφωνο για μεταγραφή ομιλίας</string>
<string name="service_alert_channel_name">Ειδοποιήσεις υπηρεσιών</string>
<string name="service_alert_channel_description">Ειδοποιήσεις όταν οι υπηρεσίες αποτυγχάνουν να ξεκινήσουν</string>
<string name="service_start_floating_mic">Πατήστε για εκκίνηση αιωρούμενου μικροφώνου</string>
<string name="service_start_folder_monitor">Πατήστε για εκκίνηση παρακολούθησης φακέλων</string>
<string name="accessibility_service_label">Εισαγωγή κειμένου TranSlander</string>
<string name="accessibility_service_description">Επιτρέπει στο TranSlander να εισάγει μεταγεγραμμένο κείμενο απευθείας σε πεδία κειμένου. Ενεργοποιήστε το για απρόσκοπτη εισαγωγή ομιλίας-σε-κείμενο.</string>
<string name="section_speech_model">Μοντέλο ομιλίας</string>
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-es/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,10 @@
<!-- Service -->
<string name="service_notification_title">TranSlander activo</string>
<string name="service_notification_text">Toca el micrófono flotante para transcribir voz</string>
<string name="service_alert_channel_name">Alertas de servicios</string>
<string name="service_alert_channel_description">Notificaciones cuando los servicios no pueden iniciarse</string>
<string name="service_start_floating_mic">Toca para iniciar el micrófono flotante</string>
<string name="service_start_folder_monitor">Toca para iniciar el monitor de carpetas</string>
<string name="accessibility_service_label">Entrada de texto TranSlander</string>
<string name="accessibility_service_description">Permite a TranSlander insertar texto transcrito directamente en campos de texto. Actívalo para una entrada de voz fluida.</string>

Expand Down
4 changes: 4 additions & 0 deletions app/src/main/res/values-et/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
<string name="settings_title">Seaded</string>
<string name="service_notification_title">TranSlander aktiivne</string>
<string name="service_notification_text">Puuduta hõljuvat mikrofoni kõne transkribeerimiseks</string>
<string name="service_alert_channel_name">Teenuse hoiatused</string>
<string name="service_alert_channel_description">Teavitused, kui teenused ei käivitu</string>
<string name="service_start_floating_mic">Puuduta hõljuva mikrofoni käivitamiseks</string>
<string name="service_start_folder_monitor">Puuduta kaustaseire käivitamiseks</string>
<string name="accessibility_service_label">TranSlander tekstisisestus</string>
<string name="accessibility_service_description">Lubab TranSlander\'il sisestada transkribeeritud teksti otse tekstiväljadele. Luba see sujuvaks kõnest tekstiks sisestuseks.</string>
<string name="section_speech_model">Kõnemudel</string>
Expand Down
Loading