Skip to content

Commit 7be8bc3

Browse files
beerosagosBeerosagos
authored andcommitted
android: split main activity responsibilities
Extract WebViewManager and UsbDeviceManager so MainActivity focuses on lifecycle wiring. WebView setup, permission handling, and back button logic move into WebViewManager, while UsbDeviceManager centralizes USB receiver registration, permissions, and device selection. This makes the activity easier to read and test.
1 parent 794f250 commit 7be8bc3

File tree

5 files changed

+328
-239
lines changed

5 files changed

+328
-239
lines changed
Lines changed: 34 additions & 233 deletions
Original file line numberDiff line numberDiff line change
@@ -1,69 +1,41 @@
11
package ch.shiftcrypto.bitboxapp;
22

3-
import android.Manifest;
4-
import android.annotation.SuppressLint;
5-
import android.app.AlertDialog;
6-
import android.app.PendingIntent;
7-
import android.content.BroadcastReceiver;
83
import android.content.ComponentName;
94
import android.content.Context;
105
import android.content.Intent;
11-
import android.content.IntentFilter;
126
import android.content.ServiceConnection;
13-
import android.content.pm.PackageManager;
147
import android.content.res.Configuration;
15-
import android.hardware.usb.UsbDevice;
16-
import android.hardware.usb.UsbManager;
178
import android.net.Uri;
189
import android.os.Bundle;
19-
import android.os.Handler;
2010
import android.os.IBinder;
21-
import android.os.Looper;
2211
import android.view.View;
2312
import android.view.WindowManager;
24-
import android.webkit.CookieManager;
2513
import android.webkit.WebView;
2614

2715
import androidx.activity.OnBackPressedCallback;
28-
import androidx.activity.result.ActivityResultLauncher;
29-
import androidx.activity.result.contract.ActivityResultContracts;
3016
import androidx.annotation.NonNull;
3117
import androidx.appcompat.app.ActionBar;
3218
import androidx.appcompat.app.AppCompatActivity;
33-
import androidx.core.app.ActivityCompat;
3419
import androidx.core.content.ContextCompat;
3520
import androidx.lifecycle.ViewModelProviders;
3621

37-
import java.util.HashMap;
38-
3922
import mobileserver.Mobileserver;
4023

