M9: search endpoint + list filters, exports (audio/txt/md/zip), retention sweep

This commit is contained in:
avi 2026-09-14 08:51:24 -05:00
commit 6824d9fbf1
20 changed files with 1513 additions and 12 deletions

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,112 @@
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 androidx.core.app.NotificationCompat
import com.shonar.MainActivity
import com.shonar.R
/**
* Branding: all colors live in Theme.kt (Teal #4FD1C5 / DeepNavy #0B1220).
* Notification uses the app icon + [NotificationCompat] accent defaults so
* it follows the system theme. Replace R.drawable.* with Shonar's final
* logo assets — only this file + widget layout reference them.
*
* Icons: system drawables (android.R.drawable.*) are used for actions so
* no new assets are required. Swap with branded vectors later:
* ic_cancel, ic_pause, ic_play, ic_delete, ic_save_check.
*/
object RecordingNotificationHelper {
const val 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)
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
ctx.getString(R.string.notif_channel_recording),
NotificationManager.IMPORTANCE_LOW, // no sound/vibration while recording
).apply { description = ctx.getString(R.string.notif_channel_recording_desc) },
)
}
private fun actionIntent(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,
)
}
fun build(ctx: Context, phase: RecordingSnapshot.Phase, elapsedMs: Long): Notification {
val openApp = PendingIntent.getActivity(
ctx, 10,
Intent(ctx, MainActivity::class.java)
.setAction(Intent.ACTION_MAIN)
.addCategory(android.content.Intent.CATEGORY_LAUNCHER),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val paused = phase == RecordingSnapshot.Phase.PAUSED
val title = if (paused) {
ctx.getString(R.string.notif_paused)
} else {
ctx.getString(R.string.notif_recording)
}
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_mic)
.setContentTitle(title)
.setContentText(formatElapsed(elapsedMs))
.setSubText("SHONAR")
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(openApp)
.setShowWhen(false)
.setUsesChronometer(false)
// Chronometer-style elapsed is rendered as text so Glance/widget
// and notification stay in sync without re-posting every second.
.addAction(
android.R.drawable.ic_menu_close_clear_cancel, "Cancel",
actionIntent(ctx, RecordingService.ACTION_CANCEL, 21),
)
.addAction(
if (paused) android.R.drawable.ic_media_play else android.R.drawable.ic_media_pause,
if (paused) "Resume" else "Pause",
actionIntent(
ctx,
if (paused) RecordingService.ACTION_RESUME else RecordingService.ACTION_PAUSE,
22,
),
)
.addAction(
android.R.drawable.ic_menu_delete, "Delete",
actionIntent(ctx, RecordingService.ACTION_DELETE, 23),
)
.addAction(
android.R.drawable.checkbox_on_background, "Save",
actionIntent(ctx, RecordingService.ACTION_SAVE, 24),
)
// MediaStyle keeps Save (checkmark) prominent on all OEM skins.
builder.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(1, 3),
)
return builder.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,42 @@
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,
) {
// 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() }
}
}