Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
858d687
feat(android): add native batch audio writer for offline capture
mdmohsin7 Jun 16, 2026
0b21c65
feat(android): route BLE audio to batch writer and persist service in…
mdmohsin7 Jun 16, 2026
705e97a
feat(app): add batchModeEnabled preference
mdmohsin7 Jun 16, 2026
683d342
feat(app): gate realtime socket/WAL in batch mode and ingest local re…
mdmohsin7 Jun 16, 2026
3fd52aa
feat(app): add Offline Mode settings toggle
mdmohsin7 Jun 16, 2026
a5c6618
feat(l10n): add Offline Mode strings
mdmohsin7 Jun 16, 2026
7f78373
chore(l10n): regenerate localizations for Offline Mode
mdmohsin7 Jun 16, 2026
70f184c
feat(app): add BatchRecordingInfo filename parser util
mdmohsin7 Jun 17, 2026
94da20f
test(app): unit tests for BatchRecordingInfo filename parsing
mdmohsin7 Jun 17, 2026
d565ac2
refactor(app): use BatchRecordingInfo for ingestion; drop journal skip
mdmohsin7 Jun 17, 2026
5bf460b
refactor(android): atomic .bin.part finalize + crash recovery for bat…
mdmohsin7 Jun 17, 2026
360189f
feat(ios): native batch audio writer for offline capture
mdmohsin7 Jun 17, 2026
2784ebb
feat(ios): store BLE audio natively in batch mode (skip Dart forward)
mdmohsin7 Jun 17, 2026
68135aa
chore(ios): add BatchAudioWriter.swift to the Runner target
mdmohsin7 Jun 17, 2026
fc46e53
feat(app): enable Offline Mode toggle on iOS + low-storage warning
mdmohsin7 Jun 17, 2026
26f4342
feat(l10n): add offline-mode low-storage warning string
mdmohsin7 Jun 17, 2026
8397ef1
chore(l10n): regenerate localizations
mdmohsin7 Jun 17, 2026
ef64817
feat(app): add RecordingListItem for unsynced recordings
mdmohsin7 Jun 17, 2026
d10b0f4
feat(app): interleave recordings with conversations in date groups
mdmohsin7 Jun 17, 2026
112f8c8
feat(app): show unsynced recordings inline on the conversations page
mdmohsin7 Jun 17, 2026
be803fe
feat(app): ingest batch recordings on conversations page load
mdmohsin7 Jun 17, 2026
735dffa
feat(app): ingest batch recordings on app resume
mdmohsin7 Jun 17, 2026
74b6173
feat(app): add batchRecordingDevice marker for offline recordings
mdmohsin7 Jun 17, 2026
7c0d968
fix(app): tag batch recordings with marker + re-ingest on BLE disconnect
mdmohsin7 Jun 17, 2026
cf8c717
fix(app): show only batch recordings in the conversations list
mdmohsin7 Jun 17, 2026
e7d5fc3
fix(app): unique recording row key via filePath
mdmohsin7 Jun 17, 2026
cef09b4
fix(app): unique Dismissible key for recording rows
mdmohsin7 Jun 17, 2026
18e0a58
fix(android): finalize batch recording on BLE disconnect
mdmohsin7 Jun 17, 2026
47194ac
fix(ios): finalize batch recording on BLE disconnect
mdmohsin7 Jun 17, 2026
ded4e2a
fix(app): re-ingest batch recordings on BLE disconnect (not network c…
mdmohsin7 Jun 17, 2026
c6fbd1d
chore(app): batch-mode diagnostics (config-saved + ingest-scan logs);…
mdmohsin7 Jun 17, 2026
0bf3382
chore(ios): one-shot batch-writer diagnostics (no-config / matched-ch…
mdmohsin7 Jun 17, 2026
8917e31
feat(app): add LocalRecording model for batch/offline-mode recordings
mdmohsin7 Jun 17, 2026
c411d1d
feat(app): LocalRecordingsProvider — scan/upload/reconcile local reco…
mdmohsin7 Jun 17, 2026
bc349bf
feat(app): recording detail page (playback + transcribe + share/delet…
mdmohsin7 Jun 17, 2026
830db50
refactor(app): recording list item uses LocalRecording + LocalRecordi…
mdmohsin7 Jun 17, 2026
2e3067d
refactor(app): conversations group widget takes List<LocalRecording>
mdmohsin7 Jun 17, 2026
0d2876c
refactor(app): conversations page reads LocalRecordingsProvider.recor…
mdmohsin7 Jun 17, 2026
b304ce6
refactor(app): drop WAL ingestion of batch recordings (keep native co…
mdmohsin7 Jun 17, 2026
686b55f
refactor(app): refresh LocalRecordingsProvider on device disconnect
mdmohsin7 Jun 17, 2026
eac2498
refactor(app): refresh LocalRecordingsProvider on app resume
mdmohsin7 Jun 17, 2026
3145681
feat(app): register LocalRecordingsProvider in provider tree
mdmohsin7 Jun 17, 2026
c3cbde4
fix(ios): tag batch recordings with omibatch marker to separate them …
mdmohsin7 Jun 17, 2026
2a2c9a6
chore(app): remove local-recordings debug prints
mdmohsin7 Jun 17, 2026
bf2aab2
fix(android): tag batch recordings with omibatch marker to separate t…
mdmohsin7 Jun 17, 2026
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
345 changes: 345 additions & 0 deletions app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,345 @@
package com.friend.ios

import android.content.Context
import android.util.Log
import org.json.JSONObject
import java.io.File
import java.io.RandomAccessFile
import java.util.Locale

/**
* Batch (offline) capture sink. When the user enables "batch mode", incoming BLE
* audio is NOT streamed to the transcription socket — instead each opus/pcm frame
* is appended to a local `.bin` file with the exact length-prefixed layout the
* Omi offline-sync backend (`POST /v2/sync-local-files`) expects:
*
* [4-byte little-endian uint32 frame_length][frame bytes] ... repeated
*
* Files are named `audio_omibatch_{codec}_{sampleRate}_{channel}_fs{frameSize}_{startSec}.bin`
* (the `omibatch` device marker distinguishes these from offline-sync WAL files,
* which share this directory; the backend treats the device segment as a label.)
* so the backend can parse the codec, frame size and start timestamp. Flutter later
* scans the directory, registers each finalized file as a WAL and uploads it.
*
* This runs entirely native (called from [OmiBleForegroundService]'s characteristic
* listener) so the Flutter engine does no per-packet work while the app is
* minimized/closed. It self-gates on the `batchModeEnabled` pref and is mutually
* exclusive with [OmiBackgroundAudioStreamer] (which is disabled in batch mode via
* `nativeBleStreamingEnabled=false`).
*
* Durability (improving on fieldy, which has none): frames are written with a
* periodic `fsync`, a `.batch_journal` names the file currently being appended so
* Flutter never ingests a half-written file, and a free-space guard stops writing
* (rather than crashing) when storage runs low.
*/
class OmiBatchAudioWriter(private val context: Context) {
companion object {
private const val TAG = "OmiBle.BatchWriter"
private const val FLUTTER_PREFS = "FlutterSharedPreferences"
private const val PART_SUFFIX = ".part" // active (still-being-written) files end .bin.part
private const val MAX_FILE_BYTES = 32L * 1024 * 1024 // ~32 MB per file
private const val MAX_FILE_SECONDS = 1800L // 30 min per file
private const val GAP_MS = 30_000L // close current file after this silence gap
private const val FSYNC_INTERVAL_MS = 2_000L
private const val MIN_FREE_BYTES = 200L * 1024 * 1024 // stop writing below 200 MB free
}

private data class Config(
val deviceId: String,
val codec: String,
val sampleRate: Int,
val serviceUuid: String,
val characteristicUuid: String,
val deviceType: String,
val dir: String,
)

private val lock = Any()
private var raf: RandomAccessFile? = null
private var currentFile: File? = null
private var currentStartSec: Long = 0
private var currentBytes: Long = 0
private var currentFrames: Long = 0
private var lastFrameMs: Long = 0
private var lastFsyncMs: Long = 0
private var storageFull = false
private var recovered = false

/** Audio target for this device if batch mode is on — used by the foreground
* service to subscribe to the audio characteristic when Flutter is dead. */
fun configuredAudioTargetFor(address: String): Pair<String, String>? {
val config = loadConfig() ?: return null
if (!config.deviceId.equals(address, ignoreCase = true)) return null
return config.serviceUuid to config.characteristicUuid
}

fun handleCharacteristic(address: String, serviceUuid: String, characteristicUuid: String, value: ByteArray) {
val config = loadConfig()
if (config == null) {
// Batch mode disabled — finalize any open file so it can be ingested.
closeCurrent("disabled")
return
}
if (!config.deviceId.equals(address, ignoreCase = true)) return
if (!matches(config, serviceUuid, characteristicUuid)) return

val frames = transformFrames(config.deviceType, value)
if (frames.isEmpty()) return

synchronized(lock) {
val now = System.currentTimeMillis()

// Gap finalize: a pause longer than GAP_MS starts a new file (so the
// backend places resumed audio as a separate conversation).
if (raf != null && lastFrameMs > 0 && now - lastFrameMs > GAP_MS) {
closeCurrentLocked("gap")
}
// Rotation: bound file size/duration (between packets, never mid-packet).
if (raf != null && (currentBytes >= MAX_FILE_BYTES || (now / 1000 - currentStartSec) >= MAX_FILE_SECONDS)) {
closeCurrentLocked("rotate")
}

ensureOpenLocked(config, now)
val out = raf ?: return // storage full or open failed — drop this packet

try {
for (frame in frames) {
writeFrameLocked(out, frame)
}
} catch (e: Exception) {
Log.e(TAG, "write failed: ${e.message}")
closeCurrentLocked("write_error")
return
}

lastFrameMs = now
if (now - lastFsyncMs >= FSYNC_INTERVAL_MS) {
fsyncLocked()
lastFsyncMs = now
}
}
}

/** Finalize + fsync the current file (e.g. on service destroy). */
fun stop(reason: String) {
synchronized(lock) { closeCurrentLocked(reason) }
}

private fun closeCurrent(reason: String) {
synchronized(lock) { closeCurrentLocked(reason) }
}

// ── File lifecycle (caller holds lock) ──

private fun ensureOpenLocked(config: Config, nowMs: Long) {
if (raf != null) return

val dir = File(config.dir)
if (!dir.exists() && !dir.mkdirs()) {
Log.e(TAG, "cannot create batch dir ${config.dir}")
return
}
// Recover from a previous process that died mid-write: any leftover .bin.part
// is a finalized-by-crash orphan — promote it to .bin so it becomes ingestable.
if (!recovered) {
recovered = true
recoverStalePartFiles(dir)
}
if (dir.usableSpace < MIN_FREE_BYTES) {
if (!storageFull) {
Log.w(TAG, "storage low (${dir.usableSpace} bytes free) — pausing batch capture")
setStorageFullFlag(true)
storageFull = true
}
return
}
if (storageFull) {
storageFull = false
setStorageFullFlag(false)
}

val startSec = nowMs / 1000
val frameSize = if (config.codec == "opus_fs320") 320 else 160
// Tag the device segment as `omibatch` so the Dart scanner can tell batch
// recordings apart from offline-sync WAL flushes, which share this directory
// and the same audio_*.bin naming. The backend ignores this segment; keep it
// in sync with Dart's `batchRecordingDevice`.
// Write to a .bin.part file while active; rename to .bin only once finalized so
// Flutter (which scans *.bin) never picks up a half-written file.
val name = "audio_omibatch_${config.codec}_${config.sampleRate}_1_fs${frameSize}_${startSec}.bin$PART_SUFFIX"
val file = File(dir, name)

try {
val out = RandomAccessFile(file, "rw")
out.seek(out.length()) // append-safe (same-second restart reuses the file)
raf = out
currentFile = file
currentStartSec = startSec
currentBytes = file.length()
currentFrames = 0
lastFsyncMs = nowMs
Log.i(TAG, "opened batch file $name")
} catch (e: Exception) {
Log.e(TAG, "open failed for $name: ${e.message}")
raf = null
currentFile = null
}
}

private fun writeFrameLocked(out: RandomAccessFile, frame: ByteArray) {
val len = frame.size
val header = byteArrayOf(
(len and 0xFF).toByte(),
((len shr 8) and 0xFF).toByte(),
((len shr 16) and 0xFF).toByte(),
((len shr 24) and 0xFF).toByte(),
)
out.write(header)
out.write(frame)
currentBytes += 4 + len
currentFrames++
}

private fun fsyncLocked() {
try {
raf?.fd?.sync()
} catch (e: Exception) {
Log.w(TAG, "fsync failed: ${e.message}")
}
}

private fun closeCurrentLocked(reason: String) {
val out = raf ?: return
val partFile = currentFile
try {
out.fd.sync()
} catch (_: Exception) {
}
try {
out.close()
} catch (_: Exception) {
}
if (partFile != null) {
if (currentBytes > 0) {
// Atomically promote .bin.part -> .bin so it becomes ingestable.
val finalFile = File(partFile.parentFile, partFile.name.removeSuffix(PART_SUFFIX))
val renamed = partFile.renameTo(finalFile)
if (!renamed) Log.w(TAG, "failed to finalize ${partFile.name}")
Log.i(TAG, "finalized ${finalFile.name} ($currentFrames frames, $currentBytes bytes, reason=$reason)")
} else {
partFile.delete() // nothing written — drop the empty placeholder
}
}
raf = null
currentFile = null
currentStartSec = 0
currentBytes = 0
currentFrames = 0
lastFrameMs = 0
}

// ── Crash recovery ──

/** Promote any leftover `*.bin.part` from a previous (crashed) process to `.bin`
* so finalized-by-crash recordings are not lost. Empty placeholders are deleted. */
private fun recoverStalePartFiles(dir: File) {
try {
val parts = dir.listFiles { f ->
f.isFile && f.name.startsWith("audio_") && f.name.endsWith(".bin$PART_SUFFIX")
} ?: return
for (p in parts) {
if (p.length() > 0L) {
val finalFile = File(dir, p.name.removeSuffix(PART_SUFFIX))
if (p.renameTo(finalFile)) Log.i(TAG, "recovered stale batch file -> ${finalFile.name}")
} else {
p.delete()
}
}
} catch (e: Exception) {
Log.w(TAG, "recoverStalePartFiles failed: ${e.message}")
}
}

// ── Frame extraction (mirrors OmiBackgroundAudioStreamer.transformFrames) ──

private fun transformFrames(deviceType: String, value: ByteArray): List<ByteArray> =
when (deviceType) {
"omi", "openglass" -> if (value.size <= 3) emptyList() else listOf(value.copyOfRange(3, value.size))
"friendPendant" -> {
if (value.size <= 5) {
emptyList()
} else {
val payload = value.copyOfRange(0, value.size - 5)
val frames = mutableListOf<ByteArray>()
var offset = 0
while (offset + 30 <= payload.size) {
frames.add(payload.copyOfRange(offset, offset + 30))
offset += 30
}
frames
}
}
else -> {
Log.w(TAG, "unsupported batch device type: $deviceType")
emptyList()
}
}

private fun matches(config: Config, serviceUuid: String, characteristicUuid: String): Boolean =
config.serviceUuid.equals(serviceUuid, ignoreCase = true) &&
config.characteristicUuid.equals(characteristicUuid, ignoreCase = true)

// ── Config + prefs ──

private fun loadConfig(): Config? {
if (!boolPref("batchModeEnabled", false)) return null
val dir = stringPref("batchAudioDir")
if (dir.isEmpty()) return null
val raw = stringPref("nativeBleStreamConfig")
if (raw.isEmpty()) return null
return try {
val json = JSONObject(raw)
val deviceId = json.optString("deviceId")
val serviceUuid = json.optString("serviceUuid").lowercase(Locale.US)
val characteristicUuid = json.optString("characteristicUuid").lowercase(Locale.US)
if (deviceId.isEmpty() || serviceUuid.isEmpty() || characteristicUuid.isEmpty()) return null
Config(
deviceId = deviceId,
codec = json.optString("codec", "opus"),
sampleRate = json.optInt("sampleRate", 16000),
serviceUuid = serviceUuid,
characteristicUuid = characteristicUuid,
deviceType = json.optString("deviceType", "omi"),
dir = dir,
)
} catch (e: Exception) {
Log.w(TAG, "invalid batch config: ${e.message}")
null
}
}

private fun setStorageFullFlag(full: Boolean) {
try {
prefs().edit().putBoolean("flutter.batchStorageFull", full).apply()
} catch (_: Exception) {
}
}

private fun prefs() = context.getSharedPreferences(FLUTTER_PREFS, Context.MODE_PRIVATE)

private fun prefValue(key: String): Any? = prefs().all["flutter.$key"]

private fun stringPref(key: String, defaultValue: String = ""): String =
when (val value = prefValue(key)) {
is String -> value
null -> defaultValue
else -> value.toString()
}

private fun boolPref(key: String, defaultValue: Boolean): Boolean =
when (val value = prefValue(key)) {
is Boolean -> value
is String -> value.toBooleanStrictOrNull() ?: defaultValue
else -> defaultValue
}
}
Loading
Loading