@livx.cc/appwrap 0.58.1 → 0.58.3

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.1",
3
+ "version": "0.58.3",
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",
@@ -14,7 +14,7 @@ android {
14
14
 
15
15
  defaultConfig {
16
16
  minSdkVersion 26 // Android 8.0 — Health Connect's floor (~99% of devices); was 24
17
- targetSdkVersion 35
17
+ targetSdkVersion 36 // Play requires target API 36 (Android 16) from 31 Aug 2026
18
18
 
19
19
  // Version Information
20
20
  versionCode 2
@@ -296,19 +296,33 @@ export const MODULES: ModuleManifest[] = [
296
296
  name: 'shareTarget', group: 'shareTarget', nativeSrc: 'shareTarget',
297
297
  capabilities: { shareTarget: 'native' },
298
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.
299
305
  android: {
300
- manifestActivity:
301
- `<intent-filter>\n` +
302
- `\t\t\t<action android:name="android.intent.action.SEND" />\n` +
303
- `\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
304
- `\t\t\t<data android:mimeType="text/plain" />\n` +
305
- `\t\t\t<data android:mimeType="image/*" />\n` +
306
- `\t\t</intent-filter>\n` +
307
- `\t\t<intent-filter>\n` +
308
- `\t\t\t<action android:name="android.intent.action.SEND_MULTIPLE" />\n` +
309
- `\t\t\t<category android:name="android.intent.category.DEFAULT" />\n` +
310
- `\t\t\t<data android:mimeType="image/*" />\n` +
311
- `\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>`,
312
326
  },
313
327
  },
314
328
 
@@ -127,6 +127,22 @@ class DevCertNavDelegate extends NSObject implements WKNavigationDelegate {
127
127
  // EXPLICITLY — ObjC message forwarding (forwardingTargetForSelector) is unreliable for a @NativeClass,
128
128
  // and a decision method (below) whose `decisionHandler` is never called STALLS the navigation → blank.
129
129
  webViewDecidePolicyForNavigationActionDecisionHandler(webView: WKWebView, navigationAction: any, decisionHandler: (p: number) => void): void {
130
+ // External URL schemes (mailto:/tel:/app deeplinks/…) can't be rendered by the WKWebView —
131
+ // navigating to one in-place silently fails ("unsupported URL"). Mirror the Android shell's
132
+ // shouldOverrideUrlLoading: hand every non-web scheme to the OS and cancel the navigation.
133
+ // http/https/file/about/blob/data (+ empty) stay in-WebView.
134
+ try {
135
+ const url: NSURL | null = navigationAction?.request?.URL ?? null;
136
+ const scheme = url?.scheme?.toLowerCase?.() ?? '';
137
+ // 'app' is the shell's own WKURLSchemeHandler serving the bundled PWA (loader:'app') — in-WebView.
138
+ if (scheme && !['http', 'https', 'file', 'about', 'blob', 'data', 'javascript', 'app'].includes(scheme)) {
139
+ UIApplication.sharedApplication.openURLOptionsCompletionHandler(url!, NSDictionary.new() as any, null);
140
+ decisionHandler(0 /* WKNavigationActionPolicy.Cancel */);
141
+ return;
142
+ }
143
+ } catch (e) {
144
+ console.warn('AppWrap: external-scheme nav check failed', e);
145
+ }
130
146
  if (this.fbResponds('webView:decidePolicyForNavigationAction:decisionHandler:')) {
131
147
  (this.fallback as any).webViewDecidePolicyForNavigationActionDecisionHandler(webView, navigationAction, decisionHandler);
132
148
  } else {
@@ -11,11 +11,14 @@ import { MAILBOX_KEY, drainShareMailbox, type ShareMailboxStore } from './share-
11
11
  const CONTEXT_KEY = 'appwrap-share-context';
12
12
 
13
13
  /**
14
- * shareTarget (INBOUND share sheet) — Android. The manifest's ACTION_SEND(_MULTIPLE) intent-filter
15
- * (stamped by the CLI when the module is active) lands the shared payload on the main activity as a
16
- * SEND intent; this handler reads it (cold launch + warm onNewIntent) and re-delivers through the
17
- * EXISTING deep-link path as a synthetic URL — so it inherits the proven cold-start buffering
18
- * (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.
19
22
  *
20
23
  * URL contract (parsed by kit.shareTarget, also directly consumable via kit.lifecycle.onDeepLink):
21
24
  * <urlScheme>://share?text=<enc>&title=<enc>&file=<enc>&file=<enc>…
@@ -35,15 +38,25 @@ const CONTEXT_KEY = 'appwrap-share-context';
35
38
  * platform-identical.
36
39
  */
37
40
  export function registerShareTargetHandlers(): void {
38
- // Share-context KV (kit.shareTarget.setContext) — generic, app-opaque. Only iOS persists it (the
39
- // AppwrapShare extension is the sole consumer); Android accepts + ignores so a cross-platform web
40
- // app can call it unconditionally without an UNSUPPORTED rejection.
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).
41
44
  bridge.register('shareTarget.setContext', ({ context }: { context?: Record<string, string | number> | null }) => {
42
- if (!isIOS) return;
43
- const d = NSUserDefaults.alloc().initWithSuiteName(`group.${SHELL_CONFIG.appId}`);
44
- if (!d) return;
45
- if (context && typeof context === 'object') d.setObjectForKey(JSON.stringify(context), CONTEXT_KEY);
46
- else d.removeObjectForKey(CONTEXT_KEY);
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();
47
60
  });
48
61
  if (isIOS) {
49
62
  // Rewrite share links carrying App-Group files (gfile=) → cache files (file=). Text-only share
@@ -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
+ }
package/src/cli.ts CHANGED
@@ -691,12 +691,10 @@ function substituteBuildTokens(s: string, tokens: Record<string, string>): strin
691
691
  }
692
692
 
693
693
  /** Substitute build-time tokens in copied module native source (after copyModuleNativeSrc) — applied
694
- * generically to every iOS extension file, so a module's app-agnostic source (e.g. the widget
695
- * extension's Swift/entitlements/plist) gets the app-specific value stamped in. Idempotent (source is
696
- * re-copied verbatim each sync, then re-substituted). No-op when no extensions were copied. */
694
+ * generically to every iOS extension file AND every Android module source file (Kotlin/Java/XML —
695
+ * e.g. shareTarget's AppwrapShareActivity), so a module's app-agnostic source gets the app-specific
696
+ * value stamped in. Idempotent (source is re-copied verbatim each sync, then re-substituted). */
697
697
  function substituteModuleTokens(outDir: string, cfg: AppwrapConfig): void {
698
- const extRoot = join(outDir, 'App_Resources/iOS/extensions');
699
- if (!existsSync(extRoot)) return;
700
698
  const tokens = buildTokens(cfg);
701
699
  let touched = false;
702
700
  const subst = (file: string) => {
@@ -704,15 +702,19 @@ function substituteModuleTokens(outDir: string, cfg: AppwrapConfig): void {
704
702
  const next = substituteBuildTokens(s, tokens);
705
703
  if (next !== s) { writeFileSync(file, next); touched = true; }
706
704
  };
707
- const walk = (dir: string) => {
705
+ const walk = (dir: string, exts: RegExp) => {
706
+ if (!existsSync(dir)) return;
708
707
  for (const e of readdirSync(dir, { withFileTypes: true })) {
709
708
  const p = join(dir, e.name);
710
- if (e.isDirectory()) walk(p);
711
- else if (/\.(swift|entitlements|plist|json)$/.test(e.name)) subst(p);
709
+ if (e.isDirectory()) walk(p, exts);
710
+ else if (exts.test(e.name)) subst(p);
712
711
  }
713
712
  };
714
- walk(extRoot);
715
- if (touched) console.log(` tokn ← __APP_GROUP__ = ${tokens.__APP_GROUP__} (extension source)`);
713
+ walk(join(outDir, 'App_Resources/iOS/extensions'), /\.(swift|entitlements|plist|json)$/);
714
+ // Android module source: only java/ (module-owned code) — NOT the whole src/main (res/ + template
715
+ // manifest have their own stamping and must not see generic token substitution).
716
+ walk(join(outDir, 'App_Resources/Android/src/main/java'), /\.(kt|java|xml)$/);
717
+ if (touched) console.log(` tokn ← __APP_GROUP__ = ${tokens.__APP_GROUP__} (module native source)`);
716
718
  }
717
719
 
718
720
  /** Hosts to register as Android App Links (autoVerify https intent-filters). Explicit