From 12adfbc96032ef1b99be46e129bde6de362e4c77 Mon Sep 17 00:00:00 2001 From: David Madl Date: Sun, 31 May 2026 10:21:31 +0200 Subject: [PATCH] feat: add canonical beat pattern TYPE_BEATS, not synced to server --- DESIGN.md | 58 ++++++++++ .../at/lockstep/player/LockstepViewModel.kt | 76 ++++++++++--- .../player/data/UserPreferencesRepository.kt | 2 +- .../at/lockstep/player/data/db/AppDatabase.kt | 2 +- .../player/data/db/FileMetadataDao.kt | 14 ++- .../player/data/db/FileMetadataEntity.kt | 6 +- .../player/util/BeatAnnotationStorage.kt | 63 ++++++++--- .../at/lockstep/player/util/RunDataStorage.kt | 103 ++++++++++++++---- 8 files changed, 268 insertions(+), 56 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 2c0b8ce..eead818 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -11,6 +11,64 @@ Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md) - Collection mode: app records sensor data during a run - Settings: allows to enable modes above, logout button +## Data files + +Filesystem layout of JSON files on primary storage (e.g. Pixel 5: `/storage/emulated/0/Documents/Lockstep/`): + +``` +/storage/emulated/0/Documents/Lockstep/ +├── 2026-05-31_10-15-30/ ← annotation session (TYPE_ANNOTATION) +│ ├── Running Mix_001.json +│ └── Running Mix_002.json +├── 2026-05-31_14-22-05/ ← now-playing collection session (TYPE_COLLECTION) +│ ├── Running Mix_001.json +│ └── Running Mix_002.json +└── Beats/ ← canonical beats per playlist (TYPE_BEATS) + └── Running Mix/ + ├── 001.json + └── 002.json +``` + +Each type is recorded in Room `file_metadata` (`FileMetadataEntity`) with `fileUri`, `trackId`, `type`, `version`, `synced`, and `lastSyncedAt` (epoch millis). + +| Type | Constant | Written by | Path pattern | Server sync | +|------|----------|------------|--------------|-------------| +| **Annotation session** | `TYPE_ANNOTATION` | Annotation screen (`BeatAnnotationStorage`) | `Documents/Lockstep/{sessionFolder}/{playlist}_{NNN}.json` — one timestamped folder per annotation session | Yes — uploaded via `POST /metadata` when signed in | +| **Run collection** | `TYPE_COLLECTION` | Now Playing with collect-run-data enabled (`RunDataStorage`) | `Documents/Lockstep/{sessionFolder}/{playlist}_{NNN}.json` — one timestamped folder per run session | Yes | +| **Canonical beats** | `TYPE_BEATS` | Annotation screen (`BeatAnnotationStorage`) — written alongside each session annotation | `Documents/Lockstep/Beats/{playlist_name}/{NNN}.json` — one folder per playlist; **overwrites** on re-annotation | **No** — tracked locally only; `synced = 1` and `lastSyncedAt` set at write time | + +During an **annotation session**, leaving a track writes **both** `TYPE_ANNOTATION` (append-only session archive) and `TYPE_BEATS` (latest beats for that playlist slot). + +### Beat JSON schema (`TYPE_ANNOTATION` and `TYPE_BEATS`) + +Same schema for session annotations and canonical beats: + +```json +{ + "contentId": "primary:Documents/Music/track.mp3", + "title": "Track Title", + "artist": "Artist Name", + "beatTimesSec": [1.23, 2.45, 3.67] +} +``` + +- **`contentId`** — SAF document id of the paired local MP3 when available; otherwise Spotify track id. +- **`beatTimesSec`** — user-tapped beat times in seconds (playback position). + +### Collection JSON schema (`TYPE_COLLECTION`) + +```json +{ + "data": [ { "timestamp": 0, "positionMs": 0, "values": [0.0, 0.0, 9.8] } ], + "gyro": [ { "timestamp": 0, "values": [0.0, 0.0, 0.0] } ], + "gps": [ { "timestamp": 0, "values": [48.2, 16.3, 200.0] } ], + "meta": "content://…", + "title": "Track Title", + "artist": "Artist Name", + "versionCode": 1 +} +``` + ## Already captured from SPECS.md - **Product**: Pace-aware playlist ordering + real-time playback adaptation via accelerometer, **libpasada** (C++/JNI) + **Oboe**, user-supplied **MP3** via file descriptors, feedback loop for sensor-driven playback. diff --git a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt index dd1fbe9..45cd80f 100644 --- a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt +++ b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt @@ -167,8 +167,9 @@ class LockstepViewModel( } /** - * Writes one JSON file under public Documents/Lockstep/{sessionFolder}/ for the track described - * by [event], records file metadata, and uploads when signed in. Skips when [beatTimesMs] is empty. + * Writes session annotation JSON under Documents/Lockstep/{sessionFolder}/ (synced when signed in) + * and canonical beat JSON under Documents/Lockstep/Beats/{playlist_name}/ (local file_metadata only). + * Skips when [beatTimesMs] is empty. */ fun persistBeatAnnotation( playlistId: String, @@ -184,9 +185,10 @@ class LockstepViewModel( val pairing = pairingDao.findForTrack(playlistId, event.trackId) val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri) val contentId = docId.ifBlank { event.trackId } - val uri = + val appContext = getApplication() + val annotationUri = BeatAnnotationStorage.writeAnnotationsFile( - context = getApplication(), + context = appContext, sessionFolder = sessionFolder, playlistDisplayName = playlistDisplayName, trackQueueIndex0Based = event.queueIndex, @@ -195,18 +197,66 @@ class LockstepViewModel( artist = event.artist, beatTimesMs = beatTimesMs, ) ?: return@launch - val entry = - FileMetadataEntity( - fileUri = uri.toString(), - trackId = event.trackId, - type = FileMetadataEntity.TYPE_ANNOTATION, - version = BuildConfig.VERSION_CODE, - ) - val id = fileMetadataDao.insert(entry) + recordMetadataEntry( + fileUri = annotationUri.toString(), + trackId = event.trackId, + type = FileMetadataEntity.TYPE_ANNOTATION, + ) + val beatsUri = + BeatAnnotationStorage.writeBeatsFile( + context = appContext, + playlistDisplayName = playlistDisplayName, + trackQueueIndex0Based = event.queueIndex, + contentId = contentId, + title = event.title, + artist = event.artist, + beatTimesMs = beatTimesMs, + ) ?: return@launch + recordOrUpdateMetadataEntry( + fileUri = beatsUri.toString(), + trackId = event.trackId, + type = FileMetadataEntity.TYPE_BEATS, + ) + } + } + + private suspend fun recordMetadataEntry( + fileUri: String, + trackId: String, + type: String, + ) { + val now = System.currentTimeMillis() + val beats = type == FileMetadataEntity.TYPE_BEATS + val entry = + FileMetadataEntity( + fileUri = fileUri, + trackId = trackId, + type = type, + version = BuildConfig.VERSION_CODE, + synced = beats, + lastSyncedAt = if (beats) now else null, + ) + val id = fileMetadataDao.insert(entry) + if (!beats) { syncMetadataEntry(entry.copy(id = id)) } } + private suspend fun recordOrUpdateMetadataEntry( + fileUri: String, + trackId: String, + type: String, + ) { + val version = BuildConfig.VERSION_CODE + val now = System.currentTimeMillis() + val existing = fileMetadataDao.findByTrackIdAndType(trackId, type) + if (existing != null) { + fileMetadataDao.updateFile(existing.id, fileUri, version, now) + } else { + recordMetadataEntry(fileUri, trackId, type) + } + } + /** * Writes one JSON file under public Documents/Lockstep/{runSessionFolder}/ when a track finishes or is skipped. * Skips when [samples] is empty or the track has no paired local URI. @@ -333,7 +383,7 @@ class LockstepViewModel( version = entry.version, collection = collection, ) - fileMetadataDao.markSynced(entry.id) + fileMetadataDao.markSynced(entry.id, System.currentTimeMillis()) true } catch (e: IOException) { Log.w(METADATA_TAG, "metadata sync failed id=${entry.id}", e) diff --git a/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt b/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt index 02ab342..bc43b1e 100644 --- a/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt +++ b/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt @@ -33,7 +33,7 @@ class UserPreferencesRepository( prefs[KEY_ANNOTATION_MODE] == true } - /** When true, Now Playing records accelerometer samples per track into JSON under filesDir/run_data. */ + /** When true, Now Playing records sensor samples per track into JSON under Documents/Lockstep/{sessionFolder}/. */ val collectRunData: Flow = dataStore.data.map { prefs -> prefs[KEY_COLLECT_RUN_DATA] == true diff --git a/app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt b/app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt index e67abd6..369afbf 100644 --- a/app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt +++ b/app/src/main/java/at/lockstep/player/data/db/AppDatabase.kt @@ -10,7 +10,7 @@ import androidx.room.RoomDatabase TrackPairingEntity::class, FileMetadataEntity::class, ], - version = 4, + version = 5, exportSchema = false, ) abstract class AppDatabase : diff --git a/app/src/main/java/at/lockstep/player/data/db/FileMetadataDao.kt b/app/src/main/java/at/lockstep/player/data/db/FileMetadataDao.kt index 82667fe..724cb1a 100644 --- a/app/src/main/java/at/lockstep/player/data/db/FileMetadataDao.kt +++ b/app/src/main/java/at/lockstep/player/data/db/FileMetadataDao.kt @@ -9,9 +9,17 @@ interface FileMetadataDao { @Insert suspend fun insert(row: FileMetadataEntity): Long - @Query("SELECT * FROM file_metadata WHERE synced = 0 ORDER BY id ASC") + @Query("SELECT * FROM file_metadata WHERE synced = 0 AND type != 'beats' ORDER BY id ASC") suspend fun listUnsynced(): List - @Query("UPDATE file_metadata SET synced = 1 WHERE id = :id") - suspend fun markSynced(id: Long) + @Query("UPDATE file_metadata SET synced = 1, lastSyncedAt = :syncedAt WHERE id = :id") + suspend fun markSynced(id: Long, syncedAt: Long) + + @Query("SELECT * FROM file_metadata WHERE trackId = :trackId AND type = :type LIMIT 1") + suspend fun findByTrackIdAndType(trackId: String, type: String): FileMetadataEntity? + + @Query( + "UPDATE file_metadata SET fileUri = :fileUri, version = :version, synced = 1, lastSyncedAt = :syncedAt WHERE id = :id", + ) + suspend fun updateFile(id: Long, fileUri: String, version: Int, syncedAt: Long) } diff --git a/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt b/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt index cb523d4..34c2b41 100644 --- a/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt +++ b/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt @@ -11,14 +11,18 @@ data class FileMetadataEntity( val fileUri: String, /** Spotify track id for the collected run. */ val trackId: String, - /** e.g. [TYPE_COLLECTION] or [TYPE_ANNOTATION]. */ + /** e.g. [TYPE_COLLECTION], [TYPE_ANNOTATION], or [TYPE_BEATS]. */ val type: String, /** [BuildConfig.VERSION_CODE] at write time. */ val version: Int, val synced: Boolean = false, + /** Epoch millis when [synced] was set; set on local write for [TYPE_BEATS], on server sync otherwise. */ + val lastSyncedAt: Long? = null, ) { companion object { const val TYPE_COLLECTION = "collection" const val TYPE_ANNOTATION = "annotation" + /** Canonical beat files under Documents/Lockstep/Beats/; tracked locally, not synced to server. */ + const val TYPE_BEATS = "beats" } } diff --git a/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt b/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt index 10a6ef1..d0f3231 100644 --- a/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt +++ b/app/src/main/java/at/lockstep/player/util/BeatAnnotationStorage.kt @@ -23,26 +23,55 @@ object BeatAnnotationStorage { artist: String, beatTimesMs: List, ): Uri? { - val safeName = - playlistDisplayName - .replace(Regex("[\\\\/:*?\"<>|]"), "_") - .trim() - .ifBlank { "playlist" } - .take(120) - val suffix = - String.format(Locale.US, "%03d", trackQueueIndex0Based + 1) - val fileName = "${safeName}_$suffix.json" - val sec = beatTimesMs.map { it / 1000.0 } - val jsonString = - JSONObject().apply { - put("contentId", contentId) - put("title", title) - put("artist", artist) - put("beatTimesSec", JSONArray(sec)) - }.toString(2) + val fileName = trackFileName(playlistDisplayName, trackQueueIndex0Based) + val jsonString = buildAnnotationJson(contentId, title, artist, beatTimesMs).toString(2) return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString) } + /** + * 1-based track index (000) + ".json" under Documents/Lockstep/Beats/{playlist_name}/. + * Overwrites an existing file for the same track index. + */ + fun writeBeatsFile( + context: Context, + playlistDisplayName: String, + trackQueueIndex0Based: Int, + contentId: String, + title: String, + artist: String, + beatTimesMs: List, + ): Uri? { + val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1) + val fileName = "$suffix.json" + val jsonString = buildAnnotationJson(contentId, title, artist, beatTimesMs).toString(2) + val relativePath = RunDataStorage.beatsPlaylistRelativePath(playlistDisplayName) + return RunDataStorage.writeOrReplacePublicJsonFile(context, relativePath, fileName, jsonString) + } + + fun buildAnnotationJson( + contentId: String, + title: String, + artist: String, + beatTimesMs: List, + ): JSONObject { + val sec = beatTimesMs.map { it / 1000.0 } + return JSONObject().apply { + put("contentId", contentId) + put("title", title) + put("artist", artist) + put("beatTimesSec", JSONArray(sec)) + } + } + + private fun trackFileName( + playlistDisplayName: String, + trackQueueIndex0Based: Int, + ): String { + val safeName = RunDataStorage.sanitizeFileLabel(playlistDisplayName) + val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1) + return "${safeName}_$suffix.json" + } + /** Document id for a content [Uri] when available; otherwise last path segment. */ fun mp3DocumentContentId(localUri: String?): String { if (localUri.isNullOrBlank()) { diff --git a/app/src/main/java/at/lockstep/player/util/RunDataStorage.kt b/app/src/main/java/at/lockstep/player/util/RunDataStorage.kt index 05c4781..a4d5c4d 100644 --- a/app/src/main/java/at/lockstep/player/util/RunDataStorage.kt +++ b/app/src/main/java/at/lockstep/player/util/RunDataStorage.kt @@ -1,5 +1,6 @@ package at.lockstep.player.util +import android.content.ContentUris import android.content.ContentValues import android.content.Context import android.net.Uri @@ -16,15 +17,27 @@ import java.util.Locale object RunDataStorage { private const val APP_DIR = "Lockstep" + private const val BEATS_DIR = "Beats" /** e.g. `2026-05-24_18-36-42` — one folder per Now Playing run session. */ fun newRunSessionFolderName(): String = SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date()) + fun sanitizeFileLabel(name: String): String = + name + .replace(Regex("[\\\\/:*?\"<>|]"), "_") + .trim() + .ifBlank { "playlist" } + .take(120) + /** Public path segment under Documents, for display: `Documents/Lockstep/{runSessionFolder}/`. */ fun documentsRelativePath(runSessionFolder: String): String = "${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$runSessionFolder" + /** Public path segment: `Documents/Lockstep/Beats/{playlist_name}/`. */ + fun beatsPlaylistRelativePath(playlistDisplayName: String): String = + "${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$BEATS_DIR/${sanitizeFileLabel(playlistDisplayName)}" + fun writeRunDataFile( context: Context, runSessionFolder: String, @@ -37,12 +50,7 @@ object RunDataStorage { ): Uri? { if (snapshot.isEmpty()) return null - val safeName = - playlistDisplayName - .replace(Regex("[\\\\/:*?\"<>|]"), "_") - .trim() - .ifBlank { "playlist" } - .take(120) + val safeName = sanitizeFileLabel(playlistDisplayName) val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1) val fileName = "${safeName}_$suffix.json" @@ -67,11 +75,25 @@ object RunDataStorage { sessionFolder: String, fileName: String, jsonString: String, + ): Uri? = + writeOrReplacePublicJsonFile( + context, + documentsRelativePath(sessionFolder), + fileName, + jsonString, + ) + + /** Writes or overwrites [jsonString] at public Documents/{relativePath}/{fileName}. */ + fun writeOrReplacePublicJsonFile( + context: Context, + relativePath: String, + fileName: String, + jsonString: String, ): Uri? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - writeViaMediaStore(context, sessionFolder, fileName, jsonString) + writeOrReplaceViaMediaStore(context, relativePath, fileName, jsonString) } else { - writeViaPublicDocumentsDir(sessionFolder, fileName, jsonString) + writeOrReplaceViaPublicDocumentsDir(relativePath, fileName, jsonString) } private fun accelToJsonArray(samples: List): JSONArray { @@ -135,22 +157,57 @@ object RunDataStorage { return array } - private fun writeViaMediaStore( + private fun writeOrReplaceViaMediaStore( context: Context, - runSessionFolder: String, + relativePath: String, fileName: String, jsonString: String, ): Uri? { val resolver = context.applicationContext.contentResolver - val relativePath = documentsRelativePath(runSessionFolder) + val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val existingUri = findMediaStoreFileUri(resolver, collection, relativePath, fileName) + if (existingUri != null) { + resolver.openOutputStream(existingUri, "wt")?.use { stream -> + stream.write(jsonString.toByteArray(Charsets.UTF_8)) + } ?: return null + return existingUri + } + return insertViaMediaStore(resolver, collection, relativePath, fileName, jsonString) + } + + private fun findMediaStoreFileUri( + resolver: android.content.ContentResolver, + collection: Uri, + relativePath: String, + fileName: String, + ): Uri? { + val projection = arrayOf(MediaStore.MediaColumns._ID) + val selection = + "${MediaStore.MediaColumns.RELATIVE_PATH} = ? AND ${MediaStore.MediaColumns.DISPLAY_NAME} = ?" + val selectionArgs = arrayOf(relativePathWithTrailingSlash(relativePath), fileName) + resolver.query(collection, projection, selection, selectionArgs, null)?.use { cursor -> + if (cursor.moveToFirst()) { + val id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID)) + return ContentUris.withAppendedId(collection, id) + } + } + return null + } + + private fun insertViaMediaStore( + resolver: android.content.ContentResolver, + collection: Uri, + relativePath: String, + fileName: String, + jsonString: String, + ): Uri? { val pending = ContentValues().apply { put(MediaStore.MediaColumns.DISPLAY_NAME, fileName) put(MediaStore.MediaColumns.MIME_TYPE, "application/json") - put(MediaStore.MediaColumns.RELATIVE_PATH, relativePath) + put(MediaStore.MediaColumns.RELATIVE_PATH, relativePathWithTrailingSlash(relativePath)) put(MediaStore.MediaColumns.IS_PENDING, 1) } - val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) val uri = resolver.insert(collection, pending) ?: return null try { resolver.openOutputStream(uri)?.use { stream -> @@ -168,17 +225,23 @@ object RunDataStorage { } } + /** MediaStore stores directory relative paths with a trailing separator. */ + private fun relativePathWithTrailingSlash(relativePath: String): String = + if (relativePath.endsWith("/")) relativePath else "$relativePath/" + @Suppress("DEPRECATION") - private fun writeViaPublicDocumentsDir( - runSessionFolder: String, + private fun writeOrReplaceViaPublicDocumentsDir( + relativePath: String, fileName: String, jsonString: String, ): Uri? { - val dir = - File( - Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), - "$APP_DIR/$runSessionFolder", - ) + val documentsRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS) + val lockstepPrefix = "$APP_DIR/" + val subPath = + relativePath.removePrefix("${Environment.DIRECTORY_DOCUMENTS}/") + .removePrefix("${Environment.DIRECTORY_DOCUMENTS}${File.separator}") + .removePrefix(lockstepPrefix) + val dir = File(documentsRoot, "$APP_DIR/$subPath") if (!dir.exists() && !dir.mkdirs()) { return null }