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