diff options
Diffstat (limited to 'android/app/src/webview/java/io')
3 files changed, 310 insertions, 0 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 new file mode 100644 index 0000000..166a1b7 --- /dev/null +++ b/android/app/src/webview/java/io/coinos/app/MainActivity.java @@ -0,0 +1,150 @@ +package io.coinos.app; + +import android.Manifest; +import android.app.Activity; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.view.ViewGroup; +import android.webkit.CookieManager; +import android.webkit.PermissionRequest; +import android.webkit.ValueCallback; +import android.webkit.WebChromeClient; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; + +/** + * The wallet, in a WebView this app owns. + * + * <p>The other build is a TWA — Chrome renders the site, and the wallet's + * keys live in Chrome's storage. That's the better browser, but it means + * nothing outside Chrome can wake the wallet, and on a phone without Google + * Play Services nothing inside Chrome can either. This build trades the + * browser for reach: the wallet lives in THIS app's storage, so a + * UnifiedPush message can start it headlessly and let it answer (see + * WakeService). + * + * <p>The trade is real and worth naming: a separate app means a separate + * wallet. Signing in here is signing in again. + */ +public class MainActivity extends Activity { + + static final String BASE = "https://v3.coinos.io/"; + private WebView web; + private ValueCallback<Uri[]> filePicker; + private static final int REQ_FILE = 10, REQ_PERMS = 11; + + @Override + protected void onCreate(Bundle saved) { + super.onCreate(saved); + web = new WebView(this); + setContentView(web, new ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT)); + + WebSettings s = web.getSettings(); + s.setJavaScriptEnabled(true); + s.setDomStorageEnabled(true); // localStorage + IndexedDB: the wallet + s.setDatabaseEnabled(true); + s.setMediaPlaybackRequiresUserGesture(false); + s.setSupportMultipleWindows(false); + s.setLoadWithOverviewMode(true); + s.setUseWideViewPort(true); + CookieManager.getInstance().setAcceptCookie(true); + + web.setWebViewClient(new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView v, WebResourceRequest r) { + Uri u = r.getUrl(); + String host = u.getHost(); + // our own pages stay in here; everything else is the world's, and + // belongs in a real browser + if (host != null && (host.equals("v3.coinos.io") || host.endsWith(".coinos.io"))) return false; + try { startActivity(new Intent(Intent.ACTION_VIEW, u)); } catch (Exception ignored) {} + return true; + } + }); + + web.setWebChromeClient(new WebChromeClient() { + @Override + public void onPermissionRequest(final PermissionRequest request) { + // the QR scanner asks for the camera; grant it only once Android has + // granted it to us + runOnUiThread(() -> { + if (checkSelfPermission(Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) { + request.grant(request.getResources()); + } else { + request.deny(); + requestPermissions(new String[] { Manifest.permission.CAMERA }, REQ_PERMS); + } + }); + } + + @Override + public boolean onShowFileChooser(WebView v, ValueCallback<Uri[]> cb, FileChooserParams params) { + // attaching a picture to a post + if (filePicker != null) filePicker.onReceiveValue(null); + filePicker = cb; + try { + startActivityForResult(params.createIntent(), REQ_FILE); + return true; + } catch (Exception e) { + filePicker = null; + return false; + } + } + }); + + if (Build.VERSION.SDK_INT >= 33 + && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + try { requestPermissions(new String[] { Manifest.permission.POST_NOTIFICATIONS }, REQ_PERMS); } catch (Exception ignored) {} + } + Push.register(this); + web.loadUrl(urlFor(getIntent())); + } + + /** A scheme intent (lightning:, bitcoin:, nostr:) rides in as ?u=, like the TWA's. */ + private String urlFor(Intent intent) { + StringBuilder url = new StringBuilder(BASE); + Uri data = intent == null ? null : intent.getData(); + if (data != null) { + String scheme = data.getScheme(); + if (scheme != null && !scheme.startsWith("http")) url.append("?u=").append(Uri.encode(data.toString())); + else if ("v3.coinos.io".equals(data.getHost())) { url.setLength(0); url.append(data.toString()); } + } + // the UnifiedPush endpoint, on its way to the part of the app that knows + // what to watch for + String endpoint = Push.endpoint(this); + if (endpoint != null && !Push.endpointDelivered(this)) { + url.append(url.indexOf("?") >= 0 ? "&" : "?").append("up=").append(Uri.encode(endpoint)); + Push.markEndpointDelivered(this); + } + return url.toString(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + if (web != null) web.loadUrl(urlFor(intent)); + } + + @Override + protected void onActivityResult(int req, int res, Intent data) { + if (req == REQ_FILE && filePicker != null) { + filePicker.onReceiveValue(WebChromeClient.FileChooserParams.parseResult(res, data)); + filePicker = null; + return; + } + super.onActivityResult(req, res, data); + } + + @Override + public void onBackPressed() { + if (web != null && web.canGoBack()) web.goBack(); + else super.onBackPressed(); + } +} diff --git a/android/app/src/webview/java/io/coinos/app/Wake.java b/android/app/src/webview/java/io/coinos/app/Wake.java new file mode 100644 index 0000000..883ec49 --- /dev/null +++ b/android/app/src/webview/java/io/coinos/app/Wake.java @@ -0,0 +1,28 @@ +package io.coinos.app; + +import android.content.Context; +import android.content.Intent; +import android.os.Build; + +/** + * 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.) + */ +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; + } catch (Exception e) { + // Android refuses background starts in some states; fall back to the + // notification, which is better than nothing happening at all + return false; + } + } +} diff --git a/android/app/src/webview/java/io/coinos/app/WakeService.java b/android/app/src/webview/java/io/coinos/app/WakeService.java new file mode 100644 index 0000000..cd33547 --- /dev/null +++ b/android/app/src/webview/java/io/coinos/app/WakeService.java @@ -0,0 +1,132 @@ +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(); + } +} |
