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
+124
View File
@@ -0,0 +1,124 @@
# Trikke Recorder for Android
Prototype v0.2 records the existing reliable BLE transport to an authoritative
`.trk` file on an Android phone. It deliberately does not filter, fuse, convert,
or upload telemetry.
The permanent Android application ID and Kotlin namespace are
`com.jsjdesigns.trikkerecorder`.
## Platform and behavior
- Android 12 (API 31) or newer
- BLE central connection to the `TrikkeSensor` service
- User-started connected-device foreground service
- Partial wake lock during an active recording
- App-private capture storage with explicit export through Android's document UI
- A `.session.json` sidecar containing phone wall/monotonic clock anchors and
final integrity counters
Every notification is reassembled using its packet sequence, offset, and total
size. A complete frame must also pass the TRK1 shape and CRC checks. For a new
frame the recorder then performs this ordering:
1. Append the unchanged frame to the `.trk` file.
2. Flush the stream and synchronize its file descriptor.
3. Update in-memory integrity state.
4. Write the exact `ACK1` packet sequence to the C3.
If the ACK is lost, the firmware replays the frame. An exact replay of the last
persisted packet is not appended again, but it is acknowledged again. The app
refuses to acknowledge a CRC-invalid frame or the same sequence carrying
different bytes.
At Start, the app generates a random session token and sends it to the Control
characteristic before subscribing. A new token makes the C3 perform one
controlled software restart. The app reconnects with the same token, after which
the C3 starts acquisition with empty queues and zeroed counters. Temporary BLE
reconnects during that recording reuse the token and therefore preserve queued
samples instead of resetting the session.
The validated phone/C3 pair takes about 10.8 seconds from tapping Start to the
first persisted frame because a new session deliberately includes a controlled
C3 restart and two connection passes. A panic, watchdog, brownout, or power-on
reset during recording invalidates the retained token; recovery adds another
controlled restart, and the resulting packet/sample sequence reset remains
visible in the sidecar integrity counters.
## Build and install
Android Studio is the easiest route: open the `android/` directory, allow the
Gradle sync, select the phone, and run the `app` configuration.
The command-line equivalent on this Mac is:
```sh
cd android
export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"
export ANDROID_HOME="$HOME/Library/Android/sdk"
./gradlew testDebugUnitTest lintDebug assembleDebug
"$ANDROID_HOME/platform-tools/adb" install -r app/build/outputs/apk/debug/app-debug.apk
```
The tests exercise the Android parser against the real
`ble_mtu_race_4bf00eb.trk` hardware fixture as well as fragment replay, malformed
ordering, CRC rejection, unsigned sequence wrap, durable append, and replay
deduplication.
## Record and export
1. Power the sensor and open **Trikke Recorder**.
2. Tap **Start recording** and grant Nearby Devices and notification permission.
3. Wait for `Recording: Connected and subscribed` and confirm that the sample
count is increasing.
4. The activity may be left, the screen may be locked, and the phone may be put
in a pocket. Keep the persistent recording notification active.
5. Reopen the app and tap **Stop and close safely**.
6. After the status reaches `Stopped`, tap **Export last .trk** and select a
destination.
The app-private `.session.json` starts with `complete: false`. A normal Stop
closes and synchronizes the binary file, then rewrites the sidecar with
`complete: true`, final counts, and first/last phone-to-device clock anchors. If
Android or the user force-stops the process, every previously acknowledged frame
remains in the `.trk` file, while the incomplete sidecar makes the abnormal end
visible.
Phone receipt time is only an alignment anchor; BLE delivery latency means it is
not the sensor's physical sample time. Analysis must continue to use the device
timestamps carried by each TRK1 frame.
## First coordinated acceptance test
Do this before a ride:
1. Record for two minutes with the enclosure flat and still.
2. Lock the phone for at least one minute and verify the sample counter resumes
visibly when the app is reopened.
3. Cause a roughly three-second outage by switching Bluetooth off, switch it
back on, and wait for `Recording` again.
4. Stop and export the `.trk` file.
5. Decode it from the repository root:
```sh
python3 tools/decode_binary.py ride_YYYYMMDD_HHMMSS.trk ride.csv
```
Acceptance requires a valid complete decode, no unexplained packet or sample
gaps, and no queue overflow for the short interruption. Duplicate replays and
the firmware disconnect/replay counters may increase and are expected.
After that passes, repeat for 1530 minutes with the screen locked and include
several short real-world range/interference interruptions.
## Prototype limitations
- The BLE service is still unauthenticated and single-connection, as documented
in `docs/ble-transport-v2.md`.
- The recorder supports one active session and one known sensor.
- Only the binary capture is exported from the UI in this pass. The sidecar is
retained app-private for diagnosis.
- A phone force-stop cannot run cleanup code. The persisted binary prefix remains
useful, but the session is intentionally marked incomplete.
- No GPS, Samsung Health import, CSV rendering, or live motion analysis is in
this milestone.
+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)
}
}
+3
View File
@@ -0,0 +1,3 @@
plugins {
id("com.android.application") version "9.2.1" apply false
}
+3
View File
@@ -0,0 +1,3 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/c2dd35c9d0aaf0ba6ad0791320f99dfc/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/e5810bd7fd1f8a586644409d395a7e55/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/cf726b4a1c84b50457225f9bba6d7650/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/fa1e318c287360478e3c83a9a3ef1007/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7b3c4877c0749019e6805bb61e421497/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/d76df094a9cbbabd3b08251f9e61444a/redirect
toolchainVersion=25
Binary file not shown.
+7
View File
@@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
Vendored Executable
+248
View File
@@ -0,0 +1,248 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+93
View File
@@ -0,0 +1,93 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+18
View File
@@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "TrikkeRecorder"
include(":app")