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:
@@ -27,6 +27,10 @@ public class BackgroundService extends Service {
|
|||||||
// sync notification above.
|
// sync notification above.
|
||||||
private static final int PAYMENT_NOTIFICATION_ID = 2;
|
private static final int PAYMENT_NOTIFICATION_ID = 2;
|
||||||
private static final String PAYMENT_CHANNEL_ID = "PaymentReceived";
|
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 NotificationCompat.Builder mNotificationBuilder;
|
||||||
|
|
||||||
private String mNotificationContentText = "";
|
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.
|
// Start the service.
|
||||||
public static void start(Context c) {
|
public static void start(Context c) {
|
||||||
if (!isServiceRunning(c)) {
|
if (!isServiceRunning(c)) {
|
||||||
|
|||||||
@@ -427,6 +427,12 @@ public class MainActivity extends GameActivity {
|
|||||||
BackgroundService.notifyPaymentReceived(this, name, amount);
|
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.
|
// Called from native code to set text into clipboard.
|
||||||
public void copyText(String data) {
|
public void copyText(String data) {
|
||||||
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
ClipboardManager clipboard = (ClipboardManager) getSystemService(Context.CLIPBOARD_SERVICE);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
/// Callback from Java code with last entered character from soft keyboard.
|
||||||
#[allow(non_snake_case)]
|
#[allow(non_snake_case)]
|
||||||
#[unsafe(no_mangle)]
|
#[unsafe(no_mangle)]
|
||||||
|
|||||||
+16
@@ -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! {
|
lazy_static! {
|
||||||
/// Data provided from deeplink or opened file.
|
/// Data provided from deeplink or opened file.
|
||||||
pub static ref INCOMING_DATA: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
pub static ref INCOMING_DATA: Arc<RwLock<Option<String>>> = Arc::new(RwLock::new(None));
|
||||||
|
|||||||
@@ -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(&wrap_id);
|
||||||
svc.store.mark_processed(&rumor_id);
|
svc.store.mark_processed(&rumor_id);
|
||||||
svc.store.mark_processed(&slate_marker);
|
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 => {
|
IngestDecision::FinalizePost => {
|
||||||
// The payer's reply is our first contact with their key on this side of
|
// The payer's reply is our first contact with their key on this side of
|
||||||
|
|||||||
Reference in New Issue
Block a user