This commit is contained in:
@ -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" />
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -106,6 +106,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
_subscriptions.add(_nativeClient.projections.listen(_handleProjection));
|
||||
_subscriptions.add(_nativeClient.sensorSamples.listen(_handleSensorSample));
|
||||
_subscriptions.add(_nativeClient.acks.listen(_handleAck));
|
||||
_subscriptions.add(_nativeClient.alerts.listen(_handleAlert));
|
||||
_subscriptions.add(
|
||||
_nativeClient.connectionEvents.listen(_handleConnectionEvent),
|
||||
);
|
||||
@ -122,6 +123,7 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
final Duration _staleProjectionThreshold;
|
||||
final Duration _connectionLostThreshold;
|
||||
final _subscriptions = <StreamSubscription<dynamic>>[];
|
||||
final _handledAlertIds = <String>{};
|
||||
|
||||
Timer? _waitingTimer;
|
||||
Timer? _commandTimeoutTimer;
|
||||
@ -379,6 +381,26 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
}
|
||||
}
|
||||
|
||||
void _handleAlert(WatchAlertEnvelope alert) {
|
||||
final alertId = alert.alertId.trim();
|
||||
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||
if (alertId.isEmpty ||
|
||||
!_handledAlertIds.add(alertId) ||
|
||||
alert.sessionId != value.projection.deviceSessionId ||
|
||||
(alert.expiresAtEpochMs > 0 && nowMs > alert.expiresAtEpochMs)) {
|
||||
return;
|
||||
}
|
||||
if (_handledAlertIds.length > 64) {
|
||||
_handledAlertIds.remove(_handledAlertIds.first);
|
||||
}
|
||||
switch (alert.pattern) {
|
||||
case WatchAlertPattern.countdownTick:
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
case WatchAlertPattern.timerFinished:
|
||||
_triggerTimerFinishedHaptic();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleConnectionEvent(WatchBridgeConnectionEvent event) {
|
||||
value = value.copyWith(connectionLost: !event.isReachable);
|
||||
if (event.isReachable || event.requestsResync) {
|
||||
@ -392,15 +414,12 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
return;
|
||||
}
|
||||
final now = DateTime.now();
|
||||
final expiresAtEpochMs = value.projection.expiresAtEpochMs;
|
||||
final fallbackExpired =
|
||||
expiresAtEpochMs <= 0 &&
|
||||
now.difference(receivedAt) >= const Duration(seconds: 12);
|
||||
final expiryAge = Duration(
|
||||
milliseconds: _projectionTtlMs(value.projection),
|
||||
);
|
||||
final expired =
|
||||
value.projection.deviceSessionId.isNotEmpty &&
|
||||
(fallbackExpired ||
|
||||
(expiresAtEpochMs > 0 &&
|
||||
now.toUtc().millisecondsSinceEpoch >= expiresAtEpochMs)) &&
|
||||
now.difference(receivedAt) >= expiryAge &&
|
||||
_pendingCommand == null &&
|
||||
_pendingScoreCommandIds.isEmpty;
|
||||
if (expired) {
|
||||
@ -444,22 +463,15 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
if (projection.deviceSessionId.isEmpty) {
|
||||
return;
|
||||
}
|
||||
final nowMs = DateTime.now().toUtc().millisecondsSinceEpoch;
|
||||
final expiresAtEpochMs = projection.expiresAtEpochMs > 0
|
||||
? projection.expiresAtEpochMs
|
||||
: nowMs + const Duration(seconds: 12).inMilliseconds;
|
||||
final delayMs = expiresAtEpochMs - nowMs;
|
||||
_projectionExpiryTimer = Timer(
|
||||
Duration(milliseconds: delayMs <= 0 ? 0 : delayMs),
|
||||
() {
|
||||
if (value.projection.deviceSessionId.isEmpty ||
|
||||
_pendingCommand != null ||
|
||||
_pendingScoreCommandIds.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
_invalidateExpiredProjection();
|
||||
},
|
||||
);
|
||||
final delayMs = _projectionTtlMs(projection);
|
||||
_projectionExpiryTimer = Timer(Duration(milliseconds: delayMs), () {
|
||||
if (value.projection.deviceSessionId.isEmpty ||
|
||||
_pendingCommand != null ||
|
||||
_pendingScoreCommandIds.isNotEmpty) {
|
||||
return;
|
||||
}
|
||||
_invalidateExpiredProjection();
|
||||
});
|
||||
}
|
||||
|
||||
void _clearCommandTimers() {
|
||||
@ -538,6 +550,29 @@ final class WatchSessionViewModel extends ValueNotifier<WatchSessionUiState> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _triggerTimerFinishedHaptic() {
|
||||
unawaited(HapticFeedback.heavyImpact());
|
||||
unawaited(
|
||||
Future<void>.delayed(const Duration(milliseconds: 140), () {
|
||||
return HapticFeedback.heavyImpact();
|
||||
}),
|
||||
);
|
||||
unawaited(
|
||||
Future<void>.delayed(const Duration(milliseconds: 320), () {
|
||||
return HapticFeedback.heavyImpact();
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int _projectionTtlMs(WatchSessionProjection projection) {
|
||||
final projectedAtEpochMs = projection.projectedAtEpochMs;
|
||||
final expiresAtEpochMs = projection.expiresAtEpochMs;
|
||||
if (projectedAtEpochMs > 0 && expiresAtEpochMs > projectedAtEpochMs) {
|
||||
return expiresAtEpochMs - projectedAtEpochMs;
|
||||
}
|
||||
return const Duration(seconds: 12).inMilliseconds;
|
||||
}
|
||||
|
||||
bool _isRejected(WatchCommandAck ack) {
|
||||
|
||||
@ -34,6 +34,8 @@ abstract interface class NativeWatchBridgeClient {
|
||||
|
||||
Stream<WatchCommandAckEvent> get acks;
|
||||
|
||||
Stream<WatchAlertEnvelope> get alerts;
|
||||
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents;
|
||||
|
||||
Future<void> sendCommand(WatchCommandEnvelope command);
|
||||
@ -54,11 +56,13 @@ final class MethodChannelNativeWatchBridgeClient
|
||||
_sensorSampleChannelName,
|
||||
),
|
||||
EventChannel ackChannel = const EventChannel(_ackChannelName),
|
||||
EventChannel alertChannel = const EventChannel(_alertChannelName),
|
||||
EventChannel connectionChannel = const EventChannel(_connectionChannelName),
|
||||
}) : _methodChannel = methodChannel,
|
||||
_projectionChannel = projectionChannel,
|
||||
_sensorSampleChannel = sensorSampleChannel,
|
||||
_ackChannel = ackChannel,
|
||||
_alertChannel = alertChannel,
|
||||
_connectionChannel = connectionChannel;
|
||||
|
||||
static const _methodChannelName = 'gametime.watch_bridge/methods';
|
||||
@ -66,12 +70,14 @@ final class MethodChannelNativeWatchBridgeClient
|
||||
static const _sensorSampleChannelName =
|
||||
'gametime.watch_bridge/sensor_samples';
|
||||
static const _ackChannelName = 'gametime.watch_bridge/acks';
|
||||
static const _alertChannelName = 'gametime.watch_bridge/alerts';
|
||||
static const _connectionChannelName = 'gametime.watch_bridge/connection';
|
||||
|
||||
final MethodChannel _methodChannel;
|
||||
final EventChannel _projectionChannel;
|
||||
final EventChannel _sensorSampleChannel;
|
||||
final EventChannel _ackChannel;
|
||||
final EventChannel _alertChannel;
|
||||
final EventChannel _connectionChannel;
|
||||
|
||||
@override
|
||||
@ -115,6 +121,16 @@ final class MethodChannelNativeWatchBridgeClient
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchAlertEnvelope> get alerts {
|
||||
return _alertChannel
|
||||
.receiveBroadcastStream()
|
||||
.where((event) => event is Map)
|
||||
.map((event) {
|
||||
return WatchAlertEnvelope.fromJson(_stringObjectMap(event));
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents {
|
||||
return _connectionChannel
|
||||
|
||||
@ -809,6 +809,7 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
|
||||
final _sensorSampleController =
|
||||
StreamController<WatchSensorSample>.broadcast();
|
||||
final _ackController = StreamController<WatchCommandAckEvent>.broadcast();
|
||||
final _alertController = StreamController<WatchAlertEnvelope>.broadcast();
|
||||
final _connectionController =
|
||||
StreamController<WatchBridgeConnectionEvent>.broadcast();
|
||||
|
||||
@ -827,6 +828,9 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
|
||||
@override
|
||||
Stream<WatchCommandAckEvent> get acks => _ackController.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchAlertEnvelope> get alerts => _alertController.stream;
|
||||
|
||||
@override
|
||||
Stream<WatchBridgeConnectionEvent> get connectionEvents =>
|
||||
_connectionController.stream;
|
||||
@ -843,6 +847,10 @@ final class _FakeNativeWatchBridgeClient implements NativeWatchBridgeClient {
|
||||
_ackController.add(ack);
|
||||
}
|
||||
|
||||
void emitAlert(WatchAlertEnvelope alert) {
|
||||
_alertController.add(alert);
|
||||
}
|
||||
|
||||
void emitConnection(WatchBridgeConnectionEvent event) {
|
||||
_connectionController.add(event);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user