diff --git a/app/_layout.tsx b/app/_layout.tsx index 29986fe..6674e8e 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -22,9 +22,12 @@ import { UserInactivityProvider } from "~/context/UserInactivity"; import "~/global.css"; import { SessionProvider } from "~/hooks/useSession"; import { IS_EXPO_GO, THEME_COLORS } from "~/lib/constants"; +import { errorToast } from "~/lib/errorToast"; import { isBiometricSupported } from "~/lib/isBiometricSupported"; +import { sweepOrphanedWalletInfo } from "~/lib/notificationsNativeStorage"; import { useAppStore } from "~/lib/state/appStore"; import { useColorScheme } from "~/lib/useColorScheme"; +import { getPubkeyFromNWCUrl } from "~/lib/utils"; import { registerForPushNotificationsAsync } from "~/services/Notifications"; Sentry.init({ @@ -78,6 +81,18 @@ export default Sentry.wrap(function RootLayout() { } } + // Removes any stored notification data for wallets that no longer exist + // in the app (e.g. left behind by a previous app version). + async function sweepOrphanedNotificationData() { + const activePublicKeys = useAppStore + .getState() + .wallets.map((wallet) => + getPubkeyFromNWCUrl(wallet.nostrWalletConnectUrl ?? ""), + ) + .filter((publicKey): publicKey is string => !!publicKey); + await sweepOrphanedWalletInfo(activePublicKeys); + } + const loadTheme = React.useCallback((): Promise => { return new Promise((resolve) => { const theme = useAppStore.getState().theme; @@ -96,10 +111,16 @@ export default Sentry.wrap(function RootLayout() { await Promise.all([loadTheme(), loadFonts(), checkBiometricStatus()]); } finally { setResourcesLoaded(true); - if (!IS_EXPO_GO) { - await checkAndPromptForNotifications(); + try { + if (!IS_EXPO_GO) { + await checkAndPromptForNotifications(); + await sweepOrphanedNotificationData(); + } + } catch (error) { + errorToast(error, "Failed to set up notifications"); + } finally { + SplashScreen.hide(); } - SplashScreen.hide(); } }; diff --git a/lib/notifications.ts b/lib/notifications.ts index 6ae8d18..28973d2 100644 --- a/lib/notifications.ts +++ b/lib/notifications.ts @@ -65,7 +65,7 @@ export async function registerWalletNotifications( walletId, ); } else { - new Error(`Error: ${response.status} ${response.statusText}`); + throw new Error(`Error: ${response.status} ${response.statusText}`); } const walletData: WalletInfo = { @@ -95,31 +95,28 @@ export async function deregisterWalletNotifications( if (!wallet.pushId) { return; } - try { - // TODO: wallets with the same secret if added will have the same token, - // hence deregistering one might make others not work but will show - // as ON because their push ids are not removed from the wallet store - const response = await fetch( - `${NOSTR_API_URL}/subscriptions/${wallet.pushId}`, - { - method: "DELETE", - }, - ); - // FIXME: if deregistering fails, app will keep receiving notifications from the server - if (!response.ok) { - throw new Error("Failed to deregister push notifications"); - } - useAppStore.getState().updateWallet( - { - pushId: "", - }, - walletId, - ); - const pubkey = getPubkeyFromNWCUrl(wallet.nostrWalletConnectUrl ?? ""); - if (pubkey) { - await removeWalletInfo(pubkey); - } - } catch (error) { - errorToast(error); + // TODO: wallets with the same secret if added will have the same token, + // hence deregistering one might make others not work but will show + // as ON because their push ids are not removed from the wallet store + const response = await fetch( + `${NOSTR_API_URL}/subscriptions/${wallet.pushId}`, + { + method: "DELETE", + }, + ); + // Callers must not remove local wallet/notification data if this throws, + // otherwise the remote subscription is orphaned with no pushId to retry. + if (!response.ok) { + throw new Error("Failed to deregister push notifications"); + } + useAppStore.getState().updateWallet( + { + pushId: "", + }, + walletId, + ); + const pubkey = getPubkeyFromNWCUrl(wallet.nostrWalletConnectUrl ?? ""); + if (pubkey) { + await removeWalletInfo(pubkey); } } diff --git a/lib/notificationsNativeStorage.ts b/lib/notificationsNativeStorage.ts index d598d7b..712be85 100644 --- a/lib/notificationsNativeStorage.ts +++ b/lib/notificationsNativeStorage.ts @@ -1,6 +1,6 @@ import { Platform } from "react-native"; import { IS_EXPO_GO, SUITE_NAME } from "~/lib/constants"; -import { BitcoinDisplayFormat } from "~/lib/state/appStore"; +import type { BitcoinDisplayFormat } from "~/lib/state/appStore"; // this is done because accessing values stored from expo-secure-store // is quite difficult and we do not wish to complicate the notification @@ -132,3 +132,49 @@ function removeWallet(wallets: Wallets, publicKey: string): Wallets { } return wallets; } + +// Removes any stored wallet notification data whose public key does not +// belong to a currently-configured wallet. This catches entries left behind +// by earlier app versions or edge cases (e.g. a crash between removing a +// wallet and its notification data being cleared). +export async function sweepOrphanedWalletInfo(activePublicKeys: string[]) { + if (IS_EXPO_GO) { + return; + } + const activeSet = new Set(activePublicKeys); + if (Platform.OS === "ios") { + const UserDefaults = await getUserDefaultsModule(); + const groupDefaults = new UserDefaults(SUITE_NAME); + const wallets: Wallets | undefined = await groupDefaults.get("wallets"); + if (!wallets) { + return; + } + const orphanedPublicKeys = Object.keys(wallets).filter( + (publicKey) => !activeSet.has(publicKey), + ); + if (!orphanedPublicKeys.length) { + return; + } + for (const publicKey of orphanedPublicKeys) { + delete wallets[publicKey]; + } + await groupDefaults.set("wallets", wallets); + } else { + const SharedPreferences = await getSharedPreferencesModule(); + const walletsString = await SharedPreferences.getItemAsync("wallets"); + if (!walletsString) { + return; + } + const wallets: Wallets = JSON.parse(walletsString); + const orphanedPublicKeys = Object.keys(wallets).filter( + (publicKey) => !activeSet.has(publicKey), + ); + if (!orphanedPublicKeys.length) { + return; + } + for (const publicKey of orphanedPublicKeys) { + delete wallets[publicKey]; + } + await SharedPreferences.setItemAsync("wallets", JSON.stringify(wallets)); + } +} diff --git a/lib/state/appStore.ts b/lib/state/appStore.ts index 4450ce2..578877d 100644 --- a/lib/state/appStore.ts +++ b/lib/state/appStore.ts @@ -2,6 +2,7 @@ import { NWCClient, type Nip47Capability } from "@getalby/sdk/nwc"; import { hexToBytes } from "@noble/hashes/utils.js"; import { getPublicKey } from "nostr-tools"; import { create } from "zustand"; +import { removeAllInfo } from "~/lib/notificationsNativeStorage"; import { secureStorage } from "../secureStorage"; interface AppState { @@ -40,7 +41,7 @@ interface AppState { addWallet(wallet: Wallet): void; addAddressBookEntry(entry: AddressBookEntry): void; removeAddressBookEntry: (index: number) => void; - reset(): void; + reset(): Promise; getLastAlbyPayment(): Date | null; updateLastAlbyPayment(): void; } @@ -353,7 +354,9 @@ export const useAppStore = create()((set, get) => { set({ lastAppStateChangeTime, }), - reset() { + async reset() { + await removeAllInfo(); + // clear wallets for (let i = 0; i < get().wallets.length; i++) { secureStorage.removeItem(getWalletKey(i)); diff --git a/pages/settings/Notifications.tsx b/pages/settings/Notifications.tsx index 9acc97d..070fe17 100644 --- a/pages/settings/Notifications.tsx +++ b/pages/settings/Notifications.tsx @@ -34,19 +34,24 @@ export function Notifications() { enabled = await registerForPushNotificationsAsync(); } else { const wallets = useAppStore.getState().wallets; - for (const [id, wallet] of wallets.entries()) { - await deregisterWalletNotifications(wallet, id); - } - enabled = useAppStore.getState().wallets.some((wallet) => wallet.pushId); - if (enabled) { - errorToast(new Error("Failed to deregister notifications")); - } else { - if (ttsNotificationsEnabled) { - useAppStore.getState().setTTSNotificationsEnabled(false); - await setNotificationSettings({ - ttsEnabled: false, - }); + try { + for (const [id, wallet] of wallets.entries()) { + await deregisterWalletNotifications(wallet, id); } + } catch (error) { + errorToast( + error, + "Failed to deregister notifications, please try again", + ); + setLoading(false); + return; + } + enabled = false; + if (ttsNotificationsEnabled) { + useAppStore.getState().setTTSNotificationsEnabled(false); + await setNotificationSettings({ + ttsEnabled: false, + }); } } useAppStore.getState().setNotificationsEnabled(enabled); @@ -169,7 +174,16 @@ function WalletNotificationSwitch({ if (!checked) { await registerWalletNotifications(wallet, index); } else { - await deregisterWalletNotifications(wallet, index); + try { + await deregisterWalletNotifications(wallet, index); + } catch (error) { + errorToast( + error, + "Failed to deregister notifications, please try again", + ); + setLoading(false); + return; + } const hasNotificationsEnabled = useAppStore .getState() .wallets.some((wallet) => wallet.pushId); diff --git a/pages/settings/Settings.tsx b/pages/settings/Settings.tsx index 591aa27..4d0f8b1 100644 --- a/pages/settings/Settings.tsx +++ b/pages/settings/Settings.tsx @@ -28,8 +28,8 @@ import Screen from "~/components/Screen"; import { Text } from "~/components/ui/text"; import { useSession } from "~/hooks/useSession"; import { IS_EXPO_GO } from "~/lib/constants"; +import { errorToast } from "~/lib/errorToast"; import { deregisterWalletNotifications } from "~/lib/notifications"; -import { removeAllInfo } from "~/lib/notificationsNativeStorage"; import { useAppStore } from "~/lib/state/appStore"; import { useColorScheme } from "~/lib/useColorScheme"; import { cn } from "~/lib/utils"; @@ -280,16 +280,30 @@ export function Settings() { text: "Confirm", onPress: async () => { if (!IS_EXPO_GO) { - for (const [id, wallet] of wallets.entries()) { - await deregisterWalletNotifications( - wallet, + try { + for (const [ id, + wallet, + ] of wallets.entries()) { + // clears each wallet's remote push + // subscription and local notification + // data; reset() below also clears any + // remaining native notification data + await deregisterWalletNotifications( + wallet, + id, + ); + } + } catch (error) { + errorToast( + error, + "Failed to deregister notifications, please try again", ); + return; } - await removeAllInfo(); } router.dismissAll(); - useAppStore.getState().reset(); + await useAppStore.getState().reset(); }, }, ], diff --git a/pages/settings/wallets/EditWallet.tsx b/pages/settings/wallets/EditWallet.tsx index 9ad039b..c45a889 100644 --- a/pages/settings/wallets/EditWallet.tsx +++ b/pages/settings/wallets/EditWallet.tsx @@ -32,18 +32,24 @@ export function EditWallet() { const { id } = useLocalSearchParams() as { id: string }; const wallets = useAppStore((store) => store.wallets); const [isDeleting, setIsDeleting] = useState(false); - const isNotificationsEnabled = useAppStore( - (store) => store.isNotificationsEnabled, - ); const [showConnectionInfo, setShowConnectionInfo] = React.useState(false); let walletId = parseInt(id); const onDeleteWallet = async () => { setIsDeleting(true); - if (!IS_EXPO_GO && isNotificationsEnabled) { + if (!IS_EXPO_GO) { const wallet = wallets[walletId]; - await deregisterWalletNotifications(wallet, walletId); + try { + await deregisterWalletNotifications(wallet, walletId); + } catch (error) { + errorToast( + error, + "Failed to deregister notifications, please try again", + ); + setIsDeleting(false); + return; + } } useAppStore.getState().removeWallet(walletId); setIsDeleting(false);