@livx.cc/appwrap 0.23.2 → 0.25.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.
@@ -0,0 +1,372 @@
1
+ import { Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+ import { startActivityForResult } from './android-helpers';
4
+
5
+ declare const android: any, java: any;
6
+
7
+ const err = (code: string, message: string) => Object.assign(new Error(message), { code });
8
+
9
+ type Dir = 'documents' | 'data' | 'cache';
10
+
11
+ /** Strip a leading slash and reject any `..` segment so a path can't escape its sandbox root. */
12
+ function safeRelPath(path: string): string {
13
+ const rel = String(path).replace(/^\/+/, '');
14
+ if (rel.split('/').some((seg) => seg === '..')) throw err('NATIVE_ERROR', `Path traversal rejected: ${path}`);
15
+ return rel;
16
+ }
17
+
18
+ /**
19
+ * `kit.fs` shell handlers — app-sandbox file I/O + a system document picker, ONE API across iOS
20
+ * and Android. No runtime permission: every root is inside the app sandbox and the picker returns
21
+ * user-chosen security-scoped (iOS) / SAF (Android) URIs.
22
+ *
23
+ * iOS uses `FileManager` + `NSData`/`NSString`; dirs resolve via `NSSearchPathForDirectoriesInDomains`.
24
+ * Android uses `java.io.File` under `getFilesDir()`/`getCacheDir()`/`getExternalFilesDir()`, the
25
+ * existing share FileProvider for `getUri` content:// URIs, and `ACTION_OPEN_DOCUMENT` for the picker.
26
+ *
27
+ * DEVICE-GATED / UNVERIFIED this session: the iOS `UIDocumentPickerViewController` delegate and the
28
+ * Android `ACTION_OPEN_DOCUMENT` activity-result plumbing (both async, new to the repo).
29
+ */
30
+ export function registerFsHandlers(): void {
31
+ if (isIOS) registerIosFs();
32
+ else if (isAndroid) registerAndroidFs();
33
+ }
34
+
35
+ // ─────────────────────────────── iOS ───────────────────────────────
36
+
37
+ /** NSSearchPath domain index per dir; `documents`/`cache` are first-class, `data` → Application Support. */
38
+ function iosBaseDir(dir: Dir): string {
39
+ // NSDocumentDirectory=9, NSCachesDirectory=13, NSApplicationSupportDirectory=14; NSUserDomainMask=1.
40
+ const which = dir === 'documents' ? 9 : dir === 'cache' ? 13 : 14;
41
+ const paths = NSSearchPathForDirectoriesInDomains(which as any, 1 as any, true);
42
+ const base = paths.objectAtIndex(0) as string;
43
+ // Application Support isn't auto-created; ensure it exists so `data` writes don't fail.
44
+ if (which === 14) NSFileManager.defaultManager.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(base, true, null, null);
45
+ return base;
46
+ }
47
+
48
+ function iosResolve(path: string, dir: Dir): string {
49
+ return iosBaseDir(dir) + '/' + safeRelPath(path);
50
+ }
51
+
52
+ function registerIosFs(): void {
53
+ const fm = () => NSFileManager.defaultManager;
54
+
55
+ bridge.register('fs.read', ({ path, dir = 'data', encoding = 'utf8' }: any) => {
56
+ const full = iosResolve(path, dir);
57
+ if (!fm().fileExistsAtPath(full)) throw err('NATIVE_ERROR', `No such file: ${path}`);
58
+ const data = NSData.dataWithContentsOfFile(full);
59
+ if (!data) throw err('NATIVE_ERROR', `Read failed: ${path}`);
60
+ // String(...) FORCES a JS string. Without it the NSString proxy goes over the bridge and
61
+ // JSON-serializes to `{}` — `as unknown as string` is a compile-time no-op, not a conversion.
62
+ if (encoding === 'base64') return String(data.base64EncodedStringWithOptions(0 as unknown as NSDataBase64EncodingOptions));
63
+ return String(NSString.alloc().initWithDataEncoding(data, 4 /* NSUTF8StringEncoding */));
64
+ });
65
+
66
+ bridge.register('fs.write', ({ path, data, dir = 'data', encoding = 'utf8' }: any) => {
67
+ const full = iosResolve(path, dir);
68
+ // Always create the parent dir — writing to a sub-path (e.g. 'demo/note.txt') otherwise fails
69
+ // ("Write failed"), which is a footgun. fs.append already does this; match it.
70
+ ensureParentIos(full);
71
+ const blob = encoding === 'base64'
72
+ ? NSData.alloc().initWithBase64EncodedStringOptions(data, 0 as unknown as NSDataBase64DecodingOptions)
73
+ : (NSString.stringWithString(String(data ?? '')) as any).dataUsingEncoding(4);
74
+ if (!blob) throw err('NATIVE_ERROR', `Encode failed: ${path}`);
75
+ if (!blob.writeToFileAtomically(full, true)) throw err('NATIVE_ERROR', `Write failed: ${path}`);
76
+ return { uri: NSURL.fileURLWithPath(full).absoluteString };
77
+ });
78
+
79
+ bridge.register('fs.append', ({ path, data, dir = 'data', encoding = 'utf8' }: any) => {
80
+ const full = iosResolve(path, dir);
81
+ ensureParentIos(full);
82
+ const blob = encoding === 'base64'
83
+ ? NSData.alloc().initWithBase64EncodedStringOptions(data, 0 as unknown as NSDataBase64DecodingOptions)
84
+ : (NSString.stringWithString(String(data ?? '')) as any).dataUsingEncoding(4);
85
+ if (!blob) throw err('NATIVE_ERROR', `Encode failed: ${path}`);
86
+ if (!fm().fileExistsAtPath(full)) {
87
+ blob.writeToFileAtomically(full, true);
88
+ return;
89
+ }
90
+ const handle = NSFileHandle.fileHandleForWritingAtPath(full);
91
+ handle.seekToEndOfFile();
92
+ handle.writeData(blob);
93
+ handle.closeFile();
94
+ });
95
+
96
+ bridge.register('fs.delete', ({ path, dir = 'data' }: any) => {
97
+ fm().removeItemAtPathError(iosResolve(path, dir), null); // no-throw if absent
98
+ });
99
+
100
+ bridge.register('fs.list', ({ path, dir = 'data' }: any) => {
101
+ const full = iosResolve(path, dir);
102
+ const names = fm().contentsOfDirectoryAtPathError(full, null);
103
+ if (!names) throw err('NATIVE_ERROR', `No such dir: ${path}`);
104
+ const out: any[] = [];
105
+ for (let i = 0; i < names.count; i++) {
106
+ const name = names.objectAtIndex(i) as string;
107
+ out.push(iosStat(full + '/' + name, name));
108
+ }
109
+ return out;
110
+ });
111
+
112
+ bridge.register('fs.mkdir', ({ path, dir = 'data', recursive = true }: any) => {
113
+ const ok = fm().createDirectoryAtPathWithIntermediateDirectoriesAttributesError(
114
+ iosResolve(path, dir), recursive !== false, null, null
115
+ );
116
+ if (!ok) throw err('NATIVE_ERROR', `mkdir failed: ${path}`);
117
+ });
118
+
119
+ bridge.register('fs.stat', ({ path, dir = 'data' }: any) => {
120
+ const full = iosResolve(path, dir);
121
+ if (!fm().fileExistsAtPath(full)) throw err('NATIVE_ERROR', `No such path: ${path}`);
122
+ return iosStat(full, path);
123
+ });
124
+
125
+ bridge.register('fs.getUri', ({ path, dir = 'data' }: any) =>
126
+ NSURL.fileURLWithPath(iosResolve(path, dir)).absoluteString
127
+ );
128
+
129
+ bridge.register('fs.pickFile', ({ types, multiple }: any) => pickFileIos(types, !!multiple));
130
+ }
131
+
132
+ function ensureParentIos(full: string): void {
133
+ const parent = (NSString.stringWithString(full) as any).stringByDeletingLastPathComponent;
134
+ NSFileManager.defaultManager.createDirectoryAtPathWithIntermediateDirectoriesAttributesError(parent, true, null, null);
135
+ }
136
+
137
+ function iosStat(full: string, name: string): any {
138
+ const fm = NSFileManager.defaultManager;
139
+ const isDirRef = new interop.Reference<boolean>(interop.types.bool, false);
140
+ const exists = fm.fileExistsAtPathIsDirectory(full, isDirRef as any);
141
+ const isDir = !!isDirRef.value;
142
+ const attrs = exists ? fm.attributesOfItemAtPathError(full, null) : null;
143
+ const size = attrs && !isDir ? Number(attrs.objectForKey(NSFileSize) ?? 0) : undefined;
144
+ const modDate = attrs ? attrs.objectForKey(NSFileModificationDate) : null;
145
+ const mtime = modDate ? Math.round(modDate.timeIntervalSince1970 * 1000) : undefined;
146
+ return {
147
+ name,
148
+ type: isDir ? 'dir' : 'file',
149
+ size,
150
+ mtime,
151
+ uri: NSURL.fileURLWithPath(full).absoluteString,
152
+ };
153
+ }
154
+
155
+ /**
156
+ * iOS document picker via UIDocumentPickerViewController. Reads each chosen URL's bytes (under a
157
+ * security-scoped access window) and returns them base64-inline. DEVICE-GATED — the delegate
158
+ * callbacks (didPickDocumentsAtURLs / wasCancelled) are not exercised in this session.
159
+ */
160
+ function pickFileIos(types: string[] | undefined, multiple: boolean): Promise<any[]> {
161
+ return new Promise((resolve, reject) => {
162
+ Utils.dispatchToMainThread(() => {
163
+ try {
164
+ const utis = NSMutableArray.new();
165
+ // Map common MIME/extension hints to UTTypes; default to "any item".
166
+ (types ?? []).forEach((t) => {
167
+ const ut = (UTType as any).typeWithMIMEType?.(t) ?? (UTType as any).typeWithFilenameExtension?.(t.replace(/^\./, ''));
168
+ if (ut) utis.addObject(ut);
169
+ });
170
+ if (utis.count === 0) utis.addObject(UTTypeItem);
171
+
172
+ const picker = UIDocumentPickerViewController.alloc().initForOpeningContentTypes(utis as any);
173
+ picker.allowsMultipleSelection = multiple;
174
+
175
+ const DelegateClass = (NSObject as any).extend(
176
+ {
177
+ documentPickerDidPickDocumentsAtURLs(_p: any, urls: any) {
178
+ const out: any[] = [];
179
+ for (let i = 0; i < urls.count; i++) {
180
+ const url = urls.objectAtIndex(i);
181
+ const scoped = url.startAccessingSecurityScopedResource?.();
182
+ try {
183
+ const data = NSData.dataWithContentsOfURL(url);
184
+ if (data) {
185
+ out.push({
186
+ name: url.lastPathComponent,
187
+ mimeType: mimeForUrlIos(url),
188
+ size: data.length,
189
+ base64: data.base64EncodedStringWithOptions(0 as unknown as NSDataBase64EncodingOptions),
190
+ });
191
+ }
192
+ } finally {
193
+ if (scoped) url.stopAccessingSecurityScopedResource?.();
194
+ }
195
+ }
196
+ resolve(out);
197
+ },
198
+ documentPickerWasCancelled() {
199
+ resolve([]);
200
+ },
201
+ },
202
+ { protocols: [UIDocumentPickerDelegate] }
203
+ );
204
+ const delegate = DelegateClass.new();
205
+ (picker as any)._appwrapDelegate = delegate; // retain past the present call
206
+ picker.delegate = delegate;
207
+ Utils.ios.getRootViewController().presentViewControllerAnimatedCompletion(picker, true, null);
208
+ } catch (e: any) {
209
+ reject(err('NATIVE_ERROR', e?.message ?? 'document picker failed'));
210
+ }
211
+ });
212
+ });
213
+ }
214
+
215
+ function mimeForUrlIos(url: any): string {
216
+ const ut = (UTType as any).typeWithFilenameExtension?.(url.pathExtension);
217
+ return ut?.preferredMIMEType ?? 'application/octet-stream';
218
+ }
219
+
220
+ // ───────────────────────────── Android ─────────────────────────────
221
+
222
+ function androidBaseDir(dir: Dir): any {
223
+ const ctx = Utils.android.getApplicationContext();
224
+ if (dir === 'cache') return ctx.getCacheDir();
225
+ if (dir === 'documents') return ctx.getExternalFilesDir(null) ?? ctx.getFilesDir();
226
+ return ctx.getFilesDir(); // 'data'
227
+ }
228
+
229
+ function androidFile(path: string, dir: Dir): any {
230
+ return new java.io.File(androidBaseDir(dir), safeRelPath(path));
231
+ }
232
+
233
+ function readBytesAndroid(file: any): any {
234
+ const fis = new java.io.FileInputStream(file);
235
+ const baos = new java.io.ByteArrayOutputStream();
236
+ const buf = Array.create('byte', 8192);
237
+ let n: number;
238
+ while ((n = fis.read(buf)) !== -1) baos.write(buf, 0, n);
239
+ fis.close();
240
+ return baos.toByteArray();
241
+ }
242
+
243
+ function registerAndroidFs(): void {
244
+ bridge.register('fs.read', ({ path, dir = 'data', encoding = 'utf8' }: any) => {
245
+ const file = androidFile(path, dir);
246
+ if (!file.exists()) throw err('NATIVE_ERROR', `No such file: ${path}`);
247
+ const bytes = readBytesAndroid(file);
248
+ if (encoding === 'base64') return android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP);
249
+ return new java.lang.String(bytes, 'UTF-8').toString();
250
+ });
251
+
252
+ bridge.register('fs.write', ({ path, data, dir = 'data', encoding = 'utf8' }: any) => {
253
+ const file = androidFile(path, dir);
254
+ // Always create the parent dir (match fs.append + iOS) — writing to a sub-path otherwise fails.
255
+ file.getParentFile()?.mkdirs();
256
+ writeAndroid(file, data, encoding, false);
257
+ return { uri: android.net.Uri.fromFile(file).toString() };
258
+ });
259
+
260
+ bridge.register('fs.append', ({ path, data, dir = 'data', encoding = 'utf8' }: any) => {
261
+ const file = androidFile(path, dir);
262
+ file.getParentFile()?.mkdirs();
263
+ writeAndroid(file, data, encoding, true);
264
+ });
265
+
266
+ bridge.register('fs.delete', ({ path, dir = 'data' }: any) => {
267
+ androidFile(path, dir).delete(); // no-throw if absent
268
+ });
269
+
270
+ bridge.register('fs.list', ({ path, dir = 'data' }: any) => {
271
+ const file = androidFile(path, dir);
272
+ const children = file.listFiles();
273
+ if (!children) throw err('NATIVE_ERROR', `No such dir: ${path}`);
274
+ const out: any[] = [];
275
+ for (let i = 0; i < children.length; i++) out.push(androidStat(children[i], children[i].getName()));
276
+ return out;
277
+ });
278
+
279
+ bridge.register('fs.mkdir', ({ path, dir = 'data', recursive = true }: any) => {
280
+ const file = androidFile(path, dir);
281
+ const ok = recursive !== false ? file.mkdirs() : file.mkdir();
282
+ if (!ok && !file.isDirectory()) throw err('NATIVE_ERROR', `mkdir failed: ${path}`);
283
+ });
284
+
285
+ bridge.register('fs.stat', ({ path, dir = 'data' }: any) => {
286
+ const file = androidFile(path, dir);
287
+ if (!file.exists()) throw err('NATIVE_ERROR', `No such path: ${path}`);
288
+ return androidStat(file, path);
289
+ });
290
+
291
+ bridge.register('fs.getUri', ({ path, dir = 'data' }: any) => {
292
+ // Hand out a content:// via the existing share FileProvider so other apps (kit.share) can read it.
293
+ const ctx = Utils.android.getApplicationContext();
294
+ const authority = ctx.getPackageName() + '.fileprovider';
295
+ return androidx.core.content.FileProvider.getUriForFile(ctx, authority, androidFile(path, dir)).toString();
296
+ });
297
+
298
+ bridge.register('fs.pickFile', ({ types, multiple }: any) => pickFileAndroid(types, !!multiple));
299
+ }
300
+
301
+ function writeAndroid(file: any, data: string, encoding: string, append: boolean): void {
302
+ const bytes = encoding === 'base64'
303
+ ? android.util.Base64.decode(String(data ?? ''), android.util.Base64.DEFAULT)
304
+ : new java.lang.String(String(data ?? '')).getBytes('UTF-8');
305
+ const fos = new java.io.FileOutputStream(file, append);
306
+ fos.write(bytes);
307
+ fos.close();
308
+ }
309
+
310
+ function androidStat(file: any, name: string): any {
311
+ const isDir = file.isDirectory();
312
+ return {
313
+ name,
314
+ type: isDir ? 'dir' : 'file',
315
+ size: isDir ? undefined : file.length(),
316
+ mtime: file.lastModified(),
317
+ uri: android.net.Uri.fromFile(file).toString(),
318
+ };
319
+ }
320
+
321
+ /**
322
+ * Android document picker via ACTION_OPEN_DOCUMENT (reuses the shared startActivityForResult).
323
+ * Reads each chosen content:// URI's bytes via the ContentResolver and returns them base64-inline.
324
+ * DEVICE-GATED — the activity-result plumbing is not exercised in this session.
325
+ */
326
+ async function pickFileAndroid(types: string[] | undefined, multiple: boolean): Promise<any[]> {
327
+ const intent = new android.content.Intent(android.content.Intent.ACTION_OPEN_DOCUMENT);
328
+ intent.addCategory(android.content.Intent.CATEGORY_OPENABLE);
329
+ intent.setType((types && types.length === 1) ? types[0] : '*/*');
330
+ if (types && types.length > 1) {
331
+ // EXTRA_MIME_TYPES needs a Java String[]; NativeScript won't auto-marshal a JS array here.
332
+ const arr = Array.create('java.lang.String', types.length);
333
+ types.forEach((t, i) => { arr[i] = t; });
334
+ intent.putExtra(android.content.Intent.EXTRA_MIME_TYPES, arr);
335
+ }
336
+ if (multiple) intent.putExtra(android.content.Intent.EXTRA_ALLOW_MULTIPLE, true);
337
+
338
+ const { resultCode, intent: result } = await startActivityForResult(intent);
339
+ if (resultCode !== android.app.Activity.RESULT_OK || !result) return [];
340
+
341
+ const uris: any[] = [];
342
+ const clip = result.getClipData?.();
343
+ if (clip) for (let i = 0; i < clip.getItemCount(); i++) uris.push(clip.getItemAt(i).getUri());
344
+ else if (result.getData()) uris.push(result.getData());
345
+
346
+ const cr = Utils.android.getApplicationContext().getContentResolver();
347
+ return uris.map((uri) => {
348
+ const stream = cr.openInputStream(uri);
349
+ const baos = new java.io.ByteArrayOutputStream();
350
+ const buf = Array.create('byte', 8192);
351
+ let n: number;
352
+ while ((n = stream.read(buf)) !== -1) baos.write(buf, 0, n);
353
+ stream.close();
354
+ const bytes = baos.toByteArray();
355
+ return {
356
+ name: queryDisplayName(cr, uri),
357
+ mimeType: cr.getType(uri) ?? 'application/octet-stream',
358
+ size: bytes.length,
359
+ base64: android.util.Base64.encodeToString(bytes, android.util.Base64.NO_WRAP),
360
+ };
361
+ });
362
+ }
363
+
364
+ function queryDisplayName(cr: any, uri: any): string {
365
+ const cursor = cr.query(uri, [android.provider.OpenableColumns.DISPLAY_NAME], null, null, null);
366
+ let name = 'file';
367
+ if (cursor) {
368
+ if (cursor.moveToFirst()) name = String(cursor.getString(0) ?? 'file');
369
+ cursor.close();
370
+ }
371
+ return name;
372
+ }
@@ -1,9 +1,11 @@
1
- import { Utils, isIOS } from '@nativescript/core';
1
+ import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
+ import { setDeepLinkInterceptor } from './events';
3
4
 
