@livx.cc/appwrap 0.54.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/package.json +5 -4
  2. package/runtime/app/main-page.ts +0 -2
  3. package/runtime/app/shell/capabilities.manifest.ts +27 -92
  4. package/runtime/app/shell/config.ts +4 -0
  5. package/runtime/app/shell/env.ts +8 -1
  6. package/runtime/app/shell/handlers.ts +2 -2
  7. package/runtime/tests/bridge-response-delivery.test.ts +10 -1
  8. package/runtime/tests/env-scheme.test.ts +50 -0
  9. package/scripts/stage-assets.mjs +1 -1
  10. package/src/cli.ts +424 -621
  11. package/src/config.ts +8 -1
  12. package/src/handlers.ts +1 -1
  13. package/src/packs.ts +174 -0
  14. package/src/testing.ts +173 -0
  15. package/templates/module-pack/README.md +44 -0
  16. package/templates/module-pack/handler.ts +9 -0
  17. package/templates/module-pack/manifest.ts +27 -0
  18. package/templates/module-pack/native-src/__MODULE_NAME__/.gitkeep +0 -0
  19. package/runtime/app/shell/billing-offer.ts +0 -54
  20. package/runtime/app/shell/handlers-billing.ts +0 -662
  21. package/runtime/app/shell/handlers-health.ts +0 -178
  22. package/runtime/app/shell/handlers-widget.ts +0 -97
  23. package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +0 -44
  24. package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +0 -74
  25. package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +0 -118
  26. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +0 -9
  27. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +0 -123
  28. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +0 -10
  29. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +0 -67
  30. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +0 -9
  31. package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +0 -17
  32. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +0 -10
  33. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +0 -275
  34. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +0 -25
  35. package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +0 -9
  36. package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +0 -18
  37. package/runtime/tests/billing-offer.test.ts +0 -61
  38. package/runtime-desktop/autotest/index.html +0 -77
  39. package/runtime-desktop/bridge-shim/build.sh +0 -9
  40. package/runtime-desktop/bridge-shim/content-entry.ts +0 -87
  41. package/runtime-desktop/bridge-shim/shim.ts +0 -132
  42. package/runtime-desktop/chrome/appwrap-browser-chrome.html +0 -185
  43. package/runtime-desktop/crates/webview-control/Cargo.lock +0 -330
  44. package/runtime-desktop/crates/webview-control/Cargo.toml +0 -28
  45. package/runtime-desktop/crates/webview-control/examples/host_harness.rs +0 -362
  46. package/runtime-desktop/crates/webview-control/src/embedded.rs +0 -138
  47. package/runtime-desktop/crates/webview-control/src/lib.rs +0 -100
  48. package/runtime-desktop/crates/webview-control/src/popup.rs +0 -522
  49. package/runtime-desktop/crates/webview-control/src/profiles.rs +0 -57
  50. package/runtime-desktop/crates/webview-control/src/wkwebview_backend.rs +0 -558
  51. package/runtime-desktop/src-tauri/Cargo.lock +0 -5474
  52. package/runtime-desktop/src-tauri/Cargo.toml +0 -45
  53. package/runtime-desktop/src-tauri/build.rs +0 -3
  54. package/runtime-desktop/src-tauri/capabilities/default.json +0 -6
  55. package/runtime-desktop/src-tauri/icons/icon.png +0 -0
  56. package/runtime-desktop/src-tauri/shell_config.json +0 -10
  57. package/runtime-desktop/src-tauri/src/biometrics_mac.rs +0 -138
  58. package/runtime-desktop/src-tauri/src/bridge_mac.rs +0 -424
  59. package/runtime-desktop/src-tauri/src/browser_tab.rs +0 -804
  60. package/runtime-desktop/src-tauri/src/main.rs +0 -1228
  61. package/runtime-desktop/src-tauri/src/notifications_mac.rs +0 -202
  62. package/runtime-desktop/src-tauri/src/oauth_mac.rs +0 -139
  63. package/runtime-desktop/src-tauri/src/plugin_host.rs +0 -522
  64. package/runtime-desktop/src-tauri/src/popup_mac.rs +0 -5
  65. package/runtime-desktop/src-tauri/src/push_mac.rs +0 -193
  66. package/runtime-desktop/src-tauri/src/server.rs +0 -156
  67. package/runtime-desktop/src-tauri/src/sidecar.rs +0 -398
  68. package/runtime-desktop/src-tauri/tauri.conf.json +0 -14
  69. package/src/desktop.ts +0 -300
  70. package/src/plugin/host.ts +0 -242
@@ -1,178 +0,0 @@
1
- import { Utils, isAndroid, isIOS } from '@nativescript/core';
2
- import { bridge } from './bridge';
3
- import { requestPermissions, startActivityForResult } from './android-helpers';
4
-
5
- declare const cc: any; // no NS types: generated Kotlin HealthConnectBridge (HealthConnectBridge.kt shim, overrides/)
6
-
7
- const err = (code: string, message: string) => Object.assign(new Error(message), { code });
8
-
9
- /** Step counting from the platform health store — today's total, global, survives an app kill.
10
- * iOS: HealthKit. Android: Health Connect (via the HealthConnectBridge.kt shim) with a
11
- * SensorManager TYPE_STEP_COUNTER fallback when Health Connect isn't available. */
12
- export function registerHealthHandlers(): void {
13
- if (isIOS) registerIos();
14
- else if (isAndroid) registerAndroid();
15
- }
16
-
17
- /** NSNumber/JS-number tolerant read. */
18
- function num(v: any): number {
19
- if (typeof v === 'number') return v;
20
- if (v && typeof v.doubleValue === 'number') return v.doubleValue; // NSNumber
21
- const n = Number(v);
22
- return Number.isFinite(n) ? n : 0;
23
- }
24
-
25
- function registerIos(): void {
26
- // HealthKit reads the Health app's AGGREGATED step total (iPhone + Apple Watch + other sources) —
27
- // recorded by the OS regardless of the app, so it's global and survives kill. Count = today's sum.
28
- const store = HKHealthStore.new();
29
- const stepType = HKObjectType.quantityTypeForIdentifier('HKQuantityTypeIdentifierStepCount');
30
- const startOfToday = () => {
31
- const d = new Date();
32
- d.setHours(0, 0, 0, 0); // local midnight
33
- return NSDate.dateWithTimeIntervalSince1970(d.getTime() / 1000);
34
- };
35
-
36
- bridge.register('health.requestAccess', () =>
37
- new Promise((resolve) => {
38
- if (!HKHealthStore.isHealthDataAvailable()) { resolve(false); return; }
39
- const readSet = NSSet.setWithObject(stepType);
40
- // null typesToShare (read-only request) — SDK types the param as a non-nullable NSSet.
41
- store.requestAuthorizationToShareTypesReadTypesCompletion(null as any, readSet, (ok: boolean, e: NSError) => {
42
- Utils.dispatchToMainThread(() => resolve(!!ok && !e));
43
- });
44
- })
45
- );
46
-
47
- bridge.register('health.start', () => {
48
- if (!HKHealthStore.isHealthDataAvailable()) throw err('UNSUPPORTED', 'HealthKit unavailable on this device');
49
- });
50
- bridge.register('health.stop', () => {});
51
-
52
- // HKStatisticsQuery completion fires off-main — settle the bridge promise on the main thread so the
53
- // WKWebView response delivery (evaluateJavaScript) is main-thread.
54
- bridge.register('health.count', () =>
55
- new Promise((resolve, reject) => {
56
- // NS marshals NSDate→the SDK's `Date` param type; options 0 = HKQueryOptions.None.
57
- const predicate = HKQuery.predicateForSamplesWithStartDateEndDateOptions(
58
- startOfToday() as unknown as Date, NSDate.date() as unknown as Date, HKQueryOptions.None
59
- );
60
- const q = HKStatisticsQuery.alloc().initWithQuantityTypeQuantitySamplePredicateOptionsCompletionHandler(
61
- stepType, predicate, HKStatisticsOptions.CumulativeSum, (_q: HKStatisticsQuery, stats: HKStatistics, e: NSError) => {
62
- Utils.dispatchToMainThread(() => {
63
- if (e) { reject(err('NATIVE_ERROR', e.localizedDescription ?? 'health query failed')); return; }
64
- const sum = stats && stats.sumQuantity ? stats.sumQuantity() : null;
65
- resolve({ steps: sum ? Math.round(sum.doubleValueForUnit(HKUnit.countUnit())) : 0 });
66
- });
67
- }
68
- );
69
- store.executeQuery(q);
70
- })
71
- );
72
-
73
- // ── Live steps (CMPedometer) — low-latency "as you walk" count, distinct from the laggy HealthKit
74
- // aggregate. Caller uses HealthKit as the periodic source-of-truth and these live deltas in between.
75
- // Emits `health.liveStep` { steps } (today's pedometer total since local midnight). iPhone-only
76
- // (no Watch), which is fine: it's the live driver; HealthKit re-anchors the authoritative total.
77
- let pedometer: CMPedometer | null = null;
78
- let anchorDay = -1; // local day-of-month the current stream is anchored to (for midnight re-anchor)
79
- const localDay = () => new Date().getDate();
80
- const startStream = () => {
81
- if (!pedometer) return;
82
- anchorDay = localDay();
83
- // startOfToday() returns an NSDate; the SDK types `start` as JS Date but NativeScript marshals it.
84
- pedometer.startPedometerUpdatesFromDateWithHandler(startOfToday() as unknown as Date, (data: CMPedometerData, e: NSError) => {
85
- if (e || !data) return;
86
- // Midnight self-heal: CMPedometer counts since the FIXED anchor date, so after local midnight
87
- // numberOfSteps still includes yesterday. When the day rolls over, restart from the new
88
- // midnight (re-anchors to 0) so the live count stays "today only".
89
- if (localDay() !== anchorDay) { Utils.dispatchToMainThread(() => { pedometer?.stopPedometerUpdates(); startStream(); bridge.emit('health.liveStep', { steps: 0 }); }); return; }
90
- const steps = Math.round(num(data.numberOfSteps));
91
- Utils.dispatchToMainThread(() => bridge.emit('health.liveStep', { steps }));
92
- });
93
- };
94
- bridge.register('health.liveSteps.start', () => {
95
- if (typeof CMPedometer === 'undefined' || !CMPedometer.isStepCountingAvailable())
96
- throw err('UNSUPPORTED', 'Pedometer (live step counting) unavailable on this device');
97
- // Motion & Fitness denied/restricted → surface it so the caller can fall back to HealthKit instead
98
- // of silently receiving no events.
99
- const auth = typeof CMPedometer.authorizationStatus === 'function'
100
- ? CMPedometer.authorizationStatus()
101
- : CMAuthorizationStatus.NotDetermined;
102
- if (auth === CMAuthorizationStatus.Denied || auth === CMAuthorizationStatus.Restricted)
103
- throw err('DENIED', 'Motion & Fitness access is off for live step counting');
104
- if (pedometer) return; // already streaming
105
- pedometer = CMPedometer.new();
106
- startStream();
107
- });
108
- bridge.register('health.liveSteps.stop', () => {
109
- pedometer?.stopPedometerUpdates();
110
- pedometer = null;
111
- });
112
- }
113
-
114
- function registerAndroid(): void {
115
- const ctx = () => Utils.android.getApplicationContext();
116
- const HCB = () => cc.livx.appwrap.HealthConnectBridge;
117
- // Primary path = Health Connect (system store, Wear-inclusive, survives kill). Cached once.
118
- let hc: boolean | null = null;
119
- const hcAvailable = () => {
120
- if (hc == null) {
121
- try { hc = !!HCB().isAvailable(ctx()); } catch (e) { hc = false; }
122
- }
123
- return hc;
124
- };
125
-
126
- // ── sensor fallback (TYPE_STEP_COUNTER) — when Health Connect isn't available ──
127
- let sensorManager: android.hardware.SensorManager | null = null;
128
- let listener: android.hardware.SensorEventListener | null = null;
129
- let baseline: number | null = null;
130
- let latest: number | null = null;
131
- let registered = false;
132
- const ensureSensor = () => {
133
- // getSystemService is typed `any` by the SDK; narrow to the concrete manager.
134
- if (!sensorManager) sensorManager = ctx().getSystemService(android.content.Context.SENSOR_SERVICE) as android.hardware.SensorManager;
135
- };
136
- const stopSensor = () => {
137
- if (registered && sensorManager && listener) { sensorManager.unregisterListener(listener); registered = false; }
138
- };
139
- const startSensor = () => {
140
- ensureSensor();
141
- const sensor = sensorManager!.getDefaultSensor(android.hardware.Sensor.TYPE_STEP_COUNTER);
142
- if (!sensor) throw err('UNSUPPORTED', 'No step-counter sensor on this device');
143
- stopSensor();
144
- baseline = null;
145
- latest = null;
146
- listener = new android.hardware.SensorEventListener({
147
- onSensorChanged: (ev: android.hardware.SensorEvent) => { const v = num(ev.values[0]); latest = v; if (baseline == null) baseline = v; },
148
- onAccuracyChanged: () => {},
149
- });
150
- sensorManager!.registerListener(listener, sensor, android.hardware.SensorManager.SENSOR_DELAY_NORMAL);
151
- registered = true;
152
- };
153
- const sensorCount = () => ({ steps: baseline != null && latest != null ? Math.max(0, Math.round(latest - baseline)) : 0 });
154
-
155
- bridge.register('health.requestAccess', async () => {
156
- if (hcAvailable()) {
157
- // Launch the Health Connect permission UI, then re-check what was granted.
158
- await startActivityForResult(HCB().permissionIntent(ctx())).catch((e) => console.warn('[health] HC permission intent failed', e));
159
- return new Promise((resolve) =>
160
- HCB().hasPermission(ctx(), new (HCB().BoolCallback)({ onResult: (g: boolean) => resolve(!!g) }))
161
- );
162
- }
163
- return requestPermissions(['android.permission.ACTIVITY_RECOGNITION']);
164
- });
165
-
166
- bridge.register('health.start', () => { if (!hcAvailable()) startSensor(); });
167
- bridge.register('health.stop', () => { if (!hcAvailable()) stopSensor(); });
168
-
169
- bridge.register('health.count', () => {
170
- if (hcAvailable()) {
171
- // Health Connect: today's aggregated total (dedupes across sources incl. Wear).
172
- return new Promise((resolve) =>
173
- HCB().readTodaySteps(ctx(), new (HCB().LongCallback)({ onResult: (v: any) => { const n = Number(v); resolve({ steps: n > 0 ? n : 0 }); } }))
174
- );
175
- }
176
- return sensorCount();
177
- });
178
- }
@@ -1,97 +0,0 @@
1
- import { Application, ImageSource, Utils, isAndroid, isIOS } from '@nativescript/core';
2
- import { bridge } from './bridge';
3
-
4
- // WidgetKit's WidgetCenter is Swift-only (no ObjC API) → unreachable from NS directly. The widget
5
- // module ships an @objc shim (AppwrapWidgetReload) in the app to bridge reloadAllTimelines():
6
- declare const AppwrapWidgetReload: any;
7
-
8
- const err = (code: string, message: string) => Object.assign(new Error(message), { code });
9
-
10
- // Shared-store key per widget kind, so a `data` publish never clobbers the `launcher` grid.
11
- const payloadKeyFor = (slot?: string) => (slot === 'data' ? 'appwrap.widget.data' : 'appwrap.widget');
12
- const PREFS_NAME = 'appwrap.widget'; // Android SharedPreferences file (keyed per slot inside)
13
-
14
- /** iOS App Group id — `group.<bundleId>`, matching what the CLI stamps onto both targets. */
15
- function iosAppGroup(): string {
16
- return 'group.' + NSBundle.mainBundle.bundleIdentifier;
17
- }
18
-
19
- /** Download an image to a PNG file, returning the saved filename (or null on failure). Reused for
20
- * iOS (App Group container) and Android (files dir). */
21
- async function saveIcon(url: string, dir: string, filename: string): Promise<string | null> {
22
- try {
23
- const img = await ImageSource.fromUrl(url);
24
- const dest = dir.replace(/\/$/, '') + '/' + filename;
25
- if (img?.saveToFile(dest, 'png')) return filename;
26
- } catch (e) {
27
- console.log('widget: icon fetch failed (' + url + '): ' + e);
28
- }
29
- return null;
30
- }
31
-
32
- type PublishEntry = { label: string; sublabel?: string; deepLink?: string; iconUrl?: string; icon?: string; badge?: number };
33
- type PublishPayload = {
34
- template?: 'icon-grid' | 'stat' | 'list';
35
- title?: string;
36
- entries?: PublishEntry[];
37
- stat?: { value: string; label?: string; sublabel?: string };
38
- deepLink?: string;
39
- slot?: 'launcher' | 'data';
40
- };
41
-
42
- export function registerWidgetHandlers(): void {
43
- // Publish a payload the home-screen widget renders. Downloads any entry.iconUrl into the shared
44
- // container (→ entry.icon filename) and writes the JSON to the App Group (iOS) / SharedPreferences
45
- // (Android), then asks the OS to reload the widget. Pass `{}`/no entries to clear.
46
- bridge.register('widget.publish', async (payload: PublishPayload) => {
47
- const template = payload?.template ?? 'icon-grid';
48
- const entries = (payload?.entries ?? []).filter((e) => e && e.label);
49
- const key = payloadKeyFor(payload?.slot);
50
-
51
- if (isIOS) {
52
- const group = iosAppGroup();
53
- const containerDir = NSFileManager.defaultManager
54
- .containerURLForSecurityApplicationGroupIdentifier(group)?.path;
55
- // fetch icons into the container (in parallel), rewriting iconUrl → icon filename (slot-scoped
56
- // so a launcher/data publish never overwrites the other's tiles)
57
- const prefix = payload?.slot === 'data' ? 'd' : 'l';
58
- if (containerDir) {
59
- await Promise.all(entries.map(async (e, i) => {
60
- if (e.iconUrl) e.icon = (await saveIcon(e.iconUrl, containerDir, `${prefix}_${i}.png`)) ?? undefined;
61
- delete e.iconUrl;
62
- }));
63
- }
64
- const json = JSON.stringify({ template, title: payload.title, entries, stat: payload.stat, deepLink: payload.deepLink });
65
- const defaults = NSUserDefaults.alloc().initWithSuiteName(group);
66
- defaults?.setObjectForKey(json, key);
67
- if (typeof AppwrapWidgetReload !== 'undefined') AppwrapWidgetReload.reloadAll();
68
- return { published: true };
69
- }
70
-
71
- if (isAndroid) {
72
- // Android ships the launcher widget only; a `data`-slot publish still persists (for a future
73
- // data provider) but there's no receiver to refresh, so it's a no-op render-wise today.
74
- const ctx = Utils.android.getApplicationContext();
75
- const filesDir = ctx.getFilesDir().getAbsolutePath();
76
- const prefix = payload?.slot === 'data' ? 'd' : 'l';
77
- await Promise.all(entries.map(async (e, i) => {
78
- if (e.iconUrl) e.icon = (await saveIcon(e.iconUrl, filesDir, `${prefix}_${i}.png`)) ?? undefined;
79
- delete e.iconUrl;
80
- }));
81
- const json = JSON.stringify({ template, title: payload.title, entries, stat: payload.stat, deepLink: payload.deepLink });
82
- ctx.getSharedPreferences(PREFS_NAME, 0).edit().putString(key, json).apply();
83
- if (payload?.slot === 'data') return { published: true };
84
- // Kick the provider to re-render: broadcast an APPWIDGET_UPDATE to our widget ids.
85
- const mgr = android.appwidget.AppWidgetManager.getInstance(ctx);
86
- const cn = new android.content.ComponentName(ctx, 'cc.livx.appwrap.AppwrapWidgetProvider');
87
- const ids = mgr.getAppWidgetIds(cn);
88
- const intent = new android.content.Intent(android.appwidget.AppWidgetManager.ACTION_APPWIDGET_UPDATE);
89
- intent.setComponent(cn);
90
- intent.putExtra(android.appwidget.AppWidgetManager.EXTRA_APPWIDGET_IDS, ids);
91
- ctx.sendBroadcast(intent);
92
- return { published: true };
93
- }
94
-
95
- return { published: false, reason: 'unsupported' as const };
96
- });
97
- }
@@ -1,44 +0,0 @@
1
- import Foundation
2
- import StoreKit
3
- import UIKit
4
-
5
- // StoreKit 2's `AppStore.showManageSubscriptions(in:)` is Swift-only + async, so it can't be reached
6
- // from the ObjC bridge NativeScript uses. This @objc wrapper bridges the async call to a completion
7
- // callback (NativeScript can't await Swift async) and runs it on the main actor against the active
8
- // foreground UIWindowScene. Shipped via the billing module's nativeSrc → compiled only when `billing`
9
- // is active. iOS 15+ (StoreKit 2); the JS layer guards the deep-link fallback.
10
- //
11
- // NOTE: this sheet presents/dismisses WITHOUT the app scene leaving foregroundActive, so iOS fires no
12
- // resumeEvent and leaves an orphaned interactive tracking-window above ours that swallows touches. The
13
- // JS handler recovers from THIS completion via `CustomWebView.recoverAfterNativeSurface()` (shared with
14
- // every other native surface) — so this shim only needs to present + report success/failure.
15
- @objc(AppwrapManageSubscriptions)
16
- public final class AppwrapManageSubscriptions: NSObject {
17
- // completion(nil) on success; completion(message) on failure. Always invoked on the main thread so
18
- // the NS JS continuation (which then drives the WebView recovery) resumes on the UI thread.
19
- @objc public static func present(_ completion: @escaping (String?) -> Void) {
20
- guard #available(iOS 15.0, *) else {
21
- completion("StoreKit 2 manage-subscriptions sheet requires iOS 15+")
22
- return
23
- }
24
- Task { @MainActor in
25
- guard let scene = AppwrapManageSubscriptions.activeWindowScene() else {
26
- completion("No active UIWindowScene to present the subscriptions sheet")
27
- return
28
- }
29
- do {
30
- try await AppStore.showManageSubscriptions(in: scene)
31
- completion(nil)
32
- } catch {
33
- completion(error.localizedDescription)
34
- }
35
- }
36
- }
37
-
38
- // The foreground-active window scene (the one allowed to present UI). Falls back to any connected
39
- // window scene so a not-yet-foreground launch still finds a presenter.
40
- @MainActor private static func activeWindowScene() -> UIWindowScene? {
41
- let scenes = UIApplication.shared.connectedScenes.compactMap { $0 as? UIWindowScene }
42
- return scenes.first { $0.activationState == .foregroundActive } ?? scenes.first
43
- }
44
- }
@@ -1,74 +0,0 @@
1
- package cc.livx.appwrap
2
-
3
- import android.content.Context
4
- import android.content.Intent
5
- import androidx.health.connect.client.HealthConnectClient
6
- import androidx.health.connect.client.PermissionController
7
- import androidx.health.connect.client.permission.HealthPermission
8
- import androidx.health.connect.client.records.StepsRecord
9
- import androidx.health.connect.client.request.AggregateRequest
10
- import androidx.health.connect.client.time.TimeRangeFilter
11
- import kotlinx.coroutines.CoroutineScope
12
- import kotlinx.coroutines.Dispatchers
13
- import kotlinx.coroutines.launch
14
- import kotlinx.coroutines.withContext
15
- import java.time.Instant
16
- import java.time.LocalDate
17
- import java.time.ZoneId
18
-
19
- /**
20
- * NS-callable bridge over Health Connect (whose client API is Kotlin-`suspend`-only). Wraps each
21
- * suspend call in a coroutine and delivers the result through a plain callback ON THE MAIN THREAD —
22
- * so the JS continuation (and the WebView response delivery) runs on the UI thread. Shipped via the
23
- * app's overrides/ until the framework grows first-class Health Connect support.
24
- */
25
- object HealthConnectBridge {
26
- interface LongCallback { fun onResult(value: Long) }
27
- interface BoolCallback { fun onResult(value: Boolean) }
28
-
29
- private val READ_STEPS = HealthPermission.getReadPermission(StepsRecord::class)
30
-
31
- @JvmStatic
32
- fun isAvailable(context: Context): Boolean =
33
- HealthConnectClient.getSdkStatus(context) == HealthConnectClient.SDK_AVAILABLE
34
-
35
- /** Intent that launches the Health Connect permission UI (for startActivityForResult). */
36
- @JvmStatic
37
- fun permissionIntent(context: Context): Intent =
38
- PermissionController.createRequestPermissionResultContract().createIntent(context, setOf(READ_STEPS))
39
-
40
- @JvmStatic
41
- fun hasPermission(context: Context, cb: BoolCallback) {
42
- val client = HealthConnectClient.getOrCreate(context)
43
- CoroutineScope(Dispatchers.IO).launch {
44
- val granted = try {
45
- client.permissionController.getGrantedPermissions().contains(READ_STEPS)
46
- } catch (e: Exception) { false }
47
- withContext(Dispatchers.Main) { cb.onResult(granted) }
48
- }
49
- }
50
-
51
- /** Today's total steps. Prefers `aggregate` (dedupes across sources incl. Wear on real devices);
52
- * falls back to summing raw records when aggregate yields null (a known HC quirk — e.g. the
53
- * emulator provider / a single app's manual records). Returns -1 when permission isn't granted. */
54
- private suspend fun todayTotal(client: HealthConnectClient): Long {
55
- val zone = ZoneId.systemDefault()
56
- val range = TimeRangeFilter.between(LocalDate.now(zone).atStartOfDay(zone).toInstant(), Instant.now())
57
- val agg = client.aggregate(AggregateRequest(setOf(StepsRecord.COUNT_TOTAL), range))[StepsRecord.COUNT_TOTAL]
58
- if (agg != null) return agg
59
- val read = client.readRecords(androidx.health.connect.client.request.ReadRecordsRequest(StepsRecord::class, range))
60
- return read.records.sumOf { it.count }
61
- }
62
-
63
- @JvmStatic
64
- fun readTodaySteps(context: Context, cb: LongCallback) {
65
- val client = HealthConnectClient.getOrCreate(context)
66
- CoroutineScope(Dispatchers.IO).launch {
67
- val result: Long = try {
68
- if (!client.permissionController.getGrantedPermissions().contains(READ_STEPS)) -1L
69
- else todayTotal(client)
70
- } catch (e: Exception) { -2L }
71
- withContext(Dispatchers.Main) { cb.onResult(result) }
72
- }
73
- }
74
- }
@@ -1,118 +0,0 @@
1
- package cc.livx.appwrap
2
-
3
- import android.app.PendingIntent
4
- import android.appwidget.AppWidgetManager
5
- import android.appwidget.AppWidgetProvider
6
- import android.content.Context
7
- import android.content.Intent
8
- import android.graphics.Bitmap
9
- import android.graphics.BitmapFactory
10
- import android.graphics.BitmapShader
11
- import android.graphics.Canvas
12
- import android.graphics.Paint
13
- import android.graphics.RectF
14
- import android.graphics.Shader
15
- import android.net.Uri
16
- import android.view.View
17
- import android.widget.RemoteViews
18
- import org.json.JSONObject
19
- import java.io.File
20
-
21
- /**
22
- * Home-screen launcher widget (Android parity of the iOS WidgetKit launcher). Reads the payload the
23
- * `widget.publish` handler wrote into SharedPreferences ("appwrap.widget" / key "appwrap.widget") and
24
- * renders up to 12 icon-grid tiles across 4 columns; each tile deep-links back into the app via its
25
- * entry's URL. Icons are PNGs the handler saved into the app files dir. Resource ids are looked up by
26
- * name so the module needs no compile-time reference to the host app's R class.
27
- */
28
- class AppwrapWidgetProvider : AppWidgetProvider() {
29
- private val columns = 4
30
- private val tiles = (0 until 12).map { "tile$it" }
31
- private val rows = (0 until 3).map { "row$it" }
32
-
33
- override fun onUpdate(ctx: Context, mgr: AppWidgetManager, ids: IntArray) {
34
- for (id in ids) render(ctx, mgr, id)
35
- }
36
-
37
- private fun res(ctx: Context, type: String, name: String) =
38
- ctx.resources.getIdentifier(name, type, ctx.packageName)
39
-
40
- /**
41
- * Round an app icon to look native on the home screen (iOS-parity: iOS clips to a
42
- * RoundedRectangle ~24% of the icon size). RemoteViews ImageViews can't clip via an outline, so we
43
- * center-crop the source to a square and redraw it into a rounded bitmap at full source resolution
44
- * — the widget host then downscales a crisp, antialiased image rather than a hard-edged square.
45
- */
46
- private fun rounded(src: Bitmap): Bitmap {
47
- val size = minOf(src.width, src.height)
48
- val sq = if (src.width != src.height)
49
- Bitmap.createBitmap(src, (src.width - size) / 2, (src.height - size) / 2, size, size)
50
- else src
51
- val out = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888)
52
- val canvas = Canvas(out)
53
- val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
54
- shader = BitmapShader(sq, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
55
- }
56
- val r = size * 0.22f
57
- canvas.drawRoundRect(RectF(0f, 0f, size.toFloat(), size.toFloat()), r, r, paint)
58
- return out
59
- }
60
-
61
- private fun render(ctx: Context, mgr: AppWidgetManager, id: Int) {
62
- val rv = RemoteViews(ctx.packageName, res(ctx, "layout", "appwrap_widget"))
63
- val raw = ctx.getSharedPreferences("appwrap.widget", Context.MODE_PRIVATE)
64
- .getString("appwrap.widget", null)
65
- val payload = raw?.let { runCatching { JSONObject(it) }.getOrNull() }
66
- val title = payload?.optString("title") ?: ""
67
- rv.setTextViewText(res(ctx, "id", "appwrap_widget_title"), title)
68
- rv.setViewVisibility(res(ctx, "id", "appwrap_widget_title"),
69
- if (title.isEmpty()) View.GONE else View.VISIBLE)
70
- val entries = payload?.optJSONArray("entries")
71
- val count = entries?.length() ?: 0
72
-
73
- // A row with no entries at all is GONE so it contributes no height (the grid stays top-packed
74
- // and leftover widget height falls to the bottom). A row that is only PARTLY filled stays
75
- // VISIBLE and hides its trailing tiles as INVISIBLE, so a short final row keeps its tiles in
76
- // the leading columns rather than centring them (iOS Grid's short-final-row behaviour).
77
- for (r in rows.indices) {
78
- rv.setViewVisibility(res(ctx, "id", rows[r]),
79
- if (r * columns < count) View.VISIBLE else View.GONE)
80
- }
81
-
82
- for (i in tiles.indices) {
83
- val tileId = res(ctx, "id", tiles[i])
84
- val e = if (entries != null && i < count) entries.optJSONObject(i) else null
85
- if (e == null) { rv.setViewVisibility(tileId, View.INVISIBLE); continue }
86
- rv.setViewVisibility(tileId, View.VISIBLE)
87
- rv.setTextViewText(res(ctx, "id", "${tiles[i]}_label"), e.optString("label"))
88
- // unread-count badge on the icon's top-right corner (absent/0 → hidden)
89
- val badgeId = res(ctx, "id", "${tiles[i]}_badge")
90
- val badge = e.optInt("badge", 0)
91
- if (badge > 0) {
92
- rv.setTextViewText(badgeId, if (badge > 99) "99+" else badge.toString())
93
- rv.setViewVisibility(badgeId, View.VISIBLE)
94
- } else rv.setViewVisibility(badgeId, View.GONE)
95
- val icon = e.optString("icon", "")
96
- if (icon.isNotEmpty()) {
97
- val f = File(ctx.filesDir, icon)
98
- if (f.exists()) BitmapFactory.decodeFile(f.absolutePath)
99
- ?.let { rv.setImageViewBitmap(res(ctx, "id", "${tiles[i]}_icon"), rounded(it)) }
100
- }
101
- val link = e.optString("deepLink", "")
102
- if (link.isNotEmpty()) {
103
- // Target the app's OWN launch activity explicitly + carry the deep link as data — the
104
- // shell reads intent.getData() (like a pinned shortcut). Relying on an ACTION_VIEW
105
- // intent-filter match would need an App Links autoVerify filter for https hosts, which
106
- // the shell doesn't declare; an explicit component always opens the app.
107
- val intent = (ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)
108
- ?: Intent(Intent.ACTION_VIEW).setPackage(ctx.packageName))
109
- .setAction(Intent.ACTION_VIEW)
110
- .setData(Uri.parse(link))
111
- .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
112
- val flags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
113
- rv.setOnClickPendingIntent(tileId, PendingIntent.getActivity(ctx, i, intent, flags))
114
- }
115
- }
116
- mgr.updateAppWidget(id, rv)
117
- }
118
- }
@@ -1,9 +0,0 @@
1
- <?xml version="1.0" encoding="utf-8"?>
2
- <!-- Red circular/pill background for the widget tile's unread-count badge (iOS-parity Capsule).
3
- Large corner radius → a true circle for single digits, a pill for 2+. White ring lifts it off
4
- the icon. -->
5
- <shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
6
- <solid android:color="#FF3B30" />
7
- <stroke android:width="1.5dp" android:color="#FFFFFF" />
8
- <corners android:radius="100dp" />
9
- </shape>
@@ -1,123 +0,0 @@
1
- <?xml version="1.0" encoding="utf-8"?>
2
- <!-- AppWrap launcher widget: title + a 4-column icon grid of deep-linked tiles (up to 3 rows / 12
3
- tiles), populated by AppwrapWidgetProvider from the published payload.
4
-
5
- LAYOUT CONTRACT (iOS parity — see AppwrapWidget.swift `gridView`):
6
- * Rows are CONTENT-HEIGHT (wrap_content, NO layout_weight). The root is a vertical
7
- LinearLayout at match_parent, so rows stack from the TOP and any leftover widget height
8
- falls to the BOTTOM — the "app folder" feel. Giving rows `0dp + weight=1` (as this layout
9
- once did) makes them greedily split the full height and float each icon to the centre of
10
- its band, which strands the last row at the bottom behind a large dead gap on a
11
- vertically-resized widget. Don't reintroduce weights here.
12
- * A partially-filled row keeps its trailing tiles INVISIBLE (not GONE) so the short final row
13
- stays aligned to the LEADING columns instead of centring. A fully-empty row is set GONE by
14
- the provider so it consumes no height. -->
15
- <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
16
- android:layout_width="match_parent"
17
- android:layout_height="match_parent"
18
- android:orientation="vertical"
19
- android:background="@color/appwrap_widget_bg"
20
- android:padding="10dp">
21
-
22
- <TextView
23
- android:id="@+id/appwrap_widget_title"
24
- android:layout_width="wrap_content"
25
- android:layout_height="wrap_content"
26
- android:textColor="@color/appwrap_widget_title"
27
- android:textStyle="bold"
28
- android:textSize="12sp"
29
- android:layout_marginBottom="8dp"
30
- android:maxLines="1"
31
- android:ellipsize="end" />
32
-
33
- <LinearLayout android:id="@+id/row0" style="@style/AppwrapWidgetRow">
34
- <LinearLayout android:id="@+id/tile0" style="@style/AppwrapWidgetTile">
35
- <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
36
- <ImageView android:id="@+id/tile0_icon" style="@style/AppwrapWidgetIcon" />
37
- <TextView android:id="@+id/tile0_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
38
- </FrameLayout>
39
- <TextView android:id="@+id/tile0_label" style="@style/AppwrapWidgetLabel" />
40
- </LinearLayout>
41
- <LinearLayout android:id="@+id/tile1" style="@style/AppwrapWidgetTile">
42
- <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
43
- <ImageView android:id="@+id/tile1_icon" style="@style/AppwrapWidgetIcon" />
44
- <TextView android:id="@+id/tile1_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
45
- </FrameLayout>
46
- <TextView android:id="@+id/tile1_label" style="@style/AppwrapWidgetLabel" />
47
- </LinearLayout>
48
- <LinearLayout android:id="@+id/tile2" style="@style/AppwrapWidgetTile">
49
- <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
50
- <ImageView android:id="@+id/tile2_icon" style="@style/AppwrapWidgetIcon" />
51
- <TextView android:id="@+id/tile2_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
52
- </FrameLayout>
53
- <TextView android:id="@+id/tile2_label" style="@style/AppwrapWidgetLabel" />
54
- </LinearLayout>
55
- <LinearLayout android:id="@+id/tile3" style="@style/AppwrapWidgetTile">
56
- <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
57
- <ImageView android:id="@+id/tile3_icon" style="@style/AppwrapWidgetIcon" />
58
- <TextView android:id="@+id/tile3_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
59
- </FrameLayout>
60
- <TextView android:id="@+id/tile3_label" style="@style/AppwrapWidgetLabel" />
61
- </LinearLayout>
62
- </LinearLayout>
63
- <LinearLayout android:id="@+id/row1" style="@style/AppwrapWidgetRow">
64
- <LinearLayout android:id="@+id/tile4" style="@style/AppwrapWidgetTile">
65
- <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
66
- <ImageView android:id="@+id/tile4_icon" style="@style/AppwrapWidgetIcon" />
67
- <TextView android:id="@+id/tile4_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
68
- </FrameLayout>
69
- <TextView android:id="@+id/tile4_label" style="@style/AppwrapWidgetLabel" />
70
- </LinearLayout>
71
- <LinearLayout android:id="@+id/tile5" style="@style/AppwrapWidgetTile">
72
- <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
73
- <ImageView android:id="@+id/tile5_icon" style="@style/AppwrapWidgetIcon" />
74
- <TextView android:id="@+id/tile5_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
75
- </FrameLayout>
76
- <TextView android:id="@+id/tile5_label" style="@style/AppwrapWidgetLabel" />
77
- </LinearLayout>
78
- <LinearLayout android:id="@+id/tile6" style="@style/AppwrapWidgetTile">
79
- <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
80
- <ImageView android:id="@+id/tile6_icon" style="@style/AppwrapWidgetIcon" />
81
- <TextView android:id="@+id/tile6_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
82
- </FrameLayout>
83
- <TextView android:id="@+id/tile6_label" style="@style/AppwrapWidgetLabel" />
84
- </LinearLayout>
85
- <LinearLayout android:id="@+id/tile7" style="@style/AppwrapWidgetTile">
86
- <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
87
- <ImageView android:id="@+id/tile7_icon" style="@style/AppwrapWidgetIcon" />
88
- <TextView android:id="@+id/tile7_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
89
- </FrameLayout>
90
- <TextView android:id="@+id/tile7_label" style="@style/AppwrapWidgetLabel" />
91
- </LinearLayout>
92
- </LinearLayout>
93
- <LinearLayout android:id="@+id/row2" style="@style/AppwrapWidgetRow">
94
- <LinearLayout android:id="@+id/tile8" style="@style/AppwrapWidgetTile">
95
- <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
96
- <ImageView android:id="@+id/tile8_icon" style="@style/AppwrapWidgetIcon" />
97
- <TextView android:id="@+id/tile8_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
98
- </FrameLayout>
99
- <TextView android:id="@+id/tile8_label" style="@style/AppwrapWidgetLabel" />
100
- </LinearLayout>
101
- <LinearLayout android:id="@+id/tile9" style="@style/AppwrapWidgetTile">
102
- <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
103
- <ImageView android:id="@+id/tile9_icon" style="@style/AppwrapWidgetIcon" />
104
- <TextView android:id="@+id/tile9_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
105
- </FrameLayout>
106
- <TextView android:id="@+id/tile9_label" style="@style/AppwrapWidgetLabel" />
107
- </LinearLayout>
108
- <LinearLayout android:id="@+id/tile10" style="@style/AppwrapWidgetTile">
109
- <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
110
- <ImageView android:id="@+id/tile10_icon" style="@style/AppwrapWidgetIcon" />
111
- <TextView android:id="@+id/tile10_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
112
- </FrameLayout>
113
- <TextView android:id="@+id/tile10_label" style="@style/AppwrapWidgetLabel" />
114
- </LinearLayout>
115
- <LinearLayout android:id="@+id/tile11" style="@style/AppwrapWidgetTile">
116
- <FrameLayout android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_marginBottom="4dp">
117
- <ImageView android:id="@+id/tile11_icon" style="@style/AppwrapWidgetIcon" />
118
- <TextView android:id="@+id/tile11_badge" style="@style/AppwrapWidgetBadge" android:visibility="gone" />
119
- </FrameLayout>
120
- <TextView android:id="@+id/tile11_label" style="@style/AppwrapWidgetLabel" />
121
- </LinearLayout>
122
- </LinearLayout>
123
- </LinearLayout>