Add reliable Android BLE ride recorder

This commit is contained in:
Jay
2026-08-20 12:40:39 -04:00
parent 3bad2275cc
commit 6e408a3048
40 changed files with 2781 additions and 120 deletions
+49
View File
@@ -0,0 +1,49 @@
plugins {
id("com.android.application")
}
android {
namespace = "com.jsjdesigns.trikkerecorder"
compileSdk = 36
defaultConfig {
applicationId = "com.jsjdesigns.trikkerecorder"
minSdk = 31
targetSdk = 36
versionCode = 2
versionName = "0.2.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
testOptions {
unitTests.all {
it.useJUnit()
}
}
sourceSets {
getByName("test").resources.directories.add(
"../../tests/fixtures",
)
}
}
dependencies {
testImplementation("junit:junit:4.13.2")
}
+1
View File
@@ -0,0 +1 @@
# Prototype v0 keeps release builds unobfuscated for diagnosability.
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature
android:name="android.hardware.bluetooth_le"
android:required="true" />
<uses-permission
android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<application
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:icon="@drawable/ic_launcher"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.TrikkeRecorder">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".TelemetryService"
android:exported="false"
android:foregroundServiceType="connectedDevice"
android:stopWithTask="false" />
</application>
</manifest>
@@ -0,0 +1,354 @@
package com.jsjdesigns.trikkerecorder
import android.annotation.SuppressLint
import android.bluetooth.BluetoothAdapter
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothGatt
import android.bluetooth.BluetoothGattCallback
import android.bluetooth.BluetoothGattCharacteristic
import android.bluetooth.BluetoothGattDescriptor
import android.bluetooth.BluetoothManager
import android.bluetooth.BluetoothProfile
import android.bluetooth.BluetoothStatusCodes
import android.bluetooth.le.ScanCallback
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanResult
import android.bluetooth.le.ScanSettings
import android.content.Context
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.ParcelUuid
import android.os.SystemClock
import com.jsjdesigns.trikkerecorder.protocol.BleFrameReassembler
import java.util.UUID
@SuppressLint("MissingPermission")
class BleTransport(
context: Context,
private val listener: Listener,
) {
interface Listener {
fun onTransportState(state: String, detail: String)
fun onFragment(fragment: ByteArray, connectionEpoch: Long, elapsedNs: Long, wallMs: Long)
}
private val appContext = context.applicationContext
private val handler = Handler(Looper.getMainLooper())
private val adapter: BluetoothAdapter? =
appContext.getSystemService(BluetoothManager::class.java)?.adapter
private var scannerCallback: ScanCallback? = null
private var gatt: BluetoothGatt? = null
private var ackCharacteristic: BluetoothGattCharacteristic? = null
private var controlCharacteristic: BluetoothGattCharacteristic? = null
private var running = false
private var sessionToken = 0L
private var connectionEpoch = 0L
private var readyEpoch = -1L
private val scanTimeout = Runnable {
stopScan()
if (running) {
listener.onTransportState("Scanning", "TrikkeSensor not seen; scanning again")
handler.postDelayed(::beginScan, SCAN_PAUSE_MS)
}
}
fun start(sessionToken: Long) {
if (running) return
this.sessionToken = sessionToken
running = true
beginScan()
}
fun stop() {
running = false
handler.removeCallbacksAndMessages(null)
stopScan()
readyEpoch = -1L
ackCharacteristic = null
controlCharacteristic = null
gatt?.disconnect()
gatt?.close()
gatt = null
}
fun acknowledge(packetSequence: Long, epoch: Long) {
handler.post {
val activeGatt = gatt
val characteristic = ackCharacteristic
if (!running || epoch != readyEpoch || activeGatt == null || characteristic == null) {
return@post
}
val ack = BleFrameReassembler.encodeAck(packetSequence)
val accepted = if (Build.VERSION.SDK_INT >= 33) {
activeGatt.writeCharacteristic(
characteristic,
ack,
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT,
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
characteristic.setValue(ack)
@Suppress("DEPRECATION")
characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
activeGatt.writeCharacteristic(characteristic)
}
if (!accepted) {
listener.onTransportState("Recording", "ACK enqueue delayed; awaiting replay")
}
}
}
private fun beginScan() {
if (!running || scannerCallback != null || gatt != null) return
val scanner = adapter?.bluetoothLeScanner
if (adapter?.isEnabled != true || scanner == null) {
listener.onTransportState("Waiting", "Bluetooth is off")
handler.postDelayed(::beginScan, RETRY_MS)
return
}
val callback = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
if (!running || scannerCallback !== this) return
stopScan()
connect(result)
}
override fun onScanFailed(errorCode: Int) {
if (scannerCallback === this) scannerCallback = null
handler.removeCallbacks(scanTimeout)
listener.onTransportState("Waiting", "BLE scan failed ($errorCode); retrying")
if (running) handler.postDelayed(::beginScan, RETRY_MS)
}
}
scannerCallback = callback
val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE_UUID)).build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
try {
scanner.startScan(listOf(filter), settings, callback)
listener.onTransportState("Scanning", "Looking for TrikkeSensor")
handler.postDelayed(scanTimeout, SCAN_WINDOW_MS)
} catch (error: RuntimeException) {
scannerCallback = null
listener.onTransportState("Waiting", "BLE scan unavailable: ${error.message}")
if (running) handler.postDelayed(::beginScan, RETRY_MS)
}
}
private fun stopScan() {
handler.removeCallbacks(scanTimeout)
val callback = scannerCallback ?: return
scannerCallback = null
try {
adapter?.bluetoothLeScanner?.stopScan(callback)
} catch (_: RuntimeException) {
// Bluetooth may have been switched off while the scan was active.
}
}
private fun connect(result: ScanResult) {
if (!running) return
connectionEpoch++
val epoch = connectionEpoch
listener.onTransportState("Connecting", result.device.address)
gatt = result.device.connectGatt(
appContext,
false,
callback(epoch),
BluetoothDevice.TRANSPORT_LE,
)
if (gatt == null) restart("Connection could not be started")
}
private fun callback(epoch: Long) = object : BluetoothGattCallback() {
override fun onConnectionStateChange(callbackGatt: BluetoothGatt, status: Int, newState: Int) {
handler.post {
if (callbackGatt !== gatt || epoch != connectionEpoch) {
callbackGatt.close()
return@post
}
if (status == BluetoothGatt.GATT_SUCCESS && newState == BluetoothProfile.STATE_CONNECTED) {
listener.onTransportState("Connecting", "Discovering telemetry service")
callbackGatt.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
if (!callbackGatt.discoverServices()) restart("Service discovery did not start")
} else if (newState == BluetoothProfile.STATE_DISCONNECTED || status != BluetoothGatt.GATT_SUCCESS) {
restart("Disconnected (GATT $status)")
}
}
}
override fun onServicesDiscovered(callbackGatt: BluetoothGatt, status: Int) {
handler.post {
if (callbackGatt !== gatt || epoch != connectionEpoch) return@post
if (status != BluetoothGatt.GATT_SUCCESS) {
restart("Service discovery failed ($status)")
return@post
}
val service = callbackGatt.getService(SERVICE_UUID)
val data = service?.getCharacteristic(DATA_UUID)
ackCharacteristic = service?.getCharacteristic(ACK_UUID)
controlCharacteristic = service?.getCharacteristic(CONTROL_UUID)
if (data == null || ackCharacteristic == null || controlCharacteristic == null) {
restart("Telemetry characteristics are missing")
return@post
}
if (!callbackGatt.requestMtu(PREFERRED_MTU)) beginSession(callbackGatt, epoch)
}
}
override fun onMtuChanged(callbackGatt: BluetoothGatt, mtu: Int, status: Int) {
handler.post {
if (callbackGatt !== gatt || epoch != connectionEpoch) return@post
beginSession(callbackGatt, epoch)
}
}
@Deprecated("Used through Android 12")
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
) {
@Suppress("DEPRECATION")
deliver(callbackGatt, characteristic, characteristic.value?.copyOf() ?: return, epoch)
}
override fun onCharacteristicChanged(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
) {
deliver(callbackGatt, characteristic, value.copyOf(), epoch)
}
override fun onDescriptorWrite(callbackGatt: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
handler.post {
if (callbackGatt !== gatt || epoch != connectionEpoch) return@post
if (descriptor.uuid != CCCD_UUID || status != BluetoothGatt.GATT_SUCCESS) {
restart("Notification subscription failed ($status)")
return@post
}
readyEpoch = epoch
listener.onTransportState("Recording", "Connected and subscribed")
}
}
override fun onCharacteristicWrite(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
status: Int,
) {
handler.post {
if (callbackGatt !== gatt || epoch != connectionEpoch) return@post
if (characteristic.uuid == CONTROL_UUID) {
if (status != BluetoothGatt.GATT_SUCCESS) {
restart("Session preparation failed ($status)")
return@post
}
val data = callbackGatt.getService(SERVICE_UUID)?.getCharacteristic(DATA_UUID)
if (data == null) restart("Data characteristic vanished")
else enableNotifications(callbackGatt, data, epoch)
} else if (characteristic.uuid == ACK_UUID && status != BluetoothGatt.GATT_SUCCESS) {
listener.onTransportState("Recording", "ACK write failed ($status); awaiting replay")
}
}
}
}
private fun deliver(
callbackGatt: BluetoothGatt,
characteristic: BluetoothGattCharacteristic,
value: ByteArray,
epoch: Long,
) {
val elapsedNs = SystemClock.elapsedRealtimeNanos()
val wallMs = System.currentTimeMillis()
handler.post {
if (callbackGatt !== gatt || epoch != readyEpoch || characteristic.uuid != DATA_UUID) return@post
listener.onFragment(value, epoch, elapsedNs, wallMs)
}
}
private fun enableNotifications(
callbackGatt: BluetoothGatt,
data: BluetoothGattCharacteristic,
epoch: Long,
) {
if (callbackGatt !== gatt || epoch != connectionEpoch) return
if (!callbackGatt.setCharacteristicNotification(data, true)) {
restart("Local notification registration failed")
return
}
val descriptor = data.getDescriptor(CCCD_UUID)
if (descriptor == null) {
restart("Notification descriptor is missing")
return
}
val accepted = if (Build.VERSION.SDK_INT >= 33) {
callbackGatt.writeDescriptor(descriptor, BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE) ==
BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
descriptor.setValue(BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE)
@Suppress("DEPRECATION")
callbackGatt.writeDescriptor(descriptor)
}
if (!accepted) restart("Notification subscription did not start")
}
private fun beginSession(callbackGatt: BluetoothGatt, epoch: Long) {
if (callbackGatt !== gatt || epoch != connectionEpoch) return
val control = controlCharacteristic
if (control == null) {
restart("Session control characteristic is missing")
return
}
listener.onTransportState("Preparing", "Establishing a clean recording session")
val command = BleFrameReassembler.encodeBeginSession(sessionToken)
val accepted = if (Build.VERSION.SDK_INT >= 33) {
callbackGatt.writeCharacteristic(
control,
command,
BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT,
) == BluetoothStatusCodes.SUCCESS
} else {
@Suppress("DEPRECATION")
control.setValue(command)
@Suppress("DEPRECATION")
control.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT
@Suppress("DEPRECATION")
callbackGatt.writeCharacteristic(control)
}
if (!accepted) restart("Session preparation did not start")
}
private fun restart(reason: String) {
readyEpoch = -1L
ackCharacteristic = null
controlCharacteristic = null
val oldGatt = gatt
gatt = null
oldGatt?.disconnect()
oldGatt?.close()
if (running) {
listener.onTransportState("Reconnecting", reason)
handler.postDelayed(::beginScan, RETRY_MS)
}
}
companion object {
val SERVICE_UUID: UUID = UUID.fromString("7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c10")
val DATA_UUID: UUID = UUID.fromString("7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c11")
val ACK_UUID: UUID = UUID.fromString("7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c12")
val CONTROL_UUID: UUID = UUID.fromString("7d2ea000-f75b-4a9b-8fbe-3d4c2a1e9c13")
private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private const val PREFERRED_MTU = 256
private const val SCAN_WINDOW_MS = 10_000L
private const val SCAN_PAUSE_MS = 500L
private const val RETRY_MS = 1_000L
}
}
@@ -0,0 +1,256 @@
package com.jsjdesigns.trikkerecorder
import android.Manifest
import android.annotation.SuppressLint
import android.app.Activity
import android.bluetooth.BluetoothAdapter
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.pm.PackageManager
import android.graphics.Typeface
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.Gravity
import android.view.ViewGroup
import android.widget.Button
import android.widget.LinearLayout
import android.widget.ScrollView
import android.widget.TextView
import java.io.File
import java.util.Locale
class MainActivity : Activity() {
private lateinit var phaseView: TextView
private lateinit var detailView: TextView
private lateinit var countersView: TextView
private lateinit var recordButton: Button
private lateinit var exportButton: Button
private var snapshot = RecorderSnapshot()
private var pendingStart = false
private var exportPath: String? = null
private val stateReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != TelemetryService.ACTION_STATE) return
snapshot = RecorderSnapshot(
active = intent.getBooleanExtra(TelemetryService.EXTRA_ACTIVE, false),
phase = intent.getStringExtra(TelemetryService.EXTRA_PHASE) ?: "Idle",
detail = intent.getStringExtra(TelemetryService.EXTRA_DETAIL) ?: "",
capturePath = intent.getStringExtra(TelemetryService.EXTRA_CAPTURE_PATH),
durationSeconds = intent.getLongExtra(TelemetryService.EXTRA_DURATION_SECONDS, 0),
frameCount = intent.getLongExtra(TelemetryService.EXTRA_FRAMES, 0),
sampleCount = intent.getLongExtra(TelemetryService.EXTRA_SAMPLES, 0),
duplicateCount = intent.getLongExtra(TelemetryService.EXTRA_DUPLICATES, 0),
rejectedFragments = intent.getLongExtra(TelemetryService.EXTRA_REJECTED_FRAGMENTS, 0),
invalidFrames = intent.getLongExtra(TelemetryService.EXTRA_INVALID_FRAMES, 0),
packetGaps = intent.getLongExtra(TelemetryService.EXTRA_PACKET_GAPS, 0),
sampleGaps = intent.getLongExtra(TelemetryService.EXTRA_SAMPLE_GAPS, 0),
droppedSamples = intent.getLongExtra(TelemetryService.EXTRA_DROPPED, 0),
queueOverflows = intent.getLongExtra(TelemetryService.EXTRA_QUEUE_OVERFLOWS, -1)
.takeIf { it >= 0 },
)
render()
}
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
buildUi()
snapshot = TelemetryService.currentSnapshot
if (snapshot.capturePath == null) {
val previous = getSharedPreferences(TelemetryService.PREFERENCES, MODE_PRIVATE)
.getString(TelemetryService.LAST_CAPTURE, null)
if (previous != null) snapshot = snapshot.copy(capturePath = previous)
}
render()
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
override fun onStart() {
super.onStart()
val filter = IntentFilter(TelemetryService.ACTION_STATE)
if (Build.VERSION.SDK_INT >= 33) registerReceiver(stateReceiver, filter, RECEIVER_NOT_EXPORTED)
else @Suppress("DEPRECATION") registerReceiver(stateReceiver, filter)
}
override fun onStop() {
unregisterReceiver(stateReceiver)
super.onStop()
}
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<out String>,
grantResults: IntArray,
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
if (requestCode == REQUEST_PERMISSIONS && hasBluetoothPermissions()) ensureBluetoothAndStart()
else if (requestCode == REQUEST_PERMISSIONS) showLocalMessage("Nearby devices permission is required")
}
@Deprecated("Used for the platform Bluetooth-enable and document-create flows")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
when (requestCode) {
REQUEST_ENABLE_BLUETOOTH -> {
if (resultCode == RESULT_OK && pendingStart) startRecorder()
else showLocalMessage("Bluetooth must be enabled to record")
pendingStart = false
}
REQUEST_EXPORT -> if (resultCode == RESULT_OK) {
val destination = data?.data ?: return
exportCapture(destination)
}
}
}
private fun buildUi() {
val density = resources.displayMetrics.density
val padding = (24 * density).toInt()
val content = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
gravity = Gravity.CENTER_HORIZONTAL
setPadding(padding, padding, padding, padding)
}
phaseView = TextView(this).apply {
textSize = 30f
setTypeface(typeface, Typeface.BOLD)
}
detailView = TextView(this).apply {
textSize = 17f
gravity = Gravity.CENTER_HORIZONTAL
setPadding(0, padding / 2, 0, padding)
}
countersView = TextView(this).apply {
textSize = 17f
typeface = Typeface.MONOSPACE
setLineSpacing(0f, 1.25f)
}
recordButton = Button(this).apply {
setOnClickListener {
if (snapshot.active) stopRecorder() else requestStart()
}
}
exportButton = Button(this).apply {
text = getString(R.string.export_capture)
setOnClickListener { chooseExportDestination() }
}
content.addView(phaseView, matchWrap())
content.addView(detailView, matchWrap())
content.addView(countersView, matchWrap())
content.addView(recordButton, matchWrap(topMargin = padding))
content.addView(exportButton, matchWrap(topMargin = padding / 2))
setContentView(ScrollView(this).apply { addView(content) })
}
private fun matchWrap(topMargin: Int = 0) = LinearLayout.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.WRAP_CONTENT,
).apply { this.topMargin = topMargin }
private fun render() {
phaseView.text = snapshot.phase
detailView.text = snapshot.detail
countersView.text = buildString {
appendLine("duration ${formatDuration(snapshot.durationSeconds)}")
appendLine("frames ${snapshot.frameCount}")
appendLine("samples ${snapshot.sampleCount}")
appendLine("duplicate replays ${snapshot.duplicateCount}")
appendLine("fragment rejects ${snapshot.rejectedFragments}")
appendLine("invalid frames ${snapshot.invalidFrames}")
appendLine("packet gaps ${snapshot.packetGaps}")
appendLine("sample gaps ${snapshot.sampleGaps}")
appendLine("device drops ${snapshot.droppedSamples}")
append("queue overflows ${snapshot.queueOverflows ?: "waiting for status"}")
}
recordButton.text = if (snapshot.active) "Stop and close safely" else "Start recording"
recordButton.isEnabled = snapshot.phase != "Stopping"
exportButton.isEnabled = !snapshot.active && snapshot.capturePath?.let(::File)?.isFile == true
}
private fun requestStart() {
if (!hasBluetoothPermissions()) {
val permissions = mutableListOf(
Manifest.permission.BLUETOOTH_SCAN,
Manifest.permission.BLUETOOTH_CONNECT,
)
if (Build.VERSION.SDK_INT >= 33) permissions += Manifest.permission.POST_NOTIFICATIONS
requestPermissions(permissions.toTypedArray(), REQUEST_PERMISSIONS)
return
}
ensureBluetoothAndStart()
}
private fun hasBluetoothPermissions(): Boolean =
checkSelfPermission(Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
@SuppressLint("MissingPermission")
private fun ensureBluetoothAndStart() {
val adapter = getSystemService(android.bluetooth.BluetoothManager::class.java)?.adapter
if (adapter?.isEnabled != true) {
pendingStart = true
@Suppress("DEPRECATION")
startActivityForResult(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE), REQUEST_ENABLE_BLUETOOTH)
} else {
startRecorder()
}
}
private fun startRecorder() {
startForegroundService(Intent(this, TelemetryService::class.java).setAction(TelemetryService.ACTION_START))
}
private fun stopRecorder() {
startService(Intent(this, TelemetryService::class.java).setAction(TelemetryService.ACTION_STOP))
}
private fun chooseExportDestination() {
val source = snapshot.capturePath?.let(::File) ?: return
exportPath = source.absolutePath
@Suppress("DEPRECATION")
startActivityForResult(
Intent(Intent.ACTION_CREATE_DOCUMENT)
.addCategory(Intent.CATEGORY_OPENABLE)
.setType("application/octet-stream")
.putExtra(Intent.EXTRA_TITLE, source.name),
REQUEST_EXPORT,
)
}
private fun exportCapture(destination: Uri) {
val source = exportPath?.let(::File) ?: return
Thread {
try {
contentResolver.openOutputStream(destination, "wt")!!.use { output ->
source.inputStream().use { input -> input.copyTo(output) }
output.flush()
}
runOnUiThread { showLocalMessage("Exported ${source.name}") }
} catch (error: Exception) {
runOnUiThread { showLocalMessage("Export failed: ${error.message}") }
}
}.start()
}
private fun showLocalMessage(message: String) {
detailView.text = message
}
private fun formatDuration(seconds: Long): String = String.format(
Locale.US,
"%02d:%02d:%02d",
seconds / 3_600,
(seconds / 60) % 60,
seconds % 60,
)
companion object {
private const val REQUEST_PERMISSIONS = 20
private const val REQUEST_ENABLE_BLUETOOTH = 21
private const val REQUEST_EXPORT = 22
}
}
@@ -0,0 +1,317 @@
package com.jsjdesigns.trikkerecorder
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.IBinder
import android.os.PowerManager
import android.os.SystemClock
import com.jsjdesigns.trikkerecorder.protocol.BleFrameReassembler
import com.jsjdesigns.trikkerecorder.protocol.TrkProtocol
import com.jsjdesigns.trikkerecorder.storage.CommitResult
import com.jsjdesigns.trikkerecorder.storage.SessionRecorder
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.security.SecureRandom
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
data class RecorderSnapshot(
val active: Boolean = false,
val phase: String = "Idle",
val detail: String = "Ready",
val capturePath: String? = null,
val durationSeconds: Long = 0,
val frameCount: Long = 0,
val sampleCount: Long = 0,
val duplicateCount: Long = 0,
val rejectedFragments: Long = 0,
val invalidFrames: Long = 0,
val packetGaps: Long = 0,
val sampleGaps: Long = 0,
val droppedSamples: Long = 0,
val queueOverflows: Long? = null,
)
class TelemetryService : Service(), BleTransport.Listener {
private val processor = Executors.newSingleThreadExecutor()
private val stopping = AtomicBoolean(false)
private val reassembler = BleFrameReassembler()
private lateinit var transport: BleTransport
private var recorder: SessionRecorder? = null
private var phase = "Idle"
private var detail = "Ready"
private var invalidFrames = 0L
private var lastPublishMs = 0L
private var wakeLock: PowerManager.WakeLock? = null
private var recordingStartElapsedMs = 0L
private var recordingEndElapsedMs = 0L
private var sessionToken = 0L
override fun onCreate() {
super.onCreate()
createNotificationChannel()
transport = BleTransport(this, this)
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
when (intent?.action) {
ACTION_STOP -> finishRecording(null)
else -> startRecording()
}
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onDestroy() {
transport.stop()
releaseWakeLock()
if (!stopping.get()) {
recorder?.close(System.currentTimeMillis(), SystemClock.elapsedRealtimeNanos(), "Service destroyed")
}
processor.shutdown()
super.onDestroy()
}
override fun onTransportState(state: String, detail: String) {
this.phase = state
this.detail = detail
publish(force = true)
}
override fun onFragment(fragment: ByteArray, connectionEpoch: Long, elapsedNs: Long, wallMs: Long) {
processor.execute {
try {
val raw = reassembler.feed(fragment) ?: run {
publish()
return@execute
}
val frame = TrkProtocol.parse(raw)
if (frame == null) {
invalidFrames++
publish(force = true)
return@execute
}
val activeRecorder = recorder ?: return@execute
when (activeRecorder.commit(frame, elapsedNs, wallMs)) {
CommitResult.PERSISTED,
CommitResult.DUPLICATE,
-> transport.acknowledge(frame.packetSequence, connectionEpoch)
CommitResult.CONFLICT -> fatal("Packet sequence replayed with different bytes")
}
publish()
} catch (error: Exception) {
fatal("Storage failure: ${error.message ?: error.javaClass.simpleName}")
}
}
}
private fun startRecording() {
if (recorder != null || stopping.get()) return
phase = "Starting"
detail = "Opening capture"
recordingStartElapsedMs = SystemClock.elapsedRealtime()
recordingEndElapsedMs = 0L
startForeground(NOTIFICATION_ID, notification(snapshot()))
acquireWakeLock()
val startWallMs = System.currentTimeMillis()
sessionToken = SecureRandom().nextLong()
val stem = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.US).format(Date(startWallMs))
val directory = File(filesDir, "captures")
try {
recorder = SessionRecorder(
directory = directory,
stem = "ride_$stem",
startWallClockMs = startWallMs,
startElapsedRealtimeNs = SystemClock.elapsedRealtimeNanos(),
sessionToken = sessionToken,
)
} catch (error: Exception) {
phase = "Error"
detail = "Cannot open capture: ${error.message}"
releaseWakeLock()
publish(force = true)
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
return
}
detail = "Capture file opened"
publish(force = true)
transport.start(sessionToken)
}
private fun fatal(message: String) {
mainExecutor.execute { finishRecording(message) }
}
private fun finishRecording(error: String?) {
if (recorder == null || !stopping.compareAndSet(false, true)) return
phase = "Stopping"
detail = error ?: "Closing capture"
recordingEndElapsedMs = SystemClock.elapsedRealtime()
transport.stop()
publish(force = true)
processor.execute {
val activeRecorder = recorder ?: return@execute
try {
activeRecorder.close(
endWallClockMs = System.currentTimeMillis(),
endElapsedRealtimeNs = SystemClock.elapsedRealtimeNanos(),
error = error,
)
getSharedPreferences(PREFERENCES, MODE_PRIVATE).edit()
.putString(LAST_CAPTURE, activeRecorder.captureFile.absolutePath)
.apply()
phase = if (error == null) "Stopped" else "Error"
detail = error ?: "Capture safely closed"
} catch (closeError: Exception) {
phase = "Error"
detail = "Could not close capture: ${closeError.message}"
}
publish(force = true)
mainExecutor.execute {
releaseWakeLock()
stopForeground(STOP_FOREGROUND_REMOVE)
stopSelf()
}
}
}
@SuppressLint("WakelockTimeout")
private fun acquireWakeLock() {
if (wakeLock?.isHeld == true) return
wakeLock = getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "TrikkeRecorder:RideRecording")
.apply {
setReferenceCounted(false)
acquire()
}
}
private fun releaseWakeLock() {
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
}
private fun snapshot(): RecorderSnapshot {
val activeRecorder = recorder
val stats = activeRecorder?.stats()
val elapsedEnd = recordingEndElapsedMs.takeIf { it != 0L } ?: SystemClock.elapsedRealtime()
val durationSeconds = if (recordingStartElapsedMs == 0L) 0L
else ((elapsedEnd - recordingStartElapsedMs).coerceAtLeast(0L) / 1_000L)
return RecorderSnapshot(
active = activeRecorder != null && !stopping.get(),
phase = phase,
detail = detail,
capturePath = activeRecorder?.captureFile?.absolutePath,
durationSeconds = durationSeconds,
frameCount = stats?.frameCount ?: 0,
sampleCount = stats?.sampleCount ?: 0,
duplicateCount = stats?.duplicateCount ?: 0,
rejectedFragments = reassembler.rejectedFragmentCount,
invalidFrames = invalidFrames,
packetGaps = stats?.integrity?.packetGaps ?: 0,
sampleGaps = stats?.integrity?.sampleGaps ?: 0,
droppedSamples = stats?.integrity?.droppedSamples ?: 0,
queueOverflows = stats?.integrity?.status?.queueOverflows,
)
}
private fun publish(force: Boolean = false) {
val now = SystemClock.elapsedRealtime()
if (!force && now - lastPublishMs < 250) return
lastPublishMs = now
val value = snapshot()
currentSnapshot = value
sendBroadcast(
Intent(ACTION_STATE)
.setPackage(packageName)
.putExtra(EXTRA_ACTIVE, value.active)
.putExtra(EXTRA_PHASE, value.phase)
.putExtra(EXTRA_DETAIL, value.detail)
.putExtra(EXTRA_CAPTURE_PATH, value.capturePath)
.putExtra(EXTRA_DURATION_SECONDS, value.durationSeconds)
.putExtra(EXTRA_FRAMES, value.frameCount)
.putExtra(EXTRA_SAMPLES, value.sampleCount)
.putExtra(EXTRA_DUPLICATES, value.duplicateCount)
.putExtra(EXTRA_REJECTED_FRAGMENTS, value.rejectedFragments)
.putExtra(EXTRA_INVALID_FRAMES, value.invalidFrames)
.putExtra(EXTRA_PACKET_GAPS, value.packetGaps)
.putExtra(EXTRA_SAMPLE_GAPS, value.sampleGaps)
.putExtra(EXTRA_DROPPED, value.droppedSamples)
.putExtra(EXTRA_QUEUE_OVERFLOWS, value.queueOverflows ?: -1L),
)
if (value.active) {
getSystemService(NotificationManager::class.java)
.notify(NOTIFICATION_ID, notification(value))
}
}
private fun notification(value: RecorderSnapshot): Notification {
val openIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val stopIntent = PendingIntent.getService(
this,
1,
Intent(this, TelemetryService::class.java).setAction(ACTION_STOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return Notification.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.stat_sys_data_bluetooth)
.setContentTitle("Trikke ride recording")
.setContentText("${value.phase}: ${value.sampleCount} samples")
.setContentIntent(openIntent)
.setOngoing(true)
.addAction(Notification.Action.Builder(null, "Stop", stopIntent).build())
.build()
}
private fun createNotificationChannel() {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_channel_name),
NotificationManager.IMPORTANCE_LOW,
),
)
}
companion object {
const val ACTION_START = "com.jsjdesigns.trikkerecorder.START"
const val ACTION_STOP = "com.jsjdesigns.trikkerecorder.STOP"
const val ACTION_STATE = "com.jsjdesigns.trikkerecorder.STATE"
const val PREFERENCES = "trikke_recorder"
const val LAST_CAPTURE = "last_capture"
const val EXTRA_ACTIVE = "active"
const val EXTRA_PHASE = "phase"
const val EXTRA_DETAIL = "detail"
const val EXTRA_CAPTURE_PATH = "capture_path"
const val EXTRA_DURATION_SECONDS = "duration_seconds"
const val EXTRA_FRAMES = "frames"
const val EXTRA_SAMPLES = "samples"
const val EXTRA_DUPLICATES = "duplicates"
const val EXTRA_REJECTED_FRAGMENTS = "rejected_fragments"
const val EXTRA_INVALID_FRAMES = "invalid_frames"
const val EXTRA_PACKET_GAPS = "packet_gaps"
const val EXTRA_SAMPLE_GAPS = "sample_gaps"
const val EXTRA_DROPPED = "dropped"
const val EXTRA_QUEUE_OVERFLOWS = "queue_overflows"
private const val CHANNEL_ID = "ride_recording"
private const val NOTIFICATION_ID = 4100
@Volatile
var currentSnapshot = RecorderSnapshot()
private set
}
}
@@ -0,0 +1,113 @@
package com.jsjdesigns.trikkerecorder.protocol
class BleFrameReassembler {
var rejectedFragmentCount: Long = 0
private set
private var sequence: Long? = null
private var totalSize = 0
private var frame = ByteArray(0)
fun reset() {
sequence = null
totalSize = 0
frame = ByteArray(0)
}
fun feed(fragment: ByteArray): ByteArray? {
if (fragment.size <= HEADER_SIZE) {
reject()
return null
}
val fragmentSequence = fragment.u32(0)
val offset = fragment.u16(4)
val declaredSize = fragment.u16(6)
val dataSize = fragment.size - HEADER_SIZE
if (
declaredSize !in MIN_FRAME_SIZE..MAX_FRAME_SIZE ||
offset >= declaredSize ||
offset + dataSize > declaredSize
) {
reject()
return null
}
if (offset == 0) {
sequence = fragmentSequence
totalSize = declaredSize
frame = ByteArray(0)
}
if (
sequence != fragmentSequence ||
totalSize != declaredSize ||
offset != frame.size
) {
reject()
return null
}
frame += fragment.copyOfRange(HEADER_SIZE, fragment.size)
if (frame.size != totalSize) {
return null
}
val completed = frame
reset()
return completed
}
private fun reject() {
rejectedFragmentCount++
reset()
}
companion object {
const val HEADER_SIZE = 8
const val MIN_FRAME_SIZE = 36
const val MAX_FRAME_SIZE = 196
fun encodeAck(packetSequence: Long): ByteArray = byteArrayOf(
'A'.code.toByte(),
'C'.code.toByte(),
'K'.code.toByte(),
'1'.code.toByte(),
packetSequence.toByte(),
(packetSequence ushr 8).toByte(),
(packetSequence ushr 16).toByte(),
(packetSequence ushr 24).toByte(),
)
fun encodeBeginSession(sessionToken: Long): ByteArray = byteArrayOf(
'B'.code.toByte(),
'G'.code.toByte(),
'N'.code.toByte(),
'1'.code.toByte(),
sessionToken.toByte(),
(sessionToken ushr 8).toByte(),
(sessionToken ushr 16).toByte(),
(sessionToken ushr 24).toByte(),
(sessionToken ushr 32).toByte(),
(sessionToken ushr 40).toByte(),
(sessionToken ushr 48).toByte(),
(sessionToken ushr 56).toByte(),
)
}
}
internal fun ByteArray.u16(offset: Int): Int =
(this[offset].toInt() and 0xff) or
((this[offset + 1].toInt() and 0xff) shl 8)
internal fun ByteArray.u32(offset: Int): Long =
(this[offset].toLong() and 0xff) or
((this[offset + 1].toLong() and 0xff) shl 8) or
((this[offset + 2].toLong() and 0xff) shl 16) or
((this[offset + 3].toLong() and 0xff) shl 24)
internal fun ByteArray.u64(offset: Int): Long {
var value = 0L
for (index in 0 until 8) {
value = value or ((this[offset + index].toLong() and 0xff) shl (index * 8))
}
return value
}
@@ -0,0 +1,168 @@
package com.jsjdesigns.trikkerecorder.protocol
import java.util.zip.CRC32
data class TransportStatus(
val sensorReadFailures: Long,
val queueOverflows: Long,
val beginRetries: Long,
val disconnects: Long,
val sendFailures: Long,
val replays: Long,
val invalidAcks: Long,
)
data class TrkFrame(
val raw: ByteArray,
val packetType: Int,
val flags: Int,
val packetSequence: Long,
val baseTimestampUs: Long,
val droppedSampleCount: Long,
val loopOverrunCount: Long,
val sampleSequences: LongArray,
val status: TransportStatus?,
) {
val sampleCount: Int
get() = sampleSequences.size
}
object TrkProtocol {
const val HEADER_SIZE = 36
const val MAX_FRAME_SIZE = 196
const val PACKET_METADATA = 1
const val PACKET_SAMPLES = 2
const val PACKET_STATUS = 3
fun parse(raw: ByteArray): TrkFrame? {
if (raw.size !in HEADER_SIZE..MAX_FRAME_SIZE) return null
if (
raw[0] != 'T'.code.toByte() || raw[1] != 'R'.code.toByte() ||
raw[2] != 'K'.code.toByte() || raw[3] != '1'.code.toByte()
) return null
val version = raw[4].toInt() and 0xff
val packetType = raw[5].toInt() and 0xff
val headerSize = raw[6].toInt() and 0xff
val recordSize = raw[7].toInt() and 0xff
val recordCount = raw[8].toInt() and 0xff
val flags = raw[9].toInt() and 0xff
val payloadSize = raw.u16(10)
if (version != 1 || headerSize != HEADER_SIZE || HEADER_SIZE + payloadSize != raw.size) {
return null
}
val validShape = when (packetType) {
PACKET_METADATA -> recordSize == 0 && recordCount == 0 && payloadSize == 48
PACKET_SAMPLES ->
recordSize == 20 && recordCount in 1..8 && payloadSize == recordSize * recordCount
PACKET_STATUS -> recordSize == 0 && recordCount == 0 && payloadSize == 32
else -> false
}
if (!validShape || raw.u32(32) != calculateCrc(raw)) return null
val status = if (packetType == PACKET_STATUS) {
if (raw.u16(36) != 1 || raw.u16(38) != 32) return null
TransportStatus(
sensorReadFailures = raw.u32(40),
queueOverflows = raw.u32(44),
beginRetries = raw.u32(48),
disconnects = raw.u32(52),
sendFailures = raw.u32(56),
replays = raw.u32(60),
invalidAcks = raw.u32(64),
)
} else {
null
}
val sampleSequences = if (packetType == PACKET_SAMPLES) {
LongArray(recordCount) { index -> raw.u32(HEADER_SIZE + index * recordSize) }
} else {
LongArray(0)
}
return TrkFrame(
raw = raw,
packetType = packetType,
flags = flags,
packetSequence = raw.u32(12),
baseTimestampUs = raw.u64(16),
droppedSampleCount = raw.u32(24),
loopOverrunCount = raw.u32(28),
sampleSequences = sampleSequences,
status = status,
)
}
private fun calculateCrc(raw: ByteArray): Long {
val crc = CRC32()
crc.update(raw, 4, 28)
crc.update(raw, HEADER_SIZE, raw.size - HEADER_SIZE)
return crc.value
}
}
data class IntegritySnapshot(
val packetGaps: Long,
val packetResets: Long,
val sampleGaps: Long,
val sampleResets: Long,
val droppedSamples: Long,
val loopOverruns: Long,
val status: TransportStatus?,
)
class IntegrityTracker {
private var previousPacket: Long? = null
private var previousSample: Long? = null
private var packetGaps = 0L
private var packetResets = 0L
private var sampleGaps = 0L
private var sampleResets = 0L
private var droppedSamples = 0L
private var loopOverruns = 0L
private var status: TransportStatus? = null
fun observe(frame: TrkFrame) {
previousPacket?.let { previous ->
val result = classify(previous, frame.packetSequence)
packetGaps += result.first
packetResets += result.second
}
previousPacket = frame.packetSequence
droppedSamples = frame.droppedSampleCount
loopOverruns = frame.loopOverrunCount
frame.status?.let { status = it }
frame.sampleSequences.forEach { sequence ->
previousSample?.let { previous ->
val result = classify(previous, sequence)
sampleGaps += result.first
sampleResets += result.second
}
previousSample = sequence
}
}
fun snapshot() = IntegritySnapshot(
packetGaps = packetGaps,
packetResets = packetResets,
sampleGaps = sampleGaps,
sampleResets = sampleResets,
droppedSamples = droppedSamples,
loopOverruns = loopOverruns,
status = status,
)
companion object {
fun classify(previous: Long, current: Long): Pair<Long, Long> {
val expected = (previous + 1) and 0xffff_ffffL
val forward = (current - expected) and 0xffff_ffffL
return when {
forward == 0L -> 0L to 0L
forward < 0x8000_0000L -> forward to 0L
else -> 0L to 1L
}
}
}
}
@@ -0,0 +1,180 @@
package com.jsjdesigns.trikkerecorder.storage
import com.jsjdesigns.trikkerecorder.protocol.IntegritySnapshot
import com.jsjdesigns.trikkerecorder.protocol.IntegrityTracker
import com.jsjdesigns.trikkerecorder.protocol.TrkFrame
import java.io.File
import java.io.FileOutputStream
enum class CommitResult {
PERSISTED,
DUPLICATE,
CONFLICT,
}
data class RecorderStats(
val frameCount: Long,
val sampleCount: Long,
val duplicateCount: Long,
val integrity: IntegritySnapshot,
)
class SessionRecorder(
directory: File,
stem: String,
private val startWallClockMs: Long,
private val startElapsedRealtimeNs: Long,
private val sessionToken: Long,
) : AutoCloseable {
val captureFile = File(directory, "$stem.trk")
val summaryFile = File(directory, "$stem.session.json")
private val output: FileOutputStream
private val integrity = IntegrityTracker()
private var lastSequence: Long? = null
private var lastRaw: ByteArray? = null
private var frameCount = 0L
private var sampleCount = 0L
private var duplicateCount = 0L
private var firstFrame: FrameClock? = null
private var lastFrame: FrameClock? = null
private var closed = false
init {
check(directory.exists() || directory.mkdirs()) { "Cannot create ${directory.path}" }
output = FileOutputStream(captureFile, false)
writeSummary(complete = false, endWallClockMs = null, endElapsedRealtimeNs = null, error = null)
}
@Synchronized
fun commit(
frame: TrkFrame,
receivedElapsedRealtimeNs: Long,
receivedWallClockMs: Long,
): CommitResult {
check(!closed) { "Recorder is closed" }
if (lastSequence == frame.packetSequence) {
if (lastRaw!!.contentEquals(frame.raw)) {
duplicateCount++
return CommitResult.DUPLICATE
}
return CommitResult.CONFLICT
}
output.write(frame.raw)
output.flush()
output.fd.sync()
integrity.observe(frame)
frameCount++
sampleCount += frame.sampleCount
lastSequence = frame.packetSequence
lastRaw = frame.raw.copyOf()
val clock = FrameClock(
packetSequence = frame.packetSequence,
deviceBaseTimestampUs = frame.baseTimestampUs,
phoneElapsedRealtimeNs = receivedElapsedRealtimeNs,
phoneWallClockMs = receivedWallClockMs,
)
if (firstFrame == null) firstFrame = clock
lastFrame = clock
return CommitResult.PERSISTED
}
@Synchronized
fun stats(): RecorderStats = RecorderStats(
frameCount = frameCount,
sampleCount = sampleCount,
duplicateCount = duplicateCount,
integrity = integrity.snapshot(),
)
@Synchronized
fun close(endWallClockMs: Long, endElapsedRealtimeNs: Long, error: String? = null) {
if (closed) return
closed = true
output.flush()
output.fd.sync()
output.close()
writeSummary(
complete = error == null,
endWallClockMs = endWallClockMs,
endElapsedRealtimeNs = endElapsedRealtimeNs,
error = error,
)
}
override fun close() {
close(System.currentTimeMillis(), System.nanoTime())
}
private fun writeSummary(
complete: Boolean,
endWallClockMs: Long?,
endElapsedRealtimeNs: Long?,
error: String?,
) {
val stats = stats()
val status = stats.integrity.status
val json = buildString {
append("{\n")
append(" \"schema\": 1,\n")
append(" \"captureFile\": \"").append(captureFile.name).append("\",\n")
append(" \"sessionTokenHex\": \"")
.append(java.lang.Long.toUnsignedString(sessionToken, 16).padStart(16, '0'))
.append("\",\n")
append(" \"complete\": ").append(complete).append(",\n")
append(" \"startWallClockMs\": ").append(startWallClockMs).append(",\n")
append(" \"startElapsedRealtimeNs\": ").append(startElapsedRealtimeNs).append(",\n")
append(" \"endWallClockMs\": ").append(endWallClockMs ?: "null").append(",\n")
append(" \"endElapsedRealtimeNs\": ").append(endElapsedRealtimeNs ?: "null").append(",\n")
append(" \"firstFrame\": ").append(firstFrame?.json() ?: "null").append(",\n")
append(" \"lastFrame\": ").append(lastFrame?.json() ?: "null").append(",\n")
append(" \"frames\": ").append(stats.frameCount).append(",\n")
append(" \"samples\": ").append(stats.sampleCount).append(",\n")
append(" \"duplicateReplays\": ").append(stats.duplicateCount).append(",\n")
append(" \"packetGaps\": ").append(stats.integrity.packetGaps).append(",\n")
append(" \"packetResets\": ").append(stats.integrity.packetResets).append(",\n")
append(" \"sampleGaps\": ").append(stats.integrity.sampleGaps).append(",\n")
append(" \"sampleResets\": ").append(stats.integrity.sampleResets).append(",\n")
append(" \"droppedSamples\": ").append(stats.integrity.droppedSamples).append(",\n")
append(" \"loopOverruns\": ").append(stats.integrity.loopOverruns).append(",\n")
append(" \"queueOverflows\": ").append(status?.queueOverflows ?: "null").append(",\n")
append(" \"transportDisconnects\": ").append(status?.disconnects ?: "null").append(",\n")
append(" \"transportReplays\": ").append(status?.replays ?: "null").append(",\n")
append(" \"error\": ").append(error?.let { "\"${escape(it)}\"" } ?: "null").append("\n")
append("}\n")
}
FileOutputStream(summaryFile, false).use { summary ->
summary.write(json.toByteArray(Charsets.UTF_8))
summary.flush()
summary.fd.sync()
}
}
private fun escape(value: String): String = buildString {
value.forEach { character ->
when (character) {
'\\' -> append("\\\\")
'"' -> append("\\\"")
'\n' -> append("\\n")
'\r' -> append("\\r")
'\t' -> append("\\t")
else -> append(character)
}
}
}
private data class FrameClock(
val packetSequence: Long,
val deviceBaseTimestampUs: Long,
val phoneElapsedRealtimeNs: Long,
val phoneWallClockMs: Long,
) {
fun json(): String =
"{\"packetSequence\":$packetSequence," +
"\"deviceBaseTimestampUs\":$deviceBaseTimestampUs," +
"\"phoneElapsedRealtimeNs\":$phoneElapsedRealtimeNs," +
"\"phoneWallClockMs\":$phoneWallClockMs}"
}
}
@@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="48"
android:viewportHeight="48">
<path
android:fillColor="#00695C"
android:pathData="M4,4h40v40h-40z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M10,12h28v6h-11v20h-6v-20h-11z" />
</vector>
@@ -0,0 +1,5 @@
<resources>
<string name="app_name">Trikke Recorder</string>
<string name="notification_channel_name">Ride recording</string>
<string name="export_capture">Export last .trk</string>
</resources>
@@ -0,0 +1,8 @@
<resources>
<style name="Theme.TrikkeRecorder" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:colorAccent">#00695C</item>
<item name="android:navigationBarColor">#10201D</item>
<item name="android:statusBarColor">#004D40</item>
</style>
</resources>
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<exclude domain="root" path="." />
</cloud-backup>
<device-transfer>
<exclude domain="root" path="." />
</device-transfer>
</data-extraction-rules>
@@ -0,0 +1,137 @@
package com.jsjdesigns.trikkerecorder.protocol
import com.jsjdesigns.trikkerecorder.storage.CommitResult
import com.jsjdesigns.trikkerecorder.storage.SessionRecorder
import java.nio.file.Files
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ProtocolContractTest {
private val fixture: ByteArray by lazy {
checkNotNull(javaClass.classLoader?.getResourceAsStream("ble_mtu_race_4bf00eb.trk")) {
"Hardware fixture was not packaged as a test resource"
}.use { it.readBytes() }
}
@Test
fun hardwareFixtureMatchesAndroidParserContract() {
val frames = splitFrames(fixture)
assertTrue(frames.size > 100)
assertTrue(frames.all { TrkProtocol.parse(it) != null })
assertTrue(frames.any { TrkProtocol.parse(it)?.packetType == TrkProtocol.PACKET_METADATA })
assertTrue(frames.any { TrkProtocol.parse(it)?.packetType == TrkProtocol.PACKET_SAMPLES })
assertTrue(frames.any { TrkProtocol.parse(it)?.packetType == TrkProtocol.PACKET_STATUS })
}
@Test
fun crcDamageIsRejected() {
val raw = splitFrames(fixture).first().copyOf()
raw[raw.lastIndex] = (raw.last().toInt() xor 0x80).toByte()
assertNull(TrkProtocol.parse(raw))
}
@Test
fun fragmentReplayRestartsAndCompletesExactFrame() {
val raw = splitFrames(fixture).first()
val sequence = raw.u32(12)
val reassembler = BleFrameReassembler()
assertNull(reassembler.feed(fragment(raw, sequence, 0, 20)))
assertNull(reassembler.feed(fragment(raw, sequence, 0, 40)))
val completed = reassembler.feed(fragment(raw, sequence, 40, raw.size - 40))
assertArrayEquals(raw, completed)
assertEquals(0, reassembler.rejectedFragmentCount)
assertArrayEquals(
byteArrayOf('A'.code.toByte(), 'C'.code.toByte(), 'K'.code.toByte(), '1'.code.toByte()) +
raw.copyOfRange(12, 16),
BleFrameReassembler.encodeAck(sequence),
)
}
@Test
fun missingOrOutOfOrderFragmentIsRejected() {
val raw = splitFrames(fixture).first()
val sequence = raw.u32(12)
val reassembler = BleFrameReassembler()
assertNull(reassembler.feed(fragment(raw, sequence, 20, 20)))
assertEquals(1, reassembler.rejectedFragmentCount)
}
@Test
fun persistencePrecedesDedupeAndSummaryClosure() {
val parsed = checkNotNull(TrkProtocol.parse(splitFrames(fixture).first()))
val directory = Files.createTempDirectory("trikke-recorder-test").toFile()
try {
val recorder = SessionRecorder(directory, "ride_test", 1_000, 2_000, 0x0102030405060708)
assertFalse(recorder.summaryFile.readText().contains("\"complete\": true"))
assertEquals(CommitResult.PERSISTED, recorder.commit(parsed, 3_000, 4_000))
assertEquals(CommitResult.DUPLICATE, recorder.commit(parsed, 5_000, 6_000))
assertArrayEquals(parsed.raw, recorder.captureFile.readBytes())
val conflicting = parsed.copy(raw = parsed.raw.copyOf().also { it[it.lastIndex]++ })
assertEquals(CommitResult.CONFLICT, recorder.commit(conflicting, 7_000, 8_000))
assertArrayEquals(parsed.raw, recorder.captureFile.readBytes())
recorder.close(9_000, 10_000)
val summary = recorder.summaryFile.readText()
assertTrue(summary.contains("\"complete\": true"))
assertTrue(summary.contains("\"sessionTokenHex\": \"0102030405060708\""))
assertTrue(summary.contains("\"frames\": 1"))
assertTrue(summary.contains("\"duplicateReplays\": 1"))
} finally {
directory.deleteRecursively()
}
}
@Test
fun unsignedSequenceClassificationHandlesWrapAndReset() {
assertEquals(0L to 0L, IntegrityTracker.classify(0xffff_ffffL, 0))
assertEquals(2L to 0L, IntegrityTracker.classify(0xffff_fffeL, 1))
assertEquals(0L to 1L, IntegrityTracker.classify(1_000, 0))
}
@Test
fun beginSessionCommandCarriesStableUnsignedToken() {
assertArrayEquals(
byteArrayOf(
'B'.code.toByte(), 'G'.code.toByte(), 'N'.code.toByte(), '1'.code.toByte(),
0x08, 0x07, 0x06, 0x05, 0x04, 0x03, 0x02, 0x01,
),
BleFrameReassembler.encodeBeginSession(0x0102030405060708),
)
}
private fun splitFrames(stream: ByteArray): List<ByteArray> {
val frames = mutableListOf<ByteArray>()
var offset = 0
while (offset < stream.size) {
assertTrue("truncated header at $offset", offset + TrkProtocol.HEADER_SIZE <= stream.size)
val payloadSize = stream.u16(offset + 10)
val size = TrkProtocol.HEADER_SIZE + payloadSize
assertTrue("truncated frame at $offset", offset + size <= stream.size)
frames += stream.copyOfRange(offset, offset + size)
offset += size
}
assertEquals(stream.size, offset)
return frames
}
private fun fragment(raw: ByteArray, sequence: Long, offset: Int, size: Int): ByteArray {
val envelope = ByteArray(BleFrameReassembler.HEADER_SIZE)
envelope[0] = sequence.toByte()
envelope[1] = (sequence ushr 8).toByte()
envelope[2] = (sequence ushr 16).toByte()
envelope[3] = (sequence ushr 24).toByte()
envelope[4] = offset.toByte()
envelope[5] = (offset ushr 8).toByte()
envelope[6] = raw.size.toByte()
envelope[7] = (raw.size ushr 8).toByte()
return envelope + raw.copyOfRange(offset, offset + size)
}
}