1
0
forked from GRIN/grim

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).
This commit is contained in:
2ro
2026-07-02 22:19:23 -04:00
parent 23bb845689
commit 54344bd1d3
6 changed files with 219 additions and 0 deletions
@@ -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)) {
@@ -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);
+105
View File
@@ -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 -- <seconds> [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<String> = 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<u128> = 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::<String>())
);
}
}
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<u128> = lats.iter().take(3).copied().collect();
println!("(sorted sample) fastest 3: {:?}", head);
}
}
+41
View File
@@ -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)]
+16
View File
@@ -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<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
+13
View File
@@ -1707,6 +1707,19 @@ async fn handle_wrap(svc: &Arc<NostrService>, 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