4
5
  // AuthenticationServices globals (NS auto-links the framework via metadata when referenced).
5
6
  declare const ASWebAuthenticationSession: any;
6
7
  declare const ASWebAuthenticationPresentationContextProviding: any;
8
+ declare const android: any, androidx: any;
7
9
 
8
10
  const err = (code: string, message: string) => Object.assign(new Error(message), { code });
9
11
 
@@ -19,6 +21,7 @@ let activeProvider: any = null;
19
21
  * the auth URL + does token exchange; this only opens the browser and hands back the callback.
20
22
  */
21
23
  export function registerOAuthHandlers(): void {
24
+ if (isAndroid) { registerAndroid(); return; }
22
25
  if (!isIOS) return;
23
26
 
24
27
  bridge.register(
@@ -67,3 +70,89 @@ export function registerOAuthHandlers(): void {
67
70
  })
68
71
  );
69
72
  }
73
+
74
+ /**
75
+ * Android: launch the auth URL in a Chrome Custom Tab. Unlike iOS ASWebAuthenticationSession, a
76
+ * Custom Tab does NOT auto-close on the provider redirect — the redirect to `callbackScheme://…`
77
+ * re-launches our (singleTask) activity as a VIEW intent, which the shell's existing deep-link path
78
+ * (events.wireAndroidDeepLinks → onDeepLink) already surfaces. We register a deep-link interceptor
79
+ * that matches the pending callbackScheme, swallows that URL (it's OAuth plumbing, not an app deep
80
+ * link), and resolves the pending authorize() with the same { url } shape the iOS path returns.
81
+ *
82
+ * Cancel contract (matches iOS user-dismiss → CANCELLED): Custom Tabs gives no dismissal callback, so
83
+ * we infer cancellation on app.resume — if the app comes back to the foreground after the tab opened
84
+ * and NO matching redirect arrived within a short grace window, the user backed out → reject CANCELLED.
85
+ *
86
+ * DEVICE-UNVERIFIED (any-typed FFI, no device run this session): the CustomTabsIntent launch + the
87
+ * redirect→deep-link round-trip + the resume-based cancel inference. Compile-clean only.
88
+ */
89
+ function registerAndroid(): void {
90
+ // Single in-flight authorize at a time (mirrors the iOS single-session model).
91
+ let pending:
92
+ | { scheme: string; resolve: (v: any) => void; reject: (e: any) => void; settled: boolean; resumeOff: () => void }
93
+ | null = null;
94
+
95
+ const finish = (fn: () => void) => {
96
+ if (!pending || pending.settled) return;
97
+ pending.settled = true;
98
+ pending.resumeOff();
99
+ const p = pending;
100
+ pending = null;
101
+ fn.call(p);
102
+ };
103
+
104
+ // First refusal on every inbound deep link: consume the one that matches the pending callbackScheme.
105
+ setDeepLinkInterceptor((url: string): boolean => {
106
+ if (!pending || pending.settled) return false;
107
+ const scheme = pending.scheme;
108
+ if (!scheme || !String(url ?? '').toLowerCase().startsWith(scheme.toLowerCase() + ':')) return false;
109
+ finish(function (this: any) { this.resolve({ url: String(url) }); });
110
+ return true; // swallow — internal OAuth callback, not an app deep link
111
+ });
112
+
113
+ bridge.register(
114
+ 'oauth.authorize',
115
+ ({ url, callbackScheme, ephemeral }: { url: string; callbackScheme: string; ephemeral?: boolean }) =>
116
+ new Promise((resolve, reject) => {
117
+ const target = String(url ?? '');
118
+ const scheme = String(callbackScheme ?? '');
119
+ if (!target) { reject(err('NATIVE_ERROR', 'oauth.authorize: empty url')); return; }
120
+ if (!scheme) { reject(err('NATIVE_ERROR', 'oauth.authorize: empty callbackScheme')); return; }
121
+ if (pending && !pending.settled) { reject(err('NATIVE_ERROR', 'oauth.authorize: a flow is already in progress')); return; }
122
+
123
+ Utils.dispatchToMainThread(() => {
124
+ try {
125
+ const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
126
+ if (!activity) { reject(err('NATIVE_ERROR', 'oauth.authorize: no foreground activity')); return; }
127
+
128
+ // Cancel inference: the tab launch will background us; the FIRST resume afterwards means
129
+ // the user is back in our app. If no redirect consumed the flow by then (+grace), it's a
130
+ // user-cancel. A small delay lets a redirect that arrives in the same resume win the race.
131
+ let launched = false;
132
+ const onResume = () => {
133
+ if (!launched || !pending || pending.settled) return;
134
+ setTimeout(() => finish(function (this: any) { this.reject(err('CANCELLED', 'oauth cancelled')); }), 400);
135
+ };
136
+ Application.on(Application.resumeEvent, onResume);
137
+ const resumeOff = () => Application.off(Application.resumeEvent, onResume);
138
+ pending = { scheme, resolve, reject, settled: false, resumeOff };
139
+
140
+ const builder = new androidx.browser.customtabs.CustomTabsIntent.Builder();
141
+ // `ephemeral` (fresh login) → don't share the persisted browser session. Custom Tabs has
142
+ // no per-launch incognito API; the closest honest knob is disabling the recents/share
143
+ // surface so the auth page isn't trivially resumable. Cookie isolation isn't guaranteed.
144
+ if (ephemeral) builder.setShareState(androidx.browser.customtabs.CustomTabsIntent.SHARE_STATE_OFF);
145
+ const tabs = builder.build();
146
+ // No extra intent flags: the `callbackScheme://…` redirect re-launches our singleTask
147
+ // activity to the front (consumed by the deep-link interceptor), leaving the tab behind it.
148
+ tabs.launchUrl(activity, android.net.Uri.parse(target));
149
+ launched = true;
150
+ } catch (e: any) {
151
+ const fail = err('NATIVE_ERROR', e?.message ?? 'oauth failed to start');
152
+ if (pending && !pending.settled) finish(function (this: any) { this.reject(fail); });
153
+ else reject(fail); // failed before `pending` was wired
154
+ }
155
+ });
156
+ })
157
+ );
158
+ }
@@ -6,7 +6,7 @@ const err = (code: string, message: string) => Object.assign(new Error(message),
6
6
  const iosOnly = () => err('UNSUPPORTED', 'iOS only for now');
7
7
 
8
8
  /**
9
- * Extended-parity handlers: dialogs, reviews, storage.clear, geo watch, theme color,
9
+ * Extended-parity handlers: dialogs, storage.clear, geo watch, theme color,
10
10
  * motion, contacts, calendar, camera. iOS-first like handlers-extended.
11
11
  */
12
12
  export function registerParityHandlers(): void {
@@ -31,18 +31,8 @@ export function registerParityHandlers(): void {
31
31
  return idx >= 0 ? idx : null;
32
32
  });
33
33
 
34
- // ── reviews (SKStoreReviewController) ──────────────────────────────
35
- bridge.register('reviews.requestReview', () => {
36
- if (!isIOS) throw iosOnly();
37
- Utils.dispatchToMainThread(() => {
38
- const scene = UIApplication.sharedApplication.keyWindow?.windowScene;
39
- if (scene && SKStoreReviewController.requestReviewInWindowScene) {
40
- SKStoreReviewController.requestReviewInWindowScene(scene);
41
- } else {
42
- SKStoreReviewController.requestReview();
43
- }
44
- });
45
- });
34
+ // reviews → moved to the strippable `reviews` module (handlers-reviews.ts): its Android Play
35
+ // In-App Review path carries a gradle dep that must not bundle into builds without `reviews`.
46
36
 
47
37
  // ── storage.clear ──────────────────────────────────────────────────
48
38
  bridge.register('storage.clear', () => {
@@ -184,6 +174,44 @@ export function registerParityHandlers(): void {
184
174
  });
185
175
  });
