diff options
Diffstat (limited to 'android/app/src/webview')
6 files changed, 131 insertions, 147 deletions
diff --git a/android/app/src/webview/AndroidManifest.xml b/android/app/src/webview/AndroidManifest.xml index e88217b..0942377 100644 --- a/android/app/src/webview/AndroidManifest.xml +++ b/android/app/src/webview/AndroidManifest.xml @@ -3,11 +3,17 @@ See MainActivity for why that trade exists. --> <manifest xmlns:android="http://schemas.android.com/apk/res/android"> <uses-permission android:name="android.permission.INTERNET" /> + <!-- the wake job asks to run when there's a network; the scheduler won't + take that constraint without this --> + <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.CAMERA" /> - <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> - <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" /> <uses-feature android:name="android.hardware.camera" android:required="false" /> - <application android:usesCleartextTraffic="false"> + <application + android:label="@string/app_name" + android:icon="@mipmap/ic_launcher" + android:theme="@style/AppTheme" + android:supportsRtl="true" + android:usesCleartextTraffic="false"> <activity android:name="io.coinos.app.MainActivity" android:exported="true" @@ -43,8 +49,8 @@ </activity> <!-- the off-screen wallet a push wakes --> <service - android:name="io.coinos.app.WakeService" + android:name="io.coinos.app.WakeJob" android:exported="false" - android:foregroundServiceType="dataSync" /> + android:permission="android.permission.BIND_JOB_SERVICE" /> </application> </manifest> 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(); - } -} diff --git a/android/app/src/webview/res/values/styles.xml b/android/app/src/webview/res/values/styles.xml new file mode 100644 index 0000000..c4eec32 --- /dev/null +++ b/android/app/src/webview/res/values/styles.xml @@ -0,0 +1,10 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <!-- The TWA has no chrome of its own; neither should this. Same system-bar + colours as the TWA declares, so the two builds look like one app. --> + <style name="AppTheme" parent="@android:style/Theme.Material.NoActionBar"> + <item name="android:statusBarColor">@color/statusBarColor</item> + <item name="android:navigationBarColor">@color/navigationBarColor</item> + <item name="android:windowBackground">@color/statusBarColor</item> + </style> +</resources> |
