From 45c4980c0554bcd7a79114fe5ef8bb479c1bd53e Mon Sep 17 00:00:00 2001 From: David Madl Date: Sat, 30 May 2026 19:47:58 +0200 Subject: [PATCH] feat: upload collected run data to api --- README.md | 2 + .../at/lockstep/player/LockstepApplication.kt | 9 ++ .../at/lockstep/player/LockstepViewModel.kt | 106 +++++++++++++++++- .../player/data/MetadataSyncClient.kt | 53 +++++++++ .../at/lockstep/player/data/db/AppDatabase.kt | 9 +- .../player/data/db/FileMetadataDao.kt | 17 +++ .../player/data/db/FileMetadataEntity.kt | 23 ++++ .../player/ui/library/LibraryScreen.kt | 10 +- 8 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 app/src/main/java/at/lockstep/player/data/MetadataSyncClient.kt create mode 100644 app/src/main/java/at/lockstep/player/data/db/FileMetadataDao.kt create mode 100644 app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt diff --git a/README.md b/README.md index b179778..881fe1b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ Android prototype: music playback adapts to running pace (accelerometer + native **App module:** [`app/`](app/) — Jetpack Compose shell (`MainActivity`); **Now Playing** View preview: [`app/src/main/res/layout/activity_now_playing.xml`](app/src/main/res/layout/activity_now_playing.xml) (open → **Design** / **Split**). Icons in [`app/src/main/res/drawable/`](app/src/main/res/drawable/). +**API:** [`lockstep-2-api/`](lockstep-2-api/) — `api.py` contains the Python API that is deployed on the server behind api.lockstep.at + Build: open the repo root in Android Studio (bundled JDK **17**), or run `.\gradlew.bat :app:assembleDebug` — on Windows the wrapper picks **`%ProgramFiles%\Android\Android Studio\jbr`** when `JAVA_HOME` is unset (see `gradlew.bat`). On macOS / Git Bash, `gradlew` falls back to Android Studio’s **jbr** under `/Applications/…` or `/c/Program Files/…`. Submodule: [`jukebox/`](jukebox/). diff --git a/app/src/main/java/at/lockstep/player/LockstepApplication.kt b/app/src/main/java/at/lockstep/player/LockstepApplication.kt index f2e3ae5..d56492a 100644 --- a/app/src/main/java/at/lockstep/player/LockstepApplication.kt +++ b/app/src/main/java/at/lockstep/player/LockstepApplication.kt @@ -6,8 +6,10 @@ import android.app.NotificationManager import android.os.Build import at.lockstep.jukebox.Jukebox import at.lockstep.jukebox.PlaylistRepository +import at.lockstep.player.data.MetadataSyncClient import at.lockstep.player.data.db.AppDatabase import okhttp3.Interceptor +import okhttp3.OkHttpClient import java.util.concurrent.atomic.AtomicReference class LockstepApplication : Application() { @@ -15,6 +17,13 @@ class LockstepApplication : Application() { val database: AppDatabase by lazy { AppDatabase.getInstance(this) } + val metadataSyncClient: MetadataSyncClient by lazy { + MetadataSyncClient( + OkHttpClient(), + BuildConfig.LOCKSTEP_API_BASE_URL.trimEnd('/'), + ) + } + val playlistRepository: PlaylistRepository by lazy { Jukebox.playlistRepository( this, diff --git a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt index 243267f..3264bf7 100644 --- a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt +++ b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt @@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope import at.lockstep.jukebox.api.LockstepApiException import at.lockstep.jukebox.db.TrackRow import at.lockstep.player.data.UserPreferencesRepository +import at.lockstep.player.data.db.FileMetadataEntity import at.lockstep.player.data.db.TrackPairingEntity import at.lockstep.player.util.AudioUriValidator import at.lockstep.player.playback.TrackBoundaryEvent @@ -28,6 +29,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.json.JSONObject +import java.io.File import java.io.IOException import java.util.concurrent.ConcurrentHashMap @@ -38,6 +41,7 @@ class LockstepViewModel( companion object { private const val TAG = "LockstepPairing" + private const val METADATA_TAG = "LockstepMetadata" /** Serialize on-demand playlist detail fetch so PairingScreen + folder flow do not double-hit the API. */ private val playlistDetailMutexes = ConcurrentHashMap() @@ -46,6 +50,7 @@ class LockstepViewModel( } private val prefs = UserPreferencesRepository(application) private val pairingDao get() = app.database.pairingDao() + private val fileMetadataDao get() = app.database.fileMetadataDao() val onboardingComplete: StateFlow = prefs.onboardingComplete.stateIn( @@ -207,11 +212,11 @@ class LockstepViewModel( viewModelScope.launch(Dispatchers.IO) { val pairing = pairingDao.findForTrack(playlistId, event.trackId) val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch - RunDataStorage.writeRunDataFile( - context = getApplication(), + writeRunDataAndRecordMetadata( runSessionFolder = runSessionFolder, playlistDisplayName = playlistDisplayName, trackQueueIndex0Based = event.queueIndex, + trackId = event.trackId, metaContentUri = meta, title = event.title, artist = event.artist, @@ -237,11 +242,11 @@ class LockstepViewModel( viewModelScope.launch(Dispatchers.IO) { val pairing = pairingDao.findForTrack(playlistId, trackId) val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch - RunDataStorage.writeRunDataFile( - context = getApplication(), + writeRunDataAndRecordMetadata( runSessionFolder = runSessionFolder, playlistDisplayName = playlistDisplayName, trackQueueIndex0Based = queueIndex, + trackId = trackId, metaContentUri = meta, title = title, artist = artist, @@ -250,6 +255,99 @@ class LockstepViewModel( } } + private suspend fun writeRunDataAndRecordMetadata( + runSessionFolder: String, + playlistDisplayName: String, + trackQueueIndex0Based: Int, + trackId: String, + metaContentUri: String, + title: String, + artist: String, + snapshot: RunTrackDataSnapshot, + ) { + val uri = + RunDataStorage.writeRunDataFile( + context = getApplication(), + runSessionFolder = runSessionFolder, + playlistDisplayName = playlistDisplayName, + trackQueueIndex0Based = trackQueueIndex0Based, + metaContentUri = metaContentUri, + title = title, + artist = artist, + snapshot = snapshot, + ) ?: return + val entry = + FileMetadataEntity( + fileUri = uri.toString(), + trackId = trackId, + type = FileMetadataEntity.TYPE_COLLECTION, + version = BuildConfig.VERSION_CODE, + ) + val id = fileMetadataDao.insert(entry) + syncMetadataEntry(entry.copy(id = id)) + } + + suspend fun syncPendingMetadata(): String? { + val token = spotifyAccessToken.value + if (token.isNullOrBlank()) { + return null + } + return withContext(Dispatchers.IO) { + val pending = fileMetadataDao.listUnsynced() + if (pending.isEmpty()) { + return@withContext null + } + val failures = pending.count { entry -> !syncMetadataEntry(entry) } + if (failures > 0) { + "Failed to sync $failures collection(s)" + } else { + null + } + } + } + + private suspend fun syncMetadataEntry(entry: FileMetadataEntity): Boolean { + val token = spotifyAccessToken.value?.takeIf { it.isNotBlank() } ?: return false + val collection = + withContext(Dispatchers.IO) { + readCollectionJson(entry.fileUri) + } ?: return false + return withContext(Dispatchers.IO) { + try { + app.metadataSyncClient.uploadCollection( + accessToken = token, + trackId = entry.trackId, + type = entry.type, + version = entry.version, + collection = collection, + ) + fileMetadataDao.markSynced(entry.id) + true + } catch (e: IOException) { + Log.w(METADATA_TAG, "metadata sync failed id=${entry.id}", e) + false + } + } + } + + private fun readCollectionJson(fileUri: String): JSONObject? { + val uri = Uri.parse(fileUri) + try { + context.contentResolver.openInputStream(uri)?.use { stream -> + return JSONObject(stream.bufferedReader().readText()) + } + } catch (e: Exception) { + Log.w(METADATA_TAG, "readCollectionJson contentResolver failed uri=$fileUri", e) + } + return try { + val path = uri.path ?: return null + JSONObject(File(path).readText()) + } catch (e: Exception) { + Log.w(METADATA_TAG, "readCollectionJson file path failed uri=$fileUri", e) + null + } + } + suspend fun syncJukeboxIfToken(): String? { val token = spotifyAccessToken.value if (token.isNullOrBlank()) { diff --git a/app/src/main/java/at/lockstep/player/data/MetadataSyncClient.kt b/app/src/main/java/at/lockstep/player/data/MetadataSyncClient.kt new file mode 100644 index 0000000..4a5550b --- /dev/null +++ b/app/src/main/java/at/lockstep/player/data/MetadataSyncClient.kt @@ -0,0 +1,53 @@ +package at.lockstep.player.data + +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import org.json.JSONObject +import java.io.IOException + +class MetadataSyncClient( + private val httpClient: OkHttpClient, + private val baseUrl: String, +) { + @Throws(IOException::class) + fun uploadCollection( + accessToken: String, + trackId: String, + type: String, + version: Int, + collection: JSONObject, + ) { + val payload = + JSONObject() + .apply { + put("trackId", trackId) + put("type", type) + put("version", version) + put("collection", collection) + }.toString() + val request = + Request.Builder() + .url("$baseUrl/metadata") + .addHeader("Authorization", "Bearer $accessToken") + .post(payload.toRequestBody(JSON_MEDIA)) + .build() + httpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) { + return + } + val bodyText = response.body?.string().orEmpty() + val message = + runCatching { JSONObject(bodyText).optString("error") } + .getOrNull() + ?.takeIf { it.isNotBlank() } + ?: response.message + throw IOException(message) + } + } + + companion object { + private val JSON_MEDIA = "application/json; charset=utf-8".toMediaType() + } +} 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 e51c271..e67abd6 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 @@ -6,14 +6,19 @@ import androidx.room.Room import androidx.room.RoomDatabase @Database( - entities = [TrackPairingEntity::class], - version = 2, + entities = [ + TrackPairingEntity::class, + FileMetadataEntity::class, + ], + version = 4, exportSchema = false, ) abstract class AppDatabase : RoomDatabase() { abstract fun pairingDao(): PairingDao + abstract fun fileMetadataDao(): FileMetadataDao + companion object { private const val DB_NAME = "lockstep.db" 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 new file mode 100644 index 0000000..82667fe --- /dev/null +++ b/app/src/main/java/at/lockstep/player/data/db/FileMetadataDao.kt @@ -0,0 +1,17 @@ +package at.lockstep.player.data.db + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.Query + +@Dao +interface FileMetadataDao { + @Insert + suspend fun insert(row: FileMetadataEntity): Long + + @Query("SELECT * FROM file_metadata WHERE synced = 0 ORDER BY id ASC") + suspend fun listUnsynced(): List + + @Query("UPDATE file_metadata SET synced = 1 WHERE id = :id") + suspend fun markSynced(id: 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 new file mode 100644 index 0000000..43df688 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/data/db/FileMetadataEntity.kt @@ -0,0 +1,23 @@ +package at.lockstep.player.data.db + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "file_metadata") +data class FileMetadataEntity( + @PrimaryKey(autoGenerate = true) + val id: Long = 0, + /** Content or file URI of the persisted JSON. */ + val fileUri: String, + /** Spotify track id for the collected run. */ + val trackId: String, + /** e.g. [TYPE_COLLECTION] for run-data JSON files. */ + val type: String, + /** [BuildConfig.VERSION_CODE] at write time. */ + val version: Int, + val synced: Boolean = false, +) { + companion object { + const val TYPE_COLLECTION = "collection" + } +} diff --git a/app/src/main/java/at/lockstep/player/ui/library/LibraryScreen.kt b/app/src/main/java/at/lockstep/player/ui/library/LibraryScreen.kt index 7aa07b1..0bb9625 100644 --- a/app/src/main/java/at/lockstep/player/ui/library/LibraryScreen.kt +++ b/app/src/main/java/at/lockstep/player/ui/library/LibraryScreen.kt @@ -49,9 +49,13 @@ fun LibraryScreen( LaunchedEffect(token) { if (token.isNullOrBlank()) return@LaunchedEffect - val err = viewModel.syncJukeboxIfToken() - if (err != null) { - Toast.makeText(context, err, Toast.LENGTH_LONG).show() + val errors = + listOfNotNull( + viewModel.syncJukeboxIfToken(), + viewModel.syncPendingMetadata(), + ) + if (errors.isNotEmpty()) { + Toast.makeText(context, errors.joinToString("\n"), Toast.LENGTH_LONG).show() } }