Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add phone lock (pin, biometrics, etc) #120

Merged
merged 8 commits into from
Sep 19, 2024
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@
},
"assetBundlePatterns": ["**/*"],
"plugins": [
[
"expo-local-authentication",
{
"faceIDPermission": "Allow Alby Go to use Face ID."
}
],
[
"expo-camera",
{
Expand Down
32 changes: 28 additions & 4 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { StatusBar } from "expo-status-bar";
import * as React from "react";
import { SafeAreaView } from "react-native";
import * as LocalAuthentication from "expo-local-authentication";
import { NAV_THEME } from "~/lib/constants";
import { useColorScheme } from "~/lib/useColorScheme";
import PolyfillCrypto from "react-native-webview-crypto";
Expand All @@ -21,7 +22,9 @@ 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';

const LIGHT_THEME: Theme = {
Expand Down Expand Up @@ -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,23 @@ export default function RootLayout() {
setFontsLoaded(true);
}

async function checkBiometricStatus() {
const compatible = await LocalAuthentication.hasHardwareAsync();
const securityLevel = await LocalAuthentication.getEnrolledLevelAsync();
if (compatible && securityLevel > 0) {
useAppStore.getState().setBiometricSupported(true);
} else {
useAppStore.getState().setBiometricSupported(false);
}
}

React.useEffect(() => {
const init = async () => {
try {
await Promise.all([
checkOnboardingStatus(),
loadFonts(),
checkBiometricStatus(),
]);
}
finally {
Expand All @@ -89,9 +104,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 +124,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 { 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/)) {
im-adithya marked this conversation as resolved.
Show resolved Hide resolved
const now = Date.now();
secureStorage.setItem("lastActiveTime", now.toString());
} else if (appState.match(/inactive|background/) && nextState === "active") {
const lastActiveTime = secureStorage.getItem("lastActiveTime");
if (lastActiveTime) {
const timeElapsed = Date.now() - parseInt(lastActiveTime, 10);
if (timeElapsed >= INACTIVITY_THRESHOLD) {
useAppStore.getState().setUnlocked(false)
}
}
await secureStorage.removeItem("lastActiveTime");
}
setAppState(nextState);
};

React.useEffect(() => {
let subscription: NativeEventSubscription
if (isSecurityEnabled) {
reneaaron marked this conversation as resolved.
Show resolved Hide resolved
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 * 1000 // 5 * 60 * 1000;
im-adithya marked this conversation as resolved.
Show resolved Hide resolved

export const CURSOR_COLOR = "hsl(47 100% 72%)";

export const TRANSACTIONS_PAGE_SIZE = 20;
Expand Down
35 changes: 35 additions & 0 deletions lib/state/appStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@ 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;
readonly isBiometricSupported: 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;
setBiometricSupported(isSupported: boolean): void;
addWallet(wallet: Wallet): void;
addAddressBookEntry(entry: AddressBookEntry): void;
reset(): void;
Expand All @@ -26,7 +32,10 @@ const walletKeyPrefix = "wallet";
const addressBookEntryKeyPrefix = "addressBookEntry";
const selectedWalletIdKey = "selectedWalletId";
const fiatCurrencyKey = "fiatCurrency";
export const isSecurityEnabledKey = "isSecurityEnabled";
export const isBiometricSupportedKey = "isBiometricSupported";
export const hasOnboardedKey = "hasOnboarded";
export const lastActiveTimeKey = "lastActiveTime";

type Wallet = {
name?: string;
Expand Down Expand Up @@ -124,15 +133,25 @@ export const useAppStore = create<AppState>()((set, get) => {
const initialSelectedWalletId = +(
secureStorage.getItem(selectedWalletIdKey) || "0"
);

const isBiometricSupported = secureStorage.getItem(isBiometricSupportedKey) === "true";
im-adithya marked this conversation as resolved.
Show resolved Hide resolved
const iSecurityEnabled = isBiometricSupported && secureStorage.getItem(isSecurityEnabledKey) === "true";

const initialWallets = loadWallets();
return {
unlocked: !iSecurityEnabled,
addressBookEntries: loadAddressBookEntries(),
wallets: initialWallets,
nwcClient: getNWCClient(initialSelectedWalletId),
fiatCurrency: secureStorage.getItem(fiatCurrencyKey) || "",
isSecurityEnabled: iSecurityEnabled,
isBiometricSupported: isBiometricSupported,
selectedWalletId: initialSelectedWalletId,
updateCurrentWallet,
removeCurrentWallet,
setUnlocked: (unlocked) => {
set({ unlocked });
},
setNWCClient: (nwcClient) => set({ nwcClient }),
removeNostrWalletConnectUrl: () => {
updateCurrentWallet({
Expand All @@ -146,6 +165,18 @@ export const useAppStore = create<AppState>()((set, get) => {
nostrWalletConnectUrl,
});
},
setSecurityEnabled: (isEnabled) => {
secureStorage.setItem(isSecurityEnabledKey, isEnabled ? "true" : "false");
set({ isSecurityEnabled: isEnabled });
},
setBiometricSupported: (isSupported) => {
secureStorage.setItem(isBiometricSupportedKey, isSupported ? "true" : "false");
im-adithya marked this conversation as resolved.
Show resolved Hide resolved
set({ isBiometricSupported: isSupported });
if (!isSupported) {
secureStorage.setItem(isSecurityEnabledKey, "false");
set({ isSecurityEnabled: false, unlocked: true });
}
},
setFiatCurrency: (fiatCurrency) => {
secureStorage.setItem(fiatCurrencyKey, fiatCurrency);
set({ fiatCurrency });
Expand Down Expand Up @@ -194,6 +225,9 @@ export const useAppStore = create<AppState>()((set, get) => {
// clear selected wallet ID
secureStorage.removeItem(selectedWalletIdKey);

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

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

Expand All @@ -203,6 +237,7 @@ export const useAppStore = create<AppState>()((set, get) => {
selectedWalletId: undefined,
wallets: [],
addressBookEntries: [],
isSecurityEnabled: false,
});
},
};
Expand Down
Loading
Loading