From c11ad041d7838dbc3f0a1c30f740fb7e1a96c35e Mon Sep 17 00:00:00 2001 From: David Madl Date: Sun, 24 May 2026 07:17:51 +0200 Subject: [PATCH] feat: collect gyro and gps --- app/src/main/AndroidManifest.xml | 5 + .../at/lockstep/player/LockstepViewModel.kt | 69 ++++++ .../player/data/UserPreferencesRepository.kt | 13 ++ .../lockstep/player/ui/LockstepAppNavHost.kt | 1 + .../at/lockstep/player/ui/NowPlayingScreen.kt | 97 ++++++++ .../player/ui/settings/SettingsScreen.kt | 25 ++ .../lockstep/player/util/RunDataCollector.kt | 213 ++++++++++++++++++ .../at/lockstep/player/util/RunDataSample.kt | 19 ++ .../at/lockstep/player/util/RunDataStorage.kt | 155 +++++++++++++ .../player/util/RunTrackDataSnapshot.kt | 17 ++ app/src/main/res/values/strings.xml | 2 + 11 files changed, 616 insertions(+) create mode 100644 app/src/main/java/at/lockstep/player/util/RunDataCollector.kt create mode 100644 app/src/main/java/at/lockstep/player/util/RunDataSample.kt create mode 100644 app/src/main/java/at/lockstep/player/util/RunDataStorage.kt create mode 100644 app/src/main/java/at/lockstep/player/util/RunTrackDataSnapshot.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d31a3c5..b65f0cb 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,11 @@ + + + diff --git a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt index 68edb60..8ea1dd4 100644 --- a/app/src/main/java/at/lockstep/player/LockstepViewModel.kt +++ b/app/src/main/java/at/lockstep/player/LockstepViewModel.kt @@ -16,6 +16,8 @@ import at.lockstep.player.util.FolderMp3Scanner import at.lockstep.player.util.MediaStoreMp3Scanner import at.lockstep.player.util.Mp3EmbeddedMetadata import at.lockstep.player.util.Mp3FolderCandidate +import at.lockstep.player.util.RunDataStorage +import at.lockstep.player.util.RunTrackDataSnapshot import at.lockstep.player.util.TrackFileMatching import at.lockstep.player.util.mp3DisplayNameFromUri import kotlinx.coroutines.Dispatchers @@ -66,12 +68,25 @@ class LockstepViewModel( false, ) + val collectRunData: StateFlow = + prefs.collectRunData.stateIn( + viewModelScope, + SharingStarted.Eagerly, + false, + ) + fun setAnnotationMode(enabled: Boolean) { viewModelScope.launch { prefs.setAnnotationMode(enabled) } } + fun setCollectRunData(enabled: Boolean) { + viewModelScope.launch { + prefs.setCollectRunData(enabled) + } + } + private val context get() = getApplication() /** @@ -175,6 +190,60 @@ class LockstepViewModel( } } + /** + * 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. + */ + fun persistRunData( + playlistId: String, + playlistDisplayName: String, + runSessionFolder: String, + event: TrackBoundaryEvent, + snapshot: RunTrackDataSnapshot, + ) { + if (snapshot.isEmpty()) { + return + } + viewModelScope.launch(Dispatchers.IO) { + val pairing = pairingDao.findForTrack(playlistId, event.trackId) + val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch + RunDataStorage.writeRunDataFile( + context = getApplication(), + runSessionFolder = runSessionFolder, + playlistDisplayName = playlistDisplayName, + trackQueueIndex0Based = event.queueIndex, + metaContentUri = meta, + snapshot = snapshot, + ) + } + } + + /** Flush in-progress run data when leaving Now Playing before a track boundary fires. */ + fun persistRunDataForCurrentTrack( + playlistId: String, + playlistDisplayName: String, + runSessionFolder: String, + trackId: String, + queueIndex: Int, + snapshot: RunTrackDataSnapshot, + ) { + if (snapshot.isEmpty()) { + return + } + viewModelScope.launch(Dispatchers.IO) { + val pairing = pairingDao.findForTrack(playlistId, trackId) + val meta = pairing?.localUri?.takeIf { it.isNotBlank() } ?: return@launch + RunDataStorage.writeRunDataFile( + context = getApplication(), + runSessionFolder = runSessionFolder, + playlistDisplayName = playlistDisplayName, + trackQueueIndex0Based = queueIndex, + metaContentUri = meta, + snapshot = snapshot, + ) + } + } + suspend fun syncJukeboxIfToken(): String? { val token = spotifyAccessToken.value if (token.isNullOrBlank()) { diff --git a/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt b/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt index ecc0f35..02ab342 100644 --- a/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt +++ b/app/src/main/java/at/lockstep/player/data/UserPreferencesRepository.kt @@ -33,6 +33,12 @@ class UserPreferencesRepository( prefs[KEY_ANNOTATION_MODE] == true } + /** When true, Now Playing records accelerometer samples per track into JSON under filesDir/run_data. */ + val collectRunData: Flow = + dataStore.data.map { prefs -> + prefs[KEY_COLLECT_RUN_DATA] == true + } + suspend fun setOnboardingComplete(done: Boolean) { dataStore.edit { prefs -> prefs[KEY_ONBOARDING_COMPLETE] = done @@ -55,9 +61,16 @@ class UserPreferencesRepository( } } + suspend fun setCollectRunData(enabled: Boolean) { + dataStore.edit { prefs -> + prefs[KEY_COLLECT_RUN_DATA] = enabled + } + } + companion object { private val KEY_ONBOARDING_COMPLETE = booleanPreferencesKey("onboarding_complete") private val KEY_SPOTIFY_ACCESS_TOKEN = stringPreferencesKey("spotify_access_token") private val KEY_ANNOTATION_MODE = booleanPreferencesKey("annotation_mode") + private val KEY_COLLECT_RUN_DATA = booleanPreferencesKey("collect_run_data") } } diff --git a/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt b/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt index bef989c..6126e6f 100644 --- a/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt +++ b/app/src/main/java/at/lockstep/player/ui/LockstepAppNavHost.kt @@ -115,6 +115,7 @@ fun LockstepAppNavHost( NowPlayingRoute( playlistId = playlistId, playback = playback, + viewModel = viewModel, onBack = { navController.popBackStack() }, ) } diff --git a/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt b/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt index 6adcb5b..22afa3c 100644 --- a/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt +++ b/app/src/main/java/at/lockstep/player/ui/NowPlayingScreen.kt @@ -1,5 +1,9 @@ package at.lockstep.player.ui +import android.Manifest +import android.content.pm.PackageManager +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -22,9 +26,11 @@ import androidx.compose.material3.Slider import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -34,8 +40,13 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import at.lockstep.player.LockstepViewModel import at.lockstep.player.playback.PlaybackService import at.lockstep.player.R +import at.lockstep.player.util.RunDataCollector +import at.lockstep.player.util.RunDataStorage data class NowPlayingUiState( val title: String, @@ -153,10 +164,38 @@ fun NowPlayingScreen( fun NowPlayingRoute( playlistId: String, playback: PlaybackService?, + viewModel: LockstepViewModel, onBack: () -> Unit, modifier: Modifier = Modifier, ) { val context = LocalContext.current + val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle() + val collector = remember { RunDataCollector(context) } + val runSessionFolder = remember { RunDataStorage.newRunSessionFolderName() } + + val locationPermissionLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.RequestPermission(), + onResult = { granted -> + if (collectRunData) { + collector.start(enableLocation = granted) + } + }, + ) + + fun startRunDataCollection() { + val hasLocation = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + if (!hasLocation) { + locationPermissionLauncher.launch(Manifest.permission.ACCESS_FINE_LOCATION) + } + collector.start(enableLocation = hasLocation) + } + var playlistDisplayName by remember { mutableStateOf("playlist") } + var currentTrackId by remember { mutableStateOf(null) } + var currentQueueIndex by remember { mutableIntStateOf(0) } + var ui by remember { mutableStateOf( NowPlayingUiState( @@ -170,9 +209,15 @@ fun NowPlayingRoute( ) } + LaunchedEffect(playlistId) { + playlistDisplayName = viewModel.getPlaylistDisplayName(playlistId) + } + LaunchedEffect(playback) { val service = playback ?: return@LaunchedEffect service.uiState.collect { p -> + currentTrackId = p.currentTrackId + currentQueueIndex = p.currentQueueIndex ui = NowPlayingUiState( title = p.title, @@ -185,6 +230,58 @@ fun NowPlayingRoute( } } + LaunchedEffect(collectRunData, playback) { + if (!collectRunData) { + collector.setAcceptSamples(false) + return@LaunchedEffect + } + val service = playback ?: return@LaunchedEffect + var lastTrackId: String? = null + service.uiState.collect { state -> + collector.setAcceptSamples(state.isPlaying) + val trackId = state.currentTrackId + if (trackId != null && trackId != lastTrackId) { + collector.markSongStart() + lastTrackId = trackId + } + } + } + + LaunchedEffect(collectRunData, playback, playlistId, playlistDisplayName) { + if (!collectRunData) return@LaunchedEffect + val service = playback ?: return@LaunchedEffect + service.trackBoundaryEvents.collect { event -> + val snapshot = collector.snapshotAndClear() + viewModel.persistRunData(playlistId, playlistDisplayName, runSessionFolder, event, snapshot) + } + } + + DisposableEffect(collectRunData) { + if (collectRunData) { + startRunDataCollection() + } else { + collector.stop() + collector.snapshotAndClear() + } + onDispose { + if (collectRunData) { + val snapshot = collector.snapshotAndClear() + val trackId = currentTrackId + if (!snapshot.isEmpty() && trackId != null) { + viewModel.persistRunDataForCurrentTrack( + playlistId = playlistId, + playlistDisplayName = playlistDisplayName, + runSessionFolder = runSessionFolder, + trackId = trackId, + queueIndex = currentQueueIndex, + snapshot = snapshot, + ) + } + } + collector.release() + } + } + Scaffold( modifier = modifier.fillMaxSize(), topBar = { diff --git a/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt b/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt index f6959b0..5308e7c 100644 --- a/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/at/lockstep/player/ui/settings/SettingsScreen.kt @@ -37,6 +37,7 @@ fun SettingsScreen( ) { val context = LocalContext.current val annotationMode by viewModel.annotationMode.collectAsStateWithLifecycle() + val collectRunData by viewModel.collectRunData.collectAsStateWithLifecycle() Scaffold( modifier = modifier.fillMaxSize(), topBar = { @@ -82,6 +83,30 @@ fun SettingsScreen( onCheckedChange = { viewModel.setAnnotationMode(it) }, ) } + Row( + modifier = + Modifier + .fillMaxWidth() + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) { + Text( + text = context.getString(R.string.settings_collect_run_data), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = context.getString(R.string.settings_collect_run_data_help), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Switch( + checked = collectRunData, + onCheckedChange = { viewModel.setCollectRunData(it) }, + ) + } Text( text = context.getString(R.string.settings_stub_body), style = MaterialTheme.typography.bodyLarge, diff --git a/app/src/main/java/at/lockstep/player/util/RunDataCollector.kt b/app/src/main/java/at/lockstep/player/util/RunDataCollector.kt new file mode 100644 index 0000000..ae0820c --- /dev/null +++ b/app/src/main/java/at/lockstep/player/util/RunDataCollector.kt @@ -0,0 +1,213 @@ +package at.lockstep.player.util + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.hardware.Sensor +import android.hardware.SensorEvent +import android.hardware.SensorEventListener +import android.hardware.SensorManager +import android.location.Location +import android.location.LocationListener +import android.location.LocationManager +import android.os.Build +import android.os.Handler +import android.os.HandlerThread +import androidx.core.content.ContextCompat + +class RunDataCollector( + context: Context, +) { + private val appContext = context.applicationContext + private val sensorManager = appContext.getSystemService(SensorManager::class.java) + private val locationManager = appContext.getSystemService(LocationManager::class.java) + private val accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) + private val gyroscope = sensorManager?.getDefaultSensor(Sensor.TYPE_GYROSCOPE) + private val handlerThread = HandlerThread("RunDataCollect").apply { start() } + private val handler = Handler(handlerThread.looper) + + private val accelBuffer = mutableListOf() + private val gyroBuffer = mutableListOf() + private val gpsBuffer = mutableListOf() + + /** Baseline sensor/GPS time for the current song; set on the first sample after [markSongStart]. */ + private var songStartElapsedRealtimeNanos: Long? = null + + @Volatile + private var acceptSamples = false + + private var sensorsRegistered = false + private var locationRegistered = false + + private val sensorListener = + object : SensorEventListener { + override fun onSensorChanged(event: SensorEvent) { + if (!acceptSamples) return + val timestamp = relativeTimestampNanos(event.timestamp) ?: return + val sample = + RunDataSample( + timestampNanos = timestamp, + values = floatArrayOf(event.values[0], event.values[1], event.values[2]), + ) + when (event.sensor.type) { + Sensor.TYPE_ACCELEROMETER -> + synchronized(accelBuffer) { + accelBuffer.add(sample) + } + Sensor.TYPE_GYROSCOPE -> + synchronized(gyroBuffer) { + gyroBuffer.add(sample) + } + } + } + + override fun onAccuracyChanged( + sensor: Sensor?, + accuracy: Int, + ) = Unit + } + + private val locationListener = + LocationListener { location -> + if (!acceptSamples) return@LocationListener + recordGpsLocation(location) + } + + fun start(enableLocation: Boolean) { + startSensors() + if (enableLocation) { + startLocationUpdates() + } + } + + private fun startSensors() { + if (sensorsRegistered || sensorManager == null) return + accelerometer?.let { + sensorManager.registerListener( + sensorListener, + it, + SensorManager.SENSOR_DELAY_GAME, + 0, + handler, + ) + } + gyroscope?.let { + sensorManager.registerListener( + sensorListener, + it, + SensorManager.SENSOR_DELAY_GAME, + 0, + handler, + ) + } + sensorsRegistered = accelerometer != null || gyroscope != null + } + + private fun startLocationUpdates() { + if (locationRegistered || locationManager == null) return + if (!hasLocationPermission()) return + val providers = + listOf(LocationManager.GPS_PROVIDER, LocationManager.NETWORK_PROVIDER) + .filter { locationManager.isProviderEnabled(it) } + if (providers.isEmpty()) return + for (provider in providers) { + locationManager.requestLocationUpdates( + provider, + GPS_MIN_TIME_MS, + 0f, + locationListener, + handler.looper, + ) + } + locationRegistered = true + } + + fun stop() { + stopLocationUpdates() + stopSensors() + } + + private fun stopSensors() { + if (!sensorsRegistered || sensorManager == null) return + sensorManager.unregisterListener(sensorListener) + sensorsRegistered = false + } + + private fun stopLocationUpdates() { + if (!locationRegistered || locationManager == null) return + locationManager.removeUpdates(locationListener) + locationRegistered = false + } + + fun release() { + stop() + handlerThread.quitSafely() + } + + fun markSongStart() { + songStartElapsedRealtimeNanos = null + } + + fun setAcceptSamples(accept: Boolean) { + acceptSamples = accept + } + + fun snapshotAndClear(): RunTrackDataSnapshot = + RunTrackDataSnapshot( + accelerometer = + synchronized(accelBuffer) { + accelBuffer.toList().also { accelBuffer.clear() } + }, + gyroscope = + synchronized(gyroBuffer) { + gyroBuffer.toList().also { gyroBuffer.clear() } + }, + gps = + synchronized(gpsBuffer) { + gpsBuffer.toList().also { gpsBuffer.clear() } + }, + ) + + private fun relativeTimestampNanos(elapsedRealtimeNanos: Long): Long? { + val start = + songStartElapsedRealtimeNanos ?: run { + songStartElapsedRealtimeNanos = elapsedRealtimeNanos + elapsedRealtimeNanos + } + return elapsedRealtimeNanos - start + } + + private fun recordGpsLocation(location: Location) { + val elapsedNs = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { + location.elapsedRealtimeNanos + } else { + @Suppress("DEPRECATION") + location.time * 1_000_000L + } + val timestamp = relativeTimestampNanos(elapsedNs) ?: return + synchronized(gpsBuffer) { + val last = gpsBuffer.lastOrNull() + if (last != null && timestamp - last.timestampNanos < GPS_MIN_TIME_NS) { + return + } + gpsBuffer.add( + RunGpsSample( + timestampNanos = timestamp, + latitude = location.latitude, + longitude = location.longitude, + altitude = location.altitude, + ), + ) + } + } + + private fun hasLocationPermission(): Boolean = + ContextCompat.checkSelfPermission(appContext, Manifest.permission.ACCESS_FINE_LOCATION) == + PackageManager.PERMISSION_GRANTED + + companion object { + private const val GPS_MIN_TIME_MS = 1_000L + private const val GPS_MIN_TIME_NS = 1_000_000_000L + } +} diff --git a/app/src/main/java/at/lockstep/player/util/RunDataSample.kt b/app/src/main/java/at/lockstep/player/util/RunDataSample.kt new file mode 100644 index 0000000..f9295da --- /dev/null +++ b/app/src/main/java/at/lockstep/player/util/RunDataSample.kt @@ -0,0 +1,19 @@ +package at.lockstep.player.util + +data class RunDataSample( + /** Nanoseconds since the current song started ([android.hardware.SensorEvent.timestamp] base). */ + val timestampNanos: Long, + val values: FloatArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is RunDataSample) return false + return timestampNanos == other.timestampNanos && values.contentEquals(other.values) + } + + override fun hashCode(): Int { + var result = timestampNanos.hashCode() + result = 31 * result + values.contentHashCode() + return result + } +} diff --git a/app/src/main/java/at/lockstep/player/util/RunDataStorage.kt b/app/src/main/java/at/lockstep/player/util/RunDataStorage.kt new file mode 100644 index 0000000..02fd6fd --- /dev/null +++ b/app/src/main/java/at/lockstep/player/util/RunDataStorage.kt @@ -0,0 +1,155 @@ +package at.lockstep.player.util + +import android.content.ContentValues +import android.content.Context +import android.net.Uri +import android.os.Build +import android.os.Environment +import android.provider.MediaStore +import at.lockstep.player.BuildConfig +import org.json.JSONArray +import org.json.JSONObject +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +object RunDataStorage { + private const val APP_DIR = "Lockstep" + + /** 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()) + + /** Public path segment under Documents, for display: `Documents/Lockstep/{runSessionFolder}/`. */ + fun documentsRelativePath(runSessionFolder: String): String = + "${Environment.DIRECTORY_DOCUMENTS}/$APP_DIR/$runSessionFolder" + + fun writeRunDataFile( + context: Context, + runSessionFolder: String, + playlistDisplayName: String, + trackQueueIndex0Based: Int, + metaContentUri: String, + snapshot: RunTrackDataSnapshot, + ): Uri? { + if (snapshot.isEmpty()) return null + + val safeName = + playlistDisplayName + .replace(Regex("[\\\\/:*?\"<>|]"), "_") + .trim() + .ifBlank { "playlist" } + .take(120) + val suffix = String.format(Locale.US, "%03d", trackQueueIndex0Based + 1) + val fileName = "${safeName}_$suffix.json" + + val jsonString = + JSONObject() + .apply { + put("data", samplesToJsonArray(snapshot.accelerometer)) + put("gyro", samplesToJsonArray(snapshot.gyroscope)) + put("gps", gpsToJsonArray(snapshot.gps)) + put("meta", metaContentUri) + 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) + } + } + + private fun samplesToJsonArray(samples: List): JSONArray { + val array = JSONArray() + for (sample in samples) { + array.put( + JSONObject().apply { + put("timestamp", sample.timestampNanos) + put( + "values", + JSONArray().apply { + for (v in sample.values) { + put(v.toDouble()) + } + }, + ) + }, + ) + } + return array + } + + private fun gpsToJsonArray(samples: List): JSONArray { + val array = JSONArray() + for (sample in samples) { + array.put( + JSONObject().apply { + put("timestamp", sample.timestampNanos) + put( + "values", + JSONArray().apply { + put(sample.latitude) + put(sample.longitude) + put(sample.altitude) + }, + ) + }, + ) + } + return array + } + + private fun writeViaMediaStore( + context: Context, + runSessionFolder: String, + fileName: String, + jsonString: String, + ): Uri? { + val resolver = context.applicationContext.contentResolver + val relativePath = documentsRelativePath(runSessionFolder) + 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.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 -> + stream.write(jsonString.toByteArray(Charsets.UTF_8)) + } ?: return null + val published = + ContentValues().apply { + put(MediaStore.MediaColumns.IS_PENDING, 0) + } + resolver.update(uri, published, null, null) + return uri + } catch (e: Exception) { + resolver.delete(uri, null, null) + throw e + } + } + + @Suppress("DEPRECATION") + private fun writeViaPublicDocumentsDir( + runSessionFolder: String, + fileName: String, + jsonString: String, + ): Uri? { + val dir = + File( + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS), + "$APP_DIR/$runSessionFolder", + ) + if (!dir.exists() && !dir.mkdirs()) { + return null + } + val file = File(dir, fileName) + file.writeText(jsonString) + return Uri.fromFile(file) + } +} diff --git a/app/src/main/java/at/lockstep/player/util/RunTrackDataSnapshot.kt b/app/src/main/java/at/lockstep/player/util/RunTrackDataSnapshot.kt new file mode 100644 index 0000000..5619738 --- /dev/null +++ b/app/src/main/java/at/lockstep/player/util/RunTrackDataSnapshot.kt @@ -0,0 +1,17 @@ +package at.lockstep.player.util + +data class RunGpsSample( + val timestampNanos: Long, + val latitude: Double, + val longitude: Double, + val altitude: Double, +) + +data class RunTrackDataSnapshot( + val accelerometer: List, + val gyroscope: List, + val gps: List, +) { + fun isEmpty(): Boolean = + accelerometer.isEmpty() && gyroscope.isEmpty() && gps.isEmpty() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f6e450d..a9b7a7a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -30,6 +30,8 @@ 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. Annotation mode When enabled, choosing a playlist opens beat annotation (tap in time) instead of Now playing. + Collect run data + When enabled, Now playing records accelerometer, gyroscope, and GPS (1 Hz) per song into Documents/Lockstep/ under a timestamped run folder. Beat annotation Tap on each beat; times use the same clock as playback (ExoPlayer position). Tap here on the beat