Skip to content

Commit

Permalink
feat: add phone lock (pin, biometrics, etc) (#120)
Browse files Browse the repository at this point in the history
* feat: add biometrics

* chore: add alert in security page

* chore: remove switch colors from constants

* fix: copy & component usage

* chore: fix inactivity threshold and use key constant

* chore: use boolean to string method

* chore: do not store is biometric supported

---------

Co-authored-by: René Aaron <rene@twentyuno.net>
  • Loading branch information
im-adithya and reneaaron authored Sep 19, 2024
1 parent 77f42dc commit 0d03586
Show file tree
Hide file tree
Showing 15 changed files with 406 additions and 5 deletions.
6 changes: 6 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
"**/*"
],
"plugins": [
[
"expo-local-authentication",
{
"faceIDPermission": "Allow Alby Go to use Face ID."
}
],
[
"expo-camera",
{
Expand Down
29 changes: 25 additions & 4 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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();
Expand All @@ -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"),
Expand All @@ -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 {
Expand All @@ -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;
}
Expand All @@ -102,7 +121,9 @@ export default function RootLayout() {
<StatusBar style={isDarkColorScheme ? "light" : "dark"} />
<PolyfillCrypto />
<SafeAreaView className="w-full h-full bg-background">
<Stack />
<UserInactivityProvider>
<Stack />
</UserInactivityProvider>
<Toast config={toastConfig} position="bottom" bottomOffset={140} topOffset={140} />
<PortalHost />
</SafeAreaView>
Expand Down
5 changes: 5 additions & 0 deletions app/settings/security.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Security } from "../../pages/settings/Security";

export default function Page() {
return <Security />;
}
5 changes: 5 additions & 0 deletions app/unlock.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Unlock } from "../pages/Unlock";

export default function Page() {
return <Unlock />;
}
6 changes: 6 additions & 0 deletions components/Icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,10 @@ import {
CameraOff,
Palette,
Egg,
Fingerprint,
HelpCircle,
CircleCheck,
TriangleAlert,
} from "lucide-react-native";
import { cssInterop } from "nativewind";

Expand Down Expand Up @@ -85,8 +87,10 @@ interopIcon(Power);
interopIcon(CameraOff);
interopIcon(Palette);
interopIcon(Egg);
interopIcon(Fingerprint);
interopIcon(HelpCircle);
interopIcon(CircleCheck);
interopIcon(TriangleAlert);

export {
AlertCircle,
Expand Down Expand Up @@ -123,6 +127,8 @@ export {
Power,
Palette,
Egg,
Fingerprint,
HelpCircle,
CircleCheck,
TriangleAlert,
};
96 changes: 96 additions & 0 deletions components/ui/switch.tsx
Original file line number Diff line number Diff line change
@@ -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<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
'peer flex-row h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed',
props.checked ? 'bg-amber-300' : 'bg-input',
props.disabled && 'opacity-50',
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
'pointer-events-none block h-5 w-5 rounded-full bg-background shadow-md shadow-foreground/5 ring-0 transition-transform',
props.checked ? 'translate-x-5' : 'translate-x-0'
)}
/>
</SwitchPrimitives.Root>
));

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<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ 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 (
<Animated.View
style={animatedRootStyle}
className={cn('h-8 w-[46px] rounded-full', props.disabled && 'opacity-50')}
>
<SwitchPrimitives.Root
className={cn(
'flex-row h-8 w-[46px] shrink-0 items-center rounded-full border-2 border-transparent',
className
)}
{...props}
ref={ref}
>
<Animated.View style={animatedThumbStyle}>
<SwitchPrimitives.Thumb
className={'h-7 w-7 rounded-full bg-background shadow-md shadow-foreground/25 ring-0'}
/>
</Animated.View>
</SwitchPrimitives.Root>
</Animated.View>
);
});
SwitchNative.displayName = 'SwitchNative';

const Switch = Platform.select({
web: SwitchWeb,
default: SwitchNative,
});

export { Switch };
42 changes: 42 additions & 0 deletions context/UserInactivity.tsx
Original file line number Diff line number Diff line change
@@ -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<AppStateStatus>(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;
}
2 changes: 2 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions lib/isBiometricSupported.ts
Original file line number Diff line number Diff line change
@@ -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
}
25 changes: 25 additions & 0 deletions lib/state/appStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,22 @@ 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;
updateCurrentWallet(wallet: Partial<Wallet>): void;
removeCurrentWallet(): void;
setFiatCurrency(fiatCurrency: string): void;
setSelectedWalletId(walletId: number): void;
setSecurityEnabled(securityEnabled: boolean): void;
addWallet(wallet: Wallet): void;
addAddressBookEntry(entry: AddressBookEntry): void;
reset(): void;
Expand All @@ -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;
Expand Down Expand Up @@ -132,15 +138,23 @@ export const useAppStore = create<AppState>()((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({
Expand All @@ -154,6 +168,13 @@ export const useAppStore = create<AppState>()((set, get) => {
nostrWalletConnectUrl,
});
},
setSecurityEnabled: (isEnabled) => {
secureStorage.setItem(isSecurityEnabledKey, isEnabled.toString());
set({
isSecurityEnabled: isEnabled,
...(!isEnabled ? { unlocked: true } : {}),
});
},
setFiatCurrency: (fiatCurrency) => {
secureStorage.setItem(fiatCurrencyKey, fiatCurrency);
set({ fiatCurrency });
Expand Down Expand Up @@ -203,6 +224,9 @@ export const useAppStore = create<AppState>()((set, get) => {
// clear fiat currency
secureStorage.removeItem(fiatCurrencyKey);

// clear security enabled status
secureStorage.removeItem(isSecurityEnabledKey);

// clear onboarding status
secureStorage.removeItem(hasOnboardedKey);

Expand All @@ -216,6 +240,7 @@ export const useAppStore = create<AppState>()((set, get) => {
selectedWalletId: 0,
wallets: [{}],
addressBookEntries: [],
isSecurityEnabled: false,
});
},
};
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
Loading

0 comments on commit 0d03586

Please sign in to comment.