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:
parent
8cd8861695
commit
5235707de5
12 changed files with 359 additions and 29 deletions
|
|
@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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("),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())),
|
||||
)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue