feat(android): renommage package com.gametime.app → com.idea.gametime
- Mise à jour namespace et applicationId dans build.gradle.kts - Déplacement des fichiers MainActivity.kt, session/, watch/ vers com/idea/gametime/ - Ajout garde-fou usesCleartextTraffic uniquement en debug/profile - Nettoyage .gitignore pour exclure .gradle/ au niveau racine Préparation ticket #261 release Android closed test
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@ -41,6 +41,7 @@ app.*.map.json
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
android/.gradle/
|
||||
/.gradle/
|
||||
android/local.properties
|
||||
android/**/gradle-wrapper.jar
|
||||
android/key.properties
|
||||
|
||||
@ -28,7 +28,7 @@ check(sharedSigningStoreFile.isFile) {
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.gametime.app"
|
||||
namespace = "com.idea.gametime"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
ndkVersion = flutter.ndkVersion
|
||||
|
||||
@ -38,8 +38,8 @@ android {
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.gametime.app"
|
||||
minSdk = flutter.minSdkVersion
|
||||
applicationId = "com.idea.gametime"
|
||||
minSdk = 26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
versionName = flutter.versionName
|
||||
@ -72,5 +72,7 @@ flutter {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("androidx.health.connect:connect-client:1.1.0")
|
||||
implementation("com.google.android.gms:play-services-wearable:19.0.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.2")
|
||||
}
|
||||
|
||||
@ -4,4 +4,6 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
|
||||
@ -6,12 +6,15 @@
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.health.WRITE_EXERCISE"/>
|
||||
<uses-permission android:name="android.permission.health.WRITE_DISTANCE"/>
|
||||
<uses-permission android:name="android.permission.health.WRITE_TOTAL_CALORIES_BURNED"/>
|
||||
<uses-permission android:name="android.permission.health.WRITE_HEART_RATE"/>
|
||||
|
||||
<application
|
||||
android:label="GameTime"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true">
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<meta-data
|
||||
android:name="com.google.android.wearable.capabilities"
|
||||
android:resource="@array/android_wear_capabilities" />
|
||||
@ -37,6 +40,23 @@
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
android:name=".health.HealthConnectRationaleActivity"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity-alias
|
||||
android:name="ViewPermissionUsageActivity"
|
||||
android:exported="true"
|
||||
android:targetActivity=".health.HealthConnectRationaleActivity"
|
||||
android:permission="android.permission.START_VIEW_PERMISSION_USAGE">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW_PERMISSION_USAGE" />
|
||||
<category android:name="android.intent.category.HEALTH_PERMISSIONS" />
|
||||
</intent-filter>
|
||||
</activity-alias>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
@ -73,6 +93,10 @@
|
||||
|
||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||
<queries>
|
||||
<package android:name="com.google.android.apps.healthdata" />
|
||||
<intent>
|
||||
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||
<data android:mimeType="text/plain"/>
|
||||
|
||||
@ -1,14 +0,0 @@
|
||||
package com.gametime.app
|
||||
|
||||
import com.gametime.app.session.SessionNotificationPlugin
|
||||
import com.gametime.app.watch.WatchBridgePlugin
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
WatchBridgePlugin.register(flutterEngine, applicationContext)
|
||||
SessionNotificationPlugin.register(flutterEngine, this)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
package com.idea.gametime
|
||||
|
||||
import android.os.Bundle
|
||||
import com.idea.gametime.health.HealthConnectPlugin
|
||||
import com.idea.gametime.session.SessionNotificationPlugin
|
||||
import com.idea.gametime.watch.WatchBridgePlugin
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
HealthConnectPlugin.prepare(this)
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
WatchBridgePlugin.register(flutterEngine, applicationContext)
|
||||
SessionNotificationPlugin.register(flutterEngine, this)
|
||||
HealthConnectPlugin.register(flutterEngine, this)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,470 @@
|
||||
package com.idea.gametime.health
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.util.Log
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.health.connect.client.HealthConnectClient
|
||||
import androidx.health.connect.client.PermissionController
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.health.connect.client.permission.HealthPermission
|
||||
import androidx.health.connect.client.records.DistanceRecord
|
||||
import androidx.health.connect.client.records.ExerciseSessionRecord
|
||||
import androidx.health.connect.client.records.HeartRateRecord
|
||||
import androidx.health.connect.client.records.Record
|
||||
import androidx.health.connect.client.records.TotalCaloriesBurnedRecord
|
||||
import androidx.health.connect.client.records.metadata.Metadata
|
||||
import androidx.health.connect.client.units.Energy
|
||||
import androidx.health.connect.client.units.Length
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.lang.ref.WeakReference
|
||||
import java.time.Instant
|
||||
import java.time.ZoneOffset
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
object HealthConnectPlugin {
|
||||
private const val METHOD_CHANNEL = "gametime.health_connect/methods"
|
||||
private const val LOG_TAG = "GTHealthConnect"
|
||||
private const val HEALTH_CONNECT_PROVIDER_PACKAGE = "com.google.android.apps.healthdata"
|
||||
private const val PLAY_STORE_PACKAGE = "com.android.vending"
|
||||
private const val FALLBACK_OPEN_DELAY_MS = 1200L
|
||||
|
||||
private val requiredPermissions =
|
||||
setOf(
|
||||
HealthPermission.getWritePermission(ExerciseSessionRecord::class),
|
||||
HealthPermission.getWritePermission(DistanceRecord::class),
|
||||
HealthPermission.getWritePermission(TotalCaloriesBurnedRecord::class),
|
||||
HealthPermission.getWritePermission(HeartRateRecord::class),
|
||||
)
|
||||
|
||||
private var appContext: Context? = null
|
||||
private var activityRef: WeakReference<Activity>? = null
|
||||
private var permissionRequestLauncher: ActivityResultLauncher<Set<String>>? = null
|
||||
private var pendingPermissionResult: MethodChannel.Result? = null
|
||||
private var fallbackOpenRunnable: Runnable? = null
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val mainScope = CoroutineScope(Dispatchers.Main)
|
||||
|
||||
fun prepare(activity: Activity) {
|
||||
appContext = activity.applicationContext
|
||||
activityRef = WeakReference(activity)
|
||||
registerPermissionRequestLauncher(activity)
|
||||
}
|
||||
|
||||
fun register(flutterEngine: FlutterEngine, activity: Activity) {
|
||||
appContext = activity.applicationContext
|
||||
activityRef = WeakReference(activity)
|
||||
if (permissionRequestLauncher == null) {
|
||||
registerPermissionRequestLauncher(activity)
|
||||
}
|
||||
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, METHOD_CHANNEL)
|
||||
.setMethodCallHandler(::handleMethodCall)
|
||||
}
|
||||
|
||||
private fun registerPermissionRequestLauncher(activity: Activity) {
|
||||
Log.d(LOG_TAG, "registerPermissionRequestLauncher(activity=${activity::class.java.name})")
|
||||
val componentActivity = activity as? ComponentActivity
|
||||
if (componentActivity == null) {
|
||||
Log.e(LOG_TAG, "Activity is not a ComponentActivity; permission launcher unavailable.")
|
||||
return
|
||||
}
|
||||
try {
|
||||
val context = activity.applicationContext
|
||||
permissionRequestLauncher?.unregister()
|
||||
permissionRequestLauncher =
|
||||
componentActivity.activityResultRegistry.register(
|
||||
"gametime_health_connect_permissions",
|
||||
PermissionController.createRequestPermissionResultContract(),
|
||||
) { grantedPermissions ->
|
||||
Log.d(
|
||||
LOG_TAG,
|
||||
"permission request callback(granted=${grantedPermissions.size})",
|
||||
)
|
||||
cancelFallbackOpen()
|
||||
val result = pendingPermissionResult ?: return@register
|
||||
pendingPermissionResult = null
|
||||
mainScope.launch {
|
||||
val state = try {
|
||||
connectionStateMap(
|
||||
context,
|
||||
grantedPermissions,
|
||||
permissionRequestOutcome =
|
||||
if (grantedPermissions.containsAll(requiredPermissions)) {
|
||||
"granted"
|
||||
} else {
|
||||
"denied"
|
||||
},
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
Log.e(LOG_TAG, "connection state failed after callback.", error)
|
||||
result.error("health_connect_state_failed", error.message, null)
|
||||
return@launch
|
||||
}
|
||||
result.success(state)
|
||||
}
|
||||
}
|
||||
Log.d(LOG_TAG, "permission launcher registered.")
|
||||
} catch (error: Throwable) {
|
||||
Log.e(LOG_TAG, "permission launcher registration failed.", error)
|
||||
permissionRequestLauncher = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
val context = appContext
|
||||
if (context == null) {
|
||||
result.error("health_connect_unavailable", "Application context unavailable.", null)
|
||||
return
|
||||
}
|
||||
when (call.method) {
|
||||
"connectionState" -> connectionState(context, result)
|
||||
"requestPermissions" -> requestPermissions(context, result)
|
||||
"openSettings" -> openSettings(context, result)
|
||||
"exportWorkout" -> exportWorkout(context, call.arguments, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectionState(context: Context, result: MethodChannel.Result) {
|
||||
mainScope.launch {
|
||||
try {
|
||||
result.success(connectionStateMap(context))
|
||||
} catch (error: Throwable) {
|
||||
result.error("health_connect_state_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun connectionStateMap(
|
||||
context: Context,
|
||||
grantedOverride: Set<String>? = null,
|
||||
permissionRequestOutcome: String? = null,
|
||||
): Map<String, Any> {
|
||||
var availability = availability(context)
|
||||
val grantedPermissions = if (availability == "available") {
|
||||
try {
|
||||
grantedOverride ?: HealthConnectClient.getOrCreate(context)
|
||||
.permissionController
|
||||
.getGrantedPermissions()
|
||||
} catch (_: SecurityException) {
|
||||
availability = "accessBlocked"
|
||||
emptySet()
|
||||
} catch (_: IllegalStateException) {
|
||||
availability = "accessBlocked"
|
||||
emptySet()
|
||||
}
|
||||
} else {
|
||||
emptySet()
|
||||
}
|
||||
val state = mutableMapOf<String, Any>(
|
||||
"availability" to availability,
|
||||
"requiredPermissions" to requiredPermissions.toList(),
|
||||
"grantedPermissions" to grantedPermissions.toList(),
|
||||
)
|
||||
if (permissionRequestOutcome != null) {
|
||||
state["permissionRequestOutcome"] = permissionRequestOutcome
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private fun availability(context: Context): String {
|
||||
return when (HealthConnectClient.getSdkStatus(context)) {
|
||||
HealthConnectClient.SDK_AVAILABLE -> "available"
|
||||
HealthConnectClient.SDK_UNAVAILABLE_PROVIDER_UPDATE_REQUIRED ->
|
||||
"providerUpdateRequired"
|
||||
else -> "unavailable"
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestPermissions(context: Context, result: MethodChannel.Result) {
|
||||
val availability = availability(context)
|
||||
Log.d(LOG_TAG, "requestPermissions availability=$availability")
|
||||
if (availability != "available") {
|
||||
mainScope.launch {
|
||||
result.success(connectionStateMap(context))
|
||||
}
|
||||
return
|
||||
}
|
||||
val activity = activityRef?.get()
|
||||
if (activity == null) {
|
||||
Log.e(LOG_TAG, "requestPermissions failed: activity unavailable.")
|
||||
result.error("health_connect_unavailable", "Activity unavailable.", null)
|
||||
return
|
||||
}
|
||||
if (pendingPermissionResult != null) {
|
||||
Log.e(LOG_TAG, "requestPermissions failed: request already active.")
|
||||
result.error(
|
||||
"health_connect_request_in_progress",
|
||||
"Permission request already active.",
|
||||
null,
|
||||
)
|
||||
return
|
||||
}
|
||||
pendingPermissionResult = result
|
||||
val launcher = permissionRequestLauncher
|
||||
if (launcher == null) {
|
||||
Log.e(LOG_TAG, "requestPermissions fallback: launcher unavailable.")
|
||||
openSettingsFallback(context, activity, "settingsFallback")
|
||||
return
|
||||
}
|
||||
try {
|
||||
Log.d(LOG_TAG, "launching Health Connect permission contract.")
|
||||
launcher.launch(requiredPermissions)
|
||||
Log.d(LOG_TAG, "Health Connect permission contract launch returned.")
|
||||
scheduleFallbackOpen(context, activity)
|
||||
} catch (error: Throwable) {
|
||||
Log.e(LOG_TAG, "Health Connect permission contract launch failed.", error)
|
||||
openSettingsFallback(context, activity, "launchFailed")
|
||||
}
|
||||
}
|
||||
|
||||
private fun scheduleFallbackOpen(context: Context, activity: Activity) {
|
||||
cancelFallbackOpen()
|
||||
val runnable = Runnable {
|
||||
if (pendingPermissionResult == null) {
|
||||
return@Runnable
|
||||
}
|
||||
val componentActivity = activity as? ComponentActivity
|
||||
if (componentActivity != null &&
|
||||
!componentActivity.lifecycle.currentState.isAtLeast(Lifecycle.State.RESUMED)
|
||||
) {
|
||||
Log.d(LOG_TAG, "permission UI appears to have focus; fallback not opened.")
|
||||
return@Runnable
|
||||
}
|
||||
Log.d(LOG_TAG, "permission UI did not take focus; opening Health Connect settings fallback.")
|
||||
openSettingsFallback(context, activity, "settingsFallback")
|
||||
}
|
||||
fallbackOpenRunnable = runnable
|
||||
mainHandler.postDelayed(runnable, FALLBACK_OPEN_DELAY_MS)
|
||||
}
|
||||
|
||||
private fun cancelFallbackOpen() {
|
||||
fallbackOpenRunnable?.let(mainHandler::removeCallbacks)
|
||||
fallbackOpenRunnable = null
|
||||
}
|
||||
|
||||
private fun openSettingsFallback(context: Context, activity: Activity, outcome: String) {
|
||||
cancelFallbackOpen()
|
||||
val pendingResult = pendingPermissionResult
|
||||
pendingPermissionResult = null
|
||||
try {
|
||||
activity.startActivity(settingsIntent())
|
||||
Log.d(LOG_TAG, "Health Connect settings fallback opened(outcome=$outcome).")
|
||||
mainScope.launch {
|
||||
try {
|
||||
pendingResult?.success(
|
||||
connectionStateMap(
|
||||
context,
|
||||
permissionRequestOutcome = outcome,
|
||||
),
|
||||
)
|
||||
} catch (stateError: Throwable) {
|
||||
Log.e(LOG_TAG, "connection state failed after settings fallback.", stateError)
|
||||
pendingResult?.error("health_connect_state_failed", stateError.message, null)
|
||||
}
|
||||
}
|
||||
} catch (error: Throwable) {
|
||||
Log.e(LOG_TAG, "Health Connect settings fallback failed.", error)
|
||||
pendingResult?.error("health_connect_permission_request_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun openSettings(context: Context, result: MethodChannel.Result) {
|
||||
val activity = activityRef?.get()
|
||||
if (activity == null) {
|
||||
result.success(false)
|
||||
return
|
||||
}
|
||||
val intents =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
|
||||
listOf(settingsIntent())
|
||||
} else if (availability(context) == "providerUpdateRequired") {
|
||||
listOf(providerUpdateIntent(context), settingsIntent())
|
||||
} else {
|
||||
listOf(settingsIntent())
|
||||
}
|
||||
val opened = intents.any { intent ->
|
||||
try {
|
||||
Log.d(LOG_TAG, "openSettings launching ${intent.action}.")
|
||||
activity.startActivity(intent)
|
||||
Log.d(LOG_TAG, "openSettings launch ok.")
|
||||
true
|
||||
} catch (error: ActivityNotFoundException) {
|
||||
Log.e(LOG_TAG, "openSettings activity not found.", error)
|
||||
false
|
||||
} catch (error: SecurityException) {
|
||||
Log.e(LOG_TAG, "openSettings security failure.", error)
|
||||
false
|
||||
}
|
||||
}
|
||||
result.success(opened)
|
||||
}
|
||||
|
||||
private fun providerUpdateIntent(context: Context): Intent {
|
||||
val uri =
|
||||
"market://details?id=$HEALTH_CONNECT_PROVIDER_PACKAGE&url=healthconnect%3A%2F%2Fonboarding"
|
||||
return Intent(Intent.ACTION_VIEW).apply {
|
||||
setPackage(PLAY_STORE_PACKAGE)
|
||||
data = Uri.parse(uri)
|
||||
putExtra("overlay", true)
|
||||
putExtra("callerId", context.packageName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun settingsIntent(): Intent =
|
||||
Intent(HealthConnectClient.ACTION_HEALTH_CONNECT_SETTINGS)
|
||||
|
||||
private fun exportWorkout(
|
||||
context: Context,
|
||||
arguments: Any?,
|
||||
result: MethodChannel.Result,
|
||||
) {
|
||||
val map = arguments as? Map<*, *>
|
||||
if (map == null) {
|
||||
result.error("invalid_health_connect_workout", "Workout payload must be a map.", null)
|
||||
return
|
||||
}
|
||||
mainScope.launch {
|
||||
try {
|
||||
if (availability(context) != "available") {
|
||||
result.error("health_connect_unavailable", "Health Connect is unavailable.", null)
|
||||
return@launch
|
||||
}
|
||||
val grantedPermissions =
|
||||
HealthConnectClient.getOrCreate(context)
|
||||
.permissionController
|
||||
.getGrantedPermissions()
|
||||
if (!grantedPermissions.containsAll(requiredPermissions)) {
|
||||
result.error(
|
||||
"health_connect_permissions_required",
|
||||
"Health Connect write permissions are not granted.",
|
||||
null,
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val records = recordsFromWorkoutPayload(map)
|
||||
HealthConnectClient.getOrCreate(context).insertRecords(records)
|
||||
result.success(null)
|
||||
} catch (error: Throwable) {
|
||||
result.error("health_connect_export_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun recordsFromWorkoutPayload(map: Map<*, *>): List<Record> {
|
||||
val historyId = nonBlankString(map["historyId"], "historyId")
|
||||
val title = map["title"] as? String ?: "GameTime Basketball"
|
||||
val start = epochMs(map["startedAtEpochMs"], "startedAtEpochMs")
|
||||
val end = epochMs(map["endedAtEpochMs"], "endedAtEpochMs")
|
||||
if (!end.isAfter(start)) {
|
||||
throw IllegalArgumentException("Workout end time must be after start time.")
|
||||
}
|
||||
val records = mutableListOf<Record>(
|
||||
ExerciseSessionRecord(
|
||||
startTime = start,
|
||||
startZoneOffset = ZoneOffset.UTC,
|
||||
endTime = end,
|
||||
endZoneOffset = ZoneOffset.UTC,
|
||||
exerciseType = ExerciseSessionRecord.EXERCISE_TYPE_BASKETBALL,
|
||||
title = title,
|
||||
notes = "Exported from GameTime.",
|
||||
metadata = Metadata.manualEntry(
|
||||
clientRecordId = clientRecordId(historyId, "exercise-session"),
|
||||
),
|
||||
),
|
||||
)
|
||||
val distanceMeters = positiveDouble(map["totalDistanceMeters"])
|
||||
if (distanceMeters != null) {
|
||||
records += DistanceRecord(
|
||||
startTime = start,
|
||||
startZoneOffset = ZoneOffset.UTC,
|
||||
endTime = end,
|
||||
endZoneOffset = ZoneOffset.UTC,
|
||||
distance = Length.meters(distanceMeters),
|
||||
metadata = Metadata.manualEntry(
|
||||
clientRecordId = clientRecordId(historyId, "distance"),
|
||||
),
|
||||
)
|
||||
}
|
||||
val caloriesKcal = positiveDouble(map["totalCaloriesKcal"])
|
||||
if (caloriesKcal != null) {
|
||||
records += TotalCaloriesBurnedRecord(
|
||||
startTime = start,
|
||||
startZoneOffset = ZoneOffset.UTC,
|
||||
endTime = end,
|
||||
endZoneOffset = ZoneOffset.UTC,
|
||||
energy = Energy.kilocalories(caloriesKcal),
|
||||
metadata = Metadata.manualEntry(
|
||||
clientRecordId = clientRecordId(historyId, "calories"),
|
||||
),
|
||||
)
|
||||
}
|
||||
val heartRateSamples = heartRateSamples(map["heartRateSamples"], start, end)
|
||||
if (heartRateSamples.isNotEmpty()) {
|
||||
records += HeartRateRecord(
|
||||
startTime = start,
|
||||
startZoneOffset = ZoneOffset.UTC,
|
||||
endTime = end,
|
||||
endZoneOffset = ZoneOffset.UTC,
|
||||
samples = heartRateSamples,
|
||||
metadata = Metadata.manualEntry(
|
||||
clientRecordId = clientRecordId(historyId, "heart-rate"),
|
||||
),
|
||||
)
|
||||
}
|
||||
return records
|
||||
}
|
||||
|
||||
private fun nonBlankString(value: Any?, fieldName: String): String {
|
||||
val text = (value as? String)?.trim()
|
||||
if (text.isNullOrEmpty()) {
|
||||
throw IllegalArgumentException("$fieldName must be a non-blank string.")
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
private fun clientRecordId(historyId: String, kind: String): String =
|
||||
"gametime:$historyId:$kind"
|
||||
|
||||
private fun heartRateSamples(
|
||||
payload: Any?,
|
||||
start: Instant,
|
||||
end: Instant,
|
||||
): List<HeartRateRecord.Sample> {
|
||||
val items = payload as? List<*> ?: return emptyList()
|
||||
return items.mapNotNull { item ->
|
||||
val map = item as? Map<*, *> ?: return@mapNotNull null
|
||||
val time =
|
||||
(map["timeEpochMs"] as? Number)?.toLong()?.let(Instant::ofEpochMilli)
|
||||
?: return@mapNotNull null
|
||||
val beatsPerMinute = (map["beatsPerMinute"] as? Number)?.toLong()
|
||||
?: return@mapNotNull null
|
||||
if (beatsPerMinute <= 0 || time.isBefore(start) || time.isAfter(end)) {
|
||||
return@mapNotNull null
|
||||
}
|
||||
HeartRateRecord.Sample(time = time, beatsPerMinute = beatsPerMinute)
|
||||
}
|
||||
}
|
||||
|
||||
private fun epochMs(value: Any?, fieldName: String): Instant {
|
||||
val epochMs = (value as? Number)?.toLong()
|
||||
?: throw IllegalArgumentException("$fieldName must be an epoch millisecond number.")
|
||||
return Instant.ofEpochMilli(epochMs)
|
||||
}
|
||||
|
||||
private fun positiveDouble(value: Any?): Double? {
|
||||
val number = (value as? Number)?.toDouble() ?: return null
|
||||
return if (number > 0.0) number else null
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
package com.idea.gametime.health
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
|
||||
class HealthConnectRationaleActivity : Activity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
title = "Health Connect"
|
||||
|
||||
val textView =
|
||||
TextView(this).apply {
|
||||
text =
|
||||
"""
|
||||
GameTime utilise Health Connect pour enregistrer les donnees compatibles de vos seances de basketball.
|
||||
|
||||
Les donnees envoyees peuvent inclure la seance d'exercice, la distance, les calories et les mesures de frequence cardiaque disponibles.
|
||||
|
||||
GameTime n'utilise ces donnees que pour le suivi sportif affiche dans l'application et dans Health Connect. Vous pouvez modifier ou retirer ces permissions a tout moment depuis Health Connect.
|
||||
""".trimIndent()
|
||||
textSize = 16f
|
||||
setPadding(48, 48, 48, 48)
|
||||
}
|
||||
|
||||
val scrollView =
|
||||
ScrollView(this).apply {
|
||||
addView(
|
||||
textView,
|
||||
ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.WRAP_CONTENT,
|
||||
),
|
||||
)
|
||||
}
|
||||
setContentView(scrollView)
|
||||
}
|
||||
}
|
||||
@ -1,4 +1,4 @@
|
||||
package com.gametime.app.session
|
||||
package com.idea.gametime.session
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
@ -1,4 +1,4 @@
|
||||
package com.gametime.app.session
|
||||
package com.idea.gametime.session
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
@ -8,8 +8,8 @@ import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import com.gametime.app.MainActivity
|
||||
import com.gametime.app.R
|
||||
import com.idea.gametime.MainActivity
|
||||
import com.idea.gametime.R
|
||||
|
||||
class SessionStatusForegroundService : Service() {
|
||||
override fun onCreate() {
|
||||
@ -62,7 +62,7 @@ class SessionStatusForegroundService : Service() {
|
||||
Notification.BigTextStyle().bigText("$primaryLine\n$it")
|
||||
}
|
||||
return builder
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setSmallIcon(R.drawable.ic_ongoing_gt)
|
||||
.setContentTitle(title)
|
||||
.setContentText(primaryLine)
|
||||
.setSubText(secondaryLine)
|
||||
@ -89,7 +89,7 @@ class SessionStatusForegroundService : Service() {
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_SHOW = "com.gametime.app.session.SHOW"
|
||||
const val ACTION_SHOW = "com.idea.gametime.session.SHOW"
|
||||
const val EXTRA_TITLE = "title"
|
||||
const val EXTRA_PRIMARY_LINE = "primaryLine"
|
||||
const val EXTRA_SECONDARY_LINE = "secondaryLine"
|
||||
@ -1,4 +1,4 @@
|
||||
package com.gametime.app.watch
|
||||
package com.idea.gametime.watch
|
||||
|
||||
import com.google.android.gms.wearable.CapabilityInfo
|
||||
import com.google.android.gms.wearable.MessageEvent
|
||||
@ -1,4 +1,4 @@
|
||||
package com.gametime.app.watch
|
||||
package com.idea.gametime.watch
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@ -214,32 +214,7 @@ object WatchBridgePlugin {
|
||||
sendMessage(context, targetNode, ACK_PATH, payload, result)
|
||||
return
|
||||
}
|
||||
Wearable.getCapabilityClient(context)
|
||||
.getCapability(WATCH_CAPABILITY, CapabilityClient.FILTER_REACHABLE)
|
||||
.addOnSuccessListener { capability ->
|
||||
val nodes = capability.nodes.toList()
|
||||
if (nodes.isEmpty()) {
|
||||
result.success(null)
|
||||
return@addOnSuccessListener
|
||||
}
|
||||
var remaining = nodes.size
|
||||
var failed = false
|
||||
for (node in nodes) {
|
||||
Wearable.getMessageClient(context)
|
||||
.sendMessage(node.id, ACK_PATH, payload)
|
||||
.addOnSuccessListener {
|
||||
remaining -= 1
|
||||
if (remaining == 0 && !failed) result.success(null)
|
||||
}
|
||||
.addOnFailureListener { error ->
|
||||
failed = true
|
||||
result.error("send_ack_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
}
|
||||
.addOnFailureListener { error ->
|
||||
result.error("capability_lookup_failed", error.message, null)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendMessage(
|
||||
@ -1,4 +1,4 @@
|
||||
package com.gametime.app.watch
|
||||
package com.idea.gametime.watch
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
@ -7,7 +7,7 @@ import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import com.gametime.app.R
|
||||
import com.idea.gametime.R
|
||||
|
||||
class WatchCompanionForegroundService : Service() {
|
||||
override fun onCreate() {
|
||||
@ -30,7 +30,7 @@ class WatchCompanionForegroundService : Service() {
|
||||
Notification.Builder(this)
|
||||
}
|
||||
return builder
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setSmallIcon(R.drawable.ic_ongoing_gt)
|
||||
.setContentTitle("GameTime")
|
||||
.setContentText("Séance en cours")
|
||||
.setOngoing(true)
|
||||
@ -4,4 +4,6 @@
|
||||
to allow setting breakpoints, to provide hot reload, etc.
|
||||
-->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
|
||||
<application android:usesCleartextTraffic="true"/>
|
||||
</manifest>
|
||||
|
||||
Reference in New Issue
Block a user