wip(watch): sauvegarde travail en cours lot critique (#157 #163 #164 #174 #175 #178)

This commit is contained in:
2026-07-29 12:34:07 +02:00
parent f523a5a8b5
commit 74d6000606
26 changed files with 516 additions and 62 deletions

View File

@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />

View File

@ -21,11 +21,11 @@ class WatchBridgeListenerService : WearableListenerService() {
override fun onMessageReceived(messageEvent: MessageEvent) {
WatchBridgePlugin.attachApplicationContext(applicationContext)
if (messageEvent.path != WatchBridgePlugin.ACK_PATH) {
return
}
val payload = JSONObject(String(messageEvent.data, StandardCharsets.UTF_8))
WatchBridgePlugin.emitAck(payload.toMap())
when (messageEvent.path) {
WatchBridgePlugin.ACK_PATH -> WatchBridgePlugin.emitAck(payload.toMap())
WatchBridgePlugin.ALERT_PATH -> WatchBridgePlugin.handleAlert(payload.toMap())
}
}
override fun onCapabilityChanged(capabilityInfo: CapabilityInfo) {

View File

@ -7,6 +7,9 @@ import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.VibrationEffect
import android.os.Vibrator
import android.os.VibratorManager
import android.util.Log
import com.google.android.gms.wearable.CapabilityClient
import com.google.android.gms.wearable.DataEvent
@ -26,12 +29,14 @@ object WatchBridgePlugin {
private const val PROJECTION_CHANNEL = "gametime.watch_bridge/projections"
private const val SENSOR_SAMPLE_CHANNEL = "gametime.watch_bridge/sensor_samples"
private const val ACK_CHANNEL = "gametime.watch_bridge/acks"
private const val ALERT_CHANNEL = "gametime.watch_bridge/alerts"
private const val CONNECTION_CHANNEL = "gametime.watch_bridge/connection"
const val COMMAND_PATH = "/gametime/watch/command"
const val SENSOR_SUMMARY_PATH = "/gametime/watch/sensor-summary"
const val SENSOR_SAMPLE_PATH = "/gametime/watch/sensor-sample"
const val ACK_PATH = "/gametime/phone/ack"
const val ALERT_PATH = "/gametime/phone/alert"
const val STATE_PATH = "/gametime/phone/projection"
const val PHONE_CAPABILITY = "gametime_phone_companion"
const val ACTION_OPEN_ACTIVE_SESSION = "com.gametime.watch.OPEN_ACTIVE_SESSION"
@ -45,6 +50,7 @@ object WatchBridgePlugin {
private var projectionSink: EventChannel.EventSink? = null
private var sensorSampleSink: EventChannel.EventSink? = null
private var ackSink: EventChannel.EventSink? = null
private var alertSink: EventChannel.EventSink? = null
private var connectionSink: EventChannel.EventSink? = null
private val mainHandler = Handler(Looper.getMainLooper())
private val heartRateCollector = WatchHeartRateCollector(
@ -94,6 +100,18 @@ object WatchBridgePlugin {
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, ALERT_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
alertSink = events
}
override fun onCancel(arguments: Any?) {
alertSink = null
}
},
)
EventChannel(flutterEngine.dartExecutor.binaryMessenger, SENSOR_SAMPLE_CHANNEL)
.setStreamHandler(
object : EventChannel.StreamHandler {
@ -194,6 +212,20 @@ object WatchBridgePlugin {
return true
}
fun emitAlert(payload: Map<String, Any?>): Boolean {
val sink = alertSink ?: return false
mainHandler.post {
sink.success(payload)
}
return true
}
fun handleAlert(payload: Map<String, Any?>) {
if (!emitAlert(payload)) {
triggerNativeAlertHaptic(payload)
}
}
fun emitSensorSample(payload: Map<String, Any?>): Boolean {
val sink = sensorSampleSink ?: return false
mainHandler.post {
@ -379,12 +411,7 @@ object WatchBridgePlugin {
return
}
val now = System.currentTimeMillis()
val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L
val delayMs = if (expiresAt > 0L) {
(expiresAt - now).coerceAtLeast(0L)
} else {
12000L
}
val delayMs = projectionTtlMs(projection)
val appContext = context.applicationContext
activeProjectionExpiryRunnable = Runnable {
if (!hasFreshActiveProjection()) {
@ -397,12 +424,43 @@ object WatchBridgePlugin {
private fun hasFreshActiveProjection(): Boolean {
val projection = lastActiveProjection ?: return false
val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L
val now = System.currentTimeMillis()
if (expiresAt > 0L) {
return now < expiresAt
return now - lastActiveProjectionReceivedAtEpochMs <= projectionTtlMs(projection)
}
private fun projectionTtlMs(projection: Map<String, Any?>): Long {
val projectedAt = (projection["projectedAtEpochMs"] as? Number)?.toLong() ?: 0L
val expiresAt = (projection["expiresAtEpochMs"] as? Number)?.toLong() ?: 0L
if (projectedAt > 0L && expiresAt > projectedAt) {
return expiresAt - projectedAt
}
return 12000L
}
private fun triggerNativeAlertHaptic(payload: Map<String, Any?>) {
if (payload["pattern"] != "timerFinished") {
return
}
val context = appContext ?: return
val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
context.getSystemService(VibratorManager::class.java)?.defaultVibrator
} else {
@Suppress("DEPRECATION")
context.getSystemService(Vibrator::class.java)
} ?: return
val timings = longArrayOf(0L, 180L, 120L, 260L)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
vibrator.vibrate(
VibrationEffect.createWaveform(
timings,
intArrayOf(0, 255, 0, 255),
-1,
),
)
} else {
@Suppress("DEPRECATION")
vibrator.vibrate(timings, -1)
}
return now - lastActiveProjectionReceivedAtEpochMs <= 12000L
}
private fun updateHeartRateCollection(context: Context, projection: Map<String, Any?>) {
@ -420,7 +478,7 @@ object WatchBridgePlugin {
WatchHeartRateForegroundService.stop(context)
heartRateCollector.noteActiveSession(
sessionId,
shouldAggregate = false,
shouldAggregate = shouldAggregate,
executionContext = telemetryContext(projection),
)
pendingSensorPermissionRequest = true

View File

@ -335,10 +335,10 @@ internal class WatchHeartRateCollector(
capabilities: androidx.health.services.client.data.ExerciseCapabilities,
): ExerciseConfig? {
val requestedTypes = listOf(
ExerciseType.WORKOUT,
ExerciseType.RUNNING,
ExerciseType.WALKING,
ExerciseType.HIGH_INTENSITY_INTERVAL_TRAINING,
ExerciseType.WORKOUT,
)
for (exerciseType in requestedTypes) {
if (exerciseType !in capabilities.supportedExerciseTypes) {

View File

@ -8,6 +8,7 @@ import android.app.PendingIntent
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.util.Log
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.wear.ongoing.OngoingActivity
@ -15,12 +16,11 @@ import androidx.wear.ongoing.Status
import com.gametime.watch.R
object WatchOngoingActivityController {
private const val TAG = "GTWatchOngoing"
private const val CHANNEL_ID = "gametime_watch_session"
private const val CHANNEL_NAME = "Séance GameTime"
private const val NOTIFICATION_ID = 9101
private const val ONGOING_ACTIVITY_ID = 9101
private const val POST_NOTIFICATIONS_PERMISSION_REQUEST = 4107
private var postNotificationsPermissionRequested = false
fun update(context: Context, projection: Map<String, Any?>, activity: Activity?) {
val phase = projection["phase"] as? String ?: "noActiveSession"
@ -30,7 +30,7 @@ object WatchOngoingActivityController {
return
}
if (!hasPostNotificationsPermission(context)) {
requestPostNotificationsPermissionOnce(activity)
Log.d(TAG, "skip ongoing activity: POST_NOTIFICATIONS not granted")
return
}
post(context, projection)
@ -104,18 +104,4 @@ object WatchOngoingActivityController {
context.checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) ==
PackageManager.PERMISSION_GRANTED
}
private fun requestPostNotificationsPermissionOnce(activity: Activity?) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU ||
activity == null ||
postNotificationsPermissionRequested
) {
return
}
postNotificationsPermissionRequested = true
activity.requestPermissions(
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
POST_NOTIFICATIONS_PERMISSION_REQUEST,
)
}
}