Compare commits
10 Commits
feature/en
...
f7c5b932e9
| Author | SHA1 | Date | |
|---|---|---|---|
| f7c5b932e9 | |||
| 633afd115d | |||
| 12adfbc960 | |||
| b332c7b2f3 | |||
| 306ad83d12 | |||
| 9d161bd2a9 | |||
| ad2dc18f00 | |||
| 8d1b762b78 | |||
| e936dee142 | |||
| 45c4980c05 |
67
DESIGN.md
@@ -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.
|
||||
|
||||
@@ -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/).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -7,7 +7,9 @@ import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import at.lockstep.jukebox.api.LockstepApiException
|
||||
import at.lockstep.jukebox.db.TrackRow
|
||||
import at.lockstep.player.data.MetadataFetchResult
|
||||
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 +30,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 +42,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 +51,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 +168,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 +186,75 @@ class LockstepViewModel(
|
||||
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
|
||||
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
|
||||
val contentId = docId.ifBlank { event.trackId }
|
||||
val appContext = getApplication<Application>()
|
||||
val annotationUri =
|
||||
BeatAnnotationStorage.writeAnnotationsFile(
|
||||
context = getApplication(),
|
||||
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 +275,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 +305,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 +318,202 @@ 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))
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches beat metadata from the server for each track in [playlistId] that has no local beats file.
|
||||
* Records [FileMetadataEntity.lastFetchAttemptAt] when the server returns 404 so polling can retry later.
|
||||
*/
|
||||
fun fetchBeatsMetadataForPlaylist(
|
||||
playlistId: String,
|
||||
playlistDisplayName: String,
|
||||
) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val token = spotifyAccessToken.value?.takeIf { it.isNotBlank() } ?: return@launch
|
||||
val tracks = loadJukeboxTracksEnsuringDetail(playlistId)
|
||||
for (track in tracks) {
|
||||
val trackId = track.trackId ?: continue
|
||||
fetchBeatMetadataForTrack(
|
||||
token = token,
|
||||
trackId = trackId,
|
||||
playlistDisplayName = playlistDisplayName,
|
||||
queueIndex = track.position,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun fetchBeatMetadataForTrack(
|
||||
token: String,
|
||||
trackId: String,
|
||||
playlistDisplayName: String,
|
||||
queueIndex: Int,
|
||||
) {
|
||||
val existing =
|
||||
fileMetadataDao.findByTrackIdAndType(trackId, FileMetadataEntity.TYPE_BEATS)
|
||||
if (existing != null && existing.fileUri.isNotBlank()) {
|
||||
return
|
||||
}
|
||||
if (existing?.lastFetchAttemptAt != null) {
|
||||
return
|
||||
}
|
||||
val attemptAt = System.currentTimeMillis()
|
||||
val result =
|
||||
try {
|
||||
app.metadataSyncClient.fetchMetadata(
|
||||
accessToken = token,
|
||||
trackId = trackId,
|
||||
type = FileMetadataEntity.TYPE_BEATS,
|
||||
)
|
||||
} catch (e: IOException) {
|
||||
Log.w(METADATA_TAG, "beat metadata fetch failed trackId=$trackId", e)
|
||||
return
|
||||
}
|
||||
when (result) {
|
||||
is MetadataFetchResult.Found -> {
|
||||
val beatsUri =
|
||||
BeatAnnotationStorage.writeBeatsFileFromJson(
|
||||
context = getApplication(),
|
||||
playlistDisplayName = playlistDisplayName,
|
||||
trackQueueIndex0Based = queueIndex,
|
||||
json = result.collection,
|
||||
) ?: return
|
||||
val now = System.currentTimeMillis()
|
||||
if (existing != null) {
|
||||
fileMetadataDao.updateFile(existing.id, beatsUri.toString(), BuildConfig.VERSION_CODE, now)
|
||||
} else {
|
||||
fileMetadataDao.insert(
|
||||
FileMetadataEntity(
|
||||
fileUri = beatsUri.toString(),
|
||||
trackId = trackId,
|
||||
type = FileMetadataEntity.TYPE_BEATS,
|
||||
version = BuildConfig.VERSION_CODE,
|
||||
synced = true,
|
||||
lastSyncedAt = now,
|
||||
),
|
||||
)
|
||||
}
|
||||
Log.d(METADATA_TAG, "fetched beat metadata from server trackId=$trackId")
|
||||
}
|
||||
MetadataFetchResult.NotFound -> {
|
||||
recordBeatMetadataUnavailable(trackId, attemptAt, existing)
|
||||
Log.d(METADATA_TAG, "beat metadata not on server trackId=$trackId")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun recordBeatMetadataUnavailable(
|
||||
trackId: String,
|
||||
attemptAt: Long,
|
||||
existing: FileMetadataEntity?,
|
||||
) {
|
||||
if (existing != null) {
|
||||
fileMetadataDao.recordFetchAttempt(existing.id, attemptAt)
|
||||
} else {
|
||||
fileMetadataDao.insert(
|
||||
FileMetadataEntity(
|
||||
fileUri = "",
|
||||
trackId = trackId,
|
||||
type = FileMetadataEntity.TYPE_BEATS,
|
||||
version = BuildConfig.VERSION_CODE,
|
||||
synced = false,
|
||||
lastFetchAttemptAt = attemptAt,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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()) {
|
||||
|
||||
100
app/src/main/java/at/lockstep/player/data/MetadataSyncClient.kt
Normal file
@@ -0,0 +1,100 @@
|
||||
package at.lockstep.player.data
|
||||
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.RequestBody.Companion.toRequestBody
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
|
||||
sealed class MetadataFetchResult {
|
||||
data class Found(val collection: JSONObject) : MetadataFetchResult()
|
||||
|
||||
data object NotFound : MetadataFetchResult()
|
||||
}
|
||||
|
||||
class MetadataSyncClient(
|
||||
private val httpClient: OkHttpClient,
|
||||
private val baseUrl: String,
|
||||
) {
|
||||
@Throws(IOException::class)
|
||||
fun fetchMetadata(
|
||||
accessToken: String,
|
||||
trackId: String,
|
||||
type: String,
|
||||
): MetadataFetchResult {
|
||||
val url =
|
||||
baseUrl.toHttpUrl()
|
||||
.newBuilder()
|
||||
.addPathSegments("metadata")
|
||||
.addQueryParameter("trackId", trackId)
|
||||
.addQueryParameter("type", type)
|
||||
.build()
|
||||
val request =
|
||||
Request.Builder()
|
||||
.url(url)
|
||||
.addHeader("Authorization", "Bearer $accessToken")
|
||||
.get()
|
||||
.build()
|
||||
httpClient.newCall(request).execute().use { response ->
|
||||
if (response.code == 404) {
|
||||
return MetadataFetchResult.NotFound
|
||||
}
|
||||
val bodyText = response.body?.string().orEmpty()
|
||||
if (!response.isSuccessful) {
|
||||
val message =
|
||||
runCatching { JSONObject(bodyText).optString("error") }
|
||||
.getOrNull()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?: response.message
|
||||
throw IOException(message)
|
||||
}
|
||||
val collection =
|
||||
runCatching { JSONObject(bodyText).getJSONObject("collection") }
|
||||
.getOrNull()
|
||||
?: throw IOException("Missing collection in response")
|
||||
return MetadataFetchResult.Found(collection)
|
||||
}
|
||||
}
|
||||
|
||||
@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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -6,14 +6,19 @@ import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [TrackPairingEntity::class],
|
||||
version = 2,
|
||||
entities = [
|
||||
TrackPairingEntity::class,
|
||||
FileMetadataEntity::class,
|
||||
],
|
||||
version = 6,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class AppDatabase :
|
||||
RoomDatabase() {
|
||||
abstract fun pairingDao(): PairingDao
|
||||
|
||||
abstract fun fileMetadataDao(): FileMetadataDao
|
||||
|
||||
companion object {
|
||||
private const val DB_NAME = "lockstep.db"
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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 lastFetchAttemptAt = :attemptAt WHERE id = :id")
|
||||
suspend fun recordFetchAttempt(id: Long, attemptAt: Long)
|
||||
|
||||
@Query(
|
||||
"UPDATE file_metadata SET fileUri = :fileUri, version = :version, synced = 1, lastSyncedAt = :syncedAt, lastFetchAttemptAt = NULL WHERE id = :id",
|
||||
)
|
||||
suspend fun updateFile(id: Long, fileUri: String, version: Int, syncedAt: Long)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
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,
|
||||
/** Epoch millis of the last server fetch attempt when beat metadata was unavailable (404). */
|
||||
val lastFetchAttemptAt: 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"
|
||||
}
|
||||
}
|
||||
@@ -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,15 +525,37 @@ class PlaybackService : Service() {
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onTaskRemoved(rootIntent: Intent?) {
|
||||
stopPlaybackAndTeardown()
|
||||
stopSelf()
|
||||
super.onTaskRemoved(rootIntent)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
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()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
}
|
||||
|
||||
data class PlaybackUiState(
|
||||
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -213,6 +213,10 @@ fun NowPlayingRoute(
|
||||
playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId)
|
||||
}
|
||||
|
||||
LaunchedEffect(playlistId, playlistDisplayName) {
|
||||
viewModel.fetchBeatsMetadataForPlaylist(playlistId, playlistDisplayName)
|
||||
}
|
||||
|
||||
LaunchedEffect(playback) {
|
||||
val service = playback ?: return@LaunchedEffect
|
||||
service.uiState.collect { p ->
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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),
|
||||
) {
|
||||
|
||||
@@ -107,16 +107,23 @@ fun SettingsScreen(
|
||||
onCheckedChange = { viewModel.setCollectRunData(it) },
|
||||
)
|
||||
}
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = context.getString(R.string.settings_stub_body),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
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.bodyMedium,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Button(
|
||||
modifier = Modifier.padding(top = 12.dp),
|
||||
onClick = { viewModel.logoutSpotifyAndRestartOnboarding() },
|
||||
colors =
|
||||
ButtonDefaults.buttonColors(
|
||||
@@ -129,3 +136,4 @@ fun SettingsScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,50 @@ 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)
|
||||
}
|
||||
|
||||
/** Writes server-fetched beat JSON under Documents/Lockstep/Beats/{playlist_name}/. */
|
||||
fun writeBeatsFileFromJson(
|
||||
context: Context,
|
||||
playlistDisplayName: String,
|
||||
trackQueueIndex0Based: Int,
|
||||
json: JSONObject,
|
||||
): Uri? {
|
||||
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||
val fileName = "$suffix.json"
|
||||
val jsonString = json.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 {
|
||||
return JSONObject().apply {
|
||||
put("contentId", contentId)
|
||||
put("title", title)
|
||||
put("artist", artist)
|
||||
put("beatTimesSec", JSONArray(sec))
|
||||
}
|
||||
file.writeText(json.toString(2))
|
||||
return file
|
||||
}
|
||||
|
||||
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. */
|
||||
|
||||
@@ -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,11 +66,34 @@ 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 {
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.2 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 5.7 KiB |
@@ -1,21 +1,21 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">Lockstep</string>
|
||||
<string name="app_name">Pasada</string>
|
||||
<string name="cd_previous_track">Previous track</string>
|
||||
<string name="cd_play_pause">Play or pause</string>
|
||||
<string name="cd_next_track">Next track</string>
|
||||
|
||||
<string name="notification_channel_playback_name">Playback</string>
|
||||
<string name="notification_channel_playback_description">Now playing controls while Lockstep runs</string>
|
||||
<string name="notification_channel_playback_description">Now playing controls while Pasada runs</string>
|
||||
<string name="notification_prev">Previous</string>
|
||||
<string name="notification_next">Next</string>
|
||||
<string name="notification_play_pause">Play or pause</string>
|
||||
<string name="notification_loading_playlist">Loading playlist…</string>
|
||||
|
||||
<string name="onboarding_title">Welcome to Lockstep</string>
|
||||
<string name="onboarding_notifications_body">Lockstep shows playback controls in a notification while you run. Grant notification permission so controls stay visible.</string>
|
||||
<string name="onboarding_title">Welcome to Pasada</string>
|
||||
<string name="onboarding_notifications_body">Pasada shows playback controls in a notification while you run. Grant notification permission so controls stay visible.</string>
|
||||
<string name="onboarding_notifications_cta">Continue and ask for notification permission</string>
|
||||
<string name="onboarding_spotify_body">Sign in with Spotify via the Lockstep web login. When your browser returns to this app, your access token is stored locally.</string>
|
||||
<string name="onboarding_spotify_body">Sign in with Spotify via the Pasada web login. When your browser returns to this app, your access token is stored locally.</string>
|
||||
<string name="onboarding_spotify_open_browser">Open Spotify login</string>
|
||||
<string name="onboarding_spotify_connected">Account linked — you can continue.</string>
|
||||
<string name="onboarding_continue_signed_in">Continue</string>
|
||||
@@ -25,13 +25,13 @@
|
||||
<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>
|
||||
<string name="settings_annotation_mode_help">When enabled, choosing a playlist opens beat annotation (tap in time) instead of Now playing.</string>
|
||||
<string name="settings_collect_run_data">Collect run data</string>
|
||||
<string name="settings_collect_run_data_help">When enabled, Now playing records accelerometer, gyroscope, and GPS (1 Hz) per song into Documents/Lockstep/ under a timestamped run folder.</string>
|
||||
<string name="settings_collect_run_data_help">When enabled, Now playing records accelerometer, gyroscope, and GPS (1 Hz) per song into Documents/Pasada/ under a timestamped run folder.</string>
|
||||
<string name="annotation_title">Beat annotation</string>
|
||||
<string name="annotation_subtitle">Tap on each beat; times use the same clock as playback (ExoPlayer position).</string>
|
||||
<string name="annotation_tap_area_label">Tap here on the beat</string>
|
||||
@@ -47,7 +47,7 @@
|
||||
<string name="pairing_status_unpaired">Not paired — tap to pick an MP3</string>
|
||||
<string name="pairing_no_mp3_in_folder">No MP3 files found in that folder.</string>
|
||||
<string name="pairing_jukebox_empty">No tracks loaded for this playlist yet. Open Playlists and wait for sync, then try again.</string>
|
||||
<string name="pairing_all_missing_spotify_id">Tracks appear but have no Spotify id (removed items or sync issue). See LockstepPairing logs.</string>
|
||||
<string name="pairing_all_missing_spotify_id">Tracks appear but have no Spotify id (removed items or sync issue). See PasadaPairing logs.</string>
|
||||
<string name="pairing_folder_ok">Paired %1$d track(s).</string>
|
||||
<string name="pairing_folder_mixed_result">Paired %1$d track(s); %2$d still unmatched or unreadable.</string>
|
||||
<string name="pairing_unknown_track">(removed or unknown track)</string>
|
||||
|
||||