From 858d687b65caef7c296b9d09a7710681a143fb73 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 01:56:43 +0530 Subject: [PATCH 01/45] feat(android): add native batch audio writer for offline capture Appends incoming BLE audio frames to length-prefixed .bin files in the app documents directory (the format the offline sync pipeline accepts) instead of streaming to the transcription socket. Includes size/duration rotation, gap-based finalize, periodic fsync, a journal so partially written files are never ingested, and a free-space guard. --- .../com/friend/ios/OmiBatchAudioWriter.kt | 318 ++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt diff --git a/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt b/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt new file mode 100644 index 0000000000..60c5206e9f --- /dev/null +++ b/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt @@ -0,0 +1,318 @@ +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_{device}_{codec}_{sampleRate}_{channel}_fs{frameSize}_{startSec}.bin` + * 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 JOURNAL_NAME = ".batch_journal" + 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 + + /** 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? { + 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 + } + 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 + val deviceToken = config.deviceType.lowercase(Locale.US).filter { it.isLetterOrDigit() }.ifEmpty { "omi" } + val name = "audio_${deviceToken}_${config.codec}_${config.sampleRate}_1_fs${frameSize}_${startSec}.bin" + 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 + writeJournal(dir, name) + 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 + try { + out.fd.sync() + } catch (_: Exception) { + } + try { + out.close() + } catch (_: Exception) { + } + Log.i(TAG, "closed batch file ${currentFile?.name} ($currentFrames frames, $currentBytes bytes, reason=$reason)") + raf = null + currentFile = null + currentStartSec = 0 + currentBytes = 0 + currentFrames = 0 + lastFrameMs = 0 + clearJournal() + } + + // ── Journal (so Flutter never ingests the file being written) ── + + private fun writeJournal(dir: File, filename: String) { + try { + File(dir, JOURNAL_NAME).writeText(filename) + } catch (e: Exception) { + Log.w(TAG, "journal write failed: ${e.message}") + } + } + + private fun clearJournal() { + try { + val dir = currentFile?.parentFile ?: loadConfig()?.dir?.let { File(it) } + if (dir != null) File(dir, JOURNAL_NAME).delete() + } catch (_: Exception) { + } + } + + // ── Frame extraction (mirrors OmiBackgroundAudioStreamer.transformFrames) ── + + private fun transformFrames(deviceType: String, value: ByteArray): List = + 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() + 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 + } +} From 0b21c654df504441ab25ac5382f12671c40a9c23 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 01:56:43 +0530 Subject: [PATCH 02/45] feat(android): route BLE audio to batch writer and persist service in batch mode Dispatches each audio packet to the batch writer alongside the background streamer (the two self-gate via prefs), subscribes to the audio characteristic when the engine is gone, finalizes the file on destroy, and keeps the foreground service sticky whenever batch mode is on. --- .../com/friend/ios/OmiBleForegroundService.kt | 33 +++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt b/app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt index 89f7909ea3..eaa9e2bb66 100644 --- a/app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt +++ b/app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt @@ -79,6 +79,17 @@ class OmiBleForegroundService : Service() { context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) .getBoolean("flutter.backgroundModeEnabled", false) + /** Batch (offline) capture opt-in. Like background mode, it keeps the BLE + * foreground service alive past app close so native can keep storing audio. */ + fun isBatchModeEnabled(context: Context): Boolean = + context.getSharedPreferences("FlutterSharedPreferences", Context.MODE_PRIVATE) + .getBoolean("flutter.batchModeEnabled", false) + + /** True when the service must persist past app close (sticky) — either for + * background streaming or batch capture. */ + fun isPersistentModeEnabled(context: Context): Boolean = + isBackgroundModeEnabled(context) || isBatchModeEnabled(context) + fun startService(context: Context, deviceAddress: String, requiresBond: Boolean = false, caller: String = "unknown") { if (caller.startsWith("CompanionSvc")) { val now = System.currentTimeMillis() @@ -149,6 +160,7 @@ class OmiBleForegroundService : Service() { private val syncLock = Any() private val bleManager get() = OmiBleManager.instance private val backgroundAudioStreamer by lazy { OmiBackgroundAudioStreamer(applicationContext) } + private val batchAudioWriter by lazy { OmiBatchAudioWriter(applicationContext) } // ── Connection listener — receives GATT events from OmiBleManager ── @@ -258,7 +270,11 @@ class OmiBleForegroundService : Service() { private fun ensureBackgroundAudioSubscription(address: String, services: List) { if (OmiBleManager.isFlutterAlive) return - val target = backgroundAudioStreamer.configuredAudioTargetFor(address) ?: return + // Subscribe for background streaming OR batch capture (whichever is configured) + // so native keeps receiving audio after the Flutter engine is gone. + val target = backgroundAudioStreamer.configuredAudioTargetFor(address) + ?: batchAudioWriter.configuredAudioTargetFor(address) + ?: return val hasTarget = services.any { service -> service.uuid.equals(target.first, ignoreCase = true) && service.characteristicUuids.any { it.equals(target.second, ignoreCase = true) } @@ -633,6 +649,9 @@ class OmiBleForegroundService : Service() { characteristicUuid: String, value: ByteArray ) { + // Batch mode and background streaming are mutually exclusive (gated by + // their respective prefs); calling both is safe — each self-gates. + batchAudioWriter.handleCharacteristic(address, serviceUuid, characteristicUuid, value) backgroundAudioStreamer.handleCharacteristic(address, serviceUuid, characteristicUuid, value) } } @@ -643,12 +662,13 @@ class OmiBleForegroundService : Service() { startForeground(NOTIFICATION_ID, buildNotification("Connecting to Omi...")) val backgroundMode = isBackgroundModeEnabled(this) + val persistentMode = isPersistentModeEnabled(this) val address = intent?.getStringExtra("device_address") val requiresBond = intent?.getBooleanExtra("requires_bond", false) ?: false if (address != null) { manageDevice(address, requiresBond) - } else if (backgroundMode) { + } else if (persistentMode) { // Restart after process death (sticky): restore the device we were managing. val saved = getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(PREFS_KEY, null) val parts = saved?.split("|") @@ -664,13 +684,13 @@ class OmiBleForegroundService : Service() { stopSelf() } - // Background mode off ⇒ non-sticky, so the OS won't resurrect the service after the app is closed. - return if (backgroundMode) START_STICKY else START_NOT_STICKY + // Persistent mode off ⇒ non-sticky, so the OS won't resurrect the service after the app is closed. + return if (persistentMode) START_STICKY else START_NOT_STICKY } override fun onTaskRemoved(rootIntent: Intent?) { - if (isBackgroundModeEnabled(this)) { - Log.i(TAG, "Task removed; keeping BLE foreground service alive (background mode on)") + if (isPersistentModeEnabled(this)) { + Log.i(TAG, "Task removed; keeping BLE foreground service alive (background/batch mode on)") } else { Log.i(TAG, "Task removed; stopping BLE foreground service (background mode off)") stopSelf() @@ -682,6 +702,7 @@ class OmiBleForegroundService : Service() { Log.d(TAG, "Service destroying") isDestroying = true backgroundAudioStreamer.stop("service_destroyed") + batchAudioWriter.stop("service_destroyed") for ((addr, managed) in managedDevices) { managed.pendingReconnect?.let { handler.removeCallbacks(it) } From 705e97a6f98f48f198d044f6b6ed5d206beddf59 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 01:56:43 +0530 Subject: [PATCH 03/45] feat(app): add batchModeEnabled preference --- app/lib/backend/preferences.dart | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/lib/backend/preferences.dart b/app/lib/backend/preferences.dart index 1f3e5ec9ce..4b7bd23d5e 100644 --- a/app/lib/backend/preferences.dart +++ b/app/lib/backend/preferences.dart @@ -62,6 +62,13 @@ class SharedPreferencesUtil { set backgroundModeEnabled(bool value) => saveBool('backgroundModeEnabled', value); + // Batch (offline) capture mode: when on, BLE audio is stored to local .bin files + // by the native layer instead of being transcribed in real time. Mutually + // exclusive with the realtime transcription socket (see CaptureProvider). + bool get batchModeEnabled => getBool('batchModeEnabled'); + + set batchModeEnabled(bool value) => saveBool('batchModeEnabled', value); + // Double tap behavior: 0 = end conversation (default), 1 = pause/mute, 2 = star ongoing conversation int get doubleTapAction => getInt('doubleTapAction'); From 683d34236352b913f8003f7aefca62f96e711e6d Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 01:56:44 +0530 Subject: [PATCH 04/45] feat(app): gate realtime socket/WAL in batch mode and ingest local recordings In batch mode the transcription websocket is never opened and the Dart WAL writer stays off (native owns writing). Finalized .bin files are registered into the existing WAL upload/reconcile pipeline via addExternalWal. --- app/lib/providers/capture_provider.dart | 127 +++++++++++++++++++++++- 1 file changed, 123 insertions(+), 4 deletions(-) diff --git a/app/lib/providers/capture_provider.dart b/app/lib/providers/capture_provider.dart index 0ec05d6bc2..acf3033cc6 100644 --- a/app/lib/providers/capture_provider.dart +++ b/app/lib/providers/capture_provider.dart @@ -523,6 +523,14 @@ class CaptureProvider extends ChangeNotifier }) async { Logger.debug('initiateWebsocket in capture_provider'); + // Batch (offline) mode: never open the realtime transcription socket. The + // native layer stores incoming BLE audio to local .bin files instead, and + // the user uploads recordings later. See _saveNativeBleStreamConfig. + if (SharedPreferencesUtil().batchModeEnabled) { + Logger.debug('Batch mode enabled — skipping transcription websocket'); + return; + } + BleAudioCodec codec = audioCodec; sampleRate ??= mapCodecToSampleRate(codec); channels ??= (codec == BleAudioCodec.pcm16 || codec == BleAudioCodec.pcm8) ? 1 : 2; @@ -765,9 +773,10 @@ class CaptureProvider extends ChangeNotifier _commandBytes.add(payload); } - // Local storage syncs - var checkWalSupported = (_recordingDevice?.type == DeviceType.omi || - _recordingDevice?.type == DeviceType.openglass) && + // Local storage syncs. In batch mode the native layer owns writing the + // .bin files, so the Dart WAL writer must stay off to avoid double-writes. + var checkWalSupported = !SharedPreferencesUtil().batchModeEnabled && + (_recordingDevice?.type == DeviceType.omi || _recordingDevice?.type == DeviceType.openglass) && codec.isOpusSupported() && (_socket?.state != SocketServiceState.connected || SharedPreferencesUtil().unlimitedLocalStorageEnabled); if (checkWalSupported != _isWalSupported) { @@ -902,6 +911,10 @@ class CaptureProvider extends ChangeNotifier await _wal.getSyncs().phone.onAudioCodecChanged(codec); await _saveNativeBleStreamConfig(device, codec); + // Batch mode: register any recordings the native layer wrote while the + // app was minimized/closed so they appear for upload. + await ingestBatchRecordings(); + // Create audio source for BLE device final pd = await device.getDeviceInfo(connection); final deviceModel = pd.modelNumber.isNotEmpty ? pd.modelNumber : "Omi"; @@ -942,8 +955,16 @@ class CaptureProvider extends ChangeNotifier 'deviceType': device.type.name, }), ); + // Batch (offline) capture: tell the native writer where to store .bin files + // and ensure the native realtime socket is disabled while batch mode is on + // (batch mode takes precedence over background streaming). + final batchMode = SharedPreferencesUtil().batchModeEnabled; + final docsDir = await getApplicationDocumentsDirectory(); + await SharedPreferencesUtil().saveString('batchAudioDir', docsDir.path); + await SharedPreferencesUtil().saveBool('nativeBleForegroundReady', false); - await SharedPreferencesUtil().saveBool('nativeBleStreamingEnabled', SharedPreferencesUtil().backgroundModeEnabled); + await SharedPreferencesUtil() + .saveBool('nativeBleStreamingEnabled', !batchMode && SharedPreferencesUtil().backgroundModeEnabled); } MapEntry? _nativeBleAudioTarget(BtDevice device) { @@ -963,6 +984,104 @@ class CaptureProvider extends ChangeNotifier } } + // ── Batch (offline) mode: ingest natively-written .bin files into the WAL ── + + /// Filenames already registered this session, to avoid re-parsing on each call. + final Set _ingestedBatchFiles = {}; + + /// Scan the batch-audio directory for finalized recordings written by the + /// native layer and register each as a [Wal] so the existing upload+reconcile + /// pipeline can sync it. Mirrors how [StorageSync] ingests device files via + /// [LocalWalSync.addExternalWal]. Idempotent — [addExternalWal] dedups by id. + Future ingestBatchRecordings() async { + if (!SharedPreferencesUtil().batchModeEnabled) return 0; + try { + final dirPath = SharedPreferencesUtil().getString('batchAudioDir'); + final dir = dirPath.isNotEmpty ? Directory(dirPath) : await getApplicationDocumentsDirectory(); + if (!await dir.exists()) return 0; + + // The file currently being appended by native — skip until it is finalized. + String activeFile = ''; + final journal = File('${dir.path}/.batch_journal'); + if (await journal.exists()) { + activeFile = (await journal.readAsString()).trim(); + } + + int added = 0; + for (final entry in dir.listSync()) { + if (entry is! File) continue; + final name = entry.path.split('/').last; + if (!name.startsWith('audio_') || !name.endsWith('.bin')) continue; + if (name == activeFile || _ingestedBatchFiles.contains(name)) continue; + + final wal = await _batchWalFromFile(entry, name); + if (wal == null) continue; + await _wal.getSyncs().phone.addExternalWal(wal); + _ingestedBatchFiles.add(name); + added++; + } + if (added > 0) { + Logger.debug('ingestBatchRecordings: registered $added batch recording(s)'); + notifyListeners(); + } + return added; + } catch (e) { + Logger.error('ingestBatchRecordings failed: $e'); + return 0; + } + } + + /// Build a [Wal] from a finalized batch `.bin` file. The filename encodes all + /// parameters: audio_{device}_{codec}_{sampleRate}_{channel}_fs{frameSize}_{timestamp}.bin + Future _batchWalFromFile(File file, String name) async { + try { + final base = name.substring(0, name.length - 4); // strip ".bin" + int timerStart = int.parse(base.split('_').last); + if (timerStart > 100000000000) timerStart ~/= 1000; // ms -> s + + final fsMatch = RegExp(r'_fs(\d+)').firstMatch(name); + final frameSize = fsMatch != null ? int.parse(fsMatch.group(1)!) : 160; + + BleAudioCodec codec; + if (name.contains('_pcm16_')) { + codec = BleAudioCodec.pcm16; + } else if (name.contains('_pcm8_')) { + codec = BleAudioCodec.pcm8; + } else { + codec = frameSize == 320 ? BleAudioCodec.opusFS320 : BleAudioCodec.opus; + } + + final sizeBytes = await file.length(); + if (sizeBytes <= 0) return null; + + // Rough duration for display/stats only — the backend recomputes the exact + // duration from the decoded WAV. ~16 kbps opus + 4-byte per-frame framing. + final int bytesPerSec = codec == BleAudioCodec.pcm16 + ? 32200 + : codec == BleAudioCodec.pcm8 + ? 16100 + : 2400; + final seconds = (sizeBytes / bytesPerSec).round().clamp(1, 24 * 3600); + + final deviceModel = SharedPreferencesUtil().deviceName.isNotEmpty ? SharedPreferencesUtil().deviceName : 'Omi'; + return Wal( + timerStart: timerStart, + codec: codec, + seconds: seconds, + sampleRate: 16000, + channel: 1, + status: WalStatus.miss, + storage: WalStorage.disk, + filePath: name, + device: 'omi', + deviceModel: deviceModel, + ); + } catch (e) { + Logger.error('_batchWalFromFile parse failed for $name: $e'); + return null; + } + } + Future _initiateDevicePhotoStreaming() async { if (_recordingDevice == null) return; final deviceId = _recordingDevice!.id; From 3fd52aa16b0b257402b19ad2fdfa065950583a4c Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 01:56:45 +0530 Subject: [PATCH 05/45] feat(app): add Offline Mode settings toggle --- app/lib/pages/settings/profile.dart | 102 ++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/app/lib/pages/settings/profile.dart b/app/lib/pages/settings/profile.dart index 18ce7e1017..7ca1ce977c 100644 --- a/app/lib/pages/settings/profile.dart +++ b/app/lib/pages/settings/profile.dart @@ -3,7 +3,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:omi/backend/preferences.dart'; +import 'package:omi/providers/capture_provider.dart'; import 'package:omi/pages/payments/payments_page.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:provider/provider.dart'; import 'package:omi/pages/settings/change_name_widget.dart'; import 'package:omi/pages/settings/language_settings_page.dart'; import 'package:omi/pages/settings/custom_vocabulary_page.dart'; @@ -315,6 +318,97 @@ class _ProfilePageState extends State { ); } + void _showOfflineModeSheet() { + final captureProvider = context.read(); + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1C1C1E), + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(16))), + builder: (sheetContext) { + return StatefulBuilder( + builder: (context, setSheetState) { + final enabled = SharedPreferencesUtil().batchModeEnabled; + Future setEnabled(bool value) async { + SharedPreferencesUtil().batchModeEnabled = value; + final docs = await getApplicationDocumentsDirectory(); + await SharedPreferencesUtil().saveString('batchAudioDir', docs.path); + // Batch capture takes precedence over background streaming. + await SharedPreferencesUtil() + .saveBool('nativeBleStreamingEnabled', !value && SharedPreferencesUtil().backgroundModeEnabled); + setSheetState(() {}); + setState(() {}); + // Re-apply capture so the transcription socket closes/opens to match. + try { + await captureProvider.onRecordProfileSettingChanged(); + } catch (_) {} + } + + return SafeArea( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 12, 20, 20), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + margin: const EdgeInsets.only(bottom: 16), + width: 36, + height: 4, + decoration: + BoxDecoration(color: const Color(0xFF3C3C43), borderRadius: BorderRadius.circular(2)), + ), + ), + Row( + children: [ + Expanded( + child: Text( + context.l10n.offlineModeTitle, + style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.w600), + ), + ), + Switch( + value: enabled, + activeThumbColor: Colors.white, + activeTrackColor: const Color(0xFF8B5CF6), + onChanged: (v) => setEnabled(v), + ), + ], + ), + const SizedBox(height: 8), + Text( + context.l10n.offlineModeDescription, + style: TextStyle(color: Colors.grey.shade400, fontSize: 14, height: 1.4), + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: + BoxDecoration(color: const Color(0xFF2A2A2E), borderRadius: BorderRadius.circular(12)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.info_outline, color: Colors.grey.shade400, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + context.l10n.offlineModeNote, + style: TextStyle(color: Colors.grey.shade400, fontSize: 13, height: 1.4), + ), + ), + ], + ), + ), + ], + ), + ), + ); + }, + ); + }, + ); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -418,6 +512,14 @@ class _ProfilePageState extends State { chipValue: SharedPreferencesUtil().backgroundModeEnabled ? context.l10n.on : context.l10n.off, onTap: _showBackgroundModeSheet, ), + const Divider(height: 1, color: Color(0xFF3C3C43)), + _buildProfileItem( + title: context.l10n.offlineModeTitle, + icon: const FaIcon(FontAwesomeIcons.floppyDisk, color: Color(0xFF8E8E93), size: 20), + showBetaTag: true, + chipValue: SharedPreferencesUtil().batchModeEnabled ? context.l10n.on : context.l10n.off, + onTap: _showOfflineModeSheet, + ), ], ], ), From a5c66183fd645a7cfbffac7b6ffd5cd9cb0bbd79 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 01:56:45 +0530 Subject: [PATCH 06/45] feat(l10n): add Offline Mode strings --- app/lib/l10n/app_en.arb | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 020385eb0b..930fc5d62b 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -11022,5 +11022,17 @@ "syncCustomSttWarningMessage": "You use your own transcription provider. Syncing these recordings transcribes them on Omi's servers instead, and they count toward your plan's transcription limit.", "@syncCustomSttWarningMessage": { "description": "Body warning that syncing transcribes on Omi servers and counts toward the plan limit" + }, + "offlineModeTitle": "Offline Mode", + "@offlineModeTitle": { + "description": "Title for the offline (batch) capture mode toggle in device settings" + }, + "offlineModeDescription": "Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.", + "@offlineModeDescription": { + "description": "Subtitle explaining the offline mode toggle" + }, + "offlineModeNote": "Works with Omi devices for now. Audio stays on your phone until you choose to upload it.", + "@offlineModeNote": { + "description": "Caveat note shown in the Offline Mode sheet" } } From 7f78373fcefb4f8b6c027c6f390a199b7f5c4436 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 01:56:45 +0530 Subject: [PATCH 07/45] chore(l10n): regenerate localizations for Offline Mode --- app/lib/l10n/app_localizations.dart | 18 ++++++++++++++++++ app/lib/l10n/app_localizations_ar.dart | 11 +++++++++++ app/lib/l10n/app_localizations_be.dart | 11 +++++++++++ app/lib/l10n/app_localizations_bg.dart | 11 +++++++++++ app/lib/l10n/app_localizations_bn.dart | 11 +++++++++++ app/lib/l10n/app_localizations_bs.dart | 11 +++++++++++ app/lib/l10n/app_localizations_ca.dart | 11 +++++++++++ app/lib/l10n/app_localizations_cs.dart | 11 +++++++++++ app/lib/l10n/app_localizations_da.dart | 11 +++++++++++ app/lib/l10n/app_localizations_de.dart | 11 +++++++++++ app/lib/l10n/app_localizations_el.dart | 11 +++++++++++ app/lib/l10n/app_localizations_en.dart | 11 +++++++++++ app/lib/l10n/app_localizations_es.dart | 11 +++++++++++ app/lib/l10n/app_localizations_et.dart | 11 +++++++++++ app/lib/l10n/app_localizations_fa.dart | 11 +++++++++++ app/lib/l10n/app_localizations_fi.dart | 11 +++++++++++ app/lib/l10n/app_localizations_fr.dart | 11 +++++++++++ app/lib/l10n/app_localizations_he.dart | 11 +++++++++++ app/lib/l10n/app_localizations_hi.dart | 11 +++++++++++ app/lib/l10n/app_localizations_hr.dart | 11 +++++++++++ app/lib/l10n/app_localizations_hu.dart | 11 +++++++++++ app/lib/l10n/app_localizations_id.dart | 11 +++++++++++ app/lib/l10n/app_localizations_it.dart | 11 +++++++++++ app/lib/l10n/app_localizations_ja.dart | 11 +++++++++++ app/lib/l10n/app_localizations_kn.dart | 11 +++++++++++ app/lib/l10n/app_localizations_ko.dart | 11 +++++++++++ app/lib/l10n/app_localizations_lt.dart | 11 +++++++++++ app/lib/l10n/app_localizations_lv.dart | 11 +++++++++++ app/lib/l10n/app_localizations_mk.dart | 11 +++++++++++ app/lib/l10n/app_localizations_mr.dart | 11 +++++++++++ app/lib/l10n/app_localizations_ms.dart | 11 +++++++++++ app/lib/l10n/app_localizations_nl.dart | 11 +++++++++++ app/lib/l10n/app_localizations_no.dart | 11 +++++++++++ app/lib/l10n/app_localizations_pl.dart | 11 +++++++++++ app/lib/l10n/app_localizations_pt.dart | 11 +++++++++++ app/lib/l10n/app_localizations_ro.dart | 11 +++++++++++ app/lib/l10n/app_localizations_ru.dart | 11 +++++++++++ app/lib/l10n/app_localizations_sk.dart | 11 +++++++++++ app/lib/l10n/app_localizations_sl.dart | 11 +++++++++++ app/lib/l10n/app_localizations_sr.dart | 11 +++++++++++ app/lib/l10n/app_localizations_sv.dart | 11 +++++++++++ app/lib/l10n/app_localizations_ta.dart | 11 +++++++++++ app/lib/l10n/app_localizations_te.dart | 11 +++++++++++ app/lib/l10n/app_localizations_th.dart | 11 +++++++++++ app/lib/l10n/app_localizations_tl.dart | 11 +++++++++++ app/lib/l10n/app_localizations_tr.dart | 11 +++++++++++ app/lib/l10n/app_localizations_uk.dart | 11 +++++++++++ app/lib/l10n/app_localizations_ur.dart | 11 +++++++++++ app/lib/l10n/app_localizations_vi.dart | 11 +++++++++++ app/lib/l10n/app_localizations_zh.dart | 11 +++++++++++ 50 files changed, 557 insertions(+) diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 53a87fc39d..09f5ee7df5 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -17378,6 +17378,24 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'You use your own transcription provider. Syncing these recordings transcribes them on Omi\'s servers instead, and they count toward your plan\'s transcription limit.'** String get syncCustomSttWarningMessage; + + /// Title for the offline (batch) capture mode toggle in device settings + /// + /// In en, this message translates to: + /// **'Offline Mode'** + String get offlineModeTitle; + + /// Subtitle explaining the offline mode toggle + /// + /// In en, this message translates to: + /// **'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'** + String get offlineModeDescription; + + /// Caveat note shown in the Offline Mode sheet + /// + /// In en, this message translates to: + /// **'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'** + String get offlineModeNote; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/app/lib/l10n/app_localizations_ar.dart b/app/lib/l10n/app_localizations_ar.dart index 2c81b768f5..9fb5272e01 100644 --- a/app/lib/l10n/app_localizations_ar.dart +++ b/app/lib/l10n/app_localizations_ar.dart @@ -9268,4 +9268,15 @@ class AppLocalizationsAr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'أنت تستخدم مزوّد النسخ الخاص بك. مزامنة هذه التسجيلات تنسخها على خوادم Omi بدلاً من ذلك، وتُحتسب ضمن حد النسخ في باقتك.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_be.dart b/app/lib/l10n/app_localizations_be.dart index fe707dc0c5..77e8bfea28 100644 --- a/app/lib/l10n/app_localizations_be.dart +++ b/app/lib/l10n/app_localizations_be.dart @@ -9351,4 +9351,15 @@ class AppLocalizationsBe extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Вы карыстаецеся ўласным сэрвісам транскрыпцыі. Сінхранізацыя гэтых запісаў транскрыбуе іх на серверах Omi, і яны залічацца ў ліміт транскрыпцыі вашага тарыфу.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_bg.dart b/app/lib/l10n/app_localizations_bg.dart index f21529ef4f..085ddc3ec1 100644 --- a/app/lib/l10n/app_localizations_bg.dart +++ b/app/lib/l10n/app_localizations_bg.dart @@ -9358,4 +9358,15 @@ class AppLocalizationsBg extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Използвате собствен доставчик за транскрипция. Синхронизирането на тези записи ги транскрибира на сървърите на Omi и те се отчитат към лимита за транскрипция на вашия план.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_bn.dart b/app/lib/l10n/app_localizations_bn.dart index 241e7c4894..76fc385c47 100644 --- a/app/lib/l10n/app_localizations_bn.dart +++ b/app/lib/l10n/app_localizations_bn.dart @@ -9330,4 +9330,15 @@ class AppLocalizationsBn extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'আপনি নিজের ট্রান্সক্রিপশন প্রদানকারী ব্যবহার করেন। এই রেকর্ডিংগুলি সিঙ্ক করলে সেগুলি Omi-এর সার্ভারে ট্রান্সক্রাইব হবে এবং আপনার প্ল্যানের ট্রান্সক্রিপশন সীমার মধ্যে গণনা হবে।'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_bs.dart b/app/lib/l10n/app_localizations_bs.dart index 0b9b741b0c..018bd63102 100644 --- a/app/lib/l10n/app_localizations_bs.dart +++ b/app/lib/l10n/app_localizations_bs.dart @@ -9348,4 +9348,15 @@ class AppLocalizationsBs extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Koristite vlastiti provajder transkripcije. Sinkronizacija ovih snimaka transkribuje ih na Omi serverima i broje se u limit transkripcije vašeg plana.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_ca.dart b/app/lib/l10n/app_localizations_ca.dart index c4d659f7d8..7dc0e16f4c 100644 --- a/app/lib/l10n/app_localizations_ca.dart +++ b/app/lib/l10n/app_localizations_ca.dart @@ -9377,4 +9377,15 @@ class AppLocalizationsCa extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Utilitzes el teu propi proveïdor de transcripció. Sincronitzar aquests enregistraments els transcriu als servidors d\'Omi i compten per al límit de transcripció del teu pla.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_cs.dart b/app/lib/l10n/app_localizations_cs.dart index 7ab6de174b..7b132c10aa 100644 --- a/app/lib/l10n/app_localizations_cs.dart +++ b/app/lib/l10n/app_localizations_cs.dart @@ -9323,4 +9323,15 @@ class AppLocalizationsCs extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Používáte vlastního poskytovatele přepisu. Synchronizace těchto nahrávek je přepíše na serverech Omi a započítají se do limitu přepisu vašeho tarifu.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_da.dart b/app/lib/l10n/app_localizations_da.dart index 49a05d077d..a72acad678 100644 --- a/app/lib/l10n/app_localizations_da.dart +++ b/app/lib/l10n/app_localizations_da.dart @@ -9308,4 +9308,15 @@ class AppLocalizationsDa extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Du bruger din egen transskriptionsudbyder. Synkronisering af disse optagelser transskriberer dem på Omis servere i stedet, og de tæller med i din plans transskriptionsgrænse.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index 51f4b8b0cb..a263f0fa6b 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -9400,4 +9400,15 @@ class AppLocalizationsDe extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Du verwendest einen eigenen Transkriptionsanbieter. Beim Synchronisieren werden diese Aufnahmen stattdessen auf den Servern von Omi transkribiert und auf das Transkriptionslimit deines Tarifs angerechnet.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_el.dart b/app/lib/l10n/app_localizations_el.dart index 036980170d..c69ad08f93 100644 --- a/app/lib/l10n/app_localizations_el.dart +++ b/app/lib/l10n/app_localizations_el.dart @@ -9389,4 +9389,15 @@ class AppLocalizationsEl extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Χρησιμοποιείτε δικό σας πάροχο μεταγραφής. Ο συγχρονισμός αυτών των ηχογραφήσεων τις μεταγράφει στους διακομιστές του Omi και προσμετρώνται στο όριο μεταγραφής του προγράμματός σας.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index 5ed0af50b0..d9ad0c14b5 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -9318,4 +9318,15 @@ class AppLocalizationsEn extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'You use your own transcription provider. Syncing these recordings transcribes them on Omi\'s servers instead, and they count toward your plan\'s transcription limit.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 9f49af58d8..8486ade23c 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -9344,4 +9344,15 @@ class AppLocalizationsEs extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Usas tu propio proveedor de transcripción. Sincronizar estas grabaciones las transcribe en los servidores de Omi y cuentan para el límite de transcripción de tu plan.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_et.dart b/app/lib/l10n/app_localizations_et.dart index 456452787d..3c269fff05 100644 --- a/app/lib/l10n/app_localizations_et.dart +++ b/app/lib/l10n/app_localizations_et.dart @@ -9320,4 +9320,15 @@ class AppLocalizationsEt extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Kasutate oma transkriptsiooniteenust. Nende salvestiste sünkroonimine transkribeerib need Omi serverites ja need arvestatakse teie paketi transkriptsioonilimiidi sisse.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_fa.dart b/app/lib/l10n/app_localizations_fa.dart index e99e3f4e96..b595584f98 100644 --- a/app/lib/l10n/app_localizations_fa.dart +++ b/app/lib/l10n/app_localizations_fa.dart @@ -9325,4 +9325,15 @@ class AppLocalizationsFa extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'شما از سرویس رونویسی خودتان استفاده می‌کنید. همگام‌سازی این ضبط‌ها آن‌ها را روی سرورهای Omi رونویسی می‌کند و در سقف رونویسی پلن شما محاسبه می‌شوند.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_fi.dart b/app/lib/l10n/app_localizations_fi.dart index 60f8133197..86f8fe132c 100644 --- a/app/lib/l10n/app_localizations_fi.dart +++ b/app/lib/l10n/app_localizations_fi.dart @@ -9323,4 +9323,15 @@ class AppLocalizationsFi extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Käytät omaa litterointipalveluasi. Näiden tallenteiden synkronointi litteroi ne Omin palvelimilla, ja ne lasketaan tilauksesi litterointirajaan.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_fr.dart b/app/lib/l10n/app_localizations_fr.dart index 3ce57438bf..57a528bb09 100644 --- a/app/lib/l10n/app_localizations_fr.dart +++ b/app/lib/l10n/app_localizations_fr.dart @@ -9407,4 +9407,15 @@ class AppLocalizationsFr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Vous utilisez votre propre fournisseur de transcription. Synchroniser ces enregistrements les transcrit sur les serveurs d\'Omi et ils comptent dans la limite de transcription de votre forfait.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_he.dart b/app/lib/l10n/app_localizations_he.dart index 04c649ed82..e53fd7f66b 100644 --- a/app/lib/l10n/app_localizations_he.dart +++ b/app/lib/l10n/app_localizations_he.dart @@ -9253,4 +9253,15 @@ class AppLocalizationsHe extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'אתה משתמש בספק תמלול משלך. סנכרון ההקלטות האלה יתמלל אותן בשרתי Omi והן ייכללו במגבלת התמלול של התוכנית שלך.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_hi.dart b/app/lib/l10n/app_localizations_hi.dart index 4b6ceeebad..d966372ae1 100644 --- a/app/lib/l10n/app_localizations_hi.dart +++ b/app/lib/l10n/app_localizations_hi.dart @@ -9300,4 +9300,15 @@ class AppLocalizationsHi extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'आप अपना स्वयं का ट्रांसक्रिप्शन प्रदाता उपयोग करते हैं। इन रिकॉर्डिंग को सिंक करने पर ये Omi के सर्वर पर ट्रांसक्राइब होंगी और आपकी योजना की ट्रांसक्रिप्शन सीमा में गिनी जाएंगी।'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_hr.dart b/app/lib/l10n/app_localizations_hr.dart index 9e83d2eebd..a0ee1c7472 100644 --- a/app/lib/l10n/app_localizations_hr.dart +++ b/app/lib/l10n/app_localizations_hr.dart @@ -9355,4 +9355,15 @@ class AppLocalizationsHr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Koristite vlastiti pružatelj transkripcije. Sinkronizacija ovih snimaka transkribira ih na Omijevim poslužiteljima i broje se u ograničenje transkripcije vašeg plana.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_hu.dart b/app/lib/l10n/app_localizations_hu.dart index 886f887304..47e31f6bcc 100644 --- a/app/lib/l10n/app_localizations_hu.dart +++ b/app/lib/l10n/app_localizations_hu.dart @@ -9363,4 +9363,15 @@ class AppLocalizationsHu extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Saját átírási szolgáltatót használsz. Ezek a felvételek szinkronizáláskor az Omi szerverein kerülnek átírásra, és beleszámítanak a csomagod átírási keretébe.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_id.dart b/app/lib/l10n/app_localizations_id.dart index 36e52d7fc9..2b4de932f9 100644 --- a/app/lib/l10n/app_localizations_id.dart +++ b/app/lib/l10n/app_localizations_id.dart @@ -9331,4 +9331,15 @@ class AppLocalizationsId extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Anda memakai penyedia transkripsi sendiri. Menyinkronkan rekaman ini akan mentranskripsikannya di server Omi dan dihitung dalam batas transkripsi paket Anda.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_it.dart b/app/lib/l10n/app_localizations_it.dart index 74f3b65f99..68cad960c9 100644 --- a/app/lib/l10n/app_localizations_it.dart +++ b/app/lib/l10n/app_localizations_it.dart @@ -9378,4 +9378,15 @@ class AppLocalizationsIt extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Usi un tuo fornitore di trascrizione. Sincronizzare queste registrazioni le trascrive sui server di Omi e contano per il limite di trascrizione del tuo piano.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 640050aeef..991f056212 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -9172,4 +9172,15 @@ class AppLocalizationsJa extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'ご自身の文字起こしプロバイダーを使用しています。これらの録音を同期すると Omi のサーバーで文字起こしされ、プランの文字起こし上限にカウントされます。'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_kn.dart b/app/lib/l10n/app_localizations_kn.dart index bedc2cb963..de9c71ddd6 100644 --- a/app/lib/l10n/app_localizations_kn.dart +++ b/app/lib/l10n/app_localizations_kn.dart @@ -9354,4 +9354,15 @@ class AppLocalizationsKn extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'ನೀವು ನಿಮ್ಮ ಸ್ವಂತ ಪ್ರತಿಲೇಖನ ಪೂರೈಕೆದಾರರನ್ನು ಬಳಸುತ್ತೀರಿ. ಈ ಧ್ವನಿಮುದ್ರಣಗಳನ್ನು ಸಿಂಕ್ ಮಾಡಿದರೆ ಅವು Omi ಸರ್ವರ್‌ಗಳಲ್ಲಿ ಪ್ರತಿಲೇಖನಗೊಳ್ಳುತ್ತವೆ ಮತ್ತು ನಿಮ್ಮ ಯೋಜನೆಯ ಪ್ರತಿಲೇಖನ ಮಿತಿಗೆ ಎಣಿಸಲಾಗುತ್ತದೆ.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 656b6ea6f3..aec528b3d0 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -9173,4 +9173,15 @@ class AppLocalizationsKo extends AppLocalizations { @override String get syncCustomSttWarningMessage => '직접 설정한 전사 제공자를 사용 중입니다. 이 녹음을 동기화하면 Omi 서버에서 전사되며 요금제의 전사 한도에 포함됩니다.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_lt.dart b/app/lib/l10n/app_localizations_lt.dart index 010d6626be..4bbe5aa0ff 100644 --- a/app/lib/l10n/app_localizations_lt.dart +++ b/app/lib/l10n/app_localizations_lt.dart @@ -9337,4 +9337,15 @@ class AppLocalizationsLt extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Naudojate savo transkripcijos paslaugą. Sinchronizuojant šiuos įrašus jie transkribuojami Omi serveriuose ir įskaičiuojami į jūsų plano transkripcijos limitą.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_lv.dart b/app/lib/l10n/app_localizations_lv.dart index 74918e0df4..8019cefb58 100644 --- a/app/lib/l10n/app_localizations_lv.dart +++ b/app/lib/l10n/app_localizations_lv.dart @@ -9345,4 +9345,15 @@ class AppLocalizationsLv extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Jūs izmantojat savu transkripcijas pakalpojumu. Šo ierakstu sinhronizēšana tos transkribē Omi serveros, un tie tiek ieskaitīti jūsu plāna transkripcijas limitā.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_mk.dart b/app/lib/l10n/app_localizations_mk.dart index a81a6062a1..95c1ab13b0 100644 --- a/app/lib/l10n/app_localizations_mk.dart +++ b/app/lib/l10n/app_localizations_mk.dart @@ -9372,4 +9372,15 @@ class AppLocalizationsMk extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Користите сопствен провајдер за транскрипција. Синхронизирањето на овие снимки ги транскрибира на серверите на Omi и се сметаат во лимитот за транскрипција на вашиот план.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_mr.dart b/app/lib/l10n/app_localizations_mr.dart index 17f9b59632..9fdc366a88 100644 --- a/app/lib/l10n/app_localizations_mr.dart +++ b/app/lib/l10n/app_localizations_mr.dart @@ -9333,4 +9333,15 @@ class AppLocalizationsMr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'तुम्ही तुमचा स्वतःचा ट्रान्सक्रिप्शन प्रदाता वापरता. ही रेकॉर्डिंग सिंक केल्यास ती Omi च्या सर्व्हरवर ट्रान्सक्राइब होतील आणि तुमच्या प्लॅनच्या ट्रान्सक्रिप्शन मर्यादेत मोजली जातील.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_ms.dart b/app/lib/l10n/app_localizations_ms.dart index 71bc3f4ad8..8e1e521f81 100644 --- a/app/lib/l10n/app_localizations_ms.dart +++ b/app/lib/l10n/app_localizations_ms.dart @@ -9346,4 +9346,15 @@ class AppLocalizationsMs extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Anda menggunakan penyedia transkripsi sendiri. Menyegerakkan rakaman ini akan mentranskripsikannya di pelayan Omi dan dikira dalam had transkripsi pelan anda.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_nl.dart b/app/lib/l10n/app_localizations_nl.dart index f8ac1f4544..ba5531271e 100644 --- a/app/lib/l10n/app_localizations_nl.dart +++ b/app/lib/l10n/app_localizations_nl.dart @@ -9349,4 +9349,15 @@ class AppLocalizationsNl extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Je gebruikt je eigen transcriptieprovider. Door deze opnames te synchroniseren worden ze op de servers van Omi getranscribeerd en tellen ze mee voor de transcriptielimiet van je abonnement.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_no.dart b/app/lib/l10n/app_localizations_no.dart index c1cef039ba..8ee1a6129a 100644 --- a/app/lib/l10n/app_localizations_no.dart +++ b/app/lib/l10n/app_localizations_no.dart @@ -9320,4 +9320,15 @@ class AppLocalizationsNo extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Du bruker din egen transkripsjonsleverandør. Synkronisering av disse opptakene transkriberer dem på Omis servere, og de teller mot transkripsjonsgrensen i abonnementet ditt.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_pl.dart b/app/lib/l10n/app_localizations_pl.dart index 37861d8f7b..d14b494b99 100644 --- a/app/lib/l10n/app_localizations_pl.dart +++ b/app/lib/l10n/app_localizations_pl.dart @@ -9348,4 +9348,15 @@ class AppLocalizationsPl extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Używasz własnego dostawcy transkrypcji. Synchronizacja tych nagrań transkrybuje je na serwerach Omi i są wliczane do limitu transkrypcji w Twoim planie.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_pt.dart b/app/lib/l10n/app_localizations_pt.dart index ea286fa49e..e119d6d080 100644 --- a/app/lib/l10n/app_localizations_pt.dart +++ b/app/lib/l10n/app_localizations_pt.dart @@ -9328,4 +9328,15 @@ class AppLocalizationsPt extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Você usa seu próprio provedor de transcrição. Sincronizar estas gravações as transcreve nos servidores da Omi e elas contam para o limite de transcrição do seu plano.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_ro.dart b/app/lib/l10n/app_localizations_ro.dart index cda35611da..d7d745c6e8 100644 --- a/app/lib/l10n/app_localizations_ro.dart +++ b/app/lib/l10n/app_localizations_ro.dart @@ -9368,4 +9368,15 @@ class AppLocalizationsRo extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Folosești propriul furnizor de transcriere. Sincronizarea acestor înregistrări le transcrie pe serverele Omi și se iau în calcul pentru limita de transcriere a planului tău.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_ru.dart b/app/lib/l10n/app_localizations_ru.dart index a376087a8b..5a7ffc8dfa 100644 --- a/app/lib/l10n/app_localizations_ru.dart +++ b/app/lib/l10n/app_localizations_ru.dart @@ -9356,4 +9356,15 @@ class AppLocalizationsRu extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Вы используете собственный сервис транскрипции. Синхронизация этих записей расшифрует их на серверах Omi, и они будут засчитаны в лимит транскрипции вашего тарифа.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_sk.dart b/app/lib/l10n/app_localizations_sk.dart index 52366229d5..aa4ea2d9f5 100644 --- a/app/lib/l10n/app_localizations_sk.dart +++ b/app/lib/l10n/app_localizations_sk.dart @@ -9315,4 +9315,15 @@ class AppLocalizationsSk extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Používate vlastného poskytovateľa prepisu. Synchronizácia týchto nahrávok ich prepíše na serveroch Omi a započítajú sa do limitu prepisu vášho plánu.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_sl.dart b/app/lib/l10n/app_localizations_sl.dart index a56d697c88..2e191f1438 100644 --- a/app/lib/l10n/app_localizations_sl.dart +++ b/app/lib/l10n/app_localizations_sl.dart @@ -9350,4 +9350,15 @@ class AppLocalizationsSl extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Uporabljate svojega ponudnika prepisa. Sinhronizacija teh posnetkov jih prepiše na strežnikih Omi in se štejejo v omejitev prepisa vašega paketa.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_sr.dart b/app/lib/l10n/app_localizations_sr.dart index c4ec734517..533155e4d2 100644 --- a/app/lib/l10n/app_localizations_sr.dart +++ b/app/lib/l10n/app_localizations_sr.dart @@ -9335,4 +9335,15 @@ class AppLocalizationsSr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Користите сопственог пружаоца транскрипције. Синхронизација ових снимака их транскрибује на Omi серверима и рачунају се у лимит транскрипције вашег плана.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_sv.dart b/app/lib/l10n/app_localizations_sv.dart index 7c54135193..1bc8655cd0 100644 --- a/app/lib/l10n/app_localizations_sv.dart +++ b/app/lib/l10n/app_localizations_sv.dart @@ -9328,4 +9328,15 @@ class AppLocalizationsSv extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Du använder din egen transkriberingsleverantör. Att synkronisera dessa inspelningar transkriberar dem på Omis servrar i stället, och de räknas mot din plans transkriberingsgräns.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_ta.dart b/app/lib/l10n/app_localizations_ta.dart index ba3c083a4c..c0bd3c53ef 100644 --- a/app/lib/l10n/app_localizations_ta.dart +++ b/app/lib/l10n/app_localizations_ta.dart @@ -9389,4 +9389,15 @@ class AppLocalizationsTa extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'நீங்கள் உங்கள் சொந்த படியெடுப்பு வழங்குநரைப் பயன்படுத்துகிறீர்கள். இந்தப் பதிவுகளை ஒத்திசைத்தால் அவை Omi சேவையகங்களில் படியெடுக்கப்படும், மேலும் உங்கள் திட்டத்தின் படியெடுப்பு வரம்பில் கணக்கிடப்படும்.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_te.dart b/app/lib/l10n/app_localizations_te.dart index e519f04f7a..f52537449b 100644 --- a/app/lib/l10n/app_localizations_te.dart +++ b/app/lib/l10n/app_localizations_te.dart @@ -9372,4 +9372,15 @@ class AppLocalizationsTe extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'మీరు మీ సొంత ట్రాన్స్‌క్రిప్షన్ ప్రొవైడర్‌ను ఉపయోగిస్తున్నారు. ఈ రికార్డింగ్‌లను సింక్ చేస్తే అవి Omi సర్వర్‌లలో ట్రాన్స్‌క్రైబ్ చేయబడతాయి మరియు మీ ప్లాన్ ట్రాన్స్‌క్రిప్షన్ పరిమితిలో లెక్కించబడతాయి.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_th.dart b/app/lib/l10n/app_localizations_th.dart index 1d71adf2cf..8de0f2c8d9 100644 --- a/app/lib/l10n/app_localizations_th.dart +++ b/app/lib/l10n/app_localizations_th.dart @@ -9273,4 +9273,15 @@ class AppLocalizationsTh extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'คุณใช้ผู้ให้บริการถอดเสียงของคุณเอง การซิงค์การบันทึกเหล่านี้จะถอดเสียงบนเซิร์ฟเวอร์ของ Omi และจะนับรวมในขีดจำกัดการถอดเสียงของแพ็กเกจของคุณ'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_tl.dart b/app/lib/l10n/app_localizations_tl.dart index 04a5cc1aeb..3dab45804b 100644 --- a/app/lib/l10n/app_localizations_tl.dart +++ b/app/lib/l10n/app_localizations_tl.dart @@ -9408,4 +9408,15 @@ class AppLocalizationsTl extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Gumagamit ka ng sarili mong transcription provider. Kapag in-sync ang mga recording na ito, ita-transcribe ang mga ito sa mga server ng Omi at mabibilang sa limitasyon ng transcription ng iyong plan.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_tr.dart b/app/lib/l10n/app_localizations_tr.dart index 6f104aeb7f..86ff7e7569 100644 --- a/app/lib/l10n/app_localizations_tr.dart +++ b/app/lib/l10n/app_localizations_tr.dart @@ -9334,4 +9334,15 @@ class AppLocalizationsTr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Kendi transkripsiyon sağlayıcınızı kullanıyorsunuz. Bu kayıtları eşitlemek onları Omi sunucularında yazıya döker ve planınızın transkripsiyon sınırına sayılır.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_uk.dart b/app/lib/l10n/app_localizations_uk.dart index a36c29b4b1..e07c123040 100644 --- a/app/lib/l10n/app_localizations_uk.dart +++ b/app/lib/l10n/app_localizations_uk.dart @@ -9341,4 +9341,15 @@ class AppLocalizationsUk extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Ви використовуєте власний сервіс транскрипції. Синхронізація цих записів розшифрує їх на серверах Omi, і вони зараховуються до ліміту транскрипції вашого тарифу.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_ur.dart b/app/lib/l10n/app_localizations_ur.dart index c0202d769f..6b324b1673 100644 --- a/app/lib/l10n/app_localizations_ur.dart +++ b/app/lib/l10n/app_localizations_ur.dart @@ -9337,4 +9337,15 @@ class AppLocalizationsUr extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'آپ اپنا ذاتی ٹرانسکرپشن فراہم کنندہ استعمال کرتے ہیں۔ ان ریکارڈنگز کو سنک کرنے پر یہ Omi کے سرورز پر ٹرانسکرائب ہوں گی اور آپ کے پلان کی ٹرانسکرپشن حد میں شمار ہوں گی۔'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_vi.dart b/app/lib/l10n/app_localizations_vi.dart index e905238c03..7d82ddc400 100644 --- a/app/lib/l10n/app_localizations_vi.dart +++ b/app/lib/l10n/app_localizations_vi.dart @@ -9321,4 +9321,15 @@ class AppLocalizationsVi extends AppLocalizations { @override String get syncCustomSttWarningMessage => 'Bạn đang dùng nhà cung cấp phiên âm riêng. Đồng bộ các bản ghi này sẽ phiên âm chúng trên máy chủ của Omi và được tính vào giới hạn phiên âm của gói của bạn.'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 1be7890fe9..3bdc834988 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -9157,4 +9157,15 @@ class AppLocalizationsZh extends AppLocalizations { @override String get syncCustomSttWarningMessage => '您使用的是自己的转录服务。同步这些录音会改为在 Omi 的服务器上转录,并计入您套餐的转录额度。'; + + @override + String get offlineModeTitle => 'Offline Mode'; + + @override + String get offlineModeDescription => + 'Save audio on your phone while you capture and transcribe later. There is no live transcription in this mode — recordings are stored locally, then you upload them to create conversations.'; + + @override + String get offlineModeNote => + 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; } From 70f184c37200facff3916f1167aacafe2b8b937d Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 15:56:14 +0530 Subject: [PATCH 08/45] feat(app): add BatchRecordingInfo filename parser util Pure, testable parser for batch recording filenames (codec, frame size, timestamp) mirroring how the offline-sync backend interprets the name. --- app/lib/utils/batch_recording.dart | 59 ++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 app/lib/utils/batch_recording.dart diff --git a/app/lib/utils/batch_recording.dart b/app/lib/utils/batch_recording.dart new file mode 100644 index 0000000000..437d3d11bd --- /dev/null +++ b/app/lib/utils/batch_recording.dart @@ -0,0 +1,59 @@ +import 'package:omi/backend/schema/bt_device/bt_device.dart'; + +/// Metadata parsed from a batch recording filename written by the native layer: +/// +/// audio_{device}_{codec}_{sampleRate}_{channel}_fs{frameSize}_{timestamp}.bin +/// +/// Mirrors how the backend `/v2/sync-local-files` pipeline interprets the name: +/// codec is detected from the `_pcm16_`/`_pcm8_` markers (otherwise opus), the +/// frame size from `_fs`, and the timestamp from the trailing segment +/// (milliseconds are normalized to seconds). Kept pure so it can be unit tested. +class BatchRecordingInfo { + /// Recording start time, unix seconds. + final int timerStart; + final BleAudioCodec codec; + final int frameSize; + + const BatchRecordingInfo({ + required this.timerStart, + required this.codec, + required this.frameSize, + }); + + /// Returns null if [name] is not a parseable, finalized batch `.bin` filename + /// (e.g. a `.bin.part` in-progress file, or an unrelated file). + static BatchRecordingInfo? fromFileName(String name) { + if (!name.startsWith('audio_') || !name.endsWith('.bin')) return null; + + final base = name.substring(0, name.length - 4); // strip ".bin" + final ts = int.tryParse(base.split('_').last); + if (ts == null) return null; + final timerStart = ts > 100000000000 ? ts ~/ 1000 : ts; // ms -> s + + final fsMatch = RegExp(r'_fs(\d+)').firstMatch(name); + final frameSize = fsMatch != null ? int.parse(fsMatch.group(1)!) : 160; + + final BleAudioCodec codec; + if (name.contains('_pcm16_')) { + codec = BleAudioCodec.pcm16; + } else if (name.contains('_pcm8_')) { + codec = BleAudioCodec.pcm8; + } else { + codec = frameSize == 320 ? BleAudioCodec.opusFS320 : BleAudioCodec.opus; + } + + return BatchRecordingInfo(timerStart: timerStart, codec: codec, frameSize: frameSize); + } + + /// Rough duration in seconds from file size — for display/stats only. The + /// backend recomputes the exact duration from the decoded audio. Accounts for + /// ~16 kbps opus plus the 4-byte per-frame length prefix, or raw PCM rates. + int estimateSeconds(int sizeBytes) { + final bytesPerSec = codec == BleAudioCodec.pcm16 + ? 32200 + : codec == BleAudioCodec.pcm8 + ? 16100 + : 2400; + return (sizeBytes / bytesPerSec).round().clamp(1, 24 * 3600); + } +} From 94da20ff387ae9c935b3b383c97241f8733b33be Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 15:56:15 +0530 Subject: [PATCH 09/45] test(app): unit tests for BatchRecordingInfo filename parsing --- app/test/unit/batch_recording_test.dart | 64 +++++++++++++++++++++++++ 1 file changed, 64 insertions(+) create mode 100644 app/test/unit/batch_recording_test.dart diff --git a/app/test/unit/batch_recording_test.dart b/app/test/unit/batch_recording_test.dart new file mode 100644 index 0000000000..0c2c2cb990 --- /dev/null +++ b/app/test/unit/batch_recording_test.dart @@ -0,0 +1,64 @@ +import 'package:flutter_test/flutter_test.dart'; + +import 'package:omi/backend/schema/bt_device/bt_device.dart'; +import 'package:omi/services/wals.dart'; +import 'package:omi/utils/batch_recording.dart'; + +void main() { + group('BatchRecordingInfo.fromFileName', () { + test('parses opus fs160', () { + final info = BatchRecordingInfo.fromFileName('audio_omi_opus_16000_1_fs160_1735689600.bin'); + expect(info, isNotNull); + expect(info!.codec, BleAudioCodec.opus); + expect(info.frameSize, 160); + expect(info.timerStart, 1735689600); + }); + + test('parses opus_fs320 (frame size disambiguates the codec)', () { + final info = BatchRecordingInfo.fromFileName('audio_omi_opus_fs320_16000_1_fs320_1735689600.bin'); + expect(info, isNotNull); + expect(info!.codec, BleAudioCodec.opusFS320); + expect(info.frameSize, 320); + expect(info.timerStart, 1735689600); + }); + + test('parses pcm16', () { + final info = BatchRecordingInfo.fromFileName('audio_omi_pcm16_16000_1_fs160_1735689600.bin'); + expect(info!.codec, BleAudioCodec.pcm16); + }); + + test('parses pcm8', () { + final info = BatchRecordingInfo.fromFileName('audio_omi_pcm8_8000_1_fs160_1735689600.bin'); + expect(info!.codec, BleAudioCodec.pcm8); + }); + + test('normalizes millisecond timestamps to seconds', () { + final info = BatchRecordingInfo.fromFileName('audio_omi_opus_16000_1_fs160_1735689600000.bin'); + expect(info!.timerStart, 1735689600); + }); + + test('rejects non-batch / in-progress / malformed files', () { + expect(BatchRecordingInfo.fromFileName('wals.json'), isNull); + // .bin.part is the in-progress file — must not be ingested + expect(BatchRecordingInfo.fromFileName('audio_omi_opus_16000_1_fs160_1735689600.bin.part'), isNull); + expect(BatchRecordingInfo.fromFileName('audio_omi_opus_16000_1_fs160_notanumber.bin'), isNull); + expect(BatchRecordingInfo.fromFileName('random_file.bin'), isNull); + }); + + test('round-trips with Wal.getFileName', () { + final wal = Wal(timerStart: 1735689600, codec: BleAudioCodec.opus, seconds: 60, device: 'omi'); + final info = BatchRecordingInfo.fromFileName(wal.getFileName()); + expect(info, isNotNull); + expect(info!.codec, BleAudioCodec.opus); + expect(info.timerStart, 1735689600); + expect(info.frameSize, wal.frameSize); + }); + + test('estimateSeconds is bounded and codec-aware', () { + final opus = BatchRecordingInfo.fromFileName('audio_omi_opus_16000_1_fs160_1735689600.bin')!; + // ~2400 B/s for opus -> 240000 bytes ~= 100s + expect(opus.estimateSeconds(240000), inInclusiveRange(90, 110)); + expect(opus.estimateSeconds(0), 1); // clamped to >= 1 + }); + }); +} From d565ac2351d85efcec061bcf293072b5fb957078 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 15:56:15 +0530 Subject: [PATCH 10/45] refactor(app): use BatchRecordingInfo for ingestion; drop journal skip Active files are now *.bin.part (excluded by the .bin scan), so the journal-based skip is no longer needed. --- app/lib/providers/capture_provider.dart | 46 ++++++------------------- 1 file changed, 10 insertions(+), 36 deletions(-) diff --git a/app/lib/providers/capture_provider.dart b/app/lib/providers/capture_provider.dart index acf3033cc6..ad6f59ca8f 100644 --- a/app/lib/providers/capture_provider.dart +++ b/app/lib/providers/capture_provider.dart @@ -41,6 +41,7 @@ import 'package:omi/services/audio_sources/ble_device_source.dart'; import 'package:omi/services/devices/models.dart'; import 'package:omi/services/audio_sources/phone_mic_source.dart'; import 'package:omi/services/wals.dart'; +import 'package:omi/utils/batch_recording.dart'; import 'package:omi/utils/alerts/app_snackbar.dart'; import 'package:omi/utils/enums.dart'; import 'package:omi/utils/image/image_utils.dart'; @@ -1000,19 +1001,14 @@ class CaptureProvider extends ChangeNotifier final dir = dirPath.isNotEmpty ? Directory(dirPath) : await getApplicationDocumentsDirectory(); if (!await dir.exists()) return 0; - // The file currently being appended by native — skip until it is finalized. - String activeFile = ''; - final journal = File('${dir.path}/.batch_journal'); - if (await journal.exists()) { - activeFile = (await journal.readAsString()).trim(); - } - int added = 0; for (final entry in dir.listSync()) { if (entry is! File) continue; final name = entry.path.split('/').last; + // Only finalized recordings (audio_*.bin). Files still being written are + // *.bin.part and are atomically promoted to *.bin by the native writer. if (!name.startsWith('audio_') || !name.endsWith('.bin')) continue; - if (name == activeFile || _ingestedBatchFiles.contains(name)) continue; + if (_ingestedBatchFiles.contains(name)) continue; final wal = await _batchWalFromFile(entry, name); if (wal == null) continue; @@ -1032,42 +1028,20 @@ class CaptureProvider extends ChangeNotifier } /// Build a [Wal] from a finalized batch `.bin` file. The filename encodes all - /// parameters: audio_{device}_{codec}_{sampleRate}_{channel}_fs{frameSize}_{timestamp}.bin + /// parameters (see [BatchRecordingInfo]). Future _batchWalFromFile(File file, String name) async { try { - final base = name.substring(0, name.length - 4); // strip ".bin" - int timerStart = int.parse(base.split('_').last); - if (timerStart > 100000000000) timerStart ~/= 1000; // ms -> s - - final fsMatch = RegExp(r'_fs(\d+)').firstMatch(name); - final frameSize = fsMatch != null ? int.parse(fsMatch.group(1)!) : 160; - - BleAudioCodec codec; - if (name.contains('_pcm16_')) { - codec = BleAudioCodec.pcm16; - } else if (name.contains('_pcm8_')) { - codec = BleAudioCodec.pcm8; - } else { - codec = frameSize == 320 ? BleAudioCodec.opusFS320 : BleAudioCodec.opus; - } + final info = BatchRecordingInfo.fromFileName(name); + if (info == null) return null; final sizeBytes = await file.length(); if (sizeBytes <= 0) return null; - // Rough duration for display/stats only — the backend recomputes the exact - // duration from the decoded WAV. ~16 kbps opus + 4-byte per-frame framing. - final int bytesPerSec = codec == BleAudioCodec.pcm16 - ? 32200 - : codec == BleAudioCodec.pcm8 - ? 16100 - : 2400; - final seconds = (sizeBytes / bytesPerSec).round().clamp(1, 24 * 3600); - final deviceModel = SharedPreferencesUtil().deviceName.isNotEmpty ? SharedPreferencesUtil().deviceName : 'Omi'; return Wal( - timerStart: timerStart, - codec: codec, - seconds: seconds, + timerStart: info.timerStart, + codec: info.codec, + seconds: info.estimateSeconds(sizeBytes), sampleRate: 16000, channel: 1, status: WalStatus.miss, From 5bf460b35c310cc9551f4b5cb6e063979ebb609b Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 15:56:16 +0530 Subject: [PATCH 11/45] refactor(android): atomic .bin.part finalize + crash recovery for batch writer Write to .bin.part and rename to .bin only when sealed (rotation/gap/stop) so a half-written file is never ingested; promote stale .bin.part from a crashed process on next start. Replaces the journal. --- .../com/friend/ios/OmiBatchAudioWriter.kt | 56 +++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt b/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt index 60c5206e9f..9b3e141573 100644 --- a/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt +++ b/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt @@ -34,7 +34,7 @@ class OmiBatchAudioWriter(private val context: Context) { companion object { private const val TAG = "OmiBle.BatchWriter" private const val FLUTTER_PREFS = "FlutterSharedPreferences" - private const val JOURNAL_NAME = ".batch_journal" + 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 @@ -61,6 +61,7 @@ class OmiBatchAudioWriter(private val context: Context) { 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. */ @@ -136,6 +137,12 @@ class OmiBatchAudioWriter(private val context: Context) { 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") @@ -152,7 +159,9 @@ class OmiBatchAudioWriter(private val context: Context) { val startSec = nowMs / 1000 val frameSize = if (config.codec == "opus_fs320") 320 else 160 val deviceToken = config.deviceType.lowercase(Locale.US).filter { it.isLetterOrDigit() }.ifEmpty { "omi" } - val name = "audio_${deviceToken}_${config.codec}_${config.sampleRate}_1_fs${frameSize}_${startSec}.bin" + // Write to a .bin.part file while active; rename to .bin only once finalized so + // Flutter (which ingests *.bin) never picks up a half-written file. + val name = "audio_${deviceToken}_${config.codec}_${config.sampleRate}_1_fs${frameSize}_${startSec}.bin$PART_SUFFIX" val file = File(dir, name) try { @@ -164,7 +173,6 @@ class OmiBatchAudioWriter(private val context: Context) { currentBytes = file.length() currentFrames = 0 lastFsyncMs = nowMs - writeJournal(dir, name) Log.i(TAG, "opened batch file $name") } catch (e: Exception) { Log.e(TAG, "open failed for $name: ${e.message}") @@ -197,6 +205,7 @@ class OmiBatchAudioWriter(private val context: Context) { private fun closeCurrentLocked(reason: String) { val out = raf ?: return + val partFile = currentFile try { out.fd.sync() } catch (_: Exception) { @@ -205,31 +214,44 @@ class OmiBatchAudioWriter(private val context: Context) { out.close() } catch (_: Exception) { } - Log.i(TAG, "closed batch file ${currentFile?.name} ($currentFrames frames, $currentBytes bytes, reason=$reason)") + 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 - clearJournal() } - // ── Journal (so Flutter never ingests the file being written) ── + // ── Crash recovery ── - private fun writeJournal(dir: File, filename: String) { + /** 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 { - File(dir, JOURNAL_NAME).writeText(filename) + 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, "journal write failed: ${e.message}") - } - } - - private fun clearJournal() { - try { - val dir = currentFile?.parentFile ?: loadConfig()?.dir?.let { File(it) } - if (dir != null) File(dir, JOURNAL_NAME).delete() - } catch (_: Exception) { + Log.w(TAG, "recoverStalePartFiles failed: ${e.message}") } } From 360189f01dee1702e071821956b7e5e6af1ad542 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 15:56:16 +0530 Subject: [PATCH 12/45] feat(ios): native batch audio writer for offline capture Mirrors the Android writer: length-prefixed .bin files, size/duration rotation, silence-gap finalize, periodic fsync, atomic .bin.part rename, stale-part recovery, and a free-space guard. --- app/ios/Runner/BatchAudioWriter.swift | 269 ++++++++++++++++++++++++++ 1 file changed, 269 insertions(+) create mode 100644 app/ios/Runner/BatchAudioWriter.swift diff --git a/app/ios/Runner/BatchAudioWriter.swift b/app/ios/Runner/BatchAudioWriter.swift new file mode 100644 index 0000000000..7a9776ba52 --- /dev/null +++ b/app/ios/Runner/BatchAudioWriter.swift @@ -0,0 +1,269 @@ +import Foundation + +/// Batch (offline) capture sink for iOS. When the user enables "batch mode", +/// incoming BLE audio is stored to local `.bin` files instead of being forwarded +/// to Dart for realtime transcription. Mirrors the Android `OmiBatchAudioWriter`. +/// +/// On-disk format (what `POST /v2/sync-local-files` decodes): +/// [4-byte little-endian uint32 frame_length][frame bytes] ... repeated +/// File name: audio_{device}_{codec}_{sampleRate}_{channel}_fs{frameSize}_{startSec}.bin +/// +/// Hooked from `OmiBleManager.peripheral(_:didUpdateValueFor:)`. When it consumes a +/// packet it returns `true`, and the manager skips the Dart forward — so the Flutter +/// engine does no per-packet work (the iOS battery win). +/// +/// Durability: frames are written to a `.bin.part` file with periodic `fsync`, and +/// the file is atomically renamed to `.bin` only once finalized (rotation / silence +/// gap / stop) so a half-written file is never ingested. A stale `.bin.part` left by +/// a crashed process is recovered (promoted to `.bin`) on the next writer start. A +/// free-space guard stops writing (and flags Flutter) rather than failing hard. +final class BatchAudioWriter { + static let shared = BatchAudioWriter() + private init() {} + + private let queue = DispatchQueue(label: "com.omi.batchAudioWriter") + + // Active-file state (only touched on `queue`). + private var fileHandle: FileHandle? + private var currentURL: URL? + private var currentStartSec: Int64 = 0 + private var currentBytes: Int64 = 0 + private var currentFrames: Int64 = 0 + private var lastFrameMs: Int64 = 0 + private var lastFsyncMs: Int64 = 0 + private var storageFull = false + private var recovered = false + + private let maxFileBytes: Int64 = 32 * 1024 * 1024 // ~32 MB per file + private let maxFileSeconds: Int64 = 1800 // 30 min per file + private let gapMs: Int64 = 30_000 // start a new file after this silence gap + private let fsyncIntervalMs: Int64 = 2_000 + private let minFreeBytes: Int64 = 200 * 1024 * 1024 // stop below 200 MB free + private let partSuffix = "part" + + private struct Config { + let deviceId: String + let codec: String + let sampleRate: Int + let serviceUuid: String + let characteristicUuid: String + let deviceType: String + let dir: String + } + + /// Returns true if the packet was consumed for batch capture (caller must then + /// skip forwarding it to Dart). Returns false to let realtime forwarding proceed. + @discardableResult + func handle(peripheralUuid: String, serviceUuid: String, characteristicUuid: String, value: Data) -> Bool { + guard let config = loadConfig() else { + queue.async { self.closeCurrentLocked("disabled") } + return false + } + guard config.deviceId.lowercased() == peripheralUuid.lowercased() else { return false } + guard config.serviceUuid == serviceUuid.lowercased(), + config.characteristicUuid == characteristicUuid.lowercased() else { return false } + + let frames = transformFrames(deviceType: config.deviceType, value: value) + if !frames.isEmpty { + queue.async { self.writeFrames(frames, config: config) } + } + // Audio packet on the configured characteristic: consume it (do not forward + // to Dart) even if it carried no payload, to keep the engine idle. + return true + } + + /// Finalize the current file (e.g. on disconnect or app teardown). + func stop(_ reason: String) { + queue.async { self.closeCurrentLocked(reason) } + } + + // MARK: - Writing (on `queue`) + + private func writeFrames(_ frames: [Data], config: Config) { + let nowMs = Int64(Date().timeIntervalSince1970 * 1000) + + if fileHandle != nil, lastFrameMs > 0, nowMs - lastFrameMs > gapMs { + closeCurrentLocked("gap") + } + if fileHandle != nil, currentBytes >= maxFileBytes || (nowMs / 1000 - currentStartSec) >= maxFileSeconds { + closeCurrentLocked("rotate") + } + + ensureOpen(config: config, nowMs: nowMs) + guard let fh = fileHandle else { return } // storage full or open failed — drop packet + + do { + for frame in frames { + var len = UInt32(frame.count).littleEndian + let header = Data(bytes: &len, count: 4) + try fh.write(contentsOf: header) + try fh.write(contentsOf: frame) + currentBytes += Int64(4 + frame.count) + currentFrames += 1 + } + } catch { + NSLog("[BatchWriter] write failed: \(error)") + closeCurrentLocked("write_error") + return + } + + lastFrameMs = nowMs + if nowMs - lastFsyncMs >= fsyncIntervalMs { + try? fh.synchronize() + lastFsyncMs = nowMs + } + } + + private func ensureOpen(config: Config, nowMs: Int64) { + if fileHandle != nil { return } + + let dir = URL(fileURLWithPath: config.dir, isDirectory: true) + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + if !recovered { + recovered = true + recoverStalePartFiles(dir) + } + + if freeBytes(at: dir) < minFreeBytes { + if !storageFull { + NSLog("[BatchWriter] storage low — pausing batch capture") + setStorageFullFlag(true) + storageFull = true + } + return + } + if storageFull { + storageFull = false + setStorageFullFlag(false) + } + + let startSec = nowMs / 1000 + let frameSize = config.codec == "opus_fs320" ? 320 : 160 + let name = "audio_\(sanitize(config.deviceType))_\(config.codec)_\(config.sampleRate)_1_fs\(frameSize)_\(startSec).bin.\(partSuffix)" + let url = dir.appendingPathComponent(name) + + if !FileManager.default.fileExists(atPath: url.path) { + FileManager.default.createFile(atPath: url.path, contents: nil) + } + guard let fh = try? FileHandle(forWritingTo: url) else { + NSLog("[BatchWriter] open failed for \(name)") + return + } + let end = (try? fh.seekToEnd()) ?? 0 + fileHandle = fh + currentURL = url + currentStartSec = startSec + currentBytes = Int64(end) + currentFrames = 0 + lastFsyncMs = nowMs + NSLog("[BatchWriter] opened \(name)") + } + + private func closeCurrentLocked(_ reason: String) { + guard let fh = fileHandle else { return } + try? fh.synchronize() + try? fh.close() + if let part = currentURL { + if currentBytes > 0 { + let finalURL = part.deletingPathExtension() // strip ".part" -> "....bin" + try? FileManager.default.removeItem(at: finalURL) + do { + try FileManager.default.moveItem(at: part, to: finalURL) + NSLog("[BatchWriter] finalized \(finalURL.lastPathComponent) (\(currentFrames) frames, \(currentBytes) bytes, reason=\(reason))") + } catch { + NSLog("[BatchWriter] finalize failed: \(error)") + } + } else { + try? FileManager.default.removeItem(at: part) + } + } + fileHandle = nil + currentURL = nil + currentStartSec = 0 + currentBytes = 0 + currentFrames = 0 + lastFrameMs = 0 + } + + // MARK: - Crash recovery + + private func recoverStalePartFiles(_ dir: URL) { + guard let items = try? FileManager.default.contentsOfDirectory( + at: dir, includingPropertiesForKeys: [.fileSizeKey] + ) else { return } + for url in items { + let name = url.lastPathComponent + guard name.hasPrefix("audio_"), name.hasSuffix(".bin.\(partSuffix)") else { continue } + let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0 + if size > 0 { + let finalURL = url.deletingPathExtension() + try? FileManager.default.moveItem(at: url, to: finalURL) + NSLog("[BatchWriter] recovered stale batch file -> \(finalURL.lastPathComponent)") + } else { + try? FileManager.default.removeItem(at: url) + } + } + } + + // MARK: - Frame extraction (mirrors Android transformFrames) + + private func transformFrames(deviceType: String, value: Data) -> [Data] { + switch deviceType { + case "omi", "openglass": + return value.count <= 3 ? [] : [value.subdata(in: 3 ..< value.count)] + case "friendPendant": + if value.count <= 5 { return [] } + let payload = value.subdata(in: 0 ..< (value.count - 5)) + var frames: [Data] = [] + var offset = 0 + while offset + 30 <= payload.count { + frames.append(payload.subdata(in: offset ..< (offset + 30))) + offset += 30 + } + return frames + default: + return [] + } + } + + // MARK: - Config + helpers + + private func loadConfig() -> Config? { + let d = UserDefaults.standard + guard d.bool(forKey: "flutter.batchModeEnabled") else { return nil } + guard let dir = d.string(forKey: "flutter.batchAudioDir"), !dir.isEmpty else { return nil } + guard let raw = d.string(forKey: "flutter.nativeBleStreamConfig"), + let data = raw.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + guard let deviceId = json["deviceId"] as? String, !deviceId.isEmpty, + let serviceUuid = json["serviceUuid"] as? String, !serviceUuid.isEmpty, + let charUuid = json["characteristicUuid"] as? String, !charUuid.isEmpty else { return nil } + return Config( + deviceId: deviceId, + codec: (json["codec"] as? String) ?? "opus", + sampleRate: (json["sampleRate"] as? Int) ?? 16000, + serviceUuid: serviceUuid.lowercased(), + characteristicUuid: charUuid.lowercased(), + deviceType: (json["deviceType"] as? String) ?? "omi", + dir: dir + ) + } + + private func sanitize(_ s: String) -> String { + let t = s.lowercased().filter { $0.isLetter || $0.isNumber } + return t.isEmpty ? "omi" : t + } + + private func freeBytes(at dir: URL) -> Int64 { + if let vals = try? dir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]), + let cap = vals.volumeAvailableCapacityForImportantUsage { + return cap + } + return Int64.max + } + + private func setStorageFullFlag(_ full: Bool) { + UserDefaults.standard.set(full, forKey: "flutter.batchStorageFull") + } +} From 2784ebb7761a5eb189f560469170e080e6399960 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 15:56:16 +0530 Subject: [PATCH 13/45] feat(ios): store BLE audio natively in batch mode (skip Dart forward) In didUpdateValueFor, hand audio packets to BatchAudioWriter when batch mode is on and skip forwarding to Dart so the Flutter engine stays idle. --- app/ios/Runner/OmiBleManager.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/ios/Runner/OmiBleManager.swift b/app/ios/Runner/OmiBleManager.swift index d9f9d53d15..2d5dcc95ab 100644 --- a/app/ios/Runner/OmiBleManager.swift +++ b/app/ios/Runner/OmiBleManager.swift @@ -755,6 +755,18 @@ extension OmiBleManager: CBPeripheralDelegate { persistBatteryReading(uuid: uuid, level: Int(firstByte)) } + // Batch (offline) mode: store audio natively and skip the Dart forward so the + // Flutter engine stays idle. Returns true only for the configured audio + // characteristic while batch mode is on; everything else falls through. + if BatchAudioWriter.shared.handle( + peripheralUuid: uuid, + serviceUuid: serviceUuid, + characteristicUuid: charUuid, + value: data + ) { + return + } + let typedData = FlutterStandardTypedData(bytes: data) flutterApi?.onCharacteristicValueUpdated( peripheralUuid: uuid, From 68135aa461755337c016c4e284696b82dddd5f0e Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 15:56:16 +0530 Subject: [PATCH 14/45] chore(ios): add BatchAudioWriter.swift to the Runner target --- app/ios/Runner.xcodeproj/project.pbxproj | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/ios/Runner.xcodeproj/project.pbxproj b/app/ios/Runner.xcodeproj/project.pbxproj index 21ddb8dd22..6f0ee87431 100644 --- a/app/ios/Runner.xcodeproj/project.pbxproj +++ b/app/ios/Runner.xcodeproj/project.pbxproj @@ -20,6 +20,7 @@ 42A7BA3E2E788BD400138969 /* omiWatchApp.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 42A7BA342E788BD300138969 /* omiWatchApp.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); platformFilter = iphoneos; }; }; 42A7BA4C2E788F0100138969 /* WatchConnectivity.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 42A7BA4B2E788F0100138969 /* WatchConnectivity.framework */; }; 42B17C622F69B9CF00BFDA9D /* OmiBleManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B17C612F69B9CF00BFDA9D /* OmiBleManager.swift */; }; + BAADF00DF00DF00DF00DF0D2 /* BatchAudioWriter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAADF00DF00DF00DF00DF0D1 /* BatchAudioWriter.swift */; }; 42B17C642F69B9E100BFDA9D /* BleHostApiImpl.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B17C632F69B9E100BFDA9D /* BleHostApiImpl.swift */; }; 42B17C7C2F69BC6600BFDA9D /* PigeonCommunicator.g.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42B17C7B2F69BC6600BFDA9D /* PigeonCommunicator.g.swift */; }; 42BABFB82F2A81B100E78A3C /* OmiRecordingAudioDevice.swift in Sources */ = {isa = PBXBuildFile; fileRef = 42BABFB62F2A81B100E78A3C /* OmiRecordingAudioDevice.swift */; }; @@ -126,6 +127,7 @@ 42A7BA342E788BD300138969 /* omiWatchApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = omiWatchApp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 42A7BA4B2E788F0100138969 /* WatchConnectivity.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WatchConnectivity.framework; path = System/Library/Frameworks/WatchConnectivity.framework; sourceTree = SDKROOT; }; 42B17C612F69B9CF00BFDA9D /* OmiBleManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmiBleManager.swift; sourceTree = ""; }; + BAADF00DF00DF00DF00DF0D1 /* BatchAudioWriter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BatchAudioWriter.swift; sourceTree = ""; }; 42B17C632F69B9E100BFDA9D /* BleHostApiImpl.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BleHostApiImpl.swift; sourceTree = ""; }; 42B17C7B2F69BC6600BFDA9D /* PigeonCommunicator.g.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PigeonCommunicator.g.swift; sourceTree = ""; }; 42BABFB62F2A81B100E78A3C /* OmiRecordingAudioDevice.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OmiRecordingAudioDevice.swift; sourceTree = ""; }; @@ -339,6 +341,7 @@ 42B17C7B2F69BC6600BFDA9D /* PigeonCommunicator.g.swift */, 42B17C632F69B9E100BFDA9D /* BleHostApiImpl.swift */, 42B17C612F69B9CF00BFDA9D /* OmiBleManager.swift */, + BAADF00DF00DF00DF00DF0D1 /* BatchAudioWriter.swift */, 42BABFC02F2A81B100E78A3C /* PhoneCalls */, 4255287C2E7C4B8300FD5CFB /* RecorderHostApiImpl.swift */, 5FCEB2FF2C2758C000B17EE8 /* RunnerRelease-dev.entitlements */, @@ -636,6 +639,7 @@ files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 42B17C622F69B9CF00BFDA9D /* OmiBleManager.swift in Sources */, + BAADF00DF00DF00DF00DF0D2 /* BatchAudioWriter.swift in Sources */, AB12345678901234567890AB /* AppleRemindersService.swift in Sources */, AB12345678901234567890AC /* AppleHealthService.swift in Sources */, AB12345678901234567890AD /* AudioInterruptionManager.swift in Sources */, From fc46e5394e683cafd1bb5454ce1f5db10a149595 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 15:56:17 +0530 Subject: [PATCH 15/45] feat(app): enable Offline Mode toggle on iOS + low-storage warning --- app/lib/pages/settings/profile.dart | 37 ++++++++++++++++++++++------- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/app/lib/pages/settings/profile.dart b/app/lib/pages/settings/profile.dart index 7ca1ce977c..5219099c5d 100644 --- a/app/lib/pages/settings/profile.dart +++ b/app/lib/pages/settings/profile.dart @@ -399,6 +399,27 @@ class _ProfilePageState extends State { ], ), ), + if (SharedPreferencesUtil().getBool('batchStorageFull')) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(12), + decoration: + BoxDecoration(color: const Color(0xFF3A2A2A), borderRadius: BorderRadius.circular(12)), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.warning_amber_rounded, color: Color(0xFFE0A030), size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + context.l10n.offlineModeStorageFull, + style: TextStyle(color: Colors.orange.shade200, fontSize: 13, height: 1.4), + ), + ), + ], + ), + ), + ], ], ), ), @@ -512,15 +533,15 @@ class _ProfilePageState extends State { chipValue: SharedPreferencesUtil().backgroundModeEnabled ? context.l10n.on : context.l10n.off, onTap: _showBackgroundModeSheet, ), - const Divider(height: 1, color: Color(0xFF3C3C43)), - _buildProfileItem( - title: context.l10n.offlineModeTitle, - icon: const FaIcon(FontAwesomeIcons.floppyDisk, color: Color(0xFF8E8E93), size: 20), - showBetaTag: true, - chipValue: SharedPreferencesUtil().batchModeEnabled ? context.l10n.on : context.l10n.off, - onTap: _showOfflineModeSheet, - ), ], + const Divider(height: 1, color: Color(0xFF3C3C43)), + _buildProfileItem( + title: context.l10n.offlineModeTitle, + icon: const FaIcon(FontAwesomeIcons.floppyDisk, color: Color(0xFF8E8E93), size: 20), + showBetaTag: true, + chipValue: SharedPreferencesUtil().batchModeEnabled ? context.l10n.on : context.l10n.off, + onTap: _showOfflineModeSheet, + ), ], ), const SizedBox(height: 32), From 26f43420fd4595da940cb160bd44f5c23e1d2680 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 15:56:17 +0530 Subject: [PATCH 16/45] feat(l10n): add offline-mode low-storage warning string --- app/lib/l10n/app_en.arb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 930fc5d62b..52d84fa82c 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -11034,5 +11034,9 @@ "offlineModeNote": "Works with Omi devices for now. Audio stays on your phone until you choose to upload it.", "@offlineModeNote": { "description": "Caveat note shown in the Offline Mode sheet" + }, + "offlineModeStorageFull": "Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.", + "@offlineModeStorageFull": { + "description": "Warning shown in the Offline Mode sheet when local storage is too low to keep recording" } } From 8397ef151a83ffcf76acf54e6cf2c4eadea114c7 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 15:56:17 +0530 Subject: [PATCH 17/45] chore(l10n): regenerate localizations --- app/lib/l10n/app_localizations.dart | 6 ++++++ app/lib/l10n/app_localizations_ar.dart | 4 ++++ app/lib/l10n/app_localizations_be.dart | 4 ++++ app/lib/l10n/app_localizations_bg.dart | 4 ++++ app/lib/l10n/app_localizations_bn.dart | 4 ++++ app/lib/l10n/app_localizations_bs.dart | 4 ++++ app/lib/l10n/app_localizations_ca.dart | 4 ++++ app/lib/l10n/app_localizations_cs.dart | 4 ++++ app/lib/l10n/app_localizations_da.dart | 4 ++++ app/lib/l10n/app_localizations_de.dart | 4 ++++ app/lib/l10n/app_localizations_el.dart | 4 ++++ app/lib/l10n/app_localizations_en.dart | 4 ++++ app/lib/l10n/app_localizations_es.dart | 4 ++++ app/lib/l10n/app_localizations_et.dart | 4 ++++ app/lib/l10n/app_localizations_fa.dart | 4 ++++ app/lib/l10n/app_localizations_fi.dart | 4 ++++ app/lib/l10n/app_localizations_fr.dart | 4 ++++ app/lib/l10n/app_localizations_he.dart | 4 ++++ app/lib/l10n/app_localizations_hi.dart | 4 ++++ app/lib/l10n/app_localizations_hr.dart | 4 ++++ app/lib/l10n/app_localizations_hu.dart | 4 ++++ app/lib/l10n/app_localizations_id.dart | 4 ++++ app/lib/l10n/app_localizations_it.dart | 4 ++++ app/lib/l10n/app_localizations_ja.dart | 4 ++++ app/lib/l10n/app_localizations_kn.dart | 4 ++++ app/lib/l10n/app_localizations_ko.dart | 4 ++++ app/lib/l10n/app_localizations_lt.dart | 4 ++++ app/lib/l10n/app_localizations_lv.dart | 4 ++++ app/lib/l10n/app_localizations_mk.dart | 4 ++++ app/lib/l10n/app_localizations_mr.dart | 4 ++++ app/lib/l10n/app_localizations_ms.dart | 4 ++++ app/lib/l10n/app_localizations_nl.dart | 4 ++++ app/lib/l10n/app_localizations_no.dart | 4 ++++ app/lib/l10n/app_localizations_pl.dart | 4 ++++ app/lib/l10n/app_localizations_pt.dart | 4 ++++ app/lib/l10n/app_localizations_ro.dart | 4 ++++ app/lib/l10n/app_localizations_ru.dart | 4 ++++ app/lib/l10n/app_localizations_sk.dart | 4 ++++ app/lib/l10n/app_localizations_sl.dart | 4 ++++ app/lib/l10n/app_localizations_sr.dart | 4 ++++ app/lib/l10n/app_localizations_sv.dart | 4 ++++ app/lib/l10n/app_localizations_ta.dart | 4 ++++ app/lib/l10n/app_localizations_te.dart | 4 ++++ app/lib/l10n/app_localizations_th.dart | 4 ++++ app/lib/l10n/app_localizations_tl.dart | 4 ++++ app/lib/l10n/app_localizations_tr.dart | 4 ++++ app/lib/l10n/app_localizations_uk.dart | 4 ++++ app/lib/l10n/app_localizations_ur.dart | 4 ++++ app/lib/l10n/app_localizations_vi.dart | 4 ++++ app/lib/l10n/app_localizations_zh.dart | 4 ++++ 50 files changed, 202 insertions(+) diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 09f5ee7df5..b97ccd4ad0 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -17396,6 +17396,12 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'** String get offlineModeNote; + + /// Warning shown in the Offline Mode sheet when local storage is too low to keep recording + /// + /// In en, this message translates to: + /// **'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'** + String get offlineModeStorageFull; } class _AppLocalizationsDelegate extends LocalizationsDelegate { diff --git a/app/lib/l10n/app_localizations_ar.dart b/app/lib/l10n/app_localizations_ar.dart index 9fb5272e01..a51a68499d 100644 --- a/app/lib/l10n/app_localizations_ar.dart +++ b/app/lib/l10n/app_localizations_ar.dart @@ -9279,4 +9279,8 @@ class AppLocalizationsAr extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_be.dart b/app/lib/l10n/app_localizations_be.dart index 77e8bfea28..9f33a0408a 100644 --- a/app/lib/l10n/app_localizations_be.dart +++ b/app/lib/l10n/app_localizations_be.dart @@ -9362,4 +9362,8 @@ class AppLocalizationsBe extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_bg.dart b/app/lib/l10n/app_localizations_bg.dart index 085ddc3ec1..be38eca916 100644 --- a/app/lib/l10n/app_localizations_bg.dart +++ b/app/lib/l10n/app_localizations_bg.dart @@ -9369,4 +9369,8 @@ class AppLocalizationsBg extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_bn.dart b/app/lib/l10n/app_localizations_bn.dart index 76fc385c47..dafb3bf488 100644 --- a/app/lib/l10n/app_localizations_bn.dart +++ b/app/lib/l10n/app_localizations_bn.dart @@ -9341,4 +9341,8 @@ class AppLocalizationsBn extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_bs.dart b/app/lib/l10n/app_localizations_bs.dart index 018bd63102..56a58e304b 100644 --- a/app/lib/l10n/app_localizations_bs.dart +++ b/app/lib/l10n/app_localizations_bs.dart @@ -9359,4 +9359,8 @@ class AppLocalizationsBs extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ca.dart b/app/lib/l10n/app_localizations_ca.dart index 7dc0e16f4c..7471e0cf7e 100644 --- a/app/lib/l10n/app_localizations_ca.dart +++ b/app/lib/l10n/app_localizations_ca.dart @@ -9388,4 +9388,8 @@ class AppLocalizationsCa extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_cs.dart b/app/lib/l10n/app_localizations_cs.dart index 7b132c10aa..e163d6802f 100644 --- a/app/lib/l10n/app_localizations_cs.dart +++ b/app/lib/l10n/app_localizations_cs.dart @@ -9334,4 +9334,8 @@ class AppLocalizationsCs extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_da.dart b/app/lib/l10n/app_localizations_da.dart index a72acad678..e0fe6a74b1 100644 --- a/app/lib/l10n/app_localizations_da.dart +++ b/app/lib/l10n/app_localizations_da.dart @@ -9319,4 +9319,8 @@ class AppLocalizationsDa extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_de.dart b/app/lib/l10n/app_localizations_de.dart index a263f0fa6b..0c9555bb35 100644 --- a/app/lib/l10n/app_localizations_de.dart +++ b/app/lib/l10n/app_localizations_de.dart @@ -9411,4 +9411,8 @@ class AppLocalizationsDe extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_el.dart b/app/lib/l10n/app_localizations_el.dart index c69ad08f93..489b170a8f 100644 --- a/app/lib/l10n/app_localizations_el.dart +++ b/app/lib/l10n/app_localizations_el.dart @@ -9400,4 +9400,8 @@ class AppLocalizationsEl extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index d9ad0c14b5..b81225ff76 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -9329,4 +9329,8 @@ class AppLocalizationsEn extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index 8486ade23c..489e51b5cd 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -9355,4 +9355,8 @@ class AppLocalizationsEs extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_et.dart b/app/lib/l10n/app_localizations_et.dart index 3c269fff05..8b492a10a4 100644 --- a/app/lib/l10n/app_localizations_et.dart +++ b/app/lib/l10n/app_localizations_et.dart @@ -9331,4 +9331,8 @@ class AppLocalizationsEt extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_fa.dart b/app/lib/l10n/app_localizations_fa.dart index b595584f98..91d4257d6f 100644 --- a/app/lib/l10n/app_localizations_fa.dart +++ b/app/lib/l10n/app_localizations_fa.dart @@ -9336,4 +9336,8 @@ class AppLocalizationsFa extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_fi.dart b/app/lib/l10n/app_localizations_fi.dart index 86f8fe132c..0009cc39d7 100644 --- a/app/lib/l10n/app_localizations_fi.dart +++ b/app/lib/l10n/app_localizations_fi.dart @@ -9334,4 +9334,8 @@ class AppLocalizationsFi extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_fr.dart b/app/lib/l10n/app_localizations_fr.dart index 57a528bb09..01c73304f5 100644 --- a/app/lib/l10n/app_localizations_fr.dart +++ b/app/lib/l10n/app_localizations_fr.dart @@ -9418,4 +9418,8 @@ class AppLocalizationsFr extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_he.dart b/app/lib/l10n/app_localizations_he.dart index e53fd7f66b..b6b5416369 100644 --- a/app/lib/l10n/app_localizations_he.dart +++ b/app/lib/l10n/app_localizations_he.dart @@ -9264,4 +9264,8 @@ class AppLocalizationsHe extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_hi.dart b/app/lib/l10n/app_localizations_hi.dart index d966372ae1..abe362dd9c 100644 --- a/app/lib/l10n/app_localizations_hi.dart +++ b/app/lib/l10n/app_localizations_hi.dart @@ -9311,4 +9311,8 @@ class AppLocalizationsHi extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_hr.dart b/app/lib/l10n/app_localizations_hr.dart index a0ee1c7472..f3deb75957 100644 --- a/app/lib/l10n/app_localizations_hr.dart +++ b/app/lib/l10n/app_localizations_hr.dart @@ -9366,4 +9366,8 @@ class AppLocalizationsHr extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_hu.dart b/app/lib/l10n/app_localizations_hu.dart index 47e31f6bcc..89d386b42b 100644 --- a/app/lib/l10n/app_localizations_hu.dart +++ b/app/lib/l10n/app_localizations_hu.dart @@ -9374,4 +9374,8 @@ class AppLocalizationsHu extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_id.dart b/app/lib/l10n/app_localizations_id.dart index 2b4de932f9..a5f0acd7ac 100644 --- a/app/lib/l10n/app_localizations_id.dart +++ b/app/lib/l10n/app_localizations_id.dart @@ -9342,4 +9342,8 @@ class AppLocalizationsId extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_it.dart b/app/lib/l10n/app_localizations_it.dart index 68cad960c9..83fd8d1ea6 100644 --- a/app/lib/l10n/app_localizations_it.dart +++ b/app/lib/l10n/app_localizations_it.dart @@ -9389,4 +9389,8 @@ class AppLocalizationsIt extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 991f056212..3773d24b07 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -9183,4 +9183,8 @@ class AppLocalizationsJa extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_kn.dart b/app/lib/l10n/app_localizations_kn.dart index de9c71ddd6..cc83d57919 100644 --- a/app/lib/l10n/app_localizations_kn.dart +++ b/app/lib/l10n/app_localizations_kn.dart @@ -9365,4 +9365,8 @@ class AppLocalizationsKn extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index aec528b3d0..87439b9888 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -9184,4 +9184,8 @@ class AppLocalizationsKo extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_lt.dart b/app/lib/l10n/app_localizations_lt.dart index 4bbe5aa0ff..afa6b6c3b5 100644 --- a/app/lib/l10n/app_localizations_lt.dart +++ b/app/lib/l10n/app_localizations_lt.dart @@ -9348,4 +9348,8 @@ class AppLocalizationsLt extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_lv.dart b/app/lib/l10n/app_localizations_lv.dart index 8019cefb58..912320d418 100644 --- a/app/lib/l10n/app_localizations_lv.dart +++ b/app/lib/l10n/app_localizations_lv.dart @@ -9356,4 +9356,8 @@ class AppLocalizationsLv extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_mk.dart b/app/lib/l10n/app_localizations_mk.dart index 95c1ab13b0..2614f3ee47 100644 --- a/app/lib/l10n/app_localizations_mk.dart +++ b/app/lib/l10n/app_localizations_mk.dart @@ -9383,4 +9383,8 @@ class AppLocalizationsMk extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_mr.dart b/app/lib/l10n/app_localizations_mr.dart index 9fdc366a88..fb774f5d19 100644 --- a/app/lib/l10n/app_localizations_mr.dart +++ b/app/lib/l10n/app_localizations_mr.dart @@ -9344,4 +9344,8 @@ class AppLocalizationsMr extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ms.dart b/app/lib/l10n/app_localizations_ms.dart index 8e1e521f81..306918daec 100644 --- a/app/lib/l10n/app_localizations_ms.dart +++ b/app/lib/l10n/app_localizations_ms.dart @@ -9357,4 +9357,8 @@ class AppLocalizationsMs extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_nl.dart b/app/lib/l10n/app_localizations_nl.dart index ba5531271e..526668afec 100644 --- a/app/lib/l10n/app_localizations_nl.dart +++ b/app/lib/l10n/app_localizations_nl.dart @@ -9360,4 +9360,8 @@ class AppLocalizationsNl extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_no.dart b/app/lib/l10n/app_localizations_no.dart index 8ee1a6129a..1a2435b113 100644 --- a/app/lib/l10n/app_localizations_no.dart +++ b/app/lib/l10n/app_localizations_no.dart @@ -9331,4 +9331,8 @@ class AppLocalizationsNo extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_pl.dart b/app/lib/l10n/app_localizations_pl.dart index d14b494b99..8332351247 100644 --- a/app/lib/l10n/app_localizations_pl.dart +++ b/app/lib/l10n/app_localizations_pl.dart @@ -9359,4 +9359,8 @@ class AppLocalizationsPl extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_pt.dart b/app/lib/l10n/app_localizations_pt.dart index e119d6d080..baa536be4d 100644 --- a/app/lib/l10n/app_localizations_pt.dart +++ b/app/lib/l10n/app_localizations_pt.dart @@ -9339,4 +9339,8 @@ class AppLocalizationsPt extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ro.dart b/app/lib/l10n/app_localizations_ro.dart index d7d745c6e8..a639966559 100644 --- a/app/lib/l10n/app_localizations_ro.dart +++ b/app/lib/l10n/app_localizations_ro.dart @@ -9379,4 +9379,8 @@ class AppLocalizationsRo extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ru.dart b/app/lib/l10n/app_localizations_ru.dart index 5a7ffc8dfa..3953ed6599 100644 --- a/app/lib/l10n/app_localizations_ru.dart +++ b/app/lib/l10n/app_localizations_ru.dart @@ -9367,4 +9367,8 @@ class AppLocalizationsRu extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_sk.dart b/app/lib/l10n/app_localizations_sk.dart index aa4ea2d9f5..869396cfd5 100644 --- a/app/lib/l10n/app_localizations_sk.dart +++ b/app/lib/l10n/app_localizations_sk.dart @@ -9326,4 +9326,8 @@ class AppLocalizationsSk extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_sl.dart b/app/lib/l10n/app_localizations_sl.dart index 2e191f1438..331c75fc18 100644 --- a/app/lib/l10n/app_localizations_sl.dart +++ b/app/lib/l10n/app_localizations_sl.dart @@ -9361,4 +9361,8 @@ class AppLocalizationsSl extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_sr.dart b/app/lib/l10n/app_localizations_sr.dart index 533155e4d2..0ee7fb1464 100644 --- a/app/lib/l10n/app_localizations_sr.dart +++ b/app/lib/l10n/app_localizations_sr.dart @@ -9346,4 +9346,8 @@ class AppLocalizationsSr extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_sv.dart b/app/lib/l10n/app_localizations_sv.dart index 1bc8655cd0..b03fd2f6ef 100644 --- a/app/lib/l10n/app_localizations_sv.dart +++ b/app/lib/l10n/app_localizations_sv.dart @@ -9339,4 +9339,8 @@ class AppLocalizationsSv extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ta.dart b/app/lib/l10n/app_localizations_ta.dart index c0bd3c53ef..bc1e0092c0 100644 --- a/app/lib/l10n/app_localizations_ta.dart +++ b/app/lib/l10n/app_localizations_ta.dart @@ -9400,4 +9400,8 @@ class AppLocalizationsTa extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_te.dart b/app/lib/l10n/app_localizations_te.dart index f52537449b..489717d50d 100644 --- a/app/lib/l10n/app_localizations_te.dart +++ b/app/lib/l10n/app_localizations_te.dart @@ -9383,4 +9383,8 @@ class AppLocalizationsTe extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_th.dart b/app/lib/l10n/app_localizations_th.dart index 8de0f2c8d9..adff0f9317 100644 --- a/app/lib/l10n/app_localizations_th.dart +++ b/app/lib/l10n/app_localizations_th.dart @@ -9284,4 +9284,8 @@ class AppLocalizationsTh extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_tl.dart b/app/lib/l10n/app_localizations_tl.dart index 3dab45804b..39c5429918 100644 --- a/app/lib/l10n/app_localizations_tl.dart +++ b/app/lib/l10n/app_localizations_tl.dart @@ -9419,4 +9419,8 @@ class AppLocalizationsTl extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_tr.dart b/app/lib/l10n/app_localizations_tr.dart index 86ff7e7569..b4abf4947e 100644 --- a/app/lib/l10n/app_localizations_tr.dart +++ b/app/lib/l10n/app_localizations_tr.dart @@ -9345,4 +9345,8 @@ class AppLocalizationsTr extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_uk.dart b/app/lib/l10n/app_localizations_uk.dart index e07c123040..ab2645e5ae 100644 --- a/app/lib/l10n/app_localizations_uk.dart +++ b/app/lib/l10n/app_localizations_uk.dart @@ -9352,4 +9352,8 @@ class AppLocalizationsUk extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_ur.dart b/app/lib/l10n/app_localizations_ur.dart index 6b324b1673..5b599b803e 100644 --- a/app/lib/l10n/app_localizations_ur.dart +++ b/app/lib/l10n/app_localizations_ur.dart @@ -9348,4 +9348,8 @@ class AppLocalizationsUr extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_vi.dart b/app/lib/l10n/app_localizations_vi.dart index 7d82ddc400..1b723e219a 100644 --- a/app/lib/l10n/app_localizations_vi.dart +++ b/app/lib/l10n/app_localizations_vi.dart @@ -9332,4 +9332,8 @@ class AppLocalizationsVi extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 3bdc834988..0e4f357b0a 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -9168,4 +9168,8 @@ class AppLocalizationsZh extends AppLocalizations { @override String get offlineModeNote => 'Works with Omi devices for now. Audio stays on your phone until you choose to upload it.'; + + @override + String get offlineModeStorageFull => + 'Your phone is low on storage, so offline recording is paused. Free up space or upload your recordings, then it will resume automatically.'; } From ef648174dbc0cfadbbe247fbb0e53caf8f1b1954 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 17:20:35 +0530 Subject: [PATCH 18/45] feat(app): add RecordingListItem for unsynced recordings A conversations-list row for a local batch recording: time + duration, sync status, swipe-to-delete, and an inline play/pause button that decodes and plays the local audio on device. Tapping opens the WAL detail page. --- .../widgets/recording_list_item.dart | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 app/lib/pages/conversations/widgets/recording_list_item.dart diff --git a/app/lib/pages/conversations/widgets/recording_list_item.dart b/app/lib/pages/conversations/widgets/recording_list_item.dart new file mode 100644 index 0000000000..e9a72897e9 --- /dev/null +++ b/app/lib/pages/conversations/widgets/recording_list_item.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/pages/conversations/wal_item_detail/wal_item_detail_page.dart'; +import 'package:omi/providers/sync_provider.dart'; +import 'package:omi/services/wals.dart'; +import 'package:omi/utils/l10n_extensions.dart'; +import 'package:omi/utils/other/temp.dart'; + +/// A row in the conversations list for an unsynced local recording (a WAL captured +/// in offline/batch mode). Unlike a conversation it has no title/icon yet — it just +/// shows the recording's time + duration, its sync status, and an inline play/pause +/// button that decodes and plays the local audio on device. Tapping the row opens +/// the WAL detail page (upload, share, etc.). +class RecordingListItem extends StatelessWidget { + final Wal wal; + + const RecordingListItem({super.key, required this.wal}); + + String _formatDuration(int seconds) { + final m = seconds ~/ 60; + final s = seconds % 60; + return '$m:${s.toString().padLeft(2, '0')}'; + } + + (Color, String) _status(BuildContext context, bool hasError) { + final l = context.l10n; + if (wal.isSyncing) return (Colors.grey.shade300, l.syncStatusBackingUp); + if (hasError) return (Colors.redAccent, l.failedStatus); + switch (wal.syncDisplayState) { + case WalSyncDisplayState.synced: + return (Colors.grey.shade500, l.syncStatusConversationCreated); + case WalSyncDisplayState.uploaded: + return (Colors.grey.shade400, l.syncStatusUploaded); + case WalSyncDisplayState.retrying: + return (Colors.orangeAccent, l.syncStatusRetrying); + case WalSyncDisplayState.failed: + return (Colors.redAccent, l.syncStatusFailed); + case WalSyncDisplayState.corrupted: + return (Colors.redAccent, l.syncStatusFileUnavailable); + case WalSyncDisplayState.waiting: + case WalSyncDisplayState.syncing: + return (Colors.grey.shade500, l.syncStatusWaiting); + } + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, syncProvider, _) { + final hasError = syncProvider.failedWal?.id == wal.id; + final (statusColor, statusLabel) = _status(context, hasError); + final isPlaying = syncProvider.isWalPlaying(wal.id); + final dt = DateTime.fromMillisecondsSinceEpoch(wal.timerStart * 1000); + final timeStr = dateTimeFormat('h:mm a', dt, locale: Localizations.localeOf(context).languageCode); + + return Padding( + padding: const EdgeInsets.only(top: 12, left: 16, right: 16), + child: Container( + width: double.maxFinite, + decoration: BoxDecoration(color: const Color(0xFF1F1F25), borderRadius: BorderRadius.circular(24.0)), + child: ClipRRect( + borderRadius: BorderRadius.circular(24.0), + child: Dismissible( + key: ValueKey('rec_${wal.id}'), + direction: wal.isSyncing ? DismissDirection.none : DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + color: Colors.red, + padding: const EdgeInsets.only(right: 20), + child: const Icon(Icons.delete, color: Colors.white), + ), + onDismissed: (_) => syncProvider.deleteWal(wal), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () { + Navigator.of(context).push( + MaterialPageRoute(builder: (context) => WalItemDetailPage(wal: wal)), + ); + }, + child: Padding( + padding: const EdgeInsetsDirectional.symmetric(horizontal: 16, vertical: 18), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: Colors.deepPurple.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon(Icons.graphic_eq, color: Colors.deepPurpleAccent, size: 20), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$timeStr · ${_formatDuration(wal.seconds)}', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 3), + Text( + statusLabel, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: statusColor, fontSize: 12), + ), + ], + ), + ), + const SizedBox(width: 10), + GestureDetector( + onTap: () => syncProvider.toggleWalPlayback(wal), + child: Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: Colors.deepPurpleAccent.withValues(alpha: 0.15), + shape: BoxShape.circle, + ), + child: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.deepPurpleAccent, + size: 24, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + }, + ); + } +} From d10b0f481031c35032072e3ae6d0cea51e784d92 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 17:20:36 +0530 Subject: [PATCH 19/45] feat(app): interleave recordings with conversations in date groups ConversationsGroupWidget now merges conversations and unsynced recordings into one time-sorted list under each date header. --- .../widgets/conversations_group_widget.dart | 56 +++++++++++++------ 1 file changed, 39 insertions(+), 17 deletions(-) diff --git a/app/lib/pages/conversations/widgets/conversations_group_widget.dart b/app/lib/pages/conversations/widgets/conversations_group_widget.dart index 60108a986f..7550c66c7e 100644 --- a/app/lib/pages/conversations/widgets/conversations_group_widget.dart +++ b/app/lib/pages/conversations/widgets/conversations_group_widget.dart @@ -1,35 +1,57 @@ import 'package:flutter/material.dart'; import 'package:omi/backend/schema/conversation.dart'; +import 'package:omi/services/wals.dart'; import 'conversation_list_item.dart'; import 'date_list_item.dart'; +import 'recording_list_item.dart'; class ConversationsGroupWidget extends StatelessWidget { final List conversations; + + /// Unsynced local recordings (batch/offline mode) for this date, interleaved with + /// conversations by time. They have no title/icon yet — see [RecordingListItem]. + final List recordings; final DateTime date; final bool isFirst; - const ConversationsGroupWidget({super.key, required this.conversations, required this.date, required this.isFirst}); + const ConversationsGroupWidget({ + super.key, + required this.conversations, + this.recordings = const [], + required this.date, + required this.isFirst, + }); @override Widget build(BuildContext context) { - if (conversations.isNotEmpty) { - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - DateListItem(date: date, isFirst: isFirst), - ...conversations.map((conversation) { + if (conversations.isEmpty && recordings.isEmpty) { + return const SizedBox.shrink(); + } + + // Merge conversations and recordings into one time-sorted list (newest first), + // matching how conversations are ordered within a date. + final entries = <({DateTime time, ServerConversation? convo, Wal? rec})>[ + for (final c in conversations) (time: c.startedAt ?? c.createdAt, convo: c, rec: null), + for (final w in recordings) (time: DateTime.fromMillisecondsSinceEpoch(w.timerStart * 1000), convo: null, rec: w), + ]..sort((a, b) => b.time.compareTo(a.time)); + + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + DateListItem(date: date, isFirst: isFirst), + ...entries.map((e) { + if (e.convo != null) { return ConversationListItem( - key: ValueKey(conversation.id), - conversation: conversation, - conversationIdx: conversations.indexOf(conversation), + key: ValueKey(e.convo!.id), + conversation: e.convo!, + conversationIdx: conversations.indexOf(e.convo!), date: date, ); - }), - const SizedBox(height: 10), - ], - ); - } else { - return const SizedBox.shrink(); - } + } + return RecordingListItem(key: ValueKey('rec_${e.rec!.id}'), wal: e.rec!); + }), + const SizedBox(height: 10), + ], + ); } } From 112f8c881e72b109d2337ffdf3c169aa941e3534 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 17:20:37 +0530 Subject: [PATCH 20/45] feat(app): show unsynced recordings inline on the conversations page Group pendingWals by date, union with conversation dates, and render them in the same list (default view only); empty-state guards account for recordings. --- .../conversations/conversations_page.dart | 36 ++++++++++++++++--- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/app/lib/pages/conversations/conversations_page.dart b/app/lib/pages/conversations/conversations_page.dart index ce491f5a20..c6bf7e0ae9 100644 --- a/app/lib/pages/conversations/conversations_page.dart +++ b/app/lib/pages/conversations/conversations_page.dart @@ -17,6 +17,8 @@ import 'package:omi/pages/conversations/widgets/search_widget.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/providers/capture_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; +import 'package:omi/providers/sync_provider.dart'; +import 'package:omi/services/wals.dart'; import 'package:omi/providers/folder_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/services/app_review_service.dart'; @@ -250,6 +252,26 @@ class _ConversationsPageState extends State with AutomaticKee super.build(context); return Consumer( builder: (context, convoProvider, child) { + // Unsynced local recordings (batch/offline mode) shown inline with conversations, + // grouped into the same date buckets. Only in the default view (no search/folder/ + // starred/daily-summaries filter). + final syncProvider = context.watch(); + final bool showRecordings = convoProvider.previousQuery.isEmpty && + convoProvider.selectedFolderId == null && + !convoProvider.showStarredOnly && + !convoProvider.showDailySummaries; + final recordingsByDate = >{}; + if (showRecordings) { + for (final wal in syncProvider.pendingWals) { + final dt = DateTime.fromMillisecondsSinceEpoch(wal.timerStart * 1000); + final day = DateTime(dt.year, dt.month, dt.day); + (recordingsByDate[day] ??= []).add(wal); + } + } + final bool hasRecordings = recordingsByDate.isNotEmpty; + final mergedDates = {...convoProvider.groupedConversations.keys, ...recordingsByDate.keys}.toList() + ..sort((a, b) => b.compareTo(a)); + return RefreshIndicator( onRefresh: () async { HapticFeedback.mediumImpact(); @@ -369,6 +391,7 @@ class _ConversationsPageState extends State with AutomaticKee if (convoProvider.showDailySummaries) const DailySummariesList() else if (_nonDiscardedConversationCount(convoProvider) == 0 && + !hasRecordings && !convoProvider.isLoadingConversations && !convoProvider.isFetchingConversations && !convoProvider.isAwaitingInitialFetchRetry && @@ -380,6 +403,7 @@ class _ConversationsPageState extends State with AutomaticKee child: Center(child: _buildNoConversationsHero(context)), ) else if (convoProvider.groupedConversations.isEmpty && + !hasRecordings && !convoProvider.isLoadingConversations && !convoProvider.isFetchingConversations && !convoProvider.isAwaitingInitialFetchRetry) @@ -392,17 +416,18 @@ class _ConversationsPageState extends State with AutomaticKee ), ) else if (convoProvider.groupedConversations.isEmpty && + !hasRecordings && (convoProvider.isLoadingConversations || convoProvider.isFetchingConversations || convoProvider.isAwaitingInitialFetchRetry)) _buildLoadingShimmer() else SliverList( - delegate: SliverChildBuilderDelegate(childCount: convoProvider.groupedConversations.length + 1, ( + delegate: SliverChildBuilderDelegate(childCount: mergedDates.length + 1, ( context, index, ) { - if (index == convoProvider.groupedConversations.length) { + if (index == mergedDates.length) { Logger.debug('loading more conversations'); if (convoProvider.isLoadingConversations) { return _buildLoadMoreShimmer(); @@ -427,8 +452,10 @@ class _ConversationsPageState extends State with AutomaticKee child: const SizedBox(height: 20, width: double.maxFinite), ); } else { - var date = convoProvider.groupedConversations.keys.elementAt(index); - List memoriesForDate = convoProvider.groupedConversations[date]!; + var date = mergedDates[index]; + List memoriesForDate = + convoProvider.groupedConversations[date] ?? const []; + List recordingsForDate = recordingsByDate[date] ?? const []; return Column( mainAxisSize: MainAxisSize.min, children: [ @@ -437,6 +464,7 @@ class _ConversationsPageState extends State with AutomaticKee key: ValueKey(date), isFirst: index == 0, conversations: memoriesForDate, + recordings: recordingsForDate, date: date, ), ], From be803fe8b18931b68767b24d62134e79fe181013 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 17:36:07 +0530 Subject: [PATCH 21/45] feat(app): ingest batch recordings on conversations page load --- app/lib/pages/conversations/conversations_page.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/lib/pages/conversations/conversations_page.dart b/app/lib/pages/conversations/conversations_page.dart index c6bf7e0ae9..25d12a1b30 100644 --- a/app/lib/pages/conversations/conversations_page.dart +++ b/app/lib/pages/conversations/conversations_page.dart @@ -66,6 +66,9 @@ class _ConversationsPageState extends State with AutomaticKee if (!mounted) return; + // Surface any unsynced batch recordings written by the native layer. + context.read().ingestBatchRecordings(); + // Load folders for folder tabs final folderProvider = context.read(); if (folderProvider.folders.isEmpty) { From 735dffad00aa3185c8535f5e4831996ff3f3bb7a Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 17:36:08 +0530 Subject: [PATCH 22/45] feat(app): ingest batch recordings on app resume --- app/lib/pages/home/page.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index 2e34e8c9bd..4b3772965c 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -188,7 +188,10 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker // Reload convos if (mounted) { Provider.of(context, listen: false).refreshConversations(); - Provider.of(context, listen: false).refreshInProgressConversations(); + final captureProvider = Provider.of(context, listen: false); + captureProvider.refreshInProgressConversations(); + // Pick up any batch recordings the native layer wrote while backgrounded/closed. + captureProvider.ingestBatchRecordings(); } // Ensure agent VM is running and restart keepalive From 74b6173bb6b61c5a396eb78be6f97001e0b3f8ee Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 18:15:03 +0530 Subject: [PATCH 23/45] feat(app): add batchRecordingDevice marker for offline recordings --- app/lib/utils/batch_recording.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/lib/utils/batch_recording.dart b/app/lib/utils/batch_recording.dart index 437d3d11bd..ddec85d232 100644 --- a/app/lib/utils/batch_recording.dart +++ b/app/lib/utils/batch_recording.dart @@ -1,5 +1,10 @@ import 'package:omi/backend/schema/bt_device/bt_device.dart'; +/// Marker stored in [Wal.device] for recordings produced by offline/batch mode. +/// Lets the conversations list show *only* batch recordings — never the device +/// SD-card/flash sync WALs or realtime offline buffers (which live on the Sync page). +const String batchRecordingDevice = 'omibatch'; + /// Metadata parsed from a batch recording filename written by the native layer: /// /// audio_{device}_{codec}_{sampleRate}_{channel}_fs{frameSize}_{timestamp}.bin From 7c0d96897a73f5ae2e9704f17a97267fc9bad945 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 18:15:03 +0530 Subject: [PATCH 24/45] fix(app): tag batch recordings with marker + re-ingest on BLE disconnect --- app/lib/providers/capture_provider.dart | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/lib/providers/capture_provider.dart b/app/lib/providers/capture_provider.dart index ad6f59ca8f..c65de3f936 100644 --- a/app/lib/providers/capture_provider.dart +++ b/app/lib/providers/capture_provider.dart @@ -1047,7 +1047,7 @@ class CaptureProvider extends ChangeNotifier status: WalStatus.miss, storage: WalStorage.disk, filePath: name, - device: 'omi', + device: batchRecordingDevice, deviceModel: deviceModel, ); } catch (e) { @@ -2057,6 +2057,11 @@ class CaptureProvider extends ChangeNotifier _orphanRecoveryDone = true; recoverOrphanedWals(); } + // On BLE disconnect in batch mode the native writer finalizes the in-progress + // recording (.bin.part -> .bin); pick it up shortly after so it appears in the list. + if (!isConnected && SharedPreferencesUtil().batchModeEnabled) { + Future.delayed(const Duration(seconds: 1), ingestBatchRecordings); + } notifyListeners(); } From cf8c7175ec9baeaffbdf70f4c0ff3835d63dd0f6 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 18:15:04 +0530 Subject: [PATCH 25/45] fix(app): show only batch recordings in the conversations list Filter pendingWals to the batch marker so device SD-card/flash sync WALs and realtime offline buffers no longer appear here (also avoids their duplicate-id collisions). --- app/lib/pages/conversations/conversations_page.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/lib/pages/conversations/conversations_page.dart b/app/lib/pages/conversations/conversations_page.dart index 25d12a1b30..c53e456b3c 100644 --- a/app/lib/pages/conversations/conversations_page.dart +++ b/app/lib/pages/conversations/conversations_page.dart @@ -19,6 +19,7 @@ import 'package:omi/providers/capture_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/sync_provider.dart'; import 'package:omi/services/wals.dart'; +import 'package:omi/utils/batch_recording.dart'; import 'package:omi/providers/folder_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/services/app_review_service.dart'; @@ -265,7 +266,9 @@ class _ConversationsPageState extends State with AutomaticKee !convoProvider.showDailySummaries; final recordingsByDate = >{}; if (showRecordings) { - for (final wal in syncProvider.pendingWals) { + // Only batch/offline recordings — never device SD-card/flash sync WALs or + // realtime offline buffers (those belong to the Sync page). + for (final wal in syncProvider.pendingWals.where((w) => w.device == batchRecordingDevice)) { final dt = DateTime.fromMillisecondsSinceEpoch(wal.timerStart * 1000); final day = DateTime(dt.year, dt.month, dt.day); (recordingsByDate[day] ??= []).add(wal); From e7d5fc37c9c493fe2b172287f7a0a5979893a0c6 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 18:15:05 +0530 Subject: [PATCH 26/45] fix(app): unique recording row key via filePath --- .../pages/conversations/widgets/conversations_group_widget.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/pages/conversations/widgets/conversations_group_widget.dart b/app/lib/pages/conversations/widgets/conversations_group_widget.dart index 7550c66c7e..6395701d7b 100644 --- a/app/lib/pages/conversations/widgets/conversations_group_widget.dart +++ b/app/lib/pages/conversations/widgets/conversations_group_widget.dart @@ -48,7 +48,7 @@ class ConversationsGroupWidget extends StatelessWidget { date: date, ); } - return RecordingListItem(key: ValueKey('rec_${e.rec!.id}'), wal: e.rec!); + return RecordingListItem(key: ValueKey('rec_${e.rec!.filePath ?? e.rec!.id}'), wal: e.rec!); }), const SizedBox(height: 10), ], From cef09b4dfe6f77fc39fa8fb8a2381fca60f91527 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 18:15:05 +0530 Subject: [PATCH 27/45] fix(app): unique Dismissible key for recording rows --- app/lib/pages/conversations/widgets/recording_list_item.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/lib/pages/conversations/widgets/recording_list_item.dart b/app/lib/pages/conversations/widgets/recording_list_item.dart index e9a72897e9..46aef83b14 100644 --- a/app/lib/pages/conversations/widgets/recording_list_item.dart +++ b/app/lib/pages/conversations/widgets/recording_list_item.dart @@ -62,7 +62,7 @@ class RecordingListItem extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(24.0), child: Dismissible( - key: ValueKey('rec_${wal.id}'), + key: ValueKey('rec_${wal.filePath ?? wal.id}'), direction: wal.isSyncing ? DismissDirection.none : DismissDirection.endToStart, background: Container( alignment: Alignment.centerRight, From 18e0a58293dfe5a5246d35e7467159a49c760451 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 18:15:06 +0530 Subject: [PATCH 28/45] fix(android): finalize batch recording on BLE disconnect --- .../src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt b/app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt index eaa9e2bb66..bb3a16013d 100644 --- a/app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt +++ b/app/android/app/src/main/kotlin/com/friend/ios/OmiBleForegroundService.kt @@ -485,6 +485,10 @@ class OmiBleForegroundService : Service() { managed.currentGattHash = null } + // Finalize the in-progress batch recording so it's saved + ingestable right away + // (a plain BLE disconnect never delivers another packet to trigger the gap finalize). + batchAudioWriter.stop("ble_disconnected") + val addr = address.uppercase() val error = when { From 47194ac79dc6cab8abc4cc40cf148b6d7f0b006d Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 18:15:06 +0530 Subject: [PATCH 29/45] fix(ios): finalize batch recording on BLE disconnect --- app/ios/Runner/OmiBleManager.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/ios/Runner/OmiBleManager.swift b/app/ios/Runner/OmiBleManager.swift index 2d5dcc95ab..b4b10bcbc0 100644 --- a/app/ios/Runner/OmiBleManager.swift +++ b/app/ios/Runner/OmiBleManager.swift @@ -641,6 +641,10 @@ extension OmiBleManager: CBCentralManagerDelegate { NSLog("[OmiBle] didDisconnect: \(peripheral.name ?? ""), uuid=\(uuid), error=\(error?.localizedDescription ?? "nil")") cleanupPeripheral(uuid) + // Finalize the in-progress batch recording so it's saved + ingestable right away + // (a plain BLE disconnect never delivers another packet to trigger the gap finalize). + BatchAudioWriter.shared.stop("disconnected") + if !isManual { let reason = Self.bleReasonString(from: error) let code = (error as? CBError)?.code.rawValue ?? -1 From ded4e2a6e72d69d436f7f32711fb5346ad1c7730 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 18:38:16 +0530 Subject: [PATCH 30/45] fix(app): re-ingest batch recordings on BLE disconnect (not network change) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous trigger lived in onConnectionStateChanged, which is wired to network connectivity — so a device disconnect never re-scanned for the finalized recording. Moved to device_provider.onDeviceDisconnected. --- app/lib/providers/device_provider.dart | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/lib/providers/device_provider.dart b/app/lib/providers/device_provider.dart index 64c8b9d163..9ce5a624ff 100644 --- a/app/lib/providers/device_provider.dart +++ b/app/lib/providers/device_provider.dart @@ -385,6 +385,12 @@ class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption captureProvider?.updateRecordingDevice(null); + // Offline/batch mode: the native writer finalizes the in-progress recording on + // disconnect (.bin.part -> .bin). Surface it shortly after the rename completes. + Future.delayed(const Duration(seconds: 1), () { + captureProvider?.ingestBatchRecordings(); + }); + // Wals ServiceManager.instance().wal.getSyncs().sdcard.setDevice(null); ServiceManager.instance().wal.getSyncs().flashPage.setDevice(null); From c6fbd1d9ae53f5356f67173257a4729c1c95b65f Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 18:38:17 +0530 Subject: [PATCH 31/45] chore(app): batch-mode diagnostics (config-saved + ingest-scan logs); drop wrong-event re-ingest --- app/lib/providers/capture_provider.dart | 29 ++++++++++++------------- 1 file changed, 14 insertions(+), 15 deletions(-) diff --git a/app/lib/providers/capture_provider.dart b/app/lib/providers/capture_provider.dart index c65de3f936..f3a528acc5 100644 --- a/app/lib/providers/capture_provider.dart +++ b/app/lib/providers/capture_provider.dart @@ -966,6 +966,8 @@ class CaptureProvider extends ChangeNotifier await SharedPreferencesUtil().saveBool('nativeBleForegroundReady', false); await SharedPreferencesUtil() .saveBool('nativeBleStreamingEnabled', !batchMode && SharedPreferencesUtil().backgroundModeEnabled); + Logger.debug('[batch] config saved: batchMode=$batchMode dir=${docsDir.path} ' + 'deviceId=${device.id} svc=${audioTarget.key} char=${audioTarget.value} type=${device.type.name}'); } MapEntry? _nativeBleAudioTarget(BtDevice device) { @@ -999,25 +1001,27 @@ class CaptureProvider extends ChangeNotifier try { final dirPath = SharedPreferencesUtil().getString('batchAudioDir'); final dir = dirPath.isNotEmpty ? Directory(dirPath) : await getApplicationDocumentsDirectory(); - if (!await dir.exists()) return 0; + if (!await dir.exists()) { + Logger.debug('[batch] ingest: dir does not exist ($dirPath)'); + return 0; + } + + final allFiles = dir.listSync().whereType().map((e) => e.path.split('/').last).toList(); + final binFiles = allFiles.where((n) => n.startsWith('audio_') && n.endsWith('.bin')).toList(); + final partFiles = allFiles.where((n) => n.startsWith('audio_') && n.endsWith('.bin.part')).toList(); + Logger.debug('[batch] ingest scan dir=${dir.path} finalized=${binFiles.length} inProgress=${partFiles.length}'); int added = 0; - for (final entry in dir.listSync()) { - if (entry is! File) continue; - final name = entry.path.split('/').last; - // Only finalized recordings (audio_*.bin). Files still being written are - // *.bin.part and are atomically promoted to *.bin by the native writer. - if (!name.startsWith('audio_') || !name.endsWith('.bin')) continue; + for (final name in binFiles) { if (_ingestedBatchFiles.contains(name)) continue; - - final wal = await _batchWalFromFile(entry, name); + final wal = await _batchWalFromFile(File('${dir.path}/$name'), name); if (wal == null) continue; await _wal.getSyncs().phone.addExternalWal(wal); _ingestedBatchFiles.add(name); added++; } if (added > 0) { - Logger.debug('ingestBatchRecordings: registered $added batch recording(s)'); + Logger.debug('[batch] ingest: registered $added batch recording(s)'); notifyListeners(); } return added; @@ -2057,11 +2061,6 @@ class CaptureProvider extends ChangeNotifier _orphanRecoveryDone = true; recoverOrphanedWals(); } - // On BLE disconnect in batch mode the native writer finalizes the in-progress - // recording (.bin.part -> .bin); pick it up shortly after so it appears in the list. - if (!isConnected && SharedPreferencesUtil().batchModeEnabled) { - Future.delayed(const Duration(seconds: 1), ingestBatchRecordings); - } notifyListeners(); } From 0bf3382c363e8c3e4525188e49dda0b4e6b725ef Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Wed, 17 Jun 2026 18:38:17 +0530 Subject: [PATCH 32/45] chore(ios): one-shot batch-writer diagnostics (no-config / matched-characteristic) --- app/ios/Runner/BatchAudioWriter.swift | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/ios/Runner/BatchAudioWriter.swift b/app/ios/Runner/BatchAudioWriter.swift index 7a9776ba52..9bae6c1c26 100644 --- a/app/ios/Runner/BatchAudioWriter.swift +++ b/app/ios/Runner/BatchAudioWriter.swift @@ -33,6 +33,8 @@ final class BatchAudioWriter { private var lastFsyncMs: Int64 = 0 private var storageFull = false private var recovered = false + private var diagLoggedNoConfig = false + private var diagLoggedMatch = false private let maxFileBytes: Int64 = 32 * 1024 * 1024 // ~32 MB per file private let maxFileSeconds: Int64 = 1800 // 30 min per file @@ -56,13 +58,24 @@ final class BatchAudioWriter { @discardableResult func handle(peripheralUuid: String, serviceUuid: String, characteristicUuid: String, value: Data) -> Bool { guard let config = loadConfig() else { + if !diagLoggedNoConfig { + diagLoggedNoConfig = true + let d = UserDefaults.standard + NSLog("[BatchWriter] no config: batchModeEnabled=\(d.bool(forKey: "flutter.batchModeEnabled")) dir=\(d.string(forKey: "flutter.batchAudioDir") ?? "nil") hasStreamConfig=\(d.string(forKey: "flutter.nativeBleStreamConfig") != nil)") + } queue.async { self.closeCurrentLocked("disabled") } return false } + diagLoggedNoConfig = false guard config.deviceId.lowercased() == peripheralUuid.lowercased() else { return false } guard config.serviceUuid == serviceUuid.lowercased(), config.characteristicUuid == characteristicUuid.lowercased() else { return false } + if !diagLoggedMatch { + diagLoggedMatch = true + NSLog("[BatchWriter] matched audio characteristic — batch capture active (device=\(peripheralUuid), dir=\(config.dir))") + } + let frames = transformFrames(deviceType: config.deviceType, value: value) if !frames.isEmpty { queue.async { self.writeFrames(frames, config: config) } From 8917e3132d9c59dc365fb0e7a4b186faa5a8dbb1 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:34:44 +0530 Subject: [PATCH 33/45] feat(app): add LocalRecording model for batch/offline-mode recordings --- app/lib/models/local_recording.dart | 92 +++++++++++++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 app/lib/models/local_recording.dart diff --git a/app/lib/models/local_recording.dart b/app/lib/models/local_recording.dart new file mode 100644 index 0000000000..54e191e5f5 --- /dev/null +++ b/app/lib/models/local_recording.dart @@ -0,0 +1,92 @@ +import 'package:omi/backend/schema/bt_device/bt_device.dart'; +import 'package:omi/utils/batch_recording.dart'; + +/// Lifecycle of a local recording captured in batch/offline mode. +enum LocalRecordingState { + /// On disk, not yet uploaded. The user can transcribe it. + pending, + + /// Upload in flight (multipart POST to /v2/sync-local-files). + uploading, + + /// Uploaded; the server is transcribing in the background (job reconciling). + processing, + + /// The last upload attempt failed; the file is intact and can be retried. + failed, +} + +/// A recording captured in batch/offline mode and written natively to the phone +/// as a length-prefixed `.bin` file (see [BatchRecordingInfo]). +/// +/// Unlike a WAL it is **not** backed by the offline-sync store — the file on +/// disk is the single source of truth and this object is derived on demand by +/// scanning the recordings directory (fieldy-style). Uploading one turns it +/// into a conversation. See [LocalRecordingsProvider]. +class LocalRecording { + /// The `.bin` file name. Doubles as the stable id and the relative path that + /// `AudioPlayerUtils`/`Wal.getFilePath` resolve against the app documents dir. + final String fileName; + + /// Absolute path on disk. + final String filePath; + + /// Recording start time, unix seconds (parsed from the filename). + final int timerStart; + + final BleAudioCodec codec; + final int frameSize; + final int sizeBytes; + + /// Estimated duration in seconds (the backend computes the exact value). + final int seconds; + + /// Server job id once uploaded (HTTP 202); null while [LocalRecordingState.pending]. + final String? jobId; + + final LocalRecordingState state; + + const LocalRecording({ + required this.fileName, + required this.filePath, + required this.timerStart, + required this.codec, + required this.frameSize, + required this.sizeBytes, + required this.seconds, + required this.state, + this.jobId, + }); + + String get id => fileName; + + DateTime get startedAt => DateTime.fromMillisecondsSinceEpoch(timerStart * 1000); + + /// True while uploading or processing — playback/delete stay allowed, but a + /// second upload must not start. + bool get isBusy => state == LocalRecordingState.uploading || state == LocalRecordingState.processing; + + /// Build from a finalized batch `.bin` file on disk. Returns null if [fileName] + /// isn't a parseable recording filename or the file is empty. + static LocalRecording? fromFile({ + required String fileName, + required String filePath, + required int sizeBytes, + String? jobId, + required LocalRecordingState state, + }) { + final info = BatchRecordingInfo.fromFileName(fileName); + if (info == null || sizeBytes <= 0) return null; + return LocalRecording( + fileName: fileName, + filePath: filePath, + timerStart: info.timerStart, + codec: info.codec, + frameSize: info.frameSize, + sizeBytes: sizeBytes, + seconds: info.estimateSeconds(sizeBytes), + jobId: jobId, + state: state, + ); + } +} From c411d1dc8fc64fbffceedfa105a914e2e081ab90 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:34:45 +0530 Subject: [PATCH 34/45] =?UTF-8?q?feat(app):=20LocalRecordingsProvider=20?= =?UTF-8?q?=E2=80=94=20scan/upload/reconcile=20local=20recordings=20(files?= =?UTF-8?q?ystem-as-queue)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../providers/local_recordings_provider.dart | 362 ++++++++++++++++++ 1 file changed, 362 insertions(+) create mode 100644 app/lib/providers/local_recordings_provider.dart diff --git a/app/lib/providers/local_recordings_provider.dart b/app/lib/providers/local_recordings_provider.dart new file mode 100644 index 0000000000..406117b863 --- /dev/null +++ b/app/lib/providers/local_recordings_provider.dart @@ -0,0 +1,362 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +import 'package:omi/backend/preferences.dart'; +import 'package:omi/models/local_recording.dart'; +import 'package:omi/providers/conversation_provider.dart'; +import 'package:omi/services/wals.dart'; +import 'package:omi/utils/audio_player_utils.dart'; +import 'package:omi/utils/batch_recording.dart'; +import 'package:omi/utils/conversation_sync_utils.dart'; +import 'package:omi/utils/logger.dart'; +import 'package:omi/utils/waveform_utils.dart'; + +/// Owns the batch/offline-mode recordings captured natively to the phone. +/// +/// Design (fieldy-style): the recordings directory is the queue and the `.bin` +/// files are the single source of truth — there is no WAL/offline-sync entry. +/// The list is derived by scanning the dir; uploading a recording turns it into +/// a conversation. Only the truly-stateless leaf helpers are reused: +/// [uploadLocalFilesV2] (the `/v2/sync-local-files` call), [fetchSyncJobStatus] +/// (reconcile), and [AudioPlayerUtils] (opus playback, fed a transient [Wal]). +/// +/// Robustness beyond fieldy: an in-flight upload's `jobId` is persisted in a +/// tiny sidecar (SharedPreferences) so an app-kill mid-processing reconciles on +/// next launch, and the local file is deleted only once the job reports +/// `completed` (never fire-and-forget). +class LocalRecordingsProvider extends ChangeNotifier { + final AudioPlayerUtils _audio = AudioPlayerUtils.instance; + + // Sidecar: fileName -> server jobId for recordings uploaded but not yet + // confirmed transcribed. Persisted as JSON under [_jobsPrefKey]. + static const String _jobsPrefKey = 'localRecordingJobs'; + Map _jobs = {}; + + List _recordings = []; + List get recordings => _recordings; + + bool _isLoading = false; + bool get isLoading => _isLoading; + + // Single-flight upload guard + the file currently uploading (for its state). + bool _isUploading = false; + String? _uploadingName; + String? _failedName; // last upload that errored (file intact, retriable) + + Timer? _reconcileTimer; + bool _disposed = false; + + ConversationProvider? _conversationProvider; + + LocalRecordingsProvider() { + _audio.addListener(_onAudioChanged); + _jobs = _loadJobs(); + refresh(); + if (_jobs.isNotEmpty) { + _startReconcileTimer(); + _reconcile(); + } + } + + /// Wired from main.dart so a finished transcription can surface its + /// conversation into the list the user is looking at. + void setConversationProvider(ConversationProvider provider) { + _conversationProvider = provider; + } + + // ───────────────────────── scanning ───────────────────────── + + Future _dir() async { + final configured = SharedPreferencesUtil().getString('batchAudioDir'); + if (configured.isNotEmpty) return Directory(configured); + return getApplicationDocumentsDirectory(); + } + + LocalRecordingState _stateFor(String name) { + if (name == _uploadingName) return LocalRecordingState.uploading; + if (_jobs.containsKey(name)) return LocalRecordingState.processing; + if (name == _failedName) return LocalRecordingState.failed; + return LocalRecordingState.pending; + } + + /// Rescan the recordings directory and rebuild [recordings]. Cheap and + /// idempotent — call it on app foreground, after a BLE disconnect, etc. + Future refresh() async { + _isLoading = true; + notifyListeners(); + try { + final dir = await _dir(); + if (dir == null || !await dir.exists()) { + _recordings = []; + return; + } + final list = []; + for (final entity in dir.listSync().whereType()) { + final name = entity.path.split('/').last; + // Only batch recordings (audio_omibatch_*) — never offline-sync WAL flushes, + // which share this directory and the same audio_*.bin naming. The native + // writer stamps the `batchRecordingDevice` marker into the device segment. + if (!name.startsWith('audio_${batchRecordingDevice}_') || !name.endsWith('.bin')) continue; + final size = await entity.length(); + final rec = LocalRecording.fromFile( + fileName: name, + filePath: entity.path, + sizeBytes: size, + jobId: _jobs[name], + state: _stateFor(name), + ); + if (rec != null) list.add(rec); + } + list.sort((a, b) => b.timerStart.compareTo(a.timerStart)); + _recordings = list; + } catch (e) { + Logger.error('LocalRecordings: scan failed: $e'); + } finally { + _isLoading = false; + // Resume polling if recordings are still awaiting transcription (e.g. the + // timer was dropped while backgrounded and we just resumed). + if (_jobs.isNotEmpty) _startReconcileTimer(); + if (!_disposed) notifyListeners(); + } + } + + LocalRecording? getById(String id) { + for (final r in _recordings) { + if (r.id == id) return r; + } + return null; + } + + // ───────────────────── upload / transcribe ───────────────────── + + /// Upload a single recording → backend transcribes it into a conversation. + /// 200 fast-path: delete the file + surface the conversation immediately. + /// 202 queued: persist the jobId and let the reconciler finish it. + Future upload(LocalRecording rec) async { + if (_isUploading || rec.isBusy) return; + if (SyncRateLimiter.instance.isLimited) { + notifyListeners(); + return; + } + + _isUploading = true; + _uploadingName = rec.fileName; + _failedName = null; + await refresh(); + + try { + final file = File(rec.filePath); + if (!file.existsSync()) { + print('[recordings] upload aborted, file missing: ${rec.fileName}'); + return; + } + print('[recordings] uploading ${rec.fileName} (${rec.sizeBytes} bytes)'); + final result = await uploadLocalFilesV2([file]); + SyncRateLimiter.instance.clear(); + + if (result.completed != null) { + print('[recordings] ${rec.fileName} transcribed synchronously (fast-path)'); + await _deleteFileOnly(rec.fileName); + await _surface(result.completed!.newConversationIds, result.completed!.updatedConversationIds); + } else if (result.jobId != null) { + _jobs[rec.fileName] = result.jobId!; + await _saveJobs(); + print('[recordings] queued ${rec.fileName} jobId=${result.jobId} (polling every 15s)'); + _startReconcileTimer(); + } + } on SyncRateLimitedException catch (e) { + print('[recordings] rate-limited uploading ${rec.fileName}, retry after ${e.retryAfterSeconds}s'); + SyncRateLimiter.instance.markLimited(retryAfterSeconds: e.retryAfterSeconds); + } catch (e) { + _failedName = rec.fileName; + print('[recordings] upload FAILED for ${rec.fileName}: $e'); + } finally { + _isUploading = false; + _uploadingName = null; + await refresh(); + } + } + + // ───────────────────────── reconcile ───────────────────────── + + void _startReconcileTimer() { + _reconcileTimer ??= Timer.periodic(const Duration(seconds: 15), (_) => _reconcile()); + } + + void _stopReconcileTimer() { + _reconcileTimer?.cancel(); + _reconcileTimer = null; + } + + /// Poll every pending job once. `completed` → delete file + surface the + /// conversation. `failed`/`notFound` → drop the job; the file stays on disk + /// so it reverts to a pending, retriable recording. + Future _reconcile() async { + if (_jobs.isEmpty) { + _stopReconcileTimer(); + return; + } + final newIds = []; + final updIds = []; + bool changed = false; + + for (final entry in Map.from(_jobs).entries) { + final name = entry.key; + final jobId = entry.value; + SyncJobFetch fetch; + try { + fetch = await fetchSyncJobStatus(jobId); + } catch (e) { + print('[recordings] poll error $name job=$jobId: $e'); + continue; // transient — retry next tick + } + final st = fetch.status; + print('[recordings] poll $name job=$jobId outcome=${fetch.outcome.name}' + '${st != null ? ' status=${st.status} terminal=${st.isTerminal}' : ''}'); + switch (fetch.outcome) { + case SyncJobFetchOutcome.transient: + break; + case SyncJobFetchOutcome.notFound: + _jobs.remove(name); + changed = true; + break; + case SyncJobFetchOutcome.ok: + final s = fetch.status!; + if (!s.isTerminal) break; + if (s.result != null) { + newIds.addAll(s.result!.newConversationIds); + updIds.addAll(s.result!.updatedConversationIds); + } + if (s.status == 'completed') { + await _deleteFileOnly(name); + } + // completed or failed: stop tracking. On failure the file is kept + // (only `completed` deletes it) so the recording becomes pending again. + _jobs.remove(name); + changed = true; + break; + } + } + + if (changed) await _saveJobs(); + if (newIds.isNotEmpty || updIds.isNotEmpty) await _surface(newIds, updIds); + await refresh(); + if (_jobs.isEmpty) _stopReconcileTimer(); + } + + Future _surface(List newIds, List updatedIds) async { + if (_conversationProvider == null) return; + if (newIds.isEmpty && updatedIds.isEmpty) return; + try { + final pointers = await ConversationSyncUtils.processConversationIds( + newConversationIds: newIds, + updatedConversationIds: updatedIds, + ); + for (final p in pointers) { + _conversationProvider!.upsertConversation(p.conversation); + } + } catch (e) { + Logger.error('LocalRecordings: surfacing conversations failed: $e'); + } + } + + // ───────────────────────── delete ───────────────────────── + + /// Delete a recording the user no longer wants. Stops playback first. + Future delete(LocalRecording rec) async { + if (isPlaying(rec)) { + await togglePlayback(rec); + } + await _deleteFileOnly(rec.fileName); + if (_jobs.remove(rec.fileName) != null) await _saveJobs(); + await refresh(); + } + + Future _deleteFileOnly(String fileName) async { + try { + final dir = await _dir(); + if (dir == null) return; + final file = File('${dir.path}/$fileName'); + if (file.existsSync()) await file.delete(); + } catch (e) { + Logger.error('LocalRecordings: delete failed for $fileName: $e'); + } + } + + // ───────────────────── playback / waveform ───────────────────── + + /// A throwaway [Wal] used only to drive [AudioPlayerUtils] (opus decode + + /// playback). Never stored anywhere. `filePath` is the relative name, which + /// `Wal.getFilePath` resolves against the app documents dir (== batchAudioDir). + Wal _walFor(LocalRecording r) => Wal( + timerStart: r.timerStart, + codec: r.codec, + seconds: r.seconds, + sampleRate: 16000, + channel: 1, + status: WalStatus.miss, + storage: WalStorage.disk, + filePath: r.fileName, + device: batchRecordingDevice, + ); + + String? get currentPlayingId => _audio.currentPlayingId; + bool get isProcessingAudio => _audio.isProcessingAudio; + Duration get currentPosition => _audio.currentPosition; + Duration get totalDuration => _audio.totalDuration; + double get playbackProgress => _audio.playbackProgress; + + bool isPlaying(LocalRecording r) => _audio.isPlaying(_walFor(r).id); + bool canPlay(LocalRecording r) => _audio.canPlayOrShare(_walFor(r)); + Future togglePlayback(LocalRecording r) => _audio.togglePlayback(_walFor(r)); + Future share(LocalRecording r) => _audio.shareAsAudio(_walFor(r)); + Future seekTo(Duration position) => _audio.seekToPosition(position); + Future skipForward() => _audio.skipForward(); + Future skipBackward() => _audio.skipBackward(); + + Future?> getWaveform(LocalRecording r) async { + final wal = _walFor(r); + String? wavPath = _audio.getCachedAudioPath(wal.id); + if (wavPath == null && _audio.canPlayOrShare(wal)) { + wavPath = await _audio.ensureAudioFileExists(wal); + } + return compute(_generateWaveform, {'id': wal.id, 'path': wavPath}); + } + + static Future?> _generateWaveform(Map params) { + return WaveformUtils.generateWaveform(params['id'] as String, params['path'] as String?); + } + + // ───────────────────────── sidecar ───────────────────────── + + Map _loadJobs() { + try { + final raw = SharedPreferencesUtil().getString(_jobsPrefKey); + if (raw.isEmpty) return {}; + final decoded = jsonDecode(raw) as Map; + return decoded.map((k, v) => MapEntry(k, v.toString())); + } catch (_) { + return {}; + } + } + + Future _saveJobs() async { + await SharedPreferencesUtil().saveString(_jobsPrefKey, jsonEncode(_jobs)); + } + + void _onAudioChanged() { + if (!_disposed) notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + _stopReconcileTimer(); + _audio.removeListener(_onAudioChanged); + super.dispose(); + } +} From bc349bfd979b2056d949f3e2e8dd29734b4b410e Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:34:46 +0530 Subject: [PATCH 35/45] feat(app): recording detail page (playback + transcribe + share/delete/info) --- .../recording_detail_page.dart | 387 ++++++++++++++++++ 1 file changed, 387 insertions(+) create mode 100644 app/lib/pages/conversations/recording_detail/recording_detail_page.dart diff --git a/app/lib/pages/conversations/recording_detail/recording_detail_page.dart b/app/lib/pages/conversations/recording_detail/recording_detail_page.dart new file mode 100644 index 0000000000..4df694ea26 --- /dev/null +++ b/app/lib/pages/conversations/recording_detail/recording_detail_page.dart @@ -0,0 +1,387 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:provider/provider.dart'; + +import 'package:omi/models/local_recording.dart'; +import 'package:omi/models/playback_state.dart'; +import 'package:omi/providers/local_recordings_provider.dart'; +import 'package:omi/ui/molecules/omi_confirm_dialog.dart'; +import 'package:omi/utils/l10n_extensions.dart'; +import 'package:omi/utils/other/temp.dart'; +import 'package:omi/utils/other/time_utils.dart'; +import 'package:omi/widgets/waveform_section.dart'; + +/// Detail screen for a batch/offline-mode recording captured locally. Unlike the +/// WAL detail page it has no SD/flash transfer flow (recordings are always on the +/// phone) and adds a primary "transcribe" action that uploads the file and turns +/// it into a conversation. Backed entirely by [LocalRecordingsProvider]. +class RecordingDetailPage extends StatefulWidget { + final LocalRecording recording; + + const RecordingDetailPage({super.key, required this.recording}); + + @override + State createState() => _RecordingDetailPageState(); +} + +class _RecordingDetailPageState extends State { + List? _waveformData; + bool _isProcessingWaveform = false; + LocalRecordingsProvider? _provider; + + @override + void initState() { + super.initState(); + _generateWaveform(); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _provider = context.read(); + } + + @override + void dispose() { + // Stop playback when leaving the page. + if (_provider != null && _provider!.isPlaying(widget.recording)) { + _provider!.togglePlayback(widget.recording); + } + super.dispose(); + } + + Future _generateWaveform() async { + if (!mounted) return; + setState(() => _isProcessingWaveform = true); + final data = await context.read().getWaveform(widget.recording); + if (mounted) { + setState(() { + _waveformData = data; + _isProcessingWaveform = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + elevation: 0, + automaticallyImplyLeading: true, + title: Text(context.l10n.recordingDetails, style: Theme.of(context).textTheme.titleLarge), + centerTitle: true, + actions: [ + IconButton( + icon: const Icon(Icons.more_horiz, color: Colors.white), + onPressed: () => _showOptionsMenu(context), + ), + ], + ), + backgroundColor: Theme.of(context).colorScheme.primary, + body: Consumer( + builder: (context, provider, child) { + final rec = provider.getById(widget.recording.id) ?? widget.recording; + final isPlaying = provider.isPlaying(rec); + final playbackState = PlaybackState( + isPlaying: isPlaying, + isProcessing: provider.isProcessingAudio && isPlaying, + canPlayOrShare: provider.canPlay(rec), + isSynced: false, + hasError: rec.state == LocalRecordingState.failed, + currentPosition: provider.currentPosition, + totalDuration: provider.totalDuration, + playbackProgress: provider.playbackProgress, + ); + + return Column( + children: [ + // Title section + Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: Column( + children: [ + Text( + dateTimeFormat('dd MMM yyyy', rec.startedAt), + style: + Theme.of(context).textTheme.titleLarge!.copyWith(fontSize: 28, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 4), + Text( + dateTimeFormat('H:mm', rec.startedAt), + style: Theme.of(context).textTheme.bodyMedium!.copyWith( + color: Colors.grey.shade400, + fontSize: 16, + fontWeight: FontWeight.w400, + ), + ), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.security, color: Colors.grey.shade400, size: 14), + const SizedBox(width: 6), + Text( + context.l10n.privateAndSecureOnDevice, + style: TextStyle(color: Colors.grey.shade400, fontSize: 12, fontWeight: FontWeight.w500), + ), + ], + ), + ), + ], + ), + ), + + // Waveform — dominant space + Expanded( + flex: 6, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: WaveformSection( + seconds: rec.seconds, + waveformData: _waveformData, + isProcessingWaveform: _isProcessingWaveform, + playbackState: playbackState, + isPlaying: isPlaying, + ), + ), + ), + + // Timer + Padding( + padding: const EdgeInsets.symmetric(vertical: 24), + child: Text( + _formatPosition(isPlaying ? provider.currentPosition : Duration.zero), + style: Theme.of(context) + .textTheme + .titleLarge! + .copyWith(fontSize: 48, fontWeight: FontWeight.w300, letterSpacing: 2), + ), + ), + + // Transport controls + Padding( + padding: const EdgeInsets.symmetric(horizontal: 40, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildControlButton( + icon: Icons.replay_10, + onPressed: playbackState.canPlayOrShare && isPlaying ? () => provider.skipBackward() : null, + size: 60, + ), + _buildControlButton( + icon: playbackState.isProcessing + ? Icons.hourglass_empty + : (isPlaying ? Icons.pause : Icons.play_arrow), + size: 80, + backgroundColor: Theme.of(context).colorScheme.secondary, + iconColor: Colors.white, + onPressed: playbackState.canPlayOrShare && !playbackState.isProcessing + ? () => provider.togglePlayback(rec) + : null, + ), + _buildControlButton( + icon: Icons.forward_10, + onPressed: playbackState.canPlayOrShare && isPlaying ? () => provider.skipForward() : null, + size: 60, + ), + ], + ), + ), + + // Primary action: transcribe (upload → conversation) + Padding( + padding: const EdgeInsets.fromLTRB(40, 12, 40, 32), + child: SizedBox( + width: double.infinity, + height: 56, + child: ElevatedButton( + onPressed: rec.isBusy ? null : () => _handleTranscribe(provider, rec), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.deepPurpleAccent, + disabledBackgroundColor: Colors.deepPurple.withValues(alpha: 0.3), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (rec.isBusy) + const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white), + ) + else + const Icon(Icons.cloud_upload_outlined, color: Colors.white, size: 22), + const SizedBox(width: 12), + Text( + rec.isBusy ? context.l10n.syncStatusUploaded : context.l10n.syncNow, + style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), + ), + ], + ), + ), + ), + ), + ], + ); + }, + ), + ); + } + + String _formatPosition(Duration duration) { + final minutes = duration.inMinutes.remainder(60); + final seconds = duration.inSeconds.remainder(60); + final centis = (duration.inMilliseconds.remainder(1000) / 10).floor(); + return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')},${centis.toString().padLeft(2, '0')}'; + } + + Widget _buildControlButton({ + required IconData icon, + VoidCallback? onPressed, + double size = 48, + Color? backgroundColor, + Color? iconColor, + }) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: backgroundColor ?? Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + ), + child: IconButton( + onPressed: onPressed, + icon: Icon(icon, color: iconColor ?? Colors.white, size: size * 0.4), + ), + ); + } + + Future _handleTranscribe(LocalRecordingsProvider provider, LocalRecording rec) async { + await provider.upload(rec); + if (mounted) Navigator.of(context).maybePop(); + } + + void _showOptionsMenu(BuildContext context) { + final provider = context.read(); + final rec = provider.getById(widget.recording.id) ?? widget.recording; + + showModalBottomSheet( + context: context, + backgroundColor: const Color(0xFF1F1F25), + builder: (sheetContext) => Container( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.info_outline, color: Colors.white), + title: Text(context.l10n.recordingInfo, style: Theme.of(sheetContext).textTheme.bodyMedium), + onTap: () { + Navigator.pop(sheetContext); + _showFileDetailsDialog(context, rec); + }, + ), + ListTile( + leading: const FaIcon(FontAwesomeIcons.share, color: Colors.white, size: 18), + title: Text(context.l10n.shareRecording, style: Theme.of(sheetContext).textTheme.bodyMedium), + onTap: () { + Navigator.pop(sheetContext); + provider.share(rec); + }, + ), + ListTile( + leading: Icon(Icons.delete, color: rec.isBusy ? Colors.grey : Colors.red), + title: Text( + context.l10n.deleteRecording, + style: + Theme.of(sheetContext).textTheme.bodyMedium!.copyWith(color: rec.isBusy ? Colors.grey : Colors.red), + ), + onTap: rec.isBusy + ? null + : () { + Navigator.pop(sheetContext); + _showDeleteDialog(context, provider, rec); + }, + ), + ], + ), + ), + ); + } + + void _showDeleteDialog(BuildContext context, LocalRecordingsProvider provider, LocalRecording rec) async { + final navigator = Navigator.of(context); + final confirmed = await OmiConfirmDialog.show( + context, + title: context.l10n.deleteRecording, + message: context.l10n.deleteRecordingConfirmation, + confirmLabel: context.l10n.delete, + confirmColor: Colors.red, + ); + if (confirmed == true && mounted) { + navigator.pop(); + provider.delete(rec); + } + } + + void _showFileDetailsDialog(BuildContext context, LocalRecording rec) { + final theme = Theme.of(context); + showDialog( + context: context, + builder: (context) => AlertDialog( + backgroundColor: const Color(0xFF1A1A1A), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildDetailRow(context.l10n.dateTimeLabel, dateTimeFormat('MMM dd, yyyy h:mm:ss a', rec.startedAt)), + _buildDetailRow(context.l10n.durationLabel, secondsToHumanReadable(rec.seconds, context)), + _buildDetailRow(context.l10n.audioFormatLabel, rec.codec.toFormattedString()), + _buildDetailRow(context.l10n.estimatedSizeLabel, _formatBytes(rec.sizeBytes)), + ], + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text( + context.l10n.close, + style: theme.textTheme.labelMedium?.copyWith(color: theme.colorScheme.secondary), + ), + ), + ], + ), + ); + } + + Widget _buildDetailRow(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: Theme.of(context).textTheme.labelMedium!.copyWith(color: Colors.grey.shade400)), + const SizedBox(height: 2), + Text(value, style: Theme.of(context).textTheme.bodyMedium), + ], + ), + ); + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } +} From 830db50535ac2b44feaa5054662e76ce5144fde3 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:35:06 +0530 Subject: [PATCH 36/45] refactor(app): recording list item uses LocalRecording + LocalRecordingsProvider --- .../widgets/recording_list_item.dart | 70 ++++++++----------- 1 file changed, 31 insertions(+), 39 deletions(-) diff --git a/app/lib/pages/conversations/widgets/recording_list_item.dart b/app/lib/pages/conversations/widgets/recording_list_item.dart index 46aef83b14..b72f5da3c6 100644 --- a/app/lib/pages/conversations/widgets/recording_list_item.dart +++ b/app/lib/pages/conversations/widgets/recording_list_item.dart @@ -1,21 +1,21 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:omi/pages/conversations/wal_item_detail/wal_item_detail_page.dart'; -import 'package:omi/providers/sync_provider.dart'; -import 'package:omi/services/wals.dart'; +import 'package:omi/models/local_recording.dart'; +import 'package:omi/pages/conversations/recording_detail/recording_detail_page.dart'; +import 'package:omi/providers/local_recordings_provider.dart'; import 'package:omi/utils/l10n_extensions.dart'; import 'package:omi/utils/other/temp.dart'; -/// A row in the conversations list for an unsynced local recording (a WAL captured -/// in offline/batch mode). Unlike a conversation it has no title/icon yet — it just -/// shows the recording's time + duration, its sync status, and an inline play/pause -/// button that decodes and plays the local audio on device. Tapping the row opens -/// the WAL detail page (upload, share, etc.). +/// A row in the conversations list for a batch/offline-mode recording captured +/// locally. Unlike a conversation it has no title/icon yet — it shows the +/// recording's time + duration, its state, and an inline play/pause button that +/// decodes and plays the local audio on device. Tapping opens the recording +/// detail page (transcribe, share, delete). class RecordingListItem extends StatelessWidget { - final Wal wal; + final LocalRecording recording; - const RecordingListItem({super.key, required this.wal}); + const RecordingListItem({super.key, required this.recording}); String _formatDuration(int seconds) { final m = seconds ~/ 60; @@ -23,36 +23,28 @@ class RecordingListItem extends StatelessWidget { return '$m:${s.toString().padLeft(2, '0')}'; } - (Color, String) _status(BuildContext context, bool hasError) { + (Color, String) _status(BuildContext context) { final l = context.l10n; - if (wal.isSyncing) return (Colors.grey.shade300, l.syncStatusBackingUp); - if (hasError) return (Colors.redAccent, l.failedStatus); - switch (wal.syncDisplayState) { - case WalSyncDisplayState.synced: - return (Colors.grey.shade500, l.syncStatusConversationCreated); - case WalSyncDisplayState.uploaded: + switch (recording.state) { + case LocalRecordingState.uploading: + return (Colors.grey.shade300, l.syncStatusBackingUp); + case LocalRecordingState.processing: return (Colors.grey.shade400, l.syncStatusUploaded); - case WalSyncDisplayState.retrying: - return (Colors.orangeAccent, l.syncStatusRetrying); - case WalSyncDisplayState.failed: - return (Colors.redAccent, l.syncStatusFailed); - case WalSyncDisplayState.corrupted: - return (Colors.redAccent, l.syncStatusFileUnavailable); - case WalSyncDisplayState.waiting: - case WalSyncDisplayState.syncing: - return (Colors.grey.shade500, l.syncStatusWaiting); + case LocalRecordingState.failed: + return (Colors.redAccent, l.failedStatus); + case LocalRecordingState.pending: + return (Colors.grey.shade500, l.privateAndSecureOnDevice); } } @override Widget build(BuildContext context) { - return Consumer( - builder: (context, syncProvider, _) { - final hasError = syncProvider.failedWal?.id == wal.id; - final (statusColor, statusLabel) = _status(context, hasError); - final isPlaying = syncProvider.isWalPlaying(wal.id); - final dt = DateTime.fromMillisecondsSinceEpoch(wal.timerStart * 1000); - final timeStr = dateTimeFormat('h:mm a', dt, locale: Localizations.localeOf(context).languageCode); + return Consumer( + builder: (context, provider, _) { + final (statusColor, statusLabel) = _status(context); + final isPlaying = provider.isPlaying(recording); + final timeStr = + dateTimeFormat('h:mm a', recording.startedAt, locale: Localizations.localeOf(context).languageCode); return Padding( padding: const EdgeInsets.only(top: 12, left: 16, right: 16), @@ -62,20 +54,20 @@ class RecordingListItem extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(24.0), child: Dismissible( - key: ValueKey('rec_${wal.filePath ?? wal.id}'), - direction: wal.isSyncing ? DismissDirection.none : DismissDirection.endToStart, + key: ValueKey('rec_${recording.id}'), + direction: recording.isBusy ? DismissDirection.none : DismissDirection.endToStart, background: Container( alignment: Alignment.centerRight, color: Colors.red, padding: const EdgeInsets.only(right: 20), child: const Icon(Icons.delete, color: Colors.white), ), - onDismissed: (_) => syncProvider.deleteWal(wal), + onDismissed: (_) => provider.delete(recording), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: () { Navigator.of(context).push( - MaterialPageRoute(builder: (context) => WalItemDetailPage(wal: wal)), + MaterialPageRoute(builder: (context) => RecordingDetailPage(recording: recording)), ); }, child: Padding( @@ -97,7 +89,7 @@ class RecordingListItem extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '$timeStr · ${_formatDuration(wal.seconds)}', + '$timeStr · ${_formatDuration(recording.seconds)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600), @@ -114,7 +106,7 @@ class RecordingListItem extends StatelessWidget { ), const SizedBox(width: 10), GestureDetector( - onTap: () => syncProvider.toggleWalPlayback(wal), + onTap: () => provider.togglePlayback(recording), child: Container( width: 44, height: 44, From 2e3067d1c530952590e5cb63e28f4270f42de196 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:35:07 +0530 Subject: [PATCH 37/45] refactor(app): conversations group widget takes List --- .../widgets/conversations_group_widget.dart | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/lib/pages/conversations/widgets/conversations_group_widget.dart b/app/lib/pages/conversations/widgets/conversations_group_widget.dart index 6395701d7b..6e47aca38a 100644 --- a/app/lib/pages/conversations/widgets/conversations_group_widget.dart +++ b/app/lib/pages/conversations/widgets/conversations_group_widget.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:omi/backend/schema/conversation.dart'; -import 'package:omi/services/wals.dart'; +import 'package:omi/models/local_recording.dart'; import 'conversation_list_item.dart'; import 'date_list_item.dart'; import 'recording_list_item.dart'; @@ -11,7 +11,7 @@ class ConversationsGroupWidget extends StatelessWidget { /// Unsynced local recordings (batch/offline mode) for this date, interleaved with /// conversations by time. They have no title/icon yet — see [RecordingListItem]. - final List recordings; + final List recordings; final DateTime date; final bool isFirst; const ConversationsGroupWidget({ @@ -30,9 +30,9 @@ class ConversationsGroupWidget extends StatelessWidget { // Merge conversations and recordings into one time-sorted list (newest first), // matching how conversations are ordered within a date. - final entries = <({DateTime time, ServerConversation? convo, Wal? rec})>[ + final entries = <({DateTime time, ServerConversation? convo, LocalRecording? rec})>[ for (final c in conversations) (time: c.startedAt ?? c.createdAt, convo: c, rec: null), - for (final w in recordings) (time: DateTime.fromMillisecondsSinceEpoch(w.timerStart * 1000), convo: null, rec: w), + for (final r in recordings) (time: r.startedAt, convo: null, rec: r), ]..sort((a, b) => b.time.compareTo(a.time)); return Column( @@ -48,7 +48,7 @@ class ConversationsGroupWidget extends StatelessWidget { date: date, ); } - return RecordingListItem(key: ValueKey('rec_${e.rec!.filePath ?? e.rec!.id}'), wal: e.rec!); + return RecordingListItem(key: ValueKey('rec_${e.rec!.id}'), recording: e.rec!); }), const SizedBox(height: 10), ], From 0d2876c4ebc597fdd36f79e23b3bc4873a65827f Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:35:07 +0530 Subject: [PATCH 38/45] refactor(app): conversations page reads LocalRecordingsProvider.recordings --- .../conversations/conversations_page.dart | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/app/lib/pages/conversations/conversations_page.dart b/app/lib/pages/conversations/conversations_page.dart index c53e456b3c..4fd471ed9d 100644 --- a/app/lib/pages/conversations/conversations_page.dart +++ b/app/lib/pages/conversations/conversations_page.dart @@ -17,9 +17,8 @@ import 'package:omi/pages/conversations/widgets/search_widget.dart'; import 'package:omi/backend/preferences.dart'; import 'package:omi/providers/capture_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; -import 'package:omi/providers/sync_provider.dart'; -import 'package:omi/services/wals.dart'; -import 'package:omi/utils/batch_recording.dart'; +import 'package:omi/providers/local_recordings_provider.dart'; +import 'package:omi/models/local_recording.dart'; import 'package:omi/providers/folder_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/services/app_review_service.dart'; @@ -68,7 +67,7 @@ class _ConversationsPageState extends State with AutomaticKee if (!mounted) return; // Surface any unsynced batch recordings written by the native layer. - context.read().ingestBatchRecordings(); + context.read().refresh(); // Load folders for folder tabs final folderProvider = context.read(); @@ -259,19 +258,19 @@ class _ConversationsPageState extends State with AutomaticKee // Unsynced local recordings (batch/offline mode) shown inline with conversations, // grouped into the same date buckets. Only in the default view (no search/folder/ // starred/daily-summaries filter). - final syncProvider = context.watch(); + final recordingsProvider = context.watch(); final bool showRecordings = convoProvider.previousQuery.isEmpty && convoProvider.selectedFolderId == null && !convoProvider.showStarredOnly && !convoProvider.showDailySummaries; - final recordingsByDate = >{}; + final recordingsByDate = >{}; if (showRecordings) { - // Only batch/offline recordings — never device SD-card/flash sync WALs or - // realtime offline buffers (those belong to the Sync page). - for (final wal in syncProvider.pendingWals.where((w) => w.device == batchRecordingDevice)) { - final dt = DateTime.fromMillisecondsSinceEpoch(wal.timerStart * 1000); + // Batch/offline-mode recordings captured locally — a separate subsystem + // from device offline-sync (which lives on the Sync page). + for (final rec in recordingsProvider.recordings) { + final dt = DateTime.fromMillisecondsSinceEpoch(rec.timerStart * 1000); final day = DateTime(dt.year, dt.month, dt.day); - (recordingsByDate[day] ??= []).add(wal); + (recordingsByDate[day] ??= []).add(rec); } } final bool hasRecordings = recordingsByDate.isNotEmpty; @@ -461,7 +460,7 @@ class _ConversationsPageState extends State with AutomaticKee var date = mergedDates[index]; List memoriesForDate = convoProvider.groupedConversations[date] ?? const []; - List recordingsForDate = recordingsByDate[date] ?? const []; + List recordingsForDate = recordingsByDate[date] ?? const []; return Column( mainAxisSize: MainAxisSize.min, children: [ From b304ce6bd5b06ca649f2721d8a0c1e58727b1709 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:35:08 +0530 Subject: [PATCH 39/45] refactor(app): drop WAL ingestion of batch recordings (keep native config write) --- app/lib/providers/capture_provider.dart | 78 ------------------------- 1 file changed, 78 deletions(-) diff --git a/app/lib/providers/capture_provider.dart b/app/lib/providers/capture_provider.dart index f3a528acc5..0b09c90fed 100644 --- a/app/lib/providers/capture_provider.dart +++ b/app/lib/providers/capture_provider.dart @@ -41,7 +41,6 @@ import 'package:omi/services/audio_sources/ble_device_source.dart'; import 'package:omi/services/devices/models.dart'; import 'package:omi/services/audio_sources/phone_mic_source.dart'; import 'package:omi/services/wals.dart'; -import 'package:omi/utils/batch_recording.dart'; import 'package:omi/utils/alerts/app_snackbar.dart'; import 'package:omi/utils/enums.dart'; import 'package:omi/utils/image/image_utils.dart'; @@ -912,10 +911,6 @@ class CaptureProvider extends ChangeNotifier await _wal.getSyncs().phone.onAudioCodecChanged(codec); await _saveNativeBleStreamConfig(device, codec); - // Batch mode: register any recordings the native layer wrote while the - // app was minimized/closed so they appear for upload. - await ingestBatchRecordings(); - // Create audio source for BLE device final pd = await device.getDeviceInfo(connection); final deviceModel = pd.modelNumber.isNotEmpty ? pd.modelNumber : "Omi"; @@ -987,79 +982,6 @@ class CaptureProvider extends ChangeNotifier } } - // ── Batch (offline) mode: ingest natively-written .bin files into the WAL ── - - /// Filenames already registered this session, to avoid re-parsing on each call. - final Set _ingestedBatchFiles = {}; - - /// Scan the batch-audio directory for finalized recordings written by the - /// native layer and register each as a [Wal] so the existing upload+reconcile - /// pipeline can sync it. Mirrors how [StorageSync] ingests device files via - /// [LocalWalSync.addExternalWal]. Idempotent — [addExternalWal] dedups by id. - Future ingestBatchRecordings() async { - if (!SharedPreferencesUtil().batchModeEnabled) return 0; - try { - final dirPath = SharedPreferencesUtil().getString('batchAudioDir'); - final dir = dirPath.isNotEmpty ? Directory(dirPath) : await getApplicationDocumentsDirectory(); - if (!await dir.exists()) { - Logger.debug('[batch] ingest: dir does not exist ($dirPath)'); - return 0; - } - - final allFiles = dir.listSync().whereType().map((e) => e.path.split('/').last).toList(); - final binFiles = allFiles.where((n) => n.startsWith('audio_') && n.endsWith('.bin')).toList(); - final partFiles = allFiles.where((n) => n.startsWith('audio_') && n.endsWith('.bin.part')).toList(); - Logger.debug('[batch] ingest scan dir=${dir.path} finalized=${binFiles.length} inProgress=${partFiles.length}'); - - int added = 0; - for (final name in binFiles) { - if (_ingestedBatchFiles.contains(name)) continue; - final wal = await _batchWalFromFile(File('${dir.path}/$name'), name); - if (wal == null) continue; - await _wal.getSyncs().phone.addExternalWal(wal); - _ingestedBatchFiles.add(name); - added++; - } - if (added > 0) { - Logger.debug('[batch] ingest: registered $added batch recording(s)'); - notifyListeners(); - } - return added; - } catch (e) { - Logger.error('ingestBatchRecordings failed: $e'); - return 0; - } - } - - /// Build a [Wal] from a finalized batch `.bin` file. The filename encodes all - /// parameters (see [BatchRecordingInfo]). - Future _batchWalFromFile(File file, String name) async { - try { - final info = BatchRecordingInfo.fromFileName(name); - if (info == null) return null; - - final sizeBytes = await file.length(); - if (sizeBytes <= 0) return null; - - final deviceModel = SharedPreferencesUtil().deviceName.isNotEmpty ? SharedPreferencesUtil().deviceName : 'Omi'; - return Wal( - timerStart: info.timerStart, - codec: info.codec, - seconds: info.estimateSeconds(sizeBytes), - sampleRate: 16000, - channel: 1, - status: WalStatus.miss, - storage: WalStorage.disk, - filePath: name, - device: batchRecordingDevice, - deviceModel: deviceModel, - ); - } catch (e) { - Logger.error('_batchWalFromFile parse failed for $name: $e'); - return null; - } - } - Future _initiateDevicePhotoStreaming() async { if (_recordingDevice == null) return; final deviceId = _recordingDevice!.id; From 686b55f1ab1565d32895af5372110218449230c4 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:35:36 +0530 Subject: [PATCH 40/45] refactor(app): refresh LocalRecordingsProvider on device disconnect --- app/lib/providers/device_provider.dart | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/app/lib/providers/device_provider.dart b/app/lib/providers/device_provider.dart index 9ce5a624ff..8cbab71f53 100644 --- a/app/lib/providers/device_provider.dart +++ b/app/lib/providers/device_provider.dart @@ -13,6 +13,7 @@ import 'package:omi/app_globals.dart'; import 'package:omi/pages/home/firmware_update.dart'; import 'package:omi/pages/home/omiglass_ota_update.dart'; import 'package:omi/providers/capture_provider.dart'; +import 'package:omi/providers/local_recordings_provider.dart'; import 'package:omi/services/devices.dart'; import 'package:omi/services/devices/omi_connection.dart'; import 'package:omi/services/notifications.dart'; @@ -27,6 +28,7 @@ import 'package:omi/widgets/confirmation_dialog.dart'; class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption { CaptureProvider? captureProvider; + LocalRecordingsProvider? localRecordingsProvider; bool isConnecting = false; bool isConnected = false; @@ -75,8 +77,9 @@ class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption ServiceManager.instance().device.subscribe(this, this); } - void setProviders(CaptureProvider provider) { + void setProviders(CaptureProvider provider, LocalRecordingsProvider recordingsProvider) { captureProvider = provider; + localRecordingsProvider = recordingsProvider; notifyListeners(); } @@ -385,10 +388,11 @@ class DeviceProvider extends ChangeNotifier implements IDeviceServiceSubsciption captureProvider?.updateRecordingDevice(null); - // Offline/batch mode: the native writer finalizes the in-progress recording on - // disconnect (.bin.part -> .bin). Surface it shortly after the rename completes. + // Batch mode: the native writer finalizes the in-progress recording on + // disconnect (.bin.part -> .bin). Rescan shortly after the rename completes + // so the new recording shows up in the conversations list. Future.delayed(const Duration(seconds: 1), () { - captureProvider?.ingestBatchRecordings(); + localRecordingsProvider?.refresh(); }); // Wals From eac24989e78983fb91999e34d79558b48f29dabf Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:35:37 +0530 Subject: [PATCH 41/45] refactor(app): refresh LocalRecordingsProvider on app resume --- app/lib/pages/home/page.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/lib/pages/home/page.dart b/app/lib/pages/home/page.dart index 4b3772965c..86c0ba395c 100644 --- a/app/lib/pages/home/page.dart +++ b/app/lib/pages/home/page.dart @@ -49,6 +49,7 @@ import 'package:omi/providers/capture_provider.dart'; import 'package:omi/providers/connectivity_provider.dart'; import 'package:omi/providers/conversation_provider.dart'; import 'package:omi/providers/device_provider.dart'; +import 'package:omi/providers/local_recordings_provider.dart'; import 'package:omi/providers/announcement_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/message_provider.dart'; @@ -191,7 +192,7 @@ class _HomePageState extends State with WidgetsBindingObserver, Ticker final captureProvider = Provider.of(context, listen: false); captureProvider.refreshInProgressConversations(); // Pick up any batch recordings the native layer wrote while backgrounded/closed. - captureProvider.ingestBatchRecordings(); + Provider.of(context, listen: false).refresh(); } // Ensure agent VM is running and restart keepalive From 3145681696d7a1480674de0ccba9d39608209bc4 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:35:37 +0530 Subject: [PATCH 42/45] feat(app): register LocalRecordingsProvider in provider tree --- app/lib/main.dart | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/lib/main.dart b/app/lib/main.dart index 6fac302a76..f145f74987 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -48,6 +48,7 @@ import 'package:omi/providers/folder_provider.dart'; import 'package:omi/providers/goals_provider.dart'; import 'package:omi/providers/home_provider.dart'; import 'package:omi/providers/integration_provider.dart'; +import 'package:omi/providers/local_recordings_provider.dart'; import 'package:omi/providers/locale_provider.dart'; import 'package:omi/providers/mcp_provider.dart'; import 'package:omi/providers/memories_provider.dart'; @@ -292,21 +293,21 @@ class _MyAppState extends State with WidgetsBindingObserver { update: (BuildContext context, value, MessageProvider? previous) => (previous?..updateAppProvider(value)) ?? MessageProvider(), ), - ChangeNotifierProxyProvider4< - ConversationProvider, - MessageProvider, - PeopleProvider, - UsageProvider, - CaptureProvider - >( + ChangeNotifierProxyProvider4( create: (context) => CaptureProvider(), update: (BuildContext context, conversation, message, people, usage, CaptureProvider? previous) => (previous?..updateProviderInstances(conversation, message, people, usage)) ?? CaptureProvider(), ), - ChangeNotifierProxyProvider( + ChangeNotifierProxyProvider( + create: (context) => LocalRecordingsProvider(), + update: (BuildContext context, conversation, LocalRecordingsProvider? previous) => + (previous?..setConversationProvider(conversation)) ?? LocalRecordingsProvider(), + ), + ChangeNotifierProxyProvider2( create: (context) => DeviceProvider(), - update: (BuildContext context, captureProvider, DeviceProvider? previous) => - (previous?..setProviders(captureProvider)) ?? DeviceProvider(), + update: (BuildContext context, captureProvider, localRecordings, DeviceProvider? previous) => + (previous?..setProviders(captureProvider, localRecordings)) ?? DeviceProvider(), ), ChangeNotifierProxyProvider( create: (context) => OnboardingProvider(), From c3cbde45f34e6ebc85db8378c4b7a0d936ddf0f7 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:35:38 +0530 Subject: [PATCH 43/45] fix(ios): tag batch recordings with omibatch marker to separate them from offline-sync files --- app/ios/Runner/BatchAudioWriter.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/app/ios/Runner/BatchAudioWriter.swift b/app/ios/Runner/BatchAudioWriter.swift index 9bae6c1c26..ad2de6933e 100644 --- a/app/ios/Runner/BatchAudioWriter.swift +++ b/app/ios/Runner/BatchAudioWriter.swift @@ -6,7 +6,9 @@ import Foundation /// /// On-disk format (what `POST /v2/sync-local-files` decodes): /// [4-byte little-endian uint32 frame_length][frame bytes] ... repeated -/// File name: audio_{device}_{codec}_{sampleRate}_{channel}_fs{frameSize}_{startSec}.bin +/// File name: 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.) /// /// Hooked from `OmiBleManager.peripheral(_:didUpdateValueFor:)`. When it consumes a /// packet it returns `true`, and the manager skips the Dart forward — so the Flutter @@ -153,7 +155,11 @@ final class BatchAudioWriter { let startSec = nowMs / 1000 let frameSize = config.codec == "opus_fs320" ? 320 : 160 - let name = "audio_\(sanitize(config.deviceType))_\(config.codec)_\(config.sampleRate)_1_fs\(frameSize)_\(startSec).bin.\(partSuffix)" + // 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`. + let name = "audio_omibatch_\(config.codec)_\(config.sampleRate)_1_fs\(frameSize)_\(startSec).bin.\(partSuffix)" let url = dir.appendingPathComponent(name) if !FileManager.default.fileExists(atPath: url.path) { @@ -263,11 +269,6 @@ final class BatchAudioWriter { ) } - private func sanitize(_ s: String) -> String { - let t = s.lowercased().filter { $0.isLetter || $0.isNumber } - return t.isEmpty ? "omi" : t - } - private func freeBytes(at dir: URL) -> Int64 { if let vals = try? dir.resourceValues(forKeys: [.volumeAvailableCapacityForImportantUsageKey]), let cap = vals.volumeAvailableCapacityForImportantUsage { From 2a2c9a6e1fb9ea100787c894afa92bbcac12ac80 Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:42:57 +0530 Subject: [PATCH 44/45] chore(app): remove local-recordings debug prints --- app/lib/providers/local_recordings_provider.dart | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/app/lib/providers/local_recordings_provider.dart b/app/lib/providers/local_recordings_provider.dart index 406117b863..7f485f811a 100644 --- a/app/lib/providers/local_recordings_provider.dart +++ b/app/lib/providers/local_recordings_provider.dart @@ -151,29 +151,25 @@ class LocalRecordingsProvider extends ChangeNotifier { try { final file = File(rec.filePath); if (!file.existsSync()) { - print('[recordings] upload aborted, file missing: ${rec.fileName}'); + Logger.error('LocalRecordings: file missing on upload: ${rec.fileName}'); return; } - print('[recordings] uploading ${rec.fileName} (${rec.sizeBytes} bytes)'); final result = await uploadLocalFilesV2([file]); SyncRateLimiter.instance.clear(); if (result.completed != null) { - print('[recordings] ${rec.fileName} transcribed synchronously (fast-path)'); await _deleteFileOnly(rec.fileName); await _surface(result.completed!.newConversationIds, result.completed!.updatedConversationIds); } else if (result.jobId != null) { _jobs[rec.fileName] = result.jobId!; await _saveJobs(); - print('[recordings] queued ${rec.fileName} jobId=${result.jobId} (polling every 15s)'); _startReconcileTimer(); } } on SyncRateLimitedException catch (e) { - print('[recordings] rate-limited uploading ${rec.fileName}, retry after ${e.retryAfterSeconds}s'); SyncRateLimiter.instance.markLimited(retryAfterSeconds: e.retryAfterSeconds); } catch (e) { _failedName = rec.fileName; - print('[recordings] upload FAILED for ${rec.fileName}: $e'); + Logger.error('LocalRecordings: upload failed for ${rec.fileName}: $e'); } finally { _isUploading = false; _uploadingName = null; @@ -210,13 +206,9 @@ class LocalRecordingsProvider extends ChangeNotifier { SyncJobFetch fetch; try { fetch = await fetchSyncJobStatus(jobId); - } catch (e) { - print('[recordings] poll error $name job=$jobId: $e'); + } catch (_) { continue; // transient — retry next tick } - final st = fetch.status; - print('[recordings] poll $name job=$jobId outcome=${fetch.outcome.name}' - '${st != null ? ' status=${st.status} terminal=${st.isTerminal}' : ''}'); switch (fetch.outcome) { case SyncJobFetchOutcome.transient: break; From bf2aab2360bb571036ae535f0d7f5b438fe3dead Mon Sep 17 00:00:00 2001 From: Mohammed Mohsin Date: Thu, 18 Jun 2026 00:42:58 +0530 Subject: [PATCH 45/45] fix(android): tag batch recordings with omibatch marker to separate them from offline-sync files --- .../kotlin/com/friend/ios/OmiBatchAudioWriter.kt | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt b/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt index 9b3e141573..7d1e69b720 100644 --- a/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt +++ b/app/android/app/src/main/kotlin/com/friend/ios/OmiBatchAudioWriter.kt @@ -15,7 +15,9 @@ import java.util.Locale * * [4-byte little-endian uint32 frame_length][frame bytes] ... repeated * - * Files are named `audio_{device}_{codec}_{sampleRate}_{channel}_fs{frameSize}_{startSec}.bin` + * 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. * @@ -158,10 +160,13 @@ class OmiBatchAudioWriter(private val context: Context) { val startSec = nowMs / 1000 val frameSize = if (config.codec == "opus_fs320") 320 else 160 - val deviceToken = config.deviceType.lowercase(Locale.US).filter { it.isLetterOrDigit() }.ifEmpty { "omi" } + // 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 ingests *.bin) never picks up a half-written file. - val name = "audio_${deviceToken}_${config.codec}_${config.sampleRate}_1_fs${frameSize}_${startSec}.bin$PART_SUFFIX" + // 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 {