craft-native 0.0.55 → 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.
Files changed (146) hide show
  1. package/dist/android/src/index.d.ts +73 -0
  2. package/dist/android/src/index.js +177 -37
  3. package/dist/android/templates/AndroidManifest.xml.template +10 -36
  4. package/dist/android/templates/CraftBridge.kt.template +192 -60
  5. package/dist/android/templates/CraftHealthConnect.kt.template +200 -0
  6. package/dist/android/templates/CraftHealthConnectStub.kt.template +28 -0
  7. package/dist/android/templates/LocationRecordingService.kt.template +160 -0
  8. package/dist/android/templates/MainActivity.kt.template +140 -19
  9. package/dist/android/templates/build.gradle.kts.app.template +23 -1
  10. package/dist/android/templates/build.gradle.kts.project.template +3 -2
  11. package/dist/android/templates/proguard-rules.pro.template +9 -0
  12. package/dist/api/android-advanced.d.ts +5 -6
  13. package/dist/api/app.d.ts +0 -1
  14. package/dist/api/clipboard.d.ts +0 -1
  15. package/dist/api/crypto.d.ts +1 -2
  16. package/dist/api/db.d.ts +1 -2
  17. package/dist/api/dialog.d.ts +0 -1
  18. package/dist/api/fs.d.ts +1 -2
  19. package/dist/api/gestures.d.ts +66 -0
  20. package/dist/api/http.d.ts +1 -2
  21. package/dist/api/index.d.ts +37 -34
  22. package/dist/api/ios-advanced.d.ts +6 -7
  23. package/dist/api/macos-advanced.d.ts +2 -3
  24. package/dist/api/media.d.ts +0 -1
  25. package/dist/api/mobile.d.ts +109 -1
  26. package/dist/api/process.d.ts +0 -1
  27. package/dist/api/sidebar.d.ts +3 -20
  28. package/dist/api/spaces.d.ts +83 -0
  29. package/dist/api/tray.d.ts +2 -3
  30. package/dist/api/window.d.ts +1 -2
  31. package/dist/api/windows-advanced.d.ts +6 -7
  32. package/dist/assets/index.d.ts +0 -1
  33. package/dist/binary-resolver.d.ts +23 -1
  34. package/dist/bridge/android.d.ts +0 -1
  35. package/dist/bridge/core.d.ts +0 -38
  36. package/dist/bridge/ids.d.ts +0 -1
  37. package/dist/bridge/ios.d.ts +0 -1
  38. package/dist/bridge/webkit-pending.d.ts +0 -1
  39. package/dist/build/bundle-optimizer.d.ts +0 -1
  40. package/dist/cli.js +666 -90
  41. package/dist/components/index.d.ts +6 -7
  42. package/dist/components/native.d.ts +0 -1
  43. package/dist/components/sidebar.d.ts +2 -81
  44. package/dist/dev/devtools.d.ts +0 -1
  45. package/dist/dev/error-overlay.d.ts +0 -1
  46. package/dist/dev/hot-reload.d.ts +0 -1
  47. package/dist/index.cjs +113 -3
  48. package/dist/index.d.cts +16 -16
  49. package/dist/index.d.ts +16 -16
  50. package/dist/index.js +112 -4
  51. package/dist/ios/src/index.d.ts +172 -0
  52. package/dist/ios/src/index.js +440 -42
  53. package/dist/ios/templates/CraftActivityAttributes.swift +14 -0
  54. package/dist/ios/templates/CraftApp.swift +1001 -73
  55. package/dist/ios/templates/CraftLiveActivityWidget.swift.template +62 -0
  56. package/dist/ios/templates/CraftWatchApp.swift.template +155 -0
  57. package/dist/ios/templates/Info.plist.template +10 -9
  58. package/dist/ios/templates/WatchApp.Info.plist.template +26 -0
  59. package/dist/ios/templates/WidgetExtension.Info.plist +31 -0
  60. package/dist/ios/templates/project.yml.template +12 -1
  61. package/dist/logging/index.d.ts +0 -1
  62. package/dist/mobile.d.ts +8 -0
  63. package/dist/mobile.js +755 -0
  64. package/dist/optimizations/index.d.ts +3 -4
  65. package/dist/package.d.ts +11 -1
  66. package/dist/performance/runtime.d.ts +0 -1
  67. package/dist/performance/startup.d.ts +0 -1
  68. package/dist/plugins/index.d.ts +0 -1
  69. package/dist/security/index.d.ts +0 -1
  70. package/dist/sidebar-types.d.ts +102 -0
  71. package/dist/signing/index.d.ts +0 -1
  72. package/dist/styles/headwind.d.ts +0 -1
  73. package/dist/styles/index.d.ts +9 -10
  74. package/dist/styles/sidebar-templates.d.ts +0 -1
  75. package/dist/styles/sidebars.d.ts +0 -1
  76. package/dist/types.d.ts +131 -1
  77. package/dist/updater/index.d.ts +0 -1
  78. package/dist/utils/audio.d.ts +0 -1
  79. package/dist/utils/index.d.ts +4 -5
  80. package/dist/utils/native.d.ts +1 -2
  81. package/dist/utils/react.d.ts +0 -1
  82. package/dist/utils/storage.d.ts +0 -1
  83. package/dist/utils/svelte.d.ts +0 -1
  84. package/dist/utils/timer.d.ts +0 -1
  85. package/dist/utils/vue.d.ts +0 -1
  86. package/package.json +32 -4
  87. package/dist/android/src/index.js.map +0 -10
  88. package/dist/api/android-advanced.d.ts.map +0 -1
  89. package/dist/api/app.d.ts.map +0 -1
  90. package/dist/api/clipboard.d.ts.map +0 -1
  91. package/dist/api/crypto.d.ts.map +0 -1
  92. package/dist/api/db.d.ts.map +0 -1
  93. package/dist/api/dialog.d.ts.map +0 -1
  94. package/dist/api/fs.d.ts.map +0 -1
  95. package/dist/api/http.d.ts.map +0 -1
  96. package/dist/api/index.d.ts.map +0 -1
  97. package/dist/api/ios-advanced.d.ts.map +0 -1
  98. package/dist/api/macos-advanced.d.ts.map +0 -1
  99. package/dist/api/media.d.ts.map +0 -1
  100. package/dist/api/mobile.d.ts.map +0 -1
  101. package/dist/api/process.d.ts.map +0 -1
  102. package/dist/api/sidebar.d.ts.map +0 -1
  103. package/dist/api/tray.d.ts.map +0 -1
  104. package/dist/api/window.d.ts.map +0 -1
  105. package/dist/api/windows-advanced.d.ts.map +0 -1
  106. package/dist/assets/index.d.ts.map +0 -1
  107. package/dist/binary-resolver.d.ts.map +0 -1
  108. package/dist/bridge/android.d.ts.map +0 -1
  109. package/dist/bridge/core.d.ts.map +0 -1
  110. package/dist/bridge/ids.d.ts.map +0 -1
  111. package/dist/bridge/ios.d.ts.map +0 -1
  112. package/dist/bridge/webkit-pending.d.ts.map +0 -1
  113. package/dist/build/bundle-optimizer.d.ts.map +0 -1
  114. package/dist/components/index.d.ts.map +0 -1
  115. package/dist/components/native.d.ts.map +0 -1
  116. package/dist/components/sidebar.d.ts.map +0 -1
  117. package/dist/dev/devtools.d.ts.map +0 -1
  118. package/dist/dev/error-overlay.d.ts.map +0 -1
  119. package/dist/dev/hot-reload.d.ts.map +0 -1
  120. package/dist/index.d.ts.map +0 -1
  121. package/dist/index.js.map +0 -45
  122. package/dist/ios/src/index.js.map +0 -10
  123. package/dist/logging/index.d.ts.map +0 -1
  124. package/dist/optimizations/index.d.ts.map +0 -1
  125. package/dist/package.d.ts.map +0 -1
  126. package/dist/package.test.d.ts +0 -2
  127. package/dist/package.test.d.ts.map +0 -1
  128. package/dist/performance/runtime.d.ts.map +0 -1
  129. package/dist/performance/startup.d.ts.map +0 -1
  130. package/dist/plugins/index.d.ts.map +0 -1
  131. package/dist/security/index.d.ts.map +0 -1
  132. package/dist/signing/index.d.ts.map +0 -1
  133. package/dist/styles/headwind.d.ts.map +0 -1
  134. package/dist/styles/index.d.ts.map +0 -1
  135. package/dist/styles/sidebar-templates.d.ts.map +0 -1
  136. package/dist/styles/sidebars.d.ts.map +0 -1
  137. package/dist/types.d.ts.map +0 -1
  138. package/dist/updater/index.d.ts.map +0 -1
  139. package/dist/utils/audio.d.ts.map +0 -1
  140. package/dist/utils/index.d.ts.map +0 -1
  141. package/dist/utils/native.d.ts.map +0 -1
  142. package/dist/utils/react.d.ts.map +0 -1
  143. package/dist/utils/storage.d.ts.map +0 -1
  144. package/dist/utils/svelte.d.ts.map +0 -1
  145. package/dist/utils/timer.d.ts.map +0 -1
  146. package/dist/utils/vue.d.ts.map +0 -1