4124
public class MainActivity extends AppCompatActivity {
4225
static {
4326
System.loadLibrary("signal_handler");
4427
}
4528
public native void initsignalhandler();
46-
private final int PERMISSIONS_REQUEST_CAMERA_QRCODE = 0;
47-
private static final String ACTION_USB_PERMISSION = "ch.shiftcrypto.bitboxapp.USB_PERMISSION";
48-
// The WebView is configured with this as the base URL. The purpose is so that requests made
49-
// from the app include shiftcrypto.ch in the Origin header to allow Moonpay to load in the
50-
// iframe. Moonpay compares the origin against a list of origins configured in the Moonpay admin.
51-
// This is a security feature relevant for websites running in browsers, but in the case of the
52-
// BitBoxApp, it is useless, as any app can do this.
53-
//
54-
// Unfortunately there seems to be no simple way to include this header only in requests to Moonpay.
55-
private static final String BASE_URL = "https://shiftcrypto.ch/";
5629

5730
GoService goService;
58-
59-
private BitBoxWebChromeClient webChrome;
31+
private GoViewModel goViewModel;
32+
private WebViewManager webViewManager;
33+
private UsbDeviceManager usbDeviceManager;
6034

6135
// Connection to bind with GoService
6236
private final ServiceConnection connection = new ServiceConnection() {
63-
6437
@Override
65-
public void onServiceConnected(ComponentName className,
66-
IBinder service) {
38+
public void onServiceConnected(ComponentName className, IBinder service) {
6739
GoService.GoServiceBinder binder = (GoService.GoServiceBinder) service;
6840
goService = binder.getService();
6941
goService.setViewModelStoreOwner(MainActivity.this);
@@ -78,12 +50,6 @@ public void onServiceDisconnected(ComponentName arg0) {
7850
}
7951
};
8052

81-
private final BroadcastReceiver usbStateReceiver = new BroadcastReceiver() {
82-
public void onReceive(Context context, Intent intent) {
83-
handleIntent(intent);
84-
}
85-
};
86-
8753
@Override
8854
public void onConfigurationChanged(Configuration newConfig) {
8955
int currentNightMode = newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK;
@@ -118,89 +84,39 @@ public void setDarkTheme(boolean isDark) {
11884
}
11985
}
12086

121-
@SuppressLint("SetJavaScriptEnabled")
12287
@Override
12388
protected void onCreate(Bundle savedInstanceState) {
12489
super.onCreate(savedInstanceState);
12590
Util.log("lifecycle: onCreate");
126-
12791
initsignalhandler();
128-
12992
ActionBar actionBar = getSupportActionBar();
13093
if (actionBar != null) {
13194
actionBar.hide(); // hide title bar with app name.
13295
}
13396
onConfigurationChanged(getResources().getConfiguration());
13497
setContentView(R.layout.activity_main);
13598
final WebView vw = findViewById(R.id.vw);
136-
// For onramp iframe'd widgets like MoonPay.
137-
CookieManager.getInstance().setAcceptThirdPartyCookies(vw, true);
13899

139100
// GoModel manages the Go backend. It is in a ViewModel so it only runs once, not every time
140101
// onCreate is called (on a configuration change like orientation change).
141-
final GoViewModel goViewModel = ViewModelProviders.of(this).get(GoViewModel.class);
102+
goViewModel = ViewModelProviders.of(this).get(GoViewModel.class);
103+
webViewManager = new WebViewManager(this, goViewModel);
104+
webViewManager.initialize(vw);
105+
usbDeviceManager = new UsbDeviceManager(this, goViewModel);
142106

143107
// The backend is run inside GoService, to avoid (as much as possible) latency errors due to
144108
// the scheduling when the app is out of focus.
145109
Intent intent = new Intent(this, GoService.class);
146110
bindService(intent, connection, Context.BIND_AUTO_CREATE);
147111

148-
goViewModel.setMessageHandlers(
149-
new Handler(Looper.getMainLooper(), msg -> {
150-
final GoAPI.Response response = (GoAPI.Response) msg.obj;
151-
vw.evaluateJavascript(
152-
"if (window.onMobileCallResponse) {" +
153-
"window.onMobileCallResponse(" + response.queryID + ", " + response.response + ")};",
154-
null);
155-
return true;
156-
}),
157-
new Handler(Looper.getMainLooper(), msg -> {
158-
vw.evaluateJavascript(
159-
"if (window.onMobilePushNotification) {" +
160-
"window.onMobilePushNotification(" + msg.obj + ")};",
161-
null);
162-
return true;
163-
}));
164-
vw.clearCache(true);
165-
vw.clearHistory();
166-
vw.getSettings().setJavaScriptEnabled(true);
167-
vw.getSettings().setAllowUniversalAccessFromFileURLs(true);
168-
vw.getSettings().setAllowFileAccess(true);
169-
// Allow widgets to open external links via window.open (handled in BitBoxWebChromeClient).
170-
vw.getSettings().setSupportMultipleWindows(true);
171-
172-
// For Moonpay widget: DOM storage and WebRTC camera access required.
173-
vw.getSettings().setDomStorageEnabled(true);
174-
vw.getSettings().setMediaPlaybackRequiresUserGesture(false);
175-
176-
// vw.setWebContentsDebuggingEnabled(true); // enable remote debugging in chrome://inspect/#devices
177-
178-
vw.setWebViewClient(new BitBoxWebViewClient(BASE_URL, getAssets(), getApplication()));
179-
180-
ActivityResultLauncher<String> fileChooser = registerForActivityResult(new ActivityResultContracts.GetContent(), uri -> webChrome.onFilePickerResult(uri));
181-
BitBoxWebChromeClient.CameraPermissionDelegate cameraPermissionDelegate = () -> ActivityCompat.requestPermissions(
182-
this,
183-
new String[]{Manifest.permission.CAMERA},
184-
PERMISSIONS_REQUEST_CAMERA_QRCODE
185-
);
186-
187-
webChrome = new BitBoxWebChromeClient(this,
188-
cameraPermissionDelegate,
189-
fileChooser
190-
);
191-
vw.setWebChromeClient(webChrome);
192-
193-
vw.addJavascriptInterface(new JavascriptBridge(this), "android");
194-
vw.loadUrl(BASE_URL + "index.html");
195-
196-
// We call updateDevice() here in case the app was started while the device was already connected.
197-
// In that case, handleIntent() is not called with ACTION_USB_DEVICE_ATTACHED.
198-
this.updateDevice();
112+
// We call refreshConnectedDevice() here in case the app was started while the device was already connected.
113+
// In that case, no intent is fired with ACTION_USB_DEVICE_ATTACHED.
114+
usbDeviceManager.refreshConnectedDevice();
199115

200116
getOnBackPressedDispatcher().addCallback(this, new OnBackPressedCallback(true) {
201117
@Override
202118
public void handleOnBackPressed() {
203-
backPressedHandler();
119+
webViewManager.handleBackPressed();
204120
}
205121
});
206122

@@ -261,17 +177,20 @@ public void noAuthConfigured() {
261177
@Override
262178
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
263179
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
264-
if (requestCode == PERMISSIONS_REQUEST_CAMERA_QRCODE) {
265-
webChrome.onCameraPermissionResult(grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED);
180+
if (webViewManager != null) {
181+
webViewManager.handleRequestPermissionsResult(requestCode, grantResults);
266182
}
267183
}
268184

269185
private void startServer() {
270-
final GoViewModel gVM = ViewModelProviders.of(this).get(GoViewModel.class);
271-
goService.startServer(getApplicationContext().getFilesDir().getAbsolutePath(), gVM.getGoEnvironment(), gVM.getGoAPI());
186+
goService.startServer(
187+
getApplicationContext().getFilesDir().getAbsolutePath(),
188+
goViewModel.getGoEnvironment(),
189+
goViewModel.getGoAPI()
190+
);
272191

273192
// Trigger connectivity and mobile connection check (as the network may already be unavailable when the app starts).
274-
gVM.getNetworkHelper().checkConnectivity();
193+
goViewModel.getNetworkHelper().checkConnectivity();
275194
}
276195

277196
@Override
@@ -287,7 +206,6 @@ protected void onNewIntent(Intent intent) {
287206
protected void onStart() {
288207
super.onStart();
289208
Util.log("lifecycle: onStart");
290-
final GoViewModel goViewModel = ViewModelProviders.of(this).get(GoViewModel.class);
291209
goViewModel.getIsDarkTheme().observe(this, this::setDarkTheme);
292210
goViewModel.getNetworkHelper().registerNetworkCallback();
293211
}
@@ -298,104 +216,26 @@ protected void onResume() {
298216
Util.log("lifecycle: onResume");
299217
Mobileserver.triggerAuth();
300218

301-
// This is only called reliably when USB is attached with android:launchMode="singleTop"
302-
303-
// Usb device list is updated on ATTACHED / DETACHED intents.
304-
// ATTACHED intent is an activity intent in AndroidManifest.xml so the app is launched when
305-
// a device is attached. On launch or when it is already running, onIntent() is called
306-
// followed by onResume(), where the intent is handled.
307-
// DETACHED intent is a broadcast intent which we register here.
308-
IntentFilter filter = new IntentFilter();
309-
filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED);
310-
filter.addAction(ACTION_USB_PERMISSION);
311-
ContextCompat.registerReceiver(
312-
this,
313-
usbStateReceiver,
314-
filter,
315-
ContextCompat.RECEIVER_NOT_EXPORTED
316-
);
219+
usbDeviceManager.startMonitoring();
317220

318221
// Trigger connectivity check (as the network may already be unavailable when the app starts).
319-
final GoViewModel goViewModel = ViewModelProviders.of(this).get(GoViewModel.class);
320222
goViewModel.getNetworkHelper().checkConnectivity();
321223

322224
Intent intent = getIntent();
323-
handleIntent(intent);
225+
usbDeviceManager.handleUsbIntent(intent);
226+
handleAOPPIntent(intent);
324227
}
325228

326229
@Override
327230
protected void onPause() {
328231
super.onPause();
329232
Util.log("lifecycle: onPause");
330-
unregisterReceiver(this.usbStateReceiver);
331-
}
332-
333-
private void handleIntent(Intent intent) {
334-
if (ACTION_USB_PERMISSION.equals(intent.getAction())) {
335-
// See https://developer.android.com/guide/topics/connectivity/usb/host#permission-d
336-
synchronized (this) {
337-
UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE);
338-
if (intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)) {
339-
if (device != null) {
340-
Util.log("usb: permission granted");
341-
final GoViewModel goViewModel = ViewModelProviders.of(this).get(GoViewModel.class);
342-
goViewModel.setDevice(device);
343-
}
344-
} else {
345-
Util.log("usb: permission denied");
346-
}
347-
}
348-
}
349-
if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(intent.getAction())) {
350-
Util.log("usb: attached");
351-
this.updateDevice();
352-
}
353-
if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(intent.getAction())) {
354-
Util.log("usb: detached");
355-
this.updateDevice();
356-
}
357-
// Handle 'aopp:' URIs. This is called when the app is launched and also if it is already
358-
// running and brought to the foreground.
359-
if (Intent.ACTION_VIEW.equals(intent.getAction())) {
360-
Uri uri = intent.getData();
361-
if (uri != null) {
362-
if ("aopp".equals(uri.getScheme())) {
363-
Mobileserver.handleURI(uri.toString());
364-
}
365-
}
366-
}
367-
}
368-
369-
private void updateDevice() {
370-
// Triggered by usb device attached intent and usb device detached broadcast events.
371-
final GoViewModel goViewModel = ViewModelProviders.of(this).get(GoViewModel.class);
372-
goViewModel.setDevice(null);
373-
UsbManager manager = (UsbManager) getApplication().getSystemService(Context.USB_SERVICE);
374-
HashMap<String, UsbDevice> deviceList = manager.getDeviceList();
375-
for (UsbDevice device : deviceList.values()) {
376-
// One other instance where we filter vendor/product IDs is in
377-
// @xml/device_filter resource, which is used for USB_DEVICE_ATTACHED
378-
// intent to launch the app when a device is plugged and the app is still
379-
// closed. This filter, on the other hand, makes sure we feed only valid
380-
// devices to the Go backend once the app is launched or opened.
381-
//
382-
// BitBox02 Vendor ID: 0x03eb, Product ID: 0x2403.
383-
if (device.getVendorId() == 1003 && device.getProductId() == 9219) {
384-
if (manager.hasPermission(device)) {
385-
goViewModel.setDevice(device);
386-
} else {
387-
PendingIntent permissionIntent = PendingIntent.getBroadcast(this, 0, new Intent(ACTION_USB_PERMISSION), PendingIntent.FLAG_IMMUTABLE);
388-
manager.requestPermission(device, permissionIntent);
389-
}
390-
break; // only one device supported for now
391-
}
392-
}
233+
usbDeviceManager.stopMonitoring();
393234
}
394235

395236
@Override
396237
protected void onStop() {
397238
super.onStop();
398-
final GoViewModel goViewModel = ViewModelProviders.of(this).get(GoViewModel.class);
399239
goViewModel.getNetworkHelper().unregisterNetworkCallback();
400240
Util.log("lifecycle: onStop");
401241
}
@@ -421,54 +261,15 @@ protected void onDestroy() {
421261
Util.quit(MainActivity.this);
422262
}
423263

424-
425-
426-
// Handle Android back button behavior:
427-
//
428-
// By default, if the webview can go back in browser history, we do that.
429-
// If there is no more history, we prompt the user to quit the app. If
430-
// confirmed, the app will be force quit.
431-
//
432-
// The default behavior can be modified by the frontend via the
433-
// window.onBackButtonPressed() function. See the `useBackButton` React
434-
// hook. It will be called first, and if it returns false, the default
435-
// behavior is prevented, otherwise we proceed with the above default
436-
// behavior.
437-
//
438-
// Without forced app process exit, some goroutines may remain active even after
439-
// the app resumption at which point new copies of goroutines are spun up.
440-
// Note that this is different from users tapping on "home" button or switching
441-
// to another app and then back, in which case no extra goroutines are created.
442-
//
443-
// A proper fix is to make the backend process run in a separate system thread.
444-
// Until such solution is implemented, forced app exit seems most appropriate.
445-
//
446-
// See the following for details about task and activity stacks:
447-
// https://developer.android.com/guide/components/activities/tasks-and-back-stack
448-
private void backPressedHandler() {
449-
runOnUiThread(new Runnable() {
450-
final WebView vw = findViewById(R.id.vw);
451-
@Override
452-
public void run() {
453-
vw.evaluateJavascript("window.onBackButtonPressed();", value -> {
454-
boolean doDefault = Boolean.parseBoolean(value);
455-
if (doDefault) {
456-
// Default behavior: go back in history if we can, otherwise prompt user
457-
// if they want to quit the app.
458-
if (vw.canGoBack()) {
459-
vw.goBack();
460-
return;
461-
}
462-
new AlertDialog.Builder(MainActivity.this)
463-
.setTitle("Close BitBoxApp")
464-
.setMessage("Do you really want to exit?")
465-
.setPositiveButton(android.R.string.yes, (dialog, which) -> Util.quit(MainActivity.this))
466-
.setNegativeButton(android.R.string.no, (dialog, which) -> dialog.dismiss())
467-
.setIcon(android.R.drawable.ic_dialog_alert)
468-
.show();
469-
}
470-
});
471-
}
472-
});
264+
// Handle 'aopp:' URIs. This is called when the app is launched and also if it is already
265+
// running and brought to the foreground.
266+
private void handleAOPPIntent(Intent intent) {
267+
if (intent == null || !Intent.ACTION_VIEW.equals(intent.getAction())) {
268+
return;
269+
}
270+
Uri uri = intent.getData();
271+
if (uri != null && "aopp".equals(uri.getScheme())) {
272+
Mobileserver.handleURI(uri.toString());
273+
}
473274
}
474275
}

0 commit comments

Comments
 (0)