8 Commits

22 changed files with 590 additions and 97 deletions

View File

@@ -2,6 +2,73 @@
Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md). This document captures **Android shell architecture**, **decisions made so far**, **libpasada JNI + state machine**, and **open UI questions**.
## UI screens
- Onboarding: explains why notifications are necessary, implements login
- Library: shows the playlists of the user
- Annotation: user presses a button to annotate the beat
- Now Playing: player showing an individual track, with pause/previous/next buttons
- 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.

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/).
**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/…`.
Submodule: [`jukebox/`](jukebox/).

View File

@@ -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,

View File

@@ -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<String, Mutex>()
@@ -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<Boolean> =
prefs.onboardingComplete.stateIn(
@@ -162,12 +167,14 @@ class LockstepViewModel(
}
/**
* Writes one JSON file under app files dir ([BeatAnnotationStorage]) for the track described
* by [event]. 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,
playlistDisplayName: String,
sessionFolder: String,
event: TrackBoundaryEvent,
beatTimesMs: List<Long>,
) {
@@ -178,15 +185,75 @@ class LockstepViewModel(
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
val contentId = docId.ifBlank { event.trackId }
BeatAnnotationStorage.writeAnnotationsFile(
context = getApplication(),
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex,
contentId = contentId,
title = event.title,
artist = event.artist,
beatTimesMs = beatTimesMs,
val appContext = getApplication<Application>()
val annotationUri =
BeatAnnotationStorage.writeAnnotationsFile(
context = appContext,
sessionFolder = sessionFolder,
playlistDisplayName = playlistDisplayName,
trackQueueIndex0Based = event.queueIndex,
contentId = contentId,
title = event.title,
artist = event.artist,
beatTimesMs = beatTimesMs,
) ?: return@launch
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)
}
}
@@ -207,11 +274,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 +304,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 +317,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 file(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, System.currentTimeMillis())
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()) {

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

@@ -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<Boolean> =
dataStore.data.map { prefs ->
prefs[KEY_COLLECT_RUN_DATA] == true

View File

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

View File

@@ -0,0 +1,25 @@
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 AND type != 'beats' ORDER BY id ASC")
suspend fun listUnsynced(): List<FileMetadataEntity>
@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)
}

View File

@@ -0,0 +1,28 @@
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], [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"
}
}

View File

@@ -62,6 +62,7 @@ class PlaybackService : Service() {
private var queue: List<TrackQueueItem> = emptyList()
private var index: Int = 0
private var tornDown = false
/** Updated on the main thread whenever progress is read from the engine — safe for sensor threads. */
@Volatile
@@ -524,17 +525,39 @@ class PlaybackService : Service() {
.build()
}
override fun onTaskRemoved(rootIntent: Intent?) {
stopPlaybackAndTeardown()
stopSelf()
super.onTaskRemoved(rootIntent)
}
override fun onDestroy() {
positionPollJob?.cancel()
positionCachePollJob?.cancel()
releaseEngine()
mediaSession.run {
isActive = false
release()
}
stopPlaybackAndTeardown()
super.onDestroy()
}
/** Stops audio, clears queue state, and removes the foreground notification. Idempotent. */
private fun stopPlaybackAndTeardown() {
if (tornDown) return
tornDown = true
positionPollJob?.cancel()
positionPollJob = null
positionCachePollJob?.cancel()
positionCachePollJob = null
releaseEngine()
queue = emptyList()
index = 0
cachedPlaybackPositionMs = 0L
_uiState.value = PlaybackUiState.initial()
if (::mediaSession.isInitialized) {
mediaSession.run {
isActive = false
release()
}
}
stopForeground(STOP_FOREGROUND_REMOVE)
}
data class PlaybackUiState(
val title: String,
val artist: String,

View File

@@ -9,6 +9,7 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
@@ -32,11 +33,13 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import at.lockstep.player.LockstepViewModel
import at.lockstep.player.R
import at.lockstep.player.playback.PlaybackService
import at.lockstep.player.util.RunDataStorage
/**
* Beat taps record [PlaybackService.getPlaybackPositionMs] (Media3 [androidx.media3.common.Player]
@@ -53,6 +56,7 @@ fun AnnotationRoute(
modifier: Modifier = Modifier,
) {
val context = LocalContext.current
val beatListHeight = LocalConfiguration.current.screenHeightDp.dp / 5
var ui by remember {
mutableStateOf(
NowPlayingUiState(
@@ -82,6 +86,7 @@ fun AnnotationRoute(
}
val beatTimesMs = remember { mutableStateListOf<Long>() }
val annotationSessionFolder = remember { RunDataStorage.newRunSessionFolderName() }
var playlistDisplayName by remember { mutableStateOf("playlist") }
@@ -89,12 +94,18 @@ fun AnnotationRoute(
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
}
LaunchedEffect(playback, playlistId, playlistDisplayName) {
LaunchedEffect(playback, playlistId, playlistDisplayName, annotationSessionFolder) {
val service = playback ?: return@LaunchedEffect
service.trackBoundaryEvents.collect { event ->
val snapshot = beatTimesMs.toList()
beatTimesMs.clear()
viewModel.persistBeatAnnotation(playlistId, playlistDisplayName, event, snapshot)
viewModel.persistBeatAnnotation(
playlistId,
playlistDisplayName,
annotationSessionFolder,
event,
snapshot,
)
}
}
@@ -174,7 +185,7 @@ fun AnnotationRoute(
modifier =
Modifier
.fillMaxWidth()
.heightIn(max = 160.dp)
.height(beatListHeight)
.padding(horizontal = 24.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(4.dp),
) {

View File

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

View File

@@ -12,6 +12,7 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
@@ -50,6 +51,7 @@ fun OnboardingScreen(
modifier =
modifier
.fillMaxSize()
.statusBarsPadding()
.padding(24.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {

View File

@@ -107,24 +107,32 @@ fun SettingsScreen(
onCheckedChange = { viewModel.setCollectRunData(it) },
)
}
Text(
text = context.getString(R.string.settings_stub_body),
style = MaterialTheme.typography.bodyLarge,
)
Text(
text = context.getString(R.string.settings_logout_spotify_help),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
onClick = { viewModel.logoutSpotifyAndRestartOnboarding() },
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(vertical = 8.dp),
) {
Text(text = context.getString(R.string.settings_logout_spotify))
Text(
text = context.getString(R.string.settings_logout_title),
style = MaterialTheme.typography.titleMedium,
)
Text(
text = context.getString(R.string.settings_logout_spotify_help),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Button(
modifier = Modifier.padding(top = 12.dp),
onClick = { viewModel.logoutSpotifyAndRestartOnboarding() },
colors =
ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) {
Text(text = context.getString(R.string.settings_logout_spotify))
}
}
}
}

View File

@@ -5,20 +5,34 @@ import android.net.Uri
import android.provider.DocumentsContract
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.util.Locale
object BeatAnnotationStorage {
private const val DIR_NAME = "beat_annotations"
fun annotationsDir(context: Context): File =
File(context.filesDir, DIR_NAME).apply { mkdirs() }
/**
* [playlistDisplayName] + "_" + 1-based track index (000) + ".json".
* [playlistDisplayName] + "_" + 1-based track index (000) + ".json" under
* Documents/Lockstep/{sessionFolder}/.
*/
fun writeAnnotationsFile(
context: Context,
sessionFolder: String,
playlistDisplayName: String,
trackQueueIndex0Based: Int,
contentId: String,
title: String,
artist: String,
beatTimesMs: List<Long>,
): Uri? {
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,
@@ -26,26 +40,36 @@ object BeatAnnotationStorage {
title: String,
artist: String,
beatTimesMs: List<Long>,
): File {
val safeName =
playlistDisplayName
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
.trim()
.ifBlank { "playlist" }
.take(120)
val suffix =
String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
val file = File(annotationsDir(context), "${safeName}_$suffix.json")
): 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<Long>,
): JSONObject {
val sec = beatTimesMs.map { it / 1000.0 }
val json =
JSONObject().apply {
put("contentId", contentId)
put("title", title)
put("artist", artist)
put("beatTimesSec", JSONArray(sec))
}
file.writeText(json.toString(2))
return file
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. */

View File

@@ -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"
@@ -58,13 +66,36 @@ object RunDataStorage {
put("versionCode", BuildConfig.VERSION_CODE)
}.toString()
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
writeViaMediaStore(context, runSessionFolder, fileName, jsonString)
} else {
writeViaPublicDocumentsDir(runSessionFolder, fileName, jsonString)
}
return writePublicJsonFile(context, runSessionFolder, fileName, jsonString)
}
/** Writes [jsonString] under public Documents/Lockstep/{sessionFolder}/. */
fun writePublicJsonFile(
context: Context,
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) {
writeOrReplaceViaMediaStore(context, relativePath, fileName, jsonString)
} else {
writeOrReplaceViaPublicDocumentsDir(relativePath, fileName, jsonString)
}
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
val array = JSONArray()
for (sample in samples) {
@@ -126,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 ->
@@ -159,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
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.7 KiB

View File

@@ -25,7 +25,7 @@
<string name="library_open_playlist">Tap to play (or pair local MP3s)</string>
<string name="settings_title">Settings</string>
<string name="settings_stub_body">More controls will land here in a later milestone.</string>
<string name="settings_logout_title">Logout</string>
<string name="settings_logout_spotify">Sign out of Spotify</string>
<string name="settings_logout_spotify_help">Clears your stored access token and returns to the welcome steps so you can log in again. Use this if the app gets HTTP 401 from the server.</string>
<string name="settings_annotation_mode">Annotation mode</string>