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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,6 @@ Thumbs.db
# Logs
*.log
github-release.md

# Local Claude instructions (not checked in)
CLAUDE.local.md
34 changes: 31 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
```

Expand Down Expand Up @@ -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
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Monitors user-selected folders for voice messages..."/>
```

## Hotwords Feature (BLOCKED)

Sherpa-onnx supports hotwords boosting but **NOT for Parakeet TDT models**:
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
<activity
android:name=".transcribe.TranscribeActivity"
android:exported="true"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:theme="@style/Theme.VoiceKeyboard.Dialog">

<!-- Share intent from apps like WhatsApp -->
Expand Down
16 changes: 8 additions & 8 deletions app/src/main/java/com/translander/asr/AudioRecorder.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand All @@ -16,7 +17,7 @@ class AudioRecorder {
}

private var audioRecord: AudioRecord? = null
private var isRecording = false
private val isRecording = AtomicBoolean(false)
private val audioBuffer = mutableListOf<Short>()

private val bufferSize = AudioRecord.getMinBufferSize(
Expand All @@ -27,7 +28,7 @@ class AudioRecorder {

@SuppressLint("MissingPermission")
fun startRecording() {
if (isRecording) return
if (!isRecording.compareAndSet(false, true)) return

audioBuffer.clear()

Expand All @@ -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) {
Expand All @@ -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()
Expand All @@ -99,7 +99,7 @@ class AudioRecorder {
}

fun release() {
isRecording = false
isRecording.set(false)
try {
audioRecord?.stop()
} catch (e: IllegalStateException) {
Expand All @@ -110,5 +110,5 @@ class AudioRecorder {
audioBuffer.clear()
}

fun isRecording(): Boolean = isRecording
fun isRecording(): Boolean = isRecording.get()
}
42 changes: 34 additions & 8 deletions app/src/main/java/com/translander/asr/DictionaryManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<List<ReplacementRule>>(emptyList())
val rules: StateFlow<List<ReplacementRule>> = _rules

// Cached compiled regex patterns for performance
@Volatile
private var compiledRules: List<CompiledRule> = emptyList()

init {
// Load rules synchronously on init to ensure they're available immediately
loadRulesSync()
Expand Down Expand Up @@ -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<ReplacementRule>) {
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)
}
}

Expand Down Expand Up @@ -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'")
}
Expand All @@ -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'")
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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)")
Expand All @@ -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")
}
Expand Down
38 changes: 19 additions & 19 deletions app/src/main/java/com/translander/asr/ModelManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
Loading