@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 +1 -1
- package/runtime/App_Resources/Android/app.gradle +1 -1
- package/runtime/app/shell/capabilities.manifest.ts +26 -12
- package/runtime/app/shell/custom-webview.ios.ts +16 -0
- package/runtime/app/shell/handlers-share-target.ts +26 -13
- package/runtime/modules-native/shareTarget/App_Resources/Android/src/main/java/cc/appwrap/share/AppwrapShareActivity.kt +307 -0
- package/src/cli.ts +12 -10
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
`\t\t\
|
|
304
|
-
`\t\t\
|
|
305
|
-
`\t\t\
|
|
306
|
-
`\t\t
|
|
307
|
-
`\t\t
|
|
308
|
-
`\t\t\t<
|
|
309
|
-
`\t\t\t<
|
|
310
|
-
`\t\t\t<
|
|
311
|
-
`\t\t
|
|
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
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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.
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
695
|
-
*
|
|
696
|
-
* re-copied verbatim each sync, then re-substituted).
|
|
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 (
|
|
709
|
+
if (e.isDirectory()) walk(p, exts);
|
|
710
|
+
else if (exts.test(e.name)) subst(p);
|
|
712
711
|
}
|
|
713
712
|
};
|
|
714
|
-
walk(
|
|
715
|
-
|
|
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
|