feat(train): recording a workout on Android (ADR 0030, D13, D16, D19, D21)
RecorderService, a foreground service of the types location and health started from the open app, takes fixes from LocationManager's GPS and steps from the step detector, pauses by itself (AutoPause, tested on the JVM) and appends each sample to the person's recording file, flushed every five seconds. MainActivity asks for location, notifications and, for auto-pause on foot, physical activity, never background location (D19), and bridges the service to the app. The app follows the file's lines with RecordingBloc and shows the moving time, the distance, the pace or speed and GPS's state; the notification shows the time and the distance with Pause or Resume. Stop is held down; a stopped recording is saved as a workout and its series, or discarded after asking, and one cut off or left unsaved is offered as the app opens. Home has the Record button where the device records. Three choices of the implementation's are recorded under D21 for review: rowing waits for 5f, auto-pause on foot without steps goes by GPS speed (below 2 km/h, on above 3 km/h), and Stop's hint shows above the button. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
aab25bf932
commit
7df1cb1740
7 changed files with 3360 additions and 27 deletions
|
|
@ -47,3 +47,11 @@ kotlin {
|
|||
flutter {
|
||||
source = "../.."
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// NotificationCompat and ServiceCompat for the recording's foreground service,
|
||||
// the version Learn's build uses.
|
||||
implementation("androidx.core:core:1.18.0")
|
||||
// The recorder's pure Kotlin parts (auto-pause) tested on the JVM.
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,20 @@
|
|||
<!-- The app talks to its instance: release builds need this too (Flutter's
|
||||
template grants it to debug and profile builds only). -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<!-- Recording a workout (ADR 0030, D13, D19): GPS while the app records in
|
||||
the foreground, never in the background; the step detector for
|
||||
auto-pause on foot; the recording's notification; the processor kept
|
||||
awake with the screen off. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH"/>
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK"/>
|
||||
<uses-feature android:name="android.hardware.location.gps" android:required="false"/>
|
||||
<uses-feature android:name="android.hardware.sensor.stepdetector" android:required="false"/>
|
||||
<application
|
||||
android:label="Halkyon Train"
|
||||
android:name="${applicationName}"
|
||||
|
|
@ -40,6 +54,10 @@
|
|||
<data android:scheme="app.halkyon.train" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<service
|
||||
android:name=".RecorderService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="location|health" />
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
|
|
|||
|
|
@ -0,0 +1,103 @@
|
|||
package app.halkyon.train
|
||||
|
||||
// Auto-pause by each activity's best signal (ADR 0030, D16), decided in the
|
||||
// service, which runs while the app may not: steps on foot, confirmed by GPS;
|
||||
// GPS speed on a bike; GPS speed on foot where steps aren't allowed (D19).
|
||||
// Times in milliseconds; each call returns true where it paused, false where
|
||||
// it went on, null where nothing changed.
|
||||
|
||||
/** Decides when a recording pauses by itself, and when it goes on. */
|
||||
class AutoPause(private val signal: Signal) {
|
||||
enum class Signal {
|
||||
/** On foot, by the step detector. */
|
||||
STEPS,
|
||||
|
||||
/** On foot, by GPS speed: paused below 2 km/h, going on above 3 km/h. */
|
||||
FOOT_SPEED,
|
||||
|
||||
/** By bike, by GPS speed: paused below 3 km/h, going on above 5 km/h. */
|
||||
BIKE_SPEED,
|
||||
}
|
||||
|
||||
/** Whether it paused by itself. */
|
||||
var paused = false
|
||||
private set
|
||||
|
||||
private var byHand = false
|
||||
private var lastStep: Long? = null
|
||||
private var slowSince: Long? = null
|
||||
private var lastFixAt: Long? = null
|
||||
private var lastFixFast = false
|
||||
|
||||
/** A step at [at]: two close together go on again (a lone one is a jostle). */
|
||||
fun step(at: Long): Boolean? {
|
||||
if (signal != Signal.STEPS || byHand) return null
|
||||
val before = lastStep
|
||||
lastStep = at
|
||||
if (paused && before != null && at - before <= STEPS_TO_GO_ON_MS) {
|
||||
paused = false
|
||||
return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** A fix at [at], moving at [metresPerSecond]. */
|
||||
fun speed(at: Long, metresPerSecond: Double): Boolean? {
|
||||
val kmh = metresPerSecond * 3.6
|
||||
lastFixAt = at
|
||||
if (signal == Signal.STEPS) {
|
||||
// Faster than walking: moving, whatever the steps say (a phone in a
|
||||
// bag counts fewer).
|
||||
lastFixFast = kmh > 5
|
||||
return null
|
||||
}
|
||||
if (byHand) return null
|
||||
val (below, above) = if (signal == Signal.BIKE_SPEED) 3.0 to 5.0 else 2.0 to 3.0
|
||||
if (paused) {
|
||||
if (kmh > above) {
|
||||
paused = false
|
||||
slowSince = null
|
||||
return false
|
||||
}
|
||||
return null
|
||||
}
|
||||
slowSince = if (kmh < below) slowSince ?: at else null
|
||||
return null
|
||||
}
|
||||
|
||||
/** Time passing, at [at]: where it pauses for having stood still long enough. */
|
||||
fun tick(at: Long): Boolean? {
|
||||
if (byHand || paused) return null
|
||||
val still =
|
||||
when (signal) {
|
||||
Signal.STEPS -> {
|
||||
// Starting counts as a step.
|
||||
val last = lastStep ?: at.also { lastStep = it }
|
||||
val fixFresh = lastFixAt?.let { at - it <= FRESH_FIX_MS } ?: false
|
||||
at - last >= STILL_MS && !(lastFixFast && fixFresh)
|
||||
}
|
||||
else -> slowSince?.let { at - it >= STILL_MS } ?: false
|
||||
}
|
||||
if (still) {
|
||||
paused = true
|
||||
return true
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** Paused, or gone on, by hand at [at]: left alone while paused, starting afresh after. */
|
||||
fun byHand(paused: Boolean, at: Long = 0) {
|
||||
byHand = paused
|
||||
if (!paused) {
|
||||
this.paused = false
|
||||
lastStep = at
|
||||
slowSince = null
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val STILL_MS = 5_000L
|
||||
const val STEPS_TO_GO_ON_MS = 2_000L
|
||||
const val FRESH_FIX_MS = 3_000L
|
||||
}
|
||||
}
|
||||
|
|
@ -1,12 +1,120 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import android.Manifest
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
/** The app's question for permissions, answered once Android has asked. */
|
||||
private var asking: Pair<List<String>, MethodChannel.Result>? = null
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
// Secrets halkyon_app shows kept out of screenshots and recents.
|
||||
ScreenGuard.attach(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||
ScreenGuard.attach(this, messenger)
|
||||
// The recording's side of the system (RecorderService).
|
||||
MethodChannel(messenger, "app.halkyon.train/recorder").setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"ask" -> ask(call.arguments<List<String>>() ?: emptyList(), result)
|
||||
"start" -> {
|
||||
RecorderService.start(
|
||||
this,
|
||||
path = call.argument<String>("path")!!,
|
||||
activity = call.argument<String>("activity")!!,
|
||||
autoPause = call.argument<String>("autoPause"),
|
||||
notice = call.argument<String>("notice")!!,
|
||||
)
|
||||
result.success(null)
|
||||
}
|
||||
"pause" -> result.success(RecorderService.pause().let { null })
|
||||
"resume" -> result.success(RecorderService.resume().let { null })
|
||||
"stop" -> result.success(RecorderService.stop().let { null })
|
||||
"show" -> result.success(RecorderService.show(call.arguments as String).let { null })
|
||||
"isRecording" -> result.success(RecorderService.isRecording())
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
// The service writes on this thread too, so no line comes between
|
||||
// the file read back and the ones after.
|
||||
RecorderService.replay(events::success)
|
||||
RecorderService.lines = { line -> runOnUiThread { events.success(line) } }
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
RecorderService.lines = null
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
RecorderService.lines = null
|
||||
super.cleanUpFlutterEngine(flutterEngine)
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks for the permissions [wanted] names (location, notifications,
|
||||
* activity) that aren't granted yet (ADR 0030, D19), and answers which are
|
||||
* granted afterwards.
|
||||
*/
|
||||
private fun ask(wanted: List<String>, result: MethodChannel.Result) {
|
||||
val permissions = wanted.flatMap(::permissionsOf)
|
||||
val missing = permissions.filter { checkSelfPermission(it) != PackageManager.PERMISSION_GRANTED }
|
||||
if (missing.isEmpty() || asking != null) {
|
||||
result.success(grantedOf(wanted))
|
||||
return
|
||||
}
|
||||
asking = wanted to result
|
||||
requestPermissions(missing.toTypedArray(), RECORDER_REQUEST)
|
||||
}
|
||||
|
||||
override fun onRequestPermissionsResult(
|
||||
requestCode: Int,
|
||||
permissions: Array<String>,
|
||||
grantResults: IntArray,
|
||||
) {
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||
if (requestCode != RECORDER_REQUEST) return
|
||||
asking?.let { (wanted, result) ->
|
||||
asking = null
|
||||
result.success(grantedOf(wanted))
|
||||
}
|
||||
}
|
||||
|
||||
private fun grantedOf(wanted: List<String>): Map<String, Boolean> =
|
||||
wanted.associateWith { name ->
|
||||
permissionsOf(name).all { checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED }
|
||||
}
|
||||
|
||||
/** Android's permissions behind each of the app's names; none where the system grants it by itself. */
|
||||
private fun permissionsOf(name: String): List<String> =
|
||||
when (name) {
|
||||
// Android 12 on asks for coarse with fine, and lets the person pick.
|
||||
"location" -> listOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION)
|
||||
"notifications" ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
listOf(Manifest.permission.POST_NOTIFICATIONS)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
"activity" ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
listOf(Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val RECORDER_REQUEST = 1
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,333 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.location.Location
|
||||
import android.location.LocationListener
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import android.os.PowerManager
|
||||
import android.os.SystemClock
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.File
|
||||
import java.io.Writer
|
||||
import kotlin.math.roundToLong
|
||||
import org.json.JSONObject
|
||||
|
||||
// The recording's side of the system (ADR 0030, D13, D16, D18, D19): a
|
||||
// foreground service, started from the open app, that takes GPS fixes from
|
||||
// Android's LocationManager (no Play services) and steps from the step
|
||||
// detector, pauses by itself, and appends each sample as a line to the
|
||||
// recording file, flushed every few seconds, so a recording survives the app
|
||||
// and is found cut off if the process doesn't. Each line goes to the app as
|
||||
// well while it listens; the app works out the totals and tells the service
|
||||
// what its notification shows (a notice, as JSON, in the app's language).
|
||||
|
||||
/** Records a workout into its file, in the foreground. */
|
||||
class RecorderService : Service(), LocationListener, SensorEventListener {
|
||||
companion object {
|
||||
private const val ACTION_START = "app.halkyon.train.recorder.START"
|
||||
private const val ACTION_PAUSE = "app.halkyon.train.recorder.PAUSE"
|
||||
private const val ACTION_RESUME = "app.halkyon.train.recorder.RESUME"
|
||||
private const val EXTRA_PATH = "path"
|
||||
private const val EXTRA_ACTIVITY = "activity"
|
||||
private const val EXTRA_AUTO_PAUSE = "autoPause"
|
||||
private const val EXTRA_NOTICE = "notice"
|
||||
private const val CHANNEL = "recording"
|
||||
private const val NOTIFICATION_ID = 5301
|
||||
private const val FLUSH_EVERY_MS = 5_000L
|
||||
|
||||
/** Hands each line written to the app; null while no app listens. */
|
||||
@Volatile var lines: ((String) -> Unit)? = null
|
||||
|
||||
/** The service while it records. */
|
||||
@Volatile private var running: RecorderService? = null
|
||||
|
||||
/** Whether a recording runs. */
|
||||
fun isRecording(): Boolean = running != null
|
||||
|
||||
/**
|
||||
* Starts recording [activity] into the file at [path], pausing by itself
|
||||
* by [autoPause] (null: never), its notification as [notice] says.
|
||||
*/
|
||||
fun start(context: Context, path: String, activity: String, autoPause: String?, notice: String) {
|
||||
ContextCompat.startForegroundService(
|
||||
context,
|
||||
Intent(context, RecorderService::class.java)
|
||||
.setAction(ACTION_START)
|
||||
.putExtra(EXTRA_PATH, path)
|
||||
.putExtra(EXTRA_ACTIVITY, activity)
|
||||
.putExtra(EXTRA_AUTO_PAUSE, autoPause)
|
||||
.putExtra(EXTRA_NOTICE, notice),
|
||||
)
|
||||
}
|
||||
|
||||
/** Pauses, or goes on, by hand. */
|
||||
fun pause() = running?.byHand(paused = true)
|
||||
|
||||
fun resume() = running?.byHand(paused = false)
|
||||
|
||||
/** Ends the recording: its last line written, the file closed, the service gone. */
|
||||
fun stop() = running?.finish()
|
||||
|
||||
/** Shows [notice] (JSON) in the recording's notification. */
|
||||
fun show(notice: String) = running?.present(JSONObject(notice))
|
||||
|
||||
/** Every line of the file so far, then each new one as it is written. */
|
||||
fun replay(to: (String) -> Unit) {
|
||||
running?.let { service ->
|
||||
service.writer?.flush()
|
||||
File(service.path).forEachLine(action = to)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var path: String
|
||||
private var writer: Writer? = null
|
||||
private var autoPause: AutoPause? = null
|
||||
private var pausedByHand = false
|
||||
private var notice: JSONObject? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var lastFlush = 0L
|
||||
|
||||
private val ticker =
|
||||
object : Runnable {
|
||||
override fun run() {
|
||||
val now = System.currentTimeMillis()
|
||||
autoPause?.tick(now)?.let { paused -> mark(paused, auto = true, at = now) }
|
||||
if (now - lastFlush >= FLUSH_EVERY_MS) {
|
||||
writer?.flush()
|
||||
lastFlush = now
|
||||
}
|
||||
handler.postDelayed(this, 1_000)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.action) {
|
||||
ACTION_START -> if (running == null) begin(intent)
|
||||
ACTION_PAUSE -> byHand(paused = true)
|
||||
ACTION_RESUME -> byHand(paused = false)
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
if (running === this) finish()
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission") // Each listener is asked for only where its permission is granted.
|
||||
private fun begin(intent: Intent) {
|
||||
path = intent.getStringExtra(EXTRA_PATH) ?: return stopSelf()
|
||||
val activity = intent.getStringExtra(EXTRA_ACTIVITY) ?: return stopSelf()
|
||||
val located = granted(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
val counted =
|
||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q || granted(Manifest.permission.ACTIVITY_RECOGNITION)
|
||||
notice = intent.getStringExtra(EXTRA_NOTICE)?.let(::JSONObject)
|
||||
channel(notice?.optString("channelName"))
|
||||
// In the foreground first: Android allows a few seconds for it.
|
||||
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification(), types(located, counted))
|
||||
running = this
|
||||
val now = System.currentTimeMillis()
|
||||
writer = File(path).apply { parentFile?.mkdirs() }.bufferedWriter().also {
|
||||
it.append("halkyon-train-recording 1 $activity $now\n")
|
||||
}
|
||||
autoPause =
|
||||
when (intent.getStringExtra(EXTRA_AUTO_PAUSE)) {
|
||||
// Steps where they may be counted, GPS speed where not (D19).
|
||||
"steps" -> AutoPause(if (counted) AutoPause.Signal.STEPS else AutoPause.Signal.FOOT_SPEED)
|
||||
"bike" -> AutoPause(AutoPause.Signal.BIKE_SPEED)
|
||||
else -> null
|
||||
}
|
||||
if (located) {
|
||||
getSystemService(LocationManager::class.java)
|
||||
.requestLocationUpdates(LocationManager.GPS_PROVIDER, 1_000L, 0f, this, Looper.getMainLooper())
|
||||
}
|
||||
if (counted && autoPause != null) {
|
||||
getSystemService(SensorManager::class.java).let { sensors ->
|
||||
sensors.getDefaultSensor(Sensor.TYPE_STEP_DETECTOR)?.let {
|
||||
sensors.registerListener(this, it, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
}
|
||||
}
|
||||
}
|
||||
// The ticker and the file need the processor with the screen off, as a
|
||||
// rower without GPS gives nothing else to wake it.
|
||||
wakeLock =
|
||||
getSystemService(PowerManager::class.java)
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "HalkyonTrain:recording")
|
||||
.apply { acquire(12 * 60 * 60 * 1_000L) }
|
||||
lastFlush = now
|
||||
handler.postDelayed(ticker, 1_000)
|
||||
}
|
||||
|
||||
/** The foreground service's types: location with GPS, health with the step detector (Android 14 on asks). */
|
||||
private fun types(located: Boolean, counted: Boolean): Int {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return 0
|
||||
var types = 0
|
||||
if (located) types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_LOCATION
|
||||
if (counted && Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
types = types or ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH
|
||||
}
|
||||
return types
|
||||
}
|
||||
|
||||
override fun onLocationChanged(location: Location) {
|
||||
val at = wallTime(location.elapsedRealtimeNanos)
|
||||
val altitude = if (location.hasAltitude()) (location.altitude * 100).roundToLong().toString() else "-"
|
||||
val accuracy = if (location.hasAccuracy()) (location.accuracy * 100).roundToLong() else 99_999L
|
||||
write(
|
||||
"p $at ${(location.latitude * 1e7).roundToLong()} ${(location.longitude * 1e7).roundToLong()} " +
|
||||
"$accuracy $altitude",
|
||||
)
|
||||
if (location.hasSpeed() && !pausedByHand) {
|
||||
autoPause?.speed(at, location.speed.toDouble())?.let { paused -> mark(paused, auto = true, at = at) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
if (event.sensor.type != Sensor.TYPE_STEP_DETECTOR) return
|
||||
val at = wallTime(event.timestamp)
|
||||
write("s $at")
|
||||
autoPause?.step(at)?.let { paused -> mark(paused, auto = true, at = at) }
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
|
||||
|
||||
/** Paused, or gone on, by hand. */
|
||||
private fun byHand(paused: Boolean) {
|
||||
if (running !== this) return
|
||||
val pausedNow = pausedByHand || autoPause?.paused == true
|
||||
if (paused == pausedNow) return
|
||||
val now = System.currentTimeMillis()
|
||||
pausedByHand = paused
|
||||
autoPause?.byHand(paused, now)
|
||||
mark(paused, auto = false, at = now)
|
||||
}
|
||||
|
||||
/** Writes a pause or its end, and shows it at once, the app there or not. */
|
||||
private fun mark(paused: Boolean, auto: Boolean, at: Long) {
|
||||
write("${if (paused) "pause" else "resume"} $at ${if (auto) "auto" else "manual"}")
|
||||
writer?.flush()
|
||||
repost()
|
||||
}
|
||||
|
||||
private fun write(line: String) {
|
||||
val writer = writer ?: return
|
||||
writer.append(line).append('\n')
|
||||
lines?.invoke(line)
|
||||
}
|
||||
|
||||
/** Ends the recording, once. */
|
||||
private fun finish() {
|
||||
if (running !== this) return
|
||||
running = null
|
||||
handler.removeCallbacks(ticker)
|
||||
getSystemService(LocationManager::class.java).removeUpdates(this)
|
||||
getSystemService(SensorManager::class.java).unregisterListener(this)
|
||||
write("end ${System.currentTimeMillis()}")
|
||||
writer?.close()
|
||||
writer = null
|
||||
wakeLock?.takeIf { it.isHeld }?.release()
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
private fun present(json: JSONObject) {
|
||||
notice = json
|
||||
repost()
|
||||
}
|
||||
|
||||
private fun repost() {
|
||||
NotificationManagerCompat.from(this).let {
|
||||
if (it.areNotificationsEnabled()) {
|
||||
try {
|
||||
it.notify(NOTIFICATION_ID, notification())
|
||||
} catch (_: SecurityException) {
|
||||
// Notifications not allowed: the recording runs on, unseen (D19).
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun notification(): android.app.Notification {
|
||||
val notice = notice
|
||||
val paused = pausedByHand || autoPause?.paused == true
|
||||
val builder =
|
||||
NotificationCompat.Builder(this, CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_stat_recording)
|
||||
.setContentTitle(notice?.optString("title") ?: "Halkyon Train")
|
||||
.setOngoing(true)
|
||||
.setSilent(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_WORKOUT)
|
||||
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.setContentIntent(openApp())
|
||||
notice?.optString("text")?.let(builder::setContentText)
|
||||
// The moving time counts on by itself while it moves, the app there or not.
|
||||
if (!paused && notice != null && notice.has("movingMs")) {
|
||||
builder
|
||||
.setUsesChronometer(true)
|
||||
.setWhen(notice.getLong("shownAt") - notice.getLong("movingMs"))
|
||||
.setShowWhen(true)
|
||||
} else {
|
||||
builder.setShowWhen(false)
|
||||
}
|
||||
if (paused) {
|
||||
builder.addAction(0, notice?.optString("resume") ?: "Resume", button(ACTION_RESUME))
|
||||
} else {
|
||||
builder.addAction(0, notice?.optString("pause") ?: "Pause", button(ACTION_PAUSE))
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun button(action: String): PendingIntent =
|
||||
PendingIntent.getService(
|
||||
this,
|
||||
action.hashCode(),
|
||||
Intent(this, RecorderService::class.java).setAction(action),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private fun openApp(): PendingIntent? =
|
||||
packageManager.getLaunchIntentForPackage(packageName)?.let {
|
||||
PendingIntent.getActivity(this, 0, it, PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT)
|
||||
}
|
||||
|
||||
/** The recording's channel, silent, named in the app's language. */
|
||||
private fun channel(name: String?) {
|
||||
getSystemService(NotificationManager::class.java)
|
||||
.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, name ?: "Recording", NotificationManager.IMPORTANCE_LOW)
|
||||
.apply { setShowBadge(false) },
|
||||
)
|
||||
}
|
||||
|
||||
private fun granted(permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(this, permission) == PackageManager.PERMISSION_GRANTED
|
||||
|
||||
/** The wall-clock time, in UTC milliseconds, of an instant on the elapsed-realtime clock. */
|
||||
private fun wallTime(elapsedNanos: Long): Long =
|
||||
System.currentTimeMillis() - (SystemClock.elapsedRealtimeNanos() - elapsedNanos) / 1_000_000
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- The recording's status bar icon: Lucide's bike, Train's own (ADR 0031),
|
||||
white on nothing, as Android tints it. -->
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M18.5,14A3.5,3.5 0,1 1,18.5 21A3.5,3.5 0,1 1,18.5 14ZM5.5,14A3.5,3.5 0,1 1,5.5 21A3.5,3.5 0,1 1,5.5 14ZM15,4A1,1 0,1 1,15 6A1,1 0,1 1,15 4ZM12,17.5V14l-3,-3 4,-3 2,3h2"
|
||||
android:strokeWidth="2"
|
||||
android:strokeColor="#FFFFFFFF"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round" />
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,87 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
// Auto-pause by each activity's best signal (ADR 0030, D16): steps on foot,
|
||||
// GPS speed on a bike, and GPS speed on foot where steps aren't allowed (D19).
|
||||
class AutoPauseTest {
|
||||
/** Feeds [steps] (seconds) to a pause on foot, ticking each second up to [until], and returns its changes by second. */
|
||||
private fun onFoot(steps: List<Int>, until: Int, fixes: Map<Int, Double> = emptyMap()): Map<Int, Boolean> {
|
||||
val pause = AutoPause(AutoPause.Signal.STEPS)
|
||||
val changes = mutableMapOf<Int, Boolean>()
|
||||
for (second in 0..until) {
|
||||
val at = second * 1000L
|
||||
fixes[second]?.let { kmh -> pause.speed(at, kmh / 3.6)?.let { changes[second] = it } }
|
||||
if (second in steps) pause.step(at)?.let { changes[second] = it }
|
||||
pause.tick(at)?.let { changes[second] = it }
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
private fun byBike(kmh: List<Double>, signal: AutoPause.Signal = AutoPause.Signal.BIKE_SPEED): Map<Int, Boolean> {
|
||||
val pause = AutoPause(signal)
|
||||
val changes = mutableMapOf<Int, Boolean>()
|
||||
kmh.forEachIndexed { second, speed ->
|
||||
pause.speed(second * 1000L, speed / 3.6)?.let { changes[second] = it }
|
||||
pause.tick(second * 1000L)?.let { changes[second] = it }
|
||||
}
|
||||
return changes
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on foot it pauses five seconds after the last step and goes on with two steps close together`() {
|
||||
val changes = onFoot(steps = listOf(0, 1, 2, 3, 12, 20, 21, 22), until = 25)
|
||||
|
||||
// Paused at 8 (no step since 3); a lone step at 12 is a jostle, not a start.
|
||||
assertEquals(mapOf(8 to true, 21 to false), changes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on foot GPS moving faster than walking keeps it going without steps`() {
|
||||
val changes = onFoot(
|
||||
steps = listOf(0, 1),
|
||||
until = 12,
|
||||
fixes = (0..12).associateWith { 12.0 },
|
||||
)
|
||||
|
||||
assertEquals(emptyMap<Int, Boolean>(), changes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `by bike it pauses below 3 kmh for five seconds and goes on above 5 kmh`() {
|
||||
val changes = byBike(listOf(20.0, 18.0, 2.0, 1.0, 0.0, 0.0, 0.0, 0.0, 4.0, 4.5, 6.0, 15.0))
|
||||
|
||||
assertEquals(mapOf(7 to true, 10 to false), changes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `by bike slowing down a moment pauses nothing`() {
|
||||
val changes = byBike(listOf(20.0, 2.0, 2.0, 2.0, 8.0, 2.0, 2.0, 2.0, 2.0, 9.0))
|
||||
|
||||
assertEquals(emptyMap<Int, Boolean>(), changes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `on foot without steps it pauses below 2 kmh and goes on above 3 kmh`() {
|
||||
val changes = byBike(listOf(6.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 2.5, 3.5), AutoPause.Signal.FOOT_SPEED)
|
||||
|
||||
assertEquals(mapOf(6 to true, 8 to false), changes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a pause by hand is left alone, and going on by hand starts afresh`() {
|
||||
val pause = AutoPause(AutoPause.Signal.STEPS)
|
||||
pause.step(0)
|
||||
pause.byHand(paused = true)
|
||||
|
||||
assertNull(pause.tick(10_000))
|
||||
assertNull(pause.step(11_000))
|
||||
assertNull(pause.step(11_500))
|
||||
|
||||
pause.byHand(paused = false, at = 12_000)
|
||||
assertNull(pause.tick(16_000))
|
||||
assertEquals(true, pause.tick(17_000))
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue