From 1c6e54485d7eac3d3d04158da3ca86328965a9e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Tue, 22 Oct 2024 16:01:10 +0200 Subject: [PATCH 01/40] feat: add push notifications --- app/(app)/notifications.js | 5 ++ components/Icons.tsx | 8 ++- package.json | 2 + pages/Notifications.tsx | 135 ++++++++++++++++++++++++++++++++++++ pages/settings/Settings.tsx | 12 +++- yarn.lock | 103 +++++++++++++++++++++++++-- 6 files changed, 256 insertions(+), 9 deletions(-) create mode 100644 app/(app)/notifications.js create mode 100644 pages/Notifications.tsx diff --git a/app/(app)/notifications.js b/app/(app)/notifications.js new file mode 100644 index 0000000..09a5702 --- /dev/null +++ b/app/(app)/notifications.js @@ -0,0 +1,5 @@ +import { Notifications } from "../../pages/Notifications"; + +export default function Page() { + return ; +} diff --git a/components/Icons.tsx b/components/Icons.tsx index c0cf94f..7cb0e6f 100644 --- a/components/Icons.tsx +++ b/components/Icons.tsx @@ -3,6 +3,7 @@ import { ArchiveRestore, ArrowDown, ArrowLeftRight, + Bell, Bitcoin, BookUser, Camera, @@ -95,13 +96,13 @@ interopIcon(CircleCheck); interopIcon(TriangleAlert); interopIcon(LogOut); interopIcon(ArchiveRestore); +interopIcon(Bell); export { AlertCircle, ArchiveRestore, ArrowDown, - ArrowLeftRight, - Bitcoin, + ArrowLeftRight, Bell, Bitcoin, BookUser, Camera, CameraOff, @@ -136,5 +137,6 @@ export { WalletIcon, X, XCircle, - ZapIcon, + ZapIcon }; + diff --git a/package.json b/package.json index 7e69fa6..0d79d45 100644 --- a/package.json +++ b/package.json @@ -41,10 +41,12 @@ "expo-camera": "~15.0.16", "expo-clipboard": "~6.0.3", "expo-constants": "~16.0.2", + "expo-device": "~6.0.2", "expo-font": "~12.0.10", "expo-linear-gradient": "~13.0.2", "expo-linking": "~6.3.1", "expo-local-authentication": "~14.0.1", + "expo-notifications": "~0.28.19", "expo-router": "^3.5.23", "expo-secure-store": "^13.0.2", "expo-status-bar": "~1.12.1", diff --git a/pages/Notifications.tsx b/pages/Notifications.tsx new file mode 100644 index 0000000..33c60b6 --- /dev/null +++ b/pages/Notifications.tsx @@ -0,0 +1,135 @@ +import Constants from "expo-constants"; +import React, { useEffect, useRef, useState } from "react"; +import { View, Platform } from "react-native"; +import { Button } from "~/components/ui/button"; +import { Text } from "~/components/ui/text"; +import Screen from "~/components/Screen"; + +import * as Device from "expo-device"; +import * as ExpoNotifications from "expo-notifications"; + +ExpoNotifications.setNotificationHandler({ + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), +}); + +async function sendPushNotification(expoPushToken: string) { + const message = { + to: expoPushToken, + sound: 'default', + title: 'Original Title', + body: 'And here is the body!', + data: { someData: 'goes here' }, + }; + + await fetch('https://exp.host/--/api/v2/push/send', { + method: 'POST', + headers: { + Accept: 'application/json', + 'Accept-encoding': 'gzip, deflate', + 'Content-Type': 'application/json', + }, + body: JSON.stringify(message), + }); +} + +function handleRegistrationError(errorMessage: string) { + alert(errorMessage); + throw new Error(errorMessage); +} + +async function registerForPushNotificationsAsync() { + if (Platform.OS === 'android') { + ExpoNotifications.setNotificationChannelAsync('default', { + name: 'default', + importance: ExpoNotifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: '#FF231F7C', + }); + } + + if (Device.isDevice) { + const { status: existingStatus } = await ExpoNotifications.getPermissionsAsync(); + let finalStatus = existingStatus; + if (existingStatus !== 'granted') { + const { status } = await ExpoNotifications.requestPermissionsAsync(); + finalStatus = status; + } + if (finalStatus !== 'granted') { + handleRegistrationError('Permission not granted to get push token for push notification!'); + return; + } + const projectId = + Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId; + if (!projectId) { + handleRegistrationError('Project ID not found'); + } + try { + const pushTokenString = ( + await ExpoNotifications.getExpoPushTokenAsync({ + projectId, + }) + ).data; + console.log(pushTokenString); + return pushTokenString; + } catch (e: unknown) { + handleRegistrationError(`${e}`); + } + } else { + handleRegistrationError('Must use physical device for push notifications'); + } +} + +export function Notifications() { + const [expoPushToken, setExpoPushToken] = useState(''); + const [notification, setNotification] = useState( + undefined + ); + const notificationListener = useRef(); + const responseListener = useRef(); + + useEffect(() => { + registerForPushNotificationsAsync() + .then(token => setExpoPushToken(token ?? '')) + .catch((error: any) => setExpoPushToken(`${error}`)); + + notificationListener.current = ExpoNotifications.addNotificationReceivedListener(notification => { + setNotification(notification); + }); + + responseListener.current = ExpoNotifications.addNotificationResponseReceivedListener(response => { + console.log(response); + }); + + return () => { + notificationListener.current && + ExpoNotifications.removeNotificationSubscription(notificationListener.current); + responseListener.current && + ExpoNotifications.removeNotificationSubscription(responseListener.current); + }; + }, []); + + return ( + + + Your Expo push token: {expoPushToken} + + Title: {notification && notification.request.content.title} + Body: {notification && notification.request.content.body} + Data: {notification && JSON.stringify(notification.request.content.data)} + + + + ); +} \ No newline at end of file diff --git a/pages/settings/Settings.tsx b/pages/settings/Settings.tsx index 151543e..33eaea6 100644 --- a/pages/settings/Settings.tsx +++ b/pages/settings/Settings.tsx @@ -1,6 +1,7 @@ import { Link, router } from "expo-router"; import { Alert, TouchableOpacity, View } from "react-native"; import { + Bell, Bitcoin, Egg, Fingerprint, @@ -104,7 +105,16 @@ export function Settings() { }} > - Open Onboarding + Onboarding + + { + router.push("/notifications"); + }} + > + + Notifications Date: Thu, 31 Oct 2024 10:40:08 +0100 Subject: [PATCH 02/40] fix: lint errors --- components/Icons.tsx | 7 +- pages/Notifications.tsx | 226 +++++++++++++++++++++------------------- 2 files changed, 124 insertions(+), 109 deletions(-) diff --git a/components/Icons.tsx b/components/Icons.tsx index 7cb0e6f..a43e9a5 100644 --- a/components/Icons.tsx +++ b/components/Icons.tsx @@ -102,7 +102,9 @@ export { AlertCircle, ArchiveRestore, ArrowDown, - ArrowLeftRight, Bell, Bitcoin, + ArrowLeftRight, + Bell, + Bitcoin, BookUser, Camera, CameraOff, @@ -137,6 +139,5 @@ export { WalletIcon, X, XCircle, - ZapIcon + ZapIcon, }; - diff --git a/pages/Notifications.tsx b/pages/Notifications.tsx index 33c60b6..b0f248d 100644 --- a/pages/Notifications.tsx +++ b/pages/Notifications.tsx @@ -1,135 +1,149 @@ import Constants from "expo-constants"; import React, { useEffect, useRef, useState } from "react"; -import { View, Platform } from "react-native"; +import { Platform, View } from "react-native"; +import Screen from "~/components/Screen"; import { Button } from "~/components/ui/button"; import { Text } from "~/components/ui/text"; -import Screen from "~/components/Screen"; import * as Device from "expo-device"; import * as ExpoNotifications from "expo-notifications"; ExpoNotifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: false, - shouldSetBadge: false, - }), + handleNotification: async () => ({ + shouldShowAlert: true, + shouldPlaySound: false, + shouldSetBadge: false, + }), }); async function sendPushNotification(expoPushToken: string) { - const message = { - to: expoPushToken, - sound: 'default', - title: 'Original Title', - body: 'And here is the body!', - data: { someData: 'goes here' }, - }; + const message = { + to: expoPushToken, + sound: "default", + title: "Original Title", + body: "And here is the body!", + data: { someData: "goes here" }, + }; - await fetch('https://exp.host/--/api/v2/push/send', { - method: 'POST', - headers: { - Accept: 'application/json', - 'Accept-encoding': 'gzip, deflate', - 'Content-Type': 'application/json', - }, - body: JSON.stringify(message), - }); + await fetch("https://exp.host/--/api/v2/push/send", { + method: "POST", + headers: { + Accept: "application/json", + "Accept-encoding": "gzip, deflate", + "Content-Type": "application/json", + }, + body: JSON.stringify(message), + }); } function handleRegistrationError(errorMessage: string) { - alert(errorMessage); - throw new Error(errorMessage); + alert(errorMessage); + throw new Error(errorMessage); } async function registerForPushNotificationsAsync() { - if (Platform.OS === 'android') { - ExpoNotifications.setNotificationChannelAsync('default', { - name: 'default', - importance: ExpoNotifications.AndroidImportance.MAX, - vibrationPattern: [0, 250, 250, 250], - lightColor: '#FF231F7C', - }); - } + if (Platform.OS === "android") { + ExpoNotifications.setNotificationChannelAsync("default", { + name: "default", + importance: ExpoNotifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: "#FF231F7C", + }); + } - if (Device.isDevice) { - const { status: existingStatus } = await ExpoNotifications.getPermissionsAsync(); - let finalStatus = existingStatus; - if (existingStatus !== 'granted') { - const { status } = await ExpoNotifications.requestPermissionsAsync(); - finalStatus = status; - } - if (finalStatus !== 'granted') { - handleRegistrationError('Permission not granted to get push token for push notification!'); - return; - } - const projectId = - Constants?.expoConfig?.extra?.eas?.projectId ?? Constants?.easConfig?.projectId; - if (!projectId) { - handleRegistrationError('Project ID not found'); - } - try { - const pushTokenString = ( - await ExpoNotifications.getExpoPushTokenAsync({ - projectId, - }) - ).data; - console.log(pushTokenString); - return pushTokenString; - } catch (e: unknown) { - handleRegistrationError(`${e}`); - } - } else { - handleRegistrationError('Must use physical device for push notifications'); + if (Device.isDevice) { + const { status: existingStatus } = + await ExpoNotifications.getPermissionsAsync(); + let finalStatus = existingStatus; + if (existingStatus !== "granted") { + const { status } = await ExpoNotifications.requestPermissionsAsync(); + finalStatus = status; } + if (finalStatus !== "granted") { + handleRegistrationError( + "Permission not granted to get push token for push notification!", + ); + return; + } + const projectId = + Constants?.expoConfig?.extra?.eas?.projectId ?? + Constants?.easConfig?.projectId; + if (!projectId) { + handleRegistrationError("Project ID not found"); + } + try { + const pushTokenString = ( + await ExpoNotifications.getExpoPushTokenAsync({ + projectId, + }) + ).data; + return pushTokenString; + } catch (e: unknown) { + handleRegistrationError(`${e}`); + } + } else { + handleRegistrationError("Must use physical device for push notifications"); + } } export function Notifications() { - const [expoPushToken, setExpoPushToken] = useState(''); - const [notification, setNotification] = useState( - undefined - ); - const notificationListener = useRef(); - const responseListener = useRef(); + const [expoPushToken, setExpoPushToken] = useState(""); + const [notification, setNotification] = useState< + ExpoNotifications.Notification | undefined + >(undefined); + const notificationListener = useRef(); + const responseListener = useRef(); - useEffect(() => { - registerForPushNotificationsAsync() - .then(token => setExpoPushToken(token ?? '')) - .catch((error: any) => setExpoPushToken(`${error}`)); + useEffect(() => { + registerForPushNotificationsAsync() + .then((token) => setExpoPushToken(token ?? "")) + .catch((error: any) => setExpoPushToken(`${error}`)); - notificationListener.current = ExpoNotifications.addNotificationReceivedListener(notification => { - setNotification(notification); - }); + notificationListener.current = + ExpoNotifications.addNotificationReceivedListener((notification) => { + setNotification(notification); + }); - responseListener.current = ExpoNotifications.addNotificationResponseReceivedListener(response => { - console.log(response); - }); + responseListener.current = + ExpoNotifications.addNotificationResponseReceivedListener((response) => { + //console.log(response); + }); - return () => { - notificationListener.current && - ExpoNotifications.removeNotificationSubscription(notificationListener.current); - responseListener.current && - ExpoNotifications.removeNotificationSubscription(responseListener.current); - }; - }, []); + return () => { + notificationListener.current && + ExpoNotifications.removeNotificationSubscription( + notificationListener.current, + ); + responseListener.current && + ExpoNotifications.removeNotificationSubscription( + responseListener.current, + ); + }; + }, []); - return ( - - - Your Expo push token: {expoPushToken} - - Title: {notification && notification.request.content.title} - Body: {notification && notification.request.content.body} - Data: {notification && JSON.stringify(notification.request.content.data)} - - - - ); -} \ No newline at end of file + return ( + + + Your Expo push token: {expoPushToken} + + + Title: {notification && notification.request.content.title}{" "} + + Body: {notification && notification.request.content.body} + + Data:{" "} + {notification && JSON.stringify(notification.request.content.data)} + + + + + ); +} From 7a42fde9c30c372ee29fea60ae899de4f66f7904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Thu, 31 Oct 2024 12:37:28 +0100 Subject: [PATCH 03/40] fix: local notifications --- pages/Notifications.tsx | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/pages/Notifications.tsx b/pages/Notifications.tsx index b0f248d..01103ee 100644 --- a/pages/Notifications.tsx +++ b/pages/Notifications.tsx @@ -9,11 +9,31 @@ import * as Device from "expo-device"; import * as ExpoNotifications from "expo-notifications"; ExpoNotifications.setNotificationHandler({ - handleNotification: async () => ({ - shouldShowAlert: true, - shouldPlaySound: false, - shouldSetBadge: false, - }), + handleNotification: async (notification) => { + console.log("๐Ÿ”” handleNotification", { data: notification.request.content.data }) + + if (!notification.request.content.data.isLocal) { + console.log("๐Ÿ ๏ธ Local notification", notification.request.content); + + ExpoNotifications.scheduleNotificationAsync({ + content: { + title: 'Decrypted content', + body: "test", + data: { + ...notification.request.content.data, + isLocal: true, + } + }, + trigger: null + }); + } + + return { + shouldShowAlert: !!notification.request.content.data.isLocal, + shouldPlaySound: false, + shouldSetBadge: false, + } + } }); async function sendPushNotification(expoPushToken: string) { From 41b4299b965cb9f165af5111a9539d5d7d55c197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Aaron?= Date: Wed, 6 Nov 2024 14:43:24 +0100 Subject: [PATCH 04/40] fix: decrypt content --- pages/Notifications.tsx | 47 +++++++++++++++++++++++++++++++---------- 1 file changed, 36 insertions(+), 11 deletions(-) diff --git a/pages/Notifications.tsx b/pages/Notifications.tsx index 01103ee..52e5f55 100644 --- a/pages/Notifications.tsx +++ b/pages/Notifications.tsx @@ -5,8 +5,11 @@ import Screen from "~/components/Screen"; import { Button } from "~/components/ui/button"; import { Text } from "~/components/ui/text"; +import { Nip47Notification } from "@getalby/sdk/dist/NWCClient"; import * as Device from "expo-device"; import * as ExpoNotifications from "expo-notifications"; +import { useAppStore } from "~/lib/state/appStore"; + ExpoNotifications.setNotificationHandler({ handleNotification: async (notification) => { @@ -15,17 +18,39 @@ ExpoNotifications.setNotificationHandler({ if (!notification.request.content.data.isLocal) { console.log("๐Ÿ ๏ธ Local notification", notification.request.content); - ExpoNotifications.scheduleNotificationAsync({ - content: { - title: 'Decrypted content', - body: "test", - data: { - ...notification.request.content.data, - isLocal: true, - } - }, - trigger: null - }); + const encryptedData = notification.request.content.data.content; + const nwcClient = useAppStore.getState().nwcClient!; + + try { + console.log("๐Ÿ”ด", encryptedData, nwcClient?.secret); + const decryptedContent = await nwcClient.decrypt( + nwcClient?.walletPubkey!, + encryptedData, + ); + console.log("๐Ÿ”“๏ธ decrypted data", decryptedContent); + const nip47Notification = JSON.parse(decryptedContent) as Nip47Notification; + console.log("deserialized", nip47Notification); + + if (nip47Notification.notification_type === "payment_received") { + ExpoNotifications.scheduleNotificationAsync({ + content: { + title: `You just received ${Math.floor(nip47Notification.notification.amount / 1000)} sats`, + body: nip47Notification.notification.description, + data: { + ...notification.request.content.data, + isLocal: true, + } + }, + trigger: null + }); + } + + + + } catch (e) { + console.error("Failed to parse decrypted event content", e); + return; + } } return { From a572e105556f58a38c7743d4a16d1c59a16f1bf8 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Mon, 11 Nov 2024 13:32:54 +0530 Subject: [PATCH 05/40] chore: improve notifications support --- .gitignore | 2 + app.json | 3 + app/(app)/notifications.js | 5 - app/(app)/settings/notifications.js | 5 + app/_layout.tsx | 23 +++- context/Notification.tsx | 103 +++++++++++++++ lib/constants.ts | 3 + lib/state/appStore.ts | 41 ++++++ package.json | 11 +- pages/Notifications.tsx | 194 ---------------------------- pages/settings/Notifications.tsx | 37 ++++++ pages/settings/Settings.tsx | 18 +-- services/Notifications.ts | 110 ++++++++++++++++ yarn.lock | 82 ++++++------ 14 files changed, 377 insertions(+), 260 deletions(-) delete mode 100644 app/(app)/notifications.js create mode 100644 app/(app)/settings/notifications.js create mode 100644 context/Notification.tsx delete mode 100644 pages/Notifications.tsx create mode 100644 pages/settings/Notifications.tsx create mode 100644 services/Notifications.ts diff --git a/.gitignore b/.gitignore index 05647d5..142d300 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ yarn-error.* # typescript *.tsbuildinfo + +ios diff --git a/app.json b/app.json index 00bc7bd..17170e2 100644 --- a/app.json +++ b/app.json @@ -46,6 +46,9 @@ "bundleIdentifier": "com.getalby.mobile", "config": { "usesNonExemptEncryption": false + }, + "infoPlist": { + "UIBackgroundModes": ["remote-notification", "processing"] } }, "android": { diff --git a/app/(app)/notifications.js b/app/(app)/notifications.js deleted file mode 100644 index 09a5702..0000000 --- a/app/(app)/notifications.js +++ /dev/null @@ -1,5 +0,0 @@ -import { Notifications } from "../../pages/Notifications"; - -export default function Page() { - return ; -} diff --git a/app/(app)/settings/notifications.js b/app/(app)/settings/notifications.js new file mode 100644 index 0000000..a5b5e36 --- /dev/null +++ b/app/(app)/settings/notifications.js @@ -0,0 +1,5 @@ +import { Notifications } from "../../../pages/settings/Notifications"; + +export default function Page() { + return ; +} diff --git a/app/_layout.tsx b/app/_layout.tsx index f1ea3c4..e683cee 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,8 +1,10 @@ import { Theme, ThemeProvider } from "@react-navigation/native"; import { PortalHost } from "@rn-primitives/portal"; import * as Font from "expo-font"; +import * as Notifications from "expo-notifications"; import { Slot, SplashScreen } from "expo-router"; import { StatusBar } from "expo-status-bar"; +import * as TaskManager from "expo-task-manager"; import { swrConfiguration } from "lib/swr"; import * as React from "react"; import { SafeAreaView } from "react-native"; @@ -14,7 +16,7 @@ import { UserInactivityProvider } from "~/context/UserInactivity"; import "~/global.css"; import { useInfo } from "~/hooks/useInfo"; import { SessionProvider } from "~/hooks/useSession"; -import { NAV_THEME } from "~/lib/constants"; +import { BACKGROUND_NOTIFICATION_TASK, NAV_THEME } from "~/lib/constants"; import { isBiometricSupported } from "~/lib/isBiometricSupported"; import { useAppStore } from "~/lib/state/appStore"; import { useColorScheme } from "~/lib/useColorScheme"; @@ -33,6 +35,25 @@ export { ErrorBoundary, } from "expo-router"; +// FIXME: only use this in android (?) +TaskManager.defineTask( + BACKGROUND_NOTIFICATION_TASK, + ({ data }: { data: Record }) => { + console.info("Received a notification in the background!", data?.body); + // Do something with the notification data + }, +); + +Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK) + .then(() => { + console.info( + `Notifications.registerTaskAsync success: ${BACKGROUND_NOTIFICATION_TASK}`, + ); + }) + .catch((reason) => { + console.info(`Notifications registerTaskAsync failed: ${reason}`); + }); + // Prevent the splash screen from auto-hiding before getting the color scheme. SplashScreen.preventAutoHideAsync(); diff --git a/context/Notification.tsx b/context/Notification.tsx new file mode 100644 index 0000000..db5f133 --- /dev/null +++ b/context/Notification.tsx @@ -0,0 +1,103 @@ +import { useEffect, useRef } from "react"; + +import { Nip47Notification } from "@getalby/sdk/dist/NWCClient"; +import * as ExpoNotifications from "expo-notifications"; +import { useAppStore } from "~/lib/state/appStore"; + +ExpoNotifications.setNotificationHandler({ + handleNotification: async (notification) => { + console.info("๐Ÿ”” handleNotification", { + data: notification.request.content.data, + }); + + if (!notification.request.content.data.isLocal) { + console.info("๐Ÿ ๏ธ Local notification", notification.request.content); + + const encryptedData = notification.request.content.data.content; + const nwcClient = useAppStore.getState().nwcClient!; + + // TODO: Get the correct keys to decrypt + + try { + console.info("๐Ÿ”ด", encryptedData, nwcClient?.secret); + const decryptedContent = await nwcClient.decrypt( + nwcClient?.walletPubkey!, + encryptedData, + ); + console.info("๐Ÿ”“๏ธ decrypted data", decryptedContent); + const nip47Notification = JSON.parse( + decryptedContent, + ) as Nip47Notification; + console.info("decrypted", nip47Notification); + + if (nip47Notification.notification_type === "payment_received") { + ExpoNotifications.scheduleNotificationAsync({ + content: { + title: `You just received ${Math.floor(nip47Notification.notification.amount / 1000)} sats`, + body: nip47Notification.notification.description, + data: { + ...notification.request.content.data, + isLocal: true, + }, + }, + trigger: null, + }); + } + } catch (e) { + console.error("Failed to parse decrypted event content", e); + return { + shouldShowAlert: false, + shouldPlaySound: false, + shouldSetBadge: false, + }; + } + } + + return { + shouldShowAlert: !!notification.request.content.data.isLocal, + shouldPlaySound: false, + shouldSetBadge: false, + }; + }, +}); + +export const NotificationProvider = ({ children }: any) => { + const notificationListener = useRef(); + const responseListener = useRef(); + const isNotificationsEnabled = useAppStore( + (store) => store.isNotificationsEnabled, + ); + + useEffect(() => { + if (!isNotificationsEnabled) { + return; + } + + notificationListener.current = + ExpoNotifications.addNotificationReceivedListener((notification) => { + // triggers when app is foregrounded + console.info("received from server just now"); + }); + + responseListener.current = + ExpoNotifications.addNotificationResponseReceivedListener((response) => { + // triggers when notification is clicked (only when foreground or background) + // see https://docs.expo.dev/versions/latest/sdk/notifications/#notification-events-listeners + // TODO: to also redirect when the app is killed, use useLastNotificationResponse + // TODO: redirect the user to transaction page after switching to the right wallet + }); + + return () => { + notificationListener.current && + ExpoNotifications.removeNotificationSubscription( + notificationListener.current, + ); + responseListener.current && + ExpoNotifications.removeNotificationSubscription( + responseListener.current, + ); + }; + }, [isNotificationsEnabled]); + + return children; +}; diff --git a/lib/constants.ts b/lib/constants.ts index f9674ee..83ec22a 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -19,6 +19,8 @@ export const NAV_THEME = { }, }; +export const BACKGROUND_NOTIFICATION_TASK = "BACKGROUND-NOTIFICATION-TASK"; + export const INACTIVITY_THRESHOLD = 5 * 60 * 1000; export const CURSOR_COLOR = "hsl(47 100% 72%)"; @@ -29,6 +31,7 @@ export const DEFAULT_CURRENCY = "USD"; export const DEFAULT_WALLET_NAME = "Default Wallet"; export const ALBY_LIGHTNING_ADDRESS = "go@getalby.com"; export const ALBY_URL = "https://getalby.com"; +export const NOSTR_API_URL = "https://api.getalby.com/nwc"; export const REQUIRED_CAPABILITIES: Nip47Capability[] = [ "get_balance", diff --git a/lib/state/appStore.ts b/lib/state/appStore.ts index dfcc173..5d14bf8 100644 --- a/lib/state/appStore.ts +++ b/lib/state/appStore.ts @@ -11,17 +11,20 @@ interface AppState { readonly wallets: Wallet[]; readonly addressBookEntries: AddressBookEntry[]; readonly isSecurityEnabled: boolean; + readonly isNotificationsEnabled: boolean; readonly isOnboarded: boolean; readonly theme: Theme; setUnlocked: (unlocked: boolean) => void; setTheme: (theme: Theme) => void; setOnboarded: (isOnboarded: boolean) => void; setNWCClient: (nwcClient: NWCClient | undefined) => void; + updateWallet(wallet: Partial, nostrWalletConnectUrl?: string): void; updateCurrentWallet(wallet: Partial): void; removeCurrentWallet(): void; setFiatCurrency(fiatCurrency: string): void; setSelectedWalletId(walletId: number): void; setSecurityEnabled(securityEnabled: boolean): void; + setNotificationsEnabled(notificationsEnabled: boolean): void; addWallet(wallet: Wallet): void; addAddressBookEntry(entry: AddressBookEntry): void; reset(): void; @@ -37,6 +40,7 @@ const hasOnboardedKey = "hasOnboarded"; const lastAlbyPaymentKey = "lastAlbyPayment"; const themeKey = "theme"; const isSecurityEnabledKey = "isSecurityEnabled"; +const isNotificationsEnabledKey = "isNotificationsEnabled"; export const lastActiveTimeKey = "lastActiveTime"; export type Theme = "system" | "light" | "dark"; @@ -46,6 +50,7 @@ type Wallet = { nostrWalletConnectUrl?: string; lightningAddress?: string; nwcCapabilities?: Nip47Capability[]; + pushId?: string; }; type AddressBookEntry = { @@ -88,6 +93,28 @@ function loadAddressBookEntries(): AddressBookEntry[] { } export const useAppStore = create()((set, get) => { + const updateWallet = ( + walletUpdate: Partial, + nostrWalletConnectUrl: string, + ) => { + const wallets = [...get().wallets]; + const walletId = wallets.findIndex( + (wallet) => wallet.nostrWalletConnectUrl === nostrWalletConnectUrl, + ); + if (walletId < 0) { + return; + } + const wallet: Wallet = { + ...(wallets[walletId] || {}), + ...walletUpdate, + }; + secureStorage.setItem(getWalletKey(walletId), JSON.stringify(wallet)); + wallets[walletId] = wallet; + set({ + wallets, + }); + }; + const updateCurrentWallet = (walletUpdate: Partial) => { const selectedWalletId = get().selectedWalletId; const wallets = [...get().wallets]; @@ -106,6 +133,7 @@ export const useAppStore = create()((set, get) => { }); }; + // TODO: de-register push notification subscripiton using pushId const removeCurrentWallet = () => { const wallets = [...get().wallets]; if (wallets.length <= 1) { @@ -157,9 +185,12 @@ export const useAppStore = create()((set, get) => { nwcClient: getNWCClient(initialSelectedWalletId), fiatCurrency: secureStorage.getItem(fiatCurrencyKey) || "", isSecurityEnabled, + isNotificationsEnabled: + secureStorage.getItem(isNotificationsEnabledKey) === "true", theme, isOnboarded: secureStorage.getItem(hasOnboardedKey) === "true", selectedWalletId: initialSelectedWalletId, + updateWallet, updateCurrentWallet, removeCurrentWallet, setUnlocked: (unlocked) => { @@ -185,6 +216,12 @@ export const useAppStore = create()((set, get) => { ...(!isEnabled ? { unlocked: true } : {}), }); }, + setNotificationsEnabled: (isEnabled) => { + secureStorage.setItem(isNotificationsEnabledKey, isEnabled.toString()); + set({ + isNotificationsEnabled: isEnabled, + }); + }, setFiatCurrency: (fiatCurrency) => { secureStorage.setItem(fiatCurrencyKey, fiatCurrency); set({ fiatCurrency }); @@ -226,6 +263,7 @@ export const useAppStore = create()((set, get) => { updateLastAlbyPayment: () => { secureStorage.setItem(lastAlbyPaymentKey, new Date().toString()); }, + // TODO: de-register push notification subscripitons using pushId reset() { // clear wallets for (let i = 0; i < get().wallets.length; i++) { @@ -242,6 +280,9 @@ export const useAppStore = create()((set, get) => { // clear security enabled status secureStorage.removeItem(isSecurityEnabledKey); + // clear security enabled status + secureStorage.removeItem(isNotificationsEnabledKey); + // clear onboarding status secureStorage.removeItem(hasOnboardedKey); diff --git a/package.json b/package.json index 0d79d45..82b0266 100644 --- a/package.json +++ b/package.json @@ -6,8 +6,8 @@ "start": "expo start", "start:clean": "expo start --reset-cache", "start:tunnel": "expo start --tunnel", - "android": "expo start --android", - "ios": "expo start --ios", + "android": "expo run:android", + "ios": "expo run:ios", "eas:build:ios:preview": "eas build --profile preview --platform ios", "eas:build:android:preview": "eas build --profile preview --platform android", "eas:build:android": "eas build --platform android", @@ -37,7 +37,7 @@ "clsx": "^2.1.1", "crypto-js": "^4.2.0", "dayjs": "^1.11.10", - "expo": "~51.0.34", + "expo": "~51.0.39", "expo-camera": "~15.0.16", "expo-clipboard": "~6.0.3", "expo-constants": "~16.0.2", @@ -47,9 +47,10 @@ "expo-linking": "~6.3.1", "expo-local-authentication": "~14.0.1", "expo-notifications": "~0.28.19", - "expo-router": "^3.5.23", + "expo-router": "^3.5.24", "expo-secure-store": "^13.0.2", "expo-status-bar": "~1.12.1", + "expo-task-manager": "^11.8.2", "lottie-react-native": "6.7.0", "lucide-react-native": "^0.376.0", "nativewind": "^4.0.1", @@ -84,9 +85,9 @@ "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.2.1", "husky": "^9.1.6", - "lint-staged": "^15.2.10", "jest": "^29.7.0", "jest-expo": "^51.0.4", + "lint-staged": "^15.2.10", "prettier": "^3.3.3", "tailwindcss": "^3.4.3", "typescript": "~5.3.3" diff --git a/pages/Notifications.tsx b/pages/Notifications.tsx deleted file mode 100644 index 52e5f55..0000000 --- a/pages/Notifications.tsx +++ /dev/null @@ -1,194 +0,0 @@ -import Constants from "expo-constants"; -import React, { useEffect, useRef, useState } from "react"; -import { Platform, View } from "react-native"; -import Screen from "~/components/Screen"; -import { Button } from "~/components/ui/button"; -import { Text } from "~/components/ui/text"; - -import { Nip47Notification } from "@getalby/sdk/dist/NWCClient"; -import * as Device from "expo-device"; -import * as ExpoNotifications from "expo-notifications"; -import { useAppStore } from "~/lib/state/appStore"; - - -ExpoNotifications.setNotificationHandler({ - handleNotification: async (notification) => { - console.log("๐Ÿ”” handleNotification", { data: notification.request.content.data }) - - if (!notification.request.content.data.isLocal) { - console.log("๐Ÿ ๏ธ Local notification", notification.request.content); - - const encryptedData = notification.request.content.data.content; - const nwcClient = useAppStore.getState().nwcClient!; - - try { - console.log("๐Ÿ”ด", encryptedData, nwcClient?.secret); - const decryptedContent = await nwcClient.decrypt( - nwcClient?.walletPubkey!, - encryptedData, - ); - console.log("๐Ÿ”“๏ธ decrypted data", decryptedContent); - const nip47Notification = JSON.parse(decryptedContent) as Nip47Notification; - console.log("deserialized", nip47Notification); - - if (nip47Notification.notification_type === "payment_received") { - ExpoNotifications.scheduleNotificationAsync({ - content: { - title: `You just received ${Math.floor(nip47Notification.notification.amount / 1000)} sats`, - body: nip47Notification.notification.description, - data: { - ...notification.request.content.data, - isLocal: true, - } - }, - trigger: null - }); - } - - - - } catch (e) { - console.error("Failed to parse decrypted event content", e); - return; - } - } - - return { - shouldShowAlert: !!notification.request.content.data.isLocal, - shouldPlaySound: false, - shouldSetBadge: false, - } - } -}); - -async function sendPushNotification(expoPushToken: string) { - const message = { - to: expoPushToken, - sound: "default", - title: "Original Title", - body: "And here is the body!", - data: { someData: "goes here" }, - }; - - await fetch("https://exp.host/--/api/v2/push/send", { - method: "POST", - headers: { - Accept: "application/json", - "Accept-encoding": "gzip, deflate", - "Content-Type": "application/json", - }, - body: JSON.stringify(message), - }); -} - -function handleRegistrationError(errorMessage: string) { - alert(errorMessage); - throw new Error(errorMessage); -} - -async function registerForPushNotificationsAsync() { - if (Platform.OS === "android") { - ExpoNotifications.setNotificationChannelAsync("default", { - name: "default", - importance: ExpoNotifications.AndroidImportance.MAX, - vibrationPattern: [0, 250, 250, 250], - lightColor: "#FF231F7C", - }); - } - - if (Device.isDevice) { - const { status: existingStatus } = - await ExpoNotifications.getPermissionsAsync(); - let finalStatus = existingStatus; - if (existingStatus !== "granted") { - const { status } = await ExpoNotifications.requestPermissionsAsync(); - finalStatus = status; - } - if (finalStatus !== "granted") { - handleRegistrationError( - "Permission not granted to get push token for push notification!", - ); - return; - } - const projectId = - Constants?.expoConfig?.extra?.eas?.projectId ?? - Constants?.easConfig?.projectId; - if (!projectId) { - handleRegistrationError("Project ID not found"); - } - try { - const pushTokenString = ( - await ExpoNotifications.getExpoPushTokenAsync({ - projectId, - }) - ).data; - return pushTokenString; - } catch (e: unknown) { - handleRegistrationError(`${e}`); - } - } else { - handleRegistrationError("Must use physical device for push notifications"); - } -} - -export function Notifications() { - const [expoPushToken, setExpoPushToken] = useState(""); - const [notification, setNotification] = useState< - ExpoNotifications.Notification | undefined - >(undefined); - const notificationListener = useRef(); - const responseListener = useRef(); - - useEffect(() => { - registerForPushNotificationsAsync() - .then((token) => setExpoPushToken(token ?? "")) - .catch((error: any) => setExpoPushToken(`${error}`)); - - notificationListener.current = - ExpoNotifications.addNotificationReceivedListener((notification) => { - setNotification(notification); - }); - - responseListener.current = - ExpoNotifications.addNotificationResponseReceivedListener((response) => { - //console.log(response); - }); - - return () => { - notificationListener.current && - ExpoNotifications.removeNotificationSubscription( - notificationListener.current, - ); - responseListener.current && - ExpoNotifications.removeNotificationSubscription( - responseListener.current, - ); - }; - }, []); - - return ( - - - Your Expo push token: {expoPushToken} - - - Title: {notification && notification.request.content.title}{" "} - - Body: {notification && notification.request.content.body} - - Data:{" "} - {notification && JSON.stringify(notification.request.content.data)} - - - - - ); -} diff --git a/pages/settings/Notifications.tsx b/pages/settings/Notifications.tsx new file mode 100644 index 0000000..0a589c3 --- /dev/null +++ b/pages/settings/Notifications.tsx @@ -0,0 +1,37 @@ +import React from "react"; +import { Text, View } from "react-native"; +import Screen from "~/components/Screen"; +import { Label } from "~/components/ui/label"; +import { Switch } from "~/components/ui/switch"; +import { useAppStore } from "~/lib/state/appStore"; +import { registerForPushNotificationsAsync } from "~/services/Notifications"; + +export function Notifications() { + // TODO: If this is enabled, register notifications on new wallets being added + const isEnabled = useAppStore((store) => store.isNotificationsEnabled); + + return ( + + + + + + { + useAppStore.getState().setNotificationsEnabled(checked); + if (checked) { + await registerForPushNotificationsAsync(); + } else { + // TODO: de-register all wallets on nostr api + } + }} + nativeID="security" + /> + + + + ); +} diff --git a/pages/settings/Settings.tsx b/pages/settings/Settings.tsx index 33eaea6..3e01eee 100644 --- a/pages/settings/Settings.tsx +++ b/pages/settings/Settings.tsx @@ -62,6 +62,15 @@ export function Settings() { + + + + + Notifications + + + + @@ -107,15 +116,6 @@ export function Settings() { Onboarding - { - router.push("/notifications"); - }} - > - - Notifications - { diff --git a/services/Notifications.ts b/services/Notifications.ts new file mode 100644 index 0000000..7f3cf82 --- /dev/null +++ b/services/Notifications.ts @@ -0,0 +1,110 @@ +import Constants from "expo-constants"; +import { Platform } from "react-native"; + +import { nwc } from "@getalby/sdk"; +import * as Device from "expo-device"; +import * as ExpoNotifications from "expo-notifications"; +import { NOSTR_API_URL } from "~/lib/constants"; +import { errorToast } from "~/lib/errorToast"; +import { useAppStore } from "~/lib/state/appStore"; + +// TODO: add background notification handling for android + +export async function registerForPushNotificationsAsync() { + if (Platform.OS === "android") { + ExpoNotifications.setNotificationChannelAsync("default", { + name: "default", + importance: ExpoNotifications.AndroidImportance.MAX, + vibrationPattern: [0, 250, 250, 250], + lightColor: "#FF231F7C", + }); + } + + if (Device.isDevice) { + const { status: existingStatus } = + await ExpoNotifications.getPermissionsAsync(); + let finalStatus = existingStatus; + if (existingStatus !== "granted") { + const { status } = await ExpoNotifications.requestPermissionsAsync(); + finalStatus = status; + } + if (finalStatus !== "granted") { + errorToast( + new Error( + "Permission not granted to get push token for push notification", + ), + ); + return; + } + const projectId = + Constants?.expoConfig?.extra?.eas?.projectId ?? + Constants?.easConfig?.projectId; + if (!projectId) { + errorToast(new Error("Project ID not found")); + } + try { + const pushTokenString = ( + await ExpoNotifications.getExpoPushTokenAsync({ + projectId, + }) + ).data; + + // FIXME: This would trigger the same notification + // per wallet if they belong to the same node + const wallets = useAppStore.getState().wallets; + + for (const wallet of wallets) { + const nwcUrl = wallet.nostrWalletConnectUrl; + if (!nwcUrl) { + continue; + } + const nwcClient = new nwc.NWCClient({ + nostrWalletConnectUrl: wallet.nostrWalletConnectUrl, + }); + + const body = { + pushToken: pushTokenString, + relayUrl: nwcClient.relayUrl, + connectionPubkey: nwcClient.publicKey, + walletPubkey: nwcClient.walletPubkey, + }; + + try { + const response = await fetch( + `${NOSTR_API_URL}/nip47/notifications/go`, + { + method: "POST", + headers: { + Accept: "application/json", + "Accept-encoding": "gzip, deflate", + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }, + ); + + if (response.ok) { + const responseData = await response.json(); + useAppStore.getState().updateWallet( + { + pushId: responseData.subscriptionId, + }, + wallet.nostrWalletConnectUrl, + ); + // TODO: Send a DELETE call to nostr api on deleting the wallet + } else { + throw new Error(`Error: ${response.status} ${response.statusText}`); + } + } catch (error) { + errorToast(error); + } + } + + return; + } catch (error) { + errorToast(error); + } + } else { + errorToast("Must use physical device for push notifications"); + } +} diff --git a/yarn.lock b/yarn.lock index 98ed439..62db63e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1199,10 +1199,10 @@ mv "~2" safe-json-stringify "~1" -"@expo/cli@0.18.30": - version "0.18.30" - resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.18.30.tgz#0cb4829aa11e98ae350a5c15958b9816e9a1d2f0" - integrity sha512-V90TUJh9Ly8stYo8nwqIqNWCsYjE28GlVFWEhAFCUOp99foiQr8HSTpiiX5GIrprcPoWmlGoY+J5fQA29R4lFg== +"@expo/cli@0.18.31": + version "0.18.31" + resolved "https://registry.yarnpkg.com/@expo/cli/-/cli-0.18.31.tgz#d07b7f1b2d10d146ec8b732ce1353b90912c56bd" + integrity sha512-v9llw9fT3Uv+TCM6Xllo54t672CuYtinEQZ2LPJ2EJsCwuTc4Cd2gXQaouuIVD21VoeGQnr5JtJuWbF97sBKzQ== dependencies: "@babel/runtime" "^7.20.0" "@expo/code-signing-certificates" "0.0.5" @@ -1290,10 +1290,10 @@ node-forge "^1.2.1" nullthrows "^1.1.1" -"@expo/config-plugins@8.0.10": - version "8.0.10" - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-8.0.10.tgz#5cda076f38bc04675cb42d8acdd23d6e460a62de" - integrity sha512-KG1fnSKRmsudPU9BWkl59PyE0byrE2HTnqbOrgwr2FAhqh7tfr9nRs6A9oLS/ntpGzmFxccTEcsV0L4apsuxxg== +"@expo/config-plugins@8.0.11": + version "8.0.11" + resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-8.0.11.tgz#b814395a910f4c8b7cc95d9719dccb6ca53ea4c5" + integrity sha512-oALE1HwnLFthrobAcC9ocnR9KXLzfWEjgIe4CPe+rDsfC6GDs8dGYCXfRFoCEzoLN4TGYs9RdZ8r0KoCcNrm2A== dependencies: "@expo/config-types" "^51.0.3" "@expo/json-file" "~8.3.0" @@ -1563,23 +1563,6 @@ base64-js "^1.2.3" xmlbuilder "^14.0.0" -"@expo/prebuild-config@7.0.6": - version "7.0.6" - resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-7.0.6.tgz#b9c2c36ee564244da8073ce7bea22ebe57743615" - integrity sha512-Hts+iGBaG6OQ+N8IEMMgwQElzJeSTb7iUJ26xADEHkaexsucAK+V52dM8M4ceicvbZR9q8M+ebJEGj0MCNA3dQ== - dependencies: - "@expo/config" "~9.0.0-beta.0" - "@expo/config-plugins" "~8.0.0-beta.0" - "@expo/config-types" "^51.0.0-unreleased" - "@expo/image-utils" "^0.5.0" - "@expo/json-file" "^8.3.0" - "@react-native/normalize-colors" "0.74.84" - debug "^4.3.1" - fs-extra "^9.0.0" - resolve-from "^5.0.0" - semver "^7.6.0" - xml2js "0.6.0" - "@expo/prebuild-config@7.0.9": version "7.0.9" resolved "https://registry.yarnpkg.com/@expo/prebuild-config/-/prebuild-config-7.0.9.tgz#7abd489e18ed6514a0c9cd214eb34c0d5efda799" @@ -2543,11 +2526,6 @@ hermes-parser "0.19.1" nullthrows "^1.1.1" -"@react-native/normalize-colors@0.74.84": - version "0.74.84" - resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.74.84.tgz#4764d59775c17a6ed193509cb01ae2f42dd5c045" - integrity sha512-Y5W6x8cC5RuakUcTVUFNAIhUZ/tYpuqHZlRBoAuakrTwVuoNHXfQki8lj1KsYU7rW6e3VWgdEx33AfOQpdNp6A== - "@react-native/normalize-colors@0.74.85": version "0.74.85" resolved "https://registry.yarnpkg.com/@react-native/normalize-colors/-/normalize-colors-0.74.85.tgz#62bcb9ab1b10b822ca0278fdfdf23d3b18e125da" @@ -5374,10 +5352,10 @@ expo-notifications@~0.28.19: expo-constants "~16.0.0" fs-extra "^9.1.0" -expo-router@^3.5.23: - version "3.5.23" - resolved "https://registry.yarnpkg.com/expo-router/-/expo-router-3.5.23.tgz#da038e28c64cb69f19d046d7c651c389c5207a3e" - integrity sha512-Re2kYcxov67hWrcjuu0+3ovsLxYn79PuX6hgtYN20MgigY5ttX79KOIBEVGTO3F3y9dxSrGHyy5Z14BcO+usGQ== +expo-router@^3.5.24: + version "3.5.24" + resolved "https://registry.yarnpkg.com/expo-router/-/expo-router-3.5.24.tgz#ac834b66c023151a3f919c456805cdd7377a677f" + integrity sha512-wFi+PIUrOntF5cgg0PgBMlkxEZlWedIv5dWnPFEzN6Tr3A3bpsqdDLgOEIwvwd+pxn5DLzykTmg9EkQ1pPGspw== dependencies: "@expo/metro-runtime" "3.2.3" "@expo/server" "^0.4.0" @@ -5385,7 +5363,7 @@ expo-router@^3.5.23: "@react-navigation/bottom-tabs" "~6.5.7" "@react-navigation/native" "~6.1.6" "@react-navigation/native-stack" "~6.9.12" - expo-splash-screen "0.27.5" + expo-splash-screen "0.27.7" react-native-helmet-async "2.0.4" schema-utils "^4.0.1" @@ -5394,27 +5372,34 @@ expo-secure-store@^13.0.2: resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-13.0.2.tgz#ba8f6076fc38062a28bb2ce5edab9cd28ef88598" integrity sha512-3QYgoneo8p8yeeBPBiAfokNNc2xq6+n8+Ob4fAlErEcf4H7Y72LH+K/dx0nQyWau2ZKZUXBxyyfuHFyVKrEVLg== -expo-splash-screen@0.27.5: - version "0.27.5" - resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-0.27.5.tgz#bcc1ebb4e761e19a1f2112469f3d424a36fb1e2c" - integrity sha512-9rdZuLkFCfgJBxrheUsOEOIW6Rp+9NVlpSE0hgXQwbTCLTncf00IHSE8/L2NbFyeDLNjof1yZBppaV7tXHRUzA== +expo-splash-screen@0.27.7: + version "0.27.7" + resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-0.27.7.tgz#52171be54d8c008880d928e802819d767fbd3c12" + integrity sha512-s+eGcG185878nixlrjhhLD6UDYrvoqBUaBkIEozBVWFg3pkdsKpONPiUAco4XR3h7I/9ODq4quN28RJLFO+s0Q== dependencies: - "@expo/prebuild-config" "7.0.6" + "@expo/prebuild-config" "7.0.9" expo-status-bar@~1.12.1: version "1.12.1" resolved "https://registry.yarnpkg.com/expo-status-bar/-/expo-status-bar-1.12.1.tgz#52ce594aab5064a0511d14375364d718ab78aa66" integrity sha512-/t3xdbS8KB0prj5KG5w7z+wZPFlPtkgs95BsmrP/E7Q0xHXTcDcQ6Cu2FkFuRM+PKTb17cJDnLkawyS5vDLxMA== -expo@~51.0.34: - version "51.0.38" - resolved "https://registry.yarnpkg.com/expo/-/expo-51.0.38.tgz#e4127b230454a34a507cfb9f1a2e4b3855cb0579" - integrity sha512-/B9npFkOPmv6WMIhdjQXEY0Z9k/67UZIVkodW8JxGIXwKUZAGHL+z1R5hTtWimpIrvVhyHUFU3f8uhfEKYhHNQ== +expo-task-manager@^11.8.2: + version "11.8.2" + resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-11.8.2.tgz#1090a445565ca65ed99991166ddda38575b3dc8c" + integrity sha512-Uhy3ol5gYeZOyeRFddYjLI1B2DGRH1gjp/YC8Hpn5p5MVENviySoKNF+wd98rRvOAokzrzElyDBHSTfX+C3tpg== + dependencies: + unimodules-app-loader "~4.6.0" + +expo@~51.0.39: + version "51.0.39" + resolved "https://registry.yarnpkg.com/expo/-/expo-51.0.39.tgz#d9efab081a91a0d3e925b0e4648722b13a8fceae" + integrity sha512-Cs/9xopyzJrpXWbyVUZnr37rprdFJorRgfSp6cdBfvbjxZeKnw2MEu7wJwV/s626i5lZTPGjZPHUF9uQvt51cg== dependencies: "@babel/runtime" "^7.20.0" - "@expo/cli" "0.18.30" + "@expo/cli" "0.18.31" "@expo/config" "9.0.4" - "@expo/config-plugins" "8.0.10" + "@expo/config-plugins" "8.0.11" "@expo/metro-config" "0.18.11" "@expo/vector-icons" "^14.0.3" babel-preset-expo "~11.0.15" @@ -10597,6 +10582,11 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== +unimodules-app-loader@~4.6.0: + version "4.6.0" + resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-4.6.0.tgz#8836040b3acbf605fc4c2c6f6feb6dd9084ea0d4" + integrity sha512-FRNIlx7sLBDVPG117JnEBhnzZkTIgZTEwYW2rzrY9HdvLBTpRN+k0dxY50U/CAhFHW3zMD0OP5JAlnSQRhx5HA== + unique-filename@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea" From 184548afc2f24cc0f6675d3d15c0d96349173d01 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Mon, 11 Nov 2024 21:35:07 +0530 Subject: [PATCH 06/40] feat: add notification service extension --- .gitignore | 1 + app.json | 7 +++ app/_layout.tsx | 39 ++++++------ assets/NotificationService.m | 117 +++++++++++++++++++++++++++++++++++ context/Notification.tsx | 54 +--------------- lib/constants.ts | 2 + lib/sharedSecret.ts | 8 +++ package.json | 3 + services/Notifications.ts | 20 +++++- yarn.lock | 57 ++++++++++++++++- 10 files changed, 236 insertions(+), 72 deletions(-) create mode 100644 assets/NotificationService.m create mode 100644 lib/sharedSecret.ts diff --git a/.gitignore b/.gitignore index 142d300..5ee8d59 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ yarn-error.* *.tsbuildinfo ios +android diff --git a/app.json b/app.json index 17170e2..d934605 100644 --- a/app.json +++ b/app.json @@ -14,6 +14,13 @@ }, "assetBundlePatterns": ["**/*"], "plugins": [ + [ + "expo-notification-service-extension-plugin", + { + "mode": "production", + "iosNSEFilePath": "./assets/NotificationService.m" + } + ], [ "expo-local-authentication", { diff --git a/app/_layout.tsx b/app/_layout.tsx index e683cee..66ac1e4 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -12,6 +12,7 @@ import Toast from "react-native-toast-message"; import PolyfillCrypto from "react-native-webview-crypto"; import { SWRConfig } from "swr"; import { toastConfig } from "~/components/ToastConfig"; +import { NotificationProvider } from "~/context/Notification"; import { UserInactivityProvider } from "~/context/UserInactivity"; import "~/global.css"; import { useInfo } from "~/hooks/useInfo"; @@ -110,24 +111,26 @@ export default function RootLayout() { return ( - - - - - - - - - - - - - + + + + + + + + + + + + + + + ); } diff --git a/assets/NotificationService.m b/assets/NotificationService.m new file mode 100644 index 0000000..ea71271 --- /dev/null +++ b/assets/NotificationService.m @@ -0,0 +1,117 @@ +#import "NotificationService.h" +#import + +@interface NotificationService () + +@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver); +@property (nonatomic, strong) UNNotificationRequest *receivedRequest; +@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent; + +@end + +@implementation NotificationService + +// Helper function to convert hex string to NSData +NSData* dataFromHexString(NSString *hexString) { + NSMutableData *data = [NSMutableData data]; + int idx; + for (idx = 0; idx+2 <= hexString.length; idx+=2) { + NSRange range = NSMakeRange(idx, 2); + NSString *hexByte = [hexString substringWithRange:range]; + unsigned int byte; + if ([[NSScanner scannerWithString:hexByte] scanHexInt:&byte]) { + [data appendBytes:&byte length:1]; + } else { + return nil; // invalid hex string + } + } + return data; +} + +- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler { + self.receivedRequest = request; + self.contentHandler = contentHandler; + self.bestAttemptContent = [request.content mutableCopy]; + + NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.getalby.mobile.nse"]; + NSDictionary *walletsDict = [sharedDefaults objectForKey:@"wallets"]; + + NSString *appPubkey = request.content.userInfo[@"body"][@"appPubkey"]; + if (!appPubkey) { + return; + } + + NSDictionary *walletInfo = walletsDict[appPubkey]; + if (!walletInfo) { + return; + } + + NSString *sharedSecretString = walletInfo[@"sharedSecret"]; + NSString *walletName = walletInfo[@"name"]; + if (!sharedSecretString) { + return; + } + + NSData *sharedSecretData = dataFromHexString(sharedSecretString); + if (!sharedSecretData || sharedSecretData.length != kCCKeySizeAES256) { + return; + } + + NSString *encryptedContent = request.content.userInfo[@"body"][@"content"]; + NSArray *parts = [encryptedContent componentsSeparatedByString:@"?iv="]; + if (parts.count < 2) { + return; + } + + NSString *ciphertextBase64 = parts[0]; + NSString *ivBase64 = parts[1]; + + NSData *ciphertextData = [[NSData alloc] initWithBase64EncodedString:ciphertextBase64 options:0]; + NSData *ivData = [[NSData alloc] initWithBase64EncodedString:ivBase64 options:0]; + + if (!ciphertextData || !ivData || ivData.length != kCCBlockSizeAES128) { + return; + } + + // Prepare for decryption + size_t decryptedDataLength = ciphertextData.length + kCCBlockSizeAES128; + NSMutableData *plaintextData = [NSMutableData dataWithLength:decryptedDataLength]; + + size_t numBytesDecrypted = 0; + CCCryptorStatus cryptStatus = CCCrypt(kCCDecrypt, kCCAlgorithmAES128, kCCOptionPKCS7Padding, sharedSecretData.bytes, sharedSecretData.length, ivData.bytes, ciphertextData.bytes, ciphertextData.length, plaintextData.mutableBytes, decryptedDataLength, &numBytesDecrypted); + + if (cryptStatus == kCCSuccess) { + plaintextData.length = numBytesDecrypted; + + NSError *jsonError = nil; + NSDictionary *parsedContent = [NSJSONSerialization JSONObjectWithData:plaintextData options:0 error:&jsonError]; + + if (!parsedContent || jsonError) { + return; + } + + NSString *notificationType = parsedContent[@"notification_type"]; + if (![notificationType isEqualToString:@"payment_received"]) { + return; + } + + NSDictionary *notificationDict = parsedContent[@"notification"]; + NSNumber *amountNumber = notificationDict[@"amount"]; + if (!amountNumber) { + return; + } + + double amountInSats = [amountNumber doubleValue] / 1000.0; + self.bestAttemptContent.title = walletName; + self.bestAttemptContent.body = [NSString stringWithFormat:@"You just received %.0f sats โšก๏ธ", amountInSats]; + } + + self.contentHandler(self.bestAttemptContent); +} + +- (void)serviceExtensionTimeWillExpire { + self.bestAttemptContent.body = @"expired noitification"; + self.contentHandler(self.bestAttemptContent); +} + +@end diff --git a/context/Notification.tsx b/context/Notification.tsx index db5f133..34c88ab 100644 --- a/context/Notification.tsx +++ b/context/Notification.tsx @@ -1,61 +1,13 @@ import { useEffect, useRef } from "react"; -import { Nip47Notification } from "@getalby/sdk/dist/NWCClient"; import * as ExpoNotifications from "expo-notifications"; import { useAppStore } from "~/lib/state/appStore"; ExpoNotifications.setNotificationHandler({ - handleNotification: async (notification) => { - console.info("๐Ÿ”” handleNotification", { - data: notification.request.content.data, - }); - - if (!notification.request.content.data.isLocal) { - console.info("๐Ÿ ๏ธ Local notification", notification.request.content); - - const encryptedData = notification.request.content.data.content; - const nwcClient = useAppStore.getState().nwcClient!; - - // TODO: Get the correct keys to decrypt - - try { - console.info("๐Ÿ”ด", encryptedData, nwcClient?.secret); - const decryptedContent = await nwcClient.decrypt( - nwcClient?.walletPubkey!, - encryptedData, - ); - console.info("๐Ÿ”“๏ธ decrypted data", decryptedContent); - const nip47Notification = JSON.parse( - decryptedContent, - ) as Nip47Notification; - console.info("decrypted", nip47Notification); - - if (nip47Notification.notification_type === "payment_received") { - ExpoNotifications.scheduleNotificationAsync({ - content: { - title: `You just received ${Math.floor(nip47Notification.notification.amount / 1000)} sats`, - body: nip47Notification.notification.description, - data: { - ...notification.request.content.data, - isLocal: true, - }, - }, - trigger: null, - }); - } - } catch (e) { - console.error("Failed to parse decrypted event content", e); - return { - shouldShowAlert: false, - shouldPlaySound: false, - shouldSetBadge: false, - }; - } - } - + handleNotification: async () => { return { - shouldShowAlert: !!notification.request.content.data.isLocal, - shouldPlaySound: false, + shouldShowAlert: true, + shouldPlaySound: true, shouldSetBadge: false, }; }, diff --git a/lib/constants.ts b/lib/constants.ts index 83ec22a..b341f6c 100644 --- a/lib/constants.ts +++ b/lib/constants.ts @@ -19,6 +19,8 @@ export const NAV_THEME = { }, }; +export const SUITE_NAME = "group.com.getalby.mobile.nse"; + export const BACKGROUND_NOTIFICATION_TASK = "BACKGROUND-NOTIFICATION-TASK"; export const INACTIVITY_THRESHOLD = 5 * 60 * 1000; diff --git a/lib/sharedSecret.ts b/lib/sharedSecret.ts new file mode 100644 index 0000000..280be09 --- /dev/null +++ b/lib/sharedSecret.ts @@ -0,0 +1,8 @@ +import { secp256k1 } from "@noble/curves/secp256k1"; +import { Buffer } from "buffer"; + +export function computeSharedSecret(pub: string, sk: string): string { + const sharedSecret = secp256k1.getSharedSecret(sk, "02" + pub); + const normalizedKey = sharedSecret.slice(1); + return Buffer.from(normalizedKey).toString("hex"); +} diff --git a/package.json b/package.json index 82b0266..78fd79f 100644 --- a/package.json +++ b/package.json @@ -25,8 +25,10 @@ "test:ci": "jest" }, "dependencies": { + "@alevy97/react-native-userdefaults": "^0.2.2", "@getalby/lightning-tools": "^5.0.3", "@getalby/sdk": "^3.7.1", + "@noble/curves": "^1.6.0", "@react-native-async-storage/async-storage": "1.23.1", "@rn-primitives/dialog": "^1.0.3", "@rn-primitives/portal": "^1.0.3", @@ -46,6 +48,7 @@ "expo-linear-gradient": "~13.0.2", "expo-linking": "~6.3.1", "expo-local-authentication": "~14.0.1", + "expo-notification-service-extension-plugin": "^1.0.1", "expo-notifications": "~0.28.19", "expo-router": "^3.5.24", "expo-secure-store": "^13.0.2", diff --git a/services/Notifications.ts b/services/Notifications.ts index 7f3cf82..4fea4a4 100644 --- a/services/Notifications.ts +++ b/services/Notifications.ts @@ -1,11 +1,13 @@ import Constants from "expo-constants"; import { Platform } from "react-native"; +import UserDefaults from "@alevy97/react-native-userdefaults/src/ReactNativeUserDefaults.ios"; import { nwc } from "@getalby/sdk"; import * as Device from "expo-device"; import * as ExpoNotifications from "expo-notifications"; -import { NOSTR_API_URL } from "~/lib/constants"; +import { NOSTR_API_URL, SUITE_NAME } from "~/lib/constants"; import { errorToast } from "~/lib/errorToast"; +import { computeSharedSecret } from "~/lib/sharedSecret"; import { useAppStore } from "~/lib/state/appStore"; // TODO: add background notification handling for android @@ -52,6 +54,10 @@ export async function registerForPushNotificationsAsync() { // FIXME: This would trigger the same notification // per wallet if they belong to the same node const wallets = useAppStore.getState().wallets; + let groupDefaults; + if (Platform.OS === "ios") { + groupDefaults = new UserDefaults(SUITE_NAME); + } for (const wallet of wallets) { const nwcUrl = wallet.nostrWalletConnectUrl; @@ -95,6 +101,18 @@ export async function registerForPushNotificationsAsync() { } else { throw new Error(`Error: ${response.status} ${response.statusText}`); } + + if (groupDefaults) { + let wallets = (await groupDefaults.get("wallets")) || {}; + wallets[nwcClient.publicKey] = { + name: wallet.name, + sharedSecret: computeSharedSecret( + nwcClient.walletPubkey, + nwcClient.secret ?? "", + ), + }; + groupDefaults.set("wallets", wallets); + } } catch (error) { errorToast(error); } diff --git a/yarn.lock b/yarn.lock index 62db63e..80dabb1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,11 @@ resolved "https://registry.yarnpkg.com/@0no-co/graphql.web/-/graphql.web-1.0.7.tgz#c7a762c887b3482a79ffa68f63de5e96059a62e4" integrity sha512-E3Qku4mTzdrlwVWGPxklDnME5ANrEGetvYw4i2GCRlppWXXE4QD66j7pwb8HelZwS6LnqEChhrSOGCXpbiu6MQ== +"@alevy97/react-native-userdefaults@^0.2.2": + version "0.2.2" + resolved "https://registry.yarnpkg.com/@alevy97/react-native-userdefaults/-/react-native-userdefaults-0.2.2.tgz#7e289d7f5f00fd9238941e022e5db5caad244cf8" + integrity sha512-ugcKr8SEi5SsQtxSxI0gMgg6S6vl2mTUU09fxcTKxnGVHzU3zs3WcbWUfFcbeH2XyjMIrqUc97beVpZLTg3PWQ== + "@alloc/quick-lru@^5.2.0": version "5.2.0" resolved "https://registry.yarnpkg.com/@alloc/quick-lru/-/quick-lru-5.2.0.tgz#7bf68b20c0a350f936915fcae06f58e32007ce30" @@ -1465,6 +1470,23 @@ dotenv-expand "~11.0.6" getenv "^1.0.0" +"@expo/image-utils@^0.3.22": + version "0.3.23" + resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.3.23.tgz#f14fd7e1f5ff6f8e4911a41e27dd274470665c3f" + integrity sha512-nhUVvW0TrRE4jtWzHQl8TR4ox7kcmrc2I0itaeJGjxF5A54uk7avgA0wRt7jP1rdvqQo1Ke1lXyLYREdhN9tPw== + dependencies: + "@expo/spawn-async" "1.5.0" + chalk "^4.0.0" + fs-extra "9.0.0" + getenv "^1.0.0" + jimp-compact "0.16.1" + mime "^2.4.4" + node-fetch "^2.6.0" + parse-png "^2.1.0" + resolve-from "^5.0.0" + semver "7.3.2" + tempy "0.3.0" + "@expo/image-utils@^0.5.0": version "0.5.1" resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.5.1.tgz#06fade141facebcd8431355923d30f3839309942" @@ -1608,6 +1630,13 @@ debug "^4.3.4" source-map-support "~0.5.21" +"@expo/spawn-async@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@expo/spawn-async/-/spawn-async-1.5.0.tgz#799827edd8c10ef07eb1a2ff9dcfe081d596a395" + integrity sha512-LB7jWkqrHo+5fJHNrLAFdimuSXQ2MQ4lA7SQW5bf/HbsXuV2VrT/jN/M8f/KoWt0uJMGN4k/j7Opx4AvOOxSew== + dependencies: + cross-spawn "^6.0.5" + "@expo/spawn-async@^1.5.0", "@expo/spawn-async@^1.7.2": version "1.7.2" resolved "https://registry.yarnpkg.com/@expo/spawn-async/-/spawn-async-1.7.2.tgz#fcfe66c3e387245e72154b1a7eae8cada6a47f58" @@ -1990,11 +2019,23 @@ dependencies: "@noble/hashes" "1.3.1" +"@noble/curves@^1.6.0": + version "1.6.0" + resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.6.0.tgz#be5296ebcd5a1730fccea4786d420f87abfeb40b" + integrity sha512-TlaHRXDehJuRNR9TfZDNQ45mMEd5dwUwmicsafcIX4SsNiqnCHKjE/1alYPd/lDRVhxdhUAlv8uEhMCI5zjIJQ== + dependencies: + "@noble/hashes" "1.5.0" + "@noble/hashes@1.3.1": version "1.3.1" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.1.tgz#8831ef002114670c603c458ab8b11328406953a9" integrity sha512-EbqwksQwz9xDRGfDST86whPBgM65E0OH/pCgqW0GBVzO22bNE+NuIbeTb714+IfSjU3aRk47EUvXIb5bTsenKA== +"@noble/hashes@1.5.0": + version "1.5.0" + resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.5.0.tgz#abadc5ca20332db2b1b2aa3e496e9af1213570b0" + integrity sha512-1j6kQFb7QRru7eKN3ZDvRcP13rugwdxZqCjbiAVZfIJwgj2A65UmT4TgARXGlXgnRkORLTDTrO19ZErt7+QXgA== + "@noble/hashes@~1.3.0", "@noble/hashes@~1.3.1": version "1.3.3" resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.3.tgz#39908da56a4adc270147bb07968bf3b16cfe1699" @@ -4291,7 +4332,7 @@ cross-fetch@^3.1.5: dependencies: node-fetch "^2.6.12" -cross-spawn@^6.0.0: +cross-spawn@^6.0.0, cross-spawn@^6.0.5: version "6.0.5" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== @@ -5338,6 +5379,13 @@ expo-modules-core@1.12.26: dependencies: invariant "^2.2.4" +expo-notification-service-extension-plugin@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/expo-notification-service-extension-plugin/-/expo-notification-service-extension-plugin-1.0.1.tgz#1f3bd59c131f23a2b6ddb7808c67be8e0951ec57" + integrity sha512-sCXJ674b4wvmzmu6INGgInl1ghlmo0B3yBhDiDzitHGVfrZBCa6g0vVfntHPMbSme8kZ9E3kK0wT9LEjC2xVTQ== + dependencies: + "@expo/image-utils" "^0.3.22" + expo-notifications@~0.28.19: version "0.28.19" resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.28.19.tgz#d8f858b887aacfc8ac5e0a59b00a9c88ded54469" @@ -7897,7 +7945,7 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== -mime@^2.4.1: +mime@^2.4.1, mime@^2.4.4: version "2.6.0" resolved "https://registry.yarnpkg.com/mime/-/mime-2.6.0.tgz#a2a682a95cd4d0cb1d6257e28f83da7e35800367" integrity sha512-USPkMeET31rOMiarsBNIHZKLGgvKc/LrjofAnBlOttf5ajRvqiRA8QsenbcooctK6d6Ts6aqZXBA+XbkKthiQg== @@ -9514,6 +9562,11 @@ selfsigned@^2.4.1: "@types/node-forge" "^1.3.0" node-forge "^1" +semver@7.3.2: + version "7.3.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.2.tgz#604962b052b81ed0786aae84389ffba70ffd3938" + integrity sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ== + semver@^5.5.0, semver@^5.6.0: version "5.7.2" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.2.tgz#48d55db737c3287cd4835e17fa13feace1c41ef8" From f4bf55d685692223df580169bc021d28884dc3d4 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Tue, 12 Nov 2024 19:06:33 +0530 Subject: [PATCH 07/40] feat: add messaging service for android notifications --- .gitignore | 4 +- MessagingService.kt | 144 ++++++++++++++++++++++++++++++++++++++ app.json | 3 +- app/_layout.tsx | 23 +----- package.json | 2 +- services/Notifications.ts | 29 ++++++-- yarn.lock | 17 ++--- 7 files changed, 181 insertions(+), 41 deletions(-) create mode 100644 MessagingService.kt diff --git a/.gitignore b/.gitignore index 5ee8d59..d8f729d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,4 +35,6 @@ yarn-error.* *.tsbuildinfo ios -android +android + +google-services.json diff --git a/MessagingService.kt b/MessagingService.kt new file mode 100644 index 0000000..1530d3a --- /dev/null +++ b/MessagingService.kt @@ -0,0 +1,144 @@ +package com.getalby.mobile + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.os.Build +import android.util.Base64 +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import org.json.JSONObject +import java.nio.charset.Charset +import javax.crypto.Cipher +import javax.crypto.spec.IvParameterSpec +import javax.crypto.spec.SecretKeySpec + +class MessagingService : FirebaseMessagingService() { + + override fun onMessageReceived(remoteMessage: RemoteMessage) { + val notificationId = System.currentTimeMillis().toInt() + + val notificationManager = NotificationManagerCompat.from(this) + + if (remoteMessage.data.isEmpty()) { + return + } + + if (!remoteMessage.data.containsKey("body")) { + return + } + + var encryptedContent = "" + var appPubkey = "" + val body = remoteMessage.data["body"] ?: return + + if (body.isEmpty()) { + return + } + + try { + val jsonBody = JSONObject(body) + encryptedContent = jsonBody.optString("content", "") + appPubkey = jsonBody.optString("appPubkey", "") + } catch (e: Exception) { + return + } + + if (encryptedContent.isEmpty()) { + return + } + + val sharedSecret = getSharedSecretFromPreferences(this, appPubkey) + val walletName = getWalletNameFromPreferences(this, appPubkey) ?: "Alby Go" + + if (sharedSecret.isNullOrEmpty()) { + return + } + + val sharedSecretBytes = hexStringToByteArray(sharedSecret) + val decryptedContent = decrypt(encryptedContent, sharedSecretBytes) + + if (decryptedContent == null) { + return + } + + val amount = try { + val json = JSONObject(decryptedContent) + val notification = json.getJSONObject("notification") + notification.getInt("amount") / 1000 + } catch (e: Exception) { + return + } + + val notificationText = "You have received $amount sats โšก๏ธ" + + // TODO: check if these are the right channel ids corressponding to expo code + // Create a notification channel for Android O and above + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + val channel = NotificationChannel( + "default", + "default", + NotificationManager.IMPORTANCE_HIGH + ) + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + + // Build the notification + val notificationBuilder = NotificationCompat.Builder(this, "default") + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(walletName) + .setContentText(notificationText) + .setAutoCancel(true) + notificationManager.notify(notificationId, notificationBuilder.build()) + } + + private fun getSharedSecretFromPreferences(context: Context, key: String): String? { + val sharedPreferences = context.getSharedPreferences(context.packageName + ".settings", Context.MODE_PRIVATE) + return sharedPreferences.getString("${key}_shared_secret", null) + } + + private fun getWalletNameFromPreferences(context: Context, key: String): String? { + val sharedPreferences = context.getSharedPreferences(context.packageName + ".settings", Context.MODE_PRIVATE) + return sharedPreferences.getString("${key}_name", null) + } + + // Function to decrypt the content + private fun decrypt(content: String, key: ByteArray): String? { + val parts = content.split("?iv=") + if (parts.size < 2) { + return null + } + + val ciphertext = Base64.decode(parts[0], Base64.DEFAULT) + val iv = Base64.decode(parts[1], Base64.DEFAULT) + + return try { + val cipher = Cipher.getInstance("AES/CBC/PKCS7Padding") + val secretKey = SecretKeySpec(key, "AES") + val ivParams = IvParameterSpec(iv) + cipher.init(Cipher.DECRYPT_MODE, secretKey, ivParams) + + val plaintext = cipher.doFinal(ciphertext) + String(plaintext, Charset.forName("UTF-8")) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + + // Helper function to convert hex string to byte array + private fun hexStringToByteArray(s: String): ByteArray { + val len = s.length + val data = ByteArray(len / 2) + var i = 0 + while (i < len) { + data[i / 2] = ((Character.digit(s[i], 16) shl 4) + + Character.digit(s[i + 1], 16)).toByte() + i += 2 + } + return data + } +} diff --git a/app.json b/app.json index d934605..72cc0ec 100644 --- a/app.json +++ b/app.json @@ -65,7 +65,8 @@ "foregroundImage": "./assets/adaptive-icon.png", "backgroundImage": "./assets/adaptive-icon-bg.png" }, - "permissions": ["android.permission.CAMERA"] + "permissions": ["android.permission.CAMERA"], + "googleServicesFile": "./google-services.json" }, "extra": { "eas": { diff --git a/app/_layout.tsx b/app/_layout.tsx index 66ac1e4..35fa219 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -1,10 +1,8 @@ import { Theme, ThemeProvider } from "@react-navigation/native"; import { PortalHost } from "@rn-primitives/portal"; import * as Font from "expo-font"; -import * as Notifications from "expo-notifications"; import { Slot, SplashScreen } from "expo-router"; import { StatusBar } from "expo-status-bar"; -import * as TaskManager from "expo-task-manager"; import { swrConfiguration } from "lib/swr"; import * as React from "react"; import { SafeAreaView } from "react-native"; @@ -17,7 +15,7 @@ import { UserInactivityProvider } from "~/context/UserInactivity"; import "~/global.css"; import { useInfo } from "~/hooks/useInfo"; import { SessionProvider } from "~/hooks/useSession"; -import { BACKGROUND_NOTIFICATION_TASK, NAV_THEME } from "~/lib/constants"; +import { NAV_THEME } from "~/lib/constants"; import { isBiometricSupported } from "~/lib/isBiometricSupported"; import { useAppStore } from "~/lib/state/appStore"; import { useColorScheme } from "~/lib/useColorScheme"; @@ -36,25 +34,6 @@ export { ErrorBoundary, } from "expo-router"; -// FIXME: only use this in android (?) -TaskManager.defineTask( - BACKGROUND_NOTIFICATION_TASK, - ({ data }: { data: Record }) => { - console.info("Received a notification in the background!", data?.body); - // Do something with the notification data - }, -); - -Notifications.registerTaskAsync(BACKGROUND_NOTIFICATION_TASK) - .then(() => { - console.info( - `Notifications.registerTaskAsync success: ${BACKGROUND_NOTIFICATION_TASK}`, - ); - }) - .catch((reason) => { - console.info(`Notifications registerTaskAsync failed: ${reason}`); - }); - // Prevent the splash screen from auto-hiding before getting the color scheme. SplashScreen.preventAutoHideAsync(); diff --git a/package.json b/package.json index 78fd79f..c246f7d 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "expo-notifications": "~0.28.19", "expo-router": "^3.5.24", "expo-secure-store": "^13.0.2", + "expo-shared-preferences": "^0.4.0", "expo-status-bar": "~1.12.1", - "expo-task-manager": "^11.8.2", "lottie-react-native": "6.7.0", "lucide-react-native": "^0.376.0", "nativewind": "^4.0.1", diff --git a/services/Notifications.ts b/services/Notifications.ts index 4fea4a4..c367d4c 100644 --- a/services/Notifications.ts +++ b/services/Notifications.ts @@ -1,15 +1,21 @@ -import Constants from "expo-constants"; -import { Platform } from "react-native"; - -import UserDefaults from "@alevy97/react-native-userdefaults/src/ReactNativeUserDefaults.ios"; import { nwc } from "@getalby/sdk"; +import Constants from "expo-constants"; import * as Device from "expo-device"; import * as ExpoNotifications from "expo-notifications"; +import * as SharedPreferences from "expo-shared-preferences"; +import { Platform } from "react-native"; import { NOSTR_API_URL, SUITE_NAME } from "~/lib/constants"; import { errorToast } from "~/lib/errorToast"; import { computeSharedSecret } from "~/lib/sharedSecret"; import { useAppStore } from "~/lib/state/appStore"; +let UserDefaults: any; + +if (Platform.OS === "ios") { + UserDefaults = + require("@alevy97/react-native-userdefaults/src/ReactNativeUserDefaults.ios").default; +} + // TODO: add background notification handling for android export async function registerForPushNotificationsAsync() { @@ -102,6 +108,9 @@ export async function registerForPushNotificationsAsync() { throw new Error(`Error: ${response.status} ${response.statusText}`); } + // TODO: also update these defaults when wallet name is updated + // TODO: remove these group defaults when wallet is removed + // or when notifications are removed maybe? if (groupDefaults) { let wallets = (await groupDefaults.get("wallets")) || {}; wallets[nwcClient.publicKey] = { @@ -112,6 +121,18 @@ export async function registerForPushNotificationsAsync() { ), }; groupDefaults.set("wallets", wallets); + } else { + // TODO: json stringify and add similar to iOS + const sharedSecretKey = `${nwcClient.publicKey}_shared_secret`; + const nameKey = `${nwcClient.publicKey}_name`; + SharedPreferences.setItemAsync(nameKey, wallet.name ?? ""); + SharedPreferences.setItemAsync( + sharedSecretKey, + computeSharedSecret( + nwcClient.walletPubkey, + nwcClient.secret ?? "", + ), + ); } } catch (error) { errorToast(error); diff --git a/yarn.lock b/yarn.lock index 80dabb1..ce74522 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5420,6 +5420,11 @@ expo-secure-store@^13.0.2: resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-13.0.2.tgz#ba8f6076fc38062a28bb2ce5edab9cd28ef88598" integrity sha512-3QYgoneo8p8yeeBPBiAfokNNc2xq6+n8+Ob4fAlErEcf4H7Y72LH+K/dx0nQyWau2ZKZUXBxyyfuHFyVKrEVLg== +expo-shared-preferences@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/expo-shared-preferences/-/expo-shared-preferences-0.4.0.tgz#948549e378606cdf994162d2476be2fafeb3671b" + integrity sha512-gsyITslV7FwT4HrnEKmcnisZbPuyjEg+1KqDfxsvFOc9gVl2/LHlvZ/KbzY38MKGiCfxqx+gr48aUypNrLjYxA== + expo-splash-screen@0.27.7: version "0.27.7" resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-0.27.7.tgz#52171be54d8c008880d928e802819d767fbd3c12" @@ -5432,13 +5437,6 @@ expo-status-bar@~1.12.1: resolved "https://registry.yarnpkg.com/expo-status-bar/-/expo-status-bar-1.12.1.tgz#52ce594aab5064a0511d14375364d718ab78aa66" integrity sha512-/t3xdbS8KB0prj5KG5w7z+wZPFlPtkgs95BsmrP/E7Q0xHXTcDcQ6Cu2FkFuRM+PKTb17cJDnLkawyS5vDLxMA== -expo-task-manager@^11.8.2: - version "11.8.2" - resolved "https://registry.yarnpkg.com/expo-task-manager/-/expo-task-manager-11.8.2.tgz#1090a445565ca65ed99991166ddda38575b3dc8c" - integrity sha512-Uhy3ol5gYeZOyeRFddYjLI1B2DGRH1gjp/YC8Hpn5p5MVENviySoKNF+wd98rRvOAokzrzElyDBHSTfX+C3tpg== - dependencies: - unimodules-app-loader "~4.6.0" - expo@~51.0.39: version "51.0.39" resolved "https://registry.yarnpkg.com/expo/-/expo-51.0.39.tgz#d9efab081a91a0d3e925b0e4648722b13a8fceae" @@ -10635,11 +10633,6 @@ unicode-property-aliases-ecmascript@^2.0.0: resolved "https://registry.yarnpkg.com/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz#43d41e3be698bd493ef911077c9b131f827e8ccd" integrity sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w== -unimodules-app-loader@~4.6.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/unimodules-app-loader/-/unimodules-app-loader-4.6.0.tgz#8836040b3acbf605fc4c2c6f6feb6dd9084ea0d4" - integrity sha512-FRNIlx7sLBDVPG117JnEBhnzZkTIgZTEwYW2rzrY9HdvLBTpRN+k0dxY50U/CAhFHW3zMD0OP5JAlnSQRhx5HA== - unique-filename@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/unique-filename/-/unique-filename-3.0.0.tgz#48ba7a5a16849f5080d26c760c86cf5cf05770ea" From ba47b3804fa529c5bdfbc0ef9ea0905cb5718319 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Tue, 12 Nov 2024 19:23:31 +0530 Subject: [PATCH 08/40] chore: changes --- MessagingService.kt => assets/MessagingService.kt | 5 +++++ assets/NotificationService.m | 14 ++++++++++---- 2 files changed, 15 insertions(+), 4 deletions(-) rename MessagingService.kt => assets/MessagingService.kt (97%) diff --git a/MessagingService.kt b/assets/MessagingService.kt similarity index 97% rename from MessagingService.kt rename to assets/MessagingService.kt index 1530d3a..c82b6a4 100644 --- a/MessagingService.kt +++ b/assets/MessagingService.kt @@ -50,6 +50,10 @@ class MessagingService : FirebaseMessagingService() { return } + if (appPubkey.isEmpty()) { + return + } + val sharedSecret = getSharedSecretFromPreferences(this, appPubkey) val walletName = getWalletNameFromPreferences(this, appPubkey) ?: "Alby Go" @@ -64,6 +68,7 @@ class MessagingService : FirebaseMessagingService() { return } + // TODO: remove if notification type is not payment_received val amount = try { val json = JSONObject(decryptedContent) val notification = json.getJSONObject("notification") diff --git a/assets/NotificationService.m b/assets/NotificationService.m index ea71271..24a4bde 100644 --- a/assets/NotificationService.m +++ b/assets/NotificationService.m @@ -32,15 +32,22 @@ - (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withConte self.receivedRequest = request; self.contentHandler = contentHandler; self.bestAttemptContent = [request.content mutableCopy]; - - NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.getalby.mobile.nse"]; - NSDictionary *walletsDict = [sharedDefaults objectForKey:@"wallets"]; + + // TODO: check if userinfo / body are empty NSString *appPubkey = request.content.userInfo[@"body"][@"appPubkey"]; if (!appPubkey) { return; } + NSString *encryptedContent = request.content.userInfo[@"body"][@"content"]; + if (!encryptedContent) { + return; + } + + NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.getalby.mobile.nse"]; + NSDictionary *walletsDict = [sharedDefaults objectForKey:@"wallets"]; + NSDictionary *walletInfo = walletsDict[appPubkey]; if (!walletInfo) { return; @@ -57,7 +64,6 @@ - (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withConte return; } - NSString *encryptedContent = request.content.userInfo[@"body"][@"content"]; NSArray *parts = [encryptedContent componentsSeparatedByString:@"?iv="]; if (parts.count < 2) { return; From 814959e9555cc7906d46f9beaefff29b8a019539 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 13 Nov 2024 00:21:31 +0530 Subject: [PATCH 09/40] chore: add message service config plugin --- RELEASE.md | 2 +- app.config.js | 88 +++++++++++++++++++++++ app.json | 78 -------------------- package.json | 1 + pages/settings/Notifications.tsx | 2 +- plugins/withMessageServicePlugin.js | 106 ++++++++++++++++++++++++++++ yarn.lock | 21 ++++++ 7 files changed, 218 insertions(+), 80 deletions(-) create mode 100644 app.config.js delete mode 100644 app.json create mode 100644 plugins/withMessageServicePlugin.js diff --git a/RELEASE.md b/RELEASE.md index b48b913..306c446 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -2,7 +2,7 @@ 1. Update version in -- `app.json` +- `app.config.js` - `package.json` 2. Create a git tag and push it (a new draft release will be created) diff --git a/app.config.js b/app.config.js new file mode 100644 index 0000000..eb47a4f --- /dev/null +++ b/app.config.js @@ -0,0 +1,88 @@ +import withMessagingServicePlugin from "./plugins/withMessageServicePlugin"; + +export default ({ config }) => { + return { + ...config, + name: "Alby Go", + slug: "alby-mobile", + version: "1.7.1", + scheme: ["bitcoin", "lightning", "alby"], + orientation: "portrait", + icon: "./assets/icon.png", + userInterfaceStyle: "automatic", + splash: { + image: "./assets/splash.png", + resizeMode: "cover", + backgroundColor: "#0F0C40", + }, + assetBundlePatterns: ["**/*"], + plugins: [ + [ + withMessagingServicePlugin, + { + androidFMSFilePath: "./assets/MessagingService.kt", + }, + ], + [ + "expo-notification-service-extension-plugin", + { + mode: "production", + iosNSEFilePath: "./assets/NotificationService.m", + }, + ], + [ + "expo-local-authentication", + { + faceIDPermission: "Allow Alby Go to use Face ID.", + }, + ], + [ + "expo-camera", + { + cameraPermission: + "Allow Alby Go to use the camera to scan wallet connection and payment QR codes", + recordAudioAndroid: false, + }, + ], + [ + "expo-font", + { + fonts: [ + "./assets/fonts/OpenRunde-Regular.otf", + "./assets/fonts/OpenRunde-Medium.otf", + "./assets/fonts/OpenRunde-Semibold.otf", + "./assets/fonts/OpenRunde-Bold.otf", + ], + }, + ], + "expo-router", + "expo-secure-store", + ], + ios: { + supportsTablet: true, + bundleIdentifier: "com.getalby.mobile", + config: { + usesNonExemptEncryption: false, + }, + infoPlist: { + UIBackgroundModes: ["remote-notification", "processing"], + }, + }, + android: { + package: "com.getalby.mobile", + icon: "./assets/icon.png", + adaptiveIcon: { + foregroundImage: "./assets/adaptive-icon.png", + backgroundImage: "./assets/adaptive-icon-bg.png", + }, + permissions: ["android.permission.CAMERA"], + googleServicesFile: "./google-services.json", + }, + extra: { + eas: { + projectId: "294965ec-3a67-4994-8794-5cc1117ef155", + }, + }, + owner: "roland_alby", + }; +}; diff --git a/app.json b/app.json deleted file mode 100644 index 72cc0ec..0000000 --- a/app.json +++ /dev/null @@ -1,78 +0,0 @@ -{ - "expo": { - "name": "Alby Go", - "slug": "alby-mobile", - "version": "1.7.1", - "scheme": ["bitcoin", "lightning", "alby"], - "orientation": "portrait", - "icon": "./assets/icon.png", - "userInterfaceStyle": "automatic", - "splash": { - "image": "./assets/splash.png", - "resizeMode": "cover", - "backgroundColor": "#0F0C40" - }, - "assetBundlePatterns": ["**/*"], - "plugins": [ - [ - "expo-notification-service-extension-plugin", - { - "mode": "production", - "iosNSEFilePath": "./assets/NotificationService.m" - } - ], - [ - "expo-local-authentication", - { - "faceIDPermission": "Allow Alby Go to use Face ID." - } - ], - [ - "expo-camera", - { - "cameraPermission": "Allow Alby Go to use the camera to scan wallet connection and payment QR codes", - "recordAudioAndroid": false - } - ], - [ - "expo-font", - { - "fonts": [ - "./assets/fonts/OpenRunde-Regular.otf", - "./assets/fonts/OpenRunde-Medium.otf", - "./assets/fonts/OpenRunde-Semibold.otf", - "./assets/fonts/OpenRunde-Bold.otf" - ] - } - ], - "expo-router", - "expo-secure-store" - ], - "ios": { - "supportsTablet": true, - "bundleIdentifier": "com.getalby.mobile", - "config": { - "usesNonExemptEncryption": false - }, - "infoPlist": { - "UIBackgroundModes": ["remote-notification", "processing"] - } - }, - "android": { - "package": "com.getalby.mobile", - "icon": "./assets/icon.png", - "adaptiveIcon": { - "foregroundImage": "./assets/adaptive-icon.png", - "backgroundImage": "./assets/adaptive-icon-bg.png" - }, - "permissions": ["android.permission.CAMERA"], - "googleServicesFile": "./google-services.json" - }, - "extra": { - "eas": { - "projectId": "294965ec-3a67-4994-8794-5cc1117ef155" - } - }, - "owner": "roland_alby" - } -} diff --git a/package.json b/package.json index c246f7d..7440502 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "devDependencies": { "@babel/core": "^7.20.0", "@babel/preset-typescript": "^7.24.7", + "@expo/config-plugins": "^8.0.10", "@testing-library/react-hooks": "^8.0.1", "@testing-library/react-native": "^12.7.2", "@types/crypto-js": "^4.2.2", diff --git a/pages/settings/Notifications.tsx b/pages/settings/Notifications.tsx index 0a589c3..fa1a591 100644 --- a/pages/settings/Notifications.tsx +++ b/pages/settings/Notifications.tsx @@ -21,12 +21,12 @@ export function Notifications() { { - useAppStore.getState().setNotificationsEnabled(checked); if (checked) { await registerForPushNotificationsAsync(); } else { // TODO: de-register all wallets on nostr api } + useAppStore.getState().setNotificationsEnabled(checked); }} nativeID="security" /> diff --git a/plugins/withMessageServicePlugin.js b/plugins/withMessageServicePlugin.js new file mode 100644 index 0000000..c6223d5 --- /dev/null +++ b/plugins/withMessageServicePlugin.js @@ -0,0 +1,106 @@ +const { + withAndroidManifest, + withAppBuildGradle, + withDangerousMod, +} = require("@expo/config-plugins"); +const fs = require("fs"); +const path = require("path"); + +module.exports = function withMessagingServicePlugin(config, props = {}) { + config = withMessagingService(config, props); + config = withAndroidManifest(config, modifyAndroidManifest); + config = withAppBuildGradle(config, modifyAppBuildGradle); + return config; +}; + +function getPackageName(config) { + return config.android && config.android.package + ? config.android.package + : null; +} + +function withMessagingService(config, props) { + return withDangerousMod(config, [ + "android", + async (config) => { + const projectRoot = config.modRequest.projectRoot; + const srcFilePath = path.resolve(projectRoot, props.androidFMSFilePath); + + const packageName = getPackageName(config); + if (!packageName) { + throw new Error("Android package name not found in app config."); + } + + const packagePath = packageName.replace(/\./g, path.sep); + + const destDir = path.join( + projectRoot, + "android", + "app", + "src", + "main", + "java", + packagePath, + ); + const destFilePath = path.join(destDir, "MessagingService.kt"); + + fs.mkdirSync(destDir, { recursive: true }); + fs.copyFileSync(srcFilePath, destFilePath); + + return config; + }, + ]); +} + +function modifyAndroidManifest(config) { + const androidManifest = config.modResults; + + const application = androidManifest.manifest.application?.[0]; + if (!application) { + throw new Error("Could not find in AndroidManifest.xml"); + } + + if (!application.service) { + application.service = []; + } + + const serviceExists = application.service.some( + (service) => service.$["android:name"] === ".MessagingService", + ); + + if (!serviceExists) { + application.service.push({ + $: { + "android:name": ".MessagingService", + "android:exported": "false", + }, + "intent-filter": [ + { + action: [ + { + $: { + "android:name": "com.google.firebase.MESSAGING_EVENT", + }, + }, + ], + }, + ], + }); + } + + return config; +} + +function modifyAppBuildGradle(config) { + const buildGradle = config.modResults.contents; + const dependency = `implementation("com.google.firebase:firebase-messaging:23.2.1")`; + + if (!buildGradle.includes(dependency)) { + const pattern = /dependencies\s?{/; + config.modResults.contents = buildGradle.replace(pattern, (match) => { + return `${match}\n ${dependency}`; + }); + } + + return config; +} diff --git a/yarn.lock b/yarn.lock index ce74522..cd0efc5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1316,6 +1316,27 @@ xcode "^3.0.1" xml2js "0.6.0" +"@expo/config-plugins@^8.0.10": + version "8.0.10" + resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-8.0.10.tgz#5cda076f38bc04675cb42d8acdd23d6e460a62de" + integrity sha512-KG1fnSKRmsudPU9BWkl59PyE0byrE2HTnqbOrgwr2FAhqh7tfr9nRs6A9oLS/ntpGzmFxccTEcsV0L4apsuxxg== + dependencies: + "@expo/config-types" "^51.0.3" + "@expo/json-file" "~8.3.0" + "@expo/plist" "^0.1.0" + "@expo/sdk-runtime-versions" "^1.0.0" + chalk "^4.1.2" + debug "^4.3.1" + find-up "~5.0.0" + getenv "^1.0.0" + glob "7.1.6" + resolve-from "^5.0.0" + semver "^7.5.4" + slash "^3.0.0" + slugify "^1.6.6" + xcode "^3.0.1" + xml2js "0.6.0" + "@expo/config-plugins@~8.0.0": version "8.0.5" resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-8.0.5.tgz#fc165e59786e399dd4694aae2a7cd716ab8a496c" From 979dfb24666c6610288b2d7b45851f779fbbf2fa Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 13 Nov 2024 01:08:51 +0530 Subject: [PATCH 10/40] chore: use our own modified package --- package.json | 2 +- services/Notifications.ts | 2 +- yarn.lock | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index 7440502..1b2d4ea 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ }, "dependencies": { "@alevy97/react-native-userdefaults": "^0.2.2", + "@getalby/expo-shared-preferences": "^0.0.1", "@getalby/lightning-tools": "^5.0.3", "@getalby/sdk": "^3.7.1", "@noble/curves": "^1.6.0", @@ -52,7 +53,6 @@ "expo-notifications": "~0.28.19", "expo-router": "^3.5.24", "expo-secure-store": "^13.0.2", - "expo-shared-preferences": "^0.4.0", "expo-status-bar": "~1.12.1", "lottie-react-native": "6.7.0", "lucide-react-native": "^0.376.0", diff --git a/services/Notifications.ts b/services/Notifications.ts index c367d4c..1d1d1d4 100644 --- a/services/Notifications.ts +++ b/services/Notifications.ts @@ -1,8 +1,8 @@ +import * as SharedPreferences from "@getalby/expo-shared-preferences"; import { nwc } from "@getalby/sdk"; import Constants from "expo-constants"; import * as Device from "expo-device"; import * as ExpoNotifications from "expo-notifications"; -import * as SharedPreferences from "expo-shared-preferences"; import { Platform } from "react-native"; import { NOSTR_API_URL, SUITE_NAME } from "~/lib/constants"; import { errorToast } from "~/lib/errorToast"; diff --git a/yarn.lock b/yarn.lock index cd0efc5..95f3683 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1682,6 +1682,11 @@ find-up "^5.0.0" js-yaml "^4.1.0" +"@getalby/expo-shared-preferences@^0.0.1": + version "0.0.1" + resolved "https://registry.yarnpkg.com/@getalby/expo-shared-preferences/-/expo-shared-preferences-0.0.1.tgz#91be9e8f6b138ee4aa014896fb9f5ce25063db30" + integrity sha512-9TW1m7rLEVttF5rUW3iLNYLFy0kLNlA5HU3x7jcUfVzixuA6CC/NZgLdLSItTR1HG5Ttw4FB/6U5TBvVIkn16Q== + "@getalby/lightning-tools@^5.0.3": version "5.0.3" resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-5.0.3.tgz#4cc6ef1253a30fb4913af89b842645e0c04994bf" @@ -5441,11 +5446,6 @@ expo-secure-store@^13.0.2: resolved "https://registry.yarnpkg.com/expo-secure-store/-/expo-secure-store-13.0.2.tgz#ba8f6076fc38062a28bb2ce5edab9cd28ef88598" integrity sha512-3QYgoneo8p8yeeBPBiAfokNNc2xq6+n8+Ob4fAlErEcf4H7Y72LH+K/dx0nQyWau2ZKZUXBxyyfuHFyVKrEVLg== -expo-shared-preferences@^0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/expo-shared-preferences/-/expo-shared-preferences-0.4.0.tgz#948549e378606cdf994162d2476be2fafeb3671b" - integrity sha512-gsyITslV7FwT4HrnEKmcnisZbPuyjEg+1KqDfxsvFOc9gVl2/LHlvZ/KbzY38MKGiCfxqx+gr48aUypNrLjYxA== - expo-splash-screen@0.27.7: version "0.27.7" resolved "https://registry.yarnpkg.com/expo-splash-screen/-/expo-splash-screen-0.27.7.tgz#52171be54d8c008880d928e802819d767fbd3c12" From 4957141828750a9957f9ebd430ba6a35d29da4fa Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 13 Nov 2024 14:05:34 +0530 Subject: [PATCH 11/40] chore: import shared preferences only in android --- services/Notifications.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/services/Notifications.ts b/services/Notifications.ts index 1d1d1d4..7eb341c 100644 --- a/services/Notifications.ts +++ b/services/Notifications.ts @@ -1,4 +1,3 @@ -import * as SharedPreferences from "@getalby/expo-shared-preferences"; import { nwc } from "@getalby/sdk"; import Constants from "expo-constants"; import * as Device from "expo-device"; @@ -10,10 +9,13 @@ import { computeSharedSecret } from "~/lib/sharedSecret"; import { useAppStore } from "~/lib/state/appStore"; let UserDefaults: any; +let SharedPreferences: any; if (Platform.OS === "ios") { UserDefaults = require("@alevy97/react-native-userdefaults/src/ReactNativeUserDefaults.ios").default; +} else { + SharedPreferences = require("@getalby/expo-shared-preferences"); } // TODO: add background notification handling for android @@ -79,6 +81,7 @@ export async function registerForPushNotificationsAsync() { relayUrl: nwcClient.relayUrl, connectionPubkey: nwcClient.publicKey, walletPubkey: nwcClient.walletPubkey, + isIOS: Platform.OS === "ios", }; try { From 9b4a596668aa52d3d4ec70c5e258ae9f1c180784 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Wed, 13 Nov 2024 15:53:53 +0530 Subject: [PATCH 12/40] chore: add google services json to env --- app.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app.config.js b/app.config.js index eb47a4f..27e029c 100644 --- a/app.config.js +++ b/app.config.js @@ -76,7 +76,7 @@ export default ({ config }) => { backgroundImage: "./assets/adaptive-icon-bg.png", }, permissions: ["android.permission.CAMERA"], - googleServicesFile: "./google-services.json", + googleServicesFile: process.env.GOOGLE_SERVICES_JSON, }, extra: { eas: { From 6a5ccc466244198562df710a0f3556eb31cecb21 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Thu, 14 Nov 2024 17:20:45 +0530 Subject: [PATCH 13/40] chore: further changes --- assets/MessagingService.kt | 119 +++++++++++++----------- assets/NotificationService.m | 82 ++++++++++------ lib/notifications.ts | 90 ++++++++++++++++++ lib/state/appStore.ts | 28 +++--- lib/storeWalletInfo.ts | 44 +++++++++ pages/settings/Notifications.tsx | 13 ++- pages/settings/Settings.tsx | 7 +- pages/settings/wallets/EditWallet.tsx | 6 +- pages/settings/wallets/RenameWallet.tsx | 7 +- pages/settings/wallets/SetupWallet.tsx | 14 ++- services/Notifications.ts | 116 ++++------------------- 11 files changed, 328 insertions(+), 198 deletions(-) create mode 100644 lib/notifications.ts create mode 100644 lib/storeWalletInfo.ts diff --git a/assets/MessagingService.kt b/assets/MessagingService.kt index c82b6a4..ffc7335 100644 --- a/assets/MessagingService.kt +++ b/assets/MessagingService.kt @@ -3,7 +3,9 @@ package com.getalby.mobile import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context +import android.graphics.Color import android.os.Build +// import android.os.PowerManager import android.util.Base64 import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -17,100 +19,94 @@ import javax.crypto.spec.SecretKeySpec class MessagingService : FirebaseMessagingService() { - override fun onMessageReceived(remoteMessage: RemoteMessage) { - val notificationId = System.currentTimeMillis().toInt() - - val notificationManager = NotificationManagerCompat.from(this) + data class WalletInfo( + val name: String, + val sharedSecret: String + ) + + private fun getWalletInfo(context: Context, key: String): WalletInfo? { + val sharedPreferences = context.getSharedPreferences("${context.packageName}.settings", Context.MODE_PRIVATE) + val walletsString = sharedPreferences.getString("wallets", null) ?: return null + return try { + val walletsJson = JSONObject(walletsString) + val walletJson = walletsJson.optJSONObject(key) ?: return null + WalletInfo( + name = walletJson.optString("name", "Alby Go"), + sharedSecret = walletJson.optString("sharedSecret", "") + ) + } catch (e: Exception) { + e.printStackTrace() + null + } + } + override fun onMessageReceived(remoteMessage: RemoteMessage) { if (remoteMessage.data.isEmpty()) { return } - if (!remoteMessage.data.containsKey("body")) { - return - } - - var encryptedContent = "" - var appPubkey = "" - val body = remoteMessage.data["body"] ?: return - - if (body.isEmpty()) { - return - } + val data = remoteMessage.data + val body = data["body"] ?: return - try { - val jsonBody = JSONObject(body) - encryptedContent = jsonBody.optString("content", "") - appPubkey = jsonBody.optString("appPubkey", "") + val jsonBody = try { + JSONObject(body) } catch (e: Exception) { return } - if (encryptedContent.isEmpty()) { - return - } + val encryptedContent = jsonBody.optString("content", "") + val appPubkey = jsonBody.optString("appPubkey", "") - if (appPubkey.isEmpty()) { + if (encryptedContent.isEmpty() || appPubkey.isEmpty()) { return } - val sharedSecret = getSharedSecretFromPreferences(this, appPubkey) - val walletName = getWalletNameFromPreferences(this, appPubkey) ?: "Alby Go" - - if (sharedSecret.isNullOrEmpty()) { - return + val walletInfo = getWalletInfo(this, appPubkey) ?: return + if (walletInfo.sharedSecret.isEmpty()) { + return } + val sharedSecretBytes = hexStringToByteArray(walletInfo.sharedSecret) + val walletName = walletInfo.name - val sharedSecretBytes = hexStringToByteArray(sharedSecret) - val decryptedContent = decrypt(encryptedContent, sharedSecretBytes) + val decryptedContent = decrypt(encryptedContent, sharedSecretBytes) ?: return - if (decryptedContent == null) { + val json = try { + JSONObject(decryptedContent) + } catch (e: Exception) { return } - // TODO: remove if notification type is not payment_received - val amount = try { - val json = JSONObject(decryptedContent) - val notification = json.getJSONObject("notification") - notification.getInt("amount") / 1000 - } catch (e: Exception) { + val notificationType = json.optString("notification_type", "") + if (notificationType != "payment_received") { return } - val notificationText = "You have received $amount sats โšก๏ธ" + val notification = json.optJSONObject("notification") ?: return + val amount = notification.optInt("amount", 0) / 1000 - // TODO: check if these are the right channel ids corressponding to expo code - // Create a notification channel for Android O and above - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - val channel = NotificationChannel( - "default", - "default", - NotificationManager.IMPORTANCE_HIGH - ) - val manager = getSystemService(NotificationManager::class.java) - manager.createNotificationChannel(channel) - } + val notificationText = "You have received $amount sats โšก๏ธ" - // Build the notification val notificationBuilder = NotificationCompat.Builder(this, "default") .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(walletName) .setContentText(notificationText) .setAutoCancel(true) + val notificationManager = NotificationManagerCompat.from(this) + val notificationId = System.currentTimeMillis().toInt() notificationManager.notify(notificationId, notificationBuilder.build()) + // wakeApp() } private fun getSharedSecretFromPreferences(context: Context, key: String): String? { - val sharedPreferences = context.getSharedPreferences(context.packageName + ".settings", Context.MODE_PRIVATE) + val sharedPreferences = context.getSharedPreferences("${context.packageName}.settings", Context.MODE_PRIVATE) return sharedPreferences.getString("${key}_shared_secret", null) } private fun getWalletNameFromPreferences(context: Context, key: String): String? { - val sharedPreferences = context.getSharedPreferences(context.packageName + ".settings", Context.MODE_PRIVATE) + val sharedPreferences = context.getSharedPreferences("${context.packageName}.settings", Context.MODE_PRIVATE) return sharedPreferences.getString("${key}_name", null) } - // Function to decrypt the content private fun decrypt(content: String, key: ByteArray): String? { val parts = content.split("?iv=") if (parts.size < 2) { @@ -129,12 +125,10 @@ class MessagingService : FirebaseMessagingService() { val plaintext = cipher.doFinal(ciphertext) String(plaintext, Charset.forName("UTF-8")) } catch (e: Exception) { - e.printStackTrace() null } } - // Helper function to convert hex string to byte array private fun hexStringToByteArray(s: String): ByteArray { val len = s.length val data = ByteArray(len / 2) @@ -146,4 +140,19 @@ class MessagingService : FirebaseMessagingService() { } return data } + + // private fun wakeApp() { + // @Suppress("DEPRECATION") + // val pm = applicationContext.getSystemService(POWER_SERVICE) as PowerManager + // val screenIsOn = pm.isInteractive + // if (!screenIsOn) { + // val wakeLockTag = packageName + "WAKELOCK" + // val wakeLock = pm.newWakeLock( + // PowerManager.FULL_WAKE_LOCK or + // PowerManager.ACQUIRE_CAUSES_WAKEUP or + // PowerManager.ON_AFTER_RELEASE, wakeLockTag + // ) + // wakeLock.acquire() + // } + // } } diff --git a/assets/NotificationService.m b/assets/NotificationService.m index 24a4bde..9762ddc 100644 --- a/assets/NotificationService.m +++ b/assets/NotificationService.m @@ -11,7 +11,6 @@ @interface NotificationService () @implementation NotificationService -// Helper function to convert hex string to NSData NSData* dataFromHexString(NSString *hexString) { NSMutableData *data = [NSMutableData data]; int idx; @@ -22,7 +21,7 @@ @implementation NotificationService if ([[NSScanner scannerWithString:hexByte] scanHexInt:&byte]) { [data appendBytes:&byte length:1]; } else { - return nil; // invalid hex string + return nil; } } return data; @@ -33,39 +32,63 @@ - (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withConte self.contentHandler = contentHandler; self.bestAttemptContent = [request.content mutableCopy]; - // TODO: check if userinfo / body are empty + NSDictionary *userInfo = request.content.userInfo; + if (!userInfo) { + self.contentHandler(nil); + return; + } + + NSDictionary *bodyDict = userInfo[@"body"]; + if (!bodyDict) { + self.contentHandler(nil); + return; + } - NSString *appPubkey = request.content.userInfo[@"body"][@"appPubkey"]; + NSString *appPubkey = bodyDict[@"appPubkey"]; if (!appPubkey) { + self.contentHandler(nil); return; } - NSString *encryptedContent = request.content.userInfo[@"body"][@"content"]; + NSString *encryptedContent = bodyDict[@"content"]; if (!encryptedContent) { + self.contentHandler(nil); return; } NSUserDefaults *sharedDefaults = [[NSUserDefaults alloc] initWithSuiteName:@"group.com.getalby.mobile.nse"]; NSDictionary *walletsDict = [sharedDefaults objectForKey:@"wallets"]; + if (!walletsDict) { + self.contentHandler(nil); + return; + } NSDictionary *walletInfo = walletsDict[appPubkey]; if (!walletInfo) { + self.contentHandler(nil); return; } NSString *sharedSecretString = walletInfo[@"sharedSecret"]; NSString *walletName = walletInfo[@"name"]; + if (!walletName) { + walletName = @"Alby Go"; + } + if (!sharedSecretString) { + self.contentHandler(nil); return; } NSData *sharedSecretData = dataFromHexString(sharedSecretString); if (!sharedSecretData || sharedSecretData.length != kCCKeySizeAES256) { + self.contentHandler(nil); return; } NSArray *parts = [encryptedContent componentsSeparatedByString:@"?iv="]; if (parts.count < 2) { + self.contentHandler(nil); return; } @@ -76,47 +99,52 @@ - (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withConte NSData *ivData = [[NSData alloc] initWithBase64EncodedString:ivBase64 options:0]; if (!ciphertextData || !ivData || ivData.length != kCCBlockSizeAES128) { + self.contentHandler(nil); return; } - - // Prepare for decryption + size_t decryptedDataLength = ciphertextData.length + kCCBlockSizeAES128; NSMutableData *plaintextData = [NSMutableData dataWithLength:decryptedDataLength]; size_t numBytesDecrypted = 0; CCCryptorStatus cryptStatus = CCCrypt(kCCDecrypt, kCCAlgorithmAES128, kCCOptionPKCS7Padding, sharedSecretData.bytes, sharedSecretData.length, ivData.bytes, ciphertextData.bytes, ciphertextData.length, plaintextData.mutableBytes, decryptedDataLength, &numBytesDecrypted); - if (cryptStatus == kCCSuccess) { - plaintextData.length = numBytesDecrypted; + if (cryptStatus != kCCSuccess) { + self.contentHandler(nil); + return; + } - NSError *jsonError = nil; - NSDictionary *parsedContent = [NSJSONSerialization JSONObjectWithData:plaintextData options:0 error:&jsonError]; + plaintextData.length = numBytesDecrypted; - if (!parsedContent || jsonError) { - return; - } + NSError *jsonError = nil; + NSDictionary *parsedContent = [NSJSONSerialization JSONObjectWithData:plaintextData options:0 error:&jsonError]; - NSString *notificationType = parsedContent[@"notification_type"]; - if (![notificationType isEqualToString:@"payment_received"]) { - return; - } + if (!parsedContent || jsonError) { + self.contentHandler(nil); + return; + } - NSDictionary *notificationDict = parsedContent[@"notification"]; - NSNumber *amountNumber = notificationDict[@"amount"]; - if (!amountNumber) { - return; - } + NSString *notificationType = parsedContent[@"notification_type"]; + if (![notificationType isEqualToString:@"payment_received"]) { + self.contentHandler(nil); + return; + } - double amountInSats = [amountNumber doubleValue] / 1000.0; - self.bestAttemptContent.title = walletName; - self.bestAttemptContent.body = [NSString stringWithFormat:@"You just received %.0f sats โšก๏ธ", amountInSats]; + NSDictionary *notificationDict = parsedContent[@"notification"]; + NSNumber *amountNumber = notificationDict[@"amount"]; + if (!amountNumber) { + self.contentHandler(nil); + return; } + double amountInSats = [amountNumber doubleValue] / 1000.0; + self.bestAttemptContent.title = walletName; + self.bestAttemptContent.body = [NSString stringWithFormat:@"You just received %.0f sats โšก๏ธ", amountInSats]; self.contentHandler(self.bestAttemptContent); } - (void)serviceExtensionTimeWillExpire { - self.bestAttemptContent.body = @"expired noitification"; + self.bestAttemptContent.body = @"expired notification"; self.contentHandler(self.bestAttemptContent); } diff --git a/lib/notifications.ts b/lib/notifications.ts new file mode 100644 index 0000000..6a21be3 --- /dev/null +++ b/lib/notifications.ts @@ -0,0 +1,90 @@ +import { nwc } from "@getalby/sdk"; +import { Platform } from "react-native"; +import { NOSTR_API_URL } from "~/lib/constants"; +import { errorToast } from "~/lib/errorToast"; +import { computeSharedSecret } from "~/lib/sharedSecret"; +import { useAppStore } from "~/lib/state/appStore"; +import { storeWalletInfo } from "~/lib/storeWalletInfo"; + +export async function registerWalletNotifications( + nwcUrl: string, + walletId: number, + walletName?: string, +) { + if (!nwcUrl) { + return; + } + + const nwcClient = new nwc.NWCClient({ + nostrWalletConnectUrl: nwcUrl, + }); + + const pushToken = useAppStore.getState().expoPushToken; + if (!pushToken) { + errorToast(new Error("Push token is not set")); + return; + } + + const body = { + pushToken, + relayUrl: nwcClient.relayUrl, + connectionPubkey: nwcClient.publicKey, + walletPubkey: nwcClient.walletPubkey, + isIOS: Platform.OS === "ios", + }; + + try { + const response = await fetch(`${NOSTR_API_URL}/nip47/notifications/push`, { + method: "POST", + headers: { + Accept: "application/json", + "Accept-encoding": "gzip, deflate", + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (response.ok) { + const responseData = await response.json(); + useAppStore.getState().updateWallet(walletId, { + pushId: responseData.subscriptionId, + }); + } else { + new Error(`Error: ${response.status} ${response.statusText}`); + } + + const walletData = { + name: walletName ?? "", + sharedSecret: computeSharedSecret( + nwcClient.walletPubkey, + nwcClient.secret ?? "", + ), + }; + + try { + await storeWalletInfo(nwcClient.publicKey, walletData); + } catch (storageError) { + errorToast(new Error("Failed to save wallet data")); + } + } catch (error) { + errorToast(error); + } +} + +export async function deregisterWalletNotifications(pushId?: string) { + if (!pushId) { + return; + } + try { + const response = await fetch(`${NOSTR_API_URL}/subscriptions/${pushId}`, { + method: "DELETE", + }); + if (!response.ok) { + errorToast( + new Error("Failed to deregister push notification subscription"), + ); + } + } catch (error) { + errorToast(error); + } +} diff --git a/lib/state/appStore.ts b/lib/state/appStore.ts index 5d14bf8..27629a3 100644 --- a/lib/state/appStore.ts +++ b/lib/state/appStore.ts @@ -14,11 +14,13 @@ interface AppState { readonly isNotificationsEnabled: boolean; readonly isOnboarded: boolean; readonly theme: Theme; + readonly expoPushToken: string; setUnlocked: (unlocked: boolean) => void; setTheme: (theme: Theme) => void; setOnboarded: (isOnboarded: boolean) => void; + setExpoPushToken: (expoPushToken: string) => void; setNWCClient: (nwcClient: NWCClient | undefined) => void; - updateWallet(wallet: Partial, nostrWalletConnectUrl?: string): void; + updateWallet(walletId: number, walletUpdate: Partial): void; updateCurrentWallet(wallet: Partial): void; removeCurrentWallet(): void; setFiatCurrency(fiatCurrency: string): void; @@ -37,6 +39,7 @@ const addressBookEntryKeyPrefix = "addressBookEntry"; const selectedWalletIdKey = "selectedWalletId"; const fiatCurrencyKey = "fiatCurrency"; const hasOnboardedKey = "hasOnboarded"; +const expoPushTokenKey = "expoPushToken"; const lastAlbyPaymentKey = "lastAlbyPayment"; const themeKey = "theme"; const isSecurityEnabledKey = "isSecurityEnabled"; @@ -93,17 +96,11 @@ function loadAddressBookEntries(): AddressBookEntry[] { } export const useAppStore = create()((set, get) => { - const updateWallet = ( - walletUpdate: Partial, - nostrWalletConnectUrl: string, - ) => { - const wallets = [...get().wallets]; - const walletId = wallets.findIndex( - (wallet) => wallet.nostrWalletConnectUrl === nostrWalletConnectUrl, - ); + const updateWallet = (walletId: number, walletUpdate: Partial) => { if (walletId < 0) { return; } + const wallets = [...get().wallets]; const wallet: Wallet = { ...(wallets[walletId] || {}), ...walletUpdate, @@ -133,7 +130,6 @@ export const useAppStore = create()((set, get) => { }); }; - // TODO: de-register push notification subscripiton using pushId const removeCurrentWallet = () => { const wallets = [...get().wallets]; if (wallets.length <= 1) { @@ -190,6 +186,7 @@ export const useAppStore = create()((set, get) => { theme, isOnboarded: secureStorage.getItem(hasOnboardedKey) === "true", selectedWalletId: initialSelectedWalletId, + expoPushToken: "", updateWallet, updateCurrentWallet, removeCurrentWallet, @@ -208,6 +205,10 @@ export const useAppStore = create()((set, get) => { } set({ isOnboarded }); }, + setExpoPushToken: (expoPushToken) => { + secureStorage.setItem(expoPushTokenKey, expoPushToken); + set({ expoPushToken }); + }, setNWCClient: (nwcClient) => set({ nwcClient }), setSecurityEnabled: (isEnabled) => { secureStorage.setItem(isSecurityEnabledKey, isEnabled.toString()); @@ -263,7 +264,6 @@ export const useAppStore = create()((set, get) => { updateLastAlbyPayment: () => { secureStorage.setItem(lastAlbyPaymentKey, new Date().toString()); }, - // TODO: de-register push notification subscripitons using pushId reset() { // clear wallets for (let i = 0; i < get().wallets.length; i++) { @@ -292,6 +292,12 @@ export const useAppStore = create()((set, get) => { // set to initial wallet status secureStorage.setItem(selectedWalletIdKey, "0"); + // clear notifications enabled status + secureStorage.removeItem(isNotificationsEnabledKey); + + // clear expo push notifications token + secureStorage.removeItem(expoPushTokenKey); + set({ nwcClient: undefined, fiatCurrency: undefined, diff --git a/lib/storeWalletInfo.ts b/lib/storeWalletInfo.ts new file mode 100644 index 0000000..501969b --- /dev/null +++ b/lib/storeWalletInfo.ts @@ -0,0 +1,44 @@ +import { Platform } from "react-native"; +import { SUITE_NAME } from "~/lib/constants"; + +let UserDefaults: any; +let SharedPreferences: any; + +if (Platform.OS === "ios") { + UserDefaults = + require("@alevy97/react-native-userdefaults/src/ReactNativeUserDefaults.ios").default; +} else { + SharedPreferences = require("@getalby/expo-shared-preferences"); +} + +type WalletInfo = { + name: string; + sharedSecret: string; +}; + +export async function storeWalletInfo( + publicKey: string, + walletData: Partial, +) { + if (!publicKey) { + return; + } + if (Platform.OS === "ios") { + const groupDefaults = new UserDefaults(SUITE_NAME); + let wallets = (await groupDefaults.get("wallets")) || []; + wallets[publicKey] = { + ...(wallets[publicKey] || {}), + ...walletData, + }; + await groupDefaults.set("wallets", wallets); + } else { + let wallets = []; + const walletsString = await SharedPreferences.getItemAsync("wallets"); + wallets = walletsString ? JSON.parse(walletsString) : []; + wallets[publicKey] = { + ...(wallets[publicKey] || {}), + ...walletData, + }; + await SharedPreferences.setItemAsync("wallets", JSON.stringify(wallets)); + } +} diff --git a/pages/settings/Notifications.tsx b/pages/settings/Notifications.tsx index fa1a591..703fddf 100644 --- a/pages/settings/Notifications.tsx +++ b/pages/settings/Notifications.tsx @@ -3,11 +3,12 @@ import { Text, View } from "react-native"; import Screen from "~/components/Screen"; import { Label } from "~/components/ui/label"; import { Switch } from "~/components/ui/switch"; +import { deregisterWalletNotifications } from "~/lib/notifications"; import { useAppStore } from "~/lib/state/appStore"; import { registerForPushNotificationsAsync } from "~/services/Notifications"; export function Notifications() { - // TODO: If this is enabled, register notifications on new wallets being added + const [isLoading, setLoading] = React.useState(false); const isEnabled = useAppStore((store) => store.isNotificationsEnabled); return ( @@ -19,14 +20,20 @@ export function Notifications() { Allow Go to send notifications { + setLoading(true); if (checked) { - await registerForPushNotificationsAsync(); + checked = await registerForPushNotificationsAsync(); } else { - // TODO: de-register all wallets on nostr api + const wallets = useAppStore.getState().wallets; + for (const wallet of wallets) { + await deregisterWalletNotifications(wallet.pushId); + } } useAppStore.getState().setNotificationsEnabled(checked); + setLoading(false); }} nativeID="security" /> diff --git a/pages/settings/Settings.tsx b/pages/settings/Settings.tsx index 3e01eee..a62b51d 100644 --- a/pages/settings/Settings.tsx +++ b/pages/settings/Settings.tsx @@ -19,10 +19,12 @@ import Screen from "~/components/Screen"; import { Text } from "~/components/ui/text"; import { useSession } from "~/hooks/useSession"; import { DEFAULT_CURRENCY, DEFAULT_WALLET_NAME } from "~/lib/constants"; +import { deregisterWalletNotifications } from "~/lib/notifications"; import { useAppStore } from "~/lib/state/appStore"; import { useColorScheme } from "~/lib/useColorScheme"; export function Settings() { + const wallets = useAppStore((store) => store.wallets); const wallet = useAppStore((store) => store.wallets[store.selectedWalletId]); const [developerCounter, setDeveloperCounter] = React.useState(0); const [developerMode, setDeveloperMode] = React.useState(__DEV__); @@ -129,7 +131,10 @@ export function Settings() { }, { text: "Confirm", - onPress: () => { + onPress: async () => { + for (const wallet of wallets) { + await deregisterWalletNotifications(wallet.pushId); + } router.dismissAll(); useAppStore.getState().reset(); }, diff --git a/pages/settings/wallets/EditWallet.tsx b/pages/settings/wallets/EditWallet.tsx index 26628e9..274eafc 100644 --- a/pages/settings/wallets/EditWallet.tsx +++ b/pages/settings/wallets/EditWallet.tsx @@ -14,6 +14,7 @@ import { } from "~/components/ui/card"; import { Text } from "~/components/ui/text"; import { DEFAULT_WALLET_NAME } from "~/lib/constants"; +import { deregisterWalletNotifications } from "~/lib/notifications"; import { useAppStore } from "~/lib/state/appStore"; export function EditWallet() { @@ -117,7 +118,10 @@ export function EditWallet() { }, { text: "Confirm", - onPress: () => { + onPress: async () => { + await deregisterWalletNotifications( + wallets[selectedWalletId].pushId, + ); useAppStore.getState().removeCurrentWallet(); if (wallets.length !== 1) { router.back(); diff --git a/pages/settings/wallets/RenameWallet.tsx b/pages/settings/wallets/RenameWallet.tsx index 10abd27..07a751e 100644 --- a/pages/settings/wallets/RenameWallet.tsx +++ b/pages/settings/wallets/RenameWallet.tsx @@ -9,10 +9,12 @@ import { Input } from "~/components/ui/input"; import { Text } from "~/components/ui/text"; import { DEFAULT_WALLET_NAME } from "~/lib/constants"; import { useAppStore } from "~/lib/state/appStore"; +import { storeWalletInfo } from "~/lib/storeWalletInfo"; export function RenameWallet() { const selectedWalletId = useAppStore((store) => store.selectedWalletId); const wallets = useAppStore((store) => store.wallets); + const nwcClient = useAppStore((store) => store.nwcClient); const [walletName, setWalletName] = React.useState( wallets[selectedWalletId].name || "", ); @@ -34,10 +36,13 @@ export function RenameWallet() { From 71b29404dbac5bda66277cd48bc474402af11605 Mon Sep 17 00:00:00 2001 From: im-adithya Date: Fri, 24 Jan 2025 13:49:32 +0530 Subject: [PATCH 40/40] chore: use persisted expo token --- lib/state/appStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/state/appStore.ts b/lib/state/appStore.ts index b3fbe0c..8cea932 100644 --- a/lib/state/appStore.ts +++ b/lib/state/appStore.ts @@ -203,7 +203,7 @@ export const useAppStore = create()((set, get) => { balanceDisplayMode, isOnboarded: secureStorage.getItem(hasOnboardedKey) === "true", selectedWalletId: initialSelectedWalletId, - expoPushToken: "", + expoPushToken: secureStorage.getItem(expoPushTokenKey) || "", updateWallet, removeWallet, removeAddressBookEntry,