204 lines
8.8 KiB
Kotlin
204 lines
8.8 KiB
Kotlin
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
|
|
|
|
// Short-circuit only if the revision matches AND we actually have data.
|
|
// The dao.count() guard protects against a desync where the stored
|
|
// revision is current but the DB was wiped — without it, an empty cache
|
|
// with a matching revision would never repopulate.
|
|
if (serverRevision == cachedRevision && dao.count() > 0) {
|
|
Log.d(TAG, "Revision $serverRevision unchanged — cache is current")
|
|
return@withContext SyncResult.UpToDate
|
|
}
|
|
|
|
Log.d(TAG, "Revision changed: $cachedRevision → $serverRevision (or empty cache) — syncing")
|
|
|
|
// 3. Build lookup maps for diffing
|
|
val serverById = serverRecordings.associateBy { it.id }
|
|
val cachedIds = dao.getAllIds().toSet()
|
|
val serverIds = serverById.keys
|
|
|
|
// First sync = the cache was actually empty before this run.
|
|
// We derive this from the DB itself, NOT the stored revision, because
|
|
// the DB and the stored revision can fall out of step (e.g. the DB gets
|
|
// wiped by a destructive migration while DataStore keeps the old revision).
|
|
// Relying on the revision caused every recording to be flagged "new" on
|
|
// a fresh install where a stale revision lingered.
|
|
val isFirstSync = cachedIds.isEmpty()
|
|
|
|
// 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, we preserve their cached file
|
|
// paths (unless size changed) and ALWAYS preserve favorite/new flags.
|
|
val entitiesToUpsert = serverRecordings.map { recording ->
|
|
// For any common id, read the old row to carry forward local state.
|
|
val existingEntity = if (recording.id in commonIds) {
|
|
dao.getById(recording.id)
|
|
} else {
|
|
null
|
|
}
|
|
val keepPaths = recording.id in commonIds && recording.id !in sizeChangedIds
|
|
recording.toEntity(
|
|
cachedAudioPath = if (keepPaths) existingEntity?.cachedAudioPath else null,
|
|
cachedMidiPath = if (keepPaths) existingEntity?.cachedMidiPath else null,
|
|
isFavorite = existingEntity?.isFavorite ?: false,
|
|
isNew = existingEntity?.isNew ?: false,
|
|
favoritedAt = existingEntity?.favoritedAt ?: 0L
|
|
)
|
|
}
|
|
dao.upsert(entitiesToUpsert)
|
|
|
|
// 7b. Mark genuinely-new entries with the "new" badge — but NOT on the
|
|
// very first sync (otherwise every recording would be flagged new).
|
|
if (!isFirstSync && addedIds.isNotEmpty()) {
|
|
dao.markNew(addedIds)
|
|
Log.d(TAG, "Flagged ${addedIds.size} new recordings with badge")
|
|
}
|
|
|
|
// 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() }
|
|
}
|
|
}
|