Skip to content

Commit 39f90f8

Browse files
committed
Merge branch 'goback4'
2 parents ccc3d38 + ef225d1 commit 39f90f8

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

+43-21
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

+4
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

+9
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

+4-2
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 && (
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

+12-9
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

+2
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

+65
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

+4-2
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)