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",
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
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)}`;
|
||||
}
|
||||
|
||||
/** 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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue