@livx.cc/appwrap 0.54.0 → 0.55.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.
- package/package.json +4 -2
- package/runtime/app/main-page.ts +0 -2
- package/runtime/app/shell/capabilities.manifest.ts +27 -92
- package/runtime/app/shell/config.ts +4 -0
- package/runtime/app/shell/env.ts +8 -1
- package/runtime/app/shell/handlers.ts +2 -2
- package/runtime/tests/env-scheme.test.ts +44 -0
- package/src/cli.ts +398 -65
- package/src/config.ts +8 -1
- package/src/packs.ts +174 -0
- package/src/testing.ts +173 -0
- package/templates/module-pack/README.md +44 -0
- package/templates/module-pack/handler.ts +9 -0
- package/templates/module-pack/manifest.ts +27 -0
- package/templates/module-pack/native-src/__MODULE_NAME__/.gitkeep +0 -0
- package/runtime/app/shell/billing-offer.ts +0 -54
- package/runtime/app/shell/handlers-billing.ts +0 -662
- package/runtime/app/shell/handlers-health.ts +0 -178
- package/runtime/app/shell/handlers-widget.ts +0 -97
- package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift +0 -44
- package/runtime/modules-native/health/App_Resources/Android/src/main/java/cc/livx/appwrap/HealthConnectBridge.kt +0 -74
- package/runtime/modules-native/widget/App_Resources/Android/src/main/java/cc/livx/appwrap/AppwrapWidgetProvider.kt +0 -118
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/drawable/appwrap_badge_bg.xml +0 -9
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml +0 -123
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_colors.xml +0 -10
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values/appwrap_widget_styles.xml +0 -67
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/values-night/appwrap_widget_colors.xml +0 -9
- package/runtime/modules-native/widget/App_Resources/Android/src/main/res/xml/appwrap_widget_info.xml +0 -17
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.entitlements +0 -10
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/AppwrapWidget.swift +0 -275
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/Info.plist +0 -25
- package/runtime/modules-native/widget/App_Resources/iOS/extensions/AppwrapWidget/extension.json +0 -9
- package/runtime/modules-native/widget/App_Resources/iOS/src/AppwrapWidgetReload.swift +0 -18
- package/runtime/tests/billing-offer.test.ts +0 -61
|
@@ -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
|
-
}
|
package/runtime/modules-native/billing/App_Resources/iOS/src/AppwrapManageSubscriptions.swift
DELETED
|
@@ -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>
|
package/runtime/modules-native/widget/App_Resources/Android/src/main/res/layout/appwrap_widget.xml
DELETED
|
@@ -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>
|