diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index ca16a99..ed7f619 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -4,6 +4,13 @@ diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/TestViewModel.kt b/app/src/main/java/com/keylightpiano/pianotimeline/TestViewModel.kt index 6e4279c..d2f1495 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/TestViewModel.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/TestViewModel.kt @@ -1,21 +1,20 @@ package com.keylightpiano.pianotimeline -import androidx.lifecycle.ViewModel +import android.app.Application +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.keylightpiano.pianotimeline.data.MusicRepository +import com.keylightpiano.pianotimeline.data.SyncManager import com.keylightpiano.pianotimeline.domain.Recording import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch -/** - * 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() { +class TestViewModel(app: Application) : AndroidViewModel(app) { - private val repo = MusicRepository() + private val repo = MusicRepository(app) + private val syncManager = SyncManager(app) private val _state = MutableStateFlow(FetchState.Loading) val state: StateFlow = _state.asStateFlow() @@ -26,15 +25,17 @@ class TestViewModel : ViewModel() { fun load() { _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 { + // Sync first (updates Room), then observe from Room + val result = syncManager.sync() try { - val result = repo.fetchRecordingList() - _state.value = FetchState.Success( - revision = result.revision, - recordings = result.recordings - ) + // One-shot collect to get current list for the test screen + repo.observeRecordings().collect { recordings -> + _state.value = FetchState.Success( + revision = syncManager.getCachedRevision(), + recordings = recordings + ) + } } catch (e: Exception) { _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 { data object Loading : FetchState data class Success(val revision: Int, val recordings: List) : FetchState data class Error(val message: String) : FetchState -} +} \ No newline at end of file diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt index 0021043..e9846c9 100644 --- a/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/MusicRepository.kt @@ -1,37 +1,93 @@ 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.domain.Recording import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext +import java.io.File /** - * Result of a list fetch: the revision number plus the parsed recordings. - * We keep the revision because the startup sync logic compares it against - * what we last cached. + * The single entry point for recording data. + * + * 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( - val revision: Int, - val recordings: List -) +class MusicRepository(context: Context) { -/** - * 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 { + private val dao: RecordingDao = AppDatabase.getInstance(context).recordingDao() /** - * Fetches list.json, parses it, and maps every entry to a clean Recording. - * Broken entries (e.g. an unparseable date) are silently dropped rather than - * crashing the whole list. - * - * Runs on the IO dispatcher — the thread pool meant for network/disk work — - * so the caller never blocks the UI thread. + * Live stream of all recordings, newest first. + * The UI collects this as state. When Room is updated (by sync or by caching + * a downloaded file), this Flow re-emits automatically. */ - suspend fun fetchRecordingList(): RecordingList = withContext(Dispatchers.IO) { - val dto = MusicNetwork.api.getRecordingList() - val recordings = dto.data.mapNotNull { it.toDomainOrNull() } - RecordingList(revision = dto.revision, recordings = recordings) + fun observeRecordings(): Flow> = + dao.observeAllByDate().map { entities -> + entities.mapNotNull { it.toDomainOrNull() } + } + + /** + * 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() } } } diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/SyncManager.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/SyncManager.kt new file mode 100644 index 0000000..9c6e31c --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/SyncManager.kt @@ -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 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) { + 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() } + } +} diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/local/AppDatabase.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/AppDatabase.kt new file mode 100644 index 0000000..c34e24d --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/AppDatabase.kt @@ -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 } + } + } + } +} diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/local/EntityMappers.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/EntityMappers.kt new file mode 100644 index 0000000..7e8b80a --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/EntityMappers.kt @@ -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 +) diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingDao.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingDao.kt new file mode 100644 index 0000000..79e0e52 --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingDao.kt @@ -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> + + // ── 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 + + /** IDs paired with their file sizes. Used to detect changed files. */ + @Query("SELECT id, fileSize FROM recordings") + suspend fun getIdAndSizes(): List + + /** 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) + + @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) + + /** 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) diff --git a/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingEntity.kt b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingEntity.kt new file mode 100644 index 0000000..b765502 --- /dev/null +++ b/app/src/main/java/com/keylightpiano/pianotimeline/data/local/RecordingEntity.kt @@ -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 +)