Library folder setting + title-time restore + notification heads-up/lockscreen + version stamp

- Settings: editable Library folder (blank = default Music/Recordings);
  bad/unusable paths keep previous folder, Current path shown, toast on update/reset
- Repository: refreshRoot never resets behind user; migrateRestoreTimes()
  restores HH-mm from createdAt for date-only titles, handles (2) collisions, once
- Notification: fresh v2 channel IMPORTANCE_HIGH (banner on record start),
  PRIVATE + publicVersion for secure lockscreen (single mic row, no pills)
- Build: versionCode = git commit count, versionName 0.1.<count>
- README: library folder note
- Tests: LibraryRootTest, TitleRestoreTest, updated layout + settings tests
This commit is contained in:
avi 2026-09-15 10:41:17 -05:00
commit 5235707de5
12 changed files with 359 additions and 29 deletions

View file

@ -14,8 +14,14 @@ android {
applicationId = "com.shonar"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
// Every iteration stamps its version: code = commit count (monotonic),
// name carries it. Falls back to 1 when git is unavailable.
val gitCount = runCatching {
providers.exec { commandLine("git", "rev-list", "--count", "HEAD") }
.standardOutput.asText.get().trim().toInt()
}.getOrDefault(1)
versionCode = gitCount
versionName = "0.1.$gitCount"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

View file

@ -47,7 +47,11 @@ class ShonarApplication : Application(), PlaybackOwner {
private suspend fun libraryRoot(): File? = runCatching {
settingsManager.ensureLoaded()
val raw = settingsManager.string(KEY_LIBRARY_ROOT).ifBlank { DEFAULT_LIBRARY_PATH }
File(raw).takeIf { it.exists() } ?: File(DEFAULT_LIBRARY_PATH).apply { mkdirs() }
val dir = File(raw)
runCatching { dir.mkdirs() }
// Null = unusable (e.g. no file grant yet): the repository keeps the
// previous folder instead of resetting behind the user's back.
dir.takeIf { it.isDirectory && it.canRead() && it.canWrite() }
}.getOrNull()
val recordingRepository: RecordingRepository by lazy {
@ -67,6 +71,7 @@ class ShonarApplication : Application(), PlaybackOwner {
}
appScope.launch {
runCatching { recordingRepository.refreshRoot() }
runCatching { recordingRepository.migrateRestoreTimes() }
}
}

View file

@ -23,6 +23,17 @@ object RecordingFiles {
return "Recording - $s"
}
/** Date-only default titles ("Recording - 2026-09-14", maybe " (n)"): time was stripped. */
private val datedTitleNoTime =
Regex("^(Shonar )?Recording - \\d{4}-\\d{2}-\\d{2}( \\(\\d+\\))?$")
/** "Recording - 2026-09-14 (2)" -> "Recording - 2026-09-14"; null unless default-shaped. */
fun restoreBase(title: String): String? {
val t = title.trim()
if (!datedTitleNoTime.matches(t)) return null
return t.replace(Regex(" \\(\\d+\\)$"), "")
}
/** Null = blank / "." / ".." / nothing usable after cleaning. */
fun sanitizeStem(raw: String): String? {
var stem = raw.trim()

View file

@ -26,21 +26,26 @@ import com.shonar.R
* system MediaStyle — that renders the small "SHONAR • SHONAR" text row
* with truncated filename and hides the big timer — and do NOT drop the
* DecoratedCustomViewStyle: without it the system ignores both custom
* views entirely (plain template stuck, cannot expand). Any layout change
* here must keep the decorated custom RemoteViews cards + big timer, and
* must keep RecordingNotificationLayoutTest green.
* views entirely (plain template stuck, cannot expand). The secure
* lockscreen shows publicVersion() (single mic icon, no pills). Any layout
* change here must keep the decorated custom RemoteViews cards + big
* timer, and must keep RecordingNotificationLayoutTest green.
*/
object RecordingNotificationHelper {
const val CHANNEL_ID = "shonar_recording_ongoing"
// v2: fresh id — sidesteps any stuck/user-locked importance lingering on
// the old id. Created HIGH on every service start (see ensureChannel).
const val CHANNEL_ID = "shonar_recording_ongoing_v2"
private const val LEGACY_CHANNEL_ID = "shonar_recording"
private const val LEGACY_CHANNEL_ID_V1 = "shonar_recording_ongoing"
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) }
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID_V1) }
val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull()
if (existing == null || existing.importance != NotificationManager.IMPORTANCE_DEFAULT ||
if (existing == null || existing.importance != NotificationManager.IMPORTANCE_HIGH ||
existing.sound != null
) {
runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) }
@ -49,12 +54,10 @@ object RecordingNotificationHelper {
NotificationChannel(
CHANNEL_ID,
ctx.getString(R.string.notif_channel_recording),
// DEFAULT, not HIGH: a heads-up banner on every record start
// pins the shade item expanded for ~5s, which reads as the
// "wrong" notification. The user just tapped record
// deliberately; the widget badge + ongoing row are
// confirmation enough. Ongoing FGS rows stay top-ranked.
NotificationManager.IMPORTANCE_DEFAULT,
// HIGH: a heads-up banner on record start is the visible
// confirmation that recording began. Ongoing FGS rows stay
// top-ranked.
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = ctx.getString(R.string.notif_channel_recording_desc)
setSound(null, null)
@ -149,10 +152,13 @@ object RecordingNotificationHelper {
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
// PRIVATE + publicVersion below: the secure lockscreen shows the
// simplified single-icon row instead of the full card.
.setVisibility(NotificationCompat.VISIBILITY_PRIVATE)
.setPublicVersion(publicVersion(ctx, openApp, elapsed, fileName))
.setContentIntent(openApp)
.setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setPriority(NotificationCompat.PRIORITY_HIGH)
// No addAction() here: system action rows duplicate the pills
// below and crowd the expanded card. (Surfaces that can't show
// custom views — watches, Auto — get title/text + tap-to-open;
@ -172,6 +178,30 @@ object RecordingNotificationHelper {
.build()
}
/**
* Secure-lockscreen row: plain template, single mic icon, no custom
* card — so no doubled icons and no pills behind the lock. Tapping
* opens the app; shade (unlocked) is untouched.
*/
private fun publicVersion(
ctx: Context,
openApp: PendingIntent,
elapsed: String,
fileName: String,
): Notification {
return NotificationCompat.Builder(ctx, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_mic)
.setContentTitle("Recording")
.setContentText("$elapsed • $fileName")
.setOngoing(true)
.setOnlyAlertOnce(true)
.setCategory(NotificationCompat.CATEGORY_SERVICE)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.setContentIntent(openApp)
.setShowWhen(false)
.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)

View file

@ -27,6 +27,8 @@ class RecordingRepository(
@Volatile private var libraryRoot: File = legacyDir
private val recordingsDir: File get() = libraryRoot
/** Effective folder UI can display (blank setting = default path). */
fun currentRootPath(): String = libraryRoot.absolutePath
private fun tempDir(): File = File(libraryRoot, ".in-progress").apply { mkdirs() }
/** Serializes library scans so two passes can never double-insert a file. */
@ -38,9 +40,12 @@ class RecordingRepository(
/** Re-read the destination (call at startup and after a location change). */
suspend fun refreshRoot() {
libraryRoot = withContext(Dispatchers.IO) {
runCatching { rootResolver?.invoke() }.getOrNull() ?: legacyDir
}
// Null resolver result keeps the previous folder (never reset to the
// sandbox behind the user's back on a bad path).
val resolved = withContext(Dispatchers.IO) {
runCatching { rootResolver?.invoke() }.getOrNull()
} ?: return
libraryRoot = resolved
libraryRoot.mkdirs()
importExistingFiles()
}
@ -99,7 +104,6 @@ class RecordingRepository(
known += runCatching { f.canonicalPath }.getOrNull() ?: f.absolutePath
}
}
private fun audioDurationMs(f: File): Long = runCatching {
android.media.MediaMetadataRetriever().use { r ->
r.setDataSource(f.absolutePath)
@ -108,6 +112,48 @@ class RecordingRepository(
}
}.getOrDefault(0L)
/**
* One-time undo: an earlier build stripped time suffixes
* ("Recording - 2026-09-14 09-26" -> "Recording - 2026-09-14"). Restores
* them from each row's recorded-at time; same-minute collisions become
* "Name (2)". Only default-shaped titles are touched. Runs once, and
* only if the strip pass ran (marker file).
*/
suspend fun migrateRestoreTimes() {
val marker = File(sandboxDir, ".title-time-restored")
if (marker.exists()) return
importMutex.withLock {
withContext(Dispatchers.IO) {
try {
if (!File(sandboxDir, ".title-dateonly-migrated").exists()) return@withContext
val fmt = java.text.SimpleDateFormat("HH-mm", java.util.Locale.US)
for (row in dao.getAll().sortedBy { it.createdAtEpochMs }) {
val base = RecordingFiles.restoreBase(row.title) ?: continue
val stem = base + " " +
fmt.format(java.util.Date(row.createdAtEpochMs))
val cur = File(row.filePath)
val dir = cur.parentFile ?: continue
if (!cur.exists()) continue
val target = RecordingFiles.uniqueFinalFile(dir, stem)
if (target.canonicalPath == cur.canonicalPath) continue
if (!cur.renameTo(target)) continue
runCatching {
dao.update(
row.copy(
title = target.nameWithoutExtension,
filePath = target.absolutePath,
sizeBytes = target.length(),
),
)
}
}
} finally {
runCatching { File(sandboxDir, ".title-dateonly-migrated").delete() }
runCatching { marker.createNewFile() }
}
}
}
}
fun tempFile(id: String): File = File(tempDir(), "$id.m4a")
suspend fun finish(

View file

@ -1,5 +1,7 @@
package com.shonar.settings
import com.shonar.ShonarApplication
/**
* Built-in settings. These ship with the app; users may add more at runtime.
*
@ -18,6 +20,17 @@ object BuiltInSettings {
val all: List<SettingDefinition> = listOf(
// --- General -------------------------------------------------------
SettingDefinition(
// id must stay in sync: ShonarApplication reads it as the library root.
id = ShonarApplication.KEY_LIBRARY_ROOT,
name = "Library folder",
description = "Where recordings are saved. Blank means the " +
"default Music/Recordings folder. Applies to new " +
"recordings; unusable paths keep the previous folder.",
category = CAT_GENERAL,
type = SettingType.STRING,
defaultJson = "\"\"",
),
SettingDefinition(
id = "default_recording_title_format",
name = "Default recording title",

View file

@ -67,9 +67,24 @@ class SettingsViewModel(private val app: ShonarApplication) : ViewModel() {
SettingsUiState.CategorySection(
cat,
items.map { row ->
// Library folder: blank means default — show the
// resolved path so the box never looks empty.
val def = if (row.id == ShonarApplication.KEY_LIBRARY_ROOT) {
row.copy(
description = row.description +
"\nCurrent: " + app.recordingRepository.currentRootPath(),
)
} else {
row
}
SettingsUiState.SettingRow(
def = row,
valueJson = settings.value(row.id)?.toString() ?: row.defaultJson,
def = def,
// App version is stamped at build time, not stored.
valueJson = if (row.id == "app_version") {
"\"" + runCatching { appVersionName() }.getOrNull() + "\""
} else {
settings.value(row.id)?.toString() ?: row.defaultJson
},
)
},
)
@ -83,13 +98,39 @@ class SettingsViewModel(private val app: ShonarApplication) : ViewModel() {
searchFlow.value = q
}
/** Build-stamped version (versionName), not a stored setting. */
private fun appVersionName(): String? {
val pm = app.packageManager
return if (android.os.Build.VERSION.SDK_INT >= 33) {
pm.getPackageInfo(
app.packageName,
android.content.pm.PackageManager.PackageInfoFlags.of(0),
).versionName
} else {
@Suppress("DEPRECATION")
pm.getPackageInfo(app.packageName, 0).versionName
}
}
fun setValue(id: String, jsonValue: String) = viewModelScope.launch {
runCatching { settings.setValue(id, jsonValue) }
.onSuccess {
if (id == ShonarApplication.KEY_LIBRARY_ROOT) {
runCatching { app.recordingRepository.refreshRoot() }
messageFlow.value = "Library folder updated"
}
}
.onFailure { messageFlow.value = it.message ?: "Invalid value" }
}
fun reset(id: String) = viewModelScope.launch {
runCatching { settings.reset(id) }
.onSuccess {
if (id == ShonarApplication.KEY_LIBRARY_ROOT) {
runCatching { app.recordingRepository.refreshRoot() }
messageFlow.value = "Library folder reset to default"
}
}
.onFailure { messageFlow.value = it.message }
}

View file

@ -271,4 +271,17 @@ class SettingsManagerTest {
catch (e: SettingValidationException) { threw = true }
assertTrue(threw)
}
@Test
fun builtIns_includeEditableLibraryFolder() = runTest {
val sm = manager()
sm.ensureLoaded()
val def = sm.definitions.value.first { it.id == "library.root" }
assertTrue(def.editable)
assertEquals(SettingType.STRING, def.type)
// blank = default Music/Recordings folder
assertEquals("", sm.string("library.root"))
sm.setValue("library.root", "\"/storage/emulated/0/Music/FieldNotes\"")
assertEquals("/storage/emulated/0/Music/FieldNotes", sm.string("library.root"))
}
}

View file

@ -0,0 +1,38 @@
package com.shonar.recording
import java.io.File
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
class LibraryRootTest {
@Test fun refreshRoot_keepsPreviousFolderWhenResolverReturnsNull() = runTest {
val first = tmpDir("libroot-first")
var resolveTo: File? = first
val repo = RecordingRepository(tmpDir("libroot-sandbox"), FakeDao()) { resolveTo }
repo.refreshRoot()
resolveTo = null // e.g. folder lost its file grant
repo.refreshRoot()
val tmp = repo.tempFile("probe")
assertTrue(
"bad path must keep the previous folder, not reset: ${tmp.absolutePath}",
tmp.absolutePath.startsWith(first.absolutePath),
)
}
@Test fun refreshRoot_adoptsNewFolderWhenResolverSucceeds() = runTest {
var resolveTo: File? = tmpDir("libroot-first")
val repo = RecordingRepository(tmpDir("libroot-sandbox"), FakeDao()) { resolveTo }
repo.refreshRoot()
resolveTo = tmpDir("libroot-second").also { it.mkdirs() }
repo.refreshRoot()
assertEquals(resolveTo!!.absolutePath, repo.currentRootPath())
val tmp = repo.tempFile("probe")
assertTrue(
"new folder must take effect: ${tmp.absolutePath}",
tmp.absolutePath.startsWith(resolveTo!!.absolutePath),
)
}
}

View file

@ -127,14 +127,32 @@ class RecordingNotificationLayoutTest {
)
}
@Test fun recordingChannel_doesNotPopHeadsUp() {
// HIGH importance pops a heads-up on every record start, pinning the
// shade item expanded for ~5s (reads as the "wrong" notification).
// DEFAULT keeps the ongoing row top-ranked with no banner.
@Test fun recordingChannel_popsHeadsUpOnRecord() {
// HIGH importance pops a heads-up banner on record start: the visible
// confirmation that recording began. v2 id sidesteps any stuck
// importance lingering on the old channel id.
val src = helperSource()
assertTrue(
"channel must be IMPORTANCE_DEFAULT, not HIGH",
src.contains("IMPORTANCE_DEFAULT") && "IMPORTANCE_HIGH" !in src,
"channel must be IMPORTANCE_HIGH",
src.contains("IMPORTANCE_HIGH") && "IMPORTANCE_DEFAULT" !in src,
)
assertTrue(
"channel must be the fresh v2 id",
src.contains("shonar_recording_ongoing_v2"),
)
}
@Test fun lockscreen_showsSimplifiedPublicVersion() {
// Secure lockscreen shows the single-icon template row instead of
// the full card (no doubled icons, no pills behind the lock).
val src = helperSource()
assertTrue(
"ongoing card must be PRIVATE so the lockscreen uses the public version",
src.contains("VISIBILITY_PRIVATE"),
)
assertTrue(
"must supply a public version for the lockscreen",
src.contains("setPublicVersion("),
)
}
}

View file

@ -0,0 +1,108 @@
package com.shonar.recording
import com.shonar.provider.SyncState
import java.io.File
import java.util.Calendar
import java.util.Locale
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class TitleRestoreTest {
private fun row(dir: File, id: String, title: String, createdAt: Long): RecordingEntity {
val f = File(dir, "$title.m4a").apply { writeBytes(ByteArray(64) { 0x1 }) }
return RecordingEntity(
id = id, title = title, createdAtEpochMs = createdAt,
durationMs = 1000, filePath = f.absolutePath, mimeType = "audio/mp4",
sizeBytes = f.length(), syncState = SyncState.LOCAL_ONLY,
)
}
/** Fixed clock: 2026-09-14 09:26 in the ambient timezone (formatter agrees). */
private fun nineTwentySix(): Long {
val c = Calendar.getInstance()
c.set(2026, Calendar.SEPTEMBER, 14, 9, 26, 0)
c.set(Calendar.MILLISECOND, 0)
return c.timeInMillis
}
private fun repoWithStripMarker(dao: FakeDao): Pair<RecordingRepository, File> {
val sandbox = tmpDir("title-restore-sb")
File(sandbox, ".title-dateonly-migrated").createNewFile()
return RecordingRepository(sandbox, dao) to sandbox
}
@Test fun restoresTimeFromCreatedAt() = runTest {
val dir = tmpDir("title-restore-basic")
val dao = FakeDao(listOf(row(dir, "a", "Recording - 2026-09-14", nineTwentySix())))
val (repo, _) = repoWithStripMarker(dao)
repo.migrateRestoreTimes()
val after = dao.getById("a")!!
assertEquals("Recording - 2026-09-14 09-26", after.title)
assertTrue(File(dir, "Recording - 2026-09-14 09-26.m4a").exists())
assertFalse(File(dir, "Recording - 2026-09-14.m4a").exists())
}
@Test fun stripsForwardCollisionSuffixFirst() = runTest {
val dir = tmpDir("title-restore-collide")
val dao = FakeDao(listOf(row(dir, "a", "Recording - 2026-09-14 (2)", nineTwentySix())))
val (repo, _) = repoWithStripMarker(dao)
repo.migrateRestoreTimes()
assertEquals("Recording - 2026-09-14 09-26", dao.getById("a")!!.title)
}
@Test fun sameMinuteCollisionGetsCounterSuffix() = runTest {
val dir = tmpDir("title-restore-same-min")
val dao = FakeDao(
listOf(
row(dir, "a", "Recording - 2026-09-14", nineTwentySix()),
row(dir, "b", "Recording - 2026-09-14 (2)", nineTwentySix()),
),
)
val (repo, _) = repoWithStripMarker(dao)
repo.migrateRestoreTimes()
assertEquals("Recording - 2026-09-14 09-26", dao.getById("a")!!.title)
assertEquals("Recording - 2026-09-14 09-26 (2)", dao.getById("b")!!.title)
}
@Test fun userTitlesUntouched() = runTest {
val dir = tmpDir("title-restore-user")
val dao = FakeDao(listOf(row(dir, "a", "Morning interview", nineTwentySix())))
val (repo, _) = repoWithStripMarker(dao)
repo.migrateRestoreTimes()
assertEquals("Morning interview", dao.getById("a")!!.title)
assertTrue(File(dir, "Morning interview.m4a").exists())
}
@Test fun noOpWithoutStripMarker() = runTest {
val dir = tmpDir("title-restore-nomark")
val dao = FakeDao(listOf(row(dir, "a", "Recording - 2026-09-14", nineTwentySix())))
RecordingRepository(tmpDir("title-restore-nomark-sb"), dao).migrateRestoreTimes()
assertEquals("Recording - 2026-09-14", dao.getById("a")!!.title)
assertTrue(File(dir, "Recording - 2026-09-14.m4a").exists())
}
@Test fun defaultDisplayNameKeepsTime24h() {
val name = RecordingFiles.defaultDisplayName(nineTwentySix())
assertTrue(
"expected 'Recording - 2026-09-14 09-26', got '$name'",
name == "Recording - 2026-09-14 09-26",
)
}
@Test fun restoreBase_rejectsNonDefaultShapes() {
assertEquals("Recording - 2026-09-14", RecordingFiles.restoreBase("Recording - 2026-09-14"))
assertEquals("Recording - 2026-09-14", RecordingFiles.restoreBase("Recording - 2026-09-14 (2)"))
assertNull(RecordingFiles.restoreBase("Morning interview"))
assertNull(RecordingFiles.restoreBase("Recording - 2026-09-14 09-26"))
assertNull(RecordingFiles.restoreBase("Take 2 10-30"))
assertEquals(
"09-26",
java.text.SimpleDateFormat("HH-mm", Locale.US).format(java.util.Date(nineTwentySix())),
)
}
}