diff --git a/.gitignore b/.gitignore
index 0f3765b..7197148 100644
--- a/.gitignore
+++ b/.gitignore
@@ -34,5 +34,7 @@ yarn-error.*
# typescript
*.tsbuildinfo
-android
-ios
+/ios
+/android
+
+google-services.json
diff --git a/README.md b/README.md
index 18a92ad..55ba762 100644
--- a/README.md
+++ b/README.md
@@ -9,3 +9,17 @@ A simple lightning mobile wallet interface that works great with [Alby Hub](http
`yarn install`
`yarn start`
+
+### Notifications
+
+Push notifications are only available when running the app on a **physical device** using the following commands:
+
+For iOS:
+
+`yarn ios:device`
+
+For Android:
+
+`yarn android:device`
+
+**Note:** Notifications do not work in the Expo Go app. You must run the app on a standalone build or a device using the above commands.
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..743de76
--- /dev/null
+++ b/app.config.js
@@ -0,0 +1,108 @@
+import withMessagingServicePlugin from "./plugins/android/withMessageServicePlugin";
+import withOpenSSLPlugin from "./plugins/ios/withOpenSSLPlugin";
+
+export default ({ config }) => {
+ return {
+ ...config,
+ name: "Alby Go",
+ slug: "alby-mobile",
+ version: "1.8.1",
+ scheme: ["lightning", "bitcoin", "alby", "nostr+walletconnect"],
+ orientation: "portrait",
+ icon: "./assets/icon.png",
+ userInterfaceStyle: "automatic",
+ newArchEnabled: true,
+ assetBundlePatterns: ["**/*"],
+ plugins: [
+ [
+ withMessagingServicePlugin,
+ {
+ androidFMSFilePath: "./assets/android/MessagingService.kt",
+ },
+ ],
+ [withOpenSSLPlugin],
+ [
+ "expo-notification-service-extension-plugin",
+ {
+ mode: "production",
+ iosNSEFilePath: "./assets/ios/NotificationService.m",
+ },
+ ],
+ [
+ "expo-splash-screen",
+ {
+ backgroundColor: "#0B0930",
+ image: "./assets/icon.png",
+ imageWidth: "150",
+ },
+ ],
+ [
+ "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-notifications",
+ {
+ icon: "./assets/notification.png",
+ },
+ ],
+ "expo-router",
+ "expo-secure-store",
+ ],
+ ios: {
+ supportsTablet: true,
+ bundleIdentifier: "com.getalby.mobile",
+ config: {
+ usesNonExemptEncryption: false,
+ },
+ infoPlist: {
+ LSMinimumSystemVersion: "12.0",
+ UIBackgroundModes: ["remote-notification"],
+ },
+ userInterfaceStyle: "automatic",
+ },
+ android: {
+ package: "com.getalby.mobile",
+ icon: "./assets/icon.png",
+ adaptiveIcon: {
+ foregroundImage: "./assets/adaptive-icon.png",
+ backgroundImage: "./assets/adaptive-icon-bg.png",
+ monochromeImage: "./assets/monochromatic.png",
+ },
+ permissions: [
+ "android.permission.CAMERA",
+ "android.permission.USE_BIOMETRIC",
+ "android.permission.USE_FINGERPRINT",
+ ],
+ userInterfaceStyle: "automatic",
+ googleServicesFile: process.env.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 7b73ebd..0000000
--- a/app.json
+++ /dev/null
@@ -1,81 +0,0 @@
-{
- "expo": {
- "name": "Alby Go",
- "slug": "alby-mobile",
- "version": "1.8.1",
- "scheme": ["lightning", "bitcoin", "alby", "nostr+walletconnect"],
- "orientation": "portrait",
- "icon": "./assets/icon.png",
- "userInterfaceStyle": "automatic",
- "newArchEnabled": true,
- "assetBundlePatterns": ["**/*"],
- "plugins": [
- [
- "expo-splash-screen",
- {
- "backgroundColor": "#0B0930",
- "image": "./assets/icon.png",
- "imageWidth": "150"
- }
- ],
- [
- "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": {
- "LSMinimumSystemVersion": "12.0"
- },
- "userInterfaceStyle": "automatic"
- },
- "android": {
- "package": "com.getalby.mobile",
- "icon": "./assets/icon.png",
- "adaptiveIcon": {
- "foregroundImage": "./assets/adaptive-icon.png",
- "backgroundImage": "./assets/adaptive-icon-bg.png",
- "monochromeImage": "./assets/monochromatic.png"
- },
- "permissions": [
- "android.permission.CAMERA",
- "android.permission.USE_BIOMETRIC",
- "android.permission.USE_FINGERPRINT"
- ],
- "userInterfaceStyle": "automatic"
- },
- "extra": {
- "eas": {
- "projectId": "294965ec-3a67-4994-8794-5cc1117ef155"
- }
- },
- "owner": "roland_alby"
- }
-}
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 bb2108c..4da6a97 100644
--- a/app/_layout.tsx
+++ b/app/_layout.tsx
@@ -15,14 +15,16 @@ import { SafeAreaView } from "react-native-safe-area-context";
import Toast from "react-native-toast-message";
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 { IS_EXPO_GO, NAV_THEME } from "~/lib/constants";
import { isBiometricSupported } from "~/lib/isBiometricSupported";
import { useAppStore } from "~/lib/state/appStore";
import { useColorScheme } from "~/lib/useColorScheme";
+import { registerForPushNotificationsAsync } from "~/services/Notifications";
const LIGHT_THEME: Theme = {
...DefaultTheme,
@@ -67,6 +69,15 @@ export default function RootLayout() {
}
}
+ async function checkAndPromptForNotifications() {
+ const isEnabled = useAppStore.getState().isNotificationsEnabled;
+ // prompt the user to enable notifications on first open
+ if (isEnabled === null) {
+ const enabled = await registerForPushNotificationsAsync();
+ useAppStore.getState().setNotificationsEnabled(enabled);
+ }
+ }
+
const loadTheme = React.useCallback((): Promise => {
return new Promise((resolve) => {
const theme = useAppStore.getState().theme;
@@ -85,6 +96,9 @@ export default function RootLayout() {
await Promise.all([loadTheme(), loadFonts(), checkBiometricStatus()]);
} finally {
setResourcesLoaded(true);
+ if (!IS_EXPO_GO) {
+ await checkAndPromptForNotifications();
+ }
SplashScreen.hide();
}
};
@@ -98,26 +112,28 @@ export default function RootLayout() {
return (
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
);
}
diff --git a/assets/android/MessagingService.kt b/assets/android/MessagingService.kt
new file mode 100644
index 0000000..28be6c2
--- /dev/null
+++ b/assets/android/MessagingService.kt
@@ -0,0 +1,313 @@
+package com.getalby.mobile
+
+import android.app.NotificationChannel
+import android.app.NotificationManager
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.graphics.Color
+import android.net.Uri
+import android.os.Build
+// import android.os.PowerManager
+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
+import java.io.ByteArrayOutputStream
+import org.bouncycastle.crypto.engines.ChaCha7539Engine
+import org.bouncycastle.crypto.params.KeyParameter
+import org.bouncycastle.crypto.params.ParametersWithIV
+
+class MessagingService : FirebaseMessagingService() {
+
+ data class WalletInfo(
+ val name: String,
+ val sharedSecret: String,
+ val id: Int,
+ val version: String = "0.0"
+ )
+
+ 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", ""),
+ id = walletJson.optInt("id", -1),
+ version = walletJson.optString("version", "0.0")
+ )
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+ }
+
+ override fun onMessageReceived(remoteMessage: RemoteMessage) {
+ if (remoteMessage.data.isEmpty()) {
+ return
+ }
+
+ val messageData = remoteMessage.data
+ val body = messageData["body"] ?: return
+
+ val jsonBody = try {
+ JSONObject(body)
+ } catch (e: Exception) {
+ return
+ }
+
+ val encryptedContent = jsonBody.optString("content", "")
+ val appPubkey = jsonBody.optString("appPubkey", "")
+
+ if (encryptedContent.isEmpty() || appPubkey.isEmpty()) {
+ return
+ }
+
+ val walletInfo = getWalletInfo(this, appPubkey) ?: return
+ if (walletInfo.sharedSecret.isEmpty() || walletInfo.id == -1) {
+ return
+ }
+ val sharedSecretBytes = hexStringToByteArray(walletInfo.sharedSecret)
+ val walletName = walletInfo.name
+
+ val decryptedContent = decrypt(encryptedContent, sharedSecretBytes, walletInfo.version) ?: return
+
+ val json = try {
+ JSONObject(decryptedContent)
+ } catch (e: Exception) {
+ return
+ }
+
+ val notificationType = json.optString("notification_type", "")
+ if (notificationType != "payment_sent" && notificationType != "payment_received") {
+ return
+ }
+
+ val notification = json.optJSONObject("notification") ?: return
+ val amount = notification.optInt("amount", 0) / 1000
+ val transaction = notification.toString()
+
+ var notificationText = ""
+ if (notificationType == "payment_sent") {
+ notificationText = "You have sent $amount sats ⚡️"
+ } else {
+ notificationText = "You have received $amount sats ⚡️"
+ }
+
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ data = Uri.parse("alby://payment_notification?transaction=${Uri.encode(transaction)}&wallet_id=${walletInfo.id}")
+ flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ }
+
+ val pendingIntent = PendingIntent.getActivity(
+ this,
+ 0,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+
+ val notificationBuilder = NotificationCompat.Builder(this, "default")
+ .setSmallIcon(R.drawable.notification_icon)
+ .setContentTitle(walletName)
+ .setContentText(notificationText)
+ .setAutoCancel(true)
+ .setContentIntent(pendingIntent)
+
+ 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)
+ 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)
+ }
+
+ private fun decrypt(content: String, key: ByteArray, version: String): String? {
+ return if (version == "1.0") {
+ decryptNip44(content, key)
+ } else {
+ decryptNip04(content, key)
+ }
+ }
+
+ private fun decryptNip04(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) {
+ null
+ }
+ }
+
+ private fun decryptNip44(b64CiphertextWrapped: String, conversationKey: ByteArray): String? {
+ val decoded = try {
+ Base64.decode(b64CiphertextWrapped, Base64.DEFAULT)
+ } catch (e: Exception) {
+ return null
+ }
+
+ if (decoded.size < 99 || decoded.size > 65603) {
+ return null
+ }
+
+ if (decoded[0].toInt() != 2) {
+ return null
+ }
+
+ val nonce = decoded.copyOfRange(1, 33)
+ val ciphertext = decoded.copyOfRange(33, decoded.size - 32)
+ val givenMac = decoded.copyOfRange(decoded.size - 32, decoded.size)
+
+ val (cc20key, cc20nonce, hmacKey) = messageKeys(conversationKey, nonce)
+
+ val expectedMac = sha256Hmac(hmacKey, ciphertext, nonce)
+ if (expectedMac == null || !expectedMac.contentEquals(givenMac)) {
+ return null
+ }
+
+ val padded = chacha20(cc20key, cc20nonce, ciphertext) ?: return null
+ if (padded.size < 2) {
+ return null
+ }
+
+ val unpaddedLen = ((padded[0].toInt() and 0xFF) shl 8) or
+ (padded[1].toInt() and 0xFF)
+
+ if (unpaddedLen < 1 || unpaddedLen > 65535) {
+ return null
+ }
+
+ val requiredSize = 2 + calcPadding(unpaddedLen)
+ if (padded.size != requiredSize) {
+ return null
+ }
+
+ val messageBytes = padded.copyOfRange(2, 2 + unpaddedLen)
+ return String(messageBytes, Charsets.UTF_8)
+ }
+
+ private fun messageKeys(conversationKey: ByteArray, nonce: ByteArray): Triple {
+ val hkdfBytes = hkdfExpandSha256(conversationKey, nonce, 32 + 12 + 32)
+ val cc20key = hkdfBytes.copyOfRange(0, 32)
+ val cc20nonce = hkdfBytes.copyOfRange(32, 32 + 12)
+ val hmacKey = hkdfBytes.copyOfRange(44, 44 + 32)
+ return Triple(cc20key, cc20nonce, hmacKey)
+ }
+
+ private fun chacha20(key: ByteArray, nonce: ByteArray, message: ByteArray): ByteArray? {
+ return try {
+ val engine = ChaCha7539Engine()
+ engine.init(true, ParametersWithIV(KeyParameter(key), nonce))
+
+ val output = ByteArray(message.size)
+ engine.processBytes(message, 0, message.size, output, 0)
+ output
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+ }
+
+ private fun sha256Hmac(key: ByteArray, ciphertext: ByteArray, nonce: ByteArray): ByteArray? {
+ return try {
+ val mac = javax.crypto.Mac.getInstance("HmacSHA256")
+ val secretKey = SecretKeySpec(key, "HmacSHA256")
+ mac.init(secretKey)
+ mac.update(nonce)
+ mac.update(ciphertext)
+ mac.doFinal()
+ } catch (e: Exception) {
+ null
+ }
+ }
+
+ private fun calcPadding(msgSize: Int): Int {
+ if (msgSize <= 32) return 32
+
+ val s = msgSize - 1
+ val highestBit = 32 - Integer.numberOfLeadingZeros(s)
+ val nextPowTwo = 1 shl highestBit
+ val chunk = kotlin.math.max(32, nextPowTwo / 8)
+ val blocks = (s / chunk) + 1
+ return chunk * blocks
+ }
+
+ private fun hkdfExpandSha256(prk: ByteArray, info: ByteArray, outLen: Int): ByteArray {
+ val hashLen = 32
+ val n = (outLen + hashLen - 1) / hashLen
+ var t = ByteArray(0)
+ val okm = ByteArrayOutputStream()
+
+ for (i in 1..n) {
+ val mac = javax.crypto.Mac.getInstance("HmacSHA256")
+ val keySpec = SecretKeySpec(prk, "HmacSHA256")
+ mac.init(keySpec)
+
+ mac.update(t)
+ mac.update(info)
+ mac.update(i.toByte())
+
+ t = mac.doFinal()
+ okm.write(t)
+ }
+
+ val result = okm.toByteArray()
+ return if (result.size > outLen) result.copyOf(outLen) else result
+ }
+
+ 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
+ }
+
+ // 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/ios/NotificationService.m b/assets/ios/NotificationService.m
new file mode 100644
index 0000000..f7c88bb
--- /dev/null
+++ b/assets/ios/NotificationService.m
@@ -0,0 +1,338 @@
+#import "NotificationService.h"
+#import
+#import
+#import
+#import
+
+@interface NotificationService ()
+
+@property (nonatomic, strong) void (^contentHandler)(UNNotificationContent *contentToDeliver);
+@property (nonatomic, strong) UNNotificationRequest *receivedRequest;
+@property (nonatomic, strong) UNMutableNotificationContent *bestAttemptContent;
+
+@end
+
+@implementation NotificationService
+
+- (void)didReceiveNotificationRequest:(UNNotificationRequest *)request withContentHandler:(void (^)(UNNotificationContent * _Nonnull))contentHandler {
+ self.receivedRequest = request;
+ self.contentHandler = contentHandler;
+ self.bestAttemptContent = [request.content mutableCopy];
+
+ NSDictionary *userInfo = request.content.userInfo;
+ if (!userInfo) {
+ self.contentHandler(nil);
+ return;
+ }
+
+ NSDictionary *bodyDict = userInfo[@"body"];
+ if (!bodyDict) {
+ self.contentHandler(nil);
+ return;
+ }
+
+ // Fetch wallet pubkey and content from notification
+ NSString *appPubkey = bodyDict[@"appPubkey"];
+ NSString *encryptedContent = bodyDict[@"content"];
+ if (!appPubkey || !encryptedContent) {
+ self.contentHandler(nil);
+ return;
+ }
+
+ // Fetch stored wallet info using the pubkey
+ 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"] ?: @"Alby Go";
+ NSNumber *walletId = walletInfo[@"id"];
+ NSString *walletVersion = walletInfo[@"version"] ?: @"0.0";
+ if (!sharedSecretString || !walletId) {
+ self.contentHandler(nil);
+ return;
+ }
+
+ NSData *decryptedData = nil;
+ if ([walletVersion isEqualToString:@"1.0"]) {
+ // Decrypt using NIP-44
+ decryptedData = Nip44Decrypt(sharedSecretString, encryptedContent);
+ } else {
+ // Decrypt using NIP-04
+ decryptedData = Nip04Decrypt(sharedSecretString, encryptedContent);
+ }
+
+ if (!decryptedData) {
+ self.contentHandler(nil);
+ return;
+ }
+
+ // Parse JSON from decrypted data
+ NSError *jsonError = nil;
+ NSDictionary *parsedContent = [NSJSONSerialization JSONObjectWithData:decryptedData
+ options:0
+ error:&jsonError];
+ if (!parsedContent || jsonError) {
+ self.contentHandler(nil);
+ return;
+ }
+
+ // Check the notification type
+ NSString *notificationType = parsedContent[@"notification_type"];
+ if (![notificationType isEqualToString:@"payment_sent"] &&
+ ![notificationType isEqualToString:@"payment_received"]) {
+ self.contentHandler(nil);
+ return;
+ }
+
+ NSDictionary *notificationDict = parsedContent[@"notification"];
+ NSNumber *amountNumber = notificationDict[@"amount"];
+ if (!amountNumber) {
+ self.contentHandler(nil);
+ return;
+ }
+
+ NSError *transactionError = nil;
+ NSData *transactionData = [NSJSONSerialization dataWithJSONObject:notificationDict
+ options:0
+ error:&transactionError];
+ if (transactionError || !transactionData) {
+ self.contentHandler(nil);
+ return;
+ }
+
+ NSString *transactionJSON = [[NSString alloc] initWithData:transactionData encoding:NSUTF8StringEncoding];
+ NSString *encodedTransaction = [transactionJSON stringByAddingPercentEncodingWithAllowedCharacters:[NSCharacterSet URLQueryAllowedCharacterSet]];
+ if (!encodedTransaction) {
+ self.contentHandler(nil);
+ return;
+ }
+
+ double amountInSats = [amountNumber doubleValue] / 1000.0;
+ NSString *deepLink = [NSString stringWithFormat:@"alby://payment_notification?transaction=%@&wallet_id=%@", encodedTransaction,
+ walletId.stringValue];
+
+ NSMutableDictionary *newUserInfo = [self.bestAttemptContent.userInfo mutableCopy] ?: [NSMutableDictionary dictionary];
+ NSMutableDictionary *newBodyDict = [newUserInfo[@"body"] mutableCopy] ?: [NSMutableDictionary dictionary];
+
+ newBodyDict[@"deepLink"] = deepLink;
+ newUserInfo[@"body"] = newBodyDict;
+ self.bestAttemptContent.userInfo = newUserInfo;
+ self.bestAttemptContent.title = walletName;
+
+ if ([notificationType isEqualToString:@"payment_sent"]) {
+ self.bestAttemptContent.body = [NSString stringWithFormat:@"You just sent %.0f sats ⚡️", amountInSats];
+ } else {
+ self.bestAttemptContent.body = [NSString stringWithFormat:@"You just received %.0f sats ⚡️", amountInSats];
+ }
+
+ self.contentHandler(self.bestAttemptContent);
+}
+
+- (void)serviceExtensionTimeWillExpire {
+ self.bestAttemptContent.body = @"expired notification";
+ self.contentHandler(self.bestAttemptContent);
+}
+
+/// Decrypts a NIP-44–style message using the given conversation key (in hex format).
+/// - Parameters:
+/// - conversationKeyHex: The conversation key in hex (32 bytes => 64 hex chars).
+/// - encryptedMessage: The base64-encoded ciphertext, which includes:
+/// 1 byte version, 32-byte nonce, variable-length ciphertext, 32-byte HMAC.
+/// - Returns: Decrypted data on success, or `nil` on failure.
+static NSData *Nip44Decrypt(NSString *conversationKeyHex, NSString *encryptedMessage) {
+ NSData *conversationKeyData = DataFromHexString(conversationKeyHex);
+ if (!conversationKeyData || conversationKeyData.length != 32) {
+ return nil;
+ }
+
+ NSData *decoded = [[NSData alloc] initWithBase64EncodedString:encryptedMessage options:0];
+ if (!decoded || decoded.length < 99 || decoded.length > 65603) {
+ return nil;
+ }
+
+ const unsigned char *bytes = (const unsigned char *)decoded.bytes;
+
+ if (bytes[0] != 2) {
+ return nil;
+ }
+
+ size_t totalLen = decoded.length;
+ if (totalLen < (1 + 32 + 32)) {
+ return nil;
+ }
+
+ NSData *nonce = [decoded subdataWithRange:NSMakeRange(1, 32)];
+ size_t hmacOffset = decoded.length - 32;
+ NSData *ciphertext = [decoded subdataWithRange:NSMakeRange(1 + 32, hmacOffset - (1 + 32))];
+ NSData *givenMac = [decoded subdataWithRange:NSMakeRange(hmacOffset, 32)];
+
+ unsigned char hkdfOutput[76];
+ size_t hkdfLength = sizeof(hkdfOutput);
+ EVP_PKEY_CTX *pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_HKDF, NULL);
+
+ if (!pctx ||
+ EVP_PKEY_derive_init(pctx) <= 0 ||
+ EVP_PKEY_CTX_set_hkdf_mode(pctx, EVP_PKEY_HKDEF_MODE_EXPAND_ONLY) <= 0 ||
+ EVP_PKEY_CTX_set_hkdf_md(pctx, EVP_sha256()) <= 0 ||
+ EVP_PKEY_CTX_set1_hkdf_key(pctx, conversationKeyData.bytes, (int)conversationKeyData.length) <= 0 ||
+ EVP_PKEY_CTX_add1_hkdf_info(pctx, nonce.bytes, (int)nonce.length) <= 0 ||
+ EVP_PKEY_derive(pctx, hkdfOutput, &hkdfLength) <= 0 ||
+ hkdfLength != 76) {
+
+ EVP_PKEY_CTX_free(pctx);
+ return nil;
+ }
+ EVP_PKEY_CTX_free(pctx);
+
+ NSData *cc20Key = [NSData dataWithBytes:hkdfOutput length:32];
+ NSData *cc20Nonce = [NSData dataWithBytes:hkdfOutput + 32 length:12];
+ NSData *hmacKey = [NSData dataWithBytes:hkdfOutput + 44 length:32];
+
+ unsigned char macBuf[EVP_MAX_MD_SIZE];
+ unsigned int macLen = 0;
+
+ HMAC_CTX *hmacCtx = HMAC_CTX_new();
+ if (!hmacCtx ||
+ HMAC_Init_ex(hmacCtx, hmacKey.bytes, (int)hmacKey.length, EVP_sha256(), NULL) != 1 ||
+ HMAC_Update(hmacCtx, nonce.bytes, (int)nonce.length) != 1 ||
+ HMAC_Update(hmacCtx, ciphertext.bytes, (int)ciphertext.length) != 1 ||
+ HMAC_Final(hmacCtx, macBuf, &macLen) != 1 || macLen != 32 ||
+ memcmp(macBuf, givenMac.bytes, 32) != 0) {
+
+ HMAC_CTX_free(hmacCtx);
+ return nil;
+ }
+ HMAC_CTX_free(hmacCtx);
+
+ uint8_t iv[16];
+ memset(iv, 0, 4);
+ memcpy(iv + 4, cc20Nonce.bytes, 12);
+
+ EVP_CIPHER_CTX *ctx = EVP_CIPHER_CTX_new();
+ if (!ctx) {
+ return nil;
+ }
+
+ if (EVP_DecryptInit_ex(ctx, EVP_chacha20(), NULL, cc20Key.bytes, iv) != 1) {
+ EVP_CIPHER_CTX_free(ctx);
+ return nil;
+ }
+
+ NSMutableData *plainMutable = [NSMutableData dataWithLength:ciphertext.length];
+ int outl = 0;
+ if (EVP_DecryptUpdate(ctx,
+ plainMutable.mutableBytes,
+ &outl,
+ ciphertext.bytes,
+ (int)ciphertext.length) != 1) {
+ EVP_CIPHER_CTX_free(ctx);
+ return nil;
+ }
+
+ int final_outl = 0;
+ if (EVP_DecryptFinal_ex(ctx,
+ (unsigned char *)plainMutable.mutableBytes + outl,
+ &final_outl) != 1) {
+ EVP_CIPHER_CTX_free(ctx);
+ return nil;
+ }
+ EVP_CIPHER_CTX_free(ctx);
+
+ [plainMutable setLength:(outl + final_outl)];
+
+ if (plainMutable.length < 2) {
+ return nil;
+ }
+
+ const unsigned char *pBytes = plainMutable.bytes;
+ uint16_t unpaddedLen = ((uint16_t)pBytes[0] << 8) | (uint16_t)pBytes[1];
+
+ if (unpaddedLen < 1 ||
+ unpaddedLen > 65535 ||
+ (2 + unpaddedLen) > plainMutable.length) {
+ return nil;
+ }
+
+ NSData *plaintext = [plainMutable subdataWithRange:NSMakeRange(2, unpaddedLen)];
+ return plaintext;
+}
+
+/// Decrypts a NIP-04–style message using the given shared secret (in hex format).
+/// - Parameters:
+/// - sharedSecretHex: The shared secret in hex format, must be AES-256 length (64 hex chars).
+/// - encryptedMessage: The encrypted message, typically in `ciphertextBase64?iv=ivBase64` format.
+/// - Returns: Decrypted data on success, or `nil` on failure.
+static NSData *Nip04Decrypt(NSString *sharedSecretHex, NSString *encryptedMessage) {
+ NSData *sharedSecretData = DataFromHexString(sharedSecretHex);
+ if (!sharedSecretData || sharedSecretData.length != kCCKeySizeAES256) {
+ return nil;
+ }
+
+ NSArray *parts = [encryptedMessage componentsSeparatedByString:@"?iv="];
+ if (parts.count < 2) {
+ return nil;
+ }
+
+ NSString *ciphertextBase64 = parts.firstObject;
+ NSString *ivBase64 = parts.lastObject;
+
+ NSData *ciphertextData = [[NSData alloc] initWithBase64EncodedString:ciphertextBase64 options:0];
+ NSData *ivData = [[NSData alloc] initWithBase64EncodedString:ivBase64 options:0];
+
+ if (!ciphertextData || !ivData || ivData.length != kCCBlockSizeAES128) {
+ return nil;
+ }
+
+ 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) {
+ return nil;
+ }
+
+ plaintextData.length = numBytesDecrypted;
+ return plaintextData;
+}
+
+static NSData *DataFromHexString(NSString *hexString) {
+ if (!hexString) {
+ return nil;
+ }
+ NSMutableData *data = [NSMutableData data];
+ for (NSInteger 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;
+ }
+ }
+ return data;
+}
+
+@end
diff --git a/assets/notification.png b/assets/notification.png
new file mode 100644
index 0000000..4e6d647
Binary files /dev/null and b/assets/notification.png differ
diff --git a/components/Icons.tsx b/components/Icons.tsx
index e26c711..c690739 100644
--- a/components/Icons.tsx
+++ b/components/Icons.tsx
@@ -12,6 +12,7 @@ import {
PopiconsCircleInfoLine as HelpCircleIcon,
PopiconsArrowDownLine as MoveDownIcon,
PopiconsArrowUpLine as MoveUpIcon,
+ PopiconsNotificationSquareSolid as NotificationIcon,
PopiconsLifebuoySolid as OnboardingIcon,
PopiconsClipboardTextSolid as PasteIcon,
PopiconsReloadLine as RefreshIcon,
@@ -57,6 +58,7 @@ interopIcon(FingerprintIcon);
interopIcon(HelpCircleIcon);
interopIcon(MoveDownIcon);
interopIcon(MoveUpIcon);
+interopIcon(NotificationIcon);
interopIcon(OnboardingIcon);
interopIcon(PasteIcon);
interopIcon(RefreshIcon);
@@ -88,6 +90,7 @@ export {
HelpCircleIcon,
MoveDownIcon,
MoveUpIcon,
+ NotificationIcon,
OnboardingIcon,
PasteIcon,
RefreshIcon,
diff --git a/context/Notification.tsx b/context/Notification.tsx
new file mode 100644
index 0000000..8566652
--- /dev/null
+++ b/context/Notification.tsx
@@ -0,0 +1,54 @@
+import { useEffect, useRef } from "react";
+import { IS_EXPO_GO } from "~/lib/constants";
+import { handleLink } from "~/lib/link";
+import { useAppStore } from "~/lib/state/appStore";
+
+let ExpoNotifications: any;
+
+if (!IS_EXPO_GO) {
+ ExpoNotifications = require("expo-notifications");
+
+ ExpoNotifications.setNotificationHandler({
+ handleNotification: async () => {
+ return {
+ shouldShowAlert: true,
+ shouldPlaySound: true,
+ shouldSetBadge: false,
+ };
+ },
+ });
+}
+
+export const NotificationProvider = ({ children }: any) => {
+ const responseListener = useRef();
+ const isNotificationsEnabled = useAppStore(
+ (store) => store.isNotificationsEnabled,
+ );
+
+ useEffect(() => {
+ if (IS_EXPO_GO || !isNotificationsEnabled) {
+ return;
+ }
+
+ // this is for iOS only as tapping the notifications
+ // directly open the deep link on android
+ responseListener.current =
+ ExpoNotifications.addNotificationResponseReceivedListener(
+ (response: any) => {
+ const deepLink = response.notification.request.content.data.deepLink;
+ if (deepLink) {
+ handleLink(deepLink);
+ }
+ },
+ );
+
+ return () => {
+ responseListener.current &&
+ ExpoNotifications.removeNotificationSubscription(
+ responseListener.current,
+ );
+ };
+ }, [isNotificationsEnabled]);
+
+ return children;
+};
diff --git a/lib/constants.ts b/lib/constants.ts
index 47d735a..bc322ef 100644
--- a/lib/constants.ts
+++ b/lib/constants.ts
@@ -1,4 +1,5 @@
import { Nip47Capability } from "@getalby/sdk/dist/NWCClient";
+import Constants, { ExecutionEnvironment } from "expo-constants";
export const NAV_THEME = {
light: {
@@ -19,6 +20,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%)";
@@ -29,6 +34,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",
@@ -42,3 +48,6 @@ export const SATS_REGEX = /^\d*$/;
export const FIAT_REGEX = /^\d*(\.\d{0,2})?$/;
export const BOLT11_REGEX = /.*?((lnbcrt|lntb|lnbc)([0-9]{1,}[a-z0-9]+){1})/;
+
+export const IS_EXPO_GO =
+ Constants.executionEnvironment === ExecutionEnvironment.StoreClient;
diff --git a/lib/link.ts b/lib/link.ts
index 5dfcb05..83c3181 100644
--- a/lib/link.ts
+++ b/lib/link.ts
@@ -63,6 +63,23 @@ export const handleLink = async (url: string) => {
console.info("Navigating to", fullUrl);
+ // Opening the notification executes the linking code
+ // We set the hostname on the notification deeplink so that it can be handled separately
+ if (hostname === "payment_notification") {
+ const urlParams = new URLSearchParams(search);
+ const walletId = urlParams.get("wallet_id");
+ const transaction = urlParams.get("transaction");
+ if (!transaction || !walletId) {
+ return;
+ }
+ const transactionJSON = decodeURIComponent(transaction);
+ router.push({
+ pathname: "/transaction",
+ params: { transactionJSON, walletId },
+ });
+ return;
+ }
+
const schemePattern = new RegExp(
`^(${SUPPORTED_SCHEMES.map((s) => s.replace(":", "")).join("|")}):`,
);
diff --git a/lib/notifications.ts b/lib/notifications.ts
new file mode 100644
index 0000000..b4500c8
--- /dev/null
+++ b/lib/notifications.ts
@@ -0,0 +1,126 @@
+import { Platform } from "react-native";
+import Toast from "react-native-toast-message";
+import { IS_EXPO_GO, NOSTR_API_URL } from "~/lib/constants";
+import { errorToast } from "~/lib/errorToast";
+import { useAppStore, Wallet } from "~/lib/state/appStore";
+import {
+ computeSharedSecret,
+ getConversationKey,
+ getPubkeyFromNWCUrl,
+} from "~/lib/utils";
+import { removeWalletInfo, storeWalletInfo } from "~/lib/walletInfo";
+
+export async function registerWalletNotifications(
+ wallet: Wallet,
+ walletId: number,
+) {
+ if (!(wallet.nwcCapabilities || []).includes("notifications")) {
+ Toast.show({
+ type: "info",
+ text1: `${wallet.name} does not have notifications capability`,
+ });
+ }
+
+ const nwcClient = useAppStore.getState().getNWCClient(walletId);
+
+ if (IS_EXPO_GO || !nwcClient) {
+ return;
+ }
+
+ const walletServiceInfo = await nwcClient.getWalletServiceInfo();
+ const isNip44 = walletServiceInfo.versions.includes("1.0");
+
+ 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",
+ ...(isNip44 ? { version: "1.0" } : {}),
+ };
+
+ 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(
+ {
+ pushId: responseData.subscriptionId,
+ },
+ walletId,
+ );
+ } else {
+ new Error(`Error: ${response.status} ${response.statusText}`);
+ }
+
+ const walletData = {
+ name: wallet.name ?? "",
+ sharedSecret: isNip44
+ ? getConversationKey(nwcClient.walletPubkey, nwcClient.secret ?? "")
+ : computeSharedSecret(nwcClient.walletPubkey, nwcClient.secret ?? ""),
+ id: walletId,
+ version: isNip44 ? "1.0" : "0.0",
+ };
+
+ try {
+ await storeWalletInfo(nwcClient.publicKey, walletData);
+ } catch (storageError) {
+ console.error(storageError);
+ errorToast(new Error("Failed to save wallet data"));
+ }
+ } catch (error) {
+ errorToast(error);
+ }
+}
+
+export async function deregisterWalletNotifications(
+ wallet: Wallet,
+ walletId: number,
+) {
+ if (IS_EXPO_GO || !wallet.pushId) {
+ return;
+ }
+ try {
+ // TODO: wallets with the same secret if added will have the same token,
+ // hence deregistering one might make others not work but will show
+ // as ON because their push ids are not removed from the wallet store
+ const response = await fetch(
+ `${NOSTR_API_URL}/subscriptions/${wallet.pushId}`,
+ {
+ method: "DELETE",
+ },
+ );
+ if (!response.ok) {
+ errorToast(
+ new Error("Failed to deregister push notification subscription"),
+ );
+ }
+ useAppStore.getState().updateWallet(
+ {
+ pushId: "",
+ },
+ walletId,
+ );
+ const pubkey = getPubkeyFromNWCUrl(wallet.nostrWalletConnectUrl ?? "");
+ if (pubkey) {
+ await removeWalletInfo(pubkey, walletId);
+ }
+ } catch (error) {
+ errorToast(error);
+ }
+}
diff --git a/lib/state/appStore.ts b/lib/state/appStore.ts
index 1fee427..8cea932 100644
--- a/lib/state/appStore.ts
+++ b/lib/state/appStore.ts
@@ -11,13 +11,16 @@ interface AppState {
readonly wallets: Wallet[];
readonly addressBookEntries: AddressBookEntry[];
readonly isSecurityEnabled: boolean;
+ readonly isNotificationsEnabled: boolean | null;
readonly isOnboarded: boolean;
+ readonly expoPushToken: string;
readonly theme?: Theme;
readonly balanceDisplayMode: BalanceDisplayMode;
setUnlocked: (unlocked: boolean) => void;
setTheme: (theme: Theme) => void;
setBalanceDisplayMode: (balanceDisplayMode: BalanceDisplayMode) => void;
setOnboarded: (isOnboarded: boolean) => void;
+ setExpoPushToken: (expoPushToken: string) => void;
getNWCClient: (walletId: number) => NWCClient | undefined;
setNWCClient: (nwcClient: NWCClient | undefined) => void;
updateWallet(wallet: Partial, walletId?: number): void;
@@ -25,6 +28,7 @@ interface AppState {
setFiatCurrency(fiatCurrency: string): void;
setSelectedWalletId(walletId: number): void;
setSecurityEnabled(securityEnabled: boolean): void;
+ setNotificationsEnabled(notificationsEnabled: boolean | null): void;
addWallet(wallet: Wallet): void;
addAddressBookEntry(entry: AddressBookEntry): void;
removeAddressBookEntry: (index: number) => void;
@@ -38,20 +42,23 @@ const addressBookEntryKeyPrefix = "addressBookEntry";
const selectedWalletIdKey = "selectedWalletId";
const fiatCurrencyKey = "fiatCurrency";
const hasOnboardedKey = "hasOnboarded";
+const expoPushTokenKey = "expoPushToken";
const lastAlbyPaymentKey = "lastAlbyPayment";
const themeKey = "theme";
const balanceDisplayModeKey = "balanceDisplayMode";
const isSecurityEnabledKey = "isSecurityEnabled";
+const isNotificationsEnabledKey = "isNotificationsEnabled";
export const lastActiveTimeKey = "lastActiveTime";
export type BalanceDisplayMode = "sats" | "fiat" | "hidden";
export type Theme = "light" | "dark";
-type Wallet = {
+export type Wallet = {
name?: string;
nostrWalletConnectUrl?: string;
lightningAddress?: string;
nwcCapabilities?: Nip47Capability[];
+ pushId?: string;
};
type AddressBookEntry = {
@@ -189,10 +196,14 @@ export const useAppStore = create()((set, get) => {
nwcClient: getNWCClient(initialSelectedWalletId),
fiatCurrency: secureStorage.getItem(fiatCurrencyKey) || "",
isSecurityEnabled,
+ isNotificationsEnabled: secureStorage.getItem(isNotificationsEnabledKey)
+ ? secureStorage.getItem(isNotificationsEnabledKey) === "true"
+ : null,
theme,
balanceDisplayMode,
isOnboarded: secureStorage.getItem(hasOnboardedKey) === "true",
selectedWalletId: initialSelectedWalletId,
+ expoPushToken: secureStorage.getItem(expoPushTokenKey) || "",
updateWallet,
removeWallet,
removeAddressBookEntry,
@@ -216,6 +227,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());
@@ -224,6 +239,16 @@ export const useAppStore = create()((set, get) => {
...(!isEnabled ? { unlocked: true } : {}),
});
},
+ setNotificationsEnabled: (isEnabled) => {
+ if (isEnabled === null) {
+ secureStorage.removeItem(isNotificationsEnabledKey);
+ } else {
+ secureStorage.setItem(isNotificationsEnabledKey, isEnabled.toString());
+ }
+ set({
+ isNotificationsEnabled: isEnabled,
+ });
+ },
setFiatCurrency: (fiatCurrency) => {
secureStorage.setItem(fiatCurrencyKey, fiatCurrency);
set({ fiatCurrency });
@@ -290,6 +315,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/utils.ts b/lib/utils.ts
index a5ef193..6705960 100644
--- a/lib/utils.ts
+++ b/lib/utils.ts
@@ -1,6 +1,31 @@
+import { nwc } from "@getalby/sdk";
+import { secp256k1 } from "@noble/curves/secp256k1";
+import { extract as hkdf_extract } from "@noble/hashes/hkdf";
+import { sha256 } from "@noble/hashes/sha256";
+import { bytesToHex, hexToBytes } from "@noble/hashes/utils";
+import { Buffer } from "buffer";
import { clsx, type ClassValue } from "clsx";
+import { getPublicKey } from "nostr-tools";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
+
+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");
+}
+
+export function getConversationKey(pub: string, sk: string): string {
+ const sharedX = secp256k1.getSharedSecret(sk, "02" + pub).subarray(1, 33);
+ return bytesToHex(hkdf_extract(sha256, sharedX, "nip44-v2"));
+}
+
+export function getPubkeyFromNWCUrl(nwcUrl: string): string | undefined {
+ const nwcOptions = nwc.NWCClient.parseWalletConnectUrl(nwcUrl);
+ if (nwcOptions.secret) {
+ return getPublicKey(hexToBytes(nwcOptions.secret));
+ }
+}
diff --git a/lib/walletInfo.ts b/lib/walletInfo.ts
new file mode 100644
index 0000000..bc77816
--- /dev/null
+++ b/lib/walletInfo.ts
@@ -0,0 +1,106 @@
+import { Platform } from "react-native";
+import { IS_EXPO_GO, SUITE_NAME } from "~/lib/constants";
+
+let UserDefaults: any;
+let SharedPreferences: any;
+
+// this is done because accessing values stored from expo-secure-store
+// is quite difficult and we do not wish to complicate the notification
+// service extension (ios) or messaging service (android)
+if (!IS_EXPO_GO) {
+ 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;
+ id: number;
+ version: string;
+};
+
+type Wallets = {
+ [publicKey: string]: Partial;
+};
+
+// TODO: In the future when we deprecate NIP-04 and stop
+// support for version 0.0 we would have display wallets
+// using 0.0 as deprecated and write a migration
+export async function storeWalletInfo(
+ publicKey: string,
+ walletData: Partial,
+) {
+ if (IS_EXPO_GO) {
+ return;
+ }
+
+ if (Platform.OS === "ios") {
+ const groupDefaults = new UserDefaults(SUITE_NAME);
+ const wallets = (await groupDefaults.get("wallets")) || {};
+ wallets[publicKey] = {
+ ...(wallets[publicKey] || {}),
+ ...walletData,
+ };
+ await groupDefaults.set("wallets", wallets);
+ } else {
+ const walletsString = await SharedPreferences.getItemAsync("wallets");
+ const wallets: Wallets = walletsString ? JSON.parse(walletsString) : {};
+ wallets[publicKey] = {
+ ...(wallets[publicKey] || {}),
+ ...walletData,
+ };
+ await SharedPreferences.setItemAsync("wallets", JSON.stringify(wallets));
+ }
+}
+
+export async function removeWalletInfo(publicKey: string, walletId: number) {
+ if (IS_EXPO_GO) {
+ return;
+ }
+
+ if (Platform.OS === "ios") {
+ const groupDefaults = new UserDefaults(SUITE_NAME);
+ const wallets = await groupDefaults.get("wallets");
+ await groupDefaults.set("wallets", wallets);
+ if (wallets && wallets[publicKey]) {
+ delete wallets[publicKey];
+ for (const key in wallets) {
+ const wallet = wallets[key];
+ if (wallet && wallet.id && wallet.id > walletId) {
+ wallet.id -= 1;
+ }
+ }
+ await groupDefaults.set("wallets", wallets);
+ }
+ } else {
+ const walletsString = await SharedPreferences.getItemAsync("wallets");
+ const wallets: Wallets = walletsString ? JSON.parse(walletsString) : {};
+ if (wallets[publicKey]) {
+ delete wallets[publicKey];
+ for (const key in wallets) {
+ const wallet = wallets[key];
+ if (wallet && wallet.id && wallet.id > walletId) {
+ wallet.id -= 1;
+ }
+ }
+ await SharedPreferences.setItemAsync("wallets", JSON.stringify(wallets));
+ }
+ }
+}
+
+export async function removeAllInfo() {
+ if (IS_EXPO_GO) {
+ return;
+ }
+
+ if (Platform.OS === "ios") {
+ const groupDefaults = new UserDefaults(SUITE_NAME);
+ await groupDefaults.removeAll();
+ } else {
+ await SharedPreferences.deleteItemAsync("wallets");
+ }
+}
diff --git a/native/notifications/service.ts b/native/notifications/service.ts
new file mode 100644
index 0000000..bd27c3b
--- /dev/null
+++ b/native/notifications/service.ts
@@ -0,0 +1,72 @@
+import Constants from "expo-constants";
+import * as Device from "expo-device";
+import * as ExpoNotifications from "expo-notifications";
+import { Platform } from "react-native";
+import { errorToast } from "~/lib/errorToast";
+import { registerWalletNotifications } from "~/lib/notifications";
+import { useAppStore } from "~/lib/state/appStore";
+
+export async function registerForPushNotificationsAsync(): Promise<
+ boolean | null
+> {
+ if (Platform.OS === "android") {
+ ExpoNotifications.setNotificationChannelAsync("default", {
+ name: "default",
+ importance: ExpoNotifications.AndroidImportance.MAX,
+ vibrationPattern: [0, 250, 250, 250],
+ lightColor: "#FF231F7C",
+ enableLights: true,
+ enableVibrate: true,
+ });
+ }
+
+ if (!Device.isDevice) {
+ errorToast("Must use physical device for push notifications");
+ return false;
+ }
+
+ const { status: existingStatus } =
+ await ExpoNotifications.getPermissionsAsync();
+ let finalStatus = existingStatus;
+ if (existingStatus !== "granted") {
+ const { status } = await ExpoNotifications.requestPermissionsAsync();
+ finalStatus = status;
+ }
+ if (finalStatus === "undetermined") {
+ return null;
+ }
+ if (finalStatus === "denied") {
+ if (existingStatus === "denied") {
+ errorToast(new Error("Enable app notifications in device settings"));
+ }
+ return false;
+ }
+ const projectId =
+ Constants?.expoConfig?.extra?.eas?.projectId ??
+ Constants?.easConfig?.projectId;
+ if (!projectId) {
+ errorToast(new Error("Project ID not found"));
+ }
+ try {
+ const pushToken = (
+ await ExpoNotifications.getExpoPushTokenAsync({
+ projectId,
+ })
+ ).data;
+
+ useAppStore.getState().setExpoPushToken(pushToken);
+
+ const wallets = useAppStore.getState().wallets;
+
+ for (let i = 0; i < wallets.length; i++) {
+ const wallet = wallets[i];
+ await registerWalletNotifications(wallet, i);
+ }
+
+ return useAppStore.getState().wallets.some((wallet) => wallet.pushId);
+ } catch (error) {
+ errorToast(error);
+ }
+
+ return false;
+}
diff --git a/package.json b/package.json
index 2ebec16..b48c830 100644
--- a/package.json
+++ b/package.json
@@ -8,6 +8,8 @@
"start:tunnel": "expo start --tunnel",
"android": "expo run:android",
"ios": "expo run:ios",
+ "ios:device": "npx expo run:ios --device",
+ "android:device": "npx expo run:android --device",
"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",
@@ -25,8 +27,11 @@
"test:ci": "jest"
},
"dependencies": {
+ "@alevy97/react-native-userdefaults": "^0.2.2",
+ "@getalby/expo-shared-preferences": "^0.0.1",
+ "@noble/curves": "^1.6.0",
"@getalby/lightning-tools": "^5.1.2",
- "@getalby/sdk": "^3.8.2",
+ "@getalby/sdk": "^3.9.0",
"@popicons/react-native": "^0.0.20",
"@react-native-async-storage/async-storage": "1.23.1",
"@rn-primitives/dialog": "^1.0.3",
@@ -37,6 +42,9 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.1",
"dayjs": "^1.11.10",
+ "expo-device": "~6.0.2",
+ "expo-notification-service-extension-plugin": "^1.0.1",
+ "expo-notifications": "~0.29.11",
"expo": "~52.0.20",
"expo-camera": "~16.0.10",
"expo-clipboard": "~7.0.0",
@@ -73,6 +81,7 @@
"devDependencies": {
"@babel/core": "^7.26.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/jest": "^29.5.13",
diff --git a/pages/Transaction.tsx b/pages/Transaction.tsx
index 29b6d98..469a00b 100644
--- a/pages/Transaction.tsx
+++ b/pages/Transaction.tsx
@@ -13,6 +13,7 @@ import SentTransactionIcon from "~/components/icons/SentTransaction";
import Screen from "~/components/Screen";
import { Text } from "~/components/ui/text";
import { useGetFiatAmount } from "~/hooks/useGetFiatAmount";
+import { useAppStore } from "~/lib/state/appStore";
import { cn } from "~/lib/utils";
type TLVRecord = {
@@ -37,12 +38,19 @@ type Boostagram = {
};
export function Transaction() {
- const { transactionJSON } = useLocalSearchParams() as unknown as {
+ const { transactionJSON, walletId } = useLocalSearchParams() as unknown as {
transactionJSON: string;
+ walletId?: string;
};
const transaction: Nip47Transaction = JSON.parse(transactionJSON);
const getFiatAmount = useGetFiatAmount();
+ React.useEffect(() => {
+ if (walletId) {
+ useAppStore.getState().setSelectedWalletId(Number(walletId));
+ }
+ }, [walletId]);
+
const boostagram = React.useMemo(() => {
let parsedBoostagram;
try {
diff --git a/pages/settings/Notifications.tsx b/pages/settings/Notifications.tsx
new file mode 100644
index 0000000..2cef04d
--- /dev/null
+++ b/pages/settings/Notifications.tsx
@@ -0,0 +1,127 @@
+import React from "react";
+import { FlatList, Text, View } from "react-native";
+import Loading from "~/components/Loading";
+import Screen from "~/components/Screen";
+import { Label } from "~/components/ui/label";
+import { Switch } from "~/components/ui/switch";
+import { errorToast } from "~/lib/errorToast";
+import {
+ deregisterWalletNotifications,
+ registerWalletNotifications,
+} from "~/lib/notifications";
+import { useAppStore, Wallet } from "~/lib/state/appStore";
+import { cn } from "~/lib/utils";
+import { registerForPushNotificationsAsync } from "~/services/Notifications";
+
+export function Notifications() {
+ const [isLoading, setLoading] = React.useState(false);
+ const wallets = useAppStore((store) => store.wallets);
+ const isEnabled = useAppStore((store) => store.isNotificationsEnabled);
+
+ return (
+
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+ {
+ setLoading(true);
+ let enabled: boolean | null = checked;
+ if (enabled) {
+ enabled = await registerForPushNotificationsAsync();
+ } else {
+ const wallets = useAppStore.getState().wallets;
+ for (const [id, wallet] of wallets.entries()) {
+ await deregisterWalletNotifications(wallet, id);
+ }
+ enabled = useAppStore
+ .getState()
+ .wallets.some((wallet) => wallet.pushId);
+ if (enabled) {
+ errorToast("Failed to deregister notifications");
+ }
+ }
+ useAppStore.getState().setNotificationsEnabled(enabled);
+ setLoading(false);
+ }}
+ nativeID="security"
+ />
+ )}
+
+ {wallets.length > 1 && (
+ <>
+
+
+ Choose from which wallets you want to receive app notifications
+
+
+ (
+
+ )}
+ />
+ >
+ )}
+
+
+ );
+}
+
+function WalletNotificationSwitch({
+ wallet,
+ index,
+ isEnabled,
+}: {
+ wallet: Wallet;
+ index: number;
+ isEnabled: boolean;
+}) {
+ const [isLoading, setLoading] = React.useState(false);
+
+ const handleSwitchToggle = async (checked: boolean) => {
+ setLoading(true);
+ if (checked) {
+ await registerWalletNotifications(wallet, index);
+ } else {
+ await deregisterWalletNotifications(wallet, index);
+ const hasNotificationsEnabled = useAppStore
+ .getState()
+ .wallets.some((wallet) => wallet.pushId);
+ useAppStore.getState().setNotificationsEnabled(hasNotificationsEnabled);
+ }
+ setLoading(false);
+ };
+
+ return (
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/pages/settings/Settings.tsx b/pages/settings/Settings.tsx
index b9c36b0..0096a81 100644
--- a/pages/settings/Settings.tsx
+++ b/pages/settings/Settings.tsx
@@ -3,6 +3,7 @@ import { Alert, TouchableOpacity, View } from "react-native";
import {
BitcoinIcon,
FingerprintIcon,
+ NotificationIcon,
OnboardingIcon,
ResetIcon,
SignOutIcon,
@@ -18,10 +19,13 @@ 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";
+import { removeAllInfo } from "~/lib/walletInfo";
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__);
@@ -61,6 +65,15 @@ export function Settings() {
+
+
+
+
+ Notifications
+
+
+
+
@@ -119,7 +132,11 @@ export function Settings() {
},
{
text: "Confirm",
- onPress: () => {
+ onPress: async () => {
+ for (const [id, wallet] of wallets.entries()) {
+ await deregisterWalletNotifications(wallet, id);
+ }
+ await removeAllInfo();
router.dismissAll();
useAppStore.getState().reset();
},
diff --git a/pages/settings/wallets/EditWallet.tsx b/pages/settings/wallets/EditWallet.tsx
index 999cc3e..b0b8f03 100644
--- a/pages/settings/wallets/EditWallet.tsx
+++ b/pages/settings/wallets/EditWallet.tsx
@@ -1,5 +1,6 @@
import * as Clipboard from "expo-clipboard";
import { Link, router, useLocalSearchParams } from "expo-router";
+import React, { useState } from "react";
import { Pressable, Alert as RNAlert, View } from "react-native";
import Toast from "react-native-toast-message";
import Alert from "~/components/Alert";
@@ -10,6 +11,7 @@ import {
WalletIcon,
ZapIcon,
} from "~/components/Icons";
+import Loading from "~/components/Loading";
import Screen from "~/components/Screen";
import {
Card,
@@ -17,142 +19,175 @@ import {
CardDescription,
CardTitle,
} from "~/components/ui/card";
+import { Text } from "~/components/ui/text";
import { DEFAULT_WALLET_NAME, REQUIRED_CAPABILITIES } from "~/lib/constants";
+import { errorToast } from "~/lib/errorToast";
+import { deregisterWalletNotifications } from "~/lib/notifications";
import { useAppStore } from "~/lib/state/appStore";
export function EditWallet() {
const { id } = useLocalSearchParams() as { id: string };
const wallets = useAppStore((store) => store.wallets);
+ const [isDeleting, setIsDeleting] = useState(false);
+ const isNotificationsEnabled = useAppStore(
+ (store) => store.isNotificationsEnabled,
+ );
let walletId = parseInt(id);
+ const onDeleteWallet = async () => {
+ setIsDeleting(true);
+ try {
+ const wallet = wallets[walletId];
+ if (isNotificationsEnabled) {
+ await deregisterWalletNotifications(wallet, walletId);
+ }
+ useAppStore.getState().removeWallet(walletId);
+ } catch (error) {
+ errorToast(error);
+ } finally {
+ setIsDeleting(false);
+ }
+ if (wallets.length !== 1) {
+ router.back();
+ }
+ };
+
return (
- {/* TODO: Do not allow notifications to be toggled without notifications capability */}
- {!REQUIRED_CAPABILITIES.every((capability) =>
- (wallets[walletId]?.nwcCapabilities || []).includes(capability),
- ) && (
-
- !(wallets[walletId]?.nwcCapabilities || []).includes(capability),
- ).join(", ")}`}
- icon={TriangleAlertIcon}
- className="mb-0"
- />
+ {isDeleting ? (
+
+
+ Deleting wallet
+
+ ) : (
+ <>
+ {!REQUIRED_CAPABILITIES.every((capability) =>
+ (wallets[walletId]?.nwcCapabilities || []).includes(capability),
+ ) && (
+
+ !(wallets[walletId]?.nwcCapabilities || []).includes(
+ capability,
+ ),
+ ).join(", ")}`}
+ icon={TriangleAlertIcon}
+ className="mb-0"
+ />
+ )}
+
+
+
+
+
+
+ Wallet Name
+
+ {wallets[walletId]?.name || DEFAULT_WALLET_NAME}
+
+
+
+
+
+
+
+
+
+
+
+
+ Lightning Address
+
+ Update your Lightning Address to easily receive payments
+
+
+
+
+
+
+ {
+ RNAlert.alert(
+ "Export Wallet",
+ "Your Wallet Connection Secret will be copied to the clipboard which you can add to another app. For per-app permission management, try out Alby Hub or add your Wallet Connection Secret to an Alby Account.",
+ [
+ {
+ text: "Cancel",
+ style: "cancel",
+ },
+ {
+ text: "Confirm",
+ onPress: () => {
+ const nwcUrl =
+ useAppStore.getState().wallets[
+ useAppStore.getState().selectedWalletId
+ ].nostrWalletConnectUrl;
+ if (!nwcUrl) {
+ return;
+ }
+ Clipboard.setStringAsync(nwcUrl);
+ Toast.show({
+ type: "success",
+ text1: "Connection Secret copied to clipboard",
+ });
+ },
+ },
+ ],
+ );
+ }}
+ >
+
+
+
+
+ Export Wallet
+
+ Copy your wallet's Connection Secret which can be imported
+ into another app
+
+
+
+
+
+ {
+ RNAlert.alert(
+ "Delete Wallet",
+ "Are you sure you want to delete your wallet? This cannot be undone.",
+ [
+ {
+ text: "Cancel",
+ style: "cancel",
+ },
+ {
+ text: "Confirm",
+ onPress: onDeleteWallet,
+ },
+ ],
+ );
+ }}
+ >
+
+
+
+
+ Delete Wallet
+
+ Remove this wallet from your list of wallets
+
+
+
+
+
+ >
)}
-
-
-
-
-
-
- Wallet Name
-
- {wallets[walletId]?.name || DEFAULT_WALLET_NAME}
-
-
-
-
-
-
-
-
-
-
-
-
- Lightning Address
-
- Update your Lightning Address to easily receive payments
-
-
-
-
-
-
- {
- RNAlert.alert(
- "Export Wallet",
- "Your Wallet Connection Secret will be copied to the clipboard which you can add to another app. For per-app permission management, try out Alby Hub or add your Wallet Connection Secret to an Alby Account.",
- [
- {
- text: "Cancel",
- style: "cancel",
- },
- {
- text: "Confirm",
- onPress: () => {
- const nwcUrl =
- useAppStore.getState().wallets[
- useAppStore.getState().selectedWalletId
- ].nostrWalletConnectUrl;
- if (!nwcUrl) {
- return;
- }
- Clipboard.setStringAsync(nwcUrl);
- Toast.show({
- type: "success",
- text1: "Connection Secret copied to clipboard",
- });
- },
- },
- ],
- );
- }}
- >
-
-
-
-
- Export Wallet
-
- Copy your wallet's Connection Secret which can be imported into
- another app
-
-
-
-
-
- {
- RNAlert.alert(
- "Delete Wallet",
- "Are you sure you want to delete your wallet? This cannot be undone.",
- [
- {
- text: "Cancel",
- style: "cancel",
- },
- {
- text: "Confirm",
- onPress: () => {
- useAppStore.getState().removeWallet(walletId);
- if (wallets.length !== 1) {
- router.back();
- }
- },
- },
- ],
- );
- }}
- >
-
-
-
-
- Delete Wallet
-
- Remove this wallet from your list of wallets
-
-
-
-
-
);
}
diff --git a/pages/settings/wallets/RenameWallet.tsx b/pages/settings/wallets/RenameWallet.tsx
index 663a106..42c9855 100644
--- a/pages/settings/wallets/RenameWallet.tsx
+++ b/pages/settings/wallets/RenameWallet.tsx
@@ -9,14 +9,48 @@ 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 { getPubkeyFromNWCUrl } from "~/lib/utils";
+import { storeWalletInfo } from "~/lib/walletInfo";
export function RenameWallet() {
const { id } = useLocalSearchParams() as { id: string };
const walletId = parseInt(id);
const wallets = useAppStore((store) => store.wallets);
+ const isNotificationsEnabled = useAppStore(
+ (store) => store.isNotificationsEnabled,
+ );
+
const [walletName, setWalletName] = React.useState(
wallets[walletId].name || "",
);
+
+ const onRenameWallet = async () => {
+ useAppStore.getState().updateWallet(
+ {
+ name: walletName,
+ },
+ walletId,
+ );
+ if (
+ isNotificationsEnabled &&
+ (wallets[walletId].nwcCapabilities || []).includes("notifications")
+ ) {
+ const pubkey = getPubkeyFromNWCUrl(
+ wallets[walletId].nostrWalletConnectUrl ?? "",
+ );
+ if (pubkey) {
+ await storeWalletInfo(pubkey, {
+ name: walletName,
+ });
+ }
+ }
+ Toast.show({
+ type: "success",
+ text1: "Wallet name updated",
+ });
+ router.back();
+ };
+
return (
@@ -33,22 +67,7 @@ export function RenameWallet() {
// aria-errormessage="inputError"
/>
-