diff --git a/app.json b/app.json
index 74d4a8e..76e9b05 100644
--- a/app.json
+++ b/app.json
@@ -20,6 +20,12 @@
"**/*"
],
"plugins": [
+ [
+ "expo-local-authentication",
+ {
+ "faceIDPermission": "Allow Alby Go to use Face ID."
+ }
+ ],
[
"expo-camera",
{
diff --git a/app/_layout.tsx b/app/_layout.tsx
index 2021613..7cdc74f 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -21,8 +21,11 @@ import { toastConfig } from "~/components/ToastConfig";
import * as Font from "expo-font";
import { useInfo } from "~/hooks/useInfo";
import { secureStorage } from "~/lib/secureStorage";
-import { hasOnboardedKey } from "~/lib/state/appStore";
+import { hasOnboardedKey, useAppStore } from "~/lib/state/appStore";
+import { usePathname } from "expo-router";
+import { UserInactivityProvider } from "~/context/UserInactivity";
import { PortalHost } from '@rn-primitives/portal';
+import { isBiometricSupported } from "~/lib/isBiometricSupported";
const LIGHT_THEME: Theme = {
dark: false,
@@ -49,6 +52,8 @@ export default function RootLayout() {
const { isDarkColorScheme } = useColorScheme();
const [fontsLoaded, setFontsLoaded] = React.useState(false);
const [checkedOnboarding, setCheckedOnboarding] = React.useState(false);
+ const isUnlocked = useAppStore((store) => store.unlocked);
+ const pathname = usePathname();
useConnectionChecker();
const rootNavigationState = useRootNavigationState();
@@ -64,7 +69,6 @@ export default function RootLayout() {
};
async function loadFonts() {
-
await Font.loadAsync({
OpenRunde: require("./../assets/fonts/OpenRunde-Regular.otf"),
"OpenRunde-Medium": require("./../assets/fonts/OpenRunde-Medium.otf"),
@@ -75,12 +79,20 @@ export default function RootLayout() {
setFontsLoaded(true);
}
+ async function checkBiometricStatus() {
+ const isSupported = await isBiometricSupported()
+ if (!isSupported) {
+ useAppStore.getState().setSecurityEnabled(false);
+ }
+ }
+
React.useEffect(() => {
const init = async () => {
try {
await Promise.all([
checkOnboardingStatus(),
loadFonts(),
+ checkBiometricStatus(),
]);
}
finally {
@@ -89,9 +101,16 @@ export default function RootLayout() {
};
init();
-
}, [hasNavigationState]);
+ React.useEffect(() => {
+ if (hasNavigationState && !isUnlocked) {
+ if (pathname !== "/unlock") {
+ router.push("/unlock");
+ }
+ }
+ }, [isUnlocked, hasNavigationState]);
+
if (!fontsLoaded || !checkedOnboarding) {
return null;
}
@@ -102,7 +121,9 @@ export default function RootLayout() {
-
+
+
+
diff --git a/app/settings/security.js b/app/settings/security.js
new file mode 100644
index 0000000..1dca538
--- /dev/null
+++ b/app/settings/security.js
@@ -0,0 +1,5 @@
+import { Security } from "../../pages/settings/Security";
+
+export default function Page() {
+ return ;
+}
diff --git a/app/unlock.js b/app/unlock.js
new file mode 100644
index 0000000..b17a77d
--- /dev/null
+++ b/app/unlock.js
@@ -0,0 +1,5 @@
+import { Unlock } from "../pages/Unlock";
+
+export default function Page() {
+ return ;
+}
diff --git a/components/Icons.tsx b/components/Icons.tsx
index a40dbac..51d14a2 100644
--- a/components/Icons.tsx
+++ b/components/Icons.tsx
@@ -34,8 +34,10 @@ import {
CameraOff,
Palette,
Egg,
+ Fingerprint,
HelpCircle,
CircleCheck,
+ TriangleAlert,
} from "lucide-react-native";
import { cssInterop } from "nativewind";
@@ -85,8 +87,10 @@ interopIcon(Power);
interopIcon(CameraOff);
interopIcon(Palette);
interopIcon(Egg);
+interopIcon(Fingerprint);
interopIcon(HelpCircle);
interopIcon(CircleCheck);
+interopIcon(TriangleAlert);
export {
AlertCircle,
@@ -123,6 +127,8 @@ export {
Power,
Palette,
Egg,
+ Fingerprint,
HelpCircle,
CircleCheck,
+ TriangleAlert,
};
diff --git a/components/ui/switch.tsx b/components/ui/switch.tsx
new file mode 100644
index 0000000..951716d
--- /dev/null
+++ b/components/ui/switch.tsx
@@ -0,0 +1,96 @@
+import * as SwitchPrimitives from '@rn-primitives/switch';
+import * as React from 'react';
+import { Platform } from 'react-native';
+import Animated, {
+ interpolateColor,
+ useAnimatedStyle,
+ useDerivedValue,
+ withTiming,
+} from 'react-native-reanimated';
+import { useColorScheme } from '~/lib/useColorScheme';
+import { cn } from '~/lib/utils';
+
+const SwitchWeb = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => (
+
+
+
+));
+
+SwitchWeb.displayName = 'SwitchWeb';
+
+const RGB_COLORS = {
+ light: {
+ primary: 'rgb(255, 224, 112)',
+ input: 'rgb(228, 228, 231)',
+ },
+ dark: {
+ primary: 'rgb(255, 224, 112)',
+ input: 'rgb(228, 228, 231)',
+ },
+} as const;
+
+const SwitchNative = React.forwardRef<
+ React.ElementRef,
+ React.ComponentPropsWithoutRef
+>(({ className, ...props }, ref) => {
+ const { colorScheme } = useColorScheme();
+ const translateX = useDerivedValue(() => (props.checked ? 18 : 0));
+ const animatedRootStyle = useAnimatedStyle(() => {
+ return {
+ backgroundColor: interpolateColor(
+ Number(props.checked),
+ [0, 1],
+ [RGB_COLORS[colorScheme].input, RGB_COLORS[colorScheme].primary]
+ ),
+ };
+ });
+ const animatedThumbStyle = useAnimatedStyle(() => ({
+ transform: [{ translateX: withTiming(translateX.value, { duration: 200 }) }],
+ }));
+ return (
+
+
+
+
+
+
+
+ );
+});
+SwitchNative.displayName = 'SwitchNative';
+
+const Switch = Platform.select({
+ web: SwitchWeb,
+ default: SwitchNative,
+});
+
+export { Switch };
diff --git a/context/UserInactivity.tsx b/context/UserInactivity.tsx
new file mode 100644
index 0000000..2133fd7
--- /dev/null
+++ b/context/UserInactivity.tsx
@@ -0,0 +1,42 @@
+import * as React from "react";
+import { AppState, AppStateStatus, NativeEventSubscription } from 'react-native';
+import { secureStorage } from "~/lib/secureStorage";
+import { INACTIVITY_THRESHOLD } from "~/lib/constants";
+import { lastActiveTimeKey, useAppStore } from "~/lib/state/appStore";
+
+export const UserInactivityProvider = ({ children }: any) => {
+ const [appState, setAppState] = React.useState(AppState.currentState);
+ const isSecurityEnabled = useAppStore((store) => store.isSecurityEnabled);
+
+ const handleAppStateChange = async (nextState: AppStateStatus) => {
+ if (appState === "active" && nextState.match(/inactive|background/)) {
+ const now = Date.now();
+ secureStorage.setItem(lastActiveTimeKey, now.toString());
+ } else if (appState.match(/inactive|background/) && nextState === "active") {
+ const lastActiveTime = secureStorage.getItem(lastActiveTimeKey);
+ if (lastActiveTime) {
+ const timeElapsed = Date.now() - parseInt(lastActiveTime, 10);
+ if (timeElapsed >= INACTIVITY_THRESHOLD) {
+ useAppStore.getState().setUnlocked(false)
+ }
+ }
+ await secureStorage.removeItem(lastActiveTimeKey);
+ }
+ setAppState(nextState);
+ };
+
+ React.useEffect(() => {
+ let subscription: NativeEventSubscription
+ if (isSecurityEnabled) {
+ subscription = AppState.addEventListener("change", handleAppStateChange);
+ }
+
+ return () => {
+ if (subscription) {
+ subscription.remove();
+ }
+ };
+ }, [appState, isSecurityEnabled]);
+
+ return children;
+}
\ No newline at end of file
diff --git a/lib/constants.ts b/lib/constants.ts
index 46c2eae..5cae282 100644
--- a/lib/constants.ts
+++ b/lib/constants.ts
@@ -17,6 +17,8 @@ export const NAV_THEME = {
},
};
+export const INACTIVITY_THRESHOLD = 5 * 60 * 1000;
+
export const CURSOR_COLOR = "hsl(47 100% 72%)";
export const TRANSACTIONS_PAGE_SIZE = 20;
diff --git a/lib/isBiometricSupported.ts b/lib/isBiometricSupported.ts
new file mode 100644
index 0000000..bcbefd4
--- /dev/null
+++ b/lib/isBiometricSupported.ts
@@ -0,0 +1,7 @@
+import * as LocalAuthentication from "expo-local-authentication";
+
+export async function isBiometricSupported() {
+ const compatible = await LocalAuthentication.hasHardwareAsync();
+ const securityLevel = await LocalAuthentication.getEnrolledLevelAsync();
+ return compatible && securityLevel > 0
+}
\ No newline at end of file
diff --git a/lib/state/appStore.ts b/lib/state/appStore.ts
index 328b98a..fa5f151 100644
--- a/lib/state/appStore.ts
+++ b/lib/state/appStore.ts
@@ -4,11 +4,14 @@ import { nwc } from "@getalby/sdk";
import { secureStorage } from "lib/secureStorage";
interface AppState {
+ readonly unlocked: boolean;
readonly nwcClient: NWCClient | undefined;
readonly fiatCurrency: string;
readonly selectedWalletId: number;
readonly wallets: Wallet[];
readonly addressBookEntries: AddressBookEntry[];
+ readonly isSecurityEnabled: boolean;
+ setUnlocked: (unlocked: boolean) => void;
setNWCClient: (nwcClient: NWCClient | undefined) => void;
setNostrWalletConnectUrl(nostrWalletConnectUrl: string): void;
removeNostrWalletConnectUrl(): void;
@@ -16,6 +19,7 @@ interface AppState {
removeCurrentWallet(): void;
setFiatCurrency(fiatCurrency: string): void;
setSelectedWalletId(walletId: number): void;
+ setSecurityEnabled(securityEnabled: boolean): void;
addWallet(wallet: Wallet): void;
addAddressBookEntry(entry: AddressBookEntry): void;
reset(): void;
@@ -26,7 +30,9 @@ const walletKeyPrefix = "wallet";
const addressBookEntryKeyPrefix = "addressBookEntry";
const selectedWalletIdKey = "selectedWalletId";
const fiatCurrencyKey = "fiatCurrency";
+export const isSecurityEnabledKey = "isSecurityEnabled";
export const hasOnboardedKey = "hasOnboarded";
+export const lastActiveTimeKey = "lastActiveTime";
type Wallet = {
name?: string;
@@ -132,15 +138,23 @@ export const useAppStore = create()((set, get) => {
const initialSelectedWalletId = +(
secureStorage.getItem(selectedWalletIdKey) || "0"
);
+
+ const iSecurityEnabled = secureStorage.getItem(isSecurityEnabledKey) === "true";
+
const initialWallets = loadWallets();
return {
+ unlocked: !iSecurityEnabled,
addressBookEntries: loadAddressBookEntries(),
wallets: initialWallets,
nwcClient: getNWCClient(initialSelectedWalletId),
fiatCurrency: secureStorage.getItem(fiatCurrencyKey) || "",
+ isSecurityEnabled: iSecurityEnabled,
selectedWalletId: initialSelectedWalletId,
updateCurrentWallet,
removeCurrentWallet,
+ setUnlocked: (unlocked) => {
+ set({ unlocked });
+ },
setNWCClient: (nwcClient) => set({ nwcClient }),
removeNostrWalletConnectUrl: () => {
updateCurrentWallet({
@@ -154,6 +168,13 @@ export const useAppStore = create()((set, get) => {
nostrWalletConnectUrl,
});
},
+ setSecurityEnabled: (isEnabled) => {
+ secureStorage.setItem(isSecurityEnabledKey, isEnabled.toString());
+ set({
+ isSecurityEnabled: isEnabled,
+ ...(!isEnabled ? { unlocked: true } : {}),
+ });
+ },
setFiatCurrency: (fiatCurrency) => {
secureStorage.setItem(fiatCurrencyKey, fiatCurrency);
set({ fiatCurrency });
@@ -203,6 +224,9 @@ export const useAppStore = create()((set, get) => {
// clear fiat currency
secureStorage.removeItem(fiatCurrencyKey);
+ // clear security enabled status
+ secureStorage.removeItem(isSecurityEnabledKey);
+
// clear onboarding status
secureStorage.removeItem(hasOnboardedKey);
@@ -216,6 +240,7 @@ export const useAppStore = create()((set, get) => {
selectedWalletId: 0,
wallets: [{}],
addressBookEntries: [],
+ isSecurityEnabled: false,
});
},
};
diff --git a/package.json b/package.json
index b56fe95..d46ab98 100644
--- a/package.json
+++ b/package.json
@@ -21,6 +21,7 @@
"@react-native-async-storage/async-storage": "1.23.1",
"@rn-primitives/dialog": "^1.0.3",
"@rn-primitives/portal": "^1.0.3",
+ "@rn-primitives/switch": "^1.0.3",
"bech32": "^2.0.0",
"buffer": "^6.0.3",
"class-variance-authority": "^0.7.0",
@@ -34,6 +35,7 @@
"expo-font": "^12.0.9",
"expo-linear-gradient": "~13.0.2",
"expo-linking": "~6.3.1",
+ "expo-local-authentication": "~14.0.1",
"expo-router": "^3.5.23",
"expo-secure-store": "^13.0.2",
"expo-status-bar": "~1.12.1",
diff --git a/pages/Unlock.tsx b/pages/Unlock.tsx
new file mode 100644
index 0000000..d2110b0
--- /dev/null
+++ b/pages/Unlock.tsx
@@ -0,0 +1,56 @@
+import { router, Stack } from "expo-router";
+import React from "react";
+import { View, Image } from "react-native";
+import * as LocalAuthentication from "expo-local-authentication";
+
+import { Button } from "~/components/ui/button";
+import { Text } from "~/components/ui/text";
+import { useAppStore } from "~/lib/state/appStore";
+
+export function Unlock() {
+ const [isUnlocking, setIsUnlocking] = React.useState(false);
+
+ const handleUnlock = async () => {
+ try {
+ setIsUnlocking(true);
+ const biometricAuth = await LocalAuthentication.authenticateAsync({
+ promptMessage: "Unlock Alby Go",
+ });
+ if (biometricAuth.success) {
+ useAppStore.getState().setUnlocked(true);
+ if (router.canGoBack()) {
+ router.back();
+ } else {
+ router.replace("/");
+ }
+ }
+ } finally {
+ setIsUnlocking(false);
+ }
+ };
+
+ React.useEffect(() => {
+ handleUnlock();
+ }, []);
+
+ return (
+
+
+
+
+ Unlock to continue
+
+
+
+ );
+}
diff --git a/pages/settings/Security.tsx b/pages/settings/Security.tsx
new file mode 100644
index 0000000..ea43809
--- /dev/null
+++ b/pages/settings/Security.tsx
@@ -0,0 +1,78 @@
+import React from "react";
+import { Text, View } from "react-native";
+import { TriangleAlert } from "~/components/Icons";
+import Loading from "~/components/Loading";
+import Screen from "~/components/Screen";
+import {
+ Card,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from "~/components/ui/card";
+import { Label } from "~/components/ui/label";
+import { Switch } from "~/components/ui/switch";
+import { isBiometricSupported } from "~/lib/isBiometricSupported";
+import { useAppStore } from "~/lib/state/appStore";
+import { cn } from "~/lib/utils";
+
+export function Security() {
+ const [isSupported, setIsSupported] = React.useState(null);
+ const isEnabled = useAppStore((store) => store.isSecurityEnabled);
+
+ React.useEffect(() => {
+ async function checkBiometricSupport() {
+ const supported = await isBiometricSupported();
+ setIsSupported(supported);
+ }
+ checkBiometricSupport();
+ }, []);
+
+ return (
+
+
+ {isSupported === null ? (
+
+
+
+ ) : (
+ <>
+ {!isSupported && (
+
+
+
+
+ Setup Device Security
+
+
+ To protect your wallet, please set up a phone lock in your device settings first.
+
+
+
+ )}
+
+
+
+ {
+ useAppStore.getState().setSecurityEnabled(!isEnabled);
+ }}
+ nativeID="security"
+ />
+
+
+ >
+ )}
+
+ );
+}
diff --git a/pages/settings/Settings.tsx b/pages/settings/Settings.tsx
index ca78a08..67ca840 100644
--- a/pages/settings/Settings.tsx
+++ b/pages/settings/Settings.tsx
@@ -1,6 +1,6 @@
import { Link, router } from "expo-router";
import { Alert, TouchableOpacity, View } from "react-native";
-import { Bitcoin, Egg, Palette, Power, Wallet2 } from "~/components/Icons";
+import { Bitcoin, Egg, Fingerprint, Palette, Power, Wallet2 } from "~/components/Icons";
import { DEFAULT_CURRENCY, DEFAULT_WALLET_NAME } from "~/lib/constants";
import { useAppStore } from "~/lib/state/appStore";
@@ -43,6 +43,15 @@ export function Settings() {
+
+
+
+
+ Security
+
+
+
+