Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

feat: notification improvements #188

Open
wants to merge 14 commits into
base: feat/push-notifications
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,6 @@ yarn-error.*

# typescript
*.tsbuildinfo

ios
android
10 changes: 10 additions & 0 deletions app.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,13 @@
},
"assetBundlePatterns": ["**/*"],
"plugins": [
[
"expo-notification-service-extension-plugin",
{
"mode": "production",
"iosNSEFilePath": "./assets/NotificationService.m"
}
],
[
"expo-local-authentication",
{
Expand Down Expand Up @@ -46,6 +53,9 @@
"bundleIdentifier": "com.getalby.mobile",
"config": {
"usesNonExemptEncryption": false
},
"infoPlist": {
"UIBackgroundModes": ["remote-notification", "processing"]
}
},
"android": {
Expand Down
5 changes: 0 additions & 5 deletions app/(app)/notifications.js

This file was deleted.

5 changes: 5 additions & 0 deletions app/(app)/settings/notifications.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Notifications } from "../../../pages/settings/Notifications";

export default function Page() {
return <Notifications />;
}
62 changes: 43 additions & 19 deletions app/_layout.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,23 @@
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";
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";
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";
Expand All @@ -33,6 +36,25 @@ export {
ErrorBoundary,
} from "expo-router";

// FIXME: only use this in android (?)
TaskManager.defineTask(
BACKGROUND_NOTIFICATION_TASK,
({ data }: { data: Record<string, any> }) => {
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();

Expand Down Expand Up @@ -89,24 +111,26 @@ export default function RootLayout() {

return (
<SWRConfig value={swrConfiguration}>
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
<StatusBar style={isDarkColorScheme ? "light" : "dark"} />
<PolyfillCrypto />
<SafeAreaView className="w-full h-full bg-background">
<UserInactivityProvider>
<SessionProvider>
<Slot />
</SessionProvider>
</UserInactivityProvider>
<Toast
config={toastConfig}
position="bottom"
bottomOffset={140}
topOffset={140}
/>
<PortalHost />
</SafeAreaView>
</ThemeProvider>
<NotificationProvider>
<ThemeProvider value={isDarkColorScheme ? DARK_THEME : LIGHT_THEME}>
<StatusBar style={isDarkColorScheme ? "light" : "dark"} />
<PolyfillCrypto />
<SafeAreaView className="w-full h-full bg-background">
<UserInactivityProvider>
<SessionProvider>
<Slot />
</SessionProvider>
</UserInactivityProvider>
<Toast
config={toastConfig}
position="bottom"
bottomOffset={140}
topOffset={140}
/>
<PortalHost />
</SafeAreaView>
</ThemeProvider>
</NotificationProvider>
</SWRConfig>
);
}
Expand Down
117 changes: 117 additions & 0 deletions assets/NotificationService.m
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
#import "NotificationService.h"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this only for iOS? should it be in a dedicated directory?

#import <CommonCrypto/CommonCryptor.h>

@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"];
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could the name be more clear, maybe nip04SharedSecret? or dmSharedSecret? and does this change much if we plan to very soon move to NIP-44?

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
55 changes: 55 additions & 0 deletions context/Notification.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useEffect, useRef } from "react";

import * as ExpoNotifications from "expo-notifications";
import { useAppStore } from "~/lib/state/appStore";

ExpoNotifications.setNotificationHandler({
handleNotification: async () => {
return {
shouldShowAlert: true,
shouldPlaySound: true,
shouldSetBadge: false,
};
},
});

export const NotificationProvider = ({ children }: any) => {
const notificationListener = useRef<ExpoNotifications.Subscription>();
const responseListener = useRef<ExpoNotifications.Subscription>();
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;
};
5 changes: 5 additions & 0 deletions lib/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ 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;

export const CURSOR_COLOR = "hsl(47 100% 72%)";
Expand All @@ -29,6 +33,7 @@ export const DEFAULT_CURRENCY = "USD";
export const DEFAULT_WALLET_NAME = "Default Wallet";
export const ALBY_LIGHTNING_ADDRESS = "[email protected]";
export const ALBY_URL = "https://getalby.com";
export const NOSTR_API_URL = "https://api.getalby.com/nwc";

export const REQUIRED_CAPABILITIES: Nip47Capability[] = [
"get_balance",
Expand Down
8 changes: 8 additions & 0 deletions lib/sharedSecret.ts
Original file line number Diff line number Diff line change
@@ -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");
}
Loading