aboutsummaryrefslogtreecommitdiffstats
path: root/android/app/src/webview/java/io/coinos
diff options
context:
space:
mode:
Diffstat (limited to 'android/app/src/webview/java/io/coinos')
-rw-r--r--android/app/src/webview/java/io/coinos/app/MainActivity.java6
-rw-r--r--android/app/src/webview/java/io/coinos/app/Wake.java36
-rw-r--r--android/app/src/webview/java/io/coinos/app/WakeJob.java78
-rw-r--r--android/app/src/webview/java/io/coinos/app/WakeService.java132
4 files changed, 110 insertions, 142 deletions
diff --git a/android/app/src/webview/java/io/coinos/app/MainActivity.java b/android/app/src/webview/java/io/coinos/app/MainActivity.java
index 166a1b7..1c0c9d5 100644
--- a/android/app/src/webview/java/io/coinos/app/MainActivity.java
+++ b/android/app/src/webview/java/io/coinos/app/MainActivity.java
@@ -45,6 +45,12 @@ public class MainActivity extends Activity {
setContentView(web, new ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
+ // A debug build can be inspected from a desktop (chrome://inspect); a
+ // release build of a wallet must not be — anything with ADB access could
+ // read its storage through the same door.
+ if ((getApplicationInfo().flags & android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0) {
+ WebView.setWebContentsDebuggingEnabled(true);
+ }
WebSettings s = web.getSettings();
s.setJavaScriptEnabled(true);
s.setDomStorageEnabled(true); // localStorage + IndexedDB: the wallet
diff --git a/android/app/src/webview/java/io/coinos/app/Wake.java b/android/app/src/webview/java/io/coinos/app/Wake.java
index 883ec49..82fc16f 100644
--- a/android/app/src/webview/java/io/coinos/app/Wake.java
+++ b/android/app/src/webview/java/io/coinos/app/Wake.java
@@ -1,27 +1,43 @@
package io.coinos.app;
+import android.app.job.JobInfo;
+import android.app.job.JobScheduler;
+import android.content.ComponentName;
import android.content.Context;
-import android.content.Intent;
import android.os.Build;
+import android.os.PersistableBundle;
/**
* This build CAN wake the wallet: it lives in this app's own storage, so a
- * push starts WakeService, which loads it off-screen and lets it answer.
- * (The TWA build's version of this class returns false — see src/main.)
+ * push schedules WakeJob, which loads it off-screen and lets it answer.
+ * (The TWA build's version of this class returns false — see src/twa.)
*/
final class Wake {
private Wake() {}
static boolean start(Context context, String payload) {
try {
- Intent i = new Intent(context, WakeService.class);
- i.putExtra("payload", payload);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) context.startForegroundService(i);
- else context.startService(i);
- return true;
+ JobScheduler js = (JobScheduler) context.getSystemService(Context.JOB_SCHEDULER_SERVICE);
+ if (js == null) return false;
+ PersistableBundle extras = new PersistableBundle();
+ extras.putString("payload", payload == null ? "" : payload);
+ JobInfo.Builder b = new JobInfo.Builder(WakeJob.JOB_ID, new ComponentName(context, WakeJob.class))
+ .setRequiredNetworkType(JobInfo.NETWORK_TYPE_ANY)
+ .setExtras(extras);
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ // expedited: runs promptly from the background, which is the whole
+ // point — a normal job could wait for hours
+ b.setExpedited(true);
+ } else {
+ b.setOverrideDeadline(0);
+ }
+ int r = js.schedule(b.build());
+ android.util.Log.i("coinos", "wake: schedule -> " + r);
+ return r == JobScheduler.RESULT_SUCCESS;
} catch (Exception e) {
- // Android refuses background starts in some states; fall back to the
- // notification, which is better than nothing happening at all
+ android.util.Log.w("coinos", "wake: schedule failed: " + e);
+ // out of expedited quota, or a manufacturer being creative: the
+ // notification is still there
return false;
}
}
diff --git a/android/app/src/webview/java/io/coinos/app/WakeJob.java b/android/app/src/webview/java/io/coinos/app/WakeJob.java
new file mode 100644
index 0000000..c866c33
--- /dev/null
+++ b/android/app/src/webview/java/io/coinos/app/WakeJob.java
@@ -0,0 +1,78 @@
+package io.coinos.app;
+
+import android.app.job.JobParameters;
+import android.app.job.JobService;
+import android.net.Uri;
+import android.os.Handler;
+import android.os.Looper;
+import android.webkit.JavascriptInterface;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+
+/**
+ * The wallet, woken without being opened.
+ *
+ * <p>A push starts this job; it loads the wallet into an off-screen WebView —
+ * the same storage the visible app uses, because it's the same app — with
+ * ?wake= naming what arrived. The page does whatever the payload calls for
+ * (answering an NWC request, checking for a payment) and calls back through
+ * the CoinosHost bridge when it's finished, or the job gives up after half a
+ * minute. Either way it ends: nothing here is allowed to sit in the
+ * background burning battery.
+ *
+ * <p>A JOB, not a foreground service. Android 12 forbids starting one of
+ * those from a broadcast receiver — "Background started FGS: Disallowed" —
+ * and an expedited job is the sanctioned replacement: it runs promptly from
+ * the background, with no notification of its own to apologise for.
+ */
+public class WakeJob extends JobService {
+
+ static final int JOB_ID = 4242;
+ private static final long GIVE_UP_MS = 30_000;
+
+ private WebView web;
+ private final Handler handler = new Handler(Looper.getMainLooper());
+ private Runnable giveUp;
+ private JobParameters params;
+
+ @Override
+ public boolean onStartJob(JobParameters p) {
+ params = p;
+ String payload = p.getExtras() == null ? null : p.getExtras().getString("payload");
+ android.util.Log.i("coinos", "wake: job started, payload=" + payload);
+
+ web = new WebView(this);
+ WebSettings s = web.getSettings();
+ s.setJavaScriptEnabled(true);
+ s.setDomStorageEnabled(true);
+ s.setDatabaseEnabled(true);
+ web.addJavascriptInterface(new Host(), "CoinosHost");
+
+ // The payload rides in as a parameter the page can read: it carries the
+ // event that woke us, so the wallet needn't hunt for it on a relay.
+ web.loadUrl(MainActivity.BASE + "?wake=" + Uri.encode(payload == null ? "1" : payload));
+
+ giveUp = () -> finish(false);
+ handler.postDelayed(giveUp, GIVE_UP_MS);
+ return true; // still working
+ }
+
+ @Override
+ public boolean onStopJob(JobParameters p) {
+ finish(false);
+ return false; // don't reschedule: the push will come again if it matters
+ }
+
+ /** What the page calls when it has finished. */
+ private class Host {
+ @JavascriptInterface
+ public void done() { handler.post(() -> finish(true)); }
+ }
+
+ private void finish(boolean fromPage) {
+ android.util.Log.i("coinos", "wake: finishing (" + (fromPage ? "page said so" : "timed out or stopped") + ")");
+ if (giveUp != null) { handler.removeCallbacks(giveUp); giveUp = null; }
+ if (web != null) { web.destroy(); web = null; }
+ if (params != null) { jobFinished(params, false); params = null; }
+ }
+}
diff --git a/android/app/src/webview/java/io/coinos/app/WakeService.java b/android/app/src/webview/java/io/coinos/app/WakeService.java
deleted file mode 100644
index cd33547..0000000
--- a/android/app/src/webview/java/io/coinos/app/WakeService.java
+++ /dev/null
@@ -1,132 +0,0 @@
-package io.coinos.app;
-
-import android.app.Notification;
-import android.app.NotificationChannel;
-import android.app.NotificationManager;
-import android.app.Service;
-import android.content.Context;
-import android.content.Intent;
-import android.net.Uri;
-import android.os.Build;
-import android.os.Handler;
-import android.os.IBinder;
-import android.os.Looper;
-import android.webkit.JavascriptInterface;
-import android.webkit.WebSettings;
-import android.webkit.WebView;
-
-/**
- * The wallet, woken without being opened.
- *
- * <p>A UnifiedPush message starts this service; it loads the wallet into an
- * off-screen WebView — the same storage the visible app uses, because it's
- * the same app — with ?wake= naming what arrived. The page does whatever the
- * payload calls for (answering an NWC request, checking for a payment) and
- * calls back through the CoinosHost bridge when it's finished, or the service
- * gives up after half a minute. Either way it stops: nothing about this is
- * allowed to sit in the background burning battery.
- *
- * <p>A foreground service, because Android will not let a background one
- * touch the network reliably. Its notification IS the point rather than an
- * apology for it — something arrived, and this says so while it's dealt with.
- */
-public class WakeService extends Service {
-
- private static final String CHANNEL = "coinos-wake";
- private static final int NOTIF_ID = 42;
- private static final long GIVE_UP_MS = 30_000;
-
- private WebView web;
- private final Handler handler = new Handler(Looper.getMainLooper());
- private Runnable giveUp;
-
- @Override
- public IBinder onBind(Intent intent) { return null; }
-
- @Override
- public int onStartCommand(Intent intent, int flags, int startId) {
- String payload = intent == null ? null : intent.getStringExtra("payload");
- startForeground(NOTIF_ID, notification(payload));
-
- if (web == null) {
- web = new WebView(this);
- WebSettings s = web.getSettings();
- s.setJavaScriptEnabled(true);
- s.setDomStorageEnabled(true);
- s.setDatabaseEnabled(true);
- web.addJavascriptInterface(new Host(), "CoinosHost");
- }
- // The payload rides in as a parameter the page can read; it carries the
- // event that woke us, so the wallet needn't hunt for it on a relay.
- String url = MainActivity.BASE + "?wake=" + Uri.encode(payload == null ? "1" : payload);
- handler.post(() -> web.loadUrl(url));
-
- if (giveUp != null) handler.removeCallbacks(giveUp);
- giveUp = this::done;
- handler.postDelayed(giveUp, GIVE_UP_MS);
- return START_NOT_STICKY;
- }
-
- /** What the page calls when it has finished. */
- private class Host {
- @JavascriptInterface
- public void done() { handler.post(WakeService.this::done); }
- /** Lets the page replace our placeholder notification with the real news. */
- @JavascriptInterface
- public void notify(String title, String body) {
- handler.post(() -> {
- NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
- if (nm == null) return;
- nm.notify(NOTIF_ID + 1, build(title, body, false));
- });
- }
- }
-
- private void done() {
- if (giveUp != null) handler.removeCallbacks(giveUp);
- if (web != null) { web.destroy(); web = null; }
- stopForeground(true);
- stopSelf();
- }
-
- @Override
- public void onDestroy() {
- if (web != null) { web.destroy(); web = null; }
- super.onDestroy();
- }
-
- private Notification notification(String payload) {
- String body = getString(R.string.wake_working);
- try {
- org.json.JSONObject j = new org.json.JSONObject(payload == null ? "{}" : payload);
- String reason = j.optString("reason", j.optString("type", ""));
- if ("nwc".equals(reason)) body = getString(R.string.push_nwc_answering);
- else if ("payment".equals(reason)) body = getString(R.string.push_payment);
- else if ("dm".equals(reason)) body = getString(R.string.push_dm);
- else if ("chat".equals(reason)) body = getString(R.string.push_chat);
- } catch (Exception ignored) {}
- return build(getString(R.string.app_name), body, true);
- }
-
- private Notification build(String title, String body, boolean quiet) {
- NotificationManager nm = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE);
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && nm != null) {
- NotificationChannel ch = new NotificationChannel(CHANNEL, getString(R.string.app_name),
- quiet ? NotificationManager.IMPORTANCE_LOW : NotificationManager.IMPORTANCE_DEFAULT);
- nm.createNotificationChannel(ch);
- }
- Intent open = new Intent(this, MainActivity.class);
- open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
- int flags = android.app.PendingIntent.FLAG_UPDATE_CURRENT
- | (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M ? android.app.PendingIntent.FLAG_IMMUTABLE : 0);
- Notification.Builder b = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
- ? new Notification.Builder(this, CHANNEL)
- : new Notification.Builder(this);
- return b.setSmallIcon(R.mipmap.ic_launcher)
- .setContentTitle(title)
- .setContentText(body)
- .setAutoCancel(true)
- .setContentIntent(android.app.PendingIntent.getActivity(this, 0, open, flags))
- .build();
- }
-}