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/profile
|
||||||
/android/app/release
|
/android/app/release
|
||||||
android/.gradle/
|
android/.gradle/
|
||||||
|
/.gradle/
|
||||||
android/local.properties
|
android/local.properties
|
||||||
android/**/gradle-wrapper.jar
|
android/**/gradle-wrapper.jar
|
||||||
android/key.properties
|
android/key.properties
|
||||||
|
|||||||
@ -28,7 +28,7 @@ check(sharedSigningStoreFile.isFile) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.gametime.app"
|
namespace = "com.idea.gametime"
|
||||||
compileSdk = flutter.compileSdkVersion
|
compileSdk = flutter.compileSdkVersion
|
||||||
ndkVersion = flutter.ndkVersion
|
ndkVersion = flutter.ndkVersion
|
||||||
|
|
||||||
@ -38,8 +38,8 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.gametime.app"
|
applicationId = "com.idea.gametime"
|
||||||
minSdk = flutter.minSdkVersion
|
minSdk = 26
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
versionName = flutter.versionName
|
versionName = flutter.versionName
|
||||||
@ -72,5 +72,7 @@ flutter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
implementation("androidx.health.connect:connect-client:1.1.0")
|
||||||
implementation("com.google.android.gms:play-services-wearable:19.0.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.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
|
||||||
|
<application android:usesCleartextTraffic="true"/>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@ -6,12 +6,15 @@
|
|||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"/>
|
<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.CHANGE_WIFI_STATE"/>
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<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
|
<application
|
||||||
android:label="GameTime"
|
android:label="GameTime"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher">
|
||||||
android:usesCleartextTraffic="true">
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.wearable.capabilities"
|
android:name="com.google.android.wearable.capabilities"
|
||||||
android:resource="@array/android_wear_capabilities" />
|
android:resource="@array/android_wear_capabilities" />
|
||||||
@ -37,6 +40,23 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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.
|
<!-- Don't delete the meta-data below.
|
||||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||||
<meta-data
|
<meta-data
|
||||||
@ -73,6 +93,10 @@
|
|||||||
|
|
||||||
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
|
||||||
<queries>
|
<queries>
|
||||||
|
<package android:name="com.google.android.apps.healthdata" />
|
||||||
|
<intent>
|
||||||
|
<action android:name="androidx.health.ACTION_SHOW_PERMISSIONS_RATIONALE" />
|
||||||
|
</intent>
|
||||||
<intent>
|
<intent>
|
||||||
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
<action android:name="android.intent.action.PROCESS_TEXT"/>
|
||||||
<data android:mimeType="text/plain"/>
|
<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.Manifest
|
||||||
import android.app.Activity
|
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.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@ -8,8 +8,8 @@ import android.app.Service
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import com.gametime.app.MainActivity
|
import com.idea.gametime.MainActivity
|
||||||
import com.gametime.app.R
|
import com.idea.gametime.R
|
||||||
|
|
||||||
class SessionStatusForegroundService : Service() {
|
class SessionStatusForegroundService : Service() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@ -62,7 +62,7 @@ class SessionStatusForegroundService : Service() {
|
|||||||
Notification.BigTextStyle().bigText("$primaryLine\n$it")
|
Notification.BigTextStyle().bigText("$primaryLine\n$it")
|
||||||
}
|
}
|
||||||
return builder
|
return builder
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.drawable.ic_ongoing_gt)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText(primaryLine)
|
.setContentText(primaryLine)
|
||||||
.setSubText(secondaryLine)
|
.setSubText(secondaryLine)
|
||||||
@ -89,7 +89,7 @@ class SessionStatusForegroundService : Service() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
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_TITLE = "title"
|
||||||
const val EXTRA_PRIMARY_LINE = "primaryLine"
|
const val EXTRA_PRIMARY_LINE = "primaryLine"
|
||||||
const val EXTRA_SECONDARY_LINE = "secondaryLine"
|
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.CapabilityInfo
|
||||||
import com.google.android.gms.wearable.MessageEvent
|
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.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
@ -214,32 +214,7 @@ object WatchBridgePlugin {
|
|||||||
sendMessage(context, targetNode, ACK_PATH, payload, result)
|
sendMessage(context, targetNode, ACK_PATH, payload, result)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Wearable.getCapabilityClient(context)
|
result.success(null)
|
||||||
.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(
|
private fun sendMessage(
|
||||||
@ -1,4 +1,4 @@
|
|||||||
package com.gametime.app.watch
|
package com.idea.gametime.watch
|
||||||
|
|
||||||
import android.app.Notification
|
import android.app.Notification
|
||||||
import android.app.NotificationChannel
|
import android.app.NotificationChannel
|
||||||
@ -7,7 +7,7 @@ import android.app.Service
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.IBinder
|
import android.os.IBinder
|
||||||
import com.gametime.app.R
|
import com.idea.gametime.R
|
||||||
|
|
||||||
class WatchCompanionForegroundService : Service() {
|
class WatchCompanionForegroundService : Service() {
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
@ -30,7 +30,7 @@ class WatchCompanionForegroundService : Service() {
|
|||||||
Notification.Builder(this)
|
Notification.Builder(this)
|
||||||
}
|
}
|
||||||
return builder
|
return builder
|
||||||
.setSmallIcon(R.mipmap.ic_launcher)
|
.setSmallIcon(R.drawable.ic_ongoing_gt)
|
||||||
.setContentTitle("GameTime")
|
.setContentTitle("GameTime")
|
||||||
.setContentText("Séance en cours")
|
.setContentText("Séance en cours")
|
||||||
.setOngoing(true)
|
.setOngoing(true)
|
||||||
@ -4,4 +4,6 @@
|
|||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
|
|
||||||
|
<application android:usesCleartextTraffic="true"/>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
Reference in New Issue
Block a user