From 54344bd1d34158fd87419979c944f7d0afc3c978 Mon Sep 17 00:00:00 2001 From: 2ro <17595647+2ro@users.noreply.github.com> Date: Thu, 2 Jul 2026 22:19:23 -0400 Subject: [PATCH] android: one-shot payment-requested notification (id=3) Fire a separate high-importance system notification when an incoming payment request (Invoice1 -> SurfaceRequest) is ingested over nostr, mirroring the existing received-payment notification (id=2). Fail-open on a missing JNI handle; fires once per not-yet-seen slate. No-op off Android. Also add examples/tunnel_measure.rs, a dev harness for measuring the Nym read tunnel (cold connect + warm per-fetch latency over the real transport). --- .../mw/gri/android/BackgroundService.java | 38 +++++++ .../java/mw/gri/android/MainActivity.java | 6 + examples/tunnel_measure.rs | 105 ++++++++++++++++++ src/gui/platform/android/mod.rs | 41 +++++++ src/lib.rs | 16 +++ src/nostr/client.rs | 13 +++ 6 files changed, 219 insertions(+) create mode 100644 examples/tunnel_measure.rs diff --git a/android/app/src/main/java/mw/gri/android/BackgroundService.java b/android/app/src/main/java/mw/gri/android/BackgroundService.java index 3cf557d..51b6d07 100644 --- a/android/app/src/main/java/mw/gri/android/BackgroundService.java +++ b/android/app/src/main/java/mw/gri/android/BackgroundService.java @@ -27,6 +27,10 @@ public class BackgroundService extends Service { // sync notification above. private static final int PAYMENT_NOTIFICATION_ID = 2; private static final String PAYMENT_CHANNEL_ID = "PaymentReceived"; + // One-shot "payment requested" notification (someone asking us to pay them), + // separate from both the sync (id=1) and received-payment (id=2) notifications. + private static final int REQUEST_NOTIFICATION_ID = 3; + private static final String REQUEST_CHANNEL_ID = "PaymentRequested"; private NotificationCompat.Builder mNotificationBuilder; private String mNotificationContentText = ""; @@ -228,6 +232,40 @@ public class BackgroundService extends Service { } } + // Show a one-shot "payment requested" notification (id=3), separate from both + // the persistent sync notification (id=1) and the received-payment one (id=2). + // Called from native code via MainActivity when a payment request (Invoice1) + // arrives over nostr, possibly while the app is backgrounded. Mirrors + // notifyPaymentReceived; strings are composed here Java-side. + public static void notifyPaymentRequested(Context context, String name, String amount) { + NotificationManager manager = context.getSystemService(NotificationManager.class); + if (manager == null) { + return; + } + // High-importance channel so the notification pops with sound + vibration. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + REQUEST_CHANNEL_ID, "Payment requests", NotificationManager.IMPORTANCE_HIGH + ); + manager.createNotificationChannel(channel); + } + Intent i = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); + PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, i, PendingIntent.FLAG_IMMUTABLE); + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, REQUEST_CHANNEL_ID) + .setContentTitle("Payment requested") + .setContentText(name + " requested " + amount + " ツ") + .setSmallIcon(R.drawable.ic_stat_name) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setDefaults(NotificationCompat.DEFAULT_ALL) + .setContentIntent(pendingIntent); + try { + manager.notify(REQUEST_NOTIFICATION_ID, builder.build()); + } catch (SecurityException e) { + // POST_NOTIFICATIONS not granted: skip the notification, never the request. + } + } + // Start the service. public static void start(Context c) { if (!isServiceRunning(c)) { diff --git a/android/app/src/main/java/mw/gri/android/MainActivity.java b/android/app/src/main/java/mw/gri/android/MainActivity.java index f5ac7a8..9aef4b5 100644 --- a/android/app/src/main/java/mw/gri/android/MainActivity.java +++ b/android/app/src/main/java/mw/gri/android/MainActivity.java @@ -427,6 +427,12 @@ public class MainActivity extends GameActivity { BackgroundService.notifyPaymentReceived(this, name, amount); } + // Called from native code to show a "payment requested" notification + // (BackgroundService id=3) when a payment request arrives over nostr. + public void notifyPaymentRequested(String name, String amount) { + BackgroundService.notifyPaymentRequested(this, name, amount); + } + // Called from native code to set text into clipboard. public void copyText(String data) { ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE); diff --git a/examples/tunnel_measure.rs b/examples/tunnel_measure.rs new file mode 100644 index 0000000..a2457da --- /dev/null +++ b/examples/tunnel_measure.rs @@ -0,0 +1,105 @@ +// Local network measurement for the Nym read tunnel. Uses the wallet's REAL +// transport (warm_up + tuned tunnel + reselect + DNS cache + HTTP keep-alive +// pool), then fetches the live price API over the mixnet on a fixed interval +// so we can see (a) cold connect time, (b) whether the connection stays warm, +// (c) per-fetch latency over time. +// +// cargo run --release --example tunnel_measure -- [interval_secs] +// +// e.g. `-- 300` (5 min) or `-- 600 15` (10 min, every 15s). + +use std::time::{Duration, Instant}; + +const PRICE_URL: &str = "https://api.coingecko.com/api/v3/simple/price?ids=grin&vs_currencies=usd"; + +#[tokio::main] +async fn main() { + let _ = rustls::crypto::ring::default_provider().install_default(); + + let args: Vec = std::env::args().collect(); + let total_secs: u64 = args.get(1).and_then(|s| s.parse().ok()).unwrap_or(300); + let interval_secs: u64 = args.get(2).and_then(|s| s.parse().ok()).unwrap_or(15); + + let run_start = Instant::now(); + println!("[t=0.0s] warm_up(): starting the tunnel"); + grim::nym::warm_up(); + + // Cold connect time: poll is_ready(). + let mut connect_ms = None; + let t_connect = Instant::now(); + while t_connect.elapsed() < Duration::from_secs(120) { + if grim::nym::is_ready() { + connect_ms = Some(t_connect.elapsed().as_millis()); + break; + } + tokio::time::sleep(Duration::from_millis(200)).await; + } + match connect_ms { + Some(ms) => println!( + "[t={:.1}s] TUNNEL READY (cold connect {} ms)", + run_start.elapsed().as_secs_f64(), + ms + ), + None => { + println!("tunnel never became ready in 120s; aborting"); + return; + } + } + + // Warm-loop: fetch price over the mixnet every interval, record latency. + let mut lats: Vec = vec![]; + let mut fails = 0u32; + let deadline = run_start + Duration::from_secs(total_secs); + let mut n = 0u32; + while Instant::now() < deadline { + n += 1; + let t = Instant::now(); + let ok = grim::nym::http_request("GET", PRICE_URL.to_string(), None, vec![]).await; + let ms = t.elapsed().as_millis(); + match ok { + Some(body) if body.contains("grin") => { + lats.push(ms); + println!( + "[t={:.1}s] fetch #{n}: {} ms ready={}", + run_start.elapsed().as_secs_f64(), + ms, + grim::nym::is_ready() + ); + } + other => { + fails += 1; + println!( + "[t={:.1}s] fetch #{n}: FAIL after {} ms (ready={}, body={:?})", + run_start.elapsed().as_secs_f64(), + ms, + grim::nym::is_ready(), + other.map(|b| b.chars().take(40).collect::()) + ); + } + } + tokio::time::sleep(Duration::from_secs(interval_secs)).await; + } + + // Summary. + lats.sort_unstable(); + let n_ok = lats.len(); + let sum: u128 = lats.iter().sum(); + let median = lats.get(n_ok / 2).copied().unwrap_or(0); + println!( + "\n==== SUMMARY ({}s run, {}s interval) ====", + total_secs, interval_secs + ); + println!("cold connect: {} ms", connect_ms.unwrap()); + println!("fetches: {} ok, {} failed", n_ok, fails); + if n_ok > 0 { + println!( + "warm fetch latency ms: min {} / median {} / max {} / mean {}", + lats.first().unwrap(), + median, + lats.last().unwrap(), + sum / n_ok as u128 + ); + let head: Vec = lats.iter().take(3).copied().collect(); + println!("(sorted sample) fastest 3: {:?}", head); + } +} diff --git a/src/gui/platform/android/mod.rs b/src/gui/platform/android/mod.rs index 35807ea..aa41088 100644 --- a/src/gui/platform/android/mod.rs +++ b/src/gui/platform/android/mod.rs @@ -317,6 +317,47 @@ pub fn notify_payment_received(name: &str, amount: &str) { ); } +/// Show the one-shot "payment requested" system notification (Java side +/// `BackgroundService.notifyPaymentRequested`, id=3, separate from both the +/// persistent sync notification id=1 and the received-payment one id=2). Called +/// by the nostr service when a payment request (Invoice1) is ingested from a +/// non-GUI thread, hence the stored [`AndroidApp`] handle instead of a platform +/// reference. Fail-open: a missing handle or JNI error just skips the +/// notification, never the request. Mirrors [`notify_payment_received`]. +pub fn notify_payment_requested(name: &str, amount: &str) { + let app = { + let r_app = ANDROID_APP.read(); + r_app.clone() + }; + let Some(app) = app else { + return; + }; + let platform = Android { + android_app: app, + ctx: Arc::new(RwLock::new(None)), + }; + let Ok(vm) = (unsafe { jni::JavaVM::from_raw(platform.android_app.vm_as_ptr() as _) }) else { + return; + }; + let Ok(env) = vm.attach_current_thread() else { + return; + }; + let Ok(j_name) = env.new_string(name) else { + return; + }; + let Ok(j_amount) = env.new_string(amount) else { + return; + }; + let _ = platform.call_java_method( + "notifyPaymentRequested", + "(Ljava/lang/String;Ljava/lang/String;)V", + &[ + JValue::Object(&JObject::from(j_name)), + JValue::Object(&JObject::from(j_amount)), + ], + ); +} + /// Callback from Java code with last entered character from soft keyboard. #[allow(non_snake_case)] #[unsafe(no_mangle)] diff --git a/src/lib.rs b/src/lib.rs index 662d3d4..83f4077 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -425,6 +425,22 @@ pub fn notify_payment_received(name: &str, amount: &str) { } } +/// Fire the platform "payment requested" notification with the requester's +/// display name and human-readable amount, for an incoming payment request +/// (someone asking us to pay them). Android shows a one-shot system +/// notification (`BackgroundService.notifyPaymentRequested`, id=3, separate from +/// both the persistent sync notification id=1 and the received-payment one +/// id=2); other platforms are a no-op. Crate-root so the nostr service can reach +/// it without holding a platform reference. Mirrors [`notify_payment_received`]. +pub fn notify_payment_requested(name: &str, amount: &str) { + #[cfg(target_os = "android")] + gui::platform::notify_payment_requested(name, amount); + #[cfg(not(target_os = "android"))] + { + let _ = (name, amount); + } +} + lazy_static! { /// Data provided from deeplink or opened file. pub static ref INCOMING_DATA: Arc>> = Arc::new(RwLock::new(None)); diff --git a/src/nostr/client.rs b/src/nostr/client.rs index d8f5209..3a3a778 100644 --- a/src/nostr/client.rs +++ b/src/nostr/client.rs @@ -1707,6 +1707,19 @@ async fn handle_wrap(svc: &Arc, wallet: &Wallet, event: Event) { svc.store.mark_processed(&wrap_id); svc.store.mark_processed(&rumor_id); svc.store.mark_processed(&slate_marker); + // "Payment requested" system notification (Android; no-op on + // desktop): only for a genuine incoming request (Invoice1 → + // SurfaceRequest, someone asking us to pay them), not a payment + // pending approval (SurfaceIncoming). Fires exactly once — this + // branch is reached only for a not-yet-seen slate (slate-level + // dedupe above + decide() drops already-known slates), mirroring the + // received-payment notification's dedup. Requester's display name + // (or short npub) and the human-readable amount, with the ツ mark. + if decision == IngestDecision::SurfaceRequest { + let name = crate::gui::views::goblin::data::contact_title(&svc.store, &sender_hex); + let amount = amount_to_hr_string(slate.amount, true); + crate::notify_payment_requested(&name, &amount); + } } IngestDecision::FinalizePost => { // The payer's reply is our first contact with their key on this side of