Standalone Shonar Desktop: vendor portable sources + local engine; decouple from ~/Projects/Shonar

- shared/ = portable Android-origin sources vendored from deferred/desktop-server
  (app/build.gradle.kts srcDir repointed; PlaybackController.kt excluded as Android-only)
- backend/ = bundled-lite engine (SQLite + inline queue); .venv symlinked from the
  old checkout, PYTHONPATH pins THIS backend's code over any editable install
- repoRoot() resolves this project dir (env SHONAR_REPO still wins); desktop-dev.sh
  watches shared/ + backend/
- Verified: :app:compileKotlin + :app:test green (23 tests); engine boots on :8010,
  self-migrates, /healthz ok
This commit is contained in:
avi 2026-09-14 17:14:54 -05:00
commit 76c867fca4
136 changed files with 21099 additions and 0 deletions

View file

@ -0,0 +1,225 @@
package com.shonar.recording
import org.json.JSONArray
import org.json.JSONObject
/**
* M8: AI content models for the recording details screen.
*
* Pure Kotlin + org.json — no Android imports, so this file runs under
* plain JVM unit tests. The backend shapes mirror
* `backend/shonar/api/schemas_recordings.py`:
* - transcript: {version, language, provider, model, text,
* segments: [{start, end, text, speaker}], edited_by_user}
* - summary: {version, provider, model, content: {short, detailed,
* key_points, decisions, action_items, questions}, edited_by_user}
* - jobs: [{job_type, status, attempt, max_attempts, error, ...}]
*/
data class TranscriptSegment(
val startSec: Double,
val endSec: Double,
val text: String,
val speaker: String? = null,
)
data class TranscriptData(
val version: Int,
val language: String? = null,
val provider: String = "",
val model: String? = null,
val text: String = "",
val segments: List<TranscriptSegment> = emptyList(),
val editedByUser: Boolean = false,
)
data class SummaryData(
val version: Int,
val provider: String = "",
val model: String? = null,
/** Raw content dict; known keys read via [summaryString]/[summaryList]. */
val content: Map<String, Any?> = emptyMap(),
val editedByUser: Boolean = false,
) {
val short: String get() = summaryString(content, "short")
val detailed: String get() = summaryString(content, "detailed")
fun list(key: String): List<String> = summaryList(content, key)
}
data class JobInfo(
val jobType: String,
val status: String,
val attempt: Int = 0,
val maxAttempts: Int = 0,
val error: String? = null,
/** Display-only phase ("loading-model", "transcribing") + 0-100 progress. */
val stage: String? = null,
val progress: Int? = null,
)
/** Summary content keys, in display order. */
val SUMMARY_LIST_KEYS = listOf("key_points", "decisions", "action_items", "questions")
/**
* Index of the segment playing at [positionMs]: the last segment whose
* start is at or before the position. -1 when there are no segments or
* playback precedes the first one. End times are ignored on purpose —
* gaps between segments keep the last-spoken line highlighted instead of
* flickering to nothing.
*/
fun activeSegmentIndex(segments: List<TranscriptSegment>, positionMs: Long): Int {
val posSec = positionMs / 1000.0
var active = -1
for (i in segments.indices) {
if (segments[i].startSec <= posSec) active = i else break
}
return active
}
fun parseTranscript(raw: String): TranscriptData? = runCatching {
val json = JSONObject(raw)
val segments = mutableListOf<TranscriptSegment>()
json.optJSONArray("segments")?.let { arr ->
for (i in 0 until arr.length()) {
val s = arr.optJSONObject(i) ?: continue
segments += TranscriptSegment(
startSec = s.optDouble("start", 0.0),
endSec = s.optDouble("end", 0.0),
text = s.optString("text", ""),
speaker = s.optString("speaker", null).takeUnless { it.isNullOrBlank() },
)
}
}
segments.sortBy { it.startSec }
TranscriptData(
version = json.optInt("version", 0),
language = json.optString("language", null).takeUnless { it.isNullOrBlank() },
provider = json.optString("provider", ""),
model = json.optString("model", null).takeUnless { it.isNullOrBlank() },
text = json.optString("text", ""),
segments = segments,
editedByUser = json.optBoolean("edited_by_user", false),
)
}.getOrNull()
fun parseSummary(raw: String): SummaryData? = runCatching {
val json = JSONObject(raw)
val content = mutableMapOf<String, Any?>()
json.optJSONObject("content")?.let { obj ->
for (key in obj.keys()) content[key] = obj.get(key)
}
SummaryData(
version = json.optInt("version", 0),
provider = json.optString("provider", ""),
model = json.optString("model", null).takeUnless { it.isNullOrBlank() },
content = content,
editedByUser = json.optBoolean("edited_by_user", false),
)
}.getOrNull()
fun parseJobs(raw: String): List<JobInfo> = runCatching {
val arr = JSONArray(raw)
(0 until arr.length()).mapNotNull { i ->
val o = arr.optJSONObject(i) ?: return@mapNotNull null
JobInfo(
jobType = o.optString("job_type", ""),
status = o.optString("status", ""),
attempt = o.optInt("attempt", 0),
maxAttempts = o.optInt("max_attempts", 0),
error = o.optString("error", null).takeUnless { it.isNullOrBlank() },
stage = o.optString("stage", null).takeUnless { it.isNullOrBlank() },
progress = if (o.has("progress") && !o.isNull("progress")) o.optInt("progress") else null,
)
}
}.getOrDefault(emptyList())
/** Payload for PUT /recordings/{id}/transcript. Null segments = text-only correction. */
fun transcriptUpdatePayload(text: String, segments: List<TranscriptSegment>? = null): String {
val obj = JSONObject().put("text", text)
if (segments != null) {
obj.put(
"segments",
JSONArray().also { arr ->
segments.forEach { s ->
arr.put(
JSONObject()
.put("start", s.startSec)
.put("end", s.endSec)
.put("text", s.text)
.let { o -> if (s.speaker != null) o.put("speaker", s.speaker) else o },
)
}
},
)
}
return obj.toString()
}
/** Payload for PUT /recordings/{id}/summary. */
fun summaryUpdatePayload(content: Map<String, Any?>): String =
JSONObject().put("content", JSONObject(content)).toString()
/** Payload for PATCH /recordings/{id} (title/notes rename from details). */
fun recordingPatchPayload(title: String? = null, notes: String? = null): String {
val obj = JSONObject()
if (title != null) obj.put("title", title)
if (notes != null) obj.put("notes", notes)
return obj.toString()
}
// ---- transcription models (Stage 1: GET /api/v1/models) ------------------------
data class TranscriptionModelInfo(
val name: String,
val displayName: String,
val description: String,
val params: String = "",
val approxMemory: String = "",
val relativeSpeed: String = "",
val isDefault: Boolean = false,
val downloaded: Boolean = false,
val available: Boolean = false,
)
data class ModelsData(
val defaultModel: String,
val fasterWhisperInstalled: Boolean,
val models: List<TranscriptionModelInfo> = emptyList(),
) {
fun byName(name: String): TranscriptionModelInfo? = models.firstOrNull { it.name == name }
}
fun parseModels(raw: String): ModelsData? = runCatching {
val json = JSONObject(raw)
val models = mutableListOf<TranscriptionModelInfo>()
json.optJSONArray("models")?.let { arr ->
for (i in 0 until arr.length()) {
val o = arr.optJSONObject(i) ?: continue
models += TranscriptionModelInfo(
name = o.optString("name", ""),
displayName = o.optString("display_name", o.optString("name", "")),
description = o.optString("description", ""),
params = o.optString("params", ""),
approxMemory = o.optString("approx_memory", ""),
relativeSpeed = o.optString("relative_speed", ""),
isDefault = o.optBoolean("is_default", false),
downloaded = o.optBoolean("downloaded", false),
available = o.optBoolean("available", false),
)
}
}
ModelsData(
defaultModel = json.optString("default_model", "base"),
fasterWhisperInstalled = json.optBoolean("faster_whisper_installed", false),
models = models.filter { it.name.isNotBlank() },
)
}.getOrNull()
private fun summaryString(content: Map<String, Any?>, key: String): String =
(content[key] as? String).orEmpty()
private fun summaryList(content: Map<String, Any?>, key: String): List<String> = when (val v = content[key]) {
is JSONArray -> (0 until v.length()).mapNotNull { v.optString(it, null)?.takeIf { it.isNotBlank() } }
is List<*> -> v.mapNotNull { (it as? String)?.takeIf { it.isNotBlank() } }
else -> emptyList()
}

View file

@ -0,0 +1,89 @@
package com.shonar.recording
import com.shonar.provider.LocalOnlyProvider
import com.shonar.provider.ProviderRegistry
import com.shonar.provider.ShonarProvider
import com.shonar.provider.SyncState
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
/**
* P7 foreground "migrate now" uploader. Deliberately bounded — this is NOT
* the M5 background driver:
*
* - runs while the user watches (progress callback), cancellable; the
* in-flight file finishes, files never started stay exactly as they
* were, and the rest can run later (or under M5).
* - one attempt per file, no retry, no network/constraint checks.
* - upload-only: the local file is the source of truth and is never
* modified; success overwrites the row's remote slot with the new
* provider, failure records ERROR + the provider's message and moves on.
* - local-only is rejected as a target: there is nothing to upload to.
* ("Keep everything local" is a migration *choice*, handled by leaving
* rows untouched — not by this runner.)
*/
class MigrationRunner(
private val slots: SyncSlots,
private val registry: ProviderRegistry,
) {
data class Progress(val done: Int, val total: Int, val currentTitle: String)
data class Result(val uploaded: Int, val failed: List<String>, val cancelled: Boolean)
suspend fun migrateAllTo(
providerId: String,
onProgress: (Progress) -> Unit = {},
): Result {
require(providerId != LocalOnlyProvider.ID) { "cannot migrate to local-only" }
val target: ShonarProvider = registry.provider(providerId)
val rows = slots.rows()
var uploaded = 0
val failed = mutableListOf<String>()
var cancelled = false
rows.forEachIndexed { index, row ->
// Cancellation lands here between files: counted, honest, resumable.
try {
currentCoroutineContext().ensureActive()
} catch (e: kotlinx.coroutines.CancellationException) {
cancelled = true
return Result(uploaded, failed, cancelled)
}
onProgress(Progress(index, rows.size, row.title))
val draft = slots.draftFor(row)
if (draft == null) {
slots.markFailed(row, "local file is gone")
failed += row.title
return@forEachIndexed
}
stepToUploading(row)
try {
val ref = target.upload(draft) {}
slots.markUploaded(slots.rows().firstOrNull { it.id == row.id } ?: row, providerId, ref)
uploaded++
} catch (e: kotlinx.coroutines.CancellationException) {
// Interrupted mid-file: leave the row UPLOADING so a later
// run retries it explicitly rather than assuming either
// outcome.
throw e
} catch (e: Exception) {
val fresh = slots.rows().firstOrNull { it.id == row.id } ?: row
// Backoff included: the M5 drain picks ERROR rows up when due.
slots.markFailed(fresh, e.message ?: e.javaClass.simpleName)
failed += row.title
}
}
onProgress(Progress(rows.size, rows.size, ""))
return Result(uploaded, failed, cancelled)
}
/**
* Legal first steps toward UPLOADING from any resting state, per the
* transition table (shared with the M5 drain).
*/
private suspend fun stepToUploading(row: RecordingEntity) {
var fresh = slots.rows().firstOrNull { it.id == row.id } ?: row
for (step in stepsToUploading(fresh.syncState)) {
slots.markState(fresh, step)
fresh = slots.rows().firstOrNull { it.id == row.id } ?: fresh
}
}
}

View file

@ -0,0 +1,328 @@
package com.shonar.recording
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaPlayer
import android.os.Build
import androidx.core.app.NotificationCompat
import com.shonar.MainActivity
import com.shonar.R
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* The single audio player for the whole app. Home rows and the details
* screen share it, so what you hear is always what the UI shows — no
* phantom playback with no stop control.
*
* - One MediaPlayer at a time; a generation counter ignores stale async
* callbacks (tap A while B is still preparing, etc.).
* - Position polling (250ms) and speed live here; both screens bind it.
* - An ongoing "Now playing" notification carries a Stop action, so audio
* can always be silenced from the shade even with the app closed.
* - Audio-focus loss (call, assistant) pauses; never auto-resumes.
*/
class PlaybackController(private val app: Context) {
data class State(
val id: String,
val title: String,
val playing: Boolean,
val positionMs: Long,
val durationMs: Long,
val speed: Float,
val error: String? = null,
)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private val _state = MutableStateFlow<State?>(null)
val state: StateFlow<State?> = _state.asStateFlow()
private var player: MediaPlayer? = null
private var generation = 0
private var poll: Job? = null
private var audioManager: AudioManager? = null
private var focusRequest: AudioFocusRequest? = null
companion object {
const val ACTION_STOP = "com.shonar.playback.STOP"
const val CHANNEL_ID = "shonar_playback"
const val NOTIFICATION_ID = 1002
val SPEEDS = listOf(0.75f, 1f, 1.25f, 1.5f, 2f)
fun stopIntent(ctx: Context): PendingIntent {
val i = Intent(ctx, PlaybackStopReceiver::class.java).setAction(ACTION_STOP)
return PendingIntent.getBroadcast(
ctx, 61, i, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
}
}
/** Play/pause toggle for [id]. Anything else playing stops first. */
fun toggle(id: String, title: String, filePath: String) {
val cur = _state.value
if (cur != null && cur.id == id) {
val p = player
if (p != null && cur.playing) {
pauseLocked()
} else if (p != null) {
runCatching { applySpeed(p, cur.speed); p.start() }
_state.value = cur.copy(playing = true, error = null)
startPolling()
pushNotification()
}
return
}
startNew(id, title, filePath, cur?.speed ?: 1f)
}
fun seekTo(positionMs: Long) {
val cur = _state.value ?: return
val clamped = positionMs.coerceIn(0, cur.durationMs.coerceAtLeast(1))
runCatching { player?.seekTo(clamped.toInt()) }
_state.value = cur.copy(positionMs = clamped)
}
fun cycleSpeed() {
val cur = _state.value ?: return
val next = SPEEDS[(SPEEDS.indexOf(cur.speed) + 1).coerceAtLeast(0) % SPEEDS.size]
player?.let { applySpeed(it, next) }
_state.value = cur.copy(speed = next)
}
fun stop() {
generation++
poll?.cancel()
poll = null
runCatching {
player?.apply {
runCatching { stop() }
release()
}
}
player = null
abandonFocus()
_state.value = null
cancelNotification()
}
/** Call when the owning process goes down for good (tests). */
fun release() {
stop()
scope.cancel()
}
// ---- internals --------------------------------------------------------
private fun startNew(id: String, title: String, filePath: String, speed: Float) {
val gen = ++generation
poll?.cancel()
poll = null
runCatching { player?.release() }
player = null
val mp: MediaPlayer
try {
mp = MediaPlayer().apply {
setDataSource(filePath)
setOnPreparedListener { prepared ->
if (gen != generation) {
runCatching { prepared.release() }
return@setOnPreparedListener
}
requestFocus()
applySpeed(prepared, speed)
runCatching { prepared.start() }
val dur = runCatching { prepared.duration.toLong() }.getOrDefault(1L)
.coerceAtLeast(1)
_state.value = State(id, title, playing = true, 0, dur, speed)
startPolling()
pushNotification()
}
setOnCompletionListener {
if (gen != generation) {
runCatching { it.release() }
return@setOnCompletionListener
}
stop()
}
setOnErrorListener { _, _, _ ->
if (gen == generation) {
_state.value = State(id, title, false, 0, 1, speed, "Could not play this file.")
cancelNotification()
}
true
}
prepareAsync()
}
} catch (_: Exception) {
if (gen == generation) {
_state.value = State(id, title, false, 0, 1, speed, "Could not play this file.")
}
return
}
player = mp
// Tentative row state so a second tap lands on this session, not a new one.
_state.value = State(id, title, false, 0, 1, speed)
}
private fun pauseLocked() {
val cur = _state.value ?: return
val p = player ?: return
runCatching { if (p.isPlaying) p.pause() }
poll?.cancel()
poll = null
_state.value = cur.copy(playing = false, positionMs = currentPosition())
pushNotification()
}
private fun currentPosition(): Long =
runCatching { player?.currentPosition?.toLong() }.getOrNull()
?: _state.value?.positionMs ?: 0L
private fun applySpeed(p: MediaPlayer, speed: Float) {
runCatching {
val params = if (Build.VERSION.SDK_INT >= 23) {
p.playbackParams ?: android.media.PlaybackParams()
} else {
return
}
p.playbackParams = params.apply { this.speed = speed }
}
}
private fun startPolling() {
poll?.cancel()
poll = scope.launch {
while (isActive) {
delay(250)
val p = player ?: break
if (!runCatching { p.isPlaying }.getOrDefault(false)) break
val cur = _state.value ?: break
_state.value = cur.copy(
positionMs = runCatching { p.currentPosition.toLong() }.getOrDefault(cur.positionMs),
durationMs = runCatching { p.duration.toLong() }.getOrDefault(cur.durationMs).coerceAtLeast(1),
)
}
}
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS, AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> pauseLocked()
else -> Unit
}
}
private fun requestFocus() {
val am = app.getSystemService(AudioManager::class.java) ?: return
audioManager = am
if (Build.VERSION.SDK_INT >= 26) {
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
.build()
)
.setOnAudioFocusChangeListener(focusListener)
.build()
focusRequest = req
runCatching { am.requestAudioFocus(req) }
} else {
@Suppress("DEPRECATION")
runCatching { am.requestAudioFocus(focusListener, AudioManager.STREAM_MUSIC, AudioManager.AUDIOFOCUS_GAIN) }
}
}
private fun abandonFocus() {
val am = audioManager ?: return
if (Build.VERSION.SDK_INT >= 26) {
focusRequest?.let { runCatching { am.abandonAudioFocusRequest(it) } }
focusRequest = null
} else {
@Suppress("DEPRECATION")
runCatching { am.abandonAudioFocus(focusListener) }
}
audioManager = null
}
// ---- notification -----------------------------------------------------
private fun notifications(): NotificationManager =
app.getSystemService(NotificationManager::class.java)
private fun ensureChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = notifications()
if (runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull() == null) {
mgr.createNotificationChannel(
NotificationChannel(CHANNEL_ID, "Playback", NotificationManager.IMPORTANCE_LOW)
)
}
}
private fun pushNotification() {
val s = _state.value ?: return cancelNotification()
ensureChannel()
val openApp = PendingIntent.getActivity(
app, 60,
Intent(app, MainActivity::class.java)
.setAction(Intent.ACTION_MAIN).addCategory(Intent.CATEGORY_LAUNCHER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val notif: Notification = NotificationCompat.Builder(app, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_mic)
.setContentTitle(if (s.playing) "Playing" else "Paused")
.setContentText(s.title)
.setOngoing(s.playing)
.setOnlyAlertOnce(true)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(openApp)
.setShowWhen(false)
.addAction(R.drawable.ic_notif_close, "Stop", stopIntent(app))
.build()
runCatching { notifications().notify(NOTIFICATION_ID, notif) }
}
private fun cancelNotification() {
runCatching { notifications().cancel(NOTIFICATION_ID) }
}
}
/** Shade "Stop" button: silences playback even with the app closed. */
class PlaybackStopReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (intent.action != PlaybackController.ACTION_STOP) return
val app = context.applicationContext
// Route through the shared controller when the process is alive…
runCatching { (app as? PlaybackOwner)?.playback?.stop() }
// …and always clear the notification (covers a restarted process
// holding no player while the card is still posted).
runCatching {
(app.getSystemService(NotificationManager::class.java) as NotificationManager)
.cancel(PlaybackController.NOTIFICATION_ID)
}
}
}
/** Implemented by [com.shonar.ShonarApplication]: one controller per process. */
interface PlaybackOwner {
val playback: PlaybackController
}

