M9: search endpoint + list filters, exports (audio/txt/md/zip), retention sweep
This commit is contained in:
parent
4944fcd162
commit
6824d9fbf1
20 changed files with 1513 additions and 12 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue