Remove all Android/phone code: the app is standalone desktop
shared/ carried the vendored phone app's tree behind a 30-entry
exclude list. Deleted the 46 phone-only files (recording service,
widgets, Room DB, Nextcloud/local-only/folder-sync providers, TOFU
trust, Android settings stores, phone UI screens) and kept only the
seven the desktop compiles: CustomShonarProvider, ProviderTypes,
ShonarProvider, ShonarAuthStore, ShonarHandshake, ProviderRegistry,
AiContent. ProviderRegistry reduces to its id constant; handshake
drops the TOFU param; provider defaults self-identify as desktop.
Gradle exclude list gone — srcDir('../shared') is pure desktop now.
This commit is contained in:
parent
751428cf3e
commit
71b55715b4
52 changed files with 24 additions and 9049 deletions
|
|
@ -1,89 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,328 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
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,
|
||||
)
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,187 +0,0 @@
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
|
|
@ -1,200 +0,0 @@
|
|||
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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,552 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
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() }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,41 +0,0 @@
|
|||
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")
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
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)
|
||||
|
|
@ -1,116 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,16 +0,0 @@
|
|||
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)
|
||||
}
|
||||
|
|
@ -1,141 +0,0 @@
|
|||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue