feat: collect gyro and gps
This commit is contained in:
213
app/src/main/java/at/lockstep/player/util/RunDataCollector.kt
Normal file
213
app/src/main/java/at/lockstep/player/util/RunDataCollector.kt
Normal file
@@ -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<RunDataSample>()
|
||||
private val gyroBuffer = mutableListOf<RunDataSample>()
|
||||
private val gpsBuffer = mutableListOf<RunGpsSample>()
|
||||
|
||||
/** 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
|
||||
}
|
||||
}
|
||||
19
app/src/main/java/at/lockstep/player/util/RunDataSample.kt
Normal file
19
app/src/main/java/at/lockstep/player/util/RunDataSample.kt
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
155
app/src/main/java/at/lockstep/player/util/RunDataStorage.kt
Normal file
155
app/src/main/java/at/lockstep/player/util/RunDataStorage.kt
Normal file
@@ -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<RunDataSample>): 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<RunGpsSample>): 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)
|
||||
}
|
||||
}
|
||||
@@ -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<RunDataSample>,
|
||||
val gyroscope: List<RunDataSample>,
|
||||
val gps: List<RunGpsSample>,
|
||||
) {
|
||||
fun isEmpty(): Boolean =
|
||||
accelerometer.isEmpty() && gyroscope.isEmpty() && gps.isEmpty()
|
||||
}
|
||||
Reference in New Issue
Block a user