chore(wip): lot #165-169 validé QA + rework foreground #163 (KO - preuve device/toolchain insuffisante)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-28 17:59:43 +02:00
parent 917777e18b
commit bc533d6c45
32 changed files with 898 additions and 123 deletions

View File

@ -3,7 +3,8 @@
android:name="android.hardware.type.watch"
android:required="true" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_HEALTH" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACTIVITY_RECOGNITION" />
<uses-permission
@ -63,5 +64,9 @@
android:scheme="wear" />
</intent-filter>
</service>
<service
android:name=".bridge.WatchHeartRateForegroundService"
android:exported="false"
android:foregroundServiceType="health" />
</application>
</manifest>

View File

@ -55,6 +55,7 @@ object WatchBridgePlugin {
private var sensorPermissionRequestInFlight = false
private var lastSensorPermissionRequestEpochMs = 0L
private var pendingSensorPermissionRequest = false
private var lastSensorProjection: Map<String, Any?>? = null
fun attachApplicationContext(context: Context) {
appContext = context.applicationContext
@ -142,7 +143,13 @@ object WatchBridgePlugin {
if (granted) {
pendingSensorPermissionRequest = false
Log.d(TAG, "sensor permissions granted")
appContext?.let { heartRateCollector.onBodySensorsGranted(it) }
appContext?.let { context ->
val projection = lastSensorProjection
if (projection != null) {
WatchHeartRateForegroundService.start(context, projection)
}
heartRateCollector.onBodySensorsGranted(context)
}
} else {
val denied = permissions.filterIndexed { index, _ ->
grantResults.getOrNull(index) != PackageManager.PERMISSION_GRANTED
@ -315,11 +322,15 @@ object WatchBridgePlugin {
val phase = projection["phase"] as? String ?: "noActiveSession"
val sessionId = projection["deviceSessionId"] as? String ?: ""
if (phase == "noActiveSession" || sessionId.isBlank()) {
lastSensorProjection = null
WatchHeartRateForegroundService.stop(context)
heartRateCollector.finishCurrentSession(context)
return
}
lastSensorProjection = projection
val shouldAggregate = phase == "running"
if (!hasRequiredSensorPermissions(context)) {
WatchHeartRateForegroundService.stop(context)
heartRateCollector.noteActiveSession(
sessionId,
shouldAggregate = false,
@ -336,8 +347,10 @@ object WatchBridgePlugin {
executionContext = telemetryContext(projection),
)
if (shouldAggregate) {
WatchHeartRateForegroundService.start(context, projection)
heartRateCollector.start(context)
} else {
WatchHeartRateForegroundService.stop(context)
heartRateCollector.pause(context)
}
}

View File

@ -1,8 +1,6 @@
package com.gametime.watch.bridge
import android.annotation.SuppressLint
import android.content.Context
import android.os.PowerManager
import android.util.Log
import androidx.health.services.client.HealthServices
import androidx.health.services.client.MeasureClient
@ -39,7 +37,6 @@ internal class WatchHeartRateCollector(
private val registeredDataTypes = mutableSetOf<DeltaDataType<*, *>>()
private var shouldAggregate = false
private var appContext: Context? = null
private var wakeLock: PowerManager.WakeLock? = null
private val callback = object : MeasureCallback {
override fun onAvailabilityChanged(
@ -104,7 +101,6 @@ internal class WatchHeartRateCollector(
return
}
appContext = context.applicationContext
acquireWakeLock(context)
val measureClient = HealthServices.getClient(context).measureClient
registerMeasureCallbackIfNeeded(measureClient, DataType.HEART_RATE_BPM)
registerMeasureCallbackIfNeeded(measureClient, DataType.DISTANCE)
@ -221,7 +217,6 @@ internal class WatchHeartRateCollector(
}
registeredDataTypes.clear()
appContext = null
releaseWakeLock()
}
private fun registerMeasureCallbackIfNeeded(
@ -240,34 +235,6 @@ internal class WatchHeartRateCollector(
}
}
@SuppressLint("WakelockTimeout")
private fun acquireWakeLock(context: Context) {
val existing = wakeLock
if (existing?.isHeld == true) {
return
}
val powerManager = context.applicationContext
.getSystemService(Context.POWER_SERVICE) as? PowerManager
?: return
wakeLock = powerManager.newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK,
"GameTime:HeartRateCollection",
).apply {
setReferenceCounted(false)
acquire()
}
Log.d(TAG, "heart rate collection wake lock acquired sessionId=$sessionId")
}
private fun releaseWakeLock() {
val lock = wakeLock
wakeLock = null
if (lock?.isHeld == true) {
lock.release()
Log.d(TAG, "heart rate collection wake lock released")
}
}
private fun reset(nextSessionId: String?) {
sessionId = nextSessionId
sampleCount = 0

View File

@ -0,0 +1,108 @@
package com.gametime.watch.bridge
import android.app.Notification
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.ServiceInfo
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import com.gametime.watch.MainActivity
import com.gametime.watch.R
internal class WatchHeartRateForegroundService : Service() {
companion object {
const val CHANNEL_ID = "gametime_watch_heart_rate"
const val CHANNEL_NAME = "Collecte cardio GameTime"
const val NOTIFICATION_ID = 9102
const val EXTRA_EXERCISE_NAME = "exerciseName"
fun start(context: Context, projection: Map<String, Any?>) {
val sessionId = projection["deviceSessionId"] as? String ?: ""
val phase = projection["phase"] as? String ?: "noActiveSession"
if (sessionId.isBlank() || phase != "running") {
stop(context)
return
}
val exerciseName = (projection["exerciseName"] as? String)
?.takeIf { it.isNotBlank() }
?: "Séance en cours"
ContextCompat.startForegroundService(
context,
Intent(context, WatchHeartRateForegroundService::class.java)
.putExtra(EXTRA_EXERCISE_NAME, exerciseName),
)
}
fun stop(context: Context) {
context.stopService(Intent(context, WatchHeartRateForegroundService::class.java))
}
}
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
WatchBridgePlugin.attachApplicationContext(applicationContext)
val exerciseName = intent
?.getStringExtra(EXTRA_EXERCISE_NAME)
?.takeIf { it.isNotBlank() }
?: "Séance en cours"
ServiceCompat.startForeground(
this,
NOTIFICATION_ID,
buildNotification(exerciseName),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_HEALTH
} else {
0
},
)
return START_STICKY
}
private fun buildNotification(exerciseName: String): Notification {
ensureNotificationChannel()
val touchIntent = PendingIntent.getActivity(
this,
0,
Intent(this, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
},
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_ongoing_gt)
.setContentTitle("GameTime")
.setContentText(exerciseName)
.setContentIntent(touchIntent)
.setCategory(NotificationCompat.CATEGORY_STATUS)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setOngoing(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.build()
}
private fun ensureNotificationChannel() {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
return
}
val manager = getSystemService(NotificationManager::class.java)
if (manager.getNotificationChannel(CHANNEL_ID) != null) {
return
}
manager.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
CHANNEL_NAME,
NotificationManager.IMPORTANCE_LOW,
),
)
}
}