View file

@ -0,0 +1,29 @@
package com.shonar.recording
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.Query
import androidx.room.Update
import kotlinx.coroutines.flow.Flow
@Dao
interface RecordingDao {
@Query("SELECT * FROM recordings ORDER BY createdAtEpochMs DESC")
fun observeAll(): Flow<List<RecordingEntity>>
@Query("SELECT * FROM recordings ORDER BY createdAtEpochMs DESC")
suspend fun getAll(): List<RecordingEntity>
@Query("SELECT * FROM recordings WHERE id = :id")
suspend fun getById(id: String): RecordingEntity?
@Insert
suspend fun insert(recording: RecordingEntity)
@Update
suspend fun update(recording: RecordingEntity)
@Delete
suspend fun delete(recording: RecordingEntity)
}

View file

@ -0,0 +1,38 @@
package com.shonar.recording
import androidx.room.Entity
import androidx.room.PrimaryKey
import com.shonar.provider.SyncState
/**
* Metadata for a completed local recording. Audio remains in app-private
* storage. P7: each row carries its provider slot —
*
* - [originProviderId] + [remoteKey]/[remoteEtag]/[remoteSizeBytes] name the
* server copy, if any. One slot is the whole history: switching providers
* never touches it until the user migrates (slot overwritten) or forgets
* (slot cleared). The local file is always the source of truth.
* - [syncState]/[syncReason] is the §2 lifecycle. Until the M5 background
* driver exists, the foreground migrator (P7) is the only writer besides
* these defaults.
*/
@Entity(tableName = "recordings")
data class RecordingEntity(
@PrimaryKey val id: String,
val title: String,
val createdAtEpochMs: Long,
val durationMs: Long,
val filePath: String,
val mimeType: String,
val sizeBytes: Long,
val originProviderId: String = "local-only",
val remoteKey: String? = null,
val remoteEtag: String? = null,
val remoteSizeBytes: Long? = null,
val syncState: SyncState = SyncState.LOCAL_ONLY,
val syncReason: String? = null,
/** Consecutive failed attempts (M5 backoff). Reset on success/forget. */
val syncAttempts: Int = 0,
/** Next eligible retry, epoch ms. Null = due immediately. */
val syncRetryAtMs: Long? = null,
)

View file

@ -0,0 +1,67 @@
package com.shonar.recording
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* All filename policy in one place. Storage choice: app-specific
* `filesDir` (default) or the user-picked folder (local-only provider).
* MediaStore is intentionally NOT used: recordings must survive uninstall
* control, stay off the shared music library, and never need
* READ_MEDIA_AUDIO for our own files. Import scan in
* [RecordingRepository.importExistingFiles] adopts externally added files.
*/
object RecordingFiles {
const val EXT = "m4a"
const val MIME = "audio/mp4"
private const val MAX_STEM = 120
fun defaultDisplayName(nowMs: Long = System.currentTimeMillis()): String {
val s = SimpleDateFormat("yyyy-MM-dd HH-mm", Locale.US).format(Date(nowMs))
return "Shonar Recording - $s"
}
/** Null = blank / "." / ".." / nothing usable after cleaning. */
fun sanitizeStem(raw: String): String? {
var stem = raw.trim()
.replace(Regex("[/\\\\]"), "_")
.replace(Regex("\\p{Cntrl}"), "")
.replace(Regex("\\s+"), " ").trim()
// Windows-hostile + MediaStore-hostile chars.
stem = stem.replace(Regex("[<>:\"|?*]"), "_").trim()
// No leading dots (hidden files) / trailing dots-spaces (Windows trim).
stem = stem.trim('.', ' ', '\t').trim()
if (stem.isEmpty() || stem == "." || stem == "..") return null
if (stem.length > MAX_STEM) stem = stem.take(MAX_STEM).trimEnd()
return stem.ifEmpty { null }
}
/** Never overwrite: "Name.m4a", "Name (2).m4a", ... */
fun uniqueFinalFile(dir: File, stem: String, ext: String = EXT): File {
var target = File(dir, "$stem.$ext")
var n = 1
while (target.exists()) {
n++
target = File(dir, "$stem ($n).$ext")
}
return target
}
/** Sweep crashed sessions: files in .in-progress older than 24h or orphaned. */
fun sweepOrphanedTempFiles(libraryRoot: File, validId: String?): Int {
val tmp = File(libraryRoot, ".in-progress")
if (!tmp.isDirectory) return 0
var removed = 0
val cutoff = System.currentTimeMillis() - 24 * 3600 * 1000L
tmp.listFiles()?.forEach { f ->
if (!f.isFile) return@forEach
val orphan = validId == null || f.nameWithoutExtension != validId
if (orphan && f.lastModified() < cutoff) {
if (f.delete()) removed++
}
}
return removed
}
}

View file

@ -0,0 +1,187 @@
package com.shonar.recording
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.os.Build
import android.widget.RemoteViews
import androidx.core.app.NotificationCompat
import com.shonar.MainActivity
import com.shonar.R
/**
* Single custom card — Image 1 is the ONLY view.
* mic | "Recording" + filename | big timer, then X / pause / check pills.
* Same RemoteViews for collapsed AND expanded, so there is no duality:
* the default view already has full controls.
* Light system card (DecoratedCustomViewStyle + CATEGORY_SERVICE) — never
* the dark media-player template.
*
* LOCKED 2026-09-14 (user-verified on-device): do NOT revert build() to
* system MediaStyle — that renders the small "SHONAR • SHONAR" text row
* with truncated filename and hides the big timer. Any layout change here
* must keep the custom RemoteViews card + big timer, and must keep
* RecordingNotificationLayoutTest green.
*/
object RecordingNotificationHelper {
const val CHANNEL_ID = "shonar_recording_ongoing"
private const val LEGACY_CHANNEL_ID = "shonar_recording"
const val NOTIFICATION_ID = 1001
fun ensureChannel(ctx: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val mgr = ctx.getSystemService(NotificationManager::class.java)
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) }
val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull()
if (existing == null || existing.importance != NotificationManager.IMPORTANCE_HIGH ||
existing.sound != null
) {
runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) }
}
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
ctx.getString(R.string.notif_channel_recording),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = ctx.getString(R.string.notif_channel_recording_desc)
setSound(null, null)
enableVibration(false)
},
)
}
private fun svcIntent(ctx: Context, action: String, req: Int): PendingIntent {
val i = Intent(ctx, RecordingService::class.java).setAction(action)
return PendingIntent.getService(
ctx, req, i,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
}
@JvmOverloads
fun build(
ctx: Context,
phase: RecordingSnapshot.Phase,
elapsedMs: Long,
fileName: String = "Shonar",
confirmingDiscard: Boolean = false,
): Notification {
val openApp = PendingIntent.getActivity(
ctx, 10,
Intent(ctx, MainActivity::class.java)
.setAction(Intent.ACTION_MAIN)
.addCategory(Intent.CATEGORY_LAUNCHER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
if (confirmingDiscard) return buildConfirm(ctx, openApp)
val paused = phase == RecordingSnapshot.Phase.PAUSED
val elapsed = formatElapsed(elapsedMs)
val card = RemoteViews(ctx.packageName, R.layout.notification_recording)
card.setTextViewText(R.id.notif_title, "Recording")
card.setTextViewText(R.id.notif_subtitle, fileName)
card.setTextViewText(R.id.notif_timer, elapsed)
card.setImageViewResource(
R.id.notif_btn_pause,
if (paused) R.drawable.ic_notif_play else R.drawable.ic_notif_pause,
)
card.setOnClickPendingIntent(
R.id.notif_btn_discard,
svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 21),
)
card.setOnClickPendingIntent(
R.id.notif_btn_pause,
svcIntent(
ctx,
if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE, 22,
),
)
card.setOnClickPendingIntent(
R.id.notif_btn_save,
svcIntent(ctx, RecordingService.ACTION_SAVE, 24),
)
card.setOnClickPendingIntent(R.id.notif_icon, openApp)
return NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_mic)
.setContentTitle("Recording")
.setContentText("$fileName • $elapsed")
.setSubText("SHONAR")
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(openApp)
.setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_HIGH)
// Fallback actions (Android Auto, watches, if custom is ever stripped).
.addAction(
R.drawable.ic_notif_close, "Discard",
svcIntent(ctx, RecordingService.ACTION_DISCARD_REQUEST, 21),
)
.addAction(
if (paused) R.drawable.ic_notif_play else R.drawable.ic_notif_pause,
if (paused) "Resume" else "Pause",
svcIntent(
ctx,
if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE, 22,
),
)
.addAction(
R.drawable.ic_notif_check, "Save",
svcIntent(ctx, RecordingService.ACTION_SAVE, 24),
)
// Same card for collapsed + big: no expanded duality.
.setCustomContentView(card)
.setCustomBigContentView(card)
.setStyle(NotificationCompat.DecoratedCustomViewStyle())
.build()
}
/** "Delete current recording? No / Yes" — same single-card pattern. */
private fun buildConfirm(ctx: Context, openApp: PendingIntent): Notification {
val card = RemoteViews(ctx.packageName, R.layout.notification_confirm_delete)
card.setOnClickPendingIntent(
R.id.notif_btn_no, svcIntent(ctx, RecordingService.ACTION_DISCARD_CANCEL, 41),
)
card.setOnClickPendingIntent(
R.id.notif_btn_yes, svcIntent(ctx, RecordingService.ACTION_CANCEL, 42),
)
card.setOnClickPendingIntent(R.id.notif_icon, openApp)
return NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_mic)
.setContentTitle("Delete current recording?")
.setContentText("Shonar")
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(openApp)
.setShowWhen(false)
.setCustomContentView(card)
.setCustomBigContentView(card)
.setStyle(NotificationCompat.DecoratedCustomViewStyle())
.setPriority(NotificationCompat.PRIORITY_HIGH)
.addAction(
R.drawable.ic_notif_close, "No",
svcIntent(ctx, RecordingService.ACTION_DISCARD_CANCEL, 43),
)
.addAction(
R.drawable.ic_notif_check, "Yes",
svcIntent(ctx, RecordingService.ACTION_CANCEL, 44),
)
.build()
}
fun formatElapsed(ms: Long): String {
val s = (ms / 1000).coerceAtLeast(0)
val h = s / 3600
val m = (s % 3600) / 60
val sec = s % 60
return if (h > 0) "%d:%02d:%02d".format(h, m, sec) else "%02d:%02d".format(m, sec)
}
}

View file

@ -0,0 +1,41 @@
package com.shonar.recording
import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.content.ContextCompat
/**
* Version matrix (12/13/14+):
* - RECORD_AUDIO: runtime on all versions, required before MediaRecorder.
* - POST_NOTIFICATIONS (API 33+): runtime; without it the foreground
* service + widget still work, but the notification is suppressed by
* the system. We keep recording + show in-app/widget state.
* - FOREGROUND_SERVICE (API 28+ manifest) + FOREGROUND_SERVICE_MICROPHONE
* (API 30+ manifest, enforced 34+): no runtime grant; declare in
* manifest + pass microphone FGS type at startForeground().
* - Background start (API 31+): a widget tap (system-bound PendingIntent)
* is an exempted FGS start; an in-app tap uses startForegroundService.
* - Microphone-in-use indicator (API 29+ green dot) is system-owned.
*/
object RecordingPermissionHelper {
fun hasRecordAudio(c: Context): Boolean =
ContextCompat.checkSelfPermission(c, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED
fun hasPostNotifications(c: Context): Boolean {
if (Build.VERSION.SDK_INT < 33) return true
return ContextCompat.checkSelfPermission(c, Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
}
fun requiredMissing(c: Context): List<String> {
val out = mutableListOf<String>()
if (!hasRecordAudio(c)) out += Manifest.permission.RECORD_AUDIO
if (Build.VERSION.SDK_INT >= 33 && !hasPostNotifications(c)) {
out += Manifest.permission.POST_NOTIFICATIONS
}
return out
}
}

View file

@ -0,0 +1,200 @@
package com.shonar.recording
import com.shonar.provider.RecordingDraft
import com.shonar.provider.RemoteRef
import com.shonar.provider.SyncState
import kotlinx.coroutines.flow.Flow
import java.io.File
import java.text.DateFormat
import java.util.Date
import java.util.UUID
class RecordingRepository(
appFilesDir: File,
private val dao: RecordingDao,
/**
* Where new recordings go. Defaults to the app-private library; the app
* wires the active local provider's picked folder here. Cached in
* [libraryRoot] (refreshed via [refreshRoot]) so the recording service —
* which must hand MediaRecorder a path synchronously — never suspends.
*/
private val rootResolver: (suspend () -> File?)? = null,
) {
private val slots = SyncSlots(dao)
private val sandboxDir: File = appFilesDir
private val legacyDir = File(sandboxDir, "recordings").apply { mkdirs() }
@Volatile private var libraryRoot: File = legacyDir
private val recordingsDir: File get() = libraryRoot
private fun tempDir(): File = File(libraryRoot, ".in-progress").apply { mkdirs() }
val recordings: Flow<List<RecordingEntity>> = dao.observeAll()
fun newRecordingId(): String = UUID.randomUUID().toString()
/** Re-read the destination (call at startup and after a location change). */
suspend fun refreshRoot() {
libraryRoot = runCatching { rootResolver?.invoke() }.getOrNull() ?: legacyDir
libraryRoot.mkdirs()
importExistingFiles()
}
/**
* Adopt audio files already sitting in the library folder (put there by
* another app, USB, …). Flat scan, top level only; dotfiles and the
* in-progress dir are skipped. Files already tracked by path are left
* alone — renaming a file re-imports it as new, which is honest.
* Nothing is copied, moved, or modified.
*/
suspend fun importExistingFiles() {
val root = libraryRoot
val known = dao.getAll().mapNotNullTo(mutableSetOf()) {
runCatching { File(it.filePath).canonicalPath }.getOrNull()
}
val files = root.listFiles()
?.filter { f ->
f.isFile && !f.name.startsWith(".") &&
f.extension.lowercase() in AUDIO_MIME_BY_EXTENSION &&
runCatching { f.canonicalPath }.getOrNull()?.let { it !in known } == true
}
?.sortedBy { it.name.lowercase() }
.orEmpty()
for (f in files) {
val ext = f.extension.lowercase()
dao.insert(
RecordingEntity(
id = UUID.randomUUID().toString(),
title = f.nameWithoutExtension,
createdAtEpochMs = f.lastModified().takeIf { it > 0 }
?: System.currentTimeMillis(),
durationMs = audioDurationMs(f).coerceAtLeast(1L),
filePath = f.absolutePath,
mimeType = AUDIO_MIME_BY_EXTENSION[ext] ?: "audio/mp4",
sizeBytes = f.length(),
),
)
known += runCatching { f.canonicalPath }.getOrNull() ?: f.absolutePath
}
}
private fun audioDurationMs(f: File): Long = runCatching {
android.media.MediaMetadataRetriever().use { r ->
r.setDataSource(f.absolutePath)
r.extractMetadata(android.media.MediaMetadataRetriever.METADATA_KEY_DURATION)
?.toLongOrNull() ?: 0L
}
}.getOrDefault(0L)
fun tempFile(id: String): File = File(tempDir(), "$id.m4a")
suspend fun finish(
id: String,
durationMs: Long,
tempFile: File,
createdAtEpochMs: Long = System.currentTimeMillis(),
) {
val finalFile = File(recordingsDir, "$id.m4a")
if (!tempFile.renameTo(finalFile)) {
tempFile.copyTo(finalFile, overwrite = true)
tempFile.delete()
}
dao.insert(
RecordingEntity(
id = id,
title = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
.format(Date(createdAtEpochMs)),
createdAtEpochMs = createdAtEpochMs,
durationMs = durationMs.coerceAtLeast(1L),
filePath = finalFile.absolutePath,
mimeType = "audio/mp4",
sizeBytes = finalFile.length(),
),
)
}
/**
* Rename the library entry AND its audio file (same folder, extension
* kept). A colliding name gets a " (2)" suffix — nothing is ever
* overwritten. Throws on a blank/invalid name or a missing file.
*/
suspend fun rename(recording: RecordingEntity, newTitle: String): RecordingEntity {
val stem = sanitizeFileStem(newTitle)
?: throw IllegalArgumentException("Not a valid name.")
val current = File(recording.filePath)
require(current.isFile) { "Original file is gone." }
val parent = current.parentFile
?: throw IllegalArgumentException("Cannot rename here.")
val ext = current.extension.ifBlank { "m4a" }
var target = File(parent, "$stem.$ext")
var n = 1
while (target.exists() && target.canonicalPath != current.canonicalPath) {
n++
target = File(parent, "$stem ($n).$ext")
}
if (target.canonicalPath != current.canonicalPath && !current.renameTo(target)) {
throw java.io.IOException("Could not rename the file.")
}
val updated = recording.copy(
title = target.nameWithoutExtension,
filePath = target.absolutePath,
sizeBytes = target.length(),
)
dao.update(updated)
return updated
}
suspend fun delete(recording: RecordingEntity) {
val canonicalFile = File(recording.filePath).canonicalFile
// Deletable when it lives under the app sandbox or the current
// library folder (older clips may predate a location change).
val allowed = listOf(sandboxDir.canonicalFile, libraryRoot.canonicalFile)
require(allowed.any { canonicalFile.toPath().startsWith(it.toPath()) }) {
"Invalid recording path"
}
canonicalFile.delete()
dao.delete(recording)
}
// ---- P7 provider slot (delegates to SyncSlots; Context-free logic) -----
/** Draft for upload, or null when the local file is gone. */
fun toDraft(entity: RecordingEntity): RecordingDraft? = slots.draftFor(entity)
suspend fun markState(entity: RecordingEntity, state: SyncState, reason: String? = null) =
slots.markState(entity, state, reason)
suspend fun markUploaded(entity: RecordingEntity, providerId: String, ref: RemoteRef) =
slots.markUploaded(entity, providerId, ref)
/**
* Forget every remote link (P7 "start fresh" choice). Files stay;
* nothing remote is touched — forgetting is metadata-only.
*/
suspend fun forgetAllRemotes(newOriginId: String) = slots.forgetAllRemotes(newOriginId)
companion object {
/** Extensions adopted by [importExistingFiles] (mirrors backend magic check). */
val AUDIO_MIME_BY_EXTENSION = mapOf( "m4a" to "audio/mp4",
"mp4" to "audio/mp4",
"aac" to "audio/aac",
"wav" to "audio/wav",
"ogg" to "audio/ogg",
"opus" to "audio/ogg",
"webm" to "audio/webm",
"mp3" to "audio/mpeg",
)
/**
* Clean a user-typed name into a safe single file stem, or null when
* it has nothing usable. Pure — unit-tested.
*/
fun sanitizeFileStem(raw: String): String? {
var stem = raw.trim().replace(Regex("[/\\\\]"), "_")
.replace(Regex("\\p{Cntrl}"), "")
.replace(Regex("\\s+"), " ").trim()
if (stem.isEmpty() || stem == "." || stem == "..") return null
if (stem.length > 120) stem = stem.take(120).trimEnd()
return stem.ifEmpty { null }
}
}
}

View file

@ -0,0 +1,552 @@
package com.shonar.recording
import android.Manifest
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.content.pm.ServiceInfo
import android.media.AudioAttributes
import android.media.AudioFocusRequest
import android.media.AudioManager
import android.media.MediaRecorder
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import android.telephony.PhoneStateListener
import android.telephony.TelephonyCallback
import android.telephony.TelephonyManager
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.shonar.ShonarApplication
import com.shonar.widget.ShonarWidgetUpdater
import java.io.File
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
* Foreground microphone owner. Single-flight: START while active is a
* no-op (prevents duplicate sessions from rapid widget double-taps).
*
* Commands: START / PAUSE / RESUME / SAVE / CANCEL / DELETE.
* SAVE == stop+finalize+open rename. CANCEL == stop+discard temp.
* DELETE == CANCEL (separate action for UX; same safe discard).
*
* Reliability:
* - MediaRecorder (not AudioRecord): container-managed AAC/M4A, far fewer
* codec/buffer bugs across OEMs; pause()/resume() is API 24+.
* - Partial WakeLock (10 min timeout) prevents CPU sleep mid-take.
* - Audio focus loss (call, assistant) auto-pauses;coming back does NOT
* auto-resume (user decides — avoids surprise hot-mic).
* - Mic in use by another app / permission revoked mid-take -> keep the
* temp file, drop to IDLE with state preserved for recovery, never
* delete silently.
*/
class RecordingService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate)
private var recorder: MediaRecorder? = null
private var wake: PowerManager.WakeLock? = null
private var audioManager: AudioManager? = null
private var focusRequest: AudioFocusRequest? = null
private var telephony: TelephonyManager? = null
private var callCallback: TelephonyCallback? = null
private var phoneListener: PhoneStateListener? = null
private var currentId: String? = null
private var currentTemp: File? = null
private var createdAtMs: Long = 0L
private var accumulatedMs = 0L
private var resumedAtElapsed = 0L
private var ticker: Job? = null
private lateinit var stateStore: RecordingStateStore
override fun onCreate() {
super.onCreate()
stateStore = RecordingStateStore(this)
RecordingNotificationHelper.ensureChannel(this)
audioManager = getSystemService(AudioManager::class.java)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
scope.launch { recoverIfNeeded() }
when (intent?.action) {
ACTION_START -> startRecording()
ACTION_PAUSE -> pauseRecording()
ACTION_RESUME -> resumeRecording()
ACTION_SAVE -> saveRecording()
ACTION_DISCARD_REQUEST -> requestDiscardConfirm()
ACTION_DISCARD_CANCEL -> cancelDiscardConfirm()
ACTION_CANCEL, ACTION_DELETE -> discardRecording()
ACTION_STOP -> saveRecording() // legacy single Stop -> Save
}
return START_NOT_STICKY
}
// ---------- public API used by widget / ViewModel ----------
private fun startRecording() {
if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) return // no duplicates
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
) {
// Permission lost: surface IDLE so widget shows Record again.
broadcast()
stopSelf()
return
}
val app = application as ShonarApplication
val id = app.recordingRepository.newRecordingId()
val temp = app.recordingRepository.tempFile(id)
runCatching { temp.parentFile?.mkdirs() }
try {
createdAtMs = System.currentTimeMillis()
val notif = RecordingNotificationHelper.build(
this, RecordingSnapshot.Phase.RECORDING, 0L,
displayFileName(),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
RecordingNotificationHelper.NOTIFICATION_ID, notif,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
} else {
startForeground(RecordingNotificationHelper.NOTIFICATION_ID, notif)
}
} catch (e: Exception) {
// API 31+ background-start race: bail cleanly, widget stays idle.
android.util.Log.w("ShonarRec", "FGS start denied", e)
broadcast()
stopSelf()
return
}
try {
recorder = (if (Build.VERSION.SDK_INT >= 31) MediaRecorder(this) else MediaRecorder()).apply {
setAudioSource(MediaRecorder.AudioSource.MIC)
setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
setAudioEncodingBitRate(128_000)
setAudioSamplingRate(44_100)
setOutputFile(temp.absolutePath)
prepare()
start()
}
} catch (e: Exception) {
// Mic busy (another app) or HW error: keep temp (0 bytes -> sweep
// later), release, go IDLE. Never crash the widget tap.
android.util.Log.w("ShonarRec", "mic unavailable", e)
recorder?.release()
recorder = null
runCatching { if (temp.exists() && temp.length() == 0L) temp.delete() }
stopForeground(STOP_FOREGROUND_REMOVE)
broadcast()
stopSelf()
return
}
currentId = id
currentTemp = temp
accumulatedMs = 0L
resumedAtElapsed = SystemClock.elapsedRealtime()
acquireWake()
requestFocus()
watchCalls()
setPhase(RecordingSnapshot.Phase.RECORDING)
scope.launch {
stateStore.save(id, RecordingPhase.RECORDING, 0L, resumedAtElapsed, createdAtMs)
}
startTicker()
}
private fun pauseRecording() {
if (_snapshot.value.phase != RecordingSnapshot.Phase.RECORDING) return
try {
if (Build.VERSION.SDK_INT >= 24) recorder?.pause()
else return // pre-24 has no pause: keep recording (button hidden there)
} catch (e: Exception) {
android.util.Log.w("ShonarRec", "pause failed", e)
return
}
accumulatedMs += SystemClock.elapsedRealtime() - resumedAtElapsed
setPhase(RecordingSnapshot.Phase.PAUSED)
scope.launch {
currentId?.let {
stateStore.save(it, RecordingPhase.PAUSED, accumulatedMs, resumedAtElapsed, createdAtMs)
}
}
}
private fun resumeRecording() {
if (_snapshot.value.phase != RecordingSnapshot.Phase.PAUSED) return
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
) return // stay paused; user must re-grant
try {
if (Build.VERSION.SDK_INT >= 24) recorder?.resume()
} catch (e: Exception) {
android.util.Log.w("ShonarRec", "resume failed", e)
return
}
resumedAtElapsed = SystemClock.elapsedRealtime()
setPhase(RecordingSnapshot.Phase.RECORDING)
scope.launch {
currentId?.let {
stateStore.save(it, RecordingPhase.RECORDING, accumulatedMs, resumedAtElapsed, createdAtMs)
}
}
}
/** Stop + finalize -> Room. Rename UI opens from MainActivity deep-link. */
private fun saveRecording() {
val id = currentId
val temp = currentTemp
if (_snapshot.value.phase == RecordingSnapshot.Phase.IDLE || id == null || temp == null) {
stopSelf()
return
}
val duration = elapsedMs()
shutdownRecorder(releaseOnly = false, keepTemp = true)
ticker?.cancel()
ticker = null
setPhase(RecordingSnapshot.Phase.IDLE)
scope.launch(Dispatchers.IO) {
var savedId: String? = null
try {
val app = application as ShonarApplication
if (temp.exists() && temp.length() > 0) {
app.recordingRepository.finish(id, duration, temp, createdAtMs)
// Apply default display name "Shonar Recording - yyyy-MM-dd HH-mm"
// so the rename sheet has something meaningful to edit.
val row = app.database.recordingDao().getById(id)
if (row != null) {
val def = RecordingFiles.defaultDisplayName(createdAtMs)
val stem = RecordingFiles.sanitizeStem(def) ?: id
val dir = java.io.File(row.filePath).parentFile
if (dir != null) {
val target = RecordingFiles.uniqueFinalFile(dir, stem)
val cur = java.io.File(row.filePath)
if (cur.canonicalPath != target.canonicalPath) {
if (cur.renameTo(target)) {
app.database.recordingDao().update(
row.copy(
title = target.nameWithoutExtension,
filePath = target.absolutePath,
sizeBytes = target.length(),
),
)
}
} else {
app.database.recordingDao().update(row.copy(title = stem))
}
}
}
savedId = id
} else {
runCatching { temp.delete() }
}
} catch (e: Exception) {
android.util.Log.w("ShonarRec", "finish failed", e)
runCatching { temp.delete() }
} finally {
stateStore.clear()
ShonarWidgetUpdater.refresh(applicationContext)
stopForeground(STOP_FOREGROUND_REMOVE)
// Open rename automatically (Android forbids silent activity
// launch from background — notification tap / full-screen
// intent path via MainActivity is the compliant route).
if (savedId != null) RenamePendingHolder.pendingRenameId = savedId
val open = Intent(applicationContext, com.shonar.MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
.setAction(ACTION_RENAME)
.putExtra(EXTRA_RECORDING_ID, savedId)
// From a service context startActivity is restricted on
// API 29+; MainActivity.onNewIntent also picks up the rename
// via RenamePendingHolder, so a best-effort launch here + a
// high-priority "Tap to rename" notification fallback covers it.
runCatching { startActivity(open) }
stopSelf()
}
}
}
private fun discardRecording() {
val temp = currentTemp
shutdownRecorder(releaseOnly = false, keepTemp = false)
ticker?.cancel()
ticker = null
runCatching { temp?.delete() }
setPhase(RecordingSnapshot.Phase.IDLE)
scope.launch {
stateStore.clear()
ShonarWidgetUpdater.refresh(applicationContext)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
// ---------- internals ----------
private fun elapsedMs(): Long = when (_snapshot.value.phase) {
RecordingSnapshot.Phase.RECORDING ->
accumulatedMs + (SystemClock.elapsedRealtime() - resumedAtElapsed)
RecordingSnapshot.Phase.PAUSED -> accumulatedMs
RecordingSnapshot.Phase.IDLE -> 0L
}
private fun setPhase(p: RecordingSnapshot.Phase) {
_snapshot.value = RecordingSnapshot(p, currentId, elapsedMs(), confirmingDiscard = false)
pushNotification()
ShonarWidgetUpdater.refresh(this)
}
/** X tap: keep recording running, swap card to "Delete current recording? No/Yes". */
private fun requestDiscardConfirm() {
val s = _snapshot.value
if (s.phase == RecordingSnapshot.Phase.IDLE || s.confirmingDiscard) return
_snapshot.value = s.copy(confirmingDiscard = true)
pushNotification()
}
/** "No": back to the media-card, recording untouched. */
private fun cancelDiscardConfirm() {
val s = _snapshot.value
if (!s.confirmingDiscard) return
_snapshot.value = s.copy(confirmingDiscard = false)
pushNotification()
}
private fun broadcast() {
ShonarWidgetUpdater.refresh(this)
}
private fun pushNotification() {
val s = _snapshot.value
if (s.phase == RecordingSnapshot.Phase.IDLE) return
val nm = getSystemService(android.app.NotificationManager::class.java)
runCatching {
nm.notify(
RecordingNotificationHelper.NOTIFICATION_ID,
RecordingNotificationHelper.build(
this, s.phase, s.elapsedMs, displayFileName(), s.confirmingDiscard,
),
)
}
}
private fun displayFileName(): String {
val base = RecordingFiles.defaultDisplayName(createdAtMs.takeIf { it > 0 } ?: System.currentTimeMillis())
return "$base.m4a"
}
private fun startTicker() {
ticker?.cancel()
ticker = scope.launch {
while (isActive) {
delay(1000)
val s = _snapshot.value
if (s.phase == RecordingSnapshot.Phase.IDLE) break
_snapshot.value = s.copy(elapsedMs = elapsedMs())
pushNotification()
ShonarWidgetUpdater.refresh(this@RecordingService)
}
}
}
/** Process-death recovery: re-read journal; stale temp without a live
* recorder is left for the next startup sweep (never auto-deleted
* under 24h — user may still want it). */
private suspend fun recoverIfNeeded() {
// No-op while a live session runs in this process.
if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) return
}
private fun shutdownRecorder(releaseOnly: Boolean, keepTemp: Boolean) {
try {
recorder?.apply {
runCatching { if (!releaseOnly) stop() }
runCatching { reset() }
release()
}
} catch (_: Exception) {
if (!keepTemp) runCatching { currentTemp?.delete() }
} finally {
recorder = null
}
releaseWake()
abandonFocus()
unwatchCalls()
currentId = null
currentTemp = null
}
private fun acquireWake() {
val pm = getSystemService(PowerManager::class.java)
wake = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SHONAR:rec").apply {
setReferenceCounted(false)
acquire(10 * 60 * 1000L)
}
}
private fun releaseWake() {
runCatching { wake?.takeIf { it.isHeld }?.release() }
wake = null
}
private val focusListener = AudioManager.OnAudioFocusChangeListener { change ->
when (change) {
AudioManager.AUDIOFOCUS_LOSS,
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT -> {
// Incoming call / assistant: pause, never discard.
if (_snapshot.value.phase == RecordingSnapshot.Phase.RECORDING) pauseRecording()
}
AudioManager.AUDIOFOCUS_LOSS_TRANSIENT_CAN_DUCK -> Unit // mic has no duck
AudioManager.AUDIOFOCUS_GAIN -> Unit // no auto-resume (explicit UX)
}
}
private fun requestFocus() {
val am = audioManager ?: return
if (Build.VERSION.SDK_INT >= 26) {
val req = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
.build(),
)
.setOnAudioFocusChangeListener(focusListener)
.build()
focusRequest = req
runCatching { am.requestAudioFocus(req) }
} else {
@Suppress("DEPRECATION")
runCatching {
am.requestAudioFocus(
focusListener, AudioManager.STREAM_MUSIC,
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT,
)
}
}
}
private fun abandonFocus() {
val am = audioManager ?: return
if (Build.VERSION.SDK_INT >= 26) {
focusRequest?.let { runCatching { am.abandonAudioFocusRequest(it) } }
focusRequest = null
} else {
@Suppress("DEPRECATION")
runCatching { am.abandonAudioFocus(focusListener) }
}
}
private fun watchCalls() {
val tm = getSystemService(TelephonyManager::class.java) ?: return
telephony = tm
if (Build.VERSION.SDK_INT >= 31) {
val cb = object : TelephonyCallback(), TelephonyCallback.CallStateListener {
override fun onCallStateChanged(state: Int) {
if (state != TelephonyManager.CALL_STATE_IDLE &&
_snapshot.value.phase == RecordingSnapshot.Phase.RECORDING
) pauseRecording()
}
}
callCallback = cb
runCatching { tm.registerTelephonyCallback(mainExecutor, cb) }
} else {
@Suppress("DEPRECATION")
val l = object : PhoneStateListener() {
@Deprecated("compat")
override fun onCallStateChanged(state: Int, number: String?) {
if (state != TelephonyManager.CALL_STATE_IDLE &&
_snapshot.value.phase == RecordingSnapshot.Phase.RECORDING
) pauseRecording()
}
}
phoneListener = l
@Suppress("DEPRECATION")
runCatching { tm.listen(l, PhoneStateListener.LISTEN_CALL_STATE) }
}
}
private fun unwatchCalls() {
telephony?.let { tm ->
if (Build.VERSION.SDK_INT >= 31) {
callCallback?.let { runCatching { tm.unregisterTelephonyCallback(it) } }
callCallback = null
} else {
phoneListener?.let {
@Suppress("DEPRECATION")
runCatching { tm.listen(it, PhoneStateListener.LISTEN_NONE) }
phoneListener = null
}
}
}
telephony = null
}
override fun onDestroy() {
// Crash/force-stop with an active take: recorder is dead but the temp
// file + journal entry survive for the next launch sweep. Only delete
// a 0-byte temp here; anything with audio is NEVER silently dropped.
if (_snapshot.value.phase != RecordingSnapshot.Phase.IDLE) {
val tmp = currentTemp
shutdownRecorder(releaseOnly = true, keepTemp = true)
if (tmp != null && tmp.exists() && tmp.length() == 0L) {
runCatching { tmp.delete() }
}
_snapshot.value = RecordingSnapshot()
ShonarWidgetUpdater.refresh(this)
}
scope.cancel()
super.onDestroy()
}
override fun onBind(intent: Intent?): IBinder? = null
companion object {
const val ACTION_START = "com.shonar.recording.START"
const val ACTION_PAUSE = "com.shonar.recording.PAUSE"
const val ACTION_RESUME = "com.shonar.recording.RESUME"
const val ACTION_SAVE = "com.shonar.recording.SAVE"
const val ACTION_CANCEL = "com.shonar.recording.CANCEL"
const val ACTION_DELETE = "com.shonar.recording.DELETE"
/** X tap -> show "Delete current recording? No/Yes" (recording keeps running). */
const val ACTION_DISCARD_REQUEST = "com.shonar.recording.DISCARD_REQUEST"
/** "No" -> back to media-card, recording untouched. */
const val ACTION_DISCARD_CANCEL = "com.shonar.recording.DISCARD_CANCEL"
const val ACTION_STOP = "com.shonar.recording.STOP" // legacy alias -> SAVE
const val ACTION_RENAME = "com.shonar.recording.RENAME"
const val EXTRA_RECORDING_ID = "recording_id"
private val _snapshot = MutableStateFlow(RecordingSnapshot())
val snapshot: StateFlow<RecordingSnapshot> = _snapshot.asStateFlow()
fun command(context: Context, action: String) {
val intent = Intent(context, RecordingService::class.java).setAction(action)
if (action == ACTION_START) {
ContextCompat.startForegroundService(context, intent)
} else {
context.startService(intent)
}
}
}
}
/** Handoff for the post-save rename sheet (process-safe via intent extra too). */
object RenamePendingHolder {
@Volatile var pendingRenameId: String? = null
}