186
176
 
177
+ // ── contacts bulk read (CNContactStore — requires permission) ──────
178
+ bridge.register('contacts.getAll', () => {
179
+ if (!isIOS) throw iosOnly();
180
+ const store = CNContactStore.new();
181
+ return new Promise((resolve, reject) => {
182
+ store.requestAccessForEntityTypeCompletionHandler(CNEntityType.Contacts, (granted: boolean, error: NSError | null) => {
183
+ if (!granted) {
184
+ return reject(err('DENIED', error?.localizedDescription ?? 'contacts access denied'));
185
+ }
186
+ try {
187
+ const collect = (labeled: NSArray<any>, map: (v: any) => string) => {
188
+ const out: string[] = [];
189
+ for (let i = 0; i < labeled.count; i++) out.push(map(labeled.objectAtIndex(i).value));
190
+ return out;
191
+ };
192
+ const keys = NSArray.arrayWithArray([
193
+ CNContactGivenNameKey,
194
+ CNContactFamilyNameKey,
195
+ CNContactPhoneNumbersKey,
196
+ CNContactEmailAddressesKey,
197
+ ] as any);
198
+ const request = CNContactFetchRequest.alloc().initWithKeysToFetch(keys);
199
+ const contacts: Array<{ name: string; phones: string[]; emails: string[] }> = [];
200
+ store.enumerateContactsWithFetchRequestErrorUsingBlock(request, null as any, (contact: any) => {
201
+ contacts.push({
202
+ name: `${contact.givenName ?? ''} ${contact.familyName ?? ''}`.trim(),
203
+ phones: collect(contact.phoneNumbers, (v) => String(v.stringValue)),
204
+ emails: collect(contact.emailAddresses, (v) => String(v)),
205
+ });
206
+ });
207
+ resolve({ contacts });
208
+ } catch (e: any) {
209
+ reject(err('NATIVE_ERROR', e.message));
210
+ }
211
+ });
212
+ });
213
+ });
214
+
187
215
  // ── calendar (EventKit) ────────────────────────────────────────────
