@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.
- package/package.json +1 -1
- package/runtime/App_Resources/Android/src/main/res/xml/file_paths.xml +7 -1
- package/runtime/app/main-page.ts +4 -0
- package/runtime/app/shell/capabilities.manifest.ts +64 -12
- package/runtime/app/shell/config.ts +9 -0
- package/runtime/app/shell/custom-webview.android.ts +2 -2
- package/runtime/app/shell/custom-webview.ios.ts +3 -2
- package/runtime/app/shell/events.ts +11 -0
- package/runtime/app/shell/handlers-android.ts +43 -0
- package/runtime/app/shell/handlers-extended.ts +4 -1
- package/runtime/app/shell/handlers-fs.ts +372 -0
- package/runtime/app/shell/handlers-oauth.ts +90 -1
- package/runtime/app/shell/handlers-parity.ts +41 -13
- package/runtime/app/shell/handlers-push.ts +24 -1
- package/runtime/app/shell/handlers-reviews.ts +73 -0
- package/runtime/app/shell/handlers-scanner.ts +222 -0
- package/runtime/app/shell/handlers-speech.ts +336 -0
- package/runtime/app/shell/ios-frameworks.d.ts +8 -0
- package/runtime/app/shell/keyboard.ts +85 -0
- package/runtime/app/shell/web-quirks.ts +58 -0
- package/runtime/package.json +1 -1
- package/src/cli.ts +29 -2
- package/src/config.ts +11 -0
|
@@ -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,
|
|
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
|
-
//
|
|
35
|
-
|
|
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
|
+
}
|