Room cache + SyncManager: offline-first with revision-based sync

This commit is contained in:
OmniaX-Dev 2026-06-23 08:54:22 +02:00
parent 4d52f85900
commit f2429cd6ef
8 changed files with 498 additions and 38 deletions

View file

@ -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>

View file

@ -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
} }

View file

@ -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() }
} }
} }

View file

@ -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() }
}
}

View file

@ -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 }
}
}
}
}

View file

@ -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
)

View file

@ -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)

View file

@ -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
)