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
|
|
@ -10,7 +10,8 @@ files added from outside into its library automatically.
|
||||||
|
|
||||||
- Record with one tap (app, notification, or home-screen widget), pause /
|
- Record with one tap (app, notification, or home-screen widget), pause /
|
||||||
resume, save with rename, play back with seek + speed.
|
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.
|
rename and delete move together with the audio file.
|
||||||
- Apache-2.0 licensed.
|
- Apache-2.0 licensed.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,14 @@ android {
|
||||||
applicationId = "com.shonar"
|
applicationId = "com.shonar"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 35
|
targetSdk = 35
|
||||||
versionCode = 1
|
// Every iteration stamps its version: code = commit count (monotonic),
|
||||||
versionName = "0.1.0"
|
// 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"
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,7 +47,11 @@ class ShonarApplication : Application(), PlaybackOwner {
|
||||||
private suspend fun libraryRoot(): File? = runCatching {
|
private suspend fun libraryRoot(): File? = runCatching {
|
||||||
settingsManager.ensureLoaded()
|
settingsManager.ensureLoaded()
|
||||||
val raw = settingsManager.string(KEY_LIBRARY_ROOT).ifBlank { DEFAULT_LIBRARY_PATH }
|
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()
|
}.getOrNull()
|
||||||
|
|
||||||
val recordingRepository: RecordingRepository by lazy {
|
val recordingRepository: RecordingRepository by lazy {
|
||||||
|
|
@ -67,6 +71,7 @@ class ShonarApplication : Application(), PlaybackOwner {
|
||||||
}
|
}
|
||||||
appScope.launch {
|
appScope.launch {
|
||||||
runCatching { recordingRepository.refreshRoot() }
|
runCatching { recordingRepository.refreshRoot() }
|
||||||
|
runCatching { recordingRepository.migrateRestoreTimes() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,17 @@ object RecordingFiles {
|
||||||
return "Recording - $s"
|
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. */
|
/** Null = blank / "." / ".." / nothing usable after cleaning. */
|
||||||
fun sanitizeStem(raw: String): String? {
|
fun sanitizeStem(raw: String): String? {
|
||||||
var stem = raw.trim()
|
var stem = raw.trim()
|
||||||
|
|
|
||||||
|
|
@ -26,21 +26,26 @@ import com.shonar.R
|
||||||
* system MediaStyle — that renders the small "SHONAR • SHONAR" text row
|
* system MediaStyle — that renders the small "SHONAR • SHONAR" text row
|
||||||
* with truncated filename and hides the big timer — and do NOT drop the
|
* with truncated filename and hides the big timer — and do NOT drop the
|
||||||
* DecoratedCustomViewStyle: without it the system ignores both custom
|
* DecoratedCustomViewStyle: without it the system ignores both custom
|
||||||
* views entirely (plain template stuck, cannot expand). Any layout change
|
* views entirely (plain template stuck, cannot expand). The secure
|
||||||
* here must keep the decorated custom RemoteViews cards + big timer, and
|
* lockscreen shows publicVersion() (single mic icon, no pills). Any layout
|
||||||
* must keep RecordingNotificationLayoutTest green.
|
* change here must keep the decorated custom RemoteViews cards + big
|
||||||
|
* timer, and must keep RecordingNotificationLayoutTest green.
|
||||||
*/
|
*/
|
||||||
object RecordingNotificationHelper {
|
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 = "shonar_recording"
|
||||||
|
private const val LEGACY_CHANNEL_ID_V1 = "shonar_recording_ongoing"
|
||||||
const val NOTIFICATION_ID = 1001
|
const val NOTIFICATION_ID = 1001
|
||||||
|
|
||||||
fun ensureChannel(ctx: Context) {
|
fun ensureChannel(ctx: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val mgr = ctx.getSystemService(NotificationManager::class.java)
|
val mgr = ctx.getSystemService(NotificationManager::class.java)
|
||||||
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) }
|
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID) }
|
||||||
|
runCatching { mgr.deleteNotificationChannel(LEGACY_CHANNEL_ID_V1) }
|
||||||
val existing = runCatching { mgr.getNotificationChannel(CHANNEL_ID) }.getOrNull()
|
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
|
existing.sound != null
|
||||||
) {
|
) {
|
||||||
runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) }
|
runCatching { mgr.deleteNotificationChannel(CHANNEL_ID) }
|
||||||
|
|
@ -49,12 +54,10 @@ object RecordingNotificationHelper {
|
||||||
NotificationChannel(
|
NotificationChannel(
|
||||||
CHANNEL_ID,
|
CHANNEL_ID,
|
||||||
ctx.getString(R.string.notif_channel_recording),
|
ctx.getString(R.string.notif_channel_recording),
|
||||||
// DEFAULT, not HIGH: a heads-up banner on every record start
|
// HIGH: a heads-up banner on record start is the visible
|
||||||
// pins the shade item expanded for ~5s, which reads as the
|
// confirmation that recording began. Ongoing FGS rows stay
|
||||||
// "wrong" notification. The user just tapped record
|
// top-ranked.
|
||||||
// deliberately; the widget badge + ongoing row are
|
NotificationManager.IMPORTANCE_HIGH,
|
||||||
// confirmation enough. Ongoing FGS rows stay top-ranked.
|
|
||||||
NotificationManager.IMPORTANCE_DEFAULT,
|
|
||||||
).apply {
|
).apply {
|
||||||
description = ctx.getString(R.string.notif_channel_recording_desc)
|
description = ctx.getString(R.string.notif_channel_recording_desc)
|
||||||
setSound(null, null)
|
setSound(null, null)
|
||||||
|
|
@ -149,10 +152,13 @@ object RecordingNotificationHelper {
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
.setOnlyAlertOnce(true)
|
.setOnlyAlertOnce(true)
|
||||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
.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)
|
.setContentIntent(openApp)
|
||||||
.setShowWhen(false)
|
.setShowWhen(false)
|
||||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||||
// No addAction() here: system action rows duplicate the pills
|
// No addAction() here: system action rows duplicate the pills
|
||||||
// below and crowd the expanded card. (Surfaces that can't show
|
// below and crowd the expanded card. (Surfaces that can't show
|
||||||
// custom views — watches, Auto — get title/text + tap-to-open;
|
// custom views — watches, Auto — get title/text + tap-to-open;
|
||||||
|
|
@ -172,6 +178,30 @@ object RecordingNotificationHelper {
|
||||||
.build()
|
.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. */
|
/** "Delete current recording? No / Yes" — same single-card pattern. */
|
||||||
private fun buildConfirm(ctx: Context, openApp: PendingIntent): Notification {
|
private fun buildConfirm(ctx: Context, openApp: PendingIntent): Notification {
|
||||||
val card = RemoteViews(ctx.packageName, R.layout.notification_confirm_delete)
|
val card = RemoteViews(ctx.packageName, R.layout.notification_confirm_delete)
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ class RecordingRepository(
|
||||||
|
|
||||||
@Volatile private var libraryRoot: File = legacyDir
|
@Volatile private var libraryRoot: File = legacyDir
|
||||||
private val recordingsDir: File get() = libraryRoot
|
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() }
|
private fun tempDir(): File = File(libraryRoot, ".in-progress").apply { mkdirs() }
|
||||||
|
|
||||||
/** Serializes library scans so two passes can never double-insert a file. */
|
/** 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). */
|
/** Re-read the destination (call at startup and after a location change). */
|
||||||
suspend fun refreshRoot() {
|
suspend fun refreshRoot() {
|
||||||
libraryRoot = withContext(Dispatchers.IO) {
|
// Null resolver result keeps the previous folder (never reset to the
|
||||||
runCatching { rootResolver?.invoke() }.getOrNull() ?: legacyDir
|
// sandbox behind the user's back on a bad path).
|
||||||
}
|
val resolved = withContext(Dispatchers.IO) {
|
||||||
|
runCatching { rootResolver?.invoke() }.getOrNull()
|
||||||
|
} ?: return
|
||||||
|
libraryRoot = resolved
|
||||||
libraryRoot.mkdirs()
|
libraryRoot.mkdirs()
|
||||||
importExistingFiles()
|
importExistingFiles()
|
||||||
}
|
}
|
||||||
|
|
@ -99,7 +104,6 @@ class RecordingRepository(
|
||||||
known += runCatching { f.canonicalPath }.getOrNull() ?: f.absolutePath
|
known += runCatching { f.canonicalPath }.getOrNull() ?: f.absolutePath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun audioDurationMs(f: File): Long = runCatching {
|
private fun audioDurationMs(f: File): Long = runCatching {
|
||||||
android.media.MediaMetadataRetriever().use { r ->
|
android.media.MediaMetadataRetriever().use { r ->
|
||||||
r.setDataSource(f.absolutePath)
|
r.setDataSource(f.absolutePath)
|
||||||
|
|
@ -108,6 +112,48 @@ class RecordingRepository(
|
||||||
}
|
}
|
||||||
}.getOrDefault(0L)
|
}.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")
|
fun tempFile(id: String): File = File(tempDir(), "$id.m4a")
|
||||||
|
|
||||||
suspend fun finish(
|
suspend fun finish(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,7 @@
|
||||||
package com.shonar.settings
|
package com.shonar.settings
|
||||||
|
|
||||||
|
import com.shonar.ShonarApplication
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Built-in settings. These ship with the app; users may add more at runtime.
|
* 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(
|
val all: List<SettingDefinition> = listOf(
|
||||||
// --- General -------------------------------------------------------
|
// --- 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(
|
SettingDefinition(
|
||||||
id = "default_recording_title_format",
|
id = "default_recording_title_format",
|
||||||
name = "Default recording title",
|
name = "Default recording title",
|
||||||
|
|
|
||||||
|
|
@ -67,9 +67,24 @@ class SettingsViewModel(private val app: ShonarApplication) : ViewModel() {
|
||||||
SettingsUiState.CategorySection(
|
SettingsUiState.CategorySection(
|
||||||
cat,
|
cat,
|
||||||
items.map { row ->
|
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(
|
SettingsUiState.SettingRow(
|
||||||
def = row,
|
def = def,
|
||||||
valueJson = settings.value(row.id)?.toString() ?: row.defaultJson,
|
// 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
|
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 {
|
fun setValue(id: String, jsonValue: String) = viewModelScope.launch {
|
||||||
runCatching { settings.setValue(id, jsonValue) }
|
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" }
|
.onFailure { messageFlow.value = it.message ?: "Invalid value" }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun reset(id: String) = viewModelScope.launch {
|
fun reset(id: String) = viewModelScope.launch {
|
||||||
runCatching { settings.reset(id) }
|
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 }
|
.onFailure { messageFlow.value = it.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -271,4 +271,17 @@ class SettingsManagerTest {
|
||||||
catch (e: SettingValidationException) { threw = true }
|
catch (e: SettingValidationException) { threw = true }
|
||||||
assertTrue(threw)
|
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() {
|
@Test fun recordingChannel_popsHeadsUpOnRecord() {
|
||||||
// HIGH importance pops a heads-up on every record start, pinning the
|
// HIGH importance pops a heads-up banner on record start: the visible
|
||||||
// shade item expanded for ~5s (reads as the "wrong" notification).
|
// confirmation that recording began. v2 id sidesteps any stuck
|
||||||
// DEFAULT keeps the ongoing row top-ranked with no banner.
|
// importance lingering on the old channel id.
|
||||||
val src = helperSource()
|
val src = helperSource()
|
||||||
assertTrue(
|
assertTrue(
|
||||||
"channel must be IMPORTANCE_DEFAULT, not HIGH",
|
"channel must be IMPORTANCE_HIGH",
|
||||||
src.contains("IMPORTANCE_DEFAULT") && "IMPORTANCE_HIGH" !in src,
|
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