@@ -49,6 +49,7 @@ import com.google.android.gms.location.LocationResult
49
49
  import com.google.android.gms.location.LocationServices
50
50
  import com.google.android.gms.location.Priority
51
51
  import com.google.android.play.core.review.ReviewManagerFactory
52
+ {{FIREBASE_IMPORT}}
52
53
  import android.content.ContentResolver
53
54
  import android.content.ContentUris
54
55
  import android.content.ContentValues
@@ -95,10 +96,12 @@ import android.graphics.BitmapFactory
95
96
  import java.io.ByteArrayOutputStream
96
97
  import java.io.File
97
98
  import java.util.concurrent.TimeUnit
99
+ import java.util.UUID
98
100
 
99
101
  class CraftBridge(
100
102
  private val activity: Activity,
101
- private val webView: WebView
103
+ private val webView: WebView,
104
+ private val healthConnect: CraftHealthConnect
102
105
  ) {
103
106
  private var speechRecognizer: SpeechRecognizer? = null
104
107
  private var isListening = false
@@ -167,42 +170,43 @@ class CraftBridge(
167
170
  window.craft = {
168
171
  platform: 'android',
169
172
  capabilities: {
170
- haptics: true,
171
- speechRecognition: ${SpeechRecognizer.isRecognitionAvailable(activity)},
172
- share: true,
173
- camera: true,
174
- biometric: ${isBiometricAvailable()},
175
- pushNotifications: true,
176
- secureStorage: true,
177
- geolocation: true,
178
- clipboard: true,
173
+ haptics: {{ENABLE_HAPTICS}},
174
+ speechRecognition: {{ENABLE_SPEECH}} && ${SpeechRecognizer.isRecognitionAvailable(activity)},
175
+ share: {{ENABLE_SHARE}},
176
+ camera: {{ENABLE_CAMERA}},
177
+ biometric: {{ENABLE_BIOMETRIC}} && ${isBiometricAvailable()},
178
+ pushNotifications: {{ENABLE_PUSH}},
179
+ secureStorage: {{ENABLE_SECURE_STORAGE}},
180
+ geolocation: {{ENABLE_GEOLOCATION}},
181
+ backgroundLocation: {{ENABLE_BACKGROUND_LOCATION}},
182
+ clipboard: false,
179
183
  deviceInfo: true,
180
- appBadge: true,
184
+ appBadge: {{ENABLE_PUSH}},
181
185
  networkStatus: true,
182
186
  appReview: true,
183
- flashlight: ${hasFlashlight()},
187
+ flashlight: {{ENABLE_CAMERA}} && ${hasFlashlight()},
184
188
  openURL: true,
185
189
  vibrationPattern: true,
186
190
  appState: true,
187
- contacts: true,
188
- calendar: true,
189
- localNotifications: true,
190
- inAppPurchase: true,
191
- keepAwake: true,
192
- orientationLock: true,
193
- deepLinks: true,
194
- qrScanner: true,
195
- filePicker: true,
196
- fileDownload: true,
197
- googleSignIn: true,
198
- audioRecording: true,
199
- videoRecording: true,
200
- motionSensors: true,
201
- localDatabase: true,
202
- bluetooth: true,
203
- nfc: ${NfcAdapter.getDefaultAdapter(activity) != null},
204
- fitness: true,
205
- screenCapture: true
191
+ contacts: false,
192
+ calendar: false,
193
+ localNotifications: {{ENABLE_PUSH}},
194
+ inAppPurchase: false,
195
+ keepAwake: {{ENABLE_KEEP_AWAKE}},
196
+ orientationLock: false,
197
+ deepLinks: {{ENABLE_DEEP_LINKS}},
198
+ qrScanner: false,
199
+ filePicker: false,
200
+ fileDownload: false,
201
+ googleSignIn: false,
202
+ audioRecording: false,
203
+ videoRecording: false,
204
+ motionSensors: false,
205
+ localDatabase: false,
206
+ bluetooth: false,
207
+ nfc: false,
208
+ health: {{ENABLE_HEALTH_CONNECT}},
209
+ screenCapture: false
206
210
  },
207
211
 
208
212
  // Haptic feedback
@@ -595,7 +599,7 @@ class CraftBridge(
595
599
  },
596
600
 
597
601
  // Fitness / Health
598
- fitness: {
602
+ health: {
599
603
  requestAuthorization: function(types) {
600
604
  return new Promise(function(resolve, reject) {
601
605
  window._craftFitnessAuthResolve = resolve;
@@ -603,11 +607,19 @@ class CraftBridge(
603
607
  CraftAndroid.requestFitnessAuthorization(JSON.stringify(types || []));
604
608
  });
605
609
  },
606
- getData: function(type, startDate, endDate) {
610
+ getData: function(type, options) {
611
+ options = options || {};
607
612
  return new Promise(function(resolve, reject) {
608
613
  window._craftFitnessDataResolve = resolve;
609
614
  window._craftFitnessDataReject = reject;
610
- CraftAndroid.getFitnessData(type, startDate || 0, endDate || 0);
615
+ CraftAndroid.getFitnessData(type, options.startDate || 0, options.endDate || 0);
616
+ });
617
+ },
618
+ saveWorkout: function(workout) {
619
+ return new Promise(function(resolve, reject) {
620
+ window._craftFitnessSaveResolve = resolve;
621
+ window._craftFitnessSaveReject = reject;
622
+ CraftAndroid.saveHealthWorkout(JSON.stringify(workout || {}));
611
623
  });
612
624
  }
613
625
  },
@@ -1202,6 +1214,73 @@ class CraftBridge(
1202
1214
  }
1203
1215
  };
1204
1216
 
1217
+ (function installCraftMobileContract(craft) {
1218
+ var legacyShare = craft.share.bind(craft);
1219
+ var legacyOpenCamera = craft.openCamera.bind(craft);
1220
+ var legacyPickImage = craft.pickImage.bind(craft);
1221
+ var legacyAuthenticate = craft.authenticate.bind(craft);
1222
+ var legacySecureStore = craft.secureStore;
1223
+ craft.contractVersion = '1.0.0';
1224
+ craft.device = {
1225
+ getInfo: function() { return Promise.resolve(craft.getDeviceInfo()); },
1226
+ getCapabilities: function() { return Promise.resolve(Object.assign({}, craft.capabilities)); }
1227
+ };
1228
+ craft.haptics = {
1229
+ impact: function(style) { craft.haptic(style || 'medium'); return Promise.resolve(); },
1230
+ notification: function(type) { craft.haptic(type === 'error' ? 'heavy' : 'light'); return Promise.resolve(); },
1231
+ selection: function() { craft.haptic('selection'); return Promise.resolve(); },
1232
+ vibrate: function(pattern) { craft.vibrate(pattern || []); return Promise.resolve(); }
1233
+ };
1234
+ craft.camera = {
1235
+ takePicture: function() { return legacyOpenCamera().then(normalizePhoto); },
1236
+ pickImage: function() { return legacyPickImage().then(normalizePhoto); },
1237
+ pickMultiple: function() { return legacyPickImage().then(function(photo) { return [normalizePhoto(photo)]; }); },
1238
+ isAvailable: function() { return Promise.resolve(!!craft.capabilities.camera); }
1239
+ };
1240
+ craft.biometrics = {
1241
+ isAvailable: function() { return Promise.resolve(!!craft.capabilities.biometric); },
1242
+ getBiometricType: function() { return Promise.resolve(craft.capabilities.biometric ? 'fingerprint' : null); },
1243
+ authenticate: function(reason) { return legacyAuthenticate(reason); }
1244
+ };
1245
+ craft.secureStorage = {
1246
+ set: function(key, value) { return Promise.resolve(legacySecureStore.set(key, value)).then(function() {}); },
1247
+ get: function(key) { return Promise.resolve(legacySecureStore.get(key)); },
1248
+ delete: function(key) { return Promise.resolve(legacySecureStore.remove(key)).then(function() {}); },
1249
+ clear: function() { return Promise.resolve(CraftAndroid.secureClear()).then(function() {}); }
1250
+ };
1251
+ craft.location = {
1252
+ getCurrentPosition: craft.getCurrentPosition,
1253
+ watchPosition: craft.watchPosition,
1254
+ clearWatch: craft.clearWatch,
1255
+ startRecording: function(options) { return Promise.resolve(JSON.parse(CraftAndroid.startLocationRecording(JSON.stringify(options || {})))); },
1256
+ pauseRecording: function() { return Promise.resolve(JSON.parse(CraftAndroid.pauseLocationRecording())); },
1257
+ resumeRecording: function() { return Promise.resolve(JSON.parse(CraftAndroid.resumeLocationRecording())); },
1258
+ stopRecording: function() { return Promise.resolve(JSON.parse(CraftAndroid.stopLocationRecording())); },
1259
+ getRecordingState: function() { return Promise.resolve(JSON.parse(CraftAndroid.getLocationRecordingState())); },
1260
+ readRecording: function() { return Promise.resolve(JSON.parse(CraftAndroid.readLocationRecording())); }
1261
+ };
1262
+ var shareApi = function(text, title) { return legacyShare(text, title); };
1263
+ shareApi.share = function(options) {
1264
+ options = options || {};
1265
+ return Promise.resolve(legacyShare([options.text, options.url].filter(Boolean).join(' '), options.title || ''));
1266
+ };
1267
+ craft.share = shareApi;
1268
+ craft.lifecycle = {
1269
+ getState: function() { return document.visibilityState === 'visible' ? 'active' : 'background'; },
1270
+ onStateChange: function(callback) {
1271
+ var handler = function() { callback(document.visibilityState === 'visible' ? 'active' : 'background'); };
1272
+ document.addEventListener('visibilitychange', handler);
1273
+ return function() { document.removeEventListener('visibilitychange', handler); };
1274
+ }
1275
+ };
1276
+ function normalizePhoto(photo) {
1277
+ if (photo && typeof photo === 'object') return photo;
1278
+ var value = String(photo || '');
1279
+ var base64 = value.indexOf(',') >= 0 ? value.split(',').pop() : value;
1280
+ return {base64: base64, uri: value, width: 0, height: 0, mimeType: 'image/jpeg'};
1281
+ }
1282
+ })(window.craft);
1283
+
1205
1284
  // Dispatch ready event
1206
1285
  window.dispatchEvent(new CustomEvent('craftReady', {detail: window.craft}));
1207
1286
  console.log('Craft Android bridge initialized');
@@ -1455,24 +1534,7 @@ class CraftBridge(
1455
1534
 
1456
1535
  @JavascriptInterface
1457
1536
  fun registerPush() {
1458
- // This would integrate with Firebase Cloud Messaging
1459
- // For now, return a placeholder token
1460
- activity.runOnUiThread {
1461
- // In a real implementation, you would use Firebase:
1462
- // FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
1463
- // if (task.isSuccessful) {
1464
- // val token = task.result
1465
- // webView.evaluateJavascript("window._craftPushResolve('$token')", null)
1466
- // } else {
1467
- // webView.evaluateJavascript("window._craftPushReject('Failed to get token')", null)
1468
- // }
1469
- // }
1470
-
1471
- webView.evaluateJavascript(
1472
- "window._craftPushResolve && window._craftPushResolve('push-token-placeholder')",
1473
- null
1474
- )
1475
- }
1537
+ {{REGISTER_PUSH_IMPLEMENTATION}}
1476
1538
  }
1477
1539
 
1478
1540
  // ==================== Secure Storage ====================
@@ -1506,6 +1568,16 @@ class CraftBridge(
1506
1568
  }
1507
1569
  }
1508
1570
 
1571
+ @JavascriptInterface
1572
+ fun secureClear(): Boolean {
1573
+ return try {
1574
+ securePrefs.edit().clear().apply()
1575
+ true
1576
+ } catch (e: Exception) {
1577
+ false
1578
+ }
1579
+ }
1580
+
1509
1581
  // ==================== Logging ====================
1510
1582
 
1511
1583
  @JavascriptInterface
@@ -1654,6 +1726,58 @@ class CraftBridge(
1654
1726
  locationCallback = null
1655
1727
  }
1656
1728
 
1729
+ @JavascriptInterface
1730
+ fun startLocationRecording(optionsJson: String): String {
1731
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
1732
+ return JSONObject().apply {
1733
+ put("id", JSONObject.NULL)
1734
+ put("active", false)
1735
+ put("paused", false)
1736
+ put("startedAt", JSONObject.NULL)
1737
+ put("sampleCount", 0)
1738
+ }.toString()
1739
+ }
1740
+ val id = UUID.randomUUID().toString()
1741
+ CraftLocationRecordingStore.start(activity, id, System.currentTimeMillis())
1742
+ ContextCompat.startForegroundService(
1743
+ activity,
1744
+ Intent(activity, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_START)
1745
+ )
1746
+ return CraftLocationRecordingStore.state(activity).toString()
1747
+ }
1748
+
1749
+ @JavascriptInterface
1750
+ fun pauseLocationRecording(): String {
1751
+ CraftLocationRecordingStore.setPaused(activity, true)
1752
+ return CraftLocationRecordingStore.state(activity).toString()
1753
+ }
1754
+
1755
+ @JavascriptInterface
1756
+ fun resumeLocationRecording(): String {
1757
+ CraftLocationRecordingStore.setPaused(activity, false)
1758
+ if (CraftLocationRecordingStore.isActive(activity)) {
1759
+ ContextCompat.startForegroundService(
1760
+ activity,
1761
+ Intent(activity, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_START)
1762
+ )
1763
+ }
1764
+ return CraftLocationRecordingStore.state(activity).toString()
1765
+ }
1766
+
1767
+ @JavascriptInterface
1768
+ fun stopLocationRecording(): String {
1769
+ CraftLocationRecordingStore.stop(activity)
1770
+ val result = CraftLocationRecordingStore.state(activity, includeLocations = true).toString()
1771
+ activity.stopService(Intent(activity, LocationRecordingService::class.java).setAction(LocationRecordingService.ACTION_STOP))
1772
+ return result
1773
+ }
1774
+
1775
+ @JavascriptInterface
1776
+ fun getLocationRecordingState(): String = CraftLocationRecordingStore.state(activity).toString()
1777
+
1778
+ @JavascriptInterface
1779
+ fun readLocationRecording(): String = CraftLocationRecordingStore.locations(activity).toString()
1780
+
1657
1781
  // ==================== Clipboard ====================
1658
1782
 
1659
1783
  @JavascriptInterface
@@ -1824,8 +1948,9 @@ class CraftBridge(
1824
1948
  }
1825
1949
  } else {
1826
1950
  activity.runOnUiThread {
1951
+ val message = JSONObject.quote(task.exception?.message ?: "Review flow failed")
1827
1952
  webView.evaluateJavascript(
1828
- "window._craftReviewReject && window._craftReviewReject('${task.exception?.message ?: \"Review flow failed\"}')",
1953
+ "window._craftReviewReject && window._craftReviewReject($message)",
1829
1954
  null
1830
1955
  )
1831
1956
  }
@@ -2769,17 +2894,24 @@ class CraftBridge(
2769
2894
 
2770
2895
  @JavascriptInterface
2771
2896
  fun requestFitnessAuthorization(typesJson: String) {
2772
- // Google Fit requires configuration
2773
- activity.runOnUiThread {
2774
- webView.evaluateJavascript("window._craftFitnessAuthReject && window._craftFitnessAuthReject('Fitness requires Google Fit configuration')", null)
2775
- }
2897
+ healthConnect.requestAuthorization(typesJson)
2776
2898
  }
2777
2899
 
2778
2900
  @JavascriptInterface
2779
2901
  fun getFitnessData(type: String, startDate: Long, endDate: Long) {
2780
- activity.runOnUiThread {
2781
- webView.evaluateJavascript("window._craftFitnessDataReject && window._craftFitnessDataReject('Fitness requires Google Fit configuration')", null)
2782
- }
2902
+ healthConnect.getData(type, startDate, endDate)
2903
+ }
2904
+
2905
+ @JavascriptInterface
2906
+ fun saveHealthWorkout(workoutJson: String) {
2907
+ healthConnect.saveWorkout(workoutJson)
2908
+ }
2909
+
2910
+ fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean =
2911
+ healthConnect.onActivityResult(requestCode, resultCode, data)
2912
+
2913
+ fun close() {
2914
+ healthConnect.close()
2783
2915
  }
2784
2916
 
2785
2917
  // ==================== Screen Capture ====================
@@ -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
+ }