Android: StrictMode (debug) + off-main-thread IO — root resolver, file import, and DateFormat moved off composition/main; details label preformatted

This commit is contained in:
avi 2026-09-14 12:29:14 -05:00
commit d9f3d0b5c4
4 changed files with 36 additions and 6 deletions

View file

@ -51,6 +51,15 @@ class ShonarApplication : Application() {
override fun onCreate() {
super.onCreate()
if (android.os.Build.VERSION.SDK_INT >= 9 && runCatching {
(applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0
}.getOrDefault(false)) {
android.os.StrictMode.setThreadPolicy(
android.os.StrictMode.ThreadPolicy.Builder()
.detectDiskReads().detectDiskWrites().detectNetwork()
.detectCustomSlowCalls().penaltyLog().build()
)
}
appScope.launch {
runCatching { recordingRepository.refreshRoot() }
}

View file

@ -1,9 +1,11 @@
package com.shonar.recording
import com.shonar.provider.SyncState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import java.io.File
import java.text.DateFormat
import java.util.Date
@ -36,7 +38,9 @@ class RecordingRepository(
/** Re-read the destination (call at startup and after a location change). */
suspend fun refreshRoot() {
libraryRoot = runCatching { rootResolver?.invoke() }.getOrNull() ?: legacyDir
libraryRoot = withContext(Dispatchers.IO) {
runCatching { rootResolver?.invoke() }.getOrNull() ?: legacyDir
}
libraryRoot.mkdirs()
importExistingFiles()
}
@ -53,6 +57,10 @@ class RecordingRepository(
* never create duplicate rows.
*/
suspend fun importExistingFiles() = importMutex.withLock {
withContext(Dispatchers.IO) { importExistingFilesLocked() }
}
private suspend fun importExistingFilesLocked() {
val root = libraryRoot
val known = dao.getAll().mapNotNullTo(mutableSetOf()) {
runCatching { File(it.filePath).canonicalPath }.getOrNull()

View file

@ -35,8 +35,6 @@ import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.shonar.ShonarApplication
import java.text.DateFormat
import java.util.Date
/**
* Recording details: playback, rename, file info. Local-only — no tabs,
@ -170,8 +168,7 @@ private fun InfoCard(ready: DetailsViewModel.Ready) {
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(4.dp)) {
InfoRow("Length", formatMs(r.durationMs))
InfoRow("Size", formatBytes(r.sizeBytes))
InfoRow("Recorded", DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT)
.format(Date(r.createdAtEpochMs)))
InfoRow("Recorded", ready.recordedLabel)
InfoRow("File", r.filePath.substringAfterLast('/'))
}
}

View file

@ -31,6 +31,8 @@ class DetailsViewModel(
val speed: Float = 1f,
val playbackError: String? = null,
val saveError: String? = null,
/** Pre-formatted off the UI thread: DateFormat hits disk on first use. */
val recordedLabel: String = "",
)
sealed interface UiState {
@ -56,6 +58,14 @@ class DetailsViewModel(
override fun <T : ViewModel> create(modelClass: Class<T>): T =
DetailsViewModel(app, recordingId) as T
}
/** DateFormat loads locale/tz data from disk — never call in composition. */
suspend fun formatRecorded(epochMs: Long): String =
kotlinx.coroutines.withContext(kotlinx.coroutines.Dispatchers.IO) {
java.text.DateFormat
.getDateTimeInstance(java.text.DateFormat.MEDIUM, java.text.DateFormat.SHORT)
.format(java.util.Date(epochMs))
}
}
init {
@ -70,7 +80,13 @@ class DetailsViewModel(
_state.value = UiState.Error("Recording not found.")
return@launch
}
_state.value = UiState.ReadyState(Ready(recording = entity, durationMs = entity.durationMs))
_state.value = UiState.ReadyState(
Ready(
recording = entity,
durationMs = entity.durationMs,
recordedLabel = formatRecorded(entity.createdAtEpochMs),
)
)
}
}