@craft-native/android 0.0.72
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +606 -0
- package/dist/index.js +410 -0
- package/dist/index.js.map +10 -0
- package/package.json +38 -0
- package/templates/AndroidManifest.xml.template +43 -0
- package/templates/CraftBridge.kt.template +3880 -0
- package/templates/CraftBridgeExtensions.kt.template +383 -0
- package/templates/CraftHealthConnect.kt.template +200 -0
- package/templates/CraftHealthConnectStub.kt.template +28 -0
- package/templates/CraftWidgetProvider.kt.template +246 -0
- package/templates/LocationRecordingService.kt.template +160 -0
- package/templates/MainActivity.kt.template +230 -0
- package/templates/build.gradle.kts.app.template +102 -0
- package/templates/build.gradle.kts.project.template +6 -0
- package/templates/fastlane/Appfile +25 -0
- package/templates/fastlane/Fastfile +192 -0
- package/templates/fastlane/Gemfile +7 -0
- package/templates/github-workflow-android.yml +248 -0
- package/templates/github-workflow-test.yml +329 -0
- package/templates/proguard-rules.pro.template +9 -0
- package/templates/settings.gradle.kts.template +18 -0
- package/templates/test-bridges.html +1463 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Context
|
|
5
|
+
import androidx.compose.foundation.layout.*
|
|
6
|
+
import androidx.compose.material3.*
|
|
7
|
+
import androidx.compose.runtime.*
|
|
8
|
+
import androidx.compose.ui.Modifier
|
|
9
|
+
import androidx.work.*
|
|
10
|
+
import com.google.android.material.color.DynamicColors
|
|
11
|
+
import com.google.firebase.analytics.FirebaseAnalytics
|
|
12
|
+
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
|
13
|
+
import com.google.firebase.messaging.FirebaseMessaging
|
|
14
|
+
import com.google.firebase.remoteconfig.FirebaseRemoteConfig
|
|
15
|
+
import java.util.concurrent.TimeUnit
|
|
16
|
+
|
|
17
|
+
/// Android 13+ Advanced Features
|
|
18
|
+
|
|
19
|
+
// MARK: - Jetpack Compose Integration
|
|
20
|
+
|
|
21
|
+
@Composable
|
|
22
|
+
fun CraftComposeApp(content: @Composable () -> Unit) {
|
|
23
|
+
MaterialTheme(
|
|
24
|
+
colorScheme = dynamicColorScheme(),
|
|
25
|
+
typography = Typography(),
|
|
26
|
+
content = content
|
|
27
|
+
)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
@Composable
|
|
31
|
+
fun dynamicColorScheme(): ColorScheme {
|
|
32
|
+
return if (DynamicColors.isDynamicColorAvailable()) {
|
|
33
|
+
dynamicDarkColorScheme()
|
|
34
|
+
} else {
|
|
35
|
+
darkColorScheme()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Composable
|
|
40
|
+
fun CraftWebViewComposable(
|
|
41
|
+
url: String,
|
|
42
|
+
modifier: Modifier = Modifier
|
|
43
|
+
) {
|
|
44
|
+
AndroidView(
|
|
45
|
+
factory = { context ->
|
|
46
|
+
android.webkit.WebView(context).apply {
|
|
47
|
+
settings.javaScriptEnabled = true
|
|
48
|
+
loadUrl(url)
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
modifier = modifier.fillMaxSize()
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// MARK: - Material You Dynamic Colors
|
|
56
|
+
|
|
57
|
+
class DynamicColorManager(private val activity: Activity) {
|
|
58
|
+
|
|
59
|
+
fun applyDynamicColors() {
|
|
60
|
+
if (DynamicColors.isDynamicColorAvailable()) {
|
|
61
|
+
DynamicColors.applyToActivityIfAvailable(activity)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
fun isDynamicColorAvailable(): Boolean {
|
|
66
|
+
return DynamicColors.isDynamicColorAvailable()
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// MARK: - Predictive Back Gesture (Android 13+)
|
|
71
|
+
|
|
72
|
+
class PredictiveBackHandler(private val activity: Activity) {
|
|
73
|
+
|
|
74
|
+
fun enablePredictiveBack() {
|
|
75
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
|
76
|
+
activity.onBackPressedDispatcher.addCallback(activity, object : androidx.activity.OnBackPressedCallback(true) {
|
|
77
|
+
override fun handleOnBackPressed() {
|
|
78
|
+
// Handle predictive back gesture
|
|
79
|
+
// Can animate the exit transition
|
|
80
|
+
isEnabled = false
|
|
81
|
+
activity.onBackPressedDispatcher.onBackPressed()
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// MARK: - Per-App Language Preferences (Android 13+)
|
|
89
|
+
|
|
90
|
+
class LanguageManager(private val context: Context) {
|
|
91
|
+
|
|
92
|
+
fun setAppLanguage(languageCode: String) {
|
|
93
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
|
94
|
+
val localeList = android.os.LocaleList(java.util.Locale.forLanguageTag(languageCode))
|
|
95
|
+
context.getSystemService(android.app.LocaleManager::class.java)
|
|
96
|
+
?.applicationLocales = localeList
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
fun getAppLanguage(): String? {
|
|
101
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
|
102
|
+
val locales = context.getSystemService(android.app.LocaleManager::class.java)
|
|
103
|
+
?.applicationLocales
|
|
104
|
+
return locales?.get(0)?.toLanguageTag()
|
|
105
|
+
}
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// MARK: - Photo Picker (Android 13+)
|
|
111
|
+
|
|
112
|
+
class PhotoPickerManager(private val activity: Activity) {
|
|
113
|
+
|
|
114
|
+
fun launchPhotoPicker(maxItems: Int = 1, callback: (List<android.net.Uri>) -> Unit) {
|
|
115
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
|
116
|
+
val intent = android.content.Intent(android.provider.MediaStore.ACTION_PICK_IMAGES).apply {
|
|
117
|
+
putExtra(android.provider.MediaStore.EXTRA_PICK_IMAGES_MAX, maxItems)
|
|
118
|
+
}
|
|
119
|
+
// Launch intent and handle result
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// MARK: - Notification Permission (Android 13+)
|
|
125
|
+
|
|
126
|
+
class NotificationPermissionManager(private val activity: Activity) {
|
|
127
|
+
|
|
128
|
+
fun requestNotificationPermission(callback: (Boolean) -> Unit) {
|
|
129
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
|
130
|
+
if (androidx.core.app.ActivityCompat.checkSelfPermission(
|
|
131
|
+
activity,
|
|
132
|
+
android.Manifest.permission.POST_NOTIFICATIONS
|
|
133
|
+
) != android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
134
|
+
) {
|
|
135
|
+
androidx.core.app.ActivityCompat.requestPermissions(
|
|
136
|
+
activity,
|
|
137
|
+
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
|
|
138
|
+
100
|
|
139
|
+
)
|
|
140
|
+
} else {
|
|
141
|
+
callback(true)
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
callback(true)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
fun hasNotificationPermission(): Boolean {
|
|
149
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
|
150
|
+
return androidx.core.app.ActivityCompat.checkSelfPermission(
|
|
151
|
+
activity,
|
|
152
|
+
android.Manifest.permission.POST_NOTIFICATIONS
|
|
153
|
+
) == android.content.pm.PackageManager.PERMISSION_GRANTED
|
|
154
|
+
}
|
|
155
|
+
return true
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// MARK: - Foreground Services
|
|
160
|
+
|
|
161
|
+
class ForegroundServiceManager(private val context: Context) {
|
|
162
|
+
|
|
163
|
+
fun startForegroundService(
|
|
164
|
+
serviceClass: Class<*>,
|
|
165
|
+
notificationTitle: String,
|
|
166
|
+
notificationText: String
|
|
167
|
+
) {
|
|
168
|
+
val intent = android.content.Intent(context, serviceClass)
|
|
169
|
+
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
|
|
170
|
+
context.startForegroundService(intent)
|
|
171
|
+
} else {
|
|
172
|
+
context.startService(intent)
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
fun stopForegroundService(serviceClass: Class<*>) {
|
|
177
|
+
val intent = android.content.Intent(context, serviceClass)
|
|
178
|
+
context.stopService(intent)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// MARK: - WorkManager for Background Tasks
|
|
183
|
+
|
|
184
|
+
class BackgroundTaskManager(private val context: Context) {
|
|
185
|
+
|
|
186
|
+
fun scheduleOneTimeWork(
|
|
187
|
+
tag: String,
|
|
188
|
+
initialDelay: Long = 0,
|
|
189
|
+
requiresCharging: Boolean = false,
|
|
190
|
+
requiresWifi: Boolean = false
|
|
191
|
+
) {
|
|
192
|
+
val constraints = Constraints.Builder()
|
|
193
|
+
.setRequiresCharging(requiresCharging)
|
|
194
|
+
.setRequiredNetworkType(if (requiresWifi) NetworkType.UNMETERED else NetworkType.CONNECTED)
|
|
195
|
+
.build()
|
|
196
|
+
|
|
197
|
+
val workRequest = OneTimeWorkRequestBuilder<CraftWorker>()
|
|
198
|
+
.setConstraints(constraints)
|
|
199
|
+
.setInitialDelay(initialDelay, TimeUnit.MILLISECONDS)
|
|
200
|
+
.addTag(tag)
|
|
201
|
+
.build()
|
|
202
|
+
|
|
203
|
+
WorkManager.getInstance(context).enqueue(workRequest)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
fun schedulePeriodicWork(
|
|
207
|
+
tag: String,
|
|
208
|
+
intervalMinutes: Long,
|
|
209
|
+
requiresCharging: Boolean = false
|
|
210
|
+
) {
|
|
211
|
+
val constraints = Constraints.Builder()
|
|
212
|
+
.setRequiresCharging(requiresCharging)
|
|
213
|
+
.build()
|
|
214
|
+
|
|
215
|
+
val workRequest = PeriodicWorkRequestBuilder<CraftWorker>(
|
|
216
|
+
intervalMinutes, TimeUnit.MINUTES
|
|
217
|
+
)
|
|
218
|
+
.setConstraints(constraints)
|
|
219
|
+
.addTag(tag)
|
|
220
|
+
.build()
|
|
221
|
+
|
|
222
|
+
WorkManager.getInstance(context).enqueue(workRequest)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
fun cancelWork(tag: String) {
|
|
226
|
+
WorkManager.getInstance(context).cancelAllWorkByTag(tag)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
class CraftWorker(context: Context, params: WorkerParameters) : Worker(context, params) {
|
|
230
|
+
override fun doWork(): Result {
|
|
231
|
+
// Perform background task
|
|
232
|
+
return Result.success()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// MARK: - Firebase Integration
|
|
238
|
+
|
|
239
|
+
class FirebaseManager(private val context: Context) {
|
|
240
|
+
|
|
241
|
+
private val analytics: FirebaseAnalytics by lazy {
|
|
242
|
+
FirebaseAnalytics.getInstance(context)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private val crashlytics: FirebaseCrashlytics by lazy {
|
|
246
|
+
FirebaseCrashlytics.getInstance()
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
private val remoteConfig: FirebaseRemoteConfig by lazy {
|
|
250
|
+
FirebaseRemoteConfig.getInstance()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Analytics
|
|
254
|
+
fun logEvent(eventName: String, params: Map<String, Any>) {
|
|
255
|
+
val bundle = android.os.Bundle().apply {
|
|
256
|
+
params.forEach { (key, value) ->
|
|
257
|
+
when (value) {
|
|
258
|
+
is String -> putString(key, value)
|
|
259
|
+
is Int -> putInt(key, value)
|
|
260
|
+
is Long -> putLong(key, value)
|
|
261
|
+
is Double -> putDouble(key, value)
|
|
262
|
+
is Boolean -> putBoolean(key, value)
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
analytics.logEvent(eventName, bundle)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
fun setUserProperty(name: String, value: String) {
|
|
270
|
+
analytics.setUserProperty(name, value)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// Crashlytics
|
|
274
|
+
fun logCrash(message: String) {
|
|
275
|
+
crashlytics.log(message)
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
fun recordException(throwable: Throwable) {
|
|
279
|
+
crashlytics.recordException(throwable)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
fun setUserId(userId: String) {
|
|
283
|
+
crashlytics.setUserId(userId)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Remote Config
|
|
287
|
+
fun fetchRemoteConfig(callback: (Boolean) -> Unit) {
|
|
288
|
+
remoteConfig.fetchAndActivate()
|
|
289
|
+
.addOnCompleteListener { task ->
|
|
290
|
+
callback(task.isSuccessful)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
fun getString(key: String): String {
|
|
295
|
+
return remoteConfig.getString(key)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
fun getBoolean(key: String): Boolean {
|
|
299
|
+
return remoteConfig.getBoolean(key)
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
fun getLong(key: String): Long {
|
|
303
|
+
return remoteConfig.getLong(key)
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// FCM
|
|
307
|
+
fun getFirebaseToken(callback: (String?) -> Unit) {
|
|
308
|
+
FirebaseMessaging.getInstance().token
|
|
309
|
+
.addOnCompleteListener { task ->
|
|
310
|
+
if (task.isSuccessful) {
|
|
311
|
+
callback(task.result)
|
|
312
|
+
} else {
|
|
313
|
+
callback(null)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
fun subscribeToTopic(topic: String) {
|
|
319
|
+
FirebaseMessaging.getInstance().subscribeToTopic(topic)
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
fun unsubscribeFromTopic(topic: String) {
|
|
323
|
+
FirebaseMessaging.getInstance().unsubscribeFromTopic(topic)
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// MARK: - Bottom Sheet
|
|
328
|
+
|
|
329
|
+
@OptIn(ExperimentalMaterial3Api::class)
|
|
330
|
+
@Composable
|
|
331
|
+
fun CraftBottomSheet(
|
|
332
|
+
sheetContent: @Composable ColumnScope.() -> Unit,
|
|
333
|
+
content: @Composable () -> Unit
|
|
334
|
+
) {
|
|
335
|
+
val sheetState = rememberModalBottomSheetState()
|
|
336
|
+
var showBottomSheet by remember { mutableStateOf(false) }
|
|
337
|
+
|
|
338
|
+
if (showBottomSheet) {
|
|
339
|
+
ModalBottomSheet(
|
|
340
|
+
onDismissRequest = { showBottomSheet = false },
|
|
341
|
+
sheetState = sheetState
|
|
342
|
+
) {
|
|
343
|
+
sheetContent()
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
content()
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// MARK: - Navigation Drawer
|
|
351
|
+
|
|
352
|
+
@Composable
|
|
353
|
+
fun CraftNavigationDrawer(
|
|
354
|
+
drawerContent: @Composable ColumnScope.() -> Unit,
|
|
355
|
+
content: @Composable () -> Unit
|
|
356
|
+
) {
|
|
357
|
+
val drawerState = rememberDrawerState(DrawerValue.Closed)
|
|
358
|
+
val scope = rememberCoroutineScope()
|
|
359
|
+
|
|
360
|
+
ModalNavigationDrawer(
|
|
361
|
+
drawerState = drawerState,
|
|
362
|
+
drawerContent = {
|
|
363
|
+
ModalDrawerSheet {
|
|
364
|
+
drawerContent()
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
) {
|
|
368
|
+
content()
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// MARK: - Bridge Integration
|
|
373
|
+
|
|
374
|
+
fun CraftBridge.initializeAdvancedFeatures() {
|
|
375
|
+
// Initialize all advanced Android features
|
|
376
|
+
DynamicColorManager(activity).applyDynamicColors()
|
|
377
|
+
PredictiveBackHandler(activity).enablePredictiveBack()
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
fun CraftBridge.setupFirebase() {
|
|
381
|
+
// Setup Firebase services
|
|
382
|
+
// Called from MainActivity onCreate
|
|
383
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.webkit.WebView
|
|
6
|
+
import androidx.health.connect.client.HealthConnectClient
|
|
7
|
+
import androidx.health.connect.client.PermissionController
|
|
8
|
+
import androidx.health.connect.client.permission.HealthPermission
|
|
9
|
+
import androidx.health.connect.client.records.ActiveCaloriesBurnedRecord
|
|
10
|
+
import androidx.health.connect.client.records.DistanceRecord
|
|
11
|
+
import androidx.health.connect.client.records.ExerciseSessionRecord
|
|
12
|
+
import androidx.health.connect.client.records.HeartRateRecord
|
|
13
|
+
import androidx.health.connect.client.records.StepsRecord
|
|
14
|
+
import androidx.health.connect.client.records.metadata.Metadata
|
|
15
|
+
import androidx.health.connect.client.request.AggregateRequest
|
|
16
|
+
import androidx.health.connect.client.time.TimeRangeFilter
|
|
17
|
+
import androidx.health.connect.client.units.Energy
|
|
18
|
+
import androidx.health.connect.client.units.Length
|
|
19
|
+
import kotlinx.coroutines.CoroutineScope
|
|
20
|
+
import kotlinx.coroutines.Dispatchers
|
|
21
|
+
import kotlinx.coroutines.SupervisorJob
|
|
22
|
+
import kotlinx.coroutines.cancel
|
|
23
|
+
import kotlinx.coroutines.launch
|
|
24
|
+
import org.json.JSONArray
|
|
25
|
+
import org.json.JSONObject
|
|
26
|
+
import java.time.Instant
|
|
27
|
+
import java.time.ZoneId
|
|
28
|
+
|
|
29
|
+
class CraftHealthConnect(
|
|
30
|
+
private val activity: Activity,
|
|
31
|
+
private val webView: WebView
|
|
32
|
+
) {
|
|
33
|
+
companion object {
|
|
34
|
+
private const val PERMISSION_REQUEST_CODE = 8407
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
|
38
|
+
private val permissionContract = PermissionController.createRequestPermissionResultContract()
|
|
39
|
+
private var pendingPermissions: Set<String> = emptySet()
|
|
40
|
+
|
|
41
|
+
private val client: HealthConnectClient?
|
|
42
|
+
get() = if (HealthConnectClient.getSdkStatus(activity) == HealthConnectClient.SDK_AVAILABLE) {
|
|
43
|
+
HealthConnectClient.getOrCreate(activity)
|
|
44
|
+
} else {
|
|
45
|
+
null
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
fun requestAuthorization(typesJson: String) {
|
|
49
|
+
val requested = permissionsFor(JSONArray(typesJson))
|
|
50
|
+
val healthClient = client ?: return reject("_craftFitnessAuthReject", "Health Connect is unavailable or requires an update")
|
|
51
|
+
scope.launch {
|
|
52
|
+
try {
|
|
53
|
+
val granted = healthClient.permissionController.getGrantedPermissions()
|
|
54
|
+
if (granted.containsAll(requested)) {
|
|
55
|
+
resolve("_craftFitnessAuthResolve", "true")
|
|
56
|
+
} else {
|
|
57
|
+
pendingPermissions = requested
|
|
58
|
+
activity.runOnUiThread {
|
|
59
|
+
val intent = permissionContract.createIntent(activity, requested)
|
|
60
|
+
activity.startActivityForResult(intent, PERMISSION_REQUEST_CODE)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
} catch (error: Exception) {
|
|
64
|
+
reject("_craftFitnessAuthReject", error.message ?: "Health permissions could not be requested")
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
|
|
70
|
+
if (requestCode != PERMISSION_REQUEST_CODE) return false
|
|
71
|
+
val granted = permissionContract.parseResult(resultCode, data)
|
|
72
|
+
resolve("_craftFitnessAuthResolve", if (granted.containsAll(pendingPermissions)) "true" else "false")
|
|
73
|
+
pendingPermissions = emptySet()
|
|
74
|
+
return true
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
fun getData(type: String, startDate: Long, endDate: Long) {
|
|
78
|
+
val healthClient = client ?: return reject("_craftFitnessDataReject", "Health Connect is unavailable or requires an update")
|
|
79
|
+
val end = if (endDate > 0) Instant.ofEpochMilli(endDate) else Instant.now()
|
|
80
|
+
val start = if (startDate > 0) Instant.ofEpochMilli(startDate) else end.minusSeconds(7 * 24 * 60 * 60)
|
|
81
|
+
scope.launch {
|
|
82
|
+
try {
|
|
83
|
+
val (unit, value) = when (type) {
|
|
84
|
+
"steps" -> {
|
|
85
|
+
val metric = StepsRecord.COUNT_TOTAL
|
|
86
|
+
val result = healthClient.aggregate(AggregateRequest(setOf(metric), TimeRangeFilter.between(start, end)))
|
|
87
|
+
Pair("count", (result[metric] ?: 0L).toDouble())
|
|
88
|
+
}
|
|
89
|
+
"heartRate" -> {
|
|
90
|
+
val metric = HeartRateRecord.BPM_AVG
|
|
91
|
+
val result = healthClient.aggregate(AggregateRequest(setOf(metric), TimeRangeFilter.between(start, end)))
|
|
92
|
+
Pair("count/min", (result[metric] ?: 0L).toDouble())
|
|
93
|
+
}
|
|
94
|
+
"activeEnergy" -> {
|
|
95
|
+
val metric = ActiveCaloriesBurnedRecord.ACTIVE_CALORIES_TOTAL
|
|
96
|
+
val result = healthClient.aggregate(AggregateRequest(setOf(metric), TimeRangeFilter.between(start, end)))
|
|
97
|
+
Pair("kcal", (result[metric] ?: Energy.kilocalories(0.0)).inKilocalories)
|
|
98
|
+
}
|
|
99
|
+
"distance" -> {
|
|
100
|
+
val metric = DistanceRecord.DISTANCE_TOTAL
|
|
101
|
+
val result = healthClient.aggregate(AggregateRequest(setOf(metric), TimeRangeFilter.between(start, end)))
|
|
102
|
+
Pair("m", (result[metric] ?: Length.meters(0.0)).inMeters)
|
|
103
|
+
}
|
|
104
|
+
else -> throw IllegalArgumentException("Unsupported Health Connect data type")
|
|
105
|
+
}
|
|
106
|
+
val payload = JSONObject().put("value", value).put("unit", unit).toString()
|
|
107
|
+
resolve("_craftFitnessDataResolve", payload)
|
|
108
|
+
} catch (error: Exception) {
|
|
109
|
+
reject("_craftFitnessDataReject", error.message ?: "Health data could not be read")
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
fun saveWorkout(workoutJson: String) {
|
|
115
|
+
val healthClient = client ?: return reject("_craftFitnessSaveReject", "Health Connect is unavailable or requires an update")
|
|
116
|
+
scope.launch {
|
|
117
|
+
try {
|
|
118
|
+
val data = JSONObject(workoutJson)
|
|
119
|
+
val activityId = data.getString("activityId")
|
|
120
|
+
val start = Instant.ofEpochMilli(data.getLong("startDate"))
|
|
121
|
+
val end = Instant.ofEpochMilli(data.getLong("endDate"))
|
|
122
|
+
require(end.isAfter(start)) { "Workout endDate must be after startDate" }
|
|
123
|
+
val zone = ZoneId.systemDefault().rules.getOffset(start)
|
|
124
|
+
val exerciseType = when (data.getString("type")) {
|
|
125
|
+
"running" -> ExerciseSessionRecord.EXERCISE_TYPE_RUNNING
|
|
126
|
+
"walking" -> ExerciseSessionRecord.EXERCISE_TYPE_WALKING
|
|
127
|
+
"hiking" -> ExerciseSessionRecord.EXERCISE_TYPE_HIKING
|
|
128
|
+
"cycling" -> ExerciseSessionRecord.EXERCISE_TYPE_BIKING
|
|
129
|
+
else -> throw IllegalArgumentException("Unsupported workout type")
|
|
130
|
+
}
|
|
131
|
+
val records = mutableListOf<androidx.health.connect.client.records.Record>()
|
|
132
|
+
records.add(ExerciseSessionRecord(
|
|
133
|
+
startTime = start,
|
|
134
|
+
startZoneOffset = zone,
|
|
135
|
+
endTime = end,
|
|
136
|
+
endZoneOffset = zone,
|
|
137
|
+
exerciseType = exerciseType,
|
|
138
|
+
title = "WildLoop",
|
|
139
|
+
metadata = Metadata.manualEntry(clientRecordId = activityId)
|
|
140
|
+
))
|
|
141
|
+
val distance = data.optDouble("distanceMeters", 0.0)
|
|
142
|
+
if (distance > 0) records.add(DistanceRecord(
|
|
143
|
+
startTime = start,
|
|
144
|
+
startZoneOffset = zone,
|
|
145
|
+
endTime = end,
|
|
146
|
+
endZoneOffset = zone,
|
|
147
|
+
distance = Length.meters(distance),
|
|
148
|
+
metadata = Metadata.manualEntry(clientRecordId = "$activityId-distance")
|
|
149
|
+
))
|
|
150
|
+
val calories = data.optDouble("activeEnergyCalories", 0.0)
|
|
151
|
+
if (calories > 0) records.add(ActiveCaloriesBurnedRecord(
|
|
152
|
+
startTime = start,
|
|
153
|
+
startZoneOffset = zone,
|
|
154
|
+
endTime = end,
|
|
155
|
+
endZoneOffset = zone,
|
|
156
|
+
energy = Energy.kilocalories(calories),
|
|
157
|
+
metadata = Metadata.manualEntry(clientRecordId = "$activityId-energy")
|
|
158
|
+
))
|
|
159
|
+
val result = healthClient.insertRecords(records)
|
|
160
|
+
val payload = JSONObject().put("id", result.recordIdsList.firstOrNull() ?: activityId).toString()
|
|
161
|
+
resolve("_craftFitnessSaveResolve", payload)
|
|
162
|
+
} catch (error: Exception) {
|
|
163
|
+
reject("_craftFitnessSaveReject", error.message ?: "Workout could not be saved")
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
fun close() {
|
|
169
|
+
scope.cancel()
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private fun permissionsFor(types: JSONArray): Set<String> {
|
|
173
|
+
val permissions = mutableSetOf<String>()
|
|
174
|
+
for (index in 0 until types.length()) {
|
|
175
|
+
when (types.optString(index)) {
|
|
176
|
+
"steps" -> permissions.add(HealthPermission.getReadPermission(StepsRecord::class))
|
|
177
|
+
"heartRate" -> permissions.add(HealthPermission.getReadPermission(HeartRateRecord::class))
|
|
178
|
+
"activeEnergy" -> permissions.add(HealthPermission.getReadPermission(ActiveCaloriesBurnedRecord::class))
|
|
179
|
+
"distance" -> permissions.add(HealthPermission.getReadPermission(DistanceRecord::class))
|
|
180
|
+
"workouts" -> {
|
|
181
|
+
permissions.add(HealthPermission.getReadPermission(ExerciseSessionRecord::class))
|
|
182
|
+
permissions.add(HealthPermission.getWritePermission(ExerciseSessionRecord::class))
|
|
183
|
+
permissions.add(HealthPermission.getWritePermission(ActiveCaloriesBurnedRecord::class))
|
|
184
|
+
permissions.add(HealthPermission.getWritePermission(DistanceRecord::class))
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
return permissions
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
private fun resolve(callback: String, json: String) {
|
|
192
|
+
activity.runOnUiThread {
|
|
193
|
+
webView.evaluateJavascript("window.$callback && window.$callback($json)", null)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private fun reject(callback: String, message: String) {
|
|
198
|
+
resolve(callback, JSONObject.quote(message))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
package {{PACKAGE_NAME}}
|
|
2
|
+
|
|
3
|
+
import android.app.Activity
|
|
4
|
+
import android.content.Intent
|
|
5
|
+
import android.webkit.WebView
|
|
6
|
+
import org.json.JSONObject
|
|
7
|
+
|
|
8
|
+
class CraftHealthConnect(
|
|
9
|
+
private val activity: Activity,
|
|
10
|
+
private val webView: WebView
|
|
11
|
+
) {
|
|
12
|
+
fun requestAuthorization(typesJson: String) = reject("_craftFitnessAuthReject")
|
|
13
|
+
|
|
14
|
+
fun getData(type: String, startDate: Long, endDate: Long) = reject("_craftFitnessDataReject")
|
|
15
|
+
|
|
16
|
+
fun saveWorkout(workoutJson: String) = reject("_craftFitnessSaveReject")
|
|
17
|
+
|
|
18
|
+
fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean = false
|
|
19
|
+
|
|
20
|
+
fun close() = Unit
|
|
21
|
+
|
|
22
|
+
private fun reject(callback: String) {
|
|
23
|
+
activity.runOnUiThread {
|
|
24
|
+
val reason = JSONObject.quote("Health Connect is disabled")
|
|
25
|
+
webView.evaluateJavascript("window.$callback && window.$callback($reason)", null)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|