feat: upload collected run data to api

This commit is contained in:
2026-05-30 19:47:58 +02:00
parent 7d9654aa7e
commit 45c4980c05
8 changed files with 220 additions and 9 deletions

View File

@@ -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/). **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 Studios **jbr** under `/Applications/…` or `/c/Program Files/…`. 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 Studios **jbr** under `/Applications/…` or `/c/Program Files/…`.
Submodule: [`jukebox/`](jukebox/). Submodule: [`jukebox/`](jukebox/).

View File

@@ -6,8 +6,10 @@ import android.app.NotificationManager
import android.os.Build import android.os.Build
import at.lockstep.jukebox.Jukebox import at.lockstep.jukebox.Jukebox
import at.lockstep.jukebox.PlaylistRepository import at.lockstep.jukebox.PlaylistRepository
import at.lockstep.player.data.MetadataSyncClient
import at.lockstep.player.data.db.AppDatabase import at.lockstep.player.data.db.AppDatabase
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.OkHttpClient
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
class LockstepApplication : Application() { class LockstepApplication : Application() {
@@ -15,6 +17,13 @@ class LockstepApplication : Application() {
val database: AppDatabase by lazy { AppDatabase.getInstance(this) } 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 { val playlistRepository: PlaylistRepository by lazy {
Jukebox.playlistRepository( Jukebox.playlistRepository(
this, this,

View File

@@ -8,6 +8,7 @@ import androidx.lifecycle.viewModelScope
import at.lockstep.jukebox.api.LockstepApiException import at.lockstep.jukebox.api.LockstepApiException
import at.lockstep.jukebox.db.TrackRow import at.lockstep.jukebox.db.TrackRow
import at.lockstep.player.data.UserPreferencesRepository import at.lockstep.player.data.UserPreferencesRepository
import at.lockstep.player.data.db.FileMetadataEntity
import at.lockstep.player.data.db.TrackPairingEntity import at.lockstep.player.data.db.TrackPairingEntity
import at.lockstep.player.util.AudioUriValidator import at.lockstep.player.util.AudioUriValidator
import at.lockstep.player.playback.TrackBoundaryEvent import at.lockstep.player.playback.TrackBoundaryEvent
@@ -28,6 +29,8 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.File
import java.io.IOException import java.io.IOException
import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentHashMap
@@ -38,6 +41,7 @@ class LockstepViewModel(
companion object { companion object {
private const val TAG = "LockstepPairing" 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. */ /** Serialize on-demand playlist detail fetch so PairingScreen + folder flow do not double-hit the API. */
private val playlistDetailMutexes = ConcurrentHashMap<String, Mutex>() private val playlistDetailMutexes = ConcurrentHashMap<String, Mutex>()
@@ -46,6 +50,7 @@ class LockstepViewModel(
} }
private val prefs = UserPreferencesRepository(application) private val prefs = UserPreferencesRepository(application)
private val pairingDao get() = app.database.pairingDao() private val pairingDao get() = app.database.pairingDao()
private val fileMetadataDao get() = app.database.fileMetadataDao()
val onboardingComplete: StateFlow<Boolean> = val onboardingComplete: StateFlow<Boolean> =
prefs.onboardingComplete.stateIn( prefs.onboardingComplete.stateIn(
@@ -207,11 +212,11 @@ class LockstepViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, event.trackId) val pairing = pairingDao.findForTrack(playlistId, event.trackId)
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
RunDataStorage.writeRunDataFile( writeRunDataAndRecordMetadata(
context = getApplication(),
runSessionFolder = runSessionFolder, runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName, playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex, trackQueueIndex0Based = event.queueIndex,
trackId = event.trackId,
metaContentUri = meta, metaContentUri = meta,
title = event.title, title = event.title,
artist = event.artist, artist = event.artist,
@@ -237,11 +242,11 @@ class LockstepViewModel(
viewModelScope.launch(Dispatchers.IO) { viewModelScope.launch(Dispatchers.IO) {
val pairing = pairingDao.findForTrack(playlistId, trackId) val pairing = pairingDao.findForTrack(playlistId, trackId)
val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch
RunDataStorage.writeRunDataFile( writeRunDataAndRecordMetadata(
context = getApplication(),
runSessionFolder = runSessionFolder, runSessionFolder = runSessionFolder,
playlistDisplayName = playlistDisplayName, playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = queueIndex, trackQueueIndex0Based = queueIndex,
trackId = trackId,
metaContentUri = meta, metaContentUri = meta,
title = title, title = title,
artist = artist, 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? { suspend fun syncJukeboxIfToken(): String? {
val token = spotifyAccessToken.value val token = spotifyAccessToken.value
if (token.isNullOrBlank()) { if (token.isNullOrBlank()) {

View File

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

View File

@@ -6,14 +6,19 @@ import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
@Database( @Database(
entities = [TrackPairingEntity::class], entities = [
version = 2, TrackPairingEntity::class,
FileMetadataEntity::class,
],
version = 4,
exportSchema = false, exportSchema = false,
) )
abstract class AppDatabase : abstract class AppDatabase :
RoomDatabase() { RoomDatabase() {
abstract fun pairingDao(): PairingDao abstract fun pairingDao(): PairingDao
abstract fun fileMetadataDao(): FileMetadataDao
companion object { companion object {
private const val DB_NAME = "lockstep.db" private const val DB_NAME = "lockstep.db"

View File

@@ -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<FileMetadataEntity>
@Query("UPDATE file_metadata SET synced = 1 WHERE id = :id")
suspend fun markSynced(id: Long)
}

View File

@@ -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"
}
}

View File

@@ -49,9 +49,13 @@ fun LibraryScreen(
LaunchedEffect(token) { LaunchedEffect(token) {
if (token.isNullOrBlank()) return@LaunchedEffect if (token.isNullOrBlank()) return@LaunchedEffect
val err = viewModel.syncJukeboxIfToken() val errors =
if (err != null) { listOfNotNull(
Toast.makeText(context, err, Toast.LENGTH_LONG).show() viewModel.syncJukeboxIfToken(),
viewModel.syncPendingMetadata(),
)
if (errors.isNotEmpty()) {
Toast.makeText(context, errors.joinToString("\n"), Toast.LENGTH_LONG).show()
} }
} }