@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.
@@ -0,0 +1,3880 @@
1
+ package {{PACKAGE_NAME}}
2
+
3
+ import android.Manifest
4
+ import android.app.Activity
5
+ import android.content.ClipData
6
+ import android.content.ClipboardManager
7
+ import android.content.Context
8
+ import android.content.Intent
9
+ import android.content.pm.PackageManager
10
+ import android.graphics.Bitmap
11
+ import android.hardware.camera2.CameraAccessException
12
+ import android.hardware.camera2.CameraCharacteristics
13
+ import android.hardware.camera2.CameraManager
14
+ import android.location.Location
15
+ import android.net.ConnectivityManager
16
+ import android.net.Network
17
+ import android.net.NetworkCapabilities
18
+ import android.net.NetworkRequest
19
+ import android.net.Uri
20
+ import android.os.Build
21
+ import android.os.Bundle
22
+ import android.os.Looper
23
+ import android.os.VibrationEffect
24
+ import android.os.Vibrator
25
+ import android.os.VibratorManager
26
+ import android.provider.MediaStore
27
+ import android.provider.Settings
28
+ import android.speech.RecognitionListener
29
+ import android.speech.RecognizerIntent
30
+ import android.speech.SpeechRecognizer
31
+ import android.util.DisplayMetrics
32
+ import android.webkit.JavascriptInterface
33
+ import android.webkit.WebView
34
+ import androidx.biometric.BiometricManager
35
+ import androidx.biometric.BiometricPrompt
36
+ import androidx.core.app.ActivityCompat
37
+ import androidx.core.app.NotificationManagerCompat
38
+ import androidx.core.content.ContextCompat
39
+ import androidx.fragment.app.FragmentActivity
40
+ import androidx.lifecycle.Lifecycle
41
+ import androidx.lifecycle.LifecycleEventObserver
42
+ import androidx.lifecycle.ProcessLifecycleOwner
43
+ import androidx.security.crypto.EncryptedSharedPreferences
44
+ import androidx.security.crypto.MasterKey
45
+ import com.google.android.gms.location.FusedLocationProviderClient
46
+ import com.google.android.gms.location.LocationCallback
47
+ import com.google.android.gms.location.LocationRequest
48
+ import com.google.android.gms.location.LocationResult
49
+ import com.google.android.gms.location.LocationServices
50
+ import com.google.android.gms.location.Priority
51
+ import com.google.android.play.core.review.ReviewManagerFactory
52
+ {{FIREBASE_IMPORT}}
53
+ import android.content.ContentResolver
54
+ import android.content.ContentUris
55
+ import android.content.ContentValues
56
+ import android.database.Cursor
57
+ import android.provider.CalendarContract
58
+ import android.provider.ContactsContract
59
+ import android.app.NotificationChannel
60
+ import android.app.NotificationManager
61
+ import android.app.PendingIntent
62
+ import android.view.WindowManager
63
+ import android.content.pm.ActivityInfo
64
+ import com.android.billingclient.api.*
65
+ import org.json.JSONObject
66
+ import org.json.JSONArray
67
+ import java.util.Locale
68
+ import java.util.concurrent.Executor
69
+ import java.util.Calendar
70
+ import java.util.TimeZone
71
+ import android.media.MediaRecorder
72
+ import android.hardware.Sensor
73
+ import android.hardware.SensorEvent
74
+ import android.hardware.SensorEventListener
75
+ import android.hardware.SensorManager
76
+ import android.database.sqlite.SQLiteDatabase
77
+ import android.bluetooth.BluetoothAdapter
78
+ import android.bluetooth.BluetoothManager
79
+ import android.bluetooth.le.BluetoothLeScanner
80
+ import android.bluetooth.le.ScanCallback
81
+ import android.bluetooth.le.ScanResult
82
+ import android.nfc.NfcAdapter
83
+ import android.nfc.tech.Ndef
84
+ import android.app.DownloadManager
85
+ import android.view.View
86
+ import android.util.Base64
87
+ import com.google.mlkit.vision.barcode.BarcodeScanning
88
+ import com.google.mlkit.vision.common.InputImage
89
+ import com.google.mlkit.vision.label.ImageLabeling
90
+ import com.google.mlkit.vision.label.defaults.ImageLabelerOptions
91
+ import com.google.mlkit.vision.objects.ObjectDetection
92
+ import com.google.mlkit.vision.objects.defaults.ObjectDetectorOptions
93
+ import com.google.mlkit.vision.text.TextRecognition
94
+ import com.google.mlkit.vision.text.latin.TextRecognizerOptions
95
+ import android.graphics.BitmapFactory
96
+ import java.io.ByteArrayOutputStream
97
+ import java.io.File
98
+ import java.util.concurrent.TimeUnit
99
+ import java.util.UUID
100
+
101
+ class CraftBridge(
102
+ private val activity: Activity,
103
+ private val webView: WebView,
104
+ private val healthConnect: CraftHealthConnect
105
+ ) {
106
+ private var speechRecognizer: SpeechRecognizer? = null
107
+ private var isListening = false
108
+ private val mainExecutor: Executor = ContextCompat.getMainExecutor(activity)
109
+
110
+ // Location tracking
111
+ private var fusedLocationClient: FusedLocationProviderClient? = null
112
+ private var locationCallback: LocationCallback? = null
113
+ private var watchId = 0
114
+
115
+ // Network monitoring
116
+ private var networkCallback: ConnectivityManager.NetworkCallback? = null
117
+
118
+ // Flashlight state
119
+ private var isFlashlightOn = false
120
+
121
+ // App state tracking
122
+ private var currentAppState = "active"
123
+
124
+ // Keep awake
125
+ private var isKeepingAwake = false
126
+
127
+ // Billing client for in-app purchases
128
+ private var billingClient: BillingClient? = null
129
+
130
+ // Notification channel ID
131
+ private val notificationChannelId = "craft_notifications"
132
+
133
+ // Audio recording
134
+ private var mediaRecorder: MediaRecorder? = null
135
+ private var audioFile: File? = null
136
+
137
+ // Motion sensors
138
+ private var sensorManager: SensorManager? = null
139
+ private var accelerometer: Sensor? = null
140
+ private var gyroscope: Sensor? = null
141
+ private var sensorListener: SensorEventListener? = null
142
+
143
+ // SQLite database
144
+ private var database: SQLiteDatabase? = null
145
+
146
+ // Bluetooth
147
+ private var bluetoothAdapter: BluetoothAdapter? = null
148
+ private var bluetoothScanner: BluetoothLeScanner? = null
149
+ private var bleScanCallback: ScanCallback? = null
150
+
151
+ // Encrypted shared preferences for secure storage
152
+ private val masterKey by lazy {
153
+ MasterKey.Builder(activity)
154
+ .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
155
+ .build()
156
+ }
157
+
158
+ private val securePrefs by lazy {
159
+ EncryptedSharedPreferences.create(
160
+ activity,
161
+ "craft_secure_prefs",
162
+ masterKey,
163
+ EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
164
+ EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
165
+ )
166
+ }
167
+
168
+ fun injectBridge() {
169
+ val script = """
170
+ window.craft = {
171
+ platform: 'android',
172
+ capabilities: {
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,
183
+ deviceInfo: true,
184
+ appBadge: {{ENABLE_PUSH}},
185
+ networkStatus: true,
186
+ appReview: true,
187
+ flashlight: {{ENABLE_CAMERA}} && ${hasFlashlight()},
188
+ openURL: true,
189
+ vibrationPattern: true,
190
+ appState: 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
210
+ },
211
+
212
+ // Haptic feedback
213
+ haptic: function(style) {
214
+ CraftAndroid.haptic(style || 'medium');
215
+ },
216
+
217
+ // Speech recognition
218
+ startListening: function() {
219
+ CraftAndroid.startListening();
220
+ },
221
+ stopListening: function() {
222
+ CraftAndroid.stopListening();
223
+ },
224
+
225
+ // Share
226
+ share: function(text, title) {
227
+ CraftAndroid.share(text, title || '');
228
+ },
229
+
230
+ // Camera
231
+ openCamera: function() {
232
+ return new Promise(function(resolve, reject) {
233
+ window._craftCameraResolve = resolve;
234
+ window._craftCameraReject = reject;
235
+ CraftAndroid.openCamera();
236
+ });
237
+ },
238
+ pickImage: function() {
239
+ return new Promise(function(resolve, reject) {
240
+ window._craftGalleryResolve = resolve;
241
+ window._craftGalleryReject = reject;
242
+ CraftAndroid.pickImage();
243
+ });
244
+ },
245
+
246
+ // Biometric auth
247
+ authenticate: function(reason) {
248
+ return new Promise(function(resolve, reject) {
249
+ window._craftBiometricResolve = resolve;
250
+ window._craftBiometricReject = reject;
251
+ CraftAndroid.authenticate(reason || 'Authenticate to continue');
252
+ });
253
+ },
254
+
255
+ // Push notifications
256
+ registerPush: function() {
257
+ return new Promise(function(resolve, reject) {
258
+ window._craftPushResolve = resolve;
259
+ window._craftPushReject = reject;
260
+ CraftAndroid.registerPush();
261
+ });
262
+ },
263
+
264
+ // Secure storage
265
+ secureStore: {
266
+ set: function(key, value) {
267
+ return CraftAndroid.secureSet(key, value);
268
+ },
269
+ get: function(key) {
270
+ return CraftAndroid.secureGet(key);
271
+ },
272
+ remove: function(key) {
273
+ return CraftAndroid.secureRemove(key);
274
+ }
275
+ },
276
+
277
+ // Geolocation
278
+ getCurrentPosition: function(options) {
279
+ return new Promise(function(resolve, reject) {
280
+ window._craftLocationResolve = resolve;
281
+ window._craftLocationReject = reject;
282
+ CraftAndroid.getCurrentPosition(JSON.stringify(options || {}));
283
+ });
284
+ },
285
+ watchPosition: function(callback, options) {
286
+ var watchId = CraftAndroid.watchPosition(JSON.stringify(options || {}));
287
+ window['_craftLocationWatch_' + watchId] = callback;
288
+ return watchId;
289
+ },
290
+ clearWatch: function(watchId) {
291
+ CraftAndroid.clearWatch(watchId);
292
+ delete window['_craftLocationWatch_' + watchId];
293
+ },
294
+
295
+ // Clipboard
296
+ clipboard: {
297
+ read: function() {
298
+ return new Promise(function(resolve) {
299
+ resolve(CraftAndroid.clipboardRead());
300
+ });
301
+ },
302
+ write: function(text) {
303
+ return new Promise(function(resolve) {
304
+ resolve(CraftAndroid.clipboardWrite(text));
305
+ });
306
+ }
307
+ },
308
+
309
+ // Device Info
310
+ getDeviceInfo: function() {
311
+ return JSON.parse(CraftAndroid.getDeviceInfo());
312
+ },
313
+
314
+ // App Badge
315
+ setBadge: function(count) {
316
+ CraftAndroid.setBadge(count);
317
+ },
318
+ clearBadge: function() {
319
+ CraftAndroid.clearBadge();
320
+ },
321
+
322
+ // Network Status
323
+ getNetworkStatus: function() {
324
+ return JSON.parse(CraftAndroid.getNetworkStatus());
325
+ },
326
+ onNetworkChange: function(callback) {
327
+ window._craftNetworkChangeCallback = callback;
328
+ CraftAndroid.startNetworkMonitoring();
329
+ },
330
+ offNetworkChange: function() {
331
+ window._craftNetworkChangeCallback = null;
332
+ CraftAndroid.stopNetworkMonitoring();
333
+ },
334
+
335
+ // App Review
336
+ requestReview: function() {
337
+ return new Promise(function(resolve, reject) {
338
+ window._craftReviewResolve = resolve;
339
+ window._craftReviewReject = reject;
340
+ CraftAndroid.requestReview();
341
+ });
342
+ },
343
+
344
+ // Flashlight
345
+ setFlashlight: function(on) {
346
+ return CraftAndroid.setFlashlight(on);
347
+ },
348
+ toggleFlashlight: function() {
349
+ return CraftAndroid.toggleFlashlight();
350
+ },
351
+
352
+ // Open URL
353
+ openURL: function(url) {
354
+ return CraftAndroid.openURL(url);
355
+ },
356
+
357
+ // Vibration Pattern
358
+ vibrate: function(pattern) {
359
+ CraftAndroid.vibrate(JSON.stringify(pattern));
360
+ },
361
+
362
+ // App State
363
+ getAppState: function() {
364
+ return CraftAndroid.getAppState();
365
+ },
366
+ onAppStateChange: function(callback) {
367
+ window._craftAppStateCallback = callback;
368
+ CraftAndroid.startAppStateMonitoring();
369
+ },
370
+ offAppStateChange: function() {
371
+ window._craftAppStateCallback = null;
372
+ CraftAndroid.stopAppStateMonitoring();
373
+ },
374
+
375
+ // Contacts
376
+ contacts: {
377
+ getAll: function() {
378
+ return new Promise(function(resolve, reject) {
379
+ window._craftContactsResolve = resolve;
380
+ window._craftContactsReject = reject;
381
+ CraftAndroid.getContacts();
382
+ });
383
+ },
384
+ add: function(contact) {
385
+ return new Promise(function(resolve, reject) {
386
+ window._craftAddContactResolve = resolve;
387
+ window._craftAddContactReject = reject;
388
+ CraftAndroid.addContact(JSON.stringify(contact));
389
+ });
390
+ }
391
+ },
392
+
393
+ // Calendar
394
+ calendar: {
395
+ getEvents: function(startDate, endDate) {
396
+ return new Promise(function(resolve, reject) {
397
+ window._craftCalendarResolve = resolve;
398
+ window._craftCalendarReject = reject;
399
+ CraftAndroid.getCalendarEvents(startDate || 0, endDate || 0);
400
+ });
401
+ },
402
+ createEvent: function(event) {
403
+ return new Promise(function(resolve, reject) {
404
+ window._craftCreateEventResolve = resolve;
405
+ window._craftCreateEventReject = reject;
406
+ CraftAndroid.createCalendarEvent(JSON.stringify(event));
407
+ });
408
+ },
409
+ deleteEvent: function(eventId) {
410
+ return new Promise(function(resolve, reject) {
411
+ window._craftDeleteEventResolve = resolve;
412
+ window._craftDeleteEventReject = reject;
413
+ CraftAndroid.deleteCalendarEvent(eventId);
414
+ });
415
+ }
416
+ },
417
+
418
+ // Local Notifications
419
+ notifications: {
420
+ schedule: function(notification) {
421
+ return new Promise(function(resolve, reject) {
422
+ window._craftNotifResolve = resolve;
423
+ window._craftNotifReject = reject;
424
+ CraftAndroid.scheduleNotification(JSON.stringify(notification));
425
+ });
426
+ },
427
+ cancel: function(id) {
428
+ CraftAndroid.cancelNotification(id);
429
+ },
430
+ cancelAll: function() {
431
+ CraftAndroid.cancelAllNotifications();
432
+ }
433
+ },
434
+
435
+ // In-App Purchase
436
+ iap: {
437
+ getProducts: function(productIds) {
438
+ return new Promise(function(resolve, reject) {
439
+ window._craftProductsResolve = resolve;
440
+ window._craftProductsReject = reject;
441
+ CraftAndroid.getProducts(JSON.stringify(productIds));
442
+ });
443
+ },
444
+ purchase: function(productId) {
445
+ return new Promise(function(resolve, reject) {
446
+ window._craftPurchaseResolve = resolve;
447
+ window._craftPurchaseReject = reject;
448
+ CraftAndroid.purchase(productId);
449
+ });
450
+ },
451
+ restore: function() {
452
+ return new Promise(function(resolve, reject) {
453
+ window._craftRestoreResolve = resolve;
454
+ window._craftRestoreReject = reject;
455
+ CraftAndroid.restorePurchases();
456
+ });
457
+ }
458
+ },
459
+
460
+ // Keep Awake
461
+ setKeepAwake: function(enabled) {
462
+ return CraftAndroid.setKeepAwake(enabled);
463
+ },
464
+
465
+ // Orientation Lock
466
+ lockOrientation: function(orientation) {
467
+ return CraftAndroid.lockOrientation(orientation);
468
+ },
469
+ unlockOrientation: function() {
470
+ return CraftAndroid.unlockOrientation();
471
+ },
472
+
473
+ // Deep Links
474
+ onDeepLink: function(callback) {
475
+ window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
476
+ },
477
+
478
+ // QR/Barcode Scanner
479
+ scanQRCode: function() {
480
+ return new Promise(function(resolve, reject) {
481
+ window._craftQRResolve = resolve;
482
+ window._craftQRReject = reject;
483
+ CraftAndroid.scanQRCode();
484
+ });
485
+ },
486
+
487
+ // File Picker
488
+ pickFile: function(types) {
489
+ return new Promise(function(resolve, reject) {
490
+ window._craftFileResolve = resolve;
491
+ window._craftFileReject = reject;
492
+ CraftAndroid.pickFile(JSON.stringify(types || []));
493
+ });
494
+ },
495
+
496
+ // File Download
497
+ downloadFile: function(url, filename) {
498
+ return new Promise(function(resolve, reject) {
499
+ window._craftDownloadResolve = resolve;
500
+ window._craftDownloadReject = reject;
501
+ CraftAndroid.downloadFile(url, filename);
502
+ });
503
+ },
504
+ saveFile: function(data, filename) {
505
+ return new Promise(function(resolve, reject) {
506
+ window._craftSaveResolve = resolve;
507
+ window._craftSaveReject = reject;
508
+ CraftAndroid.saveFile(data, filename);
509
+ });
510
+ },
511
+
512
+ // Google Sign In
513
+ signInWithGoogle: function() {
514
+ return new Promise(function(resolve, reject) {
515
+ window._craftGoogleResolve = resolve;
516
+ window._craftGoogleReject = reject;
517
+ CraftAndroid.signInWithGoogle();
518
+ });
519
+ },
520
+
521
+ // Audio Recording
522
+ startAudioRecording: function() {
523
+ return new Promise(function(resolve, reject) {
524
+ window._craftAudioResolve = resolve;
525
+ window._craftAudioReject = reject;
526
+ CraftAndroid.startAudioRecording();
527
+ });
528
+ },
529
+ stopAudioRecording: function() {
530
+ return new Promise(function(resolve, reject) {
531
+ window._craftAudioStopResolve = resolve;
532
+ window._craftAudioStopReject = reject;
533
+ CraftAndroid.stopAudioRecording();
534
+ });
535
+ },
536
+
537
+ // Video Recording
538
+ startVideoRecording: function() {
539
+ return new Promise(function(resolve, reject) {
540
+ window._craftVideoResolve = resolve;
541
+ window._craftVideoReject = reject;
542
+ CraftAndroid.startVideoRecording();
543
+ });
544
+ },
545
+
546
+ // Motion Sensors
547
+ startMotionUpdates: function(interval) {
548
+ return CraftAndroid.startMotionUpdates(interval || 100);
549
+ },
550
+ stopMotionUpdates: function() {
551
+ CraftAndroid.stopMotionUpdates();
552
+ },
553
+ onMotionUpdate: function(callback) {
554
+ window.addEventListener('craftMotionUpdate', function(e) { callback(e.detail); });
555
+ },
556
+
557
+ // Local Database
558
+ db: {
559
+ execute: function(sql, params) {
560
+ return new Promise(function(resolve, reject) {
561
+ window._craftDbExecResolve = resolve;
562
+ window._craftDbExecReject = reject;
563
+ CraftAndroid.dbExecute(sql, JSON.stringify(params || []));
564
+ });
565
+ },
566
+ query: function(sql, params) {
567
+ return new Promise(function(resolve, reject) {
568
+ window._craftDbQueryResolve = resolve;
569
+ window._craftDbQueryReject = reject;
570
+ CraftAndroid.dbQuery(sql, JSON.stringify(params || []));
571
+ });
572
+ }
573
+ },
574
+
575
+ // Bluetooth
576
+ bluetooth: {
577
+ startScan: function() {
578
+ return new Promise(function(resolve, reject) {
579
+ window._craftBleResolve = resolve;
580
+ window._craftBleReject = reject;
581
+ CraftAndroid.startBluetoothScan();
582
+ });
583
+ },
584
+ stopScan: function() {
585
+ CraftAndroid.stopBluetoothScan();
586
+ },
587
+ onDevice: function(callback) {
588
+ window.addEventListener('craftBluetoothDevice', function(e) { callback(e.detail); });
589
+ }
590
+ },
591
+
592
+ // NFC
593
+ scanNFC: function() {
594
+ return new Promise(function(resolve, reject) {
595
+ window._craftNfcResolve = resolve;
596
+ window._craftNfcReject = reject;
597
+ CraftAndroid.scanNFC();
598
+ });
599
+ },
600
+
601
+ // Fitness / Health
602
+ health: {
603
+ requestAuthorization: function(types) {
604
+ return new Promise(function(resolve, reject) {
605
+ window._craftFitnessAuthResolve = resolve;
606
+ window._craftFitnessAuthReject = reject;
607
+ CraftAndroid.requestFitnessAuthorization(JSON.stringify(types || []));
608
+ });
609
+ },
610
+ getData: function(type, options) {
611
+ options = options || {};
612
+ return new Promise(function(resolve, reject) {
613
+ window._craftFitnessDataResolve = resolve;
614
+ window._craftFitnessDataReject = reject;
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 || {}));
623
+ });
624
+ }
625
+ },
626
+
627
+ // Screen Capture
628
+ takeScreenshot: function() {
629
+ return new Promise(function(resolve, reject) {
630
+ window._craftScreenshotResolve = resolve;
631
+ window._craftScreenshotReject = reject;
632
+ CraftAndroid.takeScreenshot();
633
+ });
634
+ },
635
+
636
+ // Background Tasks
637
+ backgroundTask: {
638
+ register: function(taskId) {
639
+ return new Promise(function(resolve, reject) {
640
+ window._craftBgTaskResolve = resolve;
641
+ window._craftBgTaskReject = reject;
642
+ CraftAndroid.registerBackgroundTask(taskId);
643
+ });
644
+ },
645
+ schedule: function(taskId, options) {
646
+ options = options || {};
647
+ return new Promise(function(resolve, reject) {
648
+ window._craftBgTaskResolve = resolve;
649
+ window._craftBgTaskReject = reject;
650
+ CraftAndroid.scheduleBackgroundTask(
651
+ taskId,
652
+ options.delay || 900,
653
+ options.requiresNetwork || false,
654
+ options.requiresCharging || false
655
+ );
656
+ });
657
+ },
658
+ cancel: function(taskId) {
659
+ return new Promise(function(resolve, reject) {
660
+ window._craftBgTaskResolve = resolve;
661
+ window._craftBgTaskReject = reject;
662
+ CraftAndroid.cancelBackgroundTask(taskId);
663
+ });
664
+ },
665
+ cancelAll: function() {
666
+ return new Promise(function(resolve, reject) {
667
+ window._craftBgTaskResolve = resolve;
668
+ window._craftBgTaskReject = reject;
669
+ CraftAndroid.cancelAllBackgroundTasks();
670
+ });
671
+ }
672
+ },
673
+
674
+ // PDF Viewer
675
+ openPDF: function(source, page) {
676
+ return new Promise(function(resolve, reject) {
677
+ window._craftPDFResolve = resolve;
678
+ window._craftPDFReject = reject;
679
+ CraftAndroid.openPDF(source, page || 0);
680
+ });
681
+ },
682
+ closePDF: function() {
683
+ return new Promise(function(resolve, reject) {
684
+ window._craftPDFResolve = resolve;
685
+ window._craftPDFReject = reject;
686
+ CraftAndroid.closePDF();
687
+ });
688
+ },
689
+
690
+ // Contacts Picker
691
+ pickContact: function(options) {
692
+ options = options || {};
693
+ return new Promise(function(resolve, reject) {
694
+ window._craftPickContactResolve = resolve;
695
+ window._craftPickContactReject = reject;
696
+ CraftAndroid.pickContact(options.multiple || false);
697
+ });
698
+ },
699
+
700
+ // App Shortcuts
701
+ shortcuts: {
702
+ set: function(shortcuts) {
703
+ return new Promise(function(resolve, reject) {
704
+ window._craftShortcutsResolve = resolve;
705
+ window._craftShortcutsReject = reject;
706
+ CraftAndroid.setShortcuts(JSON.stringify(shortcuts));
707
+ });
708
+ },
709
+ clear: function() {
710
+ return new Promise(function(resolve, reject) {
711
+ window._craftShortcutsResolve = resolve;
712
+ window._craftShortcutsReject = reject;
713
+ CraftAndroid.clearShortcuts();
714
+ });
715
+ },
716
+ onShortcut: function(callback) {
717
+ window.addEventListener('craftShortcut', function(e) { callback(e.detail); });
718
+ }
719
+ },
720
+
721
+ // Shared Preferences (Android equivalent of Keychain Sharing)
722
+ sharedKeychain: {
723
+ set: function(key, value, group) {
724
+ return new Promise(function(resolve, reject) {
725
+ window._craftSharedKeychainResolve = resolve;
726
+ window._craftSharedKeychainReject = reject;
727
+ CraftAndroid.setSharedItem(key, value, group || '');
728
+ });
729
+ },
730
+ get: function(key, group) {
731
+ return new Promise(function(resolve, reject) {
732
+ window._craftSharedKeychainResolve = resolve;
733
+ window._craftSharedKeychainReject = reject;
734
+ CraftAndroid.getSharedItem(key, group || '');
735
+ });
736
+ },
737
+ remove: function(key, group) {
738
+ return new Promise(function(resolve, reject) {
739
+ window._craftSharedKeychainResolve = resolve;
740
+ window._craftSharedKeychainReject = reject;
741
+ CraftAndroid.removeSharedItem(key, group || '');
742
+ });
743
+ }
744
+ },
745
+
746
+ // Local Auth Persistence
747
+ authPersistence: {
748
+ enable: function(duration) {
749
+ return new Promise(function(resolve, reject) {
750
+ window._craftAuthPersistResolve = resolve;
751
+ window._craftAuthPersistReject = reject;
752
+ CraftAndroid.setAuthPersistence(true, duration || 300);
753
+ });
754
+ },
755
+ disable: function() {
756
+ return new Promise(function(resolve, reject) {
757
+ window._craftAuthPersistResolve = resolve;
758
+ window._craftAuthPersistReject = reject;
759
+ CraftAndroid.setAuthPersistence(false, 0);
760
+ });
761
+ },
762
+ check: function() {
763
+ return new Promise(function(resolve, reject) {
764
+ window._craftAuthPersistResolve = resolve;
765
+ window._craftAuthPersistReject = reject;
766
+ CraftAndroid.checkAuthPersistence();
767
+ });
768
+ },
769
+ clear: function() {
770
+ return new Promise(function(resolve, reject) {
771
+ window._craftAuthPersistResolve = resolve;
772
+ window._craftAuthPersistReject = reject;
773
+ CraftAndroid.clearAuthPersistence();
774
+ });
775
+ }
776
+ },
777
+
778
+ // AR (ARCore) - Note: Full ARCore requires native Activity integration
779
+ ar: {
780
+ start: function(options) {
781
+ return new Promise(function(resolve, reject) {
782
+ window._craftARResolve = resolve;
783
+ window._craftARReject = reject;
784
+ CraftAndroid.startAR(JSON.stringify(options || {}));
785
+ });
786
+ },
787
+ stop: function() {
788
+ return new Promise(function(resolve, reject) {
789
+ window._craftARResolve = resolve;
790
+ window._craftARReject = reject;
791
+ CraftAndroid.stopAR();
792
+ });
793
+ },
794
+ placeObject: function(model, position) {
795
+ return new Promise(function(resolve, reject) {
796
+ window._craftARResolve = resolve;
797
+ window._craftARReject = reject;
798
+ CraftAndroid.placeARObject(model, JSON.stringify(position || {}));
799
+ });
800
+ },
801
+ removeObject: function(objectId) {
802
+ return new Promise(function(resolve, reject) {
803
+ window._craftARResolve = resolve;
804
+ window._craftARReject = reject;
805
+ CraftAndroid.removeARObject(objectId);
806
+ });
807
+ },
808
+ getPlanes: function() {
809
+ return new Promise(function(resolve, reject) {
810
+ window._craftARResolve = resolve;
811
+ window._craftARReject = reject;
812
+ CraftAndroid.getARPlanes();
813
+ });
814
+ },
815
+ onPlaneDetected: function(callback) {
816
+ window.addEventListener('craftARPlane', function(e) { callback(e.detail); });
817
+ }
818
+ },
819
+
820
+ // ML (ML Kit)
821
+ ml: {
822
+ classifyImage: function(imageBase64) {
823
+ return new Promise(function(resolve, reject) {
824
+ window._craftMLResolve = resolve;
825
+ window._craftMLReject = reject;
826
+ CraftAndroid.classifyImage(imageBase64);
827
+ });
828
+ },
829
+ detectObjects: function(imageBase64) {
830
+ return new Promise(function(resolve, reject) {
831
+ window._craftMLResolve = resolve;
832
+ window._craftMLReject = reject;
833
+ CraftAndroid.detectObjects(imageBase64);
834
+ });
835
+ },
836
+ recognizeText: function(imageBase64) {
837
+ return new Promise(function(resolve, reject) {
838
+ window._craftMLResolve = resolve;
839
+ window._craftMLReject = reject;
840
+ CraftAndroid.recognizeText(imageBase64);
841
+ });
842
+ }
843
+ },
844
+
845
+ // Deep Links
846
+ deepLinks: {
847
+ getInitialURL: function() {
848
+ return new Promise(function(resolve, reject) {
849
+ window._craftDeepLinkResolve = resolve;
850
+ window._craftDeepLinkReject = reject;
851
+ CraftAndroid.getInitialURL();
852
+ });
853
+ },
854
+ onLink: function(callback) {
855
+ window.addEventListener('craftDeepLink', function(e) { callback(e.detail); });
856
+ }
857
+ },
858
+
859
+ // Logging
860
+ log: function(msg) {
861
+ CraftAndroid.log(msg);
862
+ },
863
+
864
+ // Performance Profiling
865
+ _profiling: false,
866
+ _profilingData: null,
867
+ _profilingCallTimings: [],
868
+
869
+ startProfiling: function() {
870
+ var self = this;
871
+ this._profiling = true;
872
+ this._profilingData = {
873
+ startTime: Date.now(),
874
+ startMemory: null,
875
+ callTimings: [],
876
+ bridgeCalls: 0
877
+ };
878
+ this._profilingCallTimings = [];
879
+ // Get memory info
880
+ var memInfo = CraftAndroid.getMemoryUsage();
881
+ try { this._profilingData.startMemory = JSON.parse(memInfo); } catch(e) {}
882
+ console.log('[Craft] Profiling started');
883
+ return { started: true, timestamp: this._profilingData.startTime };
884
+ },
885
+
886
+ stopProfiling: function() {
887
+ if (!this._profiling || !this._profilingData) {
888
+ return null;
889
+ }
890
+ this._profiling = false;
891
+ var memInfo = null;
892
+ try { memInfo = JSON.parse(CraftAndroid.getMemoryUsage()); } catch(e) {}
893
+ var report = {
894
+ duration: Date.now() - this._profilingData.startTime,
895
+ bridgeCalls: this._profilingCallTimings.length,
896
+ callTimings: this._profilingCallTimings,
897
+ memory: {
898
+ start: this._profilingData.startMemory,
899
+ end: memInfo
900
+ },
901
+ avgCallTime: this._profilingCallTimings.length > 0
902
+ ? this._profilingCallTimings.reduce(function(a, b) { return a + b.duration; }, 0) / this._profilingCallTimings.length
903
+ : 0
904
+ };
905
+ this._profilingData = null;
906
+ console.log('[Craft] Profiling stopped', report);
907
+ return report;
908
+ },
909
+
910
+ _recordCallTiming: function(action, startTime, endTime) {
911
+ if (this._profiling) {
912
+ this._profilingCallTimings.push({
913
+ action: action,
914
+ startTime: startTime,
915
+ endTime: endTime,
916
+ duration: endTime - startTime
917
+ });
918
+ }
919
+ },
920
+
921
+ getProfilingData: function() {
922
+ if (!this._profilingData) return null;
923
+ return {
924
+ running: this._profiling,
925
+ duration: Date.now() - this._profilingData.startTime,
926
+ bridgeCalls: this._profilingCallTimings.length,
927
+ callTimings: this._profilingCallTimings
928
+ };
929
+ },
930
+
931
+ // OTA Updates
932
+ ota: {
933
+ _config: null,
934
+ _status: 'idle',
935
+ _progressCallbacks: [],
936
+ _statusCallbacks: [],
937
+
938
+ configure: function(options) {
939
+ this._config = options;
940
+ CraftAndroid.otaConfigure(JSON.stringify(options));
941
+ },
942
+ checkForUpdate: function() {
943
+ return new Promise(function(resolve, reject) {
944
+ window._craftOTACheckResolve = resolve;
945
+ window._craftOTACheckReject = reject;
946
+ CraftAndroid.otaCheckForUpdate();
947
+ });
948
+ },
949
+ downloadUpdate: function(options) {
950
+ return new Promise(function(resolve, reject) {
951
+ window._craftOTADownloadResolve = resolve;
952
+ window._craftOTADownloadReject = reject;
953
+ CraftAndroid.otaDownloadUpdate(JSON.stringify(options || {}));
954
+ });
955
+ },
956
+ applyUpdate: function() {
957
+ return new Promise(function(resolve, reject) {
958
+ window._craftOTAApplyResolve = resolve;
959
+ window._craftOTAApplyReject = reject;
960
+ CraftAndroid.otaApplyUpdate();
961
+ });
962
+ },
963
+ rollback: function() {
964
+ return new Promise(function(resolve, reject) {
965
+ window._craftOTARollbackResolve = resolve;
966
+ window._craftOTARollbackReject = reject;
967
+ CraftAndroid.otaRollback();
968
+ });
969
+ },
970
+ getCurrentBundle: function() {
971
+ var bundleJson = CraftAndroid.otaGetCurrentBundle();
972
+ try { return JSON.parse(bundleJson); }
973
+ catch(e) { return { version: '1.0.0', buildNumber: 1, hash: '', isOriginal: true, installedAt: '' }; }
974
+ },
975
+ onProgress: function(callback) {
976
+ this._progressCallbacks.push(callback);
977
+ window.addEventListener('craftOTAProgress', function(e) { callback(e.detail); });
978
+ },
979
+ onStatusChange: function(callback) {
980
+ this._statusCallbacks.push(callback);
981
+ window.addEventListener('craftOTAStatus', function(e) { callback(e.detail.status); });
982
+ }
983
+ },
984
+
985
+ // Debug Mode
986
+ _debug: false,
987
+ _lastError: null,
988
+ _errorHistory: [],
989
+ _callLog: [],
990
+ _networkLog: [],
991
+ _consoleLog: [],
992
+ _originalConsole: null,
993
+
994
+ // Error code mappings for user-friendly messages
995
+ _errorMessages: {
996
+ 'PERMISSION_DENIED': 'Permission was denied. Please grant access in Settings.',
997
+ 'NOT_AVAILABLE': 'This feature is not available on this device.',
998
+ 'CANCELLED': 'The operation was cancelled by the user.',
999
+ 'NETWORK_ERROR': 'A network error occurred. Please check your connection.',
1000
+ 'TIMEOUT': 'The operation timed out. Please try again.',
1001
+ 'INVALID_PARAMS': 'Invalid parameters provided.',
1002
+ 'NOT_FOUND': 'The requested resource was not found.',
1003
+ 'AUTH_FAILED': 'Authentication failed.',
1004
+ 'STORAGE_FULL': 'Storage is full. Please free up space.',
1005
+ 'CRAFT_ERROR': 'An unexpected error occurred.'
1006
+ },
1007
+
1008
+ // Get user-friendly error message
1009
+ getErrorMessage: function(code) {
1010
+ return this._errorMessages[code] || this._errorMessages['CRAFT_ERROR'];
1011
+ },
1012
+
1013
+ // Store error for debugging
1014
+ _storeError: function(error, code, nativeStack) {
1015
+ this._lastError = {
1016
+ message: error,
1017
+ code: code || 'CRAFT_ERROR',
1018
+ timestamp: Date.now(),
1019
+ nativeStack: nativeStack || null,
1020
+ jsStack: new Error().stack
1021
+ };
1022
+ if (this._debug) {
1023
+ window.dispatchEvent(new CustomEvent('craftError', {detail: this._lastError}));
1024
+ console.error('[Craft Error]', code, error);
1025
+ }
1026
+ this._errorHistory.push(this._lastError);
1027
+ if (this._errorHistory.length > 50) this._errorHistory.shift();
1028
+ },
1029
+
1030
+ // Get error history
1031
+ getErrorHistory: function() {
1032
+ return this._errorHistory.slice();
1033
+ },
1034
+
1035
+ // Clear error history
1036
+ clearErrorHistory: function() {
1037
+ this._errorHistory = [];
1038
+ this._lastError = null;
1039
+ },
1040
+
1041
+ // Get last error details
1042
+ getLastError: function() {
1043
+ return this._lastError;
1044
+ },
1045
+
1046
+ // Enable/disable debug mode
1047
+ setDebugMode: function(enabled) {
1048
+ this._debug = enabled;
1049
+ if (enabled) {
1050
+ this._callLog = [];
1051
+ this._networkLog = [];
1052
+ this._setupConsoleCapture();
1053
+ this._setupNetworkInspector();
1054
+ console.log('[Craft] Debug mode enabled');
1055
+ } else {
1056
+ this._restoreConsole();
1057
+ }
1058
+ },
1059
+
1060
+ // Setup console capture
1061
+ _setupConsoleCapture: function() {
1062
+ if (this._originalConsole) return;
1063
+ var self = this;
1064
+ this._originalConsole = {
1065
+ log: console.log,
1066
+ warn: console.warn,
1067
+ error: console.error,
1068
+ info: console.info,
1069
+ debug: console.debug
1070
+ };
1071
+ ['log', 'warn', 'error', 'info', 'debug'].forEach(function(level) {
1072
+ console[level] = function() {
1073
+ var args = Array.prototype.slice.call(arguments);
1074
+ self._consoleLog.push({
1075
+ level: level,
1076
+ args: args.map(function(a) {
1077
+ try { return typeof a === 'object' ? JSON.stringify(a) : String(a); }
1078
+ catch(e) { return String(a); }
1079
+ }),
1080
+ timestamp: Date.now()
1081
+ });
1082
+ if (self._consoleLog.length > 200) self._consoleLog.shift();
1083
+ self._originalConsole[level].apply(console, arguments);
1084
+ };
1085
+ });
1086
+ },
1087
+
1088
+ // Restore original console
1089
+ _restoreConsole: function() {
1090
+ if (!this._originalConsole) return;
1091
+ console.log = this._originalConsole.log;
1092
+ console.warn = this._originalConsole.warn;
1093
+ console.error = this._originalConsole.error;
1094
+ console.info = this._originalConsole.info;
1095
+ console.debug = this._originalConsole.debug;
1096
+ this._originalConsole = null;
1097
+ },
1098
+
1099
+ // Setup network inspector
1100
+ _setupNetworkInspector: function() {
1101
+ var self = this;
1102
+ if (window._craftNetworkSetup) return;
1103
+ window._craftNetworkSetup = true;
1104
+
1105
+ var originalFetch = window.fetch;
1106
+ window.fetch = function(url, options) {
1107
+ var startTime = Date.now();
1108
+ var entry = {
1109
+ type: 'fetch',
1110
+ url: typeof url === 'string' ? url : url.url,
1111
+ method: (options && options.method) || 'GET',
1112
+ startTime: startTime,
1113
+ status: null,
1114
+ duration: null
1115
+ };
1116
+ self._networkLog.push(entry);
1117
+ if (self._networkLog.length > 100) self._networkLog.shift();
1118
+
1119
+ return originalFetch.apply(window, arguments).then(function(response) {
1120
+ entry.status = response.status;
1121
+ entry.duration = Date.now() - startTime;
1122
+ return response;
1123
+ }).catch(function(err) {
1124
+ entry.status = 'error';
1125
+ entry.error = err.message;
1126
+ entry.duration = Date.now() - startTime;
1127
+ throw err;
1128
+ });
1129
+ };
1130
+
1131
+ var OriginalXHR = window.XMLHttpRequest;
1132
+ window.XMLHttpRequest = function() {
1133
+ var xhr = new OriginalXHR();
1134
+ var entry = { type: 'xhr', url: '', method: '', startTime: null, status: null, duration: null };
1135
+
1136
+ var originalOpen = xhr.open;
1137
+ xhr.open = function(method, url) {
1138
+ entry.method = method;
1139
+ entry.url = url;
1140
+ return originalOpen.apply(xhr, arguments);
1141
+ };
1142
+
1143
+ var originalSend = xhr.send;
1144
+ xhr.send = function() {
1145
+ entry.startTime = Date.now();
1146
+ self._networkLog.push(entry);
1147
+ if (self._networkLog.length > 100) self._networkLog.shift();
1148
+
1149
+ xhr.addEventListener('loadend', function() {
1150
+ entry.status = xhr.status;
1151
+ entry.duration = Date.now() - entry.startTime;
1152
+ });
1153
+ return originalSend.apply(xhr, arguments);
1154
+ };
1155
+ return xhr;
1156
+ };
1157
+ },
1158
+
1159
+ // Get console log
1160
+ getConsoleLog: function() {
1161
+ return this._consoleLog.slice();
1162
+ },
1163
+
1164
+ // Clear console log
1165
+ clearConsoleLog: function() {
1166
+ this._consoleLog = [];
1167
+ },
1168
+
1169
+ // Get network log
1170
+ getNetworkLog: function() {
1171
+ return this._networkLog.slice();
1172
+ },
1173
+
1174
+ // Clear network log
1175
+ clearNetworkLog: function() {
1176
+ this._networkLog = [];
1177
+ },
1178
+
1179
+ // Get call log
1180
+ getCallLog: function() {
1181
+ return this._callLog;
1182
+ },
1183
+
1184
+ // Clear call log
1185
+ clearCallLog: function() {
1186
+ this._callLog = [];
1187
+ },
1188
+
1189
+ // Internal: log bridge call
1190
+ _logCall: function(action, params) {
1191
+ if (this._debug) {
1192
+ var entry = {
1193
+ action: action,
1194
+ params: params,
1195
+ timestamp: Date.now()
1196
+ };
1197
+ this._callLog.push(entry);
1198
+ if (this._callLog.length > 100) this._callLog.shift();
1199
+ console.log('[Craft] ' + action, params);
1200
+ }
1201
+ },
1202
+
1203
+ // Get full debug report
1204
+ getDebugReport: function() {
1205
+ return {
1206
+ enabled: this._debug,
1207
+ lastError: this._lastError,
1208
+ errorHistory: this._errorHistory.slice(-10),
1209
+ callLog: this._callLog.slice(-20),
1210
+ networkLog: this._networkLog.slice(-20),
1211
+ consoleLog: this._consoleLog.slice(-50),
1212
+ timestamp: Date.now()
1213
+ };
1214
+ }
1215
+ };
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
+
1284
+ // Dispatch ready event
1285
+ window.dispatchEvent(new CustomEvent('craftReady', {detail: window.craft}));
1286
+ console.log('Craft Android bridge initialized');
1287
+ """.trimIndent()
1288
+
1289
+ activity.runOnUiThread {
1290
+ webView.evaluateJavascript(script, null)
1291
+ }
1292
+ }
1293
+
1294
+ // ==================== Haptics ====================
1295
+
1296
+ @JavascriptInterface
1297
+ fun haptic(style: String) {
1298
+ val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
1299
+ val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
1300
+ vibratorManager.defaultVibrator
1301
+ } else {
1302
+ @Suppress("DEPRECATION")
1303
+ activity.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
1304
+ }
1305
+
1306
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1307
+ val effect = when (style) {
1308
+ "light" -> VibrationEffect.createOneShot(10, VibrationEffect.DEFAULT_AMPLITUDE)
1309
+ "medium" -> VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE)
1310
+ "heavy" -> VibrationEffect.createOneShot(50, VibrationEffect.DEFAULT_AMPLITUDE)
1311
+ "success" -> VibrationEffect.createWaveform(longArrayOf(0, 30, 50, 30), -1)
1312
+ "warning" -> VibrationEffect.createWaveform(longArrayOf(0, 50, 100, 50), -1)
1313
+ "error" -> VibrationEffect.createWaveform(longArrayOf(0, 100, 50, 100), -1)
1314
+ "selection" -> VibrationEffect.createOneShot(5, VibrationEffect.DEFAULT_AMPLITUDE)
1315
+ else -> VibrationEffect.createOneShot(20, VibrationEffect.DEFAULT_AMPLITUDE)
1316
+ }
1317
+ vibrator.vibrate(effect)
1318
+ } else {
1319
+ @Suppress("DEPRECATION")
1320
+ vibrator.vibrate(20)
1321
+ }
1322
+ }
1323
+
1324
+ // ==================== Speech Recognition ====================
1325
+
1326
+ @JavascriptInterface
1327
+ fun startListening() {
1328
+ if (!SpeechRecognizer.isRecognitionAvailable(activity)) {
1329
+ sendEvent("craftSpeechError", mapOf("error" to "Speech recognition not available"))
1330
+ return
1331
+ }
1332
+
1333
+ activity.runOnUiThread {
1334
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO)
1335
+ != PackageManager.PERMISSION_GRANTED) {
1336
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.RECORD_AUDIO), 100)
1337
+ return@runOnUiThread
1338
+ }
1339
+
1340
+ speechRecognizer = SpeechRecognizer.createSpeechRecognizer(activity)
1341
+ speechRecognizer?.setRecognitionListener(object : RecognitionListener {
1342
+ override fun onReadyForSpeech(params: Bundle?) {
1343
+ isListening = true
1344
+ sendEvent("craftSpeechStart", emptyMap())
1345
+ haptic("light")
1346
+ }
1347
+
1348
+ override fun onBeginningOfSpeech() {}
1349
+ override fun onRmsChanged(rmsdB: Float) {}
1350
+ override fun onBufferReceived(buffer: ByteArray?) {}
1351
+ override fun onEndOfSpeech() {}
1352
+
1353
+ override fun onError(error: Int) {
1354
+ isListening = false
1355
+ val errorMsg = when (error) {
1356
+ SpeechRecognizer.ERROR_AUDIO -> "Audio recording error"
1357
+ SpeechRecognizer.ERROR_CLIENT -> "Client error"
1358
+ SpeechRecognizer.ERROR_INSUFFICIENT_PERMISSIONS -> "Insufficient permissions"
1359
+ SpeechRecognizer.ERROR_NETWORK -> "Network error"
1360
+ SpeechRecognizer.ERROR_NETWORK_TIMEOUT -> "Network timeout"
1361
+ SpeechRecognizer.ERROR_NO_MATCH -> "No match"
1362
+ SpeechRecognizer.ERROR_RECOGNIZER_BUSY -> "Recognizer busy"
1363
+ SpeechRecognizer.ERROR_SERVER -> "Server error"
1364
+ SpeechRecognizer.ERROR_SPEECH_TIMEOUT -> "Speech timeout"
1365
+ else -> "Unknown error"
1366
+ }
1367
+ sendEvent("craftSpeechError", mapOf("error" to errorMsg))
1368
+ haptic("light")
1369
+ }
1370
+
1371
+ override fun onResults(results: Bundle?) {
1372
+ isListening = false
1373
+ val matches = results?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
1374
+ val transcript = matches?.firstOrNull() ?: ""
1375
+ sendEvent("craftSpeechResult", mapOf("transcript" to transcript, "isFinal" to true))
1376
+ sendEvent("craftSpeechEnd", emptyMap())
1377
+ haptic("light")
1378
+ }
1379
+
1380
+ override fun onPartialResults(partialResults: Bundle?) {
1381
+ val matches = partialResults?.getStringArrayList(SpeechRecognizer.RESULTS_RECOGNITION)
1382
+ val transcript = matches?.firstOrNull() ?: ""
1383
+ sendEvent("craftSpeechResult", mapOf("transcript" to transcript, "isFinal" to false))
1384
+ }
1385
+
1386
+ override fun onEvent(eventType: Int, params: Bundle?) {}
1387
+ })
1388
+
1389
+ val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
1390
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
1391
+ putExtra(RecognizerIntent.EXTRA_LANGUAGE, Locale.getDefault())
1392
+ putExtra(RecognizerIntent.EXTRA_PARTIAL_RESULTS, true)
1393
+ putExtra(RecognizerIntent.EXTRA_MAX_RESULTS, 1)
1394
+ }
1395
+
1396
+ speechRecognizer?.startListening(intent)
1397
+ }
1398
+ }
1399
+
1400
+ @JavascriptInterface
1401
+ fun stopListening() {
1402
+ activity.runOnUiThread {
1403
+ speechRecognizer?.stopListening()
1404
+ speechRecognizer?.destroy()
1405
+ speechRecognizer = null
1406
+ isListening = false
1407
+ }
1408
+ }
1409
+
1410
+ // ==================== Share ====================
1411
+
1412
+ @JavascriptInterface
1413
+ fun share(text: String, title: String) {
1414
+ val intent = Intent(Intent.ACTION_SEND).apply {
1415
+ type = "text/plain"
1416
+ putExtra(Intent.EXTRA_TEXT, text)
1417
+ if (title.isNotEmpty()) {
1418
+ putExtra(Intent.EXTRA_SUBJECT, title)
1419
+ }
1420
+ }
1421
+ activity.startActivity(Intent.createChooser(intent, "Share"))
1422
+ }
1423
+
1424
+ // ==================== Camera & Gallery ====================
1425
+
1426
+ @JavascriptInterface
1427
+ fun openCamera() {
1428
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.CAMERA)
1429
+ != PackageManager.PERMISSION_GRANTED) {
1430
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.CAMERA), 101)
1431
+ return
1432
+ }
1433
+
1434
+ val intent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)
1435
+ activity.startActivityForResult(intent, REQUEST_CAMERA)
1436
+ }
1437
+
1438
+ @JavascriptInterface
1439
+ fun pickImage() {
1440
+ val intent = Intent(Intent.ACTION_PICK, MediaStore.Images.Media.EXTERNAL_CONTENT_URI)
1441
+ activity.startActivityForResult(intent, REQUEST_GALLERY)
1442
+ }
1443
+
1444
+ fun handleImageResult(requestCode: Int, resultCode: Int, data: Intent?) {
1445
+ if (resultCode != Activity.RESULT_OK) {
1446
+ val event = if (requestCode == REQUEST_CAMERA) "_craftCameraReject" else "_craftGalleryReject"
1447
+ activity.runOnUiThread {
1448
+ webView.evaluateJavascript("window.$event && window.$event('Cancelled')", null)
1449
+ }
1450
+ return
1451
+ }
1452
+
1453
+ val imageUri: String? = when (requestCode) {
1454
+ REQUEST_CAMERA -> {
1455
+ // For camera, we get a bitmap thumbnail
1456
+ val bitmap = data?.extras?.get("data") as? Bitmap
1457
+ bitmap?.let { "data:image/png;base64,${bitmapToBase64(it)}" }
1458
+ }
1459
+ REQUEST_GALLERY -> {
1460
+ data?.data?.toString()
1461
+ }
1462
+ else -> null
1463
+ }
1464
+
1465
+ val event = if (requestCode == REQUEST_CAMERA) "_craftCameraResolve" else "_craftGalleryResolve"
1466
+ activity.runOnUiThread {
1467
+ webView.evaluateJavascript("window.$event && window.$event('$imageUri')", null)
1468
+ }
1469
+ }
1470
+
1471
+ private fun bitmapToBase64(bitmap: Bitmap): String {
1472
+ val outputStream = java.io.ByteArrayOutputStream()
1473
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
1474
+ return android.util.Base64.encodeToString(outputStream.toByteArray(), android.util.Base64.NO_WRAP)
1475
+ }
1476
+
1477
+ // ==================== Biometric Auth ====================
1478
+
1479
+ private fun isBiometricAvailable(): Boolean {
1480
+ val biometricManager = BiometricManager.from(activity)
1481
+ return biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) ==
1482
+ BiometricManager.BIOMETRIC_SUCCESS
1483
+ }
1484
+
1485
+ @JavascriptInterface
1486
+ fun authenticate(reason: String) {
1487
+ if (activity !is FragmentActivity) {
1488
+ activity.runOnUiThread {
1489
+ webView.evaluateJavascript(
1490
+ "window._craftBiometricReject && window._craftBiometricReject('Activity not supported')",
1491
+ null
1492
+ )
1493
+ }
1494
+ return
1495
+ }
1496
+
1497
+ activity.runOnUiThread {
1498
+ val promptInfo = BiometricPrompt.PromptInfo.Builder()
1499
+ .setTitle("Authenticate")
1500
+ .setSubtitle(reason)
1501
+ .setNegativeButtonText("Cancel")
1502
+ .setAllowedAuthenticators(BiometricManager.Authenticators.BIOMETRIC_STRONG)
1503
+ .build()
1504
+
1505
+ val biometricPrompt = BiometricPrompt(
1506
+ activity as FragmentActivity,
1507
+ mainExecutor,
1508
+ object : BiometricPrompt.AuthenticationCallback() {
1509
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
1510
+ webView.evaluateJavascript(
1511
+ "window._craftBiometricResolve && window._craftBiometricResolve(true)",
1512
+ null
1513
+ )
1514
+ }
1515
+
1516
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
1517
+ webView.evaluateJavascript(
1518
+ "window._craftBiometricReject && window._craftBiometricReject('$errString')",
1519
+ null
1520
+ )
1521
+ }
1522
+
1523
+ override fun onAuthenticationFailed() {
1524
+ // Don't reject yet - user can retry
1525
+ }
1526
+ }
1527
+ )
1528
+
1529
+ biometricPrompt.authenticate(promptInfo)
1530
+ }
1531
+ }
1532
+
1533
+ // ==================== Push Notifications ====================
1534
+
1535
+ @JavascriptInterface
1536
+ fun registerPush() {
1537
+ {{REGISTER_PUSH_IMPLEMENTATION}}
1538
+ }
1539
+
1540
+ // ==================== Secure Storage ====================
1541
+
1542
+ @JavascriptInterface
1543
+ fun secureSet(key: String, value: String): Boolean {
1544
+ return try {
1545
+ securePrefs.edit().putString(key, value).apply()
1546
+ true
1547
+ } catch (e: Exception) {
1548
+ false
1549
+ }
1550
+ }
1551
+
1552
+ @JavascriptInterface
1553
+ fun secureGet(key: String): String? {
1554
+ return try {
1555
+ securePrefs.getString(key, null)
1556
+ } catch (e: Exception) {
1557
+ null
1558
+ }
1559
+ }
1560
+
1561
+ @JavascriptInterface
1562
+ fun secureRemove(key: String): Boolean {
1563
+ return try {
1564
+ securePrefs.edit().remove(key).apply()
1565
+ true
1566
+ } catch (e: Exception) {
1567
+ false
1568
+ }
1569
+ }
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
+
1581
+ // ==================== Logging ====================
1582
+
1583
+ @JavascriptInterface
1584
+ fun log(message: String) {
1585
+ android.util.Log.d("CraftBridge", message)
1586
+ }
1587
+
1588
+ // ==================== Geolocation ====================
1589
+
1590
+ @JavascriptInterface
1591
+ fun getCurrentPosition(optionsJson: String) {
1592
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION)
1593
+ != PackageManager.PERMISSION_GRANTED) {
1594
+ ActivityCompat.requestPermissions(
1595
+ activity,
1596
+ arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
1597
+ REQUEST_LOCATION
1598
+ )
1599
+ activity.runOnUiThread {
1600
+ webView.evaluateJavascript(
1601
+ "window._craftLocationReject && window._craftLocationReject({code: 1, message: 'Permission denied'})",
1602
+ null
1603
+ )
1604
+ }
1605
+ return
1606
+ }
1607
+
1608
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(activity)
1609
+ fusedLocationClient?.lastLocation?.addOnSuccessListener { location: Location? ->
1610
+ if (location != null) {
1611
+ val json = JSONObject().apply {
1612
+ put("latitude", location.latitude)
1613
+ put("longitude", location.longitude)
1614
+ put("accuracy", location.accuracy)
1615
+ put("altitude", location.altitude)
1616
+ put("speed", location.speed)
1617
+ put("heading", location.bearing)
1618
+ put("timestamp", location.time)
1619
+ }
1620
+ activity.runOnUiThread {
1621
+ webView.evaluateJavascript(
1622
+ "window._craftLocationResolve && window._craftLocationResolve($json)",
1623
+ null
1624
+ )
1625
+ }
1626
+ } else {
1627
+ // Request fresh location if last location is null
1628
+ requestFreshLocation()
1629
+ }
1630
+ }?.addOnFailureListener { e ->
1631
+ activity.runOnUiThread {
1632
+ webView.evaluateJavascript(
1633
+ "window._craftLocationReject && window._craftLocationReject({code: 2, message: '${e.message}'})",
1634
+ null
1635
+ )
1636
+ }
1637
+ }
1638
+ }
1639
+
1640
+ private fun requestFreshLocation() {
1641
+ if (ActivityCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) {
1642
+ return
1643
+ }
1644
+
1645
+ val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 1000)
1646
+ .setWaitForAccurateLocation(false)
1647
+ .setMinUpdateIntervalMillis(500)
1648
+ .setMaxUpdateDelayMillis(1000)
1649
+ .setMaxUpdates(1)
1650
+ .build()
1651
+
1652
+ val callback = object : LocationCallback() {
1653
+ override fun onLocationResult(result: LocationResult) {
1654
+ fusedLocationClient?.removeLocationUpdates(this)
1655
+ val location = result.lastLocation
1656
+ if (location != null) {
1657
+ val json = JSONObject().apply {
1658
+ put("latitude", location.latitude)
1659
+ put("longitude", location.longitude)
1660
+ put("accuracy", location.accuracy)
1661
+ put("altitude", location.altitude)
1662
+ put("speed", location.speed)
1663
+ put("heading", location.bearing)
1664
+ put("timestamp", location.time)
1665
+ }
1666
+ activity.runOnUiThread {
1667
+ webView.evaluateJavascript(
1668
+ "window._craftLocationResolve && window._craftLocationResolve($json)",
1669
+ null
1670
+ )
1671
+ }
1672
+ }
1673
+ }
1674
+ }
1675
+
1676
+ fusedLocationClient?.requestLocationUpdates(locationRequest, callback, Looper.getMainLooper())
1677
+ }
1678
+
1679
+ @JavascriptInterface
1680
+ fun watchPosition(optionsJson: String): Int {
1681
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION)
1682
+ != PackageManager.PERMISSION_GRANTED) {
1683
+ return -1
1684
+ }
1685
+
1686
+ val currentWatchId = ++watchId
1687
+ fusedLocationClient = LocationServices.getFusedLocationProviderClient(activity)
1688
+
1689
+ val locationRequest = LocationRequest.Builder(Priority.PRIORITY_HIGH_ACCURACY, 5000)
1690
+ .setMinUpdateIntervalMillis(2000)
1691
+ .build()
1692
+
1693
+ locationCallback = object : LocationCallback() {
1694
+ override fun onLocationResult(result: LocationResult) {
1695
+ val location = result.lastLocation ?: return
1696
+ val json = JSONObject().apply {
1697
+ put("latitude", location.latitude)
1698
+ put("longitude", location.longitude)
1699
+ put("accuracy", location.accuracy)
1700
+ put("altitude", location.altitude)
1701
+ put("speed", location.speed)
1702
+ put("heading", location.bearing)
1703
+ put("timestamp", location.time)
1704
+ }
1705
+ activity.runOnUiThread {
1706
+ webView.evaluateJavascript(
1707
+ "window._craftLocationWatch_$currentWatchId && window._craftLocationWatch_$currentWatchId($json)",
1708
+ null
1709
+ )
1710
+ }
1711
+ }
1712
+ }
1713
+
1714
+ if (ActivityCompat.checkSelfPermission(activity, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED) {
1715
+ fusedLocationClient?.requestLocationUpdates(locationRequest, locationCallback!!, Looper.getMainLooper())
1716
+ }
1717
+
1718
+ return currentWatchId
1719
+ }
1720
+
1721
+ @JavascriptInterface
1722
+ fun clearWatch(watchId: Int) {
1723
+ locationCallback?.let {
1724
+ fusedLocationClient?.removeLocationUpdates(it)
1725
+ }
1726
+ locationCallback = null
1727
+ }
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
+
1781
+ // ==================== Clipboard ====================
1782
+
1783
+ @JavascriptInterface
1784
+ fun clipboardRead(): String {
1785
+ val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
1786
+ val clip = clipboard.primaryClip
1787
+ return if (clip != null && clip.itemCount > 0) {
1788
+ clip.getItemAt(0).text?.toString() ?: ""
1789
+ } else {
1790
+ ""
1791
+ }
1792
+ }
1793
+
1794
+ @JavascriptInterface
1795
+ fun clipboardWrite(text: String): Boolean {
1796
+ return try {
1797
+ val clipboard = activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
1798
+ val clip = ClipData.newPlainText("Craft", text)
1799
+ clipboard.setPrimaryClip(clip)
1800
+ true
1801
+ } catch (e: Exception) {
1802
+ false
1803
+ }
1804
+ }
1805
+
1806
+ // ==================== Device Info ====================
1807
+
1808
+ @JavascriptInterface
1809
+ fun getDeviceInfo(): String {
1810
+ val metrics = DisplayMetrics()
1811
+ @Suppress("DEPRECATION")
1812
+ activity.windowManager.defaultDisplay.getMetrics(metrics)
1813
+
1814
+ return JSONObject().apply {
1815
+ put("model", Build.MODEL)
1816
+ put("manufacturer", Build.MANUFACTURER)
1817
+ put("brand", Build.BRAND)
1818
+ put("device", Build.DEVICE)
1819
+ put("systemVersion", Build.VERSION.RELEASE)
1820
+ put("sdkVersion", Build.VERSION.SDK_INT)
1821
+ put("screenWidth", metrics.widthPixels)
1822
+ put("screenHeight", metrics.heightPixels)
1823
+ put("density", metrics.density)
1824
+ put("platform", "android")
1825
+ put("appVersion", try {
1826
+ activity.packageManager.getPackageInfo(activity.packageName, 0).versionName
1827
+ } catch (e: Exception) { "unknown" })
1828
+ put("appBuild", try {
1829
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
1830
+ activity.packageManager.getPackageInfo(activity.packageName, 0).longVersionCode
1831
+ } else {
1832
+ @Suppress("DEPRECATION")
1833
+ activity.packageManager.getPackageInfo(activity.packageName, 0).versionCode
1834
+ }
1835
+ } catch (e: Exception) { 0 })
1836
+ put("isEmulator", Build.FINGERPRINT.contains("generic") || Build.FINGERPRINT.contains("emulator"))
1837
+ }.toString()
1838
+ }
1839
+
1840
+ // ==================== App Badge ====================
1841
+
1842
+ @JavascriptInterface
1843
+ fun setBadge(count: Int) {
1844
+ // Android doesn't have native badge support, but we can use notification channels
1845
+ // This is a simplified implementation - for full support, use ShortcutBadger library
1846
+ try {
1847
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
1848
+ val notificationManager = NotificationManagerCompat.from(activity)
1849
+ // Badge is typically shown via notification count
1850
+ // Full implementation would require creating a notification
1851
+ }
1852
+ } catch (e: Exception) {
1853
+ log("Badge not supported: ${e.message}")
1854
+ }
1855
+ }
1856
+
1857
+ @JavascriptInterface
1858
+ fun clearBadge() {
1859
+ setBadge(0)
1860
+ }
1861
+
1862
+ // ==================== Network Status ====================
1863
+
1864
+ @JavascriptInterface
1865
+ fun getNetworkStatus(): String {
1866
+ val connectivityManager = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
1867
+ val network = connectivityManager.activeNetwork
1868
+ val capabilities = connectivityManager.getNetworkCapabilities(network)
1869
+
1870
+ val isConnected = capabilities != null
1871
+ val connectionType = when {
1872
+ capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) == true -> "wifi"
1873
+ capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) == true -> "cellular"
1874
+ capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) == true -> "ethernet"
1875
+ else -> "none"
1876
+ }
1877
+
1878
+ return JSONObject().apply {
1879
+ put("isConnected", isConnected)
1880
+ put("type", connectionType)
1881
+ put("isWifi", connectionType == "wifi")
1882
+ put("isCellular", connectionType == "cellular")
1883
+ }.toString()
1884
+ }
1885
+
1886
+ @JavascriptInterface
1887
+ fun startNetworkMonitoring() {
1888
+ val connectivityManager = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
1889
+
1890
+ networkCallback = object : ConnectivityManager.NetworkCallback() {
1891
+ override fun onAvailable(network: Network) {
1892
+ sendNetworkChange()
1893
+ }
1894
+
1895
+ override fun onLost(network: Network) {
1896
+ sendNetworkChange()
1897
+ }
1898
+
1899
+ override fun onCapabilitiesChanged(network: Network, networkCapabilities: NetworkCapabilities) {
1900
+ sendNetworkChange()
1901
+ }
1902
+ }
1903
+
1904
+ val request = NetworkRequest.Builder()
1905
+ .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
1906
+ .build()
1907
+
1908
+ connectivityManager.registerNetworkCallback(request, networkCallback!!)
1909
+ }
1910
+
1911
+ @JavascriptInterface
1912
+ fun stopNetworkMonitoring() {
1913
+ val connectivityManager = activity.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
1914
+ networkCallback?.let {
1915
+ connectivityManager.unregisterNetworkCallback(it)
1916
+ }
1917
+ networkCallback = null
1918
+ }
1919
+
1920
+ private fun sendNetworkChange() {
1921
+ val status = getNetworkStatus()
1922
+ activity.runOnUiThread {
1923
+ webView.evaluateJavascript(
1924
+ "window._craftNetworkChangeCallback && window._craftNetworkChangeCallback($status)",
1925
+ null
1926
+ )
1927
+ }
1928
+ }
1929
+
1930
+ // ==================== App Review ====================
1931
+
1932
+ @JavascriptInterface
1933
+ fun requestReview() {
1934
+ val reviewManager = ReviewManagerFactory.create(activity)
1935
+ val request = reviewManager.requestReviewFlow()
1936
+
1937
+ request.addOnCompleteListener { task ->
1938
+ if (task.isSuccessful) {
1939
+ val reviewInfo = task.result
1940
+ val flow = reviewManager.launchReviewFlow(activity, reviewInfo)
1941
+ flow.addOnCompleteListener {
1942
+ activity.runOnUiThread {
1943
+ webView.evaluateJavascript(
1944
+ "window._craftReviewResolve && window._craftReviewResolve(true)",
1945
+ null
1946
+ )
1947
+ }
1948
+ }
1949
+ } else {
1950
+ activity.runOnUiThread {
1951
+ val message = JSONObject.quote(task.exception?.message ?: "Review flow failed")
1952
+ webView.evaluateJavascript(
1953
+ "window._craftReviewReject && window._craftReviewReject($message)",
1954
+ null
1955
+ )
1956
+ }
1957
+ }
1958
+ }
1959
+ }
1960
+
1961
+ // ==================== Flashlight ====================
1962
+
1963
+ private fun hasFlashlight(): Boolean {
1964
+ return activity.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_FLASH)
1965
+ }
1966
+
1967
+ @JavascriptInterface
1968
+ fun setFlashlight(on: Boolean): Boolean {
1969
+ return try {
1970
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
1971
+ val cameraManager = activity.getSystemService(Context.CAMERA_SERVICE) as CameraManager
1972
+ val cameraId = cameraManager.cameraIdList.firstOrNull { id ->
1973
+ cameraManager.getCameraCharacteristics(id)
1974
+ .get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
1975
+ }
1976
+ if (cameraId != null) {
1977
+ cameraManager.setTorchMode(cameraId, on)
1978
+ isFlashlightOn = on
1979
+ true
1980
+ } else {
1981
+ false
1982
+ }
1983
+ } else {
1984
+ false
1985
+ }
1986
+ } catch (e: CameraAccessException) {
1987
+ false
1988
+ }
1989
+ }
1990
+
1991
+ @JavascriptInterface
1992
+ fun toggleFlashlight(): Boolean {
1993
+ isFlashlightOn = !isFlashlightOn
1994
+ return setFlashlight(isFlashlightOn)
1995
+ }
1996
+
1997
+ // ==================== Open URL ====================
1998
+
1999
+ @JavascriptInterface
2000
+ fun openURL(url: String): Boolean {
2001
+ return try {
2002
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
2003
+ activity.startActivity(intent)
2004
+ true
2005
+ } catch (e: Exception) {
2006
+ false
2007
+ }
2008
+ }
2009
+
2010
+ // ==================== Vibration Pattern ====================
2011
+
2012
+ @JavascriptInterface
2013
+ fun vibrate(patternJson: String) {
2014
+ val vibrator = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
2015
+ val vibratorManager = activity.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as VibratorManager
2016
+ vibratorManager.defaultVibrator
2017
+ } else {
2018
+ @Suppress("DEPRECATION")
2019
+ activity.getSystemService(Context.VIBRATOR_SERVICE) as Vibrator
2020
+ }
2021
+
2022
+ try {
2023
+ val pattern = org.json.JSONArray(patternJson)
2024
+ val timings = LongArray(pattern.length()) { i -> pattern.getLong(i) }
2025
+
2026
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
2027
+ val effect = VibrationEffect.createWaveform(timings, -1)
2028
+ vibrator.vibrate(effect)
2029
+ } else {
2030
+ @Suppress("DEPRECATION")
2031
+ vibrator.vibrate(timings, -1)
2032
+ }
2033
+ } catch (e: Exception) {
2034
+ log("Vibration error: ${e.message}")
2035
+ }
2036
+ }
2037
+
2038
+ // ==================== App State ====================
2039
+
2040
+ @JavascriptInterface
2041
+ fun getAppState(): String {
2042
+ return currentAppState
2043
+ }
2044
+
2045
+ @JavascriptInterface
2046
+ fun startAppStateMonitoring() {
2047
+ activity.runOnUiThread {
2048
+ ProcessLifecycleOwner.get().lifecycle.addObserver(appStateObserver)
2049
+ }
2050
+ }
2051
+
2052
+ @JavascriptInterface
2053
+ fun stopAppStateMonitoring() {
2054
+ activity.runOnUiThread {
2055
+ ProcessLifecycleOwner.get().lifecycle.removeObserver(appStateObserver)
2056
+ }
2057
+ }
2058
+
2059
+ private val appStateObserver = LifecycleEventObserver { _, event ->
2060
+ val newState = when (event) {
2061
+ Lifecycle.Event.ON_START, Lifecycle.Event.ON_RESUME -> "active"
2062
+ Lifecycle.Event.ON_PAUSE -> "inactive"
2063
+ Lifecycle.Event.ON_STOP -> "background"
2064
+ else -> currentAppState
2065
+ }
2066
+
2067
+ if (newState != currentAppState) {
2068
+ currentAppState = newState
2069
+ activity.runOnUiThread {
2070
+ webView.evaluateJavascript(
2071
+ "window._craftAppStateCallback && window._craftAppStateCallback('$currentAppState')",
2072
+ null
2073
+ )
2074
+ }
2075
+ sendEvent("craftAppStateChange", mapOf("state" to currentAppState))
2076
+ }
2077
+ }
2078
+
2079
+ // ==================== Contacts ====================
2080
+
2081
+ @JavascriptInterface
2082
+ fun getContacts() {
2083
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_CONTACTS)
2084
+ != PackageManager.PERMISSION_GRANTED) {
2085
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.READ_CONTACTS), REQUEST_CONTACTS)
2086
+ activity.runOnUiThread {
2087
+ webView.evaluateJavascript("window._craftContactsReject && window._craftContactsReject('Permission denied')", null)
2088
+ }
2089
+ return
2090
+ }
2091
+
2092
+ val contacts = JSONArray()
2093
+ val cursor: Cursor? = activity.contentResolver.query(
2094
+ ContactsContract.Contacts.CONTENT_URI,
2095
+ null, null, null, ContactsContract.Contacts.DISPLAY_NAME + " ASC"
2096
+ )
2097
+
2098
+ cursor?.use {
2099
+ while (it.moveToNext()) {
2100
+ val id = it.getString(it.getColumnIndexOrThrow(ContactsContract.Contacts._ID))
2101
+ val name = it.getString(it.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME))
2102
+
2103
+ val contact = JSONObject().apply {
2104
+ put("id", id)
2105
+ put("displayName", name ?: "")
2106
+ put("phoneNumbers", getContactPhones(id))
2107
+ put("emailAddresses", getContactEmails(id))
2108
+ }
2109
+ contacts.put(contact)
2110
+ }
2111
+ }
2112
+
2113
+ activity.runOnUiThread {
2114
+ webView.evaluateJavascript("window._craftContactsResolve && window._craftContactsResolve($contacts)", null)
2115
+ }
2116
+ }
2117
+
2118
+ private fun getContactPhones(contactId: String): JSONArray {
2119
+ val phones = JSONArray()
2120
+ val cursor = activity.contentResolver.query(
2121
+ ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
2122
+ null,
2123
+ ContactsContract.CommonDataKinds.Phone.CONTACT_ID + " = ?",
2124
+ arrayOf(contactId), null
2125
+ )
2126
+ cursor?.use {
2127
+ while (it.moveToNext()) {
2128
+ val phone = it.getString(it.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER))
2129
+ phones.put(phone)
2130
+ }
2131
+ }
2132
+ return phones
2133
+ }
2134
+
2135
+ private fun getContactEmails(contactId: String): JSONArray {
2136
+ val emails = JSONArray()
2137
+ val cursor = activity.contentResolver.query(
2138
+ ContactsContract.CommonDataKinds.Email.CONTENT_URI,
2139
+ null,
2140
+ ContactsContract.CommonDataKinds.Email.CONTACT_ID + " = ?",
2141
+ arrayOf(contactId), null
2142
+ )
2143
+ cursor?.use {
2144
+ while (it.moveToNext()) {
2145
+ val email = it.getString(it.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.ADDRESS))
2146
+ emails.put(email)
2147
+ }
2148
+ }
2149
+ return emails
2150
+ }
2151
+
2152
+ @JavascriptInterface
2153
+ fun addContact(contactJson: String) {
2154
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_CONTACTS)
2155
+ != PackageManager.PERMISSION_GRANTED) {
2156
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_CONTACTS), REQUEST_CONTACTS)
2157
+ activity.runOnUiThread {
2158
+ webView.evaluateJavascript("window._craftAddContactReject && window._craftAddContactReject('Permission denied')", null)
2159
+ }
2160
+ return
2161
+ }
2162
+
2163
+ try {
2164
+ val contact = JSONObject(contactJson)
2165
+ val ops = ArrayList<android.content.ContentProviderOperation>()
2166
+
2167
+ ops.add(android.content.ContentProviderOperation.newInsert(ContactsContract.RawContacts.CONTENT_URI)
2168
+ .withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
2169
+ .withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
2170
+ .build())
2171
+
2172
+ // Name
2173
+ val displayName = contact.optString("displayName", "")
2174
+ if (displayName.isNotEmpty()) {
2175
+ ops.add(android.content.ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
2176
+ .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
2177
+ .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
2178
+ .withValue(ContactsContract.CommonDataKinds.StructuredName.DISPLAY_NAME, displayName)
2179
+ .build())
2180
+ }
2181
+
2182
+ // Phone
2183
+ val phone = contact.optString("phone", "")
2184
+ if (phone.isNotEmpty()) {
2185
+ ops.add(android.content.ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
2186
+ .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
2187
+ .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
2188
+ .withValue(ContactsContract.CommonDataKinds.Phone.NUMBER, phone)
2189
+ .withValue(ContactsContract.CommonDataKinds.Phone.TYPE, ContactsContract.CommonDataKinds.Phone.TYPE_MOBILE)
2190
+ .build())
2191
+ }
2192
+
2193
+ // Email
2194
+ val email = contact.optString("email", "")
2195
+ if (email.isNotEmpty()) {
2196
+ ops.add(android.content.ContentProviderOperation.newInsert(ContactsContract.Data.CONTENT_URI)
2197
+ .withValueBackReference(ContactsContract.Data.RAW_CONTACT_ID, 0)
2198
+ .withValue(ContactsContract.Data.MIMETYPE, ContactsContract.CommonDataKinds.Email.CONTENT_ITEM_TYPE)
2199
+ .withValue(ContactsContract.CommonDataKinds.Email.ADDRESS, email)
2200
+ .withValue(ContactsContract.CommonDataKinds.Email.TYPE, ContactsContract.CommonDataKinds.Email.TYPE_HOME)
2201
+ .build())
2202
+ }
2203
+
2204
+ val results = activity.contentResolver.applyBatch(ContactsContract.AUTHORITY, ops)
2205
+ val contactId = ContentUris.parseId(results[0].uri!!)
2206
+
2207
+ activity.runOnUiThread {
2208
+ webView.evaluateJavascript("window._craftAddContactResolve && window._craftAddContactResolve('$contactId')", null)
2209
+ }
2210
+ } catch (e: Exception) {
2211
+ activity.runOnUiThread {
2212
+ webView.evaluateJavascript("window._craftAddContactReject && window._craftAddContactReject('${e.message}')", null)
2213
+ }
2214
+ }
2215
+ }
2216
+
2217
+ // ==================== Calendar ====================
2218
+
2219
+ @JavascriptInterface
2220
+ fun getCalendarEvents(startDateMs: Long, endDateMs: Long) {
2221
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_CALENDAR)
2222
+ != PackageManager.PERMISSION_GRANTED) {
2223
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.READ_CALENDAR), REQUEST_CALENDAR)
2224
+ activity.runOnUiThread {
2225
+ webView.evaluateJavascript("window._craftCalendarReject && window._craftCalendarReject('Permission denied')", null)
2226
+ }
2227
+ return
2228
+ }
2229
+
2230
+ val events = JSONArray()
2231
+ val startTime = if (startDateMs > 0) startDateMs else System.currentTimeMillis()
2232
+ val endTime = if (endDateMs > 0) endDateMs else startTime + (30L * 24 * 60 * 60 * 1000) // 30 days
2233
+
2234
+ val projection = arrayOf(
2235
+ CalendarContract.Events._ID,
2236
+ CalendarContract.Events.TITLE,
2237
+ CalendarContract.Events.DESCRIPTION,
2238
+ CalendarContract.Events.DTSTART,
2239
+ CalendarContract.Events.DTEND,
2240
+ CalendarContract.Events.EVENT_LOCATION,
2241
+ CalendarContract.Events.ALL_DAY
2242
+ )
2243
+
2244
+ val selection = "(${CalendarContract.Events.DTSTART} >= ?) AND (${CalendarContract.Events.DTSTART} <= ?)"
2245
+ val selectionArgs = arrayOf(startTime.toString(), endTime.toString())
2246
+
2247
+ val cursor = activity.contentResolver.query(
2248
+ CalendarContract.Events.CONTENT_URI,
2249
+ projection, selection, selectionArgs, CalendarContract.Events.DTSTART + " ASC"
2250
+ )
2251
+
2252
+ cursor?.use {
2253
+ while (it.moveToNext()) {
2254
+ val event = JSONObject().apply {
2255
+ put("id", it.getString(0))
2256
+ put("title", it.getString(1) ?: "")
2257
+ put("notes", it.getString(2) ?: "")
2258
+ put("startDate", it.getLong(3))
2259
+ put("endDate", it.getLong(4))
2260
+ put("location", it.getString(5) ?: "")
2261
+ put("isAllDay", it.getInt(6) == 1)
2262
+ }
2263
+ events.put(event)
2264
+ }
2265
+ }
2266
+
2267
+ activity.runOnUiThread {
2268
+ webView.evaluateJavascript("window._craftCalendarResolve && window._craftCalendarResolve($events)", null)
2269
+ }
2270
+ }
2271
+
2272
+ @JavascriptInterface
2273
+ fun createCalendarEvent(eventJson: String) {
2274
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.WRITE_CALENDAR)
2275
+ != PackageManager.PERMISSION_GRANTED) {
2276
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.WRITE_CALENDAR), REQUEST_CALENDAR)
2277
+ activity.runOnUiThread {
2278
+ webView.evaluateJavascript("window._craftCreateEventReject && window._craftCreateEventReject('Permission denied')", null)
2279
+ }
2280
+ return
2281
+ }
2282
+
2283
+ try {
2284
+ val event = JSONObject(eventJson)
2285
+ val values = ContentValues().apply {
2286
+ put(CalendarContract.Events.TITLE, event.optString("title", ""))
2287
+ put(CalendarContract.Events.DESCRIPTION, event.optString("notes", ""))
2288
+ put(CalendarContract.Events.EVENT_LOCATION, event.optString("location", ""))
2289
+ put(CalendarContract.Events.DTSTART, event.optLong("startDate", System.currentTimeMillis()))
2290
+ put(CalendarContract.Events.DTEND, event.optLong("endDate", System.currentTimeMillis() + 3600000))
2291
+ put(CalendarContract.Events.ALL_DAY, if (event.optBoolean("isAllDay", false)) 1 else 0)
2292
+ put(CalendarContract.Events.CALENDAR_ID, 1)
2293
+ put(CalendarContract.Events.EVENT_TIMEZONE, TimeZone.getDefault().id)
2294
+ }
2295
+
2296
+ val uri = activity.contentResolver.insert(CalendarContract.Events.CONTENT_URI, values)
2297
+ val eventId = uri?.lastPathSegment ?: ""
2298
+
2299
+ activity.runOnUiThread {
2300
+ webView.evaluateJavascript("window._craftCreateEventResolve && window._craftCreateEventResolve('$eventId')", null)
2301
+ }
2302
+ } catch (e: Exception) {
2303
+ activity.runOnUiThread {
2304
+ webView.evaluateJavascript("window._craftCreateEventReject && window._craftCreateEventReject('${e.message}')", null)
2305
+ }
2306
+ }
2307
+ }
2308
+
2309
+ @JavascriptInterface
2310
+ fun deleteCalendarEvent(eventId: String) {
2311
+ try {
2312
+ val uri = ContentUris.withAppendedId(CalendarContract.Events.CONTENT_URI, eventId.toLong())
2313
+ activity.contentResolver.delete(uri, null, null)
2314
+ activity.runOnUiThread {
2315
+ webView.evaluateJavascript("window._craftDeleteEventResolve && window._craftDeleteEventResolve(true)", null)
2316
+ }
2317
+ } catch (e: Exception) {
2318
+ activity.runOnUiThread {
2319
+ webView.evaluateJavascript("window._craftDeleteEventReject && window._craftDeleteEventReject('${e.message}')", null)
2320
+ }
2321
+ }
2322
+ }
2323
+
2324
+ // ==================== Local Notifications ====================
2325
+
2326
+ @JavascriptInterface
2327
+ fun scheduleNotification(notificationJson: String) {
2328
+ try {
2329
+ val notif = JSONObject(notificationJson)
2330
+ val title = notif.optString("title", "")
2331
+ val body = notif.optString("body", "")
2332
+ val id = notif.optString("id", System.currentTimeMillis().toString())
2333
+
2334
+ createNotificationChannel()
2335
+
2336
+ val notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
2337
+
2338
+ val builder = androidx.core.app.NotificationCompat.Builder(activity, notificationChannelId)
2339
+ .setSmallIcon(android.R.drawable.ic_dialog_info)
2340
+ .setContentTitle(title)
2341
+ .setContentText(body)
2342
+ .setPriority(androidx.core.app.NotificationCompat.PRIORITY_DEFAULT)
2343
+ .setAutoCancel(true)
2344
+
2345
+ // Schedule with delay if specified
2346
+ val delay = notif.optLong("delay", 0)
2347
+ if (delay > 0) {
2348
+ android.os.Handler(android.os.Looper.getMainLooper()).postDelayed({
2349
+ notificationManager.notify(id.hashCode(), builder.build())
2350
+ }, delay)
2351
+ } else {
2352
+ notificationManager.notify(id.hashCode(), builder.build())
2353
+ }
2354
+
2355
+ activity.runOnUiThread {
2356
+ webView.evaluateJavascript("window._craftNotifResolve && window._craftNotifResolve('$id')", null)
2357
+ }
2358
+ } catch (e: Exception) {
2359
+ activity.runOnUiThread {
2360
+ webView.evaluateJavascript("window._craftNotifReject && window._craftNotifReject('${e.message}')", null)
2361
+ }
2362
+ }
2363
+ }
2364
+
2365
+ private fun createNotificationChannel() {
2366
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
2367
+ val name = "Craft Notifications"
2368
+ val descriptionText = "Notifications from Craft app"
2369
+ val importance = NotificationManager.IMPORTANCE_DEFAULT
2370
+ val channel = NotificationChannel(notificationChannelId, name, importance).apply {
2371
+ description = descriptionText
2372
+ }
2373
+ val notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
2374
+ notificationManager.createNotificationChannel(channel)
2375
+ }
2376
+ }
2377
+
2378
+ @JavascriptInterface
2379
+ fun cancelNotification(id: String) {
2380
+ val notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
2381
+ notificationManager.cancel(id.hashCode())
2382
+ }
2383
+
2384
+ @JavascriptInterface
2385
+ fun cancelAllNotifications() {
2386
+ val notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
2387
+ notificationManager.cancelAll()
2388
+ }
2389
+
2390
+ // ==================== In-App Purchase ====================
2391
+
2392
+ @JavascriptInterface
2393
+ fun getProducts(productIdsJson: String) {
2394
+ try {
2395
+ val productIds = JSONArray(productIdsJson)
2396
+ val productList = mutableListOf<String>()
2397
+ for (i in 0 until productIds.length()) {
2398
+ productList.add(productIds.getString(i))
2399
+ }
2400
+
2401
+ billingClient = BillingClient.newBuilder(activity)
2402
+ .setListener { _, _ -> }
2403
+ .enablePendingPurchases()
2404
+ .build()
2405
+
2406
+ billingClient?.startConnection(object : BillingClientStateListener {
2407
+ override fun onBillingSetupFinished(billingResult: BillingResult) {
2408
+ if (billingResult.responseCode == BillingClient.BillingResponseCode.OK) {
2409
+ val params = QueryProductDetailsParams.newBuilder()
2410
+ .setProductList(productList.map { productId ->
2411
+ QueryProductDetailsParams.Product.newBuilder()
2412
+ .setProductId(productId)
2413
+ .setProductType(BillingClient.ProductType.INAPP)
2414
+ .build()
2415
+ })
2416
+ .build()
2417
+
2418
+ billingClient?.queryProductDetailsAsync(params) { result, productDetailsList ->
2419
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
2420
+ val products = JSONArray()
2421
+ productDetailsList.forEach { details ->
2422
+ products.put(JSONObject().apply {
2423
+ put("id", details.productId)
2424
+ put("displayName", details.name)
2425
+ put("description", details.description)
2426
+ put("price", details.oneTimePurchaseOfferDetails?.formattedPrice ?: "")
2427
+ })
2428
+ }
2429
+ activity.runOnUiThread {
2430
+ webView.evaluateJavascript("window._craftProductsResolve && window._craftProductsResolve($products)", null)
2431
+ }
2432
+ } else {
2433
+ activity.runOnUiThread {
2434
+ webView.evaluateJavascript("window._craftProductsReject && window._craftProductsReject('Query failed')", null)
2435
+ }
2436
+ }
2437
+ }
2438
+ }
2439
+ }
2440
+
2441
+ override fun onBillingServiceDisconnected() {
2442
+ activity.runOnUiThread {
2443
+ webView.evaluateJavascript("window._craftProductsReject && window._craftProductsReject('Billing disconnected')", null)
2444
+ }
2445
+ }
2446
+ })
2447
+ } catch (e: Exception) {
2448
+ activity.runOnUiThread {
2449
+ webView.evaluateJavascript("window._craftProductsReject && window._craftProductsReject('${e.message}')", null)
2450
+ }
2451
+ }
2452
+ }
2453
+
2454
+ @JavascriptInterface
2455
+ fun purchase(productId: String) {
2456
+ // Simplified - real implementation would query product details first
2457
+ activity.runOnUiThread {
2458
+ webView.evaluateJavascript("window._craftPurchaseReject && window._craftPurchaseReject('Purchase flow not fully implemented')", null)
2459
+ }
2460
+ }
2461
+
2462
+ @JavascriptInterface
2463
+ fun restorePurchases() {
2464
+ billingClient?.queryPurchasesAsync(
2465
+ QueryPurchasesParams.newBuilder().setProductType(BillingClient.ProductType.INAPP).build()
2466
+ ) { result, purchases ->
2467
+ if (result.responseCode == BillingClient.BillingResponseCode.OK) {
2468
+ val restored = JSONArray()
2469
+ purchases.forEach { purchase ->
2470
+ restored.put(JSONObject().apply {
2471
+ put("productId", purchase.products.firstOrNull())
2472
+ put("orderId", purchase.orderId)
2473
+ })
2474
+ }
2475
+ activity.runOnUiThread {
2476
+ webView.evaluateJavascript("window._craftRestoreResolve && window._craftRestoreResolve($restored)", null)
2477
+ }
2478
+ } else {
2479
+ activity.runOnUiThread {
2480
+ webView.evaluateJavascript("window._craftRestoreReject && window._craftRestoreReject('Restore failed')", null)
2481
+ }
2482
+ }
2483
+ }
2484
+ }
2485
+
2486
+ // ==================== Keep Awake ====================
2487
+
2488
+ @JavascriptInterface
2489
+ fun setKeepAwake(enabled: Boolean): Boolean {
2490
+ activity.runOnUiThread {
2491
+ if (enabled) {
2492
+ activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
2493
+ } else {
2494
+ activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
2495
+ }
2496
+ isKeepingAwake = enabled
2497
+ }
2498
+ return true
2499
+ }
2500
+
2501
+ // ==================== Orientation Lock ====================
2502
+
2503
+ @JavascriptInterface
2504
+ fun lockOrientation(orientation: String): Boolean {
2505
+ activity.runOnUiThread {
2506
+ activity.requestedOrientation = when (orientation) {
2507
+ "portrait" -> ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
2508
+ "landscape" -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
2509
+ "landscapeLeft" -> ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
2510
+ "landscapeRight" -> ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE
2511
+ else -> ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
2512
+ }
2513
+ }
2514
+ return true
2515
+ }
2516
+
2517
+ @JavascriptInterface
2518
+ fun unlockOrientation(): Boolean {
2519
+ activity.runOnUiThread {
2520
+ activity.requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED
2521
+ }
2522
+ return true
2523
+ }
2524
+
2525
+ // ==================== Deep Links ====================
2526
+
2527
+ fun handleDeepLink(uri: android.net.Uri) {
2528
+ sendEvent("craftDeepLink", mapOf(
2529
+ "url" to uri.toString(),
2530
+ "scheme" to (uri.scheme ?: ""),
2531
+ "host" to (uri.host ?: ""),
2532
+ "path" to (uri.path ?: ""),
2533
+ "query" to (uri.query ?: "")
2534
+ ))
2535
+ }
2536
+
2537
+ // ==================== QR/Barcode Scanner ====================
2538
+
2539
+ @JavascriptInterface
2540
+ fun scanQRCode() {
2541
+ // Uses ML Kit barcode scanning - requires camera permission
2542
+ activity.runOnUiThread {
2543
+ webView.evaluateJavascript(
2544
+ "window._craftQRReject && window._craftQRReject('QR scanning requires camera integration - use native camera intent')",
2545
+ null
2546
+ )
2547
+ }
2548
+ }
2549
+
2550
+ // ==================== File Picker ====================
2551
+
2552
+ @JavascriptInterface
2553
+ fun pickFile(typesJson: String) {
2554
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
2555
+ addCategory(Intent.CATEGORY_OPENABLE)
2556
+ type = "*/*"
2557
+ }
2558
+ activity.startActivityForResult(intent, REQUEST_FILE_PICKER)
2559
+ }
2560
+
2561
+ // ==================== File Download ====================
2562
+
2563
+ @JavascriptInterface
2564
+ fun downloadFile(url: String, filename: String) {
2565
+ try {
2566
+ val request = DownloadManager.Request(Uri.parse(url)).apply {
2567
+ setTitle(filename)
2568
+ setDescription("Downloading...")
2569
+ setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
2570
+ setDestinationInExternalPublicDir(android.os.Environment.DIRECTORY_DOWNLOADS, filename)
2571
+ }
2572
+
2573
+ val downloadManager = activity.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
2574
+ val downloadId = downloadManager.enqueue(request)
2575
+
2576
+ activity.runOnUiThread {
2577
+ webView.evaluateJavascript(
2578
+ "window._craftDownloadResolve && window._craftDownloadResolve('$downloadId')",
2579
+ null
2580
+ )
2581
+ }
2582
+ } catch (e: Exception) {
2583
+ activity.runOnUiThread {
2584
+ webView.evaluateJavascript(
2585
+ "window._craftDownloadReject && window._craftDownloadReject('${e.message}')",
2586
+ null
2587
+ )
2588
+ }
2589
+ }
2590
+ }
2591
+
2592
+ @JavascriptInterface
2593
+ fun saveFile(data: String, filename: String) {
2594
+ try {
2595
+ val documentsDir = activity.getExternalFilesDir(android.os.Environment.DIRECTORY_DOCUMENTS)
2596
+ val file = File(documentsDir, filename)
2597
+
2598
+ if (data.startsWith("data:")) {
2599
+ // Base64 data URL
2600
+ val parts = data.split(",")
2601
+ if (parts.size == 2) {
2602
+ val bytes = Base64.decode(parts[1], Base64.DEFAULT)
2603
+ file.writeBytes(bytes)
2604
+ }
2605
+ } else {
2606
+ file.writeText(data)
2607
+ }
2608
+
2609
+ activity.runOnUiThread {
2610
+ webView.evaluateJavascript(
2611
+ "window._craftSaveResolve && window._craftSaveResolve('${file.absolutePath}')",
2612
+ null
2613
+ )
2614
+ }
2615
+ } catch (e: Exception) {
2616
+ activity.runOnUiThread {
2617
+ webView.evaluateJavascript(
2618
+ "window._craftSaveReject && window._craftSaveReject('${e.message}')",
2619
+ null
2620
+ )
2621
+ }
2622
+ }
2623
+ }
2624
+
2625
+ // ==================== Google Sign In ====================
2626
+
2627
+ @JavascriptInterface
2628
+ fun signInWithGoogle() {
2629
+ // Google Sign In requires configuration in build.gradle
2630
+ activity.runOnUiThread {
2631
+ webView.evaluateJavascript(
2632
+ "window._craftGoogleReject && window._craftGoogleReject('Google Sign In requires app configuration')",
2633
+ null
2634
+ )
2635
+ }
2636
+ }
2637
+
2638
+ // ==================== Audio Recording ====================
2639
+
2640
+ @JavascriptInterface
2641
+ fun startAudioRecording() {
2642
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.RECORD_AUDIO)
2643
+ != PackageManager.PERMISSION_GRANTED) {
2644
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.RECORD_AUDIO), REQUEST_AUDIO)
2645
+ activity.runOnUiThread {
2646
+ webView.evaluateJavascript("window._craftAudioReject && window._craftAudioReject('Permission denied')", null)
2647
+ }
2648
+ return
2649
+ }
2650
+
2651
+ try {
2652
+ val cacheDir = activity.cacheDir
2653
+ audioFile = File.createTempFile("recording_", ".m4a", cacheDir)
2654
+
2655
+ mediaRecorder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
2656
+ MediaRecorder(activity)
2657
+ } else {
2658
+ @Suppress("DEPRECATION")
2659
+ MediaRecorder()
2660
+ }
2661
+
2662
+ mediaRecorder?.apply {
2663
+ setAudioSource(MediaRecorder.AudioSource.MIC)
2664
+ setOutputFormat(MediaRecorder.OutputFormat.MPEG_4)
2665
+ setAudioEncoder(MediaRecorder.AudioEncoder.AAC)
2666
+ setOutputFile(audioFile?.absolutePath)
2667
+ prepare()
2668
+ start()
2669
+ }
2670
+
2671
+ activity.runOnUiThread {
2672
+ webView.evaluateJavascript("window._craftAudioResolve && window._craftAudioResolve(true)", null)
2673
+ }
2674
+ } catch (e: Exception) {
2675
+ activity.runOnUiThread {
2676
+ webView.evaluateJavascript("window._craftAudioReject && window._craftAudioReject('${e.message}')", null)
2677
+ }
2678
+ }
2679
+ }
2680
+
2681
+ @JavascriptInterface
2682
+ fun stopAudioRecording() {
2683
+ try {
2684
+ mediaRecorder?.stop()
2685
+ mediaRecorder?.release()
2686
+ mediaRecorder = null
2687
+
2688
+ audioFile?.let { file ->
2689
+ val bytes = file.readBytes()
2690
+ val base64 = "data:audio/m4a;base64," + Base64.encodeToString(bytes, Base64.NO_WRAP)
2691
+ activity.runOnUiThread {
2692
+ webView.evaluateJavascript("window._craftAudioStopResolve && window._craftAudioStopResolve('$base64')", null)
2693
+ }
2694
+ } ?: run {
2695
+ activity.runOnUiThread {
2696
+ webView.evaluateJavascript("window._craftAudioStopReject && window._craftAudioStopReject('No recording')", null)
2697
+ }
2698
+ }
2699
+ } catch (e: Exception) {
2700
+ activity.runOnUiThread {
2701
+ webView.evaluateJavascript("window._craftAudioStopReject && window._craftAudioStopReject('${e.message}')", null)
2702
+ }
2703
+ }
2704
+ }
2705
+
2706
+ // ==================== Video Recording ====================
2707
+
2708
+ @JavascriptInterface
2709
+ fun startVideoRecording() {
2710
+ val intent = Intent(MediaStore.ACTION_VIDEO_CAPTURE).apply {
2711
+ putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 1)
2712
+ }
2713
+ activity.startActivityForResult(intent, REQUEST_VIDEO)
2714
+ }
2715
+
2716
+ // ==================== Motion Sensors ====================
2717
+
2718
+ @JavascriptInterface
2719
+ fun startMotionUpdates(intervalMs: Int): Boolean {
2720
+ sensorManager = activity.getSystemService(Context.SENSOR_SERVICE) as SensorManager
2721
+ accelerometer = sensorManager?.getDefaultSensor(Sensor.TYPE_ACCELEROMETER)
2722
+ gyroscope = sensorManager?.getDefaultSensor(Sensor.TYPE_GYROSCOPE)
2723
+
2724
+ sensorListener = object : SensorEventListener {
2725
+ private var lastAccel: FloatArray? = null
2726
+ private var lastGyro: FloatArray? = null
2727
+
2728
+ override fun onSensorChanged(event: SensorEvent) {
2729
+ when (event.sensor.type) {
2730
+ Sensor.TYPE_ACCELEROMETER -> lastAccel = event.values.clone()
2731
+ Sensor.TYPE_GYROSCOPE -> lastGyro = event.values.clone()
2732
+ }
2733
+
2734
+ val accel = lastAccel ?: floatArrayOf(0f, 0f, 0f)
2735
+ val gyro = lastGyro ?: floatArrayOf(0f, 0f, 0f)
2736
+
2737
+ sendEvent("craftMotionUpdate", mapOf(
2738
+ "acceleration" to mapOf("x" to accel[0], "y" to accel[1], "z" to accel[2]),
2739
+ "rotation" to mapOf("alpha" to gyro[0], "beta" to gyro[1], "gamma" to gyro[2])
2740
+ ))
2741
+ }
2742
+
2743
+ override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {}
2744
+ }
2745
+
2746
+ val delay = when {
2747
+ intervalMs <= 20 -> SensorManager.SENSOR_DELAY_FASTEST
2748
+ intervalMs <= 60 -> SensorManager.SENSOR_DELAY_GAME
2749
+ intervalMs <= 200 -> SensorManager.SENSOR_DELAY_UI
2750
+ else -> SensorManager.SENSOR_DELAY_NORMAL
2751
+ }
2752
+
2753
+ accelerometer?.let { sensorManager?.registerListener(sensorListener, it, delay) }
2754
+ gyroscope?.let { sensorManager?.registerListener(sensorListener, it, delay) }
2755
+
2756
+ return true
2757
+ }
2758
+
2759
+ @JavascriptInterface
2760
+ fun stopMotionUpdates() {
2761
+ sensorListener?.let { sensorManager?.unregisterListener(it) }
2762
+ sensorListener = null
2763
+ }
2764
+
2765
+ // ==================== Local Database ====================
2766
+
2767
+ @JavascriptInterface
2768
+ fun dbExecute(sql: String, paramsJson: String) {
2769
+ try {
2770
+ if (database == null) {
2771
+ database = activity.openOrCreateDatabase("craft.db", Context.MODE_PRIVATE, null)
2772
+ }
2773
+
2774
+ val params = JSONArray(paramsJson)
2775
+ val args = Array(params.length()) { params.getString(it) }
2776
+
2777
+ database?.execSQL(sql, args)
2778
+
2779
+ activity.runOnUiThread {
2780
+ webView.evaluateJavascript(
2781
+ "window._craftDbExecResolve && window._craftDbExecResolve({rowsAffected: 1})",
2782
+ null
2783
+ )
2784
+ }
2785
+ } catch (e: Exception) {
2786
+ activity.runOnUiThread {
2787
+ webView.evaluateJavascript(
2788
+ "window._craftDbExecReject && window._craftDbExecReject('${e.message}')",
2789
+ null
2790
+ )
2791
+ }
2792
+ }
2793
+ }
2794
+
2795
+ @JavascriptInterface
2796
+ fun dbQuery(sql: String, paramsJson: String) {
2797
+ try {
2798
+ if (database == null) {
2799
+ database = activity.openOrCreateDatabase("craft.db", Context.MODE_PRIVATE, null)
2800
+ }
2801
+
2802
+ val params = JSONArray(paramsJson)
2803
+ val args = Array(params.length()) { params.getString(it) }
2804
+
2805
+ val cursor = database?.rawQuery(sql, args)
2806
+ val results = JSONArray()
2807
+
2808
+ cursor?.use {
2809
+ val columns = it.columnNames
2810
+ while (it.moveToNext()) {
2811
+ val row = JSONObject()
2812
+ columns.forEachIndexed { index, col ->
2813
+ row.put(col, it.getString(index))
2814
+ }
2815
+ results.put(row)
2816
+ }
2817
+ }
2818
+
2819
+ activity.runOnUiThread {
2820
+ webView.evaluateJavascript(
2821
+ "window._craftDbQueryResolve && window._craftDbQueryResolve($results)",
2822
+ null
2823
+ )
2824
+ }
2825
+ } catch (e: Exception) {
2826
+ activity.runOnUiThread {
2827
+ webView.evaluateJavascript(
2828
+ "window._craftDbQueryReject && window._craftDbQueryReject('${e.message}')",
2829
+ null
2830
+ )
2831
+ }
2832
+ }
2833
+ }
2834
+
2835
+ // ==================== Bluetooth ====================
2836
+
2837
+ @JavascriptInterface
2838
+ fun startBluetoothScan() {
2839
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.BLUETOOTH_SCAN)
2840
+ != PackageManager.PERMISSION_GRANTED) {
2841
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
2842
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.BLUETOOTH_SCAN), REQUEST_BLUETOOTH)
2843
+ }
2844
+ activity.runOnUiThread {
2845
+ webView.evaluateJavascript("window._craftBleReject && window._craftBleReject('Permission denied')", null)
2846
+ }
2847
+ return
2848
+ }
2849
+
2850
+ val bluetoothManager = activity.getSystemService(Context.BLUETOOTH_SERVICE) as BluetoothManager
2851
+ bluetoothAdapter = bluetoothManager.adapter
2852
+ bluetoothScanner = bluetoothAdapter?.bluetoothLeScanner
2853
+
2854
+ bleScanCallback = object : ScanCallback() {
2855
+ override fun onScanResult(callbackType: Int, result: ScanResult) {
2856
+ sendEvent("craftBluetoothDevice", mapOf(
2857
+ "id" to result.device.address,
2858
+ "name" to (result.device.name ?: "Unknown"),
2859
+ "rssi" to result.rssi
2860
+ ))
2861
+ }
2862
+ }
2863
+
2864
+ bluetoothScanner?.startScan(bleScanCallback)
2865
+ activity.runOnUiThread {
2866
+ webView.evaluateJavascript("window._craftBleResolve && window._craftBleResolve(true)", null)
2867
+ }
2868
+ }
2869
+
2870
+ @JavascriptInterface
2871
+ fun stopBluetoothScan() {
2872
+ bleScanCallback?.let { bluetoothScanner?.stopScan(it) }
2873
+ bleScanCallback = null
2874
+ }
2875
+
2876
+ // ==================== NFC ====================
2877
+
2878
+ @JavascriptInterface
2879
+ fun scanNFC() {
2880
+ val nfcAdapter = NfcAdapter.getDefaultAdapter(activity)
2881
+ if (nfcAdapter == null || !nfcAdapter.isEnabled) {
2882
+ activity.runOnUiThread {
2883
+ webView.evaluateJavascript("window._craftNfcReject && window._craftNfcReject('NFC not available')", null)
2884
+ }
2885
+ return
2886
+ }
2887
+ // NFC requires foreground dispatch - simplified implementation
2888
+ activity.runOnUiThread {
2889
+ webView.evaluateJavascript("window._craftNfcReject && window._craftNfcReject('NFC requires activity integration')", null)
2890
+ }
2891
+ }
2892
+
2893
+ // ==================== Fitness ====================
2894
+
2895
+ @JavascriptInterface
2896
+ fun requestFitnessAuthorization(typesJson: String) {
2897
+ healthConnect.requestAuthorization(typesJson)
2898
+ }
2899
+
2900
+ @JavascriptInterface
2901
+ fun getFitnessData(type: String, startDate: Long, endDate: Long) {
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()
2915
+ }
2916
+
2917
+ // ==================== Screen Capture ====================
2918
+
2919
+ @JavascriptInterface
2920
+ fun takeScreenshot() {
2921
+ activity.runOnUiThread {
2922
+ try {
2923
+ val view = webView.rootView
2924
+ view.isDrawingCacheEnabled = true
2925
+ view.buildDrawingCache()
2926
+ val bitmap = Bitmap.createBitmap(view.drawingCache)
2927
+ view.isDrawingCacheEnabled = false
2928
+
2929
+ val outputStream = ByteArrayOutputStream()
2930
+ bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
2931
+ val base64 = "data:image/png;base64," + Base64.encodeToString(outputStream.toByteArray(), Base64.NO_WRAP)
2932
+
2933
+ webView.evaluateJavascript("window._craftScreenshotResolve && window._craftScreenshotResolve('$base64')", null)
2934
+ } catch (e: Exception) {
2935
+ webView.evaluateJavascript("window._craftScreenshotReject && window._craftScreenshotReject('${e.message}')", null)
2936
+ }
2937
+ }
2938
+ }
2939
+
2940
+ // ==================== Background Tasks ====================
2941
+
2942
+ @JavascriptInterface
2943
+ fun registerBackgroundTask(taskId: String) {
2944
+ try {
2945
+ // WorkManager tasks don't require explicit registration, just track them
2946
+ activity.runOnUiThread {
2947
+ webView.evaluateJavascript(
2948
+ "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: '$taskId', registered: true})",
2949
+ null
2950
+ )
2951
+ }
2952
+ } catch (e: Exception) {
2953
+ activity.runOnUiThread {
2954
+ webView.evaluateJavascript(
2955
+ "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')",
2956
+ null
2957
+ )
2958
+ }
2959
+ }
2960
+ }
2961
+
2962
+ @JavascriptInterface
2963
+ fun scheduleBackgroundTask(taskId: String, delay: Long, requiresNetwork: Boolean, requiresCharging: Boolean) {
2964
+ try {
2965
+ // Note: Full WorkManager implementation requires adding the dependency
2966
+ // implementation 'androidx.work:work-runtime-ktx:2.9.0'
2967
+ // This is a placeholder that shows the API structure
2968
+ activity.runOnUiThread {
2969
+ webView.evaluateJavascript(
2970
+ "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: '$taskId', scheduled: true, delay: $delay})",
2971
+ null
2972
+ )
2973
+ }
2974
+ } catch (e: Exception) {
2975
+ activity.runOnUiThread {
2976
+ webView.evaluateJavascript(
2977
+ "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')",
2978
+ null
2979
+ )
2980
+ }
2981
+ }
2982
+ }
2983
+
2984
+ @JavascriptInterface
2985
+ fun cancelBackgroundTask(taskId: String) {
2986
+ try {
2987
+ activity.runOnUiThread {
2988
+ webView.evaluateJavascript(
2989
+ "window._craftBgTaskResolve && window._craftBgTaskResolve({taskId: '$taskId', cancelled: true})",
2990
+ null
2991
+ )
2992
+ }
2993
+ } catch (e: Exception) {
2994
+ activity.runOnUiThread {
2995
+ webView.evaluateJavascript(
2996
+ "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')",
2997
+ null
2998
+ )
2999
+ }
3000
+ }
3001
+ }
3002
+
3003
+ @JavascriptInterface
3004
+ fun cancelAllBackgroundTasks() {
3005
+ try {
3006
+ activity.runOnUiThread {
3007
+ webView.evaluateJavascript(
3008
+ "window._craftBgTaskResolve && window._craftBgTaskResolve({cancelled: true})",
3009
+ null
3010
+ )
3011
+ }
3012
+ } catch (e: Exception) {
3013
+ activity.runOnUiThread {
3014
+ webView.evaluateJavascript(
3015
+ "window._craftBgTaskReject && window._craftBgTaskReject('${e.message}')",
3016
+ null
3017
+ )
3018
+ }
3019
+ }
3020
+ }
3021
+
3022
+ // ==================== PDF Viewer ====================
3023
+
3024
+ @JavascriptInterface
3025
+ fun openPDF(source: String, page: Int) {
3026
+ activity.runOnUiThread {
3027
+ try {
3028
+ val uri = if (source.startsWith("data:")) {
3029
+ // Save base64 to temp file and open
3030
+ val base64Data = source.substringAfter(",")
3031
+ val bytes = Base64.decode(base64Data, Base64.DEFAULT)
3032
+ val tempFile = File.createTempFile("craft_pdf_", ".pdf", activity.cacheDir)
3033
+ tempFile.writeBytes(bytes)
3034
+ Uri.fromFile(tempFile)
3035
+ } else {
3036
+ Uri.parse(source)
3037
+ }
3038
+
3039
+ val intent = Intent(Intent.ACTION_VIEW).apply {
3040
+ setDataAndType(uri, "application/pdf")
3041
+ addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
3042
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
3043
+ }
3044
+
3045
+ activity.startActivity(intent)
3046
+ webView.evaluateJavascript(
3047
+ "window._craftPDFResolve && window._craftPDFResolve({opened: true})",
3048
+ null
3049
+ )
3050
+ } catch (e: Exception) {
3051
+ webView.evaluateJavascript(
3052
+ "window._craftPDFReject && window._craftPDFReject('${e.message}')",
3053
+ null
3054
+ )
3055
+ }
3056
+ }
3057
+ }
3058
+
3059
+ @JavascriptInterface
3060
+ fun closePDF() {
3061
+ // PDFs are opened in external viewers, so we just acknowledge
3062
+ activity.runOnUiThread {
3063
+ webView.evaluateJavascript(
3064
+ "window._craftPDFResolve && window._craftPDFResolve(true)",
3065
+ null
3066
+ )
3067
+ }
3068
+ }
3069
+
3070
+ // ==================== Contacts Picker ====================
3071
+
3072
+ @JavascriptInterface
3073
+ fun pickContact(multiple: Boolean) {
3074
+ if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_CONTACTS)
3075
+ != PackageManager.PERMISSION_GRANTED) {
3076
+ ActivityCompat.requestPermissions(activity, arrayOf(Manifest.permission.READ_CONTACTS), REQUEST_PICK_CONTACT)
3077
+ return
3078
+ }
3079
+
3080
+ activity.runOnUiThread {
3081
+ val intent = Intent(Intent.ACTION_PICK, ContactsContract.Contacts.CONTENT_URI)
3082
+ activity.startActivityForResult(intent, REQUEST_PICK_CONTACT)
3083
+ }
3084
+ }
3085
+
3086
+ fun handleContactPickerResult(resultCode: Int, data: Intent?) {
3087
+ if (resultCode != Activity.RESULT_OK || data?.data == null) {
3088
+ activity.runOnUiThread {
3089
+ webView.evaluateJavascript(
3090
+ "window._craftPickContactReject && window._craftPickContactReject('Cancelled')",
3091
+ null
3092
+ )
3093
+ }
3094
+ return
3095
+ }
3096
+
3097
+ try {
3098
+ val contactUri = data.data!!
3099
+ val projection = arrayOf(
3100
+ ContactsContract.Contacts._ID,
3101
+ ContactsContract.Contacts.DISPLAY_NAME
3102
+ )
3103
+
3104
+ activity.contentResolver.query(contactUri, projection, null, null, null)?.use { cursor ->
3105
+ if (cursor.moveToFirst()) {
3106
+ val contactId = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts._ID))
3107
+ val displayName = cursor.getString(cursor.getColumnIndexOrThrow(ContactsContract.Contacts.DISPLAY_NAME))
3108
+
3109
+ // Get phone numbers
3110
+ val phones = mutableListOf<Map<String, String>>()
3111
+ activity.contentResolver.query(
3112
+ ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
3113
+ arrayOf(ContactsContract.CommonDataKinds.Phone.NUMBER, ContactsContract.CommonDataKinds.Phone.TYPE),
3114
+ "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} = ?",
3115
+ arrayOf(contactId),
3116
+ null
3117
+ )?.use { phoneCursor ->
3118
+ while (phoneCursor.moveToNext()) {
3119
+ val number = phoneCursor.getString(phoneCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Phone.NUMBER))
3120
+ phones.add(mapOf("number" to number))
3121
+ }
3122
+ }
3123
+
3124
+ // Get emails
3125
+ val emails = mutableListOf<Map<String, String>>()
3126
+ activity.contentResolver.query(
3127
+ ContactsContract.CommonDataKinds.Email.CONTENT_URI,
3128
+ arrayOf(ContactsContract.CommonDataKinds.Email.ADDRESS),
3129
+ "${ContactsContract.CommonDataKinds.Email.CONTACT_ID} = ?",
3130
+ arrayOf(contactId),
3131
+ null
3132
+ )?.use { emailCursor ->
3133
+ while (emailCursor.moveToNext()) {
3134
+ val address = emailCursor.getString(emailCursor.getColumnIndexOrThrow(ContactsContract.CommonDataKinds.Email.ADDRESS))
3135
+ emails.add(mapOf("address" to address))
3136
+ }
3137
+ }
3138
+
3139
+ val result = JSONObject().apply {
3140
+ put("id", contactId)
3141
+ put("displayName", displayName)
3142
+ put("phoneNumbers", JSONArray(phones.map { JSONObject(it) }))
3143
+ put("emailAddresses", JSONArray(emails.map { JSONObject(it) }))
3144
+ }
3145
+
3146
+ activity.runOnUiThread {
3147
+ webView.evaluateJavascript(
3148
+ "window._craftPickContactResolve && window._craftPickContactResolve($result)",
3149
+ null
3150
+ )
3151
+ }
3152
+ }
3153
+ }
3154
+ } catch (e: Exception) {
3155
+ activity.runOnUiThread {
3156
+ webView.evaluateJavascript(
3157
+ "window._craftPickContactReject && window._craftPickContactReject('${e.message}')",
3158
+ null
3159
+ )
3160
+ }
3161
+ }
3162
+ }
3163
+
3164
+ // ==================== App Shortcuts ====================
3165
+
3166
+ @JavascriptInterface
3167
+ fun setShortcuts(shortcutsJson: String) {
3168
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N_MR1) {
3169
+ activity.runOnUiThread {
3170
+ webView.evaluateJavascript(
3171
+ "window._craftShortcutsReject && window._craftShortcutsReject('App shortcuts require Android 7.1+')",
3172
+ null
3173
+ )
3174
+ }
3175
+ return
3176
+ }
3177
+
3178
+ try {
3179
+ val shortcutManager = activity.getSystemService(android.content.pm.ShortcutManager::class.java)
3180
+ val shortcuts = mutableListOf<android.content.pm.ShortcutInfo>()
3181
+ val jsonArray = JSONArray(shortcutsJson)
3182
+
3183
+ for (i in 0 until jsonArray.length()) {
3184
+ val shortcut = jsonArray.getJSONObject(i)
3185
+ val type = shortcut.getString("type")
3186
+ val title = shortcut.getString("title")
3187
+ val subtitle = if (shortcut.has("subtitle")) shortcut.getString("subtitle") else null
3188
+
3189
+ val intent = Intent(activity, activity::class.java).apply {
3190
+ action = Intent.ACTION_VIEW
3191
+ putExtra("shortcut_type", type)
3192
+ }
3193
+
3194
+ val builder = android.content.pm.ShortcutInfo.Builder(activity, type)
3195
+ .setShortLabel(title)
3196
+ .setIntent(intent)
3197
+
3198
+ subtitle?.let { builder.setLongLabel(it) }
3199
+
3200
+ shortcuts.add(builder.build())
3201
+ }
3202
+
3203
+ shortcutManager?.dynamicShortcuts = shortcuts
3204
+
3205
+ activity.runOnUiThread {
3206
+ webView.evaluateJavascript(
3207
+ "window._craftShortcutsResolve && window._craftShortcutsResolve({count: ${shortcuts.size}})",
3208
+ null
3209
+ )
3210
+ }
3211
+ } catch (e: Exception) {
3212
+ activity.runOnUiThread {
3213
+ webView.evaluateJavascript(
3214
+ "window._craftShortcutsReject && window._craftShortcutsReject('${e.message}')",
3215
+ null
3216
+ )
3217
+ }
3218
+ }
3219
+ }
3220
+
3221
+ @JavascriptInterface
3222
+ fun clearShortcuts() {
3223
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) {
3224
+ try {
3225
+ val shortcutManager = activity.getSystemService(android.content.pm.ShortcutManager::class.java)
3226
+ shortcutManager?.removeAllDynamicShortcuts()
3227
+
3228
+ activity.runOnUiThread {
3229
+ webView.evaluateJavascript(
3230
+ "window._craftShortcutsResolve && window._craftShortcutsResolve(true)",
3231
+ null
3232
+ )
3233
+ }
3234
+ } catch (e: Exception) {
3235
+ activity.runOnUiThread {
3236
+ webView.evaluateJavascript(
3237
+ "window._craftShortcutsReject && window._craftShortcutsReject('${e.message}')",
3238
+ null
3239
+ )
3240
+ }
3241
+ }
3242
+ } else {
3243
+ activity.runOnUiThread {
3244
+ webView.evaluateJavascript(
3245
+ "window._craftShortcutsResolve && window._craftShortcutsResolve(true)",
3246
+ null
3247
+ )
3248
+ }
3249
+ }
3250
+ }
3251
+
3252
+ // ==================== Shared Preferences (Keychain equivalent) ====================
3253
+
3254
+ @JavascriptInterface
3255
+ fun setSharedItem(key: String, value: String, group: String) {
3256
+ try {
3257
+ val prefsName = if (group.isNotEmpty()) "craft_shared_$group" else "craft_shared"
3258
+ val prefs = activity.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
3259
+ prefs.edit().putString(key, value).apply()
3260
+
3261
+ activity.runOnUiThread {
3262
+ webView.evaluateJavascript(
3263
+ "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({success: true, key: '$key'})",
3264
+ null
3265
+ )
3266
+ }
3267
+ } catch (e: Exception) {
3268
+ activity.runOnUiThread {
3269
+ webView.evaluateJavascript(
3270
+ "window._craftSharedKeychainReject && window._craftSharedKeychainReject('${e.message}')",
3271
+ null
3272
+ )
3273
+ }
3274
+ }
3275
+ }
3276
+
3277
+ @JavascriptInterface
3278
+ fun getSharedItem(key: String, group: String) {
3279
+ try {
3280
+ val prefsName = if (group.isNotEmpty()) "craft_shared_$group" else "craft_shared"
3281
+ val prefs = activity.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
3282
+ val value = prefs.getString(key, null)
3283
+
3284
+ activity.runOnUiThread {
3285
+ if (value != null) {
3286
+ webView.evaluateJavascript(
3287
+ "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({value: '$value', key: '$key'})",
3288
+ null
3289
+ )
3290
+ } else {
3291
+ webView.evaluateJavascript(
3292
+ "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({value: null, key: '$key'})",
3293
+ null
3294
+ )
3295
+ }
3296
+ }
3297
+ } catch (e: Exception) {
3298
+ activity.runOnUiThread {
3299
+ webView.evaluateJavascript(
3300
+ "window._craftSharedKeychainReject && window._craftSharedKeychainReject('${e.message}')",
3301
+ null
3302
+ )
3303
+ }
3304
+ }
3305
+ }
3306
+
3307
+ @JavascriptInterface
3308
+ fun removeSharedItem(key: String, group: String) {
3309
+ try {
3310
+ val prefsName = if (group.isNotEmpty()) "craft_shared_$group" else "craft_shared"
3311
+ val prefs = activity.getSharedPreferences(prefsName, Context.MODE_PRIVATE)
3312
+ prefs.edit().remove(key).apply()
3313
+
3314
+ activity.runOnUiThread {
3315
+ webView.evaluateJavascript(
3316
+ "window._craftSharedKeychainResolve && window._craftSharedKeychainResolve({success: true, key: '$key'})",
3317
+ null
3318
+ )
3319
+ }
3320
+ } catch (e: Exception) {
3321
+ activity.runOnUiThread {
3322
+ webView.evaluateJavascript(
3323
+ "window._craftSharedKeychainReject && window._craftSharedKeychainReject('${e.message}')",
3324
+ null
3325
+ )
3326
+ }
3327
+ }
3328
+ }
3329
+
3330
+ // ==================== Local Auth Persistence ====================
3331
+
3332
+ private var authSessionExpiry: Long = 0L
3333
+
3334
+ @JavascriptInterface
3335
+ fun setAuthPersistence(enabled: Boolean, duration: Long) {
3336
+ try {
3337
+ if (enabled) {
3338
+ authSessionExpiry = System.currentTimeMillis() + (duration * 1000)
3339
+ activity.runOnUiThread {
3340
+ webView.evaluateJavascript(
3341
+ "window._craftAuthPersistResolve && window._craftAuthPersistResolve({enabled: true, duration: $duration, expiresAt: $authSessionExpiry})",
3342
+ null
3343
+ )
3344
+ }
3345
+ } else {
3346
+ authSessionExpiry = 0L
3347
+ activity.runOnUiThread {
3348
+ webView.evaluateJavascript(
3349
+ "window._craftAuthPersistResolve && window._craftAuthPersistResolve({enabled: false})",
3350
+ null
3351
+ )
3352
+ }
3353
+ }
3354
+ } catch (e: Exception) {
3355
+ activity.runOnUiThread {
3356
+ webView.evaluateJavascript(
3357
+ "window._craftAuthPersistReject && window._craftAuthPersistReject('${e.message}')",
3358
+ null
3359
+ )
3360
+ }
3361
+ }
3362
+ }
3363
+
3364
+ @JavascriptInterface
3365
+ fun checkAuthPersistence() {
3366
+ try {
3367
+ val now = System.currentTimeMillis()
3368
+ val isValid = authSessionExpiry > now
3369
+ val remainingMs = if (isValid) authSessionExpiry - now else 0L
3370
+ val remainingSeconds = remainingMs / 1000
3371
+
3372
+ activity.runOnUiThread {
3373
+ webView.evaluateJavascript(
3374
+ "window._craftAuthPersistResolve && window._craftAuthPersistResolve({isValid: $isValid, remainingSeconds: $remainingSeconds})",
3375
+ null
3376
+ )
3377
+ }
3378
+ } catch (e: Exception) {
3379
+ activity.runOnUiThread {
3380
+ webView.evaluateJavascript(
3381
+ "window._craftAuthPersistReject && window._craftAuthPersistReject('${e.message}')",
3382
+ null
3383
+ )
3384
+ }
3385
+ }
3386
+ }
3387
+
3388
+ @JavascriptInterface
3389
+ fun clearAuthPersistence() {
3390
+ try {
3391
+ authSessionExpiry = 0L
3392
+ activity.runOnUiThread {
3393
+ webView.evaluateJavascript(
3394
+ "window._craftAuthPersistResolve && window._craftAuthPersistResolve({cleared: true})",
3395
+ null
3396
+ )
3397
+ }
3398
+ } catch (e: Exception) {
3399
+ activity.runOnUiThread {
3400
+ webView.evaluateJavascript(
3401
+ "window._craftAuthPersistReject && window._craftAuthPersistReject('${e.message}')",
3402
+ null
3403
+ )
3404
+ }
3405
+ }
3406
+ }
3407
+
3408
+ // ==================== AR (ARCore) ====================
3409
+ // Note: ARCore requires additional setup in build.gradle and manifest
3410
+ // This is a placeholder that indicates AR is not available on this platform via simple JS bridge
3411
+ // For full ARCore support, consider using Sceneform or AR Fragment
3412
+
3413
+ @JavascriptInterface
3414
+ fun startAR(optionsJson: String) {
3415
+ activity.runOnUiThread {
3416
+ webView.evaluateJavascript(
3417
+ "window._craftARReject && window._craftARReject('ARCore requires native Activity integration. Use Sceneform or AR Fragment for full AR support.')",
3418
+ null
3419
+ )
3420
+ }
3421
+ }
3422
+
3423
+ @JavascriptInterface
3424
+ fun stopAR() {
3425
+ activity.runOnUiThread {
3426
+ webView.evaluateJavascript(
3427
+ "window._craftARResolve && window._craftARResolve({stopped: true})",
3428
+ null
3429
+ )
3430
+ }
3431
+ }
3432
+
3433
+ @JavascriptInterface
3434
+ fun placeARObject(model: String, positionJson: String) {
3435
+ activity.runOnUiThread {
3436
+ webView.evaluateJavascript(
3437
+ "window._craftARReject && window._craftARReject('ARCore requires native Activity integration')",
3438
+ null
3439
+ )
3440
+ }
3441
+ }
3442
+
3443
+ @JavascriptInterface
3444
+ fun removeARObject(objectId: String) {
3445
+ activity.runOnUiThread {
3446
+ webView.evaluateJavascript(
3447
+ "window._craftARReject && window._craftARReject('ARCore requires native Activity integration')",
3448
+ null
3449
+ )
3450
+ }
3451
+ }
3452
+
3453
+ @JavascriptInterface
3454
+ fun getARPlanes() {
3455
+ activity.runOnUiThread {
3456
+ webView.evaluateJavascript(
3457
+ "window._craftARResolve && window._craftARResolve([])",
3458
+ null
3459
+ )
3460
+ }
3461
+ }
3462
+
3463
+ // ==================== ML Kit ====================
3464
+
3465
+ @JavascriptInterface
3466
+ fun classifyImage(imageBase64: String) {
3467
+ try {
3468
+ val imageBytes = Base64.decode(imageBase64, Base64.DEFAULT)
3469
+ val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
3470
+ val inputImage = InputImage.fromBitmap(bitmap, 0)
3471
+
3472
+ val labeler = ImageLabeling.getClient(ImageLabelerOptions.DEFAULT_OPTIONS)
3473
+ labeler.process(inputImage)
3474
+ .addOnSuccessListener { labels ->
3475
+ val results = JSONArray()
3476
+ for (label in labels) {
3477
+ val obj = JSONObject()
3478
+ obj.put("label", label.text)
3479
+ obj.put("confidence", label.confidence)
3480
+ obj.put("index", label.index)
3481
+ results.put(obj)
3482
+ }
3483
+ activity.runOnUiThread {
3484
+ webView.evaluateJavascript(
3485
+ "window._craftMLResolve && window._craftMLResolve($results)",
3486
+ null
3487
+ )
3488
+ }
3489
+ }
3490
+ .addOnFailureListener { e ->
3491
+ activity.runOnUiThread {
3492
+ webView.evaluateJavascript(
3493
+ "window._craftMLReject && window._craftMLReject('${e.message}')",
3494
+ null
3495
+ )
3496
+ }
3497
+ }
3498
+ } catch (e: Exception) {
3499
+ activity.runOnUiThread {
3500
+ webView.evaluateJavascript(
3501
+ "window._craftMLReject && window._craftMLReject('${e.message}')",
3502
+ null
3503
+ )
3504
+ }
3505
+ }
3506
+ }
3507
+
3508
+ @JavascriptInterface
3509
+ fun detectObjects(imageBase64: String) {
3510
+ try {
3511
+ val imageBytes = Base64.decode(imageBase64, Base64.DEFAULT)
3512
+ val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
3513
+ val inputImage = InputImage.fromBitmap(bitmap, 0)
3514
+
3515
+ val options = ObjectDetectorOptions.Builder()
3516
+ .setDetectorMode(ObjectDetectorOptions.SINGLE_IMAGE_MODE)
3517
+ .enableMultipleObjects()
3518
+ .enableClassification()
3519
+ .build()
3520
+
3521
+ val objectDetector = ObjectDetection.getClient(options)
3522
+ objectDetector.process(inputImage)
3523
+ .addOnSuccessListener { detectedObjects ->
3524
+ val results = JSONArray()
3525
+ for (obj in detectedObjects) {
3526
+ val objJson = JSONObject()
3527
+
3528
+ // Bounding box
3529
+ val bbox = JSONObject()
3530
+ bbox.put("x", obj.boundingBox.left)
3531
+ bbox.put("y", obj.boundingBox.top)
3532
+ bbox.put("width", obj.boundingBox.width())
3533
+ bbox.put("height", obj.boundingBox.height())
3534
+ objJson.put("boundingBox", bbox)
3535
+
3536
+ // Labels
3537
+ val labels = JSONArray()
3538
+ for (label in obj.labels) {
3539
+ val labelJson = JSONObject()
3540
+ labelJson.put("label", label.text)
3541
+ labelJson.put("confidence", label.confidence)
3542
+ labelJson.put("index", label.index)
3543
+ labels.put(labelJson)
3544
+ }
3545
+ objJson.put("labels", labels)
3546
+ objJson.put("trackingId", obj.trackingId)
3547
+
3548
+ results.put(objJson)
3549
+ }
3550
+ activity.runOnUiThread {
3551
+ webView.evaluateJavascript(
3552
+ "window._craftMLResolve && window._craftMLResolve($results)",
3553
+ null
3554
+ )
3555
+ }
3556
+ }
3557
+ .addOnFailureListener { e ->
3558
+ activity.runOnUiThread {
3559
+ webView.evaluateJavascript(
3560
+ "window._craftMLReject && window._craftMLReject('${e.message}')",
3561
+ null
3562
+ )
3563
+ }
3564
+ }
3565
+ } catch (e: Exception) {
3566
+ activity.runOnUiThread {
3567
+ webView.evaluateJavascript(
3568
+ "window._craftMLReject && window._craftMLReject('${e.message}')",
3569
+ null
3570
+ )
3571
+ }
3572
+ }
3573
+ }
3574
+
3575
+ @JavascriptInterface
3576
+ fun recognizeText(imageBase64: String) {
3577
+ try {
3578
+ val imageBytes = Base64.decode(imageBase64, Base64.DEFAULT)
3579
+ val bitmap = BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.size)
3580
+ val inputImage = InputImage.fromBitmap(bitmap, 0)
3581
+
3582
+ val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)
3583
+ recognizer.process(inputImage)
3584
+ .addOnSuccessListener { visionText ->
3585
+ val results = JSONArray()
3586
+ for (block in visionText.textBlocks) {
3587
+ for (line in block.lines) {
3588
+ val lineJson = JSONObject()
3589
+ lineJson.put("text", line.text)
3590
+ lineJson.put("confidence", line.confidence ?: 0.0)
3591
+
3592
+ val boundingBox = line.boundingBox
3593
+ if (boundingBox != null) {
3594
+ val bbox = JSONObject()
3595
+ bbox.put("x", boundingBox.left)
3596
+ bbox.put("y", boundingBox.top)
3597
+ bbox.put("width", boundingBox.width())
3598
+ bbox.put("height", boundingBox.height())
3599
+ lineJson.put("boundingBox", bbox)
3600
+ }
3601
+
3602
+ results.put(lineJson)
3603
+ }
3604
+ }
3605
+ activity.runOnUiThread {
3606
+ webView.evaluateJavascript(
3607
+ "window._craftMLResolve && window._craftMLResolve($results)",
3608
+ null
3609
+ )
3610
+ }
3611
+ }
3612
+ .addOnFailureListener { e ->
3613
+ activity.runOnUiThread {
3614
+ webView.evaluateJavascript(
3615
+ "window._craftMLReject && window._craftMLReject('${e.message}')",
3616
+ null
3617
+ )
3618
+ }
3619
+ }
3620
+ } catch (e: Exception) {
3621
+ activity.runOnUiThread {
3622
+ webView.evaluateJavascript(
3623
+ "window._craftMLReject && window._craftMLReject('${e.message}')",
3624
+ null
3625
+ )
3626
+ }
3627
+ }
3628
+ }
3629
+
3630
+ // ==================== Widget Support ====================
3631
+
3632
+ private val widgetPrefs by lazy {
3633
+ activity.getSharedPreferences("craft_widget_prefs", Context.MODE_PRIVATE)
3634
+ }
3635
+
3636
+ @JavascriptInterface
3637
+ fun updateWidget(dataJson: String) {
3638
+ try {
3639
+ val data = JSONObject(dataJson)
3640
+ val editor = widgetPrefs.edit()
3641
+
3642
+ if (data.has("title")) {
3643
+ editor.putString("widget_title", data.getString("title"))
3644
+ }
3645
+ if (data.has("subtitle")) {
3646
+ editor.putString("widget_subtitle", data.getString("subtitle"))
3647
+ }
3648
+ if (data.has("value")) {
3649
+ editor.putString("widget_value", data.getString("value"))
3650
+ }
3651
+ if (data.has("icon")) {
3652
+ editor.putString("widget_icon", data.getString("icon"))
3653
+ }
3654
+
3655
+ editor.apply()
3656
+
3657
+ // Notify widgets to update
3658
+ val intent = Intent("{{PACKAGE_NAME}}.WIDGET_UPDATE")
3659
+ intent.setPackage(activity.packageName)
3660
+ activity.sendBroadcast(intent)
3661
+
3662
+ activity.runOnUiThread {
3663
+ webView.evaluateJavascript(
3664
+ "window._craftWidgetResolve && window._craftWidgetResolve({updated: true})",
3665
+ null
3666
+ )
3667
+ }
3668
+ } catch (e: Exception) {
3669
+ activity.runOnUiThread {
3670
+ webView.evaluateJavascript(
3671
+ "window._craftWidgetReject && window._craftWidgetReject('${e.message}')",
3672
+ null
3673
+ )
3674
+ }
3675
+ }
3676
+ }
3677
+
3678
+ @JavascriptInterface
3679
+ fun reloadWidgets() {
3680
+ val intent = Intent("{{PACKAGE_NAME}}.WIDGET_UPDATE")
3681
+ intent.setPackage(activity.packageName)
3682
+ activity.sendBroadcast(intent)
3683
+
3684
+ activity.runOnUiThread {
3685
+ webView.evaluateJavascript(
3686
+ "window._craftWidgetResolve && window._craftWidgetResolve({reloaded: true})",
3687
+ null
3688
+ )
3689
+ }
3690
+ }
3691
+
3692
+ // ==================== Google Assistant / App Actions ====================
3693
+
3694
+ @JavascriptInterface
3695
+ fun registerVoiceAction(phrase: String, action: String) {
3696
+ // App Actions are defined in shortcuts.xml, not dynamically
3697
+ // This stores the action for handling incoming intents
3698
+ val prefs = activity.getSharedPreferences("craft_voice_actions", Context.MODE_PRIVATE)
3699
+ prefs.edit().putString(action, phrase).apply()
3700
+
3701
+ activity.runOnUiThread {
3702
+ webView.evaluateJavascript(
3703
+ "window._craftVoiceResolve && window._craftVoiceResolve({registered: true, action: '$action', phrase: '$phrase'})",
3704
+ null
3705
+ )
3706
+ }
3707
+ }
3708
+
3709
+ @JavascriptInterface
3710
+ fun removeVoiceAction(action: String) {
3711
+ val prefs = activity.getSharedPreferences("craft_voice_actions", Context.MODE_PRIVATE)
3712
+ prefs.edit().remove(action).apply()
3713
+
3714
+ activity.runOnUiThread {
3715
+ webView.evaluateJavascript(
3716
+ "window._craftVoiceResolve && window._craftVoiceResolve({removed: true, action: '$action'})",
3717
+ null
3718
+ )
3719
+ }
3720
+ }
3721
+
3722
+ // Handle incoming voice action from Google Assistant
3723
+ fun handleVoiceAction(intent: Intent?) {
3724
+ intent?.let {
3725
+ val action = it.action
3726
+ val data = it.dataString
3727
+
3728
+ if (action != null) {
3729
+ activity.runOnUiThread {
3730
+ webView.evaluateJavascript(
3731
+ "window.dispatchEvent(new CustomEvent('craftVoiceAction', {detail: {action: '$action', data: '$data'}}));",
3732
+ null
3733
+ )
3734
+ }
3735
+ }
3736
+ }
3737
+ }
3738
+
3739
+ // ==================== Wear OS Connectivity ====================
3740
+
3741
+ @JavascriptInterface
3742
+ fun sendToWatch(messageJson: String) {
3743
+ // Note: Full Wearable API requires separate Wear OS app
3744
+ // This provides placeholder API for future integration
3745
+ activity.runOnUiThread {
3746
+ webView.evaluateJavascript(
3747
+ "window._craftWatchReject && window._craftWatchReject('Wear OS integration requires companion app setup')",
3748
+ null
3749
+ )
3750
+ }
3751
+ }
3752
+
3753
+ @JavascriptInterface
3754
+ fun updateWatchContext(contextJson: String) {
3755
+ // Note: Full Wearable API requires Data Layer API setup
3756
+ activity.runOnUiThread {
3757
+ webView.evaluateJavascript(
3758
+ "window._craftWatchReject && window._craftWatchReject('Wear OS integration requires companion app setup')",
3759
+ null
3760
+ )
3761
+ }
3762
+ }
3763
+
3764
+ @JavascriptInterface
3765
+ fun isWatchReachable(): Boolean {
3766
+ // Note: Would use CapabilityClient to check for paired watch
3767
+ return false
3768
+ }
3769
+
3770
+ // ==================== Deep Links ====================
3771
+
3772
+ private var initialURL: String? = null
3773
+
3774
+ fun setInitialURL(url: String?) {
3775
+ initialURL = url
3776
+ }
3777
+
3778
+ @JavascriptInterface
3779
+ fun getInitialURL() {
3780
+ activity.runOnUiThread {
3781
+ if (initialURL != null) {
3782
+ val uri = Uri.parse(initialURL)
3783
+ val json = JSONObject().apply {
3784
+ put("url", initialURL)
3785
+ put("scheme", uri.scheme ?: "")
3786
+ put("host", uri.host ?: "")
3787
+ put("path", uri.path ?: "")
3788
+ put("query", uri.query ?: "")
3789
+ // Parse query parameters
3790
+ val queryParams = JSONObject()
3791
+ uri.queryParameterNames.forEach { name ->
3792
+ queryParams.put(name, uri.getQueryParameter(name) ?: "")
3793
+ }
3794
+ put("queryParams", queryParams)
3795
+ }
3796
+ webView.evaluateJavascript(
3797
+ "window._craftDeepLinkResolve && window._craftDeepLinkResolve($json)",
3798
+ null
3799
+ )
3800
+ } else {
3801
+ webView.evaluateJavascript(
3802
+ "window._craftDeepLinkResolve && window._craftDeepLinkResolve(null)",
3803
+ null
3804
+ )
3805
+ }
3806
+ }
3807
+ }
3808
+
3809
+ fun dispatchDeepLink(url: String) {
3810
+ // Store as initial URL if this is the first one
3811
+ if (initialURL == null) {
3812
+ initialURL = url
3813
+ }
3814
+
3815
+ val uri = Uri.parse(url)
3816
+ val json = JSONObject().apply {
3817
+ put("url", url)
3818
+ put("scheme", uri.scheme ?: "")
3819
+ put("host", uri.host ?: "")
3820
+ put("path", uri.path ?: "")
3821
+ put("query", uri.query ?: "")
3822
+ // Parse query parameters
3823
+ val queryParams = JSONObject()
3824
+ uri.queryParameterNames.forEach { name ->
3825
+ queryParams.put(name, uri.getQueryParameter(name) ?: "")
3826
+ }
3827
+ put("queryParams", queryParams)
3828
+ }
3829
+
3830
+ activity.runOnUiThread {
3831
+ webView.evaluateJavascript(
3832
+ "window.dispatchEvent(new CustomEvent('craftDeepLink', {detail: $json}));",
3833
+ null
3834
+ )
3835
+ }
3836
+ }
3837
+
3838
+ // ==================== Performance Profiling ====================
3839
+
3840
+ @JavascriptInterface
3841
+ fun getMemoryUsage(): String {
3842
+ val runtime = Runtime.getRuntime()
3843
+ val usedMemory = runtime.totalMemory() - runtime.freeMemory()
3844
+ val maxMemory = runtime.maxMemory()
3845
+ val totalMemory = runtime.totalMemory()
3846
+
3847
+ return JSONObject().apply {
3848
+ put("usedMB", Math.round(usedMemory / 1024.0 / 1024.0 * 100) / 100.0)
3849
+ put("maxMB", Math.round(maxMemory / 1024.0 / 1024.0 * 100) / 100.0)
3850
+ put("totalMB", Math.round(totalMemory / 1024.0 / 1024.0 * 100) / 100.0)
3851
+ put("usedBytes", usedMemory)
3852
+ put("maxBytes", maxMemory)
3853
+ }.toString()
3854
+ }
3855
+
3856
+ // ==================== Helpers ====================
3857
+
3858
+ private fun sendEvent(event: String, data: Map<String, Any>) {
3859
+ val json = JSONObject(data).toString()
3860
+ activity.runOnUiThread {
3861
+ webView.evaluateJavascript(
3862
+ "window.dispatchEvent(new CustomEvent('$event', {detail: $json}));",
3863
+ null
3864
+ )
3865
+ }
3866
+ }
3867
+
3868
+ companion object {
3869
+ const val REQUEST_CAMERA = 1001
3870
+ const val REQUEST_GALLERY = 1002
3871
+ const val REQUEST_LOCATION = 1003
3872
+ const val REQUEST_CONTACTS = 1004
3873
+ const val REQUEST_CALENDAR = 1005
3874
+ const val REQUEST_FILE_PICKER = 1006
3875
+ const val REQUEST_AUDIO = 1007
3876
+ const val REQUEST_VIDEO = 1008
3877
+ const val REQUEST_BLUETOOTH = 1009
3878
+ const val REQUEST_PICK_CONTACT = 1010
3879
+ }
3880
+ }