Compare commits
2 Commits
306ad83d12
...
12adfbc960
| Author | SHA1 | Date | |
|---|---|---|---|
| 12adfbc960 | |||
| b332c7b2f3 |
58
DESIGN.md
@@ -11,6 +11,64 @@ Product goals, DSP scope, and repo layout are summarized in [SPECS.md](SPECS.md)
|
|||||||
- Collection mode: app records sensor data during a run
|
- Collection mode: app records sensor data during a run
|
||||||
- Settings: allows to enable modes above, logout button
|
- 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
|
## 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.
|
- **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.
|
||||||
|
|||||||
@@ -167,8 +167,9 @@ class LockstepViewModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes one JSON file under public Documents/Lockstep/{sessionFolder}/ for the track described
|
* Writes session annotation JSON under Documents/Lockstep/{sessionFolder}/ (synced when signed in)
|
||||||
* by [event], records file metadata, and uploads when signed in. Skips when [beatTimesMs] is empty.
|
* and canonical beat JSON under Documents/Lockstep/Beats/{playlist_name}/ (local file_metadata only).
|
||||||
|
* Skips when [beatTimesMs] is empty.
|
||||||
*/
|
*/
|
||||||
fun persistBeatAnnotation(
|
fun persistBeatAnnotation(
|
||||||
playlistId: String,
|
playlistId: String,
|
||||||
@@ -184,9 +185,10 @@ class LockstepViewModel(
|
|||||||
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
|
val pairing = pairingDao.findForTrack(playlistId, event.trackId)
|
||||||
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
|
val docId = BeatAnnotationStorage.mp3DocumentContentId(pairing?.localUri)
|
||||||
val contentId = docId.ifBlank { event.trackId }
|
val contentId = docId.ifBlank { event.trackId }
|
||||||
val uri =
|
val appContext = getApplication<Application>()
|
||||||
|
val annotationUri =
|
||||||
BeatAnnotationStorage.writeAnnotationsFile(
|
BeatAnnotationStorage.writeAnnotationsFile(
|
||||||
context = getApplication(),
|
context = appContext,
|
||||||
sessionFolder = sessionFolder,
|
sessionFolder = sessionFolder,
|
||||||
playlistDisplayName = playlistDisplayName,
|
playlistDisplayName = playlistDisplayName,
|
||||||
trackQueueIndex0Based = event.queueIndex,
|
trackQueueIndex0Based = event.queueIndex,
|
||||||
@@ -195,18 +197,66 @@ class LockstepViewModel(
|
|||||||
artist = event.artist,
|
artist = event.artist,
|
||||||
beatTimesMs = beatTimesMs,
|
beatTimesMs = beatTimesMs,
|
||||||
) ?: return@launch
|
) ?: return@launch
|
||||||
val entry =
|
recordMetadataEntry(
|
||||||
FileMetadataEntity(
|
fileUri = annotationUri.toString(),
|
||||||
fileUri = uri.toString(),
|
|
||||||
trackId = event.trackId,
|
trackId = event.trackId,
|
||||||
type = FileMetadataEntity.TYPE_ANNOTATION,
|
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,
|
version = BuildConfig.VERSION_CODE,
|
||||||
|
synced = beats,
|
||||||
|
lastSyncedAt = if (beats) now else null,
|
||||||
)
|
)
|
||||||
val id = fileMetadataDao.insert(entry)
|
val id = fileMetadataDao.insert(entry)
|
||||||
|
if (!beats) {
|
||||||
syncMetadataEntry(entry.copy(id = id))
|
syncMetadataEntry(entry.copy(id = id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun recordOrUpdateMetadataEntry(
|
||||||
|
fileUri: String,
|
||||||
|
trackId: String,
|
||||||
|
type: String,
|
||||||
|
) {
|
||||||
|
val version = BuildConfig.VERSION_CODE
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
val existing = fileMetadataDao.findByTrackIdAndType(trackId, type)
|
||||||
|
if (existing != null) {
|
||||||
|
fileMetadataDao.updateFile(existing.id, fileUri, version, now)
|
||||||
|
} else {
|
||||||
|
recordMetadataEntry(fileUri, trackId, type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Writes one JSON file under public Documents/Lockstep/{runSessionFolder}/ when a track finishes or is skipped.
|
* Writes one JSON file under public Documents/Lockstep/{runSessionFolder}/ when a track finishes or is skipped.
|
||||||
* Skips when [samples] is empty or the track has no paired local URI.
|
* Skips when [samples] is empty or the track has no paired local URI.
|
||||||
@@ -333,7 +383,7 @@ class LockstepViewModel(
|
|||||||
version = entry.version,
|
version = entry.version,
|
||||||
collection = collection,
|
collection = collection,
|
||||||
)
|
)
|
||||||
fileMetadataDao.markSynced(entry.id)
|
fileMetadataDao.markSynced(entry.id, System.currentTimeMillis())
|
||||||
true
|
true
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
Log.w(METADATA_TAG, "metadata sync failed id=${entry.id}", e)
|
Log.w(METADATA_TAG, "metadata sync failed id=${entry.id}", e)
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ class UserPreferencesRepository(
|
|||||||
prefs[KEY_ANNOTATION_MODE] == true
|
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> =
|
val collectRunData: Flow<Boolean> =
|
||||||
dataStore.data.map { prefs ->
|
dataStore.data.map { prefs ->
|
||||||
prefs[KEY_COLLECT_RUN_DATA] == true
|
prefs[KEY_COLLECT_RUN_DATA] == true
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import androidx.room.RoomDatabase
|
|||||||
TrackPairingEntity::class,
|
TrackPairingEntity::class,
|
||||||
FileMetadataEntity::class,
|
FileMetadataEntity::class,
|
||||||
],
|
],
|
||||||
version = 4,
|
version = 5,
|
||||||
exportSchema = false,
|
exportSchema = false,
|
||||||
)
|
)
|
||||||
abstract class AppDatabase :
|
abstract class AppDatabase :
|
||||||
|
|||||||
@@ -9,9 +9,17 @@ interface FileMetadataDao {
|
|||||||
@Insert
|
@Insert
|
||||||
suspend fun insert(row: FileMetadataEntity): Long
|
suspend fun insert(row: FileMetadataEntity): Long
|
||||||
|
|
||||||
@Query("SELECT * FROM file_metadata WHERE synced = 0 ORDER BY id ASC")
|
@Query("SELECT * FROM file_metadata WHERE synced = 0 AND type != 'beats' ORDER BY id ASC")
|
||||||
suspend fun listUnsynced(): List<FileMetadataEntity>
|
suspend fun listUnsynced(): List<FileMetadataEntity>
|
||||||
|
|
||||||
@Query("UPDATE file_metadata SET synced = 1 WHERE id = :id")
|
@Query("UPDATE file_metadata SET synced = 1, lastSyncedAt = :syncedAt WHERE id = :id")
|
||||||
suspend fun markSynced(id: Long)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,14 +11,18 @@ data class FileMetadataEntity(
|
|||||||
val fileUri: String,
|
val fileUri: String,
|
||||||
/** Spotify track id for the collected run. */
|
/** Spotify track id for the collected run. */
|
||||||
val trackId: String,
|
val trackId: String,
|
||||||
/** e.g. [TYPE_COLLECTION] or [TYPE_ANNOTATION]. */
|
/** e.g. [TYPE_COLLECTION], [TYPE_ANNOTATION], or [TYPE_BEATS]. */
|
||||||
val type: String,
|
val type: String,
|
||||||
/** [BuildConfig.VERSION_CODE] at write time. */
|
/** [BuildConfig.VERSION_CODE] at write time. */
|
||||||
val version: Int,
|
val version: Int,
|
||||||
val synced: Boolean = false,
|
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 {
|
companion object {
|
||||||
const val TYPE_COLLECTION = "collection"
|
const val TYPE_COLLECTION = "collection"
|
||||||
const val TYPE_ANNOTATION = "annotation"
|
const val TYPE_ANNOTATION = "annotation"
|
||||||
|
/** Canonical beat files under Documents/Lockstep/Beats/; tracked locally, not synced to server. */
|
||||||
|
const val TYPE_BEATS = "beats"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,24 +23,53 @@ object BeatAnnotationStorage {
|
|||||||
artist: String,
|
artist: String,
|
||||||
beatTimesMs: List<Long>,
|
beatTimesMs: List<Long>,
|
||||||
): Uri? {
|
): Uri? {
|
||||||
val safeName =
|
val fileName = trackFileName(playlistDisplayName, trackQueueIndex0Based)
|
||||||
playlistDisplayName
|
val jsonString = buildAnnotationJson(contentId, title, artist, beatTimesMs).toString(2)
|
||||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString)
|
||||||
.trim()
|
}
|
||||||
.ifBlank { "playlist" }
|
|
||||||
.take(120)
|
/**
|
||||||
val suffix =
|
* 1-based track index (000) + ".json" under Documents/Lockstep/Beats/{playlist_name}/.
|
||||||
String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
* Overwrites an existing file for the same track index.
|
||||||
val fileName = "${safeName}_$suffix.json"
|
*/
|
||||||
|
fun writeBeatsFile(
|
||||||
|
context: Context,
|
||||||
|
playlistDisplayName: String,
|
||||||
|
trackQueueIndex0Based: Int,
|
||||||
|
contentId: String,
|
||||||
|
title: String,
|
||||||
|
artist: String,
|
||||||
|
beatTimesMs: List<Long>,
|
||||||
|
): 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 sec = beatTimesMs.map { it / 1000.0 }
|
||||||
val jsonString =
|
return JSONObject().apply {
|
||||||
JSONObject().apply {
|
|
||||||
put("contentId", contentId)
|
put("contentId", contentId)
|
||||||
put("title", title)
|
put("title", title)
|
||||||
put("artist", artist)
|
put("artist", artist)
|
||||||
put("beatTimesSec", JSONArray(sec))
|
put("beatTimesSec", JSONArray(sec))
|
||||||
}.toString(2)
|
}
|
||||||
return RunDataStorage.writePublicJsonFile(context, sessionFolder, fileName, jsonString)
|
}
|
||||||
|
|
||||||
|
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. */
|
/** Document id for a content [Uri] when available; otherwise last path segment. */
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package at.lockstep.player.util
|
package at.lockstep.player.util
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
import android.content.ContentValues
|
import android.content.ContentValues
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
@@ -16,15 +17,27 @@ import java.util.Locale
|
|||||||
|
|
||||||
object RunDataStorage {
|
object RunDataStorage {
|
||||||
private const val APP_DIR = "Lockstep"
|
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. */
|
/** e.g. `2026-05-24_18-36-42` — one folder per Now Playing run session. */
|
||||||
fun newRunSessionFolderName(): String =
|
fun newRunSessionFolderName(): String =
|
||||||
SimpleDateFormat("yyyy-MM-dd_HH-mm-ss", Locale.US).format(Date())
|
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}/`. */
|
/** Public path segment under Documents, for display: `Documents/Lockstep/{runSessionFolder}/`. */
|
||||||
fun documentsRelativePath(runSessionFolder: String): String =
|
fun documentsRelativePath(runSessionFolder: String): String =
|
||||||
"${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$runSessionFolder"
|
"${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(
|
fun writeRunDataFile(
|
||||||
context: Context,
|
context: Context,
|
||||||
runSessionFolder: String,
|
runSessionFolder: String,
|
||||||
@@ -37,12 +50,7 @@ object RunDataStorage {
|
|||||||
): Uri? {
|
): Uri? {
|
||||||
if (snapshot.isEmpty()) return null
|
if (snapshot.isEmpty()) return null
|
||||||
|
|
||||||
val safeName =
|
val safeName = sanitizeFileLabel(playlistDisplayName)
|
||||||
playlistDisplayName
|
|
||||||
.replace(Regex("[\\\\/:*?\"<>|]"), "_")
|
|
||||||
.trim()
|
|
||||||
.ifBlank { "playlist" }
|
|
||||||
.take(120)
|
|
||||||
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1)
|
||||||
val fileName = "${safeName}_$suffix.json"
|
val fileName = "${safeName}_$suffix.json"
|
||||||
|
|
||||||
@@ -67,11 +75,25 @@ object RunDataStorage {
|
|||||||
sessionFolder: String,
|
sessionFolder: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
jsonString: 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? =
|
): Uri? =
|
||||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
writeViaMediaStore(context, sessionFolder, fileName, jsonString)
|
writeOrReplaceViaMediaStore(context, relativePath, fileName, jsonString)
|
||||||
} else {
|
} else {
|
||||||
writeViaPublicDocumentsDir(sessionFolder, fileName, jsonString)
|
writeOrReplaceViaPublicDocumentsDir(relativePath, fileName, jsonString)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
|
private fun accelToJsonArray(samples: List<RunAccelSample>): JSONArray {
|
||||||
@@ -135,22 +157,57 @@ object RunDataStorage {
|
|||||||
return array
|
return array
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun writeViaMediaStore(
|
private fun writeOrReplaceViaMediaStore(
|
||||||
context: Context,
|
context: Context,
|
||||||
runSessionFolder: String,
|
relativePath: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
jsonString: String,
|
jsonString: String,
|
||||||
): Uri? {
|
): Uri? {
|
||||||
val resolver = context.applicationContext.contentResolver
|
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 =
|
val pending =
|
||||||
ContentValues().apply {
|
ContentValues().apply {
|
||||||
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
put(MediaStore.MediaColumns.DISPLAY_NAME, fileName)
|
||||||
put(MediaStore.MediaColumns.MIME_TYPE, "application/json")
|
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)
|
put(MediaStore.MediaColumns.IS_PENDING, 1)
|
||||||
}
|
}
|
||||||
val collection = MediaStore.Files.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
|
|
||||||
val uri = resolver.insert(collection, pending) ?: return null
|
val uri = resolver.insert(collection, pending) ?: return null
|
||||||
try {
|
try {
|
||||||
resolver.openOutputStream(uri)?.use { stream ->
|
resolver.openOutputStream(uri)?.use { stream ->
|
||||||
@@ -168,17 +225,23 @@ object RunDataStorage {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** MediaStore stores directory relative paths with a trailing separator. */
|
||||||
|
private fun relativePathWithTrailingSlash(relativePath: String): String =
|
||||||
|
if (relativePath.endsWith("/")) relativePath else "$relativePath/"
|
||||||
|
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private fun writeViaPublicDocumentsDir(
|
private fun writeOrReplaceViaPublicDocumentsDir(
|
||||||
runSessionFolder: String,
|
relativePath: String,
|
||||||
fileName: String,
|
fileName: String,
|
||||||
jsonString: String,
|
jsonString: String,
|
||||||
): Uri? {
|
): Uri? {
|
||||||
val dir =
|
val documentsRoot = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS)
|
||||||
File(
|
val lockstepPrefix = "$APP_DIR/"
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS),
|
val subPath =
|
||||||
"$APP_DIR/$runSessionFolder",
|
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()) {
|
if (!dir.exists() && !dir.mkdirs()) {
|
||||||
return null
|
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 |