View file

@ -0,0 +1,44 @@
package com.shonar.recording
/**
* Single source of truth for the in-progress recording session.
* Persists across process death via [RecordingStateStore]; mirrored to
* the widget + notification.
*/
enum class RecordingPhase { IDLE, RECORDING, PAUSED }
sealed interface RecordingCommand {
data object Start : RecordingCommand
data object Pause : RecordingCommand
data object Resume : RecordingCommand
/** Stop, finalize temp file -> Room, then open rename. */
data object Save : RecordingCommand
/** Stop + delete temp file, no Room row. */
data object Cancel : RecordingCommand
/** Delete = Cancel alias kept for notification UX (per spec). */
data object Delete : RecordingCommand
}
/** Snapshot observed by UI / widget. Kept compatible with existing code. */
data class RecordingSnapshot(
val phase: Phase = Phase.IDLE,
val recordingId: String? = null,
val elapsedMs: Long = 0L,
/** True while the notification shows "Delete current recording? No/Yes". */
val confirmingDiscard: Boolean = false,
) {
// Legacy alias so existing HomeScreen code keeps compiling.
enum class Phase { IDLE, RECORDING, PAUSED }
fun toPhase(): RecordingPhase = when (phase) {
Phase.IDLE -> RecordingPhase.IDLE
Phase.RECORDING -> RecordingPhase.RECORDING
Phase.PAUSED -> RecordingPhase.PAUSED
}
}
fun RecordingPhase.toSnapshotPhase(): RecordingSnapshot.Phase = when (this) {
RecordingPhase.IDLE -> RecordingSnapshot.Phase.IDLE
RecordingPhase.RECORDING -> RecordingSnapshot.Phase.RECORDING
RecordingPhase.PAUSED -> RecordingSnapshot.Phase.PAUSED
}

