fix(android): native intent hand-off so Amber actually opens for signing (1.2.8)
Amber never appeared for delete/accept-invite/zap because Capacitor's default WebView silently drops JS-initiated intent:/nostrsigner: navigations — the request that opens Amber never reached the OS. Login worked only because it uses the plugin's native startActivityForResult. Add a custom MainActivity (android-templates/MainActivity.java) that overrides shouldOverrideUrlLoading to launch intent:/nostrsigner: URLs via startActivity() — a real native launch that opens Amber on GrapheneOS, matching login. Injected into the generated android/ project at build time by inject-main-activity.mjs, wired into android:build/sync/init. Simplify the JS launcher to navigate the WebView (now intercepted natively). Bump to 1.2.8. Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
6030c76256
commit
fea9ab3091
5 changed files with 191 additions and 33 deletions
101
android-templates/MainActivity.java
Normal file
101
android-templates/MainActivity.java
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
package org.skatespots.app;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.util.Log;
|
||||||
|
import android.webkit.WebView;
|
||||||
|
import android.webkit.WebViewClient;
|
||||||
|
|
||||||
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom MainActivity that lets the WebView hand OFF `intent:` / `nostrsigner:`
|
||||||
|
* URLs to the Android OS so Amber's approval screen actually opens.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS
|
||||||
|
* ───────────────
|
||||||
|
* When the JS signer navigates to an `intent:...;scheme=nostrsigner;...;end`
|
||||||
|
* URL, Capacitor's default WebView treats it as a page navigation and silently
|
||||||
|
* DROPS it — so Amber never launches and every signing action (delete, accept
|
||||||
|
* crew invite, zap, profile edit) hangs forever. Login works only because it
|
||||||
|
* goes through the native plugin's startActivityForResult; JS-initiated intents
|
||||||
|
* were never reaching the OS. On GrapheneOS this is enforced strictly.
|
||||||
|
*
|
||||||
|
* The fix: override shouldOverrideUrlLoading. Whenever the WebView tries to load
|
||||||
|
* an `intent:` or `nostrsigner:` URL, we parse it and call startActivity()
|
||||||
|
* ourselves — a REAL native launch that opens Amber, exactly like login. The
|
||||||
|
* result comes back via the app's custom-scheme deep link
|
||||||
|
* (org.skatespots.app://amber-callback...), handled by DeepLinkHandler /
|
||||||
|
* resolveAmberIntent in the web layer.
|
||||||
|
*
|
||||||
|
* This file is injected into the generated android/ project at build time by
|
||||||
|
* scripts/inject-main-activity.mjs (android/ is not committed).
|
||||||
|
*/
|
||||||
|
public class MainActivity extends BridgeActivity {
|
||||||
|
|
||||||
|
private static final String TAG = "SkateSpotsAmber";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onCreate(Bundle savedInstanceState) {
|
||||||
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
|
final WebView webView = getBridge().getWebView();
|
||||||
|
if (webView == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final WebViewClient existing = new WebViewClient();
|
||||||
|
|
||||||
|
webView.setWebViewClient(new WebViewClient() {
|
||||||
|
@Override
|
||||||
|
public boolean shouldOverrideUrlLoading(WebView view, String url) {
|
||||||
|
if (handleExternalScheme(url)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.shouldOverrideUrlLoading(view, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean shouldOverrideUrlLoading(WebView view, android.webkit.WebResourceRequest request) {
|
||||||
|
final String url = request.getUrl() != null ? request.getUrl().toString() : null;
|
||||||
|
if (url != null && handleExternalScheme(url)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return super.shouldOverrideUrlLoading(view, request);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* If the URL is an Amber/signer intent, launch it natively and return true.
|
||||||
|
* Returns false for normal in-app URLs so Capacitor handles them as usual.
|
||||||
|
*/
|
||||||
|
private boolean handleExternalScheme(String url) {
|
||||||
|
if (url == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (url.startsWith("intent:")) {
|
||||||
|
// Parse the full Android intent: URI (carries scheme, extras, end).
|
||||||
|
Intent intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
startActivity(intent);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.startsWith("nostrsigner:")) {
|
||||||
|
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
|
||||||
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
|
startActivity(intent);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
Log.e(TAG, "Failed to launch external signer intent: " + e.getMessage(), e);
|
||||||
|
// Fall through — let the WebView try to handle it (will no-op safely).
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
{
|
{
|
||||||
"name": "skate-spots",
|
"name": "skate-spots",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.2.7",
|
"version": "1.2.8",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"homepage": "https://skate-spots.com",
|
"homepage": "https://skate-spots.com",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|
@ -9,11 +9,11 @@
|
||||||
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
"build": "npm i --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'Project built successfully!'",
|
||||||
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
"test": "npm i --silent && tsc --noEmit && eslint && vitest run --reporter=dot --silent && vite build -l error && cp dist/index.html dist/404.html && echo 'All tests passed!'",
|
||||||
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
|
"deploy": "npm i --silent && npm run build && npx -y nostr-deploy-cli deploy --skip-setup",
|
||||||
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs",
|
"android:init": "npm run android:build:apk && npx cap add android && npx cap sync android && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs",
|
||||||
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs",
|
"android:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs",
|
||||||
"android:open": "npx cap open android",
|
"android:open": "npx cap open android",
|
||||||
"android:version": "node scripts/sync-android-version.mjs",
|
"android:version": "node scripts/sync-android-version.mjs",
|
||||||
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && cd android && ./gradlew assembleRelease",
|
"android:build": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && node scripts/inject-amber-queries.mjs && node scripts/inject-main-activity.mjs && cd android && ./gradlew assembleRelease",
|
||||||
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html"
|
"android:build:apk": "CAPACITOR_BUILD=1 vite build && sed -i 's|<script type=\"module\" src=\"/shakespeare_tailwind[^\"]*\\.js\"></script>||g' dist/index.html && sed -i 's|<style type=\"text/tailwindcss\">|<style>|g' dist/index.html && cp dist/index.html dist/404.html"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,20 @@
|
||||||
v1.2.7
|
v1.2.8
|
||||||
|
|
||||||
🛠️ Amber finally pops up — signing works
|
🛠️ Amber finally pops up — signing works (the real fix)
|
||||||
The big one. On some Android setups (notably GrapheneOS), tapping delete,
|
The big one. On some Android setups (notably GrapheneOS), tapping delete,
|
||||||
accepting a crew invite, sending a zap, or editing your profile would spin
|
accepting a crew invite, sending a zap, or editing your profile would spin
|
||||||
forever and Amber never appeared. Under the hood the app was asking Amber
|
forever and Amber never appeared.
|
||||||
through a silent background channel that stalls on locked-down devices.
|
|
||||||
|
|
||||||
Now every signing and encryption action launches Amber's approval screen
|
Root cause: the app's browser layer was silently dropping the request that
|
||||||
directly (the same reliable path login already used) and hands the result
|
opens Amber, so the approval screen never launched. We added a native hand-off
|
||||||
straight back to the app — so the action completes instead of hanging. If
|
so signing requests now open Amber directly — exactly like login already does.
|
||||||
Amber genuinely doesn't respond, you get a clear "open Amber and approve"
|
|
||||||
message in seconds instead of an endless spinner.
|
|
||||||
|
|
||||||
IMPORTANT: after updating, log OUT and log back IN once so Amber re-grants
|
Now every signing/encryption action launches Amber, you approve, and the
|
||||||
the signing permission. Then delete/accept/zap will just work.
|
action completes. If Amber genuinely doesn't respond you get a clear message
|
||||||
|
in seconds instead of an endless spinner.
|
||||||
|
|
||||||
|
IMPORTANT: after updating, log OUT and log back IN once. Then delete, accept
|
||||||
|
invite, zap, and profile edits will just work.
|
||||||
|
|
||||||
🔔 Notification bell "Mark read" actually clears the badge
|
🔔 Notification bell "Mark read" actually clears the badge
|
||||||
Marking notifications read now clears the count for crew items too, while
|
Marking notifications read now clears the count for crew items too, while
|
||||||
|
|
|
||||||
59
scripts/inject-main-activity.mjs
Normal file
59
scripts/inject-main-activity.mjs
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Inject the custom MainActivity.java into the generated Android project.
|
||||||
|
*
|
||||||
|
* WHY THIS EXISTS
|
||||||
|
* ───────────────
|
||||||
|
* Capacitor's default MainActivity uses a WebView that silently DROPS
|
||||||
|
* `intent:` / `nostrsigner:` URL navigations, so Amber's approval screen never
|
||||||
|
* opens and every signing action (delete, accept crew invite, zap, profile
|
||||||
|
* edit) hangs forever. Our MainActivity overrides shouldOverrideUrlLoading to
|
||||||
|
* launch those intents natively via startActivity() — the real fix that opens
|
||||||
|
* Amber, matching how login already works.
|
||||||
|
*
|
||||||
|
* The android/ folder is generated per-machine (not committed), so we keep the
|
||||||
|
* source of truth in android-templates/MainActivity.java and copy it in on
|
||||||
|
* every build. Idempotent. Exits quietly if android/ hasn't been generated yet.
|
||||||
|
*/
|
||||||
|
import { readFileSync, writeFileSync, existsSync, copyFileSync } from 'node:fs';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { dirname, join } from 'node:path';
|
||||||
|
|
||||||
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
|
// Package path org.skatespots.app → org/skatespots/app
|
||||||
|
const target = join(
|
||||||
|
root,
|
||||||
|
'android',
|
||||||
|
'app',
|
||||||
|
'src',
|
||||||
|
'main',
|
||||||
|
'java',
|
||||||
|
'org',
|
||||||
|
'skatespots',
|
||||||
|
'app',
|
||||||
|
'MainActivity.java',
|
||||||
|
);
|
||||||
|
|
||||||
|
const template = join(root, 'android-templates', 'MainActivity.java');
|
||||||
|
|
||||||
|
if (!existsSync(target)) {
|
||||||
|
console.log('[inject-main-activity] android/ not generated yet — skipping.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(template)) {
|
||||||
|
console.error('[inject-main-activity] template android-templates/MainActivity.java missing — aborting.');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = readFileSync(target, 'utf8');
|
||||||
|
const desired = readFileSync(template, 'utf8');
|
||||||
|
|
||||||
|
if (current === desired) {
|
||||||
|
console.log('[inject-main-activity] MainActivity.java already up to date.');
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
copyFileSync(template, target);
|
||||||
|
console.log('[inject-main-activity] ✓ Installed custom MainActivity.java (intent hand-off for Amber).');
|
||||||
|
|
@ -81,25 +81,22 @@ function makeOpId(): string {
|
||||||
return `si_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
return `si_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Open a URL via the native runtime, falling back to a plain navigation. */
|
/**
|
||||||
|
* Launch an `intent:` URL so Amber opens.
|
||||||
|
*
|
||||||
|
* We navigate the WebView to the intent URL. Our custom native MainActivity
|
||||||
|
* (see android-templates/MainActivity.java) intercepts `intent:` /
|
||||||
|
* `nostrsigner:` navigations in shouldOverrideUrlLoading and hands them to the
|
||||||
|
* OS via startActivity() — a real native launch that opens Amber (the default
|
||||||
|
* Capacitor WebView silently drops these, which is why Amber never appeared).
|
||||||
|
*
|
||||||
|
* We assign to location.href in a microtask-safe way so the surrounding promise
|
||||||
|
* registration (in runIntent) has already stored the pending op before Android
|
||||||
|
* potentially pauses/replaces our WebView.
|
||||||
|
*/
|
||||||
async function openExternalUrl(url: string): Promise<void> {
|
async function openExternalUrl(url: string): Promise<void> {
|
||||||
const cap = (window as unknown as {
|
// Defer one tick so the pending-op registry is populated before we navigate.
|
||||||
Capacitor?: {
|
await Promise.resolve();
|
||||||
isNativePlatform?: () => boolean;
|
|
||||||
Plugins?: { App?: { openUrl?: (o: { url: string }) => Promise<unknown> } };
|
|
||||||
};
|
|
||||||
}).Capacitor;
|
|
||||||
|
|
||||||
const openUrl = cap?.Plugins?.App?.openUrl;
|
|
||||||
if (openUrl) {
|
|
||||||
try {
|
|
||||||
await openUrl({ url });
|
|
||||||
return;
|
|
||||||
} catch {
|
|
||||||
// fall through to location navigation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fallback: assign to location. On Android WebView this still fires the intent.
|
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue