diff --git a/README.md b/README.md index c87d124..56893e3 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,8 @@ files added from outside into its library automatically. - Record with one tap (app, notification, or home-screen widget), pause / resume, save with rename, play back with seek + speed. -- Library lives in a folder on the phone (`Music/Recordings` by default); +- Library lives in a folder on the phone (`Music/Recordings` by default; + change it in Settings → Library folder); rename and delete move together with the audio file. - Apache-2.0 licensed. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 095ead2..58f3bce 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -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" } diff --git a/android/app/src/main/java/com/shonar/ShonarApplication.kt b/android/app/src/main/java/com/shonar/ShonarApplication.kt index 619b99f..525a67f 100644 --- a/android/app/src/main/java/com/shonar/ShonarApplication.kt +++ b/android/app/src/main/java/com/shonar/ShonarApplication.kt @@ -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() } } } diff --git a/android/app/src/main/java/com/shonar/recording/RecordingFiles.kt b/android/app/src/main/java/com/shonar/recording/RecordingFiles.kt index 70678bb..a1e61b4 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingFiles.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingFiles.kt @@ -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() diff --git a/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt b/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt index 6b2a409..c9f82f1 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingNotificationHelper.kt @@ -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) diff --git a/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt index 9928c60..3787d68 100644 --- a/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt +++ b/android/app/src/main/java/com/shonar/recording/RecordingRepository.kt @@ -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( diff --git a/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt b/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt index dbb814b..0151f25 100644 --- a/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt +++ b/android/app/src/main/java/com/shonar/settings/BuiltInSettings.kt @@ -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 = 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", diff --git a/android/app/src/main/java/com/shonar/ui/settings/SettingsViewModel.kt b/android/app/src/main/java/com/shonar/ui/settings/SettingsViewModel.kt index c6f9f18..0de53b7 100644 --- a/android/app/src/main/java/com/shonar/ui/settings/SettingsViewModel.kt +++ b/android/app/src/main/java/com/shonar/ui/settings/SettingsViewModel.kt @@ -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 } } diff --git a/android/app/src/test/java/com/shonar/SettingsManagerTest.kt b/android/app/src/test/java/com/shonar/SettingsManagerTest.kt index 25bb790..99bdc7f 100644 --- a/android/app/src/test/java/com/shonar/SettingsManagerTest.kt +++ b/android/app/src/test/java/com/shonar/SettingsManagerTest.kt @@ -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")) + } } diff --git a/android/app/src/test/java/com/shonar/recording/LibraryRootTest.kt b/android/app/src/test/java/com/shonar/recording/LibraryRootTest.kt new file mode 100644 index 0000000..cd811e4 --- /dev/null +++ b/android/app/src/test/java/com/shonar/recording/LibraryRootTest.kt @@ -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), + ) + } +} diff --git a/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt b/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt index ee1ed64..db12c5e 100644 --- a/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt +++ b/android/app/src/test/java/com/shonar/recording/RecordingNotificationLayoutTest.kt @@ -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("), ) } } diff --git a/android/app/src/test/java/com/shonar/recording/TitleRestoreTest.kt b/android/app/src/test/java/com/shonar/recording/TitleRestoreTest.kt new file mode 100644 index 0000000..db93336 --- /dev/null +++ b/android/app/src/test/java/com/shonar/recording/TitleRestoreTest.kt @@ -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 { + 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())), + ) + } +}