Room cache + SyncManager: offline-first with revision-based sync
This commit is contained in:
parent
4d52f85900
commit
f2429cd6ef
8 changed files with 498 additions and 38 deletions
|
|
@ -4,6 +4,13 @@
|
||||||
<selectionStates>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<option name="selectionMode" value="DROPDOWN" />
|
||||||
|
<DropdownSelection timestamp="2026-06-23T06:48:40.641060889Z">
|
||||||
|
<Target type="DEFAULT_BOOT">
|
||||||
|
<handle>
|
||||||
|
<DeviceId pluginId="LocalEmulator" identifier="path=/home/sylar/.android/avd/Medium_Phone.avd" />
|
||||||
|
</handle>
|
||||||
|
</Target>
|
||||||
|
</DropdownSelection>
|
||||||
<DialogSelection />
|
<DialogSelection />
|
||||||
</SelectionState>
|
</SelectionState>
|
||||||
</selectionStates>
|
</selectionStates>
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,20 @@
|
||||||
package com.keylightpiano.pianotimeline
|
package com.keylightpiano.pianotimeline
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import android.app.Application
|
||||||
|
import androidx.lifecycle.AndroidViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.keylightpiano.pianotimeline.data.MusicRepository
|
import com.keylightpiano.pianotimeline.data.MusicRepository
|
||||||
|
import com.keylightpiano.pianotimeline.data.SyncManager
|
||||||
import com.keylightpiano.pianotimeline.domain.Recording
|
import com.keylightpiano.pianotimeline.domain.Recording
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
/**
|
class TestViewModel(app: Application) : AndroidViewModel(app) {
|
||||||
* Throwaway test ViewModel: fetch the list on creation, expose the result as state.
|
|
||||||
* This is the real shape we'll keep (StateFlow + sealed state), just bare-bones.
|
|
||||||
*/
|
|
||||||
class TestViewModel : ViewModel() {
|
|
||||||
|
|
||||||
private val repo = MusicRepository()
|
private val repo = MusicRepository(app)
|
||||||
|
private val syncManager = SyncManager(app)
|
||||||
|
|
||||||
private val _state = MutableStateFlow<FetchState>(FetchState.Loading)
|
private val _state = MutableStateFlow<FetchState>(FetchState.Loading)
|
||||||
val state: StateFlow<FetchState> = _state.asStateFlow()
|
val state: StateFlow<FetchState> = _state.asStateFlow()
|
||||||
|
|
@ -26,15 +25,17 @@ class TestViewModel : ViewModel() {
|
||||||
|
|
||||||
fun load() {
|
fun load() {
|
||||||
_state.value = FetchState.Loading
|
_state.value = FetchState.Loading
|
||||||
// viewModelScope is a coroutine scope tied to this ViewModel's lifetime.
|
|
||||||
// Launching here is what lets us CALL the suspend repository function.
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
// Sync first (updates Room), then observe from Room
|
||||||
|
val result = syncManager.sync()
|
||||||
try {
|
try {
|
||||||
val result = repo.fetchRecordingList()
|
// One-shot collect to get current list for the test screen
|
||||||
_state.value = FetchState.Success(
|
repo.observeRecordings().collect { recordings ->
|
||||||
revision = result.revision,
|
_state.value = FetchState.Success(
|
||||||
recordings = result.recordings
|
revision = syncManager.getCachedRevision(),
|
||||||
)
|
recordings = recordings
|
||||||
|
)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_state.value = FetchState.Error(e.message ?: e.toString())
|
_state.value = FetchState.Error(e.message ?: e.toString())
|
||||||
}
|
}
|
||||||
|
|
@ -42,9 +43,8 @@ class TestViewModel : ViewModel() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Three possible states of the screen, modeled explicitly. */
|
|
||||||
sealed interface FetchState {
|
sealed interface FetchState {
|
||||||
data object Loading : FetchState
|
data object Loading : FetchState
|
||||||
data class Success(val revision: Int, val recordings: List<Recording>) : FetchState
|
data class Success(val revision: Int, val recordings: List<Recording>) : FetchState
|
||||||
data class Error(val message: String) : FetchState
|
data class Error(val message: String) : FetchState
|
||||||
}
|
}
|
||||||
|
|
@ -1,37 +1,93 @@
|
||||||
package com.keylightpiano.pianotimeline.data
|
package com.keylightpiano.pianotimeline.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.keylightpiano.pianotimeline.data.local.AppDatabase
|
||||||
|
import com.keylightpiano.pianotimeline.data.local.RecordingDao
|
||||||
|
import com.keylightpiano.pianotimeline.data.local.toDomainOrNull
|
||||||
import com.keylightpiano.pianotimeline.data.remote.MusicNetwork
|
import com.keylightpiano.pianotimeline.data.remote.MusicNetwork
|
||||||
import com.keylightpiano.pianotimeline.domain.Recording
|
import com.keylightpiano.pianotimeline.domain.Recording
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of a list fetch: the revision number plus the parsed recordings.
|
* The single entry point for recording data.
|
||||||
* We keep the revision because the startup sync logic compares it against
|
*
|
||||||
* what we last cached.
|
* Primary source: Room database (fast, offline).
|
||||||
|
* The SyncManager refreshes Room from the network on startup.
|
||||||
|
*
|
||||||
|
* The UI observes `observeRecordings()` which is a Flow from Room —
|
||||||
|
* when SyncManager writes new data into Room, the Flow automatically
|
||||||
|
* emits the updated list, and the UI recomposes. No manual "refresh" needed.
|
||||||
*/
|
*/
|
||||||
data class RecordingList(
|
class MusicRepository(context: Context) {
|
||||||
val revision: Int,
|
|
||||||
val recordings: List<Recording>
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
private val dao: RecordingDao = AppDatabase.getInstance(context).recordingDao()
|
||||||
* The repository is the single entry point the rest of the app uses to get data.
|
|
||||||
* It hides *where* data comes from (network now, cache later) behind clean functions.
|
|
||||||
*/
|
|
||||||
class MusicRepository {
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetches list.json, parses it, and maps every entry to a clean Recording.
|
* Live stream of all recordings, newest first.
|
||||||
* Broken entries (e.g. an unparseable date) are silently dropped rather than
|
* The UI collects this as state. When Room is updated (by sync or by caching
|
||||||
* crashing the whole list.
|
* a downloaded file), this Flow re-emits automatically.
|
||||||
*
|
|
||||||
* Runs on the IO dispatcher — the thread pool meant for network/disk work —
|
|
||||||
* so the caller never blocks the UI thread.
|
|
||||||
*/
|
*/
|
||||||
suspend fun fetchRecordingList(): RecordingList = withContext(Dispatchers.IO) {
|
fun observeRecordings(): Flow<List<Recording>> =
|
||||||
val dto = MusicNetwork.api.getRecordingList()
|
dao.observeAllByDate().map { entities ->
|
||||||
val recordings = dto.data.mapNotNull { it.toDomainOrNull() }
|
entities.mapNotNull { it.toDomainOrNull() }
|
||||||
RecordingList(revision = dto.revision, recordings = recordings)
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a recording's audio as cached at the given path.
|
||||||
|
* Called after the player successfully downloads and saves an mp3.
|
||||||
|
*/
|
||||||
|
suspend fun setAudioCached(id: String, path: String) = withContext(Dispatchers.IO) {
|
||||||
|
dao.setCachedAudioPath(id, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a recording's MIDI as cached at the given path.
|
||||||
|
*/
|
||||||
|
suspend fun setMidiCached(id: String, path: String) = withContext(Dispatchers.IO) {
|
||||||
|
dao.setCachedMidiPath(id, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all cached audio/midi files from disk and reset paths in the database.
|
||||||
|
* Used by the "clear cache" setting.
|
||||||
|
*/
|
||||||
|
suspend fun clearAllCache(context: Context) = withContext(Dispatchers.IO) {
|
||||||
|
// Delete physical files
|
||||||
|
val cacheDir = audioCacheDir(context)
|
||||||
|
if (cacheDir.exists()) {
|
||||||
|
cacheDir.deleteRecursively()
|
||||||
|
cacheDir.mkdirs() // recreate empty dir
|
||||||
|
}
|
||||||
|
// Reset DB paths
|
||||||
|
dao.clearAllCachedPaths()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a URL for streaming/downloading a recording's audio.
|
||||||
|
*/
|
||||||
|
fun audioUrl(recording: Recording): String =
|
||||||
|
MusicNetwork.fileUrl(recording.fileName)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a URL for downloading a recording's MIDI file.
|
||||||
|
*/
|
||||||
|
fun midiUrl(recording: Recording): String? =
|
||||||
|
recording.midiFileName?.let { MusicNetwork.fileUrl(it) }
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Directory where cached audio files are stored.
|
||||||
|
* Uses the app's internal cache dir so the OS can reclaim space if needed,
|
||||||
|
* and it's automatically deleted if the user clears app data.
|
||||||
|
*/
|
||||||
|
fun audioCacheDir(context: Context): File =
|
||||||
|
File(context.cacheDir, "audio").also { it.mkdirs() }
|
||||||
|
|
||||||
|
fun midiCacheDir(context: Context): File =
|
||||||
|
File(context.cacheDir, "midi").also { it.mkdirs() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,180 @@
|
||||||
|
package com.keylightpiano.pianotimeline.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.datastore.core.DataStore
|
||||||
|
import androidx.datastore.preferences.core.Preferences
|
||||||
|
import androidx.datastore.preferences.core.edit
|
||||||
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
|
import com.keylightpiano.pianotimeline.data.local.AppDatabase
|
||||||
|
import com.keylightpiano.pianotimeline.data.local.RecordingDao
|
||||||
|
import com.keylightpiano.pianotimeline.data.local.toEntity
|
||||||
|
import com.keylightpiano.pianotimeline.data.remote.MusicNetwork
|
||||||
|
import com.keylightpiano.pianotimeline.domain.Recording
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DataStore singleton: one per app, declared as a top-level extension property.
|
||||||
|
* This is the recommended pattern from the official docs.
|
||||||
|
* The string "settings" becomes the filename: settings.preferences_pb
|
||||||
|
*/
|
||||||
|
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "settings")
|
||||||
|
|
||||||
|
private val KEY_REVISION = intPreferencesKey("cached_revision")
|
||||||
|
|
||||||
|
private const val TAG = "SyncManager"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the startup sync:
|
||||||
|
*
|
||||||
|
* 1. Fetch list.json from the server.
|
||||||
|
* 2. Compare its revision against the locally stored one.
|
||||||
|
* 3. If unchanged → skip, the cache is current.
|
||||||
|
* 4. If changed →
|
||||||
|
* a. Diff IDs: find new recordings, removed recordings.
|
||||||
|
* b. Diff file-sizes: find recordings whose audio file changed on the server.
|
||||||
|
* c. Insert new entries into Room (with no cached paths).
|
||||||
|
* d. Delete removed entries from Room AND delete their cached files from disk.
|
||||||
|
* e. For changed-size entries: delete the stale cached files, clear their paths in Room.
|
||||||
|
* f. Update all remaining entries' metadata (artist, title, duration, etc. might have changed).
|
||||||
|
* g. Store the new revision number.
|
||||||
|
*
|
||||||
|
* The SyncManager does NOT download audio/midi files. Those are fetched on-demand
|
||||||
|
* when the user plays a recording (handled by the player layer later).
|
||||||
|
*/
|
||||||
|
class SyncManager(private val context: Context) {
|
||||||
|
|
||||||
|
private val dao: RecordingDao = AppDatabase.getInstance(context).recordingDao()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Result of a sync attempt.
|
||||||
|
*/
|
||||||
|
sealed interface SyncResult {
|
||||||
|
/** Cache is already up to date. */
|
||||||
|
data object UpToDate : SyncResult
|
||||||
|
/** Cache was refreshed. Contains the updated recordings for immediate use. */
|
||||||
|
data class Updated(
|
||||||
|
val added: Int,
|
||||||
|
val removed: Int,
|
||||||
|
val sizeChanged: Int
|
||||||
|
) : SyncResult
|
||||||
|
/** Network or parsing failure. Stale cache may still be usable. */
|
||||||
|
data class Failed(val message: String) : SyncResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full sync. Safe to call from any coroutine — does its own dispatching.
|
||||||
|
*/
|
||||||
|
suspend fun sync(): SyncResult = withContext(Dispatchers.IO) {
|
||||||
|
try {
|
||||||
|
// 1. Fetch fresh list from server
|
||||||
|
val dto = MusicNetwork.api.getRecordingList()
|
||||||
|
val serverRevision = dto.revision
|
||||||
|
val serverRecordings = dto.data.mapNotNull { it.toDomainOrNull() }
|
||||||
|
|
||||||
|
// 2. Read our last-known revision
|
||||||
|
val prefs = context.dataStore.data.first()
|
||||||
|
val cachedRevision = prefs[KEY_REVISION] ?: -1
|
||||||
|
|
||||||
|
if (serverRevision == cachedRevision) {
|
||||||
|
Log.d(TAG, "Revision $serverRevision unchanged — cache is current")
|
||||||
|
return@withContext SyncResult.UpToDate
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Revision changed: $cachedRevision → $serverRevision — syncing")
|
||||||
|
|
||||||
|
// 3. Build lookup maps for diffing
|
||||||
|
val serverById = serverRecordings.associateBy { it.id }
|
||||||
|
val cachedIds = dao.getAllIds().toSet()
|
||||||
|
val serverIds = serverById.keys
|
||||||
|
|
||||||
|
// 4a. New recordings (on server but not in cache)
|
||||||
|
val addedIds = serverIds - cachedIds
|
||||||
|
// 4b. Removed recordings (in cache but not on server)
|
||||||
|
val removedIds = cachedIds - serverIds
|
||||||
|
// 4c. Recordings that exist in both — check for file-size changes
|
||||||
|
val commonIds = cachedIds.intersect(serverIds)
|
||||||
|
|
||||||
|
val cachedSizes = dao.getIdAndSizes().associate { it.id to it.fileSize }
|
||||||
|
val sizeChangedIds = commonIds.filter { id ->
|
||||||
|
val serverSize = serverById[id]?.fileSize ?: 0L
|
||||||
|
val cachedSize = cachedSizes[id] ?: 0L
|
||||||
|
serverSize != cachedSize
|
||||||
|
}.toSet()
|
||||||
|
|
||||||
|
// 5. Delete removed entries and their files
|
||||||
|
if (removedIds.isNotEmpty()) {
|
||||||
|
deleteFilesForIds(removedIds)
|
||||||
|
dao.deleteByIds(removedIds)
|
||||||
|
Log.d(TAG, "Removed ${removedIds.size} deleted recordings")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Clear cached files for size-changed entries
|
||||||
|
for (id in sizeChangedIds) {
|
||||||
|
deleteFilesForId(id)
|
||||||
|
dao.clearCachedPaths(id)
|
||||||
|
}
|
||||||
|
if (sizeChangedIds.isNotEmpty()) {
|
||||||
|
Log.d(TAG, "${sizeChangedIds.size} recordings had file-size changes — cleared cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Upsert ALL server recordings into the database.
|
||||||
|
// For entries that already exist and haven't changed size,
|
||||||
|
// we preserve their cached file paths.
|
||||||
|
val entitiesToUpsert = serverRecordings.map { recording ->
|
||||||
|
val existingEntity = if (recording.id in commonIds && recording.id !in sizeChangedIds) {
|
||||||
|
dao.getById(recording.id)
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
recording.toEntity(
|
||||||
|
cachedAudioPath = existingEntity?.cachedAudioPath,
|
||||||
|
cachedMidiPath = existingEntity?.cachedMidiPath
|
||||||
|
)
|
||||||
|
}
|
||||||
|
dao.upsert(entitiesToUpsert)
|
||||||
|
|
||||||
|
// 8. Store the new revision
|
||||||
|
context.dataStore.edit { it[KEY_REVISION] = serverRevision }
|
||||||
|
|
||||||
|
Log.d(TAG, "Sync complete: +${addedIds.size} -${removedIds.size} ~${sizeChangedIds.size}")
|
||||||
|
|
||||||
|
SyncResult.Updated(
|
||||||
|
added = addedIds.size,
|
||||||
|
removed = removedIds.size,
|
||||||
|
sizeChanged = sizeChangedIds.size
|
||||||
|
)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Sync failed", e)
|
||||||
|
SyncResult.Failed(e.message ?: e.toString())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get the stored revision, or -1 if we've never synced. */
|
||||||
|
suspend fun getCachedRevision(): Int {
|
||||||
|
val prefs = context.dataStore.data.first()
|
||||||
|
return prefs[KEY_REVISION] ?: -1
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if we have any cached recordings at all (i.e. not first launch). */
|
||||||
|
suspend fun hasCachedData(): Boolean = dao.count() > 0
|
||||||
|
|
||||||
|
// ── File cleanup helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
private suspend fun deleteFilesForIds(ids: Set<String>) {
|
||||||
|
for (id in ids) {
|
||||||
|
deleteFilesForId(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun deleteFilesForId(id: String) {
|
||||||
|
val entity = dao.getById(id) ?: return
|
||||||
|
entity.cachedAudioPath?.let { File(it).delete() }
|
||||||
|
entity.cachedMidiPath?.let { File(it).delete() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,38 @@
|
||||||
|
package com.keylightpiano.pianotimeline.data.local
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.room.Database
|
||||||
|
import androidx.room.Room
|
||||||
|
import androidx.room.RoomDatabase
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Room database. version = 1 for our first schema.
|
||||||
|
* If we ever change RecordingEntity's columns, we bump the version
|
||||||
|
* and provide a migration (or fallbackToDestructiveMigration for dev).
|
||||||
|
*
|
||||||
|
* The companion object provides a thread-safe singleton via Double-Checked Locking.
|
||||||
|
* This is the standard Room pattern — you want exactly one database instance per app.
|
||||||
|
*/
|
||||||
|
@Database(entities = [RecordingEntity::class], version = 1, exportSchema = false)
|
||||||
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
||||||
|
abstract fun recordingDao(): RecordingDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
@Volatile
|
||||||
|
private var INSTANCE: AppDatabase? = null
|
||||||
|
|
||||||
|
fun getInstance(context: Context): AppDatabase {
|
||||||
|
return INSTANCE ?: synchronized(this) {
|
||||||
|
INSTANCE ?: Room.databaseBuilder(
|
||||||
|
context.applicationContext,
|
||||||
|
AppDatabase::class.java,
|
||||||
|
"piano_timeline.db"
|
||||||
|
)
|
||||||
|
.fallbackToDestructiveMigration() // fine during dev; revisit for production
|
||||||
|
.build()
|
||||||
|
.also { INSTANCE = it }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
package com.keylightpiano.pianotimeline.data.local
|
||||||
|
|
||||||
|
import com.keylightpiano.pianotimeline.domain.Recording
|
||||||
|
import java.time.LocalDate
|
||||||
|
import java.time.format.DateTimeFormatter
|
||||||
|
|
||||||
|
private val DATE_FORMAT: DateTimeFormatter = DateTimeFormatter.ofPattern("dd.MM.yyyy")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity → Domain. Used when reading from the cache to give the UI clean types.
|
||||||
|
* Returns null if the date is somehow corrupted (shouldn't happen if we wrote it,
|
||||||
|
* but defensive coding never hurt).
|
||||||
|
*/
|
||||||
|
fun RecordingEntity.toDomainOrNull(): Recording? {
|
||||||
|
val parsedDate = try {
|
||||||
|
LocalDate.parse(date.trim(), DATE_FORMAT)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return Recording(
|
||||||
|
id = id,
|
||||||
|
date = parsedDate,
|
||||||
|
artist = artist,
|
||||||
|
title = title,
|
||||||
|
extraInfo = extraInfo,
|
||||||
|
performanceNumber = performanceNumber,
|
||||||
|
totalPerformances = totalPerformances,
|
||||||
|
fileName = fileName,
|
||||||
|
midiFileName = midiFileName,
|
||||||
|
durationSeconds = durationSeconds,
|
||||||
|
soundStartSeconds = soundStartSeconds,
|
||||||
|
fileSize = fileSize
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Domain → Entity. Used when storing fresh server data into Room.
|
||||||
|
* cachedAudioPath/cachedMidiPath default to null (not yet downloaded).
|
||||||
|
*/
|
||||||
|
fun Recording.toEntity(
|
||||||
|
cachedAudioPath: String? = null,
|
||||||
|
cachedMidiPath: String? = null
|
||||||
|
): RecordingEntity = RecordingEntity(
|
||||||
|
id = id,
|
||||||
|
date = date.format(DATE_FORMAT),
|
||||||
|
artist = artist,
|
||||||
|
title = title,
|
||||||
|
extraInfo = extraInfo,
|
||||||
|
performanceNumber = performanceNumber,
|
||||||
|
totalPerformances = totalPerformances,
|
||||||
|
fileName = fileName,
|
||||||
|
midiFileName = midiFileName,
|
||||||
|
durationSeconds = durationSeconds,
|
||||||
|
soundStartSeconds = soundStartSeconds,
|
||||||
|
fileSize = fileSize,
|
||||||
|
cachedAudioPath = cachedAudioPath,
|
||||||
|
cachedMidiPath = cachedMidiPath
|
||||||
|
)
|
||||||
|
|
@ -0,0 +1,87 @@
|
||||||
|
package com.keylightpiano.pianotimeline.data.local
|
||||||
|
|
||||||
|
import androidx.room.Dao
|
||||||
|
import androidx.room.Insert
|
||||||
|
import androidx.room.OnConflictStrategy
|
||||||
|
import androidx.room.Query
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DAO = Data Access Object. Room generates the SQL implementation at compile time
|
||||||
|
* from these annotated functions. Every function is either suspend (one-shot)
|
||||||
|
* or returns a Flow (live-updating stream).
|
||||||
|
*
|
||||||
|
* Convention: suspend for writes and one-shot reads, Flow for reads the UI observes.
|
||||||
|
*/
|
||||||
|
@Dao
|
||||||
|
interface RecordingDao {
|
||||||
|
|
||||||
|
// ── Reads the UI will observe ──────────────────────────────────────
|
||||||
|
|
||||||
|
/** All recordings, newest first. Returns a Flow so the list auto-updates. */
|
||||||
|
@Query("SELECT * FROM recordings ORDER BY " +
|
||||||
|
"substr(date, 7, 4) DESC, " + // year
|
||||||
|
"substr(date, 4, 2) DESC, " + // month
|
||||||
|
"substr(date, 1, 2) DESC") // day
|
||||||
|
fun observeAllByDate(): Flow<List<RecordingEntity>>
|
||||||
|
|
||||||
|
// ── One-shot reads for sync logic ──────────────────────────────────
|
||||||
|
|
||||||
|
/** All IDs currently in the cache. Used to diff against the fresh server list. */
|
||||||
|
@Query("SELECT id FROM recordings")
|
||||||
|
suspend fun getAllIds(): List<String>
|
||||||
|
|
||||||
|
/** IDs paired with their file sizes. Used to detect changed files. */
|
||||||
|
@Query("SELECT id, fileSize FROM recordings")
|
||||||
|
suspend fun getIdAndSizes(): List<IdAndSize>
|
||||||
|
|
||||||
|
/** Get a single recording by ID. */
|
||||||
|
@Query("SELECT * FROM recordings WHERE id = :id")
|
||||||
|
suspend fun getById(id: String): RecordingEntity?
|
||||||
|
|
||||||
|
// ── Writes ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Insert or replace recordings. REPLACE means: if a row with the same
|
||||||
|
* primary key (id) already exists, delete it and insert the new one.
|
||||||
|
* This is the simplest upsert strategy and works perfectly for our sync
|
||||||
|
* because the new row carries the fresh metadata from the server.
|
||||||
|
*
|
||||||
|
* IMPORTANT: REPLACE deletes the old row first, which means cachedAudioPath
|
||||||
|
* and cachedMidiPath would be lost. The sync logic must preserve those
|
||||||
|
* fields explicitly (copy them from the old row onto the new entity before
|
||||||
|
* inserting). See SyncManager for how this is handled.
|
||||||
|
*/
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(recordings: List<RecordingEntity>)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun upsert(recording: RecordingEntity)
|
||||||
|
|
||||||
|
/** Delete specific recordings by ID. Used when the server removed entries. */
|
||||||
|
@Query("DELETE FROM recordings WHERE id IN (:ids)")
|
||||||
|
suspend fun deleteByIds(ids: Set<String>)
|
||||||
|
|
||||||
|
/** Nuke cached file paths for a recording (e.g. file-size changed, must re-download). */
|
||||||
|
@Query("UPDATE recordings SET cachedAudioPath = NULL, cachedMidiPath = NULL WHERE id = :id")
|
||||||
|
suspend fun clearCachedPaths(id: String)
|
||||||
|
|
||||||
|
/** Set the cached audio path after a successful download. */
|
||||||
|
@Query("UPDATE recordings SET cachedAudioPath = :path WHERE id = :id")
|
||||||
|
suspend fun setCachedAudioPath(id: String, path: String)
|
||||||
|
|
||||||
|
/** Set the cached MIDI path after a successful download. */
|
||||||
|
@Query("UPDATE recordings SET cachedMidiPath = :path WHERE id = :id")
|
||||||
|
suspend fun setCachedMidiPath(id: String, path: String)
|
||||||
|
|
||||||
|
/** Clear ALL cached file paths. Used by the "clear cache" setting. */
|
||||||
|
@Query("UPDATE recordings SET cachedAudioPath = NULL, cachedMidiPath = NULL")
|
||||||
|
suspend fun clearAllCachedPaths()
|
||||||
|
|
||||||
|
/** Total number of recordings. Handy for the UI or debugging. */
|
||||||
|
@Query("SELECT COUNT(*) FROM recordings")
|
||||||
|
suspend fun count(): Int
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Lightweight projection for the file-size diff check. */
|
||||||
|
data class IdAndSize(val id: String, val fileSize: Long)
|
||||||
|
|
@ -0,0 +1,33 @@
|
||||||
|
package com.keylightpiano.pianotimeline.data.local
|
||||||
|
|
||||||
|
import androidx.room.Entity
|
||||||
|
import androidx.room.PrimaryKey
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row in the Room database = one recording's metadata + cache state.
|
||||||
|
*
|
||||||
|
* Room needs an @Entity class to know what columns the table has.
|
||||||
|
* Each `val` becomes a column. Room handles String, Int, Long, Double natively.
|
||||||
|
* It does NOT know LocalDate, so we store the date as a String ("dd.MM.yyyy")
|
||||||
|
* and parse it when mapping back to the domain model.
|
||||||
|
*
|
||||||
|
* The two nullable path fields track whether we've downloaded the actual files.
|
||||||
|
* null = not cached yet. Non-null = file lives at that path on disk.
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "recordings")
|
||||||
|
data class RecordingEntity(
|
||||||
|
@PrimaryKey val id: String, // MD5 of filename — stable key
|
||||||
|
val date: String, // stored as "dd.MM.yyyy", parsed on read
|
||||||
|
val artist: String,
|
||||||
|
val title: String,
|
||||||
|
val extraInfo: String,
|
||||||
|
val performanceNumber: Int,
|
||||||
|
val totalPerformances: Int,
|
||||||
|
val fileName: String, // raw filename (not URL-encoded)
|
||||||
|
val midiFileName: String?, // null = no MIDI file exists for this piece
|
||||||
|
val durationSeconds: Double,
|
||||||
|
val soundStartSeconds: Double,
|
||||||
|
val fileSize: Long, // from list.json — used for change detection
|
||||||
|
val cachedAudioPath: String? = null, // null = not downloaded yet
|
||||||
|
val cachedMidiPath: String? = null // null = not downloaded yet
|
||||||
|
)
|
||||||
Loading…
Reference in a new issue