feat(train): reminders to move and workouts suggested from the steps (ADR 0037)
Settings' Moving more, on Android: a reminder to get up after an idle time, and a suggested walk or run, each off until turned on (then every 30 minutes, weekdays 9:00 to 18:00, not 12:00 to 13:00, each value the person's), turning on asking for the activity and notification permissions. A WorkManager job every 15 minutes reads the phone's step counter and keeps two hours of readings. It reminds where fewer than 50 steps came over the idle time, all of it within the hours and outside the break, on the person's days, never while Train records or the phone is in a car (Android's car mode, or a car's Bluetooth audio or hands-free). It suggests a walk or a run where the last span averaged a walking or running cadence, once an hour; tapped, the Record sheet for it opens. The decisions are pure Kotlin (Movement.kt), tested on the JVM; the notifications' words are the app's, written into the job's configuration as the person's app opens. Polar's SDK sets neither the band's inactivity alert nor tells of one without an all-day connection, hence the phone's steps (ADR 0037). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
parent
3f7b409e80
commit
e4585b478d
5 changed files with 1882 additions and 30 deletions
|
|
@ -36,12 +36,16 @@ class MainActivity : FlutterActivity() {
|
|||
/** The recording's notification tapped: the recording's screen (ADR 0030, D21). */
|
||||
private val recordingOpenings = RecordingOpenings()
|
||||
|
||||
/** A suggested workout's notification tapped: the Record sheet for it (ADR 0037, D2). */
|
||||
private val movementOpenings = MovementOpenings()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
health.onIntent(intent)
|
||||
// Not again where Android makes the activity anew with the same intent.
|
||||
if (savedInstanceState == null) files.onIntent(intent)
|
||||
recordingOpenings.onIntent(intent)
|
||||
movementOpenings.onIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
|
|
@ -49,6 +53,7 @@ class MainActivity : FlutterActivity() {
|
|||
health.onIntent(intent)
|
||||
files.onIntent(intent)
|
||||
recordingOpenings.onIntent(intent)
|
||||
movementOpenings.onIntent(intent)
|
||||
}
|
||||
|
||||
@Deprecated("FlutterActivity has no result registry")
|
||||
|
|
@ -125,6 +130,15 @@ class MainActivity : FlutterActivity() {
|
|||
EventChannel(messenger, "app.halkyon.train/files/opened").setStreamHandler(files.opened)
|
||||
EventChannel(messenger, "app.halkyon.train/recorder/opened")
|
||||
.setStreamHandler(recordingOpenings.opened)
|
||||
// Reminders to move and suggested workouts (ADR 0037).
|
||||
MethodChannel(messenger, "app.halkyon.train/movement").setMethodCallHandler { call, result ->
|
||||
when (call.method) {
|
||||
"available" -> result.success(MovementWorker.available(this))
|
||||
"configure" -> result.success(MovementWorker.schedule(this, call.arguments<Map<String, Any?>>()).let { null })
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
EventChannel(messenger, "app.halkyon.train/movement/opened").setStreamHandler(movementOpenings.opened)
|
||||
MethodChannel(messenger, "app.halkyon.train/music").setMethodCallHandler(music)
|
||||
EventChannel(messenger, "app.halkyon.train/music/fetched").setStreamHandler(music.fetched)
|
||||
EventChannel(messenger, "app.halkyon.train/recorder/lines").setStreamHandler(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,133 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
|
||||
/**
|
||||
* What the person chose for reminders to move and suggested workouts (ADR
|
||||
* 0037, D3): the times are minutes of the day, the days ISO's (1 Monday to
|
||||
* 7 Sunday).
|
||||
*/
|
||||
data class MovementSettings(
|
||||
val remind: Boolean,
|
||||
val idleMinutes: Int,
|
||||
val from: Int,
|
||||
val until: Int,
|
||||
val breakFrom: Int,
|
||||
val breakUntil: Int,
|
||||
val days: Set<Int>,
|
||||
val suggest: Boolean,
|
||||
)
|
||||
|
||||
/** The step counter's count at a moment (it counts up from the phone's start). */
|
||||
data class StepReading(val atMs: Long, val steps: Long)
|
||||
|
||||
/** What the job keeps between its runs: the last readings, and when it last reminded and suggested. */
|
||||
data class MovementState(
|
||||
val readings: List<StepReading> = emptyList(),
|
||||
val remindedMs: Long? = null,
|
||||
val suggestedMs: Long? = null,
|
||||
)
|
||||
|
||||
/** A nudge to the person: get up, or record what they seem to be doing. */
|
||||
sealed interface MovementNudge {
|
||||
data object Remind : MovementNudge
|
||||
|
||||
/** [activity] as the app names it: `walking` or `running`. */
|
||||
data class Suggest(val activity: String) : MovementNudge
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether to remind or suggest, from the step counter's readings (ADR 0037,
|
||||
* D1, D2): pure, so it is tested on the JVM.
|
||||
*/
|
||||
object Movement {
|
||||
/** Fewer steps than this over the idle time is idle. */
|
||||
const val IDLE_STEPS = 50L
|
||||
|
||||
/** Steps a minute, averaged over the last span, for a walk and for a run. */
|
||||
const val WALKING = 80.0
|
||||
const val RUNNING = 140.0
|
||||
|
||||
/** The span a suggestion averages over: about one run of the job. */
|
||||
private const val SPAN_MIN_MS = 10 * 60_000L
|
||||
private const val SPAN_MAX_MS = 25 * 60_000L
|
||||
|
||||
/** At most one suggestion an hour. */
|
||||
const val SUGGEST_GAP_MS = 60 * 60_000L
|
||||
|
||||
/** How long readings are kept. */
|
||||
const val KEEP_MS = 2 * 60 * 60_000L
|
||||
|
||||
/** The job's runs drift: a reading this much short of the idle time still counts. */
|
||||
private const val SLACK_MS = 2 * 60_000L
|
||||
|
||||
/**
|
||||
* [state] with [reading] added, those older than [KEEP_MS] let go, and all
|
||||
* before it where the counter went back (the phone started again).
|
||||
*/
|
||||
fun withReading(state: MovementState, reading: StepReading): MovementState {
|
||||
val restarted = state.readings.lastOrNull()?.let { it.steps > reading.steps } == true
|
||||
val kept = if (restarted) emptyList() else state.readings.filter { reading.atMs - it.atMs in 0..KEEP_MS }
|
||||
return state.copy(readings = kept + reading)
|
||||
}
|
||||
|
||||
/**
|
||||
* The nudge for now, the newest of [state]'s readings being now's; null for
|
||||
* none. A suggestion goes before a reminder.
|
||||
*/
|
||||
fun decide(
|
||||
state: MovementState,
|
||||
settings: MovementSettings,
|
||||
zone: ZoneId,
|
||||
recording: Boolean,
|
||||
inCar: Boolean,
|
||||
): MovementNudge? {
|
||||
val now = state.readings.lastOrNull() ?: return null
|
||||
if (recording || inCar) return null
|
||||
return suggestion(state, settings, now) ?: reminder(state, settings, zone, now)
|
||||
}
|
||||
|
||||
/** [state] once [nudge] was given at [atMs]. */
|
||||
fun after(state: MovementState, nudge: MovementNudge, atMs: Long): MovementState =
|
||||
when (nudge) {
|
||||
MovementNudge.Remind -> state.copy(remindedMs = atMs)
|
||||
is MovementNudge.Suggest -> state.copy(suggestedMs = atMs)
|
||||
}
|
||||
|
||||
private fun suggestion(state: MovementState, settings: MovementSettings, now: StepReading): MovementNudge? {
|
||||
if (!settings.suggest) return null
|
||||
if (state.suggestedMs?.let { now.atMs - it < SUGGEST_GAP_MS } == true) return null
|
||||
val before =
|
||||
state.readings.lastOrNull { now.atMs - it.atMs in SPAN_MIN_MS..SPAN_MAX_MS } ?: return null
|
||||
val perMinute = (now.steps - before.steps) / ((now.atMs - before.atMs) / 60_000.0)
|
||||
return when {
|
||||
perMinute >= RUNNING -> MovementNudge.Suggest("running")
|
||||
perMinute >= WALKING -> MovementNudge.Suggest("walking")
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
private fun reminder(
|
||||
state: MovementState,
|
||||
settings: MovementSettings,
|
||||
zone: ZoneId,
|
||||
now: StepReading,
|
||||
): MovementNudge? {
|
||||
if (!settings.remind) return null
|
||||
val idleMs = settings.idleMinutes * 60_000L
|
||||
if (state.remindedMs?.let { now.atMs - it < idleMs } == true) return null
|
||||
val since = state.readings.lastOrNull { it.atMs <= now.atMs - idleMs + SLACK_MS } ?: return null
|
||||
if (now.steps - since.steps >= IDLE_STEPS) return null
|
||||
// All of the idle time within the person's hours, on their day, and none of it in their break.
|
||||
val start = Instant.ofEpochMilli(since.atMs).atZone(zone)
|
||||
val end = Instant.ofEpochMilli(now.atMs).atZone(zone)
|
||||
if (start.toLocalDate() != end.toLocalDate()) return null
|
||||
if (end.dayOfWeek.value !in settings.days) return null
|
||||
val startMinute = start.hour * 60 + start.minute
|
||||
val endMinute = end.hour * 60 + end.minute
|
||||
if (startMinute < settings.from || endMinute > settings.until) return null
|
||||
if (startMinute < settings.breakUntil && endMinute > settings.breakFrom) return null
|
||||
return MovementNudge.Remind
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import android.content.Intent
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
|
||||
/**
|
||||
* A suggested workout's notification tapped (ADR 0037, D2): the app opens the
|
||||
* Record sheet for its activity. The tap is handed to the app as it listens
|
||||
* (now, or once it does, after a cold start).
|
||||
*/
|
||||
class MovementOpenings {
|
||||
private var sink: EventChannel.EventSink? = null
|
||||
private var waiting: String? = null
|
||||
|
||||
val opened =
|
||||
object : EventChannel.StreamHandler {
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink) {
|
||||
sink = events
|
||||
waiting?.let {
|
||||
waiting = null
|
||||
events.success(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancel(arguments: Any?) {
|
||||
sink = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Takes in [intent]'s suggested activity, if it has one. */
|
||||
fun onIntent(intent: Intent?) {
|
||||
val activity = intent?.getStringExtra(MovementWorker.EXTRA_SUGGESTED) ?: return
|
||||
// Asked once: the activity made anew with the same intent asks no more.
|
||||
intent.removeExtra(MovementWorker.EXTRA_SUGGESTED)
|
||||
sink?.success(activity) ?: run { waiting = activity }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,279 @@
|
|||
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.UiModeManager
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothClass
|
||||
import android.bluetooth.BluetoothDevice
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.res.Configuration
|
||||
import android.hardware.Sensor
|
||||
import android.hardware.SensorEvent
|
||||
import android.hardware.SensorEventListener
|
||||
import android.hardware.SensorManager
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import java.io.File
|
||||
import java.time.ZoneId
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.resume
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
/**
|
||||
* Every 15 minutes while reminders or suggestions are on (ADR 0037, D1,
|
||||
* D2): reads the step counter, keeps the last readings, and reminds the
|
||||
* person to move or suggests the workout they seem to be doing, as
|
||||
* [Movement] decides, in the words the app wrote into its configuration (D4).
|
||||
*/
|
||||
class MovementWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) {
|
||||
override suspend fun doWork(): Result {
|
||||
val context = applicationContext
|
||||
val config = configOf(context) ?: return Result.success()
|
||||
val steps = stepCount(context) ?: return Result.success()
|
||||
val stateFile = File(context.filesDir, STATE)
|
||||
val now = System.currentTimeMillis()
|
||||
var state = Movement.withReading(stateOf(stateFile), StepReading(now, steps))
|
||||
val nudge =
|
||||
Movement.decide(
|
||||
state,
|
||||
config.settings,
|
||||
ZoneId.systemDefault(),
|
||||
recording = RecorderService.isRecording(),
|
||||
inCar = inCar(context),
|
||||
)
|
||||
if (nudge != null && notify(context, config.texts, nudge)) {
|
||||
state = Movement.after(state, nudge, now)
|
||||
}
|
||||
stateFile.writeText(jsonOf(state).toString())
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
/** The person's choices and the app's words for the notifications. */
|
||||
private class Config(val settings: MovementSettings, val texts: JSONObject)
|
||||
|
||||
companion object {
|
||||
private const val CONFIG = "movement.json"
|
||||
private const val STATE = "movement_state.json"
|
||||
private const val WORK = "movement"
|
||||
private const val CHANNEL = "movement"
|
||||
private const val REMINDER_ID = 4101
|
||||
private const val SUGGESTION_ID = 4102
|
||||
|
||||
/** The extra of a suggestion's intent: the activity to open the Record sheet for. */
|
||||
const val EXTRA_SUGGESTED = "app.halkyon.train.SUGGESTED_ACTIVITY"
|
||||
|
||||
/** Whether the phone counts steps at all. */
|
||||
fun available(context: Context): Boolean =
|
||||
context.getSystemService(SensorManager::class.java)?.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) != null
|
||||
|
||||
/**
|
||||
* Runs every 15 minutes with [config] (`settings`, `texts`), or no
|
||||
* longer where it is null or turns both off.
|
||||
*/
|
||||
fun schedule(context: Context, config: Map<String, Any?>?) {
|
||||
val file = File(context.filesDir, CONFIG)
|
||||
val work = WorkManager.getInstance(context)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val settings = config?.get("settings") as Map<String, Any?>?
|
||||
if (settings == null || (settings["remind"] != true && settings["suggest"] != true)) {
|
||||
file.delete()
|
||||
File(context.filesDir, STATE).delete()
|
||||
work.cancelUniqueWork(WORK)
|
||||
return
|
||||
}
|
||||
file.writeText(JsonText.of(config))
|
||||
work.enqueueUniquePeriodicWork(
|
||||
WORK,
|
||||
// A new config is read by the next run; the schedule stays.
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<MovementWorker>(15, TimeUnit.MINUTES).build(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun configOf(context: Context): Config? {
|
||||
val file = File(context.filesDir, CONFIG)
|
||||
if (!file.exists()) return null
|
||||
return runCatching {
|
||||
val json = JSONObject(file.readText())
|
||||
val settings = json.getJSONObject("settings")
|
||||
val days = settings.getJSONArray("days")
|
||||
Config(
|
||||
MovementSettings(
|
||||
remind = settings.getBoolean("remind"),
|
||||
idleMinutes = settings.getInt("idleMinutes"),
|
||||
from = settings.getInt("from"),
|
||||
until = settings.getInt("until"),
|
||||
breakFrom = settings.getInt("breakFrom"),
|
||||
breakUntil = settings.getInt("breakUntil"),
|
||||
days = (0 until days.length()).map(days::getInt).toSet(),
|
||||
suggest = settings.getBoolean("suggest"),
|
||||
),
|
||||
json.getJSONObject("texts"),
|
||||
)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun stateOf(file: File): MovementState =
|
||||
runCatching {
|
||||
val json = JSONObject(file.readText())
|
||||
val readings = json.getJSONArray("readings")
|
||||
MovementState(
|
||||
readings =
|
||||
(0 until readings.length()).map {
|
||||
val reading = readings.getJSONArray(it)
|
||||
StepReading(reading.getLong(0), reading.getLong(1))
|
||||
},
|
||||
remindedMs = json.optLong("remindedMs").takeIf { json.has("remindedMs") },
|
||||
suggestedMs = json.optLong("suggestedMs").takeIf { json.has("suggestedMs") },
|
||||
)
|
||||
}.getOrDefault(MovementState())
|
||||
|
||||
private fun jsonOf(state: MovementState): JSONObject =
|
||||
JSONObject().apply {
|
||||
put("readings", JSONArray(state.readings.map { JSONArray(listOf(it.atMs, it.steps)) }))
|
||||
state.remindedMs?.let { put("remindedMs", it) }
|
||||
state.suggestedMs?.let { put("suggestedMs", it) }
|
||||
}
|
||||
|
||||
/** The step counter's count now; null without the sensor, the permission or an answer within 10 s. */
|
||||
private suspend fun stepCount(context: Context): Long? {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !granted(context, Manifest.permission.ACTIVITY_RECOGNITION)) {
|
||||
return null
|
||||
}
|
||||
val manager = context.getSystemService(SensorManager::class.java) ?: return null
|
||||
val sensor = manager.getDefaultSensor(Sensor.TYPE_STEP_COUNTER) ?: return null
|
||||
return withTimeoutOrNull(TimeUnit.SECONDS.toMillis(10)) {
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val listener =
|
||||
object : SensorEventListener {
|
||||
override fun onSensorChanged(event: SensorEvent) {
|
||||
manager.unregisterListener(this)
|
||||
if (continuation.isActive) continuation.resume(event.values[0].toLong())
|
||||
}
|
||||
|
||||
override fun onAccuracyChanged(sensor: Sensor, accuracy: Int) {}
|
||||
}
|
||||
manager.registerListener(listener, sensor, SensorManager.SENSOR_DELAY_NORMAL)
|
||||
continuation.invokeOnCancellation { manager.unregisterListener(listener) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the phone is in a car (D1): Android's car mode, or a car's
|
||||
* audio or hands-free connected over Bluetooth.
|
||||
*/
|
||||
private suspend fun inCar(context: Context): Boolean {
|
||||
val ui = context.getSystemService(UiModeManager::class.java)
|
||||
if (ui?.currentModeType == Configuration.UI_MODE_TYPE_CAR) return true
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && !granted(context, Manifest.permission.BLUETOOTH_CONNECT)) {
|
||||
return false
|
||||
}
|
||||
val adapter = context.getSystemService(BluetoothManager::class.java)?.adapter ?: return false
|
||||
if (!adapter.isEnabled) return false
|
||||
for (profile in listOf(BluetoothProfile.A2DP, BluetoothProfile.HEADSET)) {
|
||||
val devices = withTimeoutOrNull(TimeUnit.SECONDS.toMillis(3)) { connected(context, adapter, profile) }
|
||||
if (devices.orEmpty().any(::isCar)) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission") // Checked by inCar.
|
||||
private fun isCar(device: BluetoothDevice): Boolean =
|
||||
when (device.bluetoothClass?.deviceClass) {
|
||||
BluetoothClass.Device.AUDIO_VIDEO_CAR_AUDIO,
|
||||
BluetoothClass.Device.AUDIO_VIDEO_HANDSFREE -> true
|
||||
else -> false
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission") // Checked by inCar.
|
||||
private suspend fun connected(context: Context, adapter: BluetoothAdapter, profile: Int): List<BluetoothDevice> =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
val asked =
|
||||
adapter.getProfileProxy(
|
||||
context,
|
||||
object : BluetoothProfile.ServiceListener {
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
val devices = runCatching { proxy.connectedDevices }.getOrDefault(emptyList())
|
||||
adapter.closeProfileProxy(profile, proxy)
|
||||
if (continuation.isActive) continuation.resume(devices)
|
||||
}
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {}
|
||||
},
|
||||
profile,
|
||||
)
|
||||
if (!asked && continuation.isActive) continuation.resume(emptyList())
|
||||
}
|
||||
|
||||
/** Shows [nudge]; whether it could (notifications allowed). */
|
||||
@SuppressLint("MissingPermission") // areNotificationsEnabled is checked.
|
||||
private fun notify(context: Context, texts: JSONObject, nudge: MovementNudge): Boolean {
|
||||
val notifications = NotificationManagerCompat.from(context)
|
||||
if (!notifications.areNotificationsEnabled()) return false
|
||||
context
|
||||
.getSystemService(NotificationManager::class.java)
|
||||
.createNotificationChannel(
|
||||
NotificationChannel(CHANNEL, texts.optString("channel", "Moving more"), NotificationManager.IMPORTANCE_DEFAULT),
|
||||
)
|
||||
val (id, title, text, activity) =
|
||||
when (nudge) {
|
||||
MovementNudge.Remind -> Quad(REMINDER_ID, texts.optString("remindTitle"), texts.optString("remindText"), null)
|
||||
is MovementNudge.Suggest ->
|
||||
Quad(
|
||||
SUGGESTION_ID,
|
||||
texts.optString("${nudge.activity}Title"),
|
||||
texts.optString("suggestText"),
|
||||
nudge.activity,
|
||||
)
|
||||
}
|
||||
val open =
|
||||
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { intent ->
|
||||
if (activity != null) intent.putExtra(EXTRA_SUGGESTED, activity)
|
||||
PendingIntent.getActivity(
|
||||
context,
|
||||
id,
|
||||
intent,
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
}
|
||||
runCatching {
|
||||
notifications.notify(
|
||||
id,
|
||||
NotificationCompat.Builder(context, CHANNEL)
|
||||
.setSmallIcon(R.drawable.ic_stat_recording)
|
||||
.setContentTitle(title)
|
||||
.setContentText(text)
|
||||
.setContentIntent(open)
|
||||
.setAutoCancel(true)
|
||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||
.build(),
|
||||
)
|
||||
}.onFailure { Log.i("Movement", "Not shown: ${it.javaClass.simpleName}") }
|
||||
return true
|
||||
}
|
||||
|
||||
private fun granted(context: Context, permission: String): Boolean =
|
||||
ContextCompat.checkSelfPermission(context, permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private data class Quad(val id: Int, val title: String, val text: String, val activity: String?)
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
package app.halkyon.train
|
||||
|
||||
import java.time.LocalDateTime
|
||||
import java.time.ZoneId
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** Reminders to move and workouts suggested from the step counter (ADR 0037). */
|
||||
class MovementTest {
|
||||
private val zone = ZoneId.of("Europe/Berlin")
|
||||
private val settings =
|
||||
MovementSettings(
|
||||
remind = true,
|
||||
idleMinutes = 30,
|
||||
from = 9 * 60,
|
||||
until = 18 * 60,
|
||||
breakFrom = 12 * 60,
|
||||
breakUntil = 13 * 60,
|
||||
days = setOf(1, 2, 3, 4, 5),
|
||||
suggest = true,
|
||||
)
|
||||
|
||||
/** Monday 2 Nov 2026 at [hour]:[minute], Berlin's time. */
|
||||
private fun at(hour: Int, minute: Int = 0, day: Int = 2): Long =
|
||||
LocalDateTime.of(2026, 11, day, hour, minute).atZone(zone).toInstant().toEpochMilli()
|
||||
|
||||
/** The state after readings of [steps] at each of [times]. */
|
||||
private fun read(vararg readings: Pair<Long, Long>, state: MovementState = MovementState()): MovementState =
|
||||
readings.fold(state) { kept, (time, steps) -> Movement.withReading(kept, StepReading(time, steps)) }
|
||||
|
||||
private fun decide(
|
||||
state: MovementState,
|
||||
settings: MovementSettings = this.settings,
|
||||
recording: Boolean = false,
|
||||
inCar: Boolean = false,
|
||||
) = Movement.decide(state, settings, zone, recording, inCar)
|
||||
|
||||
@Test
|
||||
fun `still for the idle time within the hours, the person is reminded`() {
|
||||
val state = read(at(10) to 5000, at(10, 15) to 5010, at(10, 30) to 5020)
|
||||
assertEquals(MovementNudge.Remind, decide(state))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a reading a little short of the idle time still counts, as the job drifts`() {
|
||||
assertEquals(MovementNudge.Remind, decide(read(at(10, 1) to 5000, at(10, 30) to 5010)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `steps over the idle time, or too little known yet, remind nobody`() {
|
||||
assertNull(decide(read(at(10) to 5000, at(10, 30) to 5100)))
|
||||
assertNull(decide(read(at(10, 15) to 5000, at(10, 30) to 5000)))
|
||||
assertNull(decide(read(at(10, 30) to 5000)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reminded once each idle time, not at every run`() {
|
||||
val first = read(at(10) to 5000, at(10, 30) to 5000)
|
||||
val reminded = Movement.after(first, MovementNudge.Remind, at(10, 30))
|
||||
assertNull(decide(read(at(10, 45) to 5000, state = reminded)))
|
||||
assertEquals(MovementNudge.Remind, decide(read(at(10, 45) to 5000, at(11) to 5000, state = reminded)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `outside the hours, over the break, on another day or switched off, nobody is reminded`() {
|
||||
assertNull(decide(read(at(8, 30) to 0, at(9) to 0)), "idle since before nine")
|
||||
assertEquals(MovementNudge.Remind, decide(read(at(9) to 0, at(9, 30) to 0)))
|
||||
assertNull(decide(read(at(17, 45) to 0, at(18, 15) to 0)), "past six")
|
||||
assertNull(decide(read(at(11, 45) to 0, at(12, 15) to 0)), "into the break")
|
||||
assertNull(decide(read(at(12, 45) to 0, at(13, 15) to 0)), "out of the break")
|
||||
assertEquals(MovementNudge.Remind, decide(read(at(13) to 0, at(13, 30) to 0)))
|
||||
// Saturday 7 Nov.
|
||||
assertNull(decide(read(at(10, day = 7) to 0, at(10, 30, day = 7) to 0)))
|
||||
assertNull(decide(read(at(10) to 0, at(10, 30) to 0), settings.copy(remind = false)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `while recording or in a car, nothing is said`() {
|
||||
val idle = read(at(10) to 0, at(10, 30) to 0)
|
||||
assertNull(decide(idle, recording = true))
|
||||
assertNull(decide(idle, inCar = true))
|
||||
val walking = read(at(10) to 0, at(10, 15) to 1500)
|
||||
assertNull(decide(walking, recording = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a walking or running cadence over the last span suggests the workout, once an hour`() {
|
||||
val walking = read(at(10) to 0, at(10, 15) to 1500)
|
||||
assertEquals(MovementNudge.Suggest("walking"), decide(walking))
|
||||
val running = read(at(10) to 0, at(10, 15) to 2400)
|
||||
assertEquals(MovementNudge.Suggest("running"), decide(running))
|
||||
assertNull(decide(read(at(10) to 0, at(10, 15) to 900)), "60 a minute: pottering about")
|
||||
assertNull(decide(walking, settings.copy(suggest = false)))
|
||||
|
||||
val suggested = Movement.after(walking, MovementNudge.Suggest("walking"), at(10, 15))
|
||||
assertNull(decide(read(at(10, 30) to 3000, state = suggested)))
|
||||
assertEquals(
|
||||
MovementNudge.Suggest("walking"),
|
||||
decide(read(at(10, 30) to 3000, at(11, 15) to 4500, at(11, 30) to 6000, state = suggested)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a span too short or too long suggests nothing`() {
|
||||
assertNull(decide(read(at(10) to 0, at(10, 5) to 600)))
|
||||
assertNull(decide(read(at(10) to 0, at(10, 40) to 4000)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `readings older than two hours, and those before the phone started again, are let go`() {
|
||||
val kept = read(at(8) to 100, at(9) to 200, at(10, 1) to 300)
|
||||
assertEquals(listOf(at(9), at(10, 1)), kept.readings.map { it.atMs })
|
||||
val restarted = read(at(10) to 5000, at(10, 15) to 20)
|
||||
assertEquals(listOf(StepReading(at(10, 15), 20)), restarted.readings)
|
||||
}
|
||||
|
||||
private fun assertNull(actual: Any?, message: String) = org.junit.Assert.assertNull(message, actual)
|
||||
}
|
||||
Loading…
Reference in a new issue