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:
shakespeare.diy 2026-09-04 21:23:38 +07:00
parent 6030c76256
commit fea9ab3091
5 changed files with 191 additions and 33 deletions

View 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;
}
}

View file

@ -1,7 +1,7 @@
{
"name": "skate-spots",
"private": true,
"version": "1.2.7",
"version": "1.2.8",
"type": "module",
"homepage": "https://skate-spots.com",
"scripts": {
@ -9,11 +9,11 @@
"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!'",
"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:sync": "npm run android:build:apk && npx cap sync android && node scripts/sync-android-version.mjs && 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 && node scripts/inject-main-activity.mjs",
"android:open": "npx cap open android",
"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"
},
"dependencies": {

View file

@ -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,
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
through a silent background channel that stalls on locked-down devices.
forever and Amber never appeared.
Now every signing and encryption action launches Amber's approval screen
directly (the same reliable path login already used) and hands the result
straight back to the app — so the action completes instead of hanging. If
Amber genuinely doesn't respond, you get a clear "open Amber and approve"
message in seconds instead of an endless spinner.
Root cause: the app's browser layer was silently dropping the request that
opens Amber, so the approval screen never launched. We added a native hand-off
so signing requests now open Amber directly — exactly like login already does.
IMPORTANT: after updating, log OUT and log back IN once so Amber re-grants
the signing permission. Then delete/accept/zap will just work.
Now every signing/encryption action launches Amber, you approve, and the
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
Marking notifications read now clears the count for crew items too, while

View 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).');

View file

@ -81,25 +81,22 @@ function makeOpId(): string {
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> {
const cap = (window as unknown as {
Capacitor?: {
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.
// Defer one tick so the pending-op registry is populated before we navigate.
await Promise.resolve();
window.location.href = url;
}