@livx.cc/appwrap 0.58.0 → 0.58.2

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@livx.cc/appwrap",
3
- "version": "0.58.0",
3
+ "version": "0.58.2",
4
4
  "description": "Wrap any PWA into a native app with native capabilities (appwrap runtime + @livx.cc/native-kit).",
5
5
  "license": "MIT",
6
6
  "author": "Elya Livshitz",
@@ -282,27 +282,47 @@ export const MODULES: ModuleManifest[] = [
282
282
  // proven cold-start buffering (handshake `deepLink`) + warm `deeplink.open` event with zero new
283
283
  // bridge surface. iOS: a generated share-extension TARGET (`AppwrapShare`, from this module's
284
284
  // nativeSrc via NS's App_Resources/iOS/extensions seam — same machinery as the widget pack) that
285
- // forwards to the host app over `urlScheme` (REQUIRED in config); images cross via the App Group
286
- // container (entitlement below, stamped on both targets) and the host shell relocates them into the
287
- // app cache so the JS contract is platform-identical. Consume via `kit.shareTarget.onReceive` (or
288
- // raw `kit.lifecycle.onDeepLink`).
285
+ // PERSISTS the payload as the same `<urlScheme>://share?…` URL into an App-Group UserDefaults
286
+ // MAILBOX (modern iOS blocks share extensions from launching their host via openURL:, so there is
287
+ // no live hand-off — the extension shows a brief "Added to <App>" confirmation instead); the host
288
+ // drains the mailbox on cold launch + every resume (read-once) into the same deep-link path. Images
289
+ // cross via the App Group container (entitlement below, stamped on both targets) and the host shell
290
+ // relocates them into the app cache so the JS contract is platform-identical. `urlScheme` REQUIRED. Consume via `kit.shareTarget.onReceive` (or
291
+ // raw `kit.lifecycle.onDeepLink`). OPTIONAL direct sync: config `shareTarget.directSync` lets the
292
+ // iOS extension complete the share ITSELF (one templated HTTP call, "Syncing… → Synced" drawer
293
+ // status) using the app-published context KV (`kit.shareTarget.setContext`) — mailbox stays the
294
+ // fallback on any failure. See ShareDirectSyncConfig in appwrap-cli config.ts.
289
295
  {
290
296
  name: 'shareTarget', group: 'shareTarget', nativeSrc: 'shareTarget',
291
297
  capabilities: { shareTarget: 'native' },
292
298
  ios: { entitlements: { 'com.apple.security.application-groups': ['__APP_GROUP__'] } },
299
+ // Android UX parity with the iOS drawer: the SEND filters sit on a LIGHTWEIGHT translucent
300
+ // activity (module Kotlin source, cc.appwrap.share.AppwrapShareActivity) — NOT the main
301
+ // activity — so a share never boots the full app/WebView. It performs the same config-driven
302
+ // direct sync (toast status) and only falls back to launching the app (deep-link contract
303
+ // unchanged) when sync isn't possible. taskAffinity="" keeps it out of the app's task;
304
+ // noHistory + excludeFromRecents make it invisible to navigation.
293
305
  android: {
294
- manifestActivity:
295
- `<intent-filter>\n` +
296
- `\t\t\t<action android:name="android.intent.action.SEND" />\n` +
297
- `\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
298
- `\t\t\t<data android:mimeType="text/plain" />\n` +
299
- `\t\t\t<data android:mimeType="image/*" />\n` +
300
- `\t\t</intent-filter>\n` +
301
- `\t\t<intent-filter>\n` +
302
- `\t\t\t<action android:name="android.intent.action.SEND_MULTIPLE" />\n` +
303
- `\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
304
- `\t\t\t<data android:mimeType="image/*" />\n` +
305
- `\t\t</intent-filter>`,
306
+ kotlin: true,
307
+ manifestApplication:
308
+ `<activity android:name="cc.appwrap.share.AppwrapShareActivity"\n` +
309
+ `\t\t\tandroid:exported="true"\n` +
310
+ `\t\t\tandroid:excludeFromRecents="true"\n` +
311
+ `\t\t\tandroid:noHistory="true"\n` +
312
+ `\t\t\tandroid:taskAffinity=""\n` +
313
+ `\t\t\tandroid:theme="@android:style/Theme.Translucent.NoTitleBar">\n` +
314
+ `\t\t\t<intent-filter>\n` +
315
+ `\t\t\t\t<action android:name="android.intent.action.SEND" />\n` +
316
+ `\t\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
317
+ `\t\t\t\t<data android:mimeType="text/plain" />\n` +
318
+ `\t\t\t\t<data android:mimeType="image/*" />\n` +
319
+ `\t\t\t</intent-filter>\n` +
320
+ `\t\t\t<intent-filter>\n` +
321
+ `\t\t\t\t<action android:name="android.intent.action.SEND_MULTIPLE" />\n` +
322
+ `\t\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
323
+ `\t\t\t\t<data android:mimeType="image/*" />\n` +
324
+ `\t\t\t</intent-filter>\n` +
325
+ `\t\t</activity>`,
306
326
  },
307
327
  },
308
328
 
@@ -161,6 +161,23 @@ class DevCertNavDelegate extends NSObject implements WKNavigationDelegate {
161
161
  }
162
162
 
163
163
  export class CustomWebView extends WebView {
164
+ constructor() {
165
+ super();
166
+ // The WKWebView must OWN the safe areas for CSS env(safe-area-inset-*) to be non-zero: WebKit
167
+ // (WKWebViewIOS.mm _computedUnobscuredSafeAreaInset) feeds env() from the WKWebView's OWN UIKit
168
+ // safeAreaInsets when contentInsetAdjustmentBehavior is .never — which requires the view's frame
169
+ // to genuinely extend under the status bar / home indicator. NS core's WebView defaults
170
+ // `iosOverflowSafeArea` to FALSE (only layout containers default true), which routes every layout
171
+ // pass through IOSHelper.shrinkToSafeArea: whenever the pass runs with the view attached to a
172
+ // window, the frame is boxed inside the safe area → safeAreaInsets become 0 → env() = 0, and the
173
+ // wrapped page's own safe-area padding collapses (content behind the notch). Whether the shrink
174
+ // actually lands is TIMING-dependent (NS's _cachedFrame skips re-adjustment when the frame value
175
+ // is unchanged), so devices differ. `true` flips NS to its expandBeyondSafeArea path —
176
+ // deterministic full-bleed frame, real safeAreaInsets, env() populated. Page-level padding then
177
+ // works with zero per-app configuration. (iOS-only NS property; inert on Android.)
178
+ this.iosOverflowSafeArea = true;
179
+ }
180
+
164
181
  /** Set by the bridge before load; receives raw envelope JSON. */
165
182
  onAppwrapMessage: ((json: string) => void) | null = null;
166
183
  private _scriptHandler!: AppwrapScriptHandler; // retained — WKUserContentController holds it weakly
@@ -363,8 +380,44 @@ export class CustomWebView extends WebView {
363
380
  return webView;
364
381
  }
365
382
 
383
+ /** Debug diagnostic: snapshot the native safe-area geometry (frame, safeAreaInsets, scroll-view
384
+ * inset behavior, superview chain) into the page as `window.__appwrapSafeAreaDiag` + the file log.
385
+ * Support surface for "env(safe-area-inset-*) is 0" reports — separates the UIKit side (view
386
+ * geometry, this snapshot) from the WebKit side (viewport-fit meta, see NATIVE_FEEL_JS): the field
387
+ * bug this was built for showed PERFECT native insets while a late page viewport meta without
388
+ * viewport-fit reverted cover and zeroed env(). Debug builds only. */
389
+ private snapshotSafeAreaDiag(tag: string): void {
390
+ const wk = this.nativeViewProtected as WKWebView;
391
+ if (!wk) return;
392
+ const r = (x: CGRect) => `${Math.round(x.origin.x)},${Math.round(x.origin.y)},${Math.round(x.size.width)}x${Math.round(x.size.height)}`;
393
+ const ins = (i: UIEdgeInsets) => `t${i.top} l${i.left} b${i.bottom} r${i.right}`;
394
+ const chain: string[] = [];
395
+ let v: UIView | null = wk;
396
+ while (v) { chain.push(`${NSStringFromClass(v.class())}[${r(v.frame)}]sa(${ins(v.safeAreaInsets)})`); v = v.superview; }
397
+ const win = wk.window;
398
+ const diag = {
399
+ tag,
400
+ frame: r(wk.frame), bounds: r(wk.bounds),
401
+ safeAreaInsets: ins(wk.safeAreaInsets),
402
+ behavior: wk.scrollView.contentInsetAdjustmentBehavior,
403
+ adjusted: ins(wk.scrollView.adjustedContentInset),
404
+ contentInset: ins(wk.scrollView.contentInset),
405
+ inWindow: !!win,
406
+ windowSA: win ? ins(win.safeAreaInsets) : 'nil',
407
+ windowFrame: win ? r(win.frame) : 'nil',
408
+ chain: chain.join(' < '),
409
+ };
410
+ const js = `window.__appwrapSafeAreaDiag=${JSON.stringify(JSON.stringify(diag))};`;
411
+ wk.evaluateJavaScriptCompletionHandler(js, () => { /* fire-and-forget */ });
412
+ appendWebLog(`[native:sa-diag] ${JSON.stringify(diag)}`);
413
+ }
414
+
366
415
  initNativeView(): void {
367
416
  super.initNativeView();
417
+ // Debug builds: delayed safe-area snapshots after the view settles in-window (see method doc).
418
+ if (SHELL_CONFIG.debug) {
419
+ for (const ms of [1500, 4000, 8000]) setTimeout(() => this.snapshotSafeAreaDiag(`t+${ms}`), ms);
420
+ }
368
421
  // NativeScript's WebView assigns its own wkWebView.UIDelegate during init,
369
422
  // which clobbers the media-capture grant set in createNativeView — so WebKit
370
423
  // falls back to prompting on every getUserMedia call. Reassert ours last so
@@ -1,14 +1,24 @@
1
1
  import { Application, Utils, isAndroid, isIOS } from '@nativescript/core';
2
2
  import type { AndroidActivityNewIntentEventData } from '@nativescript/core';
3
3
  import { SHELL_CONFIG } from './config';
4
+ import { bridge } from './bridge';
4
5
  import { onDeepLink, setDeepLinkTransformer } from './events';
6
+ import { MAILBOX_KEY, drainShareMailbox, type ShareMailboxStore } from './share-mailbox';
7
+
8
+ /** App Group UserDefaults key holding the app-published SHARE CONTEXT (JSON string KV). Written by
9
+ * `shareTarget.setContext`, read by the AppwrapShare extension to resolve direct-sync `{key}`
10
+ * template placeholders. Must match ShareViewController.swift. */
11
+ const CONTEXT_KEY = 'appwrap-share-context';
5
12
 
6
13
  /**
7
- * shareTarget (INBOUND share sheet) — Android. The manifest's ACTION_SEND(_MULTIPLE) intent-filter
8
- * (stamped by the CLI when the module is active) lands the shared payload on the main activity as a
9
- * SEND intent; this handler reads it (cold launch + warm onNewIntent) and re-delivers through the
10
- * EXISTING deep-link path as a synthetic URL — so it inherits the proven cold-start buffering
11
- * (delivered in the handshake `deepLink`) and warm `deeplink.open` event with no new bridge surface.
14
+ * shareTarget (INBOUND share sheet) — Android. The SEND(_MULTIPLE) intent-filters live on the
15
+ * LIGHTWEIGHT `cc.appwrap.share.AppwrapShareActivity` (module Kotlin source, translucent — the iOS
16
+ * drawer's parity): it direct-syncs when configured, and on any failure launches the app with the
17
+ * `<urlScheme>://share?…` deep link below (a VIEW intent → the normal deep-link path). The SEND
18
+ * handler here remains for builds whose manifest still carries the filters on the main activity
19
+ * (pre-parity installs): it reads the intent (cold launch + warm onNewIntent) and re-delivers
20
+ * through the EXISTING deep-link path as a synthetic URL — inheriting the proven cold-start
21
+ * buffering (handshake `deepLink`) and warm `deeplink.open` event with no new bridge surface.
12
22
  *
13
23
  * URL contract (parsed by kit.shareTarget, also directly consumable via kit.lifecycle.onDeepLink):
14
24
  * <urlScheme>://share?text=<enc>&title=<enc>&file=<enc>&file=<enc>…
@@ -17,17 +27,47 @@ import { onDeepLink, setDeepLinkTransformer } from './events';
17
27
  * - `file` — repeated; each is a path RELATIVE TO THE APP CACHE DIR (`appwrap-share/…`) where the
18
28
  * shared stream (image) was copied — read it via `kit.fs.read(p, { dir: 'cache', encoding: 'base64' })`.
19
29
  *
20
- * iOS: the generated `AppwrapShare` extension (this module's nativeSrc) forwards the payload as the
21
- * SAME URL over the app's urlScheme — that link enters through the normal openURL delegate path. Text
22
- * rides in the URL directly; images land in the App Group container as `gfile=<name>` params, which
23
- * the transformer below relocates into the app cache and rewrites to `file=` (cache-relative) so the
24
- * contract the web app sees is platform-identical.
30
+ * iOS: the generated `AppwrapShare` extension (this module's nativeSrc) PERSISTS the payload as the
31
+ * SAME URL into an App-Group "mailbox" (UserDefaults key `appwrap-share-mailbox`, a string array) —
32
+ * modern iOS silently blocks a share extension from launching its host via `openURL:`, so there is
33
+ * no live hand-off; the host drains the mailbox on cold launch and on every foreground/resume
34
+ * (read-once) and feeds each URL through the normal deep-link path (cold: buffered into the
35
+ * handshake `deepLink`; warm: `deeplink.open` event). Text rides in the URL directly; images land in
36
+ * the App Group container as `gfile=<name>` params, which the transformer below relocates into the
37
+ * app cache and rewrites to `file=` (cache-relative) so the contract the web app sees is
38
+ * platform-identical.
25
39
  */
26
40
  export function registerShareTargetHandlers(): void {
41
+ // Share-context KV (kit.shareTarget.setContext) — generic, app-opaque. iOS persists it in the App
42
+ // Group (read by the AppwrapShare extension); Android persists it in SharedPreferences
43
+ // `appwrap-share`/`context` (read by cc.appwrap.share.AppwrapShareActivity's direct sync).
44
+ bridge.register('shareTarget.setContext', ({ context }: { context?: Record<string, string | number> | null }) => {
45
+ const json = context && typeof context === 'object' ? JSON.stringify(context) : null;
46
+ if (isIOS) {
47
+ const d = NSUserDefaults.alloc().initWithSuiteName(`group.${SHELL_CONFIG.appId}`);
48
+ if (!d) return;
49
+ if (json) d.setObjectForKey(json, CONTEXT_KEY);
50
+ else d.removeObjectForKey(CONTEXT_KEY);
51
+ return;
52
+ }
53
+ if (!isAndroid) return;
54
+ const prefs = (Utils.android.getApplicationContext() as android.content.Context)
55
+ .getSharedPreferences('appwrap-share', 0 /* MODE_PRIVATE */);
56
+ const ed = prefs.edit();
57
+ if (json) ed.putString('context', json);
58
+ else ed.remove('context');
59
+ ed.apply();
60
+ });
27
61
  if (isIOS) {
28
62
  // Rewrite share links carrying App-Group files (gfile=) → cache files (file=). Text-only share
29
63
  // links (no gfile) pass through untouched.
30
64
  setDeepLinkTransformer((url) => (url.includes('://share?') && url.includes('gfile=') ? iosRelocateGroupFiles(url) : url));
65
+ const drain = () => {
66
+ try { drainShareMailbox(iosMailboxStore(), onDeepLink); }
67
+ catch (e) { console.warn('AppWrap: share mailbox drain failed', e); }
68
+ };
69
+ drain(); // cold launch — a share made while the app was killed is waiting in the mailbox
70
+ Application.on(Application.resumeEvent, drain); // warm — user shared, then switched back to the app
31
71
  return;
32
72
  }
33
73
  if (!isAndroid) return;
@@ -38,6 +78,23 @@ export function registerShareTargetHandlers(): void {
38
78
  deliverShareIntent(Application.android.startActivity?.getIntent?.());
39
79
  }
40
80
 
81
+ // ── iOS App-Group mailbox (written by the AppwrapShare extension, drained by the host) ──
82
+ // Drain semantics (read-once, crash-safe) are PURE and live in share-mailbox.ts (bun-tested).
83
+
84
+ /** The real iOS store: `group.<appId>` suite UserDefaults, string-array under MAILBOX_KEY. */
85
+ function iosMailboxStore(): ShareMailboxStore {
86
+ const defaults = NSUserDefaults.alloc().initWithSuiteName(`group.${SHELL_CONFIG.appId}`);
87
+ return {
88
+ read: () => {
89
+ const arr = defaults?.arrayForKey(MAILBOX_KEY);
90
+ const out: string[] = [];
91
+ for (let i = 0; arr && i < arr.count; i++) out.push(String(arr.objectAtIndex(i)));
92
+ return out;
93
+ },
94
+ clear: () => { defaults?.removeObjectForKey(MAILBOX_KEY); },
95
+ };
96
+ }
97
+
41
98
  /** iOS: move each `gfile=<name>` from `<AppGroup>/appwrap-share/` into `<Caches>/appwrap-share/` and
42
99
  * rewrite the param to `file=appwrap-share/<name>` (the fs `cache` root), matching Android's shape.
43
100
  * Any file that can't be moved (missing group container, purged file) is dropped from the link;
@@ -0,0 +1,40 @@
1
+ /**
2
+ * PURE iOS shareTarget mailbox-drain logic (no NativeScript imports; bun-tested — mirrors
3
+ * geo-auth.ts/notif-identity.ts: the semantics live here, handlers-share-target.ts only does the
4
+ * NSUserDefaults plumbing).
5
+ *
6
+ * WHY a mailbox: modern iOS silently blocks a share extension's responder-chain `openURL:` — a share
7
+ * extension may NOT launch its host app. So the generated `AppwrapShare` extension durably appends
8
+ * the share URL (`<scheme>://share?…`) to an App-Group UserDefaults string array under MAILBOX_KEY,
9
+ * and the HOST drains it on cold launch and on every foreground/resume. Read-once: the drain clears
10
+ * the mailbox BEFORE delivering, so overlapping drains can never double-deliver; crash-safety: the
11
+ * extension's write persists until a drain actually runs, and one runs on every foreground.
12
+ */
13
+
14
+ /** App-Group UserDefaults key the extension appends to (see ShareViewController.swift). */
15
+ export const MAILBOX_KEY = 'appwrap-share-mailbox';
16
+
17
+ /** Minimal seam over the App-Group UserDefaults mailbox — injectable for tests. */
18
+ export interface ShareMailboxStore {
19
+ read(): string[];
20
+ clear(): void;
21
+ }
22
+
23
+ /**
24
+ * Drain the share mailbox: read all pending share URLs, CLEAR first (exactly-once), then deliver
25
+ * each through `deliver` (the normal deep-link ingress: cold launches buffer into the handshake
26
+ * `deepLink`, warm apps get `deeplink.open`). Non-share/garbage entries are dropped. Returns the
27
+ * number of URLs delivered.
28
+ */
29
+ export function drainShareMailbox(store: ShareMailboxStore, deliver: (url: string) => void): number {
30
+ const urls = store.read();
31
+ if (!urls.length) return 0;
32
+ store.clear(); // read-once: clear BEFORE delivering so re-entry during delivery can't duplicate
33
+ let delivered = 0;
34
+ for (const u of urls) {
35
+ if (typeof u !== 'string' || !u.includes('://share?')) continue;
36
+ delivered++;
37
+ deliver(u);
38
+ }
39
+ return delivered;
40
+ }
@@ -296,14 +296,21 @@ export const NATIVE_FEEL_JS = `(function(){
296
296
  window.__appwrapNativeFeel = true;
297
297
  function apply(){
298
298
  var head = document.head || document.documentElement;
299
- var vp = document.querySelector('meta[name=viewport]');
299
+ // Normalize the LAST viewport meta — WebKit honors the last one parsed, so patching the first
300
+ // (or an injected one) is silently overridden when the page's own meta appears later in <head>.
301
+ // Device-verified failure: injected meta had viewport-fit=cover, the site's own plain
302
+ // "width=device-width, initial-scale=1.0" meta parsed after it → viewport-fit reverted to auto
303
+ // → env(safe-area-inset-*) = 0 despite correct native safeAreaInsets (t47/b34).
304
+ var metas = document.querySelectorAll('meta[name=viewport]');
305
+ var vp = metas.length ? metas[metas.length - 1] : null;
300
306
  if (!vp){ vp = document.createElement('meta'); vp.setAttribute('name','viewport'); head.appendChild(vp); }
301
307
  var c = vp.getAttribute('content') || 'width=device-width, initial-scale=1';
302
308
  if (!/user-scalable\\s*=\\s*no/.test(c)) c += ', maximum-scale=1, user-scalable=no';
303
309
  // viewport-fit=cover makes env(safe-area-inset-*) resolve to real notch /
304
310
  // home-indicator insets — the WebView owns the safe areas (contentInset .never).
311
+ // A page's own explicit viewport-fit is respected (only appended when absent).
305
312
  if (!/viewport-fit/.test(c)) c += ', viewport-fit=cover';
306
- vp.setAttribute('content', c);
313
+ if (vp.getAttribute('content') !== c) vp.setAttribute('content', c);
307
314
  if (!document.getElementById('__appwrap_feel')){
308
315
  var s = document.createElement('style'); s.id = '__appwrap_feel';
309
316
  s.textContent = ${JSON.stringify(NATIVE_FEEL_CSS)};
@@ -322,6 +329,22 @@ export const NATIVE_FEEL_JS = `(function(){
322
329
  }
323
330
  apply();
324
331
  if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', apply);
332
+ // SPA head managers (React Helmet, Next, vue-meta) add/rewrite the viewport meta AFTER load —
333
+ // re-normalize whenever one changes. apply() is idempotent (only writes on an actual content
334
+ // change), so the observer can't loop on its own writes.
335
+ try {
336
+ new MutationObserver(function(muts){
337
+ for (var i = 0; i < muts.length; i++){
338
+ var m = muts[i];
339
+ var t = m.target;
340
+ if (t && t.nodeName === 'META' && t.getAttribute('name') === 'viewport') return apply();
341
+ for (var j = 0; j < (m.addedNodes ? m.addedNodes.length : 0); j++){
342
+ var n = m.addedNodes[j];
343
+ if (n.nodeName === 'META' && n.getAttribute && n.getAttribute('name') === 'viewport') return apply();
344
+ }
345
+ }
346
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['content', 'name'] });
347
+ } catch(e) { /* observer is a hardening layer; core apply() already ran */ }
325
348
  // Pinch-zoom on iOS fires gesture* even with user-scalable=no in some builds.
326
349
  document.addEventListener('gesturestart', function(e){ e.preventDefault(); }, { passive: false });
327
350
  })();`;
@@ -0,0 +1,307 @@
1
+ package cc.appwrap.share
2
+
3
+ import android.app.Activity
4
+ import android.content.Intent
5
+ import android.graphics.Bitmap
6
+ import android.graphics.BitmapFactory
7
+ import android.net.Uri
8
+ import android.os.Bundle
9
+ import android.util.Base64
10
+ import android.widget.Toast
11
+ import org.json.JSONObject
12
+ import java.io.ByteArrayOutputStream
13
+ import java.io.File
14
+ import java.net.HttpURLConnection
15
+ import java.net.URL
16
+
17
+ /**
18
+ * appwrap shareTarget — Android lightweight share handler (generated per-app by the CLI, active only
19
+ * when the `shareTarget` module is on). Declared translucent + no-history in the manifest (see the
20
+ * module's `manifestApplication` XML), so tapping the app in the share sheet does NOT launch the full
21
+ * app/WebView — this activity handles the SEND intent invisibly, exactly like the iOS share
22
+ * extension handles the drawer:
23
+ *
24
+ * 1. DIRECT SYNC (config `shareTarget.directSync`, stamped as `__SHARE_SYNC_B64__` — base64 JSON,
25
+ * empty = off): one HTTP call to the app's backend with a "Syncing… → ✓ <successMessage>" toast.
26
+ * `{key}` placeholders resolve from the SHARE CONTEXT the web app published via
27
+ * `kit.shareTarget.setContext(...)` (SharedPreferences `appwrap-share` / key `context` — the
28
+ * Android sibling of iOS's App-Group `appwrap-share-context`). `merge:"append"` GETs first,
29
+ * appends text (newline-joined) and preserves the existing image unless a new one is shared.
30
+ * Oversized images are downscaled (longest edge → maxImageEdge, JPEG jpegQuality) first.
31
+ *
32
+ * 2. FALLBACK (any failure: offline, non-2xx, no/unresolved context, >1 image, still-oversized
33
+ * image): launch the main activity with the SAME deep link Android shares always used —
34
+ * `<urlScheme>://share?text=…&file=<cache-relative>…` — so the share is never lost and the JS
35
+ * contract is unchanged (see runtime/app/shell/handlers-share-target.ts).
36
+ *
37
+ * Build-time tokens (stamped by `appwrap init`/`sync`): __URL_SCHEME__, __SHARE_SYNC_B64__.
38
+ */
39
+ class AppwrapShareActivity : Activity() {
40
+ private val syncB64 = "__SHARE_SYNC_B64__"
41
+ private val urlScheme = "__URL_SCHEME__"
42
+
43
+ override fun onCreate(savedInstanceState: Bundle?) {
44
+ super.onCreate(savedInstanceState)
45
+ try {
46
+ handleShare()
47
+ } catch (e: Exception) {
48
+ android.util.Log.w("AppwrapShare", "share handling failed", e)
49
+ finish()
50
+ }
51
+ }
52
+
53
+ private fun handleShare() {
54
+ val action = intent?.action
55
+ if (action != Intent.ACTION_SEND && action != Intent.ACTION_SEND_MULTIPLE) { finish(); return }
56
+ val text = intent.getStringExtra(Intent.EXTRA_TEXT) ?: ""
57
+ val title = intent.getStringExtra(Intent.EXTRA_SUBJECT) ?: ""
58
+ val uris = streamUris(action)
59
+ if (text.isEmpty() && uris.isEmpty()) { finish(); return }
60
+
61
+ val plan = directSyncPlan(text, uris)
62
+ if (plan == null) {
63
+ android.util.Log.i("AppwrapShare", "direct sync unavailable (no config/context/resolvable url or multi-image) — falling back to app launch")
64
+ fallbackToApp(text, title, uris); return
65
+ }
66
+ android.util.Log.i("AppwrapShare", "direct sync → ${plan.method} ${plan.url} (append=${plan.append}, image=${plan.imageDataUrl != null})")
67
+ Toast.makeText(applicationContext, "Syncing…", Toast.LENGTH_SHORT).show()
68
+ Thread {
69
+ val ok = runDirectSync(plan)
70
+ runOnUiThread {
71
+ if (ok) {
72
+ Toast.makeText(applicationContext, "✓ ${plan.successText}", Toast.LENGTH_SHORT).show()
73
+ finish()
74
+ } else {
75
+ fallbackToApp(text, title, uris) // never lose a share
76
+ }
77
+ }
78
+ }.start()
79
+ }
80
+
81
+ private fun streamUris(action: String): List<Uri> = try {
82
+ if (action == Intent.ACTION_SEND_MULTIPLE) {
83
+ @Suppress("DEPRECATION")
84
+ intent.getParcelableArrayListExtra<Uri>(Intent.EXTRA_STREAM)?.filterNotNull() ?: emptyList()
85
+ } else {
86
+ @Suppress("DEPRECATION")
87
+ (intent.getParcelableExtra(Intent.EXTRA_STREAM) as? Uri)?.let { listOf(it) } ?: emptyList()
88
+ }
89
+ } catch (e: Exception) {
90
+ android.util.Log.w("AppwrapShare", "stream extras read failed", e)
91
+ emptyList()
92
+ }
93
+
94
+ // ── direct sync ─────────────────────────────────────────────────────────
95
+
96
+ private class SyncPlan(
97
+ val url: String, val method: String, val textField: String, val imageField: String,
98
+ val append: Boolean, val text: String, val imageDataUrl: String?, val successText: String,
99
+ )
100
+
101
+ /** Decide whether THIS share can direct-sync: config stamped, context published, url template
102
+ * fully resolved, ≤1 image, image under the size cap (after downscale). null → fallback. */
103
+ private fun directSyncPlan(text: String, uris: List<Uri>): SyncPlan? {
104
+ if (syncB64.isEmpty()) return null
105
+ if (uris.size > 1) return null // multi-image keeps full fidelity via the app
106
+ val cfg = try { JSONObject(String(Base64.decode(syncB64, Base64.DEFAULT), Charsets.UTF_8)) }
107
+ catch (e: Exception) { return null }
108
+ val ctx = shareContext() ?: return null
109
+ val url = resolveTemplate(cfg.optString("urlTemplate"), ctx) ?: return null
110
+ if (!url.startsWith("http")) return null
111
+
112
+ var imageDataUrl: String? = null
113
+ if (uris.isNotEmpty()) {
114
+ imageDataUrl = encodeImage(
115
+ uris[0],
116
+ cfg.optInt("maxImageBytes", 4_000_000),
117
+ cfg.optInt("maxImageEdge", 2000),
118
+ cfg.optDouble("jpegQuality", 0.85),
119
+ ) ?: return null // undecodable / still oversized → fallback keeps the ORIGINAL bytes
120
+ }
121
+ val fields = cfg.optJSONObject("fields")
122
+ return SyncPlan(
123
+ url = url,
124
+ method = cfg.optString("method", "PUT").ifEmpty { "PUT" },
125
+ textField = fields?.optString("text").takeUnless { it.isNullOrEmpty() } ?: "content",
126
+ imageField = fields?.optString("image").takeUnless { it.isNullOrEmpty() } ?: "image",
127
+ append = cfg.optString("merge") == "append",
128
+ text = text,
129
+ imageDataUrl = imageDataUrl,
130
+ successText = resolveTemplate(cfg.optString("successMessage").ifEmpty { "Synced" }, ctx) ?: "Synced",
131
+ )
132
+ }
133
+
134
+ /** The app-published share context (SharedPreferences `appwrap-share`/`context`, JSON KV —
135
+ * written by the shell's `shareTarget.setContext` handler). Values stringified for templates. */
136
+ private fun shareContext(): Map<String, String>? = try {
137
+ val raw = getSharedPreferences("appwrap-share", MODE_PRIVATE).getString("context", null) ?: return null
138
+ val obj = JSONObject(raw)
139
+ val out = mutableMapOf<String, String>()
140
+ for (k in obj.keys()) out[k] = obj.get(k).toString()
141
+ out
142
+ } catch (e: Exception) { null }
143
+
144
+ /** Replace `{key}` placeholders; null when any stays unresolved — a partially-resolved URL must
145
+ * never be called. */
146
+ private fun resolveTemplate(template: String, ctx: Map<String, String>): String? {
147
+ if (template.isEmpty()) return null
148
+ var s = template
149
+ for ((k, v) in ctx) s = s.replace("{$k}", v)
150
+ return if (Regex("\\{[^}]+\\}").containsMatchIn(s)) null else s
151
+ }
152
+
153
+ /** Shared image → base64 data URL under `maxBytes`; oversized inputs are downscaled (longest
154
+ * edge → maxEdge px, JPEG at quality) via inSampleSize decode (no full-size bitmap → no OOM).
155
+ * null on decode failure or still-over-cap. */
156
+ private fun encodeImage(uri: Uri, maxBytes: Int, maxEdge: Int, quality: Double): String? {
157
+ try {
158
+ val raw = contentResolver.openInputStream(uri)?.use { it.readBytes() } ?: return null
159
+ val asIs = "data:${sniffMime(raw)};base64,${Base64.encodeToString(raw, Base64.NO_WRAP)}"
160
+ if (asIs.length <= maxBytes) return asIs
161
+ val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
162
+ BitmapFactory.decodeByteArray(raw, 0, raw.size, bounds)
163
+ if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null
164
+ val longest = maxOf(bounds.outWidth, bounds.outHeight)
165
+ var sample = 1
166
+ while (longest / (sample * 2) >= maxEdge) sample *= 2 // power-of-2 pre-scale keeps memory bounded
167
+ val opts = BitmapFactory.Options().apply { inSampleSize = sample }
168
+ var bmp = BitmapFactory.decodeByteArray(raw, 0, raw.size, opts) ?: return null
169
+ val edge = maxOf(bmp.width, bmp.height)
170
+ if (edge > maxEdge) { // exact final scale (inSampleSize only gets within 2x)
171
+ val scale = maxEdge.toFloat() / edge
172
+ val scaled = Bitmap.createScaledBitmap(
173
+ bmp, maxOf(1, (bmp.width * scale).toInt()), maxOf(1, (bmp.height * scale).toInt()), true
174
+ )
175
+ if (scaled !== bmp) bmp.recycle()
176
+ bmp = scaled
177
+ }
178
+ val out = ByteArrayOutputStream()
179
+ bmp.compress(Bitmap.CompressFormat.JPEG, (quality * 100).toInt().coerceIn(1, 100), out)
180
+ bmp.recycle()
181
+ val small = "data:image/jpeg;base64,${Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)}"
182
+ return if (small.length <= maxBytes) small else null
183
+ } catch (e: Exception) {
184
+ android.util.Log.w("AppwrapShare", "image encode failed", e)
185
+ return null
186
+ }
187
+ }
188
+
189
+ private fun sniffMime(d: ByteArray): String = when {
190
+ d.size > 3 && d[0] == 0x89.toByte() && d[1] == 0x50.toByte() -> "image/png"
191
+ d.size > 1 && d[0] == 0xFF.toByte() && d[1] == 0xD8.toByte() -> "image/jpeg"
192
+ d.size > 2 && d[0] == 0x47.toByte() && d[1] == 0x49.toByte() -> "image/gif"
193
+ d.size > 11 && d[8] == 0x57.toByte() && d[9] == 0x45.toByte() -> "image/webp"
194
+ else -> "image/jpeg"
195
+ }
196
+
197
+ /** Execute the sync on a background thread: (append) GET current → merge → write; else write
198
+ * as-is. Mirrors ShareViewController.runDirectSync. Returns success. */
199
+ private fun runDirectSync(plan: SyncPlan): Boolean = try {
200
+ val existing: JSONObject = if (plan.append) {
201
+ val (code, body) = http("GET", plan.url, null)
202
+ android.util.Log.i("AppwrapShare", "direct sync merge GET → HTTP $code")
203
+ when {
204
+ code == 404 -> JSONObject() // nothing there yet — first write
205
+ code in 200..299 -> try { JSONObject(body) } catch (e: Exception) { JSONObject() }
206
+ else -> return false // offline / server error → fallback
207
+ }
208
+ } else JSONObject()
209
+
210
+ val oldText = existing.optString(plan.textField)
211
+ val merged = if (plan.append && oldText.isNotEmpty() && plan.text.isNotEmpty()) "$oldText\n${plan.text}"
212
+ else plan.text.ifEmpty { oldText }
213
+ val body = JSONObject().put(plan.textField, merged)
214
+ // New image replaces; append mode preserves the existing one (the endpoint is a full PUT).
215
+ when {
216
+ plan.imageDataUrl != null -> body.put(plan.imageField, plan.imageDataUrl)
217
+ plan.append && existing.has(plan.imageField) -> body.put(plan.imageField, existing.get(plan.imageField))
218
+ else -> body.put(plan.imageField, JSONObject.NULL)
219
+ }
220
+ val (code, _) = http(plan.method, plan.url, body.toString())
221
+ android.util.Log.i("AppwrapShare", "direct sync write → HTTP $code")
222
+ code in 200..299
223
+ } catch (e: Exception) {
224
+ android.util.Log.w("AppwrapShare", "direct sync failed", e)
225
+ false
226
+ }
227
+
228
+ private fun http(method: String, url: String, jsonBody: String?): Pair<Int, String> {
229
+ val conn = URL(url).openConnection() as HttpURLConnection
230
+ try {
231
+ conn.requestMethod = method
232
+ conn.connectTimeout = 15000
233
+ conn.readTimeout = 15000
234
+ if (jsonBody != null) {
235
+ conn.doOutput = true
236
+ conn.setRequestProperty("Content-Type", "application/json")
237
+ conn.outputStream.use { it.write(jsonBody.toByteArray(Charsets.UTF_8)) }
238
+ }
239
+ val code = conn.responseCode
240
+ val stream = if (code in 200..299) conn.inputStream else conn.errorStream
241
+ val body = stream?.use { it.readBytes().toString(Charsets.UTF_8) } ?: ""
242
+ return Pair(code, body)
243
+ } finally {
244
+ conn.disconnect()
245
+ }
246
+ }
247
+
248
+ // ── fallback: hand the share to the full app via the existing deep-link path ──
249
+
250
+ /** Launch the main activity with `<scheme>://share?…` (the pre-direct-sync contract): text/title
251
+ * in the URL, streams copied into `<cacheDir>/appwrap-share/` as cache-relative `file=` params —
252
+ * the same shape handlers-share-target.ts produced when the SEND filter sat on the main activity. */
253
+ private fun fallbackToApp(text: String, title: String, uris: List<Uri>) {
254
+ try {
255
+ val params = mutableListOf<String>()
256
+ if (text.isNotEmpty()) params.add("text=${Uri.encode(text)}")
257
+ if (title.isNotEmpty()) params.add("title=${Uri.encode(title)}")
258
+ for (rel in copyStreamsToCache(uris)) params.add("file=${Uri.encode(rel)}")
259
+ if (params.isNotEmpty() && urlScheme.isNotEmpty()) {
260
+ val open = Intent(Intent.ACTION_VIEW, Uri.parse("$urlScheme://share?${params.joinToString("&")}"))
261
+ open.setPackage(packageName)
262
+ open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
263
+ startActivity(open)
264
+ }
265
+ } catch (e: Exception) {
266
+ android.util.Log.w("AppwrapShare", "fallback launch failed", e)
267
+ }
268
+ finish()
269
+ }
270
+
271
+ /** Copy each content:// stream into `<cacheDir>/appwrap-share/`; return cache-relative paths. */
272
+ private fun copyStreamsToCache(uris: List<Uri>): List<String> {
273
+ val out = mutableListOf<String>()
274
+ if (uris.isEmpty()) return out
275
+ val dir = File(cacheDir, "appwrap-share").apply { mkdirs() }
276
+ uris.forEachIndexed { i, uri ->
277
+ try {
278
+ val name = fileNameFor(uri, i)
279
+ contentResolver.openInputStream(uri)?.use { input ->
280
+ File(dir, name).outputStream().use { input.copyTo(it) }
281
+ out.add("appwrap-share/$name")
282
+ }
283
+ } catch (e: Exception) {
284
+ android.util.Log.w("AppwrapShare", "stream copy failed", e)
285
+ }
286
+ }
287
+ return out
288
+ }
289
+
290
+ /** Display name when the provider offers one, else mime-derived; sanitized + timestamp-prefixed
291
+ * so repeated shares don't collide (mirrors handlers-share-target.ts fileNameFor). */
292
+ private fun fileNameFor(uri: Uri, i: Int): String {
293
+ var name = ""
294
+ try {
295
+ contentResolver.query(uri, null, null, null, null)?.use { c ->
296
+ val idx = c.getColumnIndex("_display_name")
297
+ if (idx >= 0 && c.moveToFirst()) name = c.getString(idx) ?: ""
298
+ }
299
+ } catch (e: Exception) { /* fall through to mime-derived name */ }
300
+ if (name.isEmpty()) {
301
+ val mime = contentResolver.getType(uri) ?: ""
302
+ val ext = if (mime.startsWith("image/")) mime.substring(6).replace(Regex("[^a-zA-Z0-9]"), "").ifEmpty { "img" } else "bin"
303
+ name = "shared-$i.$ext"
304
+ }
305
+ return "${System.currentTimeMillis()}-$i-${name.replace(Regex("[^\\w.-]"), "_")}"
306
+ }
307
+ }