188
216
  bridge.register('calendar.createEvent', ({ title, start, durationMin, notes }: any) => {
189
217
  if (!isIOS) throw iosOnly();
@@ -1,4 +1,4 @@
1
- import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
1
+ import { Application, Http, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import { bridge } from './bridge';
3
3
  import { SHELL_CONFIG } from './config';
4
4
 
@@ -21,9 +21,32 @@ let cachedToken: string | null = null;
21
21
 
22
22
  interface PushToken { platform: 'apns' | 'fcm'; token: string; }
23
23
 
24
+ /** POST the device token to the app's configured backend NATIVELY (no WKWebView fetch → no app://
25
+ * cross-origin/CORS wall). The backend stores it + sends pushes. No-op when no URL is configured. */
26
+ function registerTokenWithBackend(platform: 'ios' | 'android', token: string): void {
27
+ const url = SHELL_CONFIG.pushRegistrationUrl;
28
+ if (!url) return;
29
+ // NativeScript Http (NOT the global fetch — unreliable in the NS runtime). Native HTTP, no CORS.
30
+ // Guarded: never let a backend-register hiccup break token resolution (this runs before resolve()).
31
+ try {
32
+ Http.request({
33
+ url, method: 'POST', headers: { 'Content-Type': 'application/json' },
34
+ content: JSON.stringify({ token, platform }),
35
+ })
36
+ .then((res) => console.log('[push] token registered with backend → ' + res.statusCode))
37
+ .catch((e: any) => console.log('[push] backend register failed: ' + (e?.message ?? e)));
38
+ } catch (e: any) {
39
+ console.log('[push] backend register threw: ' + (e?.message ?? e));
40
+ }
41
+ }
42
+
24
43
  /** iOS AppDelegate → APNs device token arrived. Resolve any pending register() + cache. */
25
44
  export function onApnsToken(hexToken: string): void {
26
45
  cachedToken = hexToken;
46
+ // Log the FULL token so it's readable headlessly (`appwrap logs ios`) to send a test push —
47
+ // mirrors the Android FCM boot-log. The token alone isn't a secret (you still need the APNs key).
48
+ console.log('[push] APNs token: ' + hexToken);
49
+ registerTokenWithBackend('ios', hexToken);
27
50
  if (pendingRegister) {
28
51
  pendingRegister.resolve({ platform: 'apns', token: hexToken });
29
52
  pendingRegister = null;
@@ -0,0 +1,73 @@
1
+ import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
+ import { bridge } from './bridge';
3
+
4
+ // SKStoreReviewController is declared globally in ios-frameworks.d.ts. `com` is the Android namespace.
5
+ declare const com: any;
6
+
7
+ /**
8
+ * In-app store review prompt — opt-in, strippable (its own handler file + group). Lives apart from
9
+ * the always-bundled parity/system handlers because the Android path references the Play In-App
10
+ * Review classes (`com.google.android.play:review`), a gradle dep that must NOT land in builds that
11
+ * don't enable `reviews` (missing-class crash / dead weight otherwise).
12
+ *
13
+ * iOS: SKStoreReviewController — the OS rate-limits display (may show nothing). Fire-and-forget.
14
+ * Android: Play In-App Review (ReviewManager). HONEST LIMIT — the dialog only actually surfaces for a
15
+ * Play-Store-track install; on a bare emulator / sideload the API resolves WITHOUT showing UI (same
16
+ * class as android-billing). We call the API and resolve gracefully; we never fabricate "shown".
17
+ *
18
+ * DEVICE-UNVERIFIED (any-typed FFI, no device run this session): the Android ReviewManager
19
+ * requestReviewFlow → launchReviewFlow task/listener chain. Compile-clean only.
20
+ */
21
+ export function registerReviewsHandlers(): void {
22
+ if (isIOS) registerIos();
23
+ else if (isAndroid) registerAndroid();
24
+ }
25
+
26
+ function registerIos(): void {
27
+ bridge.register('reviews.requestReview', () => {
28
+ Utils.dispatchToMainThread(() => {
29
+ const scene = UIApplication.sharedApplication.keyWindow?.windowScene;
30
+ if (scene && SKStoreReviewController.requestReviewInWindowScene) {
31
+ SKStoreReviewController.requestReviewInWindowScene(scene);
32
+ } else {
33
+ SKStoreReviewController.requestReview();
34
+ }
35
+ });
36
+ });
37
+ }
38
+
39
+ function registerAndroid(): void {
40
+ bridge.register('reviews.requestReview', () =>
41
+ new Promise<void>((resolve) => {
42
+ Utils.dispatchToMainThread(() => {
43
+ try {
44
+ const activity = Application.android.foregroundActivity ?? Application.android.startActivity;
45
+ if (!activity) { resolve(); return; }
46
+ const manager = com.google.android.play.core.review.ReviewManagerFactory.create(activity);
47
+ const request = manager.requestReviewFlow();
48
+ // requestReviewFlow() → Task<ReviewInfo>; on success launchReviewFlow() → Task<Void>.
49
+ // Both tasks COMPLETE even when the OS shows nothing (no Play track / rate-limited), so we
50
+ // resolve on completion regardless — never block, never fabricate a "shown" signal.
51
+ request.addOnCompleteListener(new com.google.android.gms.tasks.OnCompleteListener({
52
+ onComplete(infoTask: any) {
53
+ if (!infoTask.isSuccessful()) { resolve(); return; }
54
+ try {
55
+ const flow = manager.launchReviewFlow(activity, infoTask.getResult());
56
+ flow.addOnCompleteListener(new com.google.android.gms.tasks.OnCompleteListener({
57
+ onComplete() { resolve(); },
58
+ }));
59
+ } catch (e) {
60
+ console.warn('AppWrap: reviews.launchReviewFlow failed', e);
61
+ resolve();
62
+ }
63
+ },
64
+ }));
65
+ } catch (e) {
66
+ // Play services / review lib absent (e.g. non-GMS device) → honest no-op, don't reject.
67
+ console.warn('AppWrap: reviews.requestReview unavailable', e);
68
+ resolve();
69
+ }
70
+ });
71
+ })
72
+ );
73
+ }