Skip to content

Commit ef225d1

Browse files
committed
android: make back button actually go back
**Behavior** Before it would always prompt to quit the app. Now, the back button behavior is: - if a frontend component is using `useBackButton(handler)`, then handler is executed. If the handler returns false, stop. This is useful for dialog components to inhibit the Android back button or make it close the dialog. - If there is no active frontend back button handler, or a handler was executed and returns true, then the default behavior kicks in: - if we can go back in browser history, we go back - if we can't we prompt exiting the app This commit contains this with a few frontend components modified to use the useBackButton hook: - wait dialogs make the back button do nothing, so user can't exit blocking dialogs when e.g. input on the BitBox02 is required - close normal dialogs if they can be closed **Future work to fix history back behavior everywhere** There are many places where we need to fix navigate()/history.back() behavior to make history.back() work as expected. This commit contains one such fix in the BB02 passphrase flow. In general we want to navigate(-1) instead of navigate forward on a back/abort click, and navigate(-1) afte a flow finished instead of navigating forward, so the back button does bring you back into the previous process. Future PRs should fix it everywhere, e.g. "Back" buttons should mostly just do `navigate(-1)`. **webdev** In webdev, the history back button has the same behavior as on Android except for where useBackButton() is used, which is basically only in dialogs, so one can test the back button behavior almost completely in webdev. One can call `window.onBackButtonPressed()` in the console invoke an active useBackButton hook handler like Android does if one needs to test that, e.g. in a dialog. **ALTERNATIVE dimsmissed**: I spent an obscene amount of time trying to solve intercepting "history back" in the frontend only using a combination of history.pushState() and window.onpopstate(), and made it work, but with downsides: - navigate() or history.pushState() could not be called until the useBackButton hook became incative, which is difficult to get right, e.g. `navigate()` after a blocking BB02 operation would happen while the useBackButton hook was still active to inhibit the back button, leading to chaos. - onpopstate() only fires after history back was executed and cannot be prevented, so the common workaround is to first do a pushstate(), then intercept it in onpopstate() and pushState() again immediately. Since history.pushState() and history.back() are asynchronous operations, and useCallBack() could be rapidly remounted, it would fall out of sync, so a queue processor was needed. Generally the code complexity of the frontend-only solution was huge and added requirements for how to use it correctly made me abandon the approach. **Code of alternative solution that was abandoned for the record**: ```backbutton.ts import { useContext, useEffect, useRef } from 'react'; import { BackButtonContext, THandler } from '@/contexts/BackButtonContext'; export const useBackButton = (handler: THandler) => { const { pushHandler, popHandler } = useContext(BackButtonContext); // We don't want to re-trigger the handler effect below when the handler changes, no need to // repeat the push/pop pair unnecessarily. const handlerRef = useRef<(() => void)>(handler); useEffect(() => { handlerRef.current = handler; }, [handler]); useEffect(() => { pushHandler(handlerRef.current); return popHandler; }, [handlerRef, pushHandler, popHandler]); }; // A convenience component that makes sure useBackButton is only used when the component is rendered. // This avoids complicated useEffect() uses to make sure useBackButton is only active depending on // rendering conditions. // This also is useful if you want to use this hook in a component that is still class-based. export const UseBackButton = ({ handler }: { handler: THandler }) => { useBackButton(handler); return null; }; ``` ```BackButtonContext.ts import { ReactNode, useContext, createContext, useEffect, useState, useCallback } from 'react'; import { usePrevious } from '@/hooks/previous'; import { runningOnMobile } from '@/utils/env'; import { AppContext } from './AppContext'; export type THandler = () => void; type TProps = { pushHandler: (handler: THandler) => void; popHandler: () => void; } export const BackButtonContext = createContext<TProps>({ pushHandler: () => { console.error('pushHandler called out of context'); return true; }, popHandler: () => { console.error('popHandler called out of context'); return true; }, }); let queue: { action: 'pushState' | 'back'; invoke?: () => void; }[] = []; let isProcessing = false; let ignoreNext = 0; function processQueue() { if (isProcessing || queue.length === 0) { return; } isProcessing = true; const item = queue.shift(); if (!item) { return; } if (item.action === 'pushState') { console.log('QUEUE PUSH'); window.history.pushState('BLOCKED', '', window.location.href); } else { console.log('QUEUE POP'); window.history.back(); } if (item.invoke) { item.invoke(); } // Ensure the browser has time to process each navigation setTimeout(() => { isProcessing = false; processQueue(); }, 100); // Adjust delay based on testing } type TProviderProps = { children: ReactNode; } export const BackButtonProvider = ({ children }: TProviderProps) => { const [handlers, sethandlers] = useState<THandler[]>([]); const { guideShown, setGuideShown } = useContext(AppContext); const previousGuideShown = usePrevious(guideShown); const callTopHandler = useCallback(() => { console.log('CALL TOP', handlers.length); if (handlers.length > 0) { const topHandler = handlers[handlers.length - 1]; topHandler(); return true; } return false; }, [handlers]); useEffect(() => { const handler = () => { if (ignoreNext > 0) { console.log('IGNORED', ignoreNext); ignoreNext--; return; } if (callTopHandler()) { queue.push({ action: 'pushState' }); processQueue(); } }; window.addEventListener('popstate', handler); return () => { window.removeEventListener('popstate', handler); }; }, [callTopHandler]); const pushHandler = useCallback((handler: THandler) => { console.log('pushHandler'); sethandlers((prevStack) => [...prevStack, handler]); queue.push({ action: 'pushState' }); processQueue(); }, []); const popHandler = useCallback(() => { console.log('popHandler'); sethandlers((prevStack) => prevStack.slice(0, -1)); queue.push({ action: 'back' }); ignoreNext++; processQueue(); }, []); // On mobile, the guide covers the whole screen. // Make the back button remove the guide first. // On desktop the guide does not cover everything and one can keep navigating while it is visible. useEffect(() => { if (!runningOnMobile) { return; } if (guideShown && !previousGuideShown) { pushHandler(() => setGuideShown(false)); } if (!guideShown && previousGuideShown) { popHandler(); } }, [pushHandler, popHandler, guideShown, previousGuideShown, setGuideShown]); return ( <BackButtonContext.Provider value={{ pushHandler, popHandler }}> {children} </BackButtonContext.Provider> ); }; ```
1 parent 32bc3d6 commit ef225d1

File tree

10 files changed

+237
-34
lines changed

10 files changed

+237
-34
lines changed

frontends/android/BitBoxApp/app/src/main/java/ch/shiftcrypto/bitboxapp/MainActivity.java

Lines changed: 43 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -582,12 +582,17 @@ public void onRequestPermissionsResult(int requestCode, String[] permissions, in
582582
}
583583
}
584584

585-
// The app cannot currently handle the back button action to allow users
586-
// to move between screens back and forth. What happens is the app is "moved"
587-
// to background as if "home" button were pressed.
588-
// To avoid unexpected behaviour, we prompt users and force the app process
589-
// to exit which helps with preserving phone's resources by shutting down
590-
// all goroutines.
585+
// Handle Android back button behavior:
586+
//
587+
// By default, if the webview can go back in browser history, we do that.
588+
// If there is no more history, we prompt the user to quit the app. If
589+
// confirmed, the app will be force quit.
590+
//
591+
// The default behavior can be modified by the frontend via the
592+
// window.onBackButtonPressed() function. See the `useBackButton` React
593+
// hook. It will be called first, and if it returns false, the default
594+
// behavior is prevented, otherwise we proceed with the above default
595+
// behavior.
591596
//
592597
// Without forced app process exit, some goroutines may remain active even after
593598
// the app resumption at which point new copies of goroutines are spun up.
@@ -601,20 +606,37 @@ public void onRequestPermissionsResult(int requestCode, String[] permissions, in
601606
// https://developer.android.com/guide/components/activities/tasks-and-back-stack
602607
@Override
603608
public void onBackPressed() {
604-
new AlertDialog.Builder(MainActivity.this)
605-
.setTitle("Close BitBoxApp")
606-
.setMessage("Do you really want to exit?")
607-
.setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
608-
public void onClick(DialogInterface dialog, int which) {
609-
Util.quit(MainActivity.this);
610-
}
611-
})
612-
.setNegativeButton(android.R.string.no, new DialogInterface.OnClickListener() {
613-
public void onClick(DialogInterface dialog, int which) {
614-
dialog.dismiss();
615-
}
616-
})
617-
.setIcon(android.R.drawable.ic_dialog_alert)
618-
.show();
609+
runOnUiThread(new Runnable() {
610+
final WebView vw = (WebView) findViewById(R.id.vw);
611+
@Override
612+
public void run() {
613+
vw.evaluateJavascript("window.onBackButtonPressed();", value -> {
614+
boolean doDefault = Boolean.parseBoolean(value);
615+
if (doDefault) {
616+
// Default behavior: go back in history if we can, otherwise prompt user
617+
// if they want to quit the app.
618+
if (vw.canGoBack()) {
619+
vw.goBack();
620+
return;
621+
}
622+
new AlertDialog.Builder(MainActivity.this)
623+
.setTitle("Close BitBoxApp")
624+
.setMessage("Do you really want to exit?")
625+
.setPositiveButton(android.R.string.yes, new DialogInterface.OnClickListener() {
626+
public void onClick(DialogInterface dialog, int which) {
627+
Util.quit(MainActivity.this);
628+
}
629+
})
630+
.setNegativeButton(android.R.string.no, new DialogInterface.OnClickListener() {
631+
public void onClick(DialogInterface dialog, int which) {
632+
dialog.dismiss();
633+
}
634+
})
635+
.setIcon(android.R.drawable.ic_dialog_alert)
636+
.show();
637+
}
638+
});
639+
}
640+
});
619641
}
620642
}

frontends/web/src/components/alert/Alert.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import { useState } from 'react';
1919
import { useTranslation } from 'react-i18next';
2020
import { MultilineMarkup } from '@/utils/markup';
21+
import { UseBackButton } from '@/hooks/backbutton';
2122
import { View, ViewButtons, ViewHeader } from '@/components/view/view';
2223
import { Button } from '@/components/forms';
2324

@@ -62,6 +63,9 @@ const Alert = () => {
6263

6364
return (active && message) ? (
6465
<form onSubmit={() => setActive(false)}>
66+
<UseBackButton handler={() => {
67+
setActive(false); return false;
68+
}} />
6569
<View
6670
key="alert-overlay"
6771
dialog={asDialog}

frontends/web/src/components/dialog/dialog.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import React, { useCallback, useEffect, useRef, useState } from 'react';
1919
import { CloseXDark, CloseXWhite } from '@/components/icon';
20+
import { UseBackButton } from '@/hooks/backbutton';
2021
import { useEsc, useKeydown } from '@/hooks/keyboard';
2122
import style from './dialog.module.css';
2223

@@ -156,6 +157,13 @@ export const Dialog = ({
156157
}
157158
}, [deactivateModal]);
158159

160+
const closeHandler = useCallback(() => {
161+
if (onClose !== undefined) {
162+
deactivate(true);
163+
return false;
164+
}
165+
return true;
166+
}, [onClose, deactivate]);
159167

160168
useEsc(useCallback(() => {
161169
if (!renderDialog) {
@@ -192,6 +200,7 @@ export const Dialog = ({
192200

193201
return (
194202
<div className={style.overlay} ref={overlayRef}>
203+
<UseBackButton handler={closeHandler}/>
195204
<div
196205
className={[style.modal, isSmall, isMedium, isLarge].join(' ')}
197206
ref={modalRef}>

frontends/web/src/components/wait-dialog/wait-dialog.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,13 @@
1515
* limitations under the License.
1616
*/
1717

18-
import { Component, createRef, ReactNode } from 'react';
18+
import React, { Component, createRef, ReactNode } from 'react';
1919
import { translate, TranslateProps } from '@/decorators/translate';
2020
import approve from '@/assets/icons/hold.png';
2121
import reject from '@/assets/icons/tap.png';
2222
import style from '@/components/dialog/dialog.module.css';
23-
import React from 'react';
23+
import { UseDisableBackButton } from '@/hooks/backbutton';
24+
2425

2526
interface WaitDialogProps {
2627
includeDefault?: boolean;
@@ -140,6 +141,7 @@ class WaitDialog extends Component<Props, State> {
140141
className={style.overlay}
141142
ref={this.overlay}
142143
style={{ zIndex: 10001 }}>
144+
<UseDisableBackButton />
143145
<div className={[style.modal, style.open].join(' ')} ref={this.modal}>
144146
{
145147
title && (
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/**
2+
* Copyright 2024 Shift Crypto AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { ReactNode, useContext, createContext, useEffect, useState, useCallback } from 'react';
18+
import { runningOnMobile } from '@/utils/env';
19+
import { AppContext } from './AppContext';
20+
21+
export type THandler = () => boolean;
22+
23+
type TProps = {
24+
pushHandler: (handler: THandler) => void;
25+
popHandler: (handler: THandler) => void;
26+
}
27+
28+
export const BackButtonContext = createContext<TProps>({
29+
pushHandler: () => {
30+
console.error('pushHandler called out of context');
31+
return true;
32+
},
33+
popHandler: () => {
34+
console.error('popHandler called out of context');
35+
return true;
36+
},
37+
});
38+
39+
type TProviderProps = {
40+
children: ReactNode;
41+
}
42+
43+
export const BackButtonProvider = ({ children }: TProviderProps) => {
44+
const [handlers, sethandlers] = useState<THandler[]>([]);
45+
const { guideShown, setGuideShown } = useContext(AppContext);
46+
47+
const callTopHandler = useCallback(() => {
48+
// On mobile, the guide covers the whole screen.
49+
// Make the back button remove the guide first.
50+
// On desktop the guide does not cover everything and one can keep navigating while it is visible.
51+
if (runningOnMobile() && guideShown) {
52+
setGuideShown(false);
53+
return false;
54+
}
55+
56+
if (handlers.length > 0) {
57+
const topHandler = handlers[handlers.length - 1];
58+
return topHandler();
59+
}
60+
return true;
61+
}, [handlers, guideShown, setGuideShown]);
62+
63+
const pushHandler = useCallback((handler: THandler) => {
64+
sethandlers((prevStack) => [...prevStack, handler]);
65+
}, []);
66+
67+
const popHandler = useCallback((handler: THandler) => {
68+
sethandlers((prevStack) => {
69+
const index = prevStack.indexOf(handler);
70+
if (index === -1) {
71+
// Should never happen.
72+
return prevStack;
73+
}
74+
return prevStack.slice(index, 1);
75+
});
76+
}, []);
77+
78+
// Install back button callback that is called from Android/iOS.
79+
useEffect(() => {
80+
window.onBackButtonPressed = callTopHandler;
81+
return () => {
82+
delete window.onBackButtonPressed;
83+
};
84+
}, [callTopHandler]);
85+
86+
87+
return (
88+
<BackButtonContext.Provider value={{ pushHandler, popHandler }}>
89+
{children}
90+
</BackButtonContext.Provider>
91+
);
92+
};

frontends/web/src/contexts/providers.tsx

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import { ReactNode } from 'react';
1818
import { DarkModeProvider } from './DarkmodeProvider';
1919
import { AppProvider } from './AppProvider';
20+
import { BackButtonProvider } from './BackButtonContext';
2021
import { WCWeb3WalletProvider } from './WCWeb3WalletProvider';
2122
import { RatesProvider } from './RatesProvider';
2223
import { LocalizationProvider } from './localization-provider';
@@ -28,15 +29,17 @@ type Props = {
2829
export const Providers = ({ children }: Props) => {
2930
return (
3031
<AppProvider>
31-
<DarkModeProvider>
32-
<LocalizationProvider>
33-
<RatesProvider>
34-
<WCWeb3WalletProvider>
35-
{children}
36-
</WCWeb3WalletProvider>
37-
</RatesProvider>
38-
</LocalizationProvider>
39-
</DarkModeProvider>
32+
<BackButtonProvider>
33+
<DarkModeProvider>
34+
<LocalizationProvider>
35+
<RatesProvider>
36+
<WCWeb3WalletProvider>
37+
{children}
38+
</WCWeb3WalletProvider>
39+
</RatesProvider>
40+
</LocalizationProvider>
41+
</DarkModeProvider>
42+
</BackButtonProvider>
4043
</AppProvider>
4144
);
4245
};

frontends/web/src/globals.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ export declare global {
2525
onMobileCallResponse?: (queryID: number, response: unknown) => void;
2626
onMobilePushNotification?: (msg: TPayload) => void;
2727
runningOnIOS?: boolean;
28+
// Called by Android when the back button is pressed.
29+
onBackButtonPressed?: () => boolean;
2830
webkit?: {
2931
messageHandlers: {
3032
goCall: {

frontends/web/src/hooks/backbutton.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* Copyright 2024 Shift Crypto AG
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { useContext, useEffect, useRef } from 'react';
18+
import { BackButtonContext, THandler } from '@/contexts/BackButtonContext';
19+
20+
/**
21+
* The Android back button will call this handler while this hook is active.
22+
* The handler can perform an action and:
23+
* - return false to stop any further action
24+
* - return true for Android to perform the default back operation, which is going back in browser
25+
* history if possible, or prompting to quit the app.
26+
*/
27+
export const useBackButton = (handler: THandler) => {
28+
const { pushHandler, popHandler } = useContext(BackButtonContext);
29+
30+
// We don't want to re-trigger the handler effect below when the handler changes, no need to
31+
// repeat the push/pop pair unnecessarily.
32+
const handlerRef = useRef<THandler>(handler);
33+
useEffect(() => {
34+
handlerRef.current = handler;
35+
}, [handler]);
36+
37+
useEffect(() => {
38+
const handler = handlerRef.current;
39+
pushHandler(handler);
40+
return () => popHandler(handler);
41+
}, [handlerRef, pushHandler, popHandler]);
42+
};
43+
44+
/**
45+
* A convenience component that makes sure useBackButton is only used when the component is rendered.
46+
* This avoids complicated useEffect() uses to make sure useBackButton is only active depending on
47+
* rendering conditions.
48+
* This also is useful if you want to use this hook in a component that is still class-based.
49+
* MUST be unmounted before any calls to `navigate()`.
50+
*/
51+
export const UseBackButton = ({ handler }: { handler: THandler }) => {
52+
useBackButton(handler);
53+
return null;
54+
};
55+
56+
/**
57+
* Same as UseBackButton, but with a default handler that does nothing and disables the Android back
58+
* button completely.
59+
*/
60+
export const UseDisableBackButton = () => {
61+
useBackButton(() => {
62+
return false;
63+
});
64+
return null;
65+
};

frontends/web/src/routes/device/bitbox02/passphrase.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { useNavigate } from 'react-router';
2020
import { useEffect, useState } from 'react';
2121
import { getDeviceInfo, setMnemonicPassphraseEnabled } from '@/api/bitbox02';
2222
import { MultilineMarkup, SimpleMarkup } from '@/utils/markup';
23+
import { UseDisableBackButton } from '@/hooks/backbutton';
2324
import { Main } from '@/components/layout';
2425
import { Button, Checkbox } from '@/components/forms';
2526
import { alertUser } from '@/components/alert/Alert';
@@ -62,7 +63,7 @@ export const Passphrase = ({ deviceID }: TProps) => {
6263
try {
6364
const result = await setMnemonicPassphraseEnabled(deviceID, enabled);
6465
if (!result.success) {
65-
navigate(`/settings/device-settings/${deviceID}`);
66+
navigate(-1);
6667
alertUser(t(`passphrase.error.e${result.code}`, {
6768
defaultValue: result.message || t('genericError'),
6869
}));
@@ -75,7 +76,7 @@ export const Passphrase = ({ deviceID }: TProps) => {
7576
}
7677
};
7778

78-
const handleAbort = () => navigate(`/settings/device-settings/${deviceID}`);
79+
const handleAbort = () => navigate(-1);
7980

8081
if (isEnabled === undefined) {
8182
return null;
@@ -114,6 +115,7 @@ export const Passphrase = ({ deviceID }: TProps) => {
114115
: 'passphrase.progressEnable.message')} />
115116
</ViewHeader>
116117
<ViewContent>
118+
<UseDisableBackButton />
117119
<PointToBitBox02 />
118120
</ViewContent>
119121
</View>

0 commit comments

Comments
 (0)