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:
@ -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>
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user