View file

@ -0,0 +1,68 @@
package com.shonar.recording
import android.content.Context
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.longPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
private val Context.sessionStore by preferencesDataStore("recording_session")
/**
* Crash-safe session journal. The service writes before/after every
* transition; on reboot / process death we either resume UI state or
* sweep an orphaned temp file. Small, synchronous, no Room needed here
* because this must work even when the DB is closed.
*/
class RecordingStateStore(private val context: Context) {
private val ID = stringPreferencesKey("session_id")
private val PHASE = stringPreferencesKey("session_phase")
private val ACCUM = longPreferencesKey("session_accum_ms")
private val STARTED_AT = longPreferencesKey("session_started_at_ms")
private val CREATED_AT = longPreferencesKey("session_created_at_ms")
data class Persisted(
val id: String,
val phase: RecordingPhase,
val accumulatedMs: Long,
val resumedAtElapsedMs: Long,
val createdAtMs: Long,
)
suspend fun save(
id: String,
phase: RecordingPhase,
accumulatedMs: Long,
resumedAtElapsedMs: Long,
createdAtMs: Long,
) {
context.sessionStore.edit { p ->
p[ID] = id
p[PHASE] = phase.name
p[ACCUM] = accumulatedMs
p[STARTED_AT] = resumedAtElapsedMs
p[CREATED_AT] = createdAtMs
}
}
suspend fun load(): Persisted? {
val p = context.sessionStore.data.map { it }.first()
val id = p[ID] ?: return null
val phase = runCatching { RecordingPhase.valueOf(p[PHASE] ?: "IDLE") }
.getOrDefault(RecordingPhase.IDLE)
if (phase == RecordingPhase.IDLE) return null
return Persisted(
id = id,
phase = phase,
accumulatedMs = p[ACCUM] ?: 0L,
resumedAtElapsedMs = p[STARTED_AT] ?: 0L,
createdAtMs = p[CREATED_AT] ?: System.currentTimeMillis(),
)
}
suspend fun clear() {
context.sessionStore.edit { it.clear() }
}
}

View file

@ -0,0 +1,41 @@
package com.shonar.recording
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
@Database(entities = [RecordingEntity::class], version = 3, exportSchema = false)
@TypeConverters(SyncStateConverter::class)
abstract class ShonarDatabase : RoomDatabase() {
abstract fun recordingDao(): RecordingDao
}
/**
* v1 -> v2 (P7): provider slot per recording. Existing rows predate sync
* tracking, so they read as never-synced locals — which is exactly what
* they are. NOT NULL columns carry defaults; no data moves.
*/
val MIGRATION_1_2 = object : Migration(1, 2) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recordings ADD COLUMN originProviderId TEXT NOT NULL DEFAULT 'local-only'")
db.execSQL("ALTER TABLE recordings ADD COLUMN remoteKey TEXT")
db.execSQL("ALTER TABLE recordings ADD COLUMN remoteEtag TEXT")
db.execSQL("ALTER TABLE recordings ADD COLUMN remoteSizeBytes INTEGER")
db.execSQL("ALTER TABLE recordings ADD COLUMN syncState TEXT NOT NULL DEFAULT 'LOCAL_ONLY'")
db.execSQL("ALTER TABLE recordings ADD COLUMN syncReason TEXT")
}
}
/**
* v2 -> v3 (M5): consecutive-failure backoff. attempts/retryAt default to
* "never failed, due immediately", which is exactly right for pre-existing
* ERROR rows left by P7 runs.
*/
val MIGRATION_2_3 = object : Migration(2, 3) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE recordings ADD COLUMN syncAttempts INTEGER NOT NULL DEFAULT 0")
db.execSQL("ALTER TABLE recordings ADD COLUMN syncRetryAtMs INTEGER")
}
}

View file

@ -0,0 +1,94 @@
package com.shonar.recording
import com.shonar.provider.LocalOnlyProvider
import com.shonar.provider.ShonarProvider
import com.shonar.provider.SyncState
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.ensureActive
/**
* M5 background drain engine. Context-free on purpose: the [SyncWorker]
* (Context-bound) builds the inputs, unit tests drive this directly.
*
* One pass over due rows — QUEUED, plus ERROR whose backoff expired (null
* retryAt = due immediately, which is how P7 leftovers read). SYNCED,
* UPLOADED and LOCAL_ONLY rows are never touched. Local-only target is a
* no-op. Failures record backoff via [SyncSlots.markFailed]; cancellation
* between files stops honestly, and a row interrupted mid-upload reverts
* to QUEUED so a later pass retries it explicitly.
*/
class SyncDrain(
private val slots: SyncSlots,
private val clock: () -> Long = System::currentTimeMillis,
) {
/** Snapshot of device conditions, taken by the worker. Pure to evaluate. */
data class Gates(val online: Boolean, val unmetered: Boolean, val charging: Boolean)
data class Progress(val done: Int, val total: Int, val currentTitle: String)
data class Result(val uploaded: Int, val failed: List<String>, val postponed: Boolean)
suspend fun drainOnce(
provider: ShonarProvider,
wifiOnly: Boolean,
chargingOnly: Boolean,
gates: Gates,
onProgress: (Progress) -> Unit = {},
): Result {
if (provider.descriptor.id == LocalOnlyProvider.ID) {
return Result(0, emptyList(), postponed = false)
}
val now = clock()
val due = slots.rows().filter { row ->
row.syncState == SyncState.QUEUED ||
(row.syncState == SyncState.ERROR &&
(row.syncRetryAtMs == null || row.syncRetryAtMs <= now))
}
if (due.isEmpty()) return Result(0, emptyList(), postponed = false)
if (!gates.satisfiedBy(wifiOnly, chargingOnly)) {
return Result(0, emptyList(), postponed = true)
}
var uploaded = 0
val failed = mutableListOf<String>()
due.forEachIndexed { index, row ->
try {
currentCoroutineContext().ensureActive()
} catch (e: kotlinx.coroutines.CancellationException) {
return Result(uploaded, failed, postponed = false)
}
onProgress(Progress(index, due.size, row.title))
val draft = slots.draftFor(row)
if (draft == null) {
slots.markFailed(row, "local file is gone", now)
failed += row.title
return@forEachIndexed
}
var fresh = slots.rows().firstOrNull { it.id == row.id } ?: row
for (step in stepsToUploading(fresh.syncState)) {
slots.markState(fresh, step)
fresh = slots.rows().firstOrNull { it.id == row.id } ?: fresh
}
try {
val ref = provider.upload(draft) {}
val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh
slots.markUploaded(done, provider.descriptor.id, ref)
uploaded++
} catch (e: kotlinx.coroutines.CancellationException) {
// Interrupted mid-file: revert so a later pass retries
// explicitly instead of assuming either outcome.
val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh
slots.markState(done, SyncState.QUEUED)
throw e
} catch (e: Exception) {
val done = slots.rows().firstOrNull { it.id == row.id } ?: fresh
slots.markFailed(done, e.message ?: e.javaClass.simpleName, now)
failed += row.title
}
}
onProgress(Progress(due.size, due.size, ""))
return Result(uploaded, failed, postponed = false)
}
}
/** Pure gate evaluation, unit-tested. */
internal fun SyncDrain.Gates.satisfiedBy(wifiOnly: Boolean, chargingOnly: Boolean): Boolean =
online && (!wifiOnly || unmetered) && (!chargingOnly || charging)

View file

@ -0,0 +1,116 @@
package com.shonar.recording
import com.shonar.provider.RecordingDraft
import com.shonar.provider.RemoteRef
import com.shonar.provider.SyncState
import java.io.File
/**
* P7 provider-slot bookkeeping over [RecordingDao]. Context-free on purpose:
* the repository (Context-bound) delegates here, and the migration runner
* and unit tests drive it directly.
*
* One slot is the whole history: switching providers never touches it until
* the user migrates (slot overwritten) or forgets (slot cleared). The local
* file is always the source of truth.
*/
class SyncSlots(private val dao: RecordingDao) {
suspend fun rows(): List<RecordingEntity> = dao.getAll()
/** Draft for upload, or null when the local file is gone. */
fun draftFor(entity: RecordingEntity): RecordingDraft? {
val file = File(entity.filePath)
if (!file.isFile) return null
return RecordingDraft(
id = entity.id,
title = entity.title,
createdAtEpochMs = entity.createdAtEpochMs,
durationMs = entity.durationMs,
mime = entity.mimeType,
sourceFile = file,
sizeBytes = file.length(),
)
}
suspend fun markState(entity: RecordingEntity, state: SyncState, reason: String? = null) {
dao.update(entity.copy(syncState = state, syncReason = reason))
}
suspend fun markUploaded(entity: RecordingEntity, providerId: String, ref: RemoteRef) {
dao.update(
entity.copy(
originProviderId = providerId,
remoteKey = ref.key,
remoteEtag = ref.etag,
remoteSizeBytes = ref.sizeBytes,
syncState = SyncState.UPLOADED,
syncReason = null,
syncAttempts = 0,
syncRetryAtMs = null,
)
)
}
/**
* Record a failed attempt with exponential backoff. [nowMs] is injected
* (default wall-clock) so tests run on a fixed clock.
*/
suspend fun markFailed(
entity: RecordingEntity,
reason: String,
nowMs: Long = System.currentTimeMillis(),
) {
val attempts = (entity.syncAttempts + 1).coerceAtLeast(1)
dao.update(
entity.copy(
syncState = SyncState.ERROR,
syncReason = reason,
syncAttempts = attempts,
syncRetryAtMs = nowMs + backoffDelayMs(attempts),
)
)
}
/**
* Forget every remote link ("start fresh"). Files stay; rows read as
* never-synced under [newOriginId], ready for a later upload run.
* Nothing remote is touched — forgetting is metadata-only.
*/
suspend fun forgetAllRemotes(newOriginId: String) { for (row in dao.getAll()) {
dao.update(
row.copy(
originProviderId = newOriginId,
remoteKey = null,
remoteEtag = null,
remoteSizeBytes = null,
syncState = SyncState.QUEUED,
syncReason = null,
syncAttempts = 0,
syncRetryAtMs = null,
)
)
}
}
}
/**
* Legal steps from any resting state toward UPLOADING, per the transition
* table. Shared by the P7 foreground migrator and the M5 background drain
* so both move rows through identical states.
*/
internal fun stepsToUploading(from: SyncState): List<SyncState> = when (from) {
SyncState.LOCAL_ONLY, SyncState.ERROR -> listOf(SyncState.QUEUED, SyncState.UPLOADING)
// A leftover UPLOADING row (killed run) has no self-loop: detour via QUEUED.
SyncState.UPLOADING -> listOf(SyncState.QUEUED, SyncState.UPLOADING)
else -> listOf(SyncState.UPLOADING)
}
/**
* Consecutive-failure backoff: 1m, 2m, 4m … capped at 1h. [attempt] is the
* 1-based count *including* the failure just recorded.
*/
internal fun backoffDelayMs(attempt: Int): Long {
val shift = (attempt.coerceAtLeast(1) - 1).coerceAtMost(6)
return (60_000L shl shift).coerceAtMost(3_600_000L)
}

View file

@ -0,0 +1,16 @@
package com.shonar.recording
import androidx.room.TypeConverter
import com.shonar.provider.SyncState
/** SyncState <-> String by enum name. Unknown names (future states from a
* newer app reading this DB are impossible — same app writes and reads) fall
* back to LOCAL_ONLY rather than crashing the library. */
class SyncStateConverter {
@TypeConverter
fun toString(state: SyncState): String = state.name
@TypeConverter
fun toState(raw: String?): SyncState =
runCatching { SyncState.valueOf(raw!!) }.getOrDefault(SyncState.LOCAL_ONLY)
}

View file

@ -0,0 +1,141 @@
package com.shonar.recording
import android.content.Context
import androidx.work.BackoffPolicy
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.ExistingWorkPolicy
import androidx.work.NetworkType
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.shonar.ShonarApplication
import com.shonar.provider.AuthState
import com.shonar.provider.LocalOnlyProvider
import com.shonar.settings.BuiltInSettings
import java.util.concurrent.TimeUnit
/**
* M5 background sync driver. Thin by design: it resolves settings, the
* active provider, and live device gates, then hands one pass to the
* Context-free [SyncDrain] (which owns all state transitions and is where
* the unit tests live).
*
* Result contract:
* - success: the pass completed (per-file failures are recorded in the DB
* with backoff — retrying the whole worker immediately would hammer).
* - retry: transient only — offline at start, postponed by gates. Auth
* states needing the user (disconnected/expired/revoked) are failure.
* - cancellation propagates: the drain reverts the in-flight row to QUEUED.
*/
class SyncWorker(appContext: Context, params: WorkerParameters) :
CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val app = applicationContext as ShonarApplication
app.settingsManager.ensureLoaded()
val settings = app.settingsManager
val providerId = settings.string(BuiltInSettings.PROVIDER_ID).ifBlank { "local-only" }
if (providerId == LocalOnlyProvider.ID) return Result.success()
val wifiOnly = settings.bool(BuiltInSettings.WIFI_ONLY_UPLOAD)
val chargingOnly = settings.bool(BuiltInSettings.CHARGING_ONLY_UPLOAD)
val gates = readGates(applicationContext)
val provider = try {
app.providerRegistry.provider(providerId)
} catch (e: Exception) {
return Result.failure()
}
val auth = try {
provider.reconnect()
} catch (e: Exception) {
return Result.retry()
}
when (auth) {
AuthState.DISCONNECTED, AuthState.EXPIRED, AuthState.REVOKED ->
return Result.failure()
AuthState.OFFLINE -> return Result.retry()
AuthState.CONNECTED -> Unit
}
val drain = SyncDrain(SyncSlots(app.database.recordingDao()))
val res = drain.drainOnce(provider, wifiOnly, chargingOnly, gates)
return if (res.postponed) Result.retry() else Result.success()
}
companion object {
/** Snapshot of device conditions for [SyncDrain.Gates]. */
internal fun readGates(context: Context): SyncDrain.Gates {
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE)
as? android.net.ConnectivityManager
val caps = cm?.getNetworkCapabilities(cm.activeNetwork)
val online = caps?.hasCapability(
android.net.NetworkCapabilities.NET_CAPABILITY_VALIDATED
) == true
val unmetered = caps?.hasCapability(
android.net.NetworkCapabilities.NET_CAPABILITY_NOT_METERED
) == true
val bm = context.getSystemService(Context.BATTERY_SERVICE)
as? android.os.BatteryManager
return SyncDrain.Gates(
online = online,
unmetered = unmetered,
charging = bm?.isCharging == true,
)
}
}
}
/**
* WorkManager wiring: one-shot drains on demand, a periodic 15-minute
* drain (the platform minimum) while constraints hold, and pause.
* Constraint mapping is a pure function ([workConstraints]) so the
* settings-to-WorkManager translation is unit-tested.
*/
object SyncScheduler {
const val ONCE_NAME = "shonar-sync-drain-once"
const val PERIODIC_NAME = "shonar-sync-drain-periodic"
/** Pure settings -> constraints mapping. */
internal fun workConstraints(wifiOnly: Boolean, chargingOnly: Boolean): Constraints =
Constraints.Builder()
.setRequiredNetworkType(if (wifiOnly) NetworkType.UNMETERED else NetworkType.CONNECTED)
.setRequiresCharging(chargingOnly)
.build()
/** Drain now (startup, after a switch, after settings change). */
fun requestNow(context: Context, wifiOnly: Boolean, chargingOnly: Boolean) {
val req = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(workConstraints(wifiOnly, chargingOnly))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.addTag(ONCE_NAME)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(ONCE_NAME, ExistingWorkPolicy.REPLACE, req)
}
/** Steady-state background drain. Idempotent; safe to call on every launch. */
fun ensurePeriodic(context: Context, wifiOnly: Boolean, chargingOnly: Boolean) {
val req = PeriodicWorkRequestBuilder<SyncWorker>(15, TimeUnit.MINUTES)
.setConstraints(workConstraints(wifiOnly, chargingOnly))
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 1, TimeUnit.MINUTES)
.addTag(PERIODIC_NAME)
.build()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
PERIODIC_NAME, ExistingPeriodicWorkPolicy.UPDATE, req
)
}
/** Pause: cancel scheduled work and revert in-flight rows to QUEUED. */
suspend fun pauseAll(context: Context, dao: RecordingDao) {
val wm = WorkManager.getInstance(context)
wm.cancelUniqueWork(ONCE_NAME)
wm.cancelUniqueWork(PERIODIC_NAME)
for (row in dao.getAll()) {
if (row.syncState == com.shonar.provider.SyncState.UPLOADING) {
dao.update(row.copy(syncState = com.shonar.provider.SyncState.QUEUED))
}
}
}
}