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" /> - diff --git a/pages/settings/wallets/SetupWallet.tsx b/pages/settings/wallets/SetupWallet.tsx index 315dbdb..1de2841 100644 --- a/pages/settings/wallets/SetupWallet.tsx +++ b/pages/settings/wallets/SetupWallet.tsx @@ -32,6 +32,7 @@ import { Label } from "~/components/ui/label"; import { Text } from "~/components/ui/text"; import { REQUIRED_CAPABILITIES } from "~/lib/constants"; import { errorToast } from "~/lib/errorToast"; +import { registerWalletNotifications } from "~/lib/notifications"; export function SetupWallet() { const { nwcUrl: nwcUrlFromSchemeLink } = useLocalSearchParams<{ @@ -49,6 +50,7 @@ export function SetupWallet() { React.useState(); const [name, setName] = React.useState(""); const [startScanning, setStartScanning] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); const handleScanned = (data: string) => { return connect(data); @@ -104,18 +106,28 @@ export function SetupWallet() { [], ); - const addWallet = () => { - if (!nostrWalletConnectUrl) { + const addWallet = async () => { + if (isLoading || !nostrWalletConnectUrl) { return; } + setIsLoading(true); const nwcClient = new nwc.NWCClient({ nostrWalletConnectUrl }); - useAppStore.getState().addWallet({ + + const wallet = { nostrWalletConnectUrl, nwcCapabilities: capabilities, name: name, lightningAddress: nwcClient.lud16 || "", - }); + }; + useAppStore.getState().addWallet(wallet); + + const isNotificationsEnabled = + useAppStore.getState().isNotificationsEnabled; + if (isNotificationsEnabled) { + await registerWalletNotifications(wallet, wallets.length); + } + useAppStore.getState().setNWCClient(nwcClient); Toast.show({ @@ -129,6 +141,7 @@ export function SetupWallet() { router.dismissAll(); } router.replace("/"); + setIsLoading(false); }; React.useEffect(() => { @@ -258,7 +271,13 @@ export function SetupWallet() { icon={TriangleAlertIcon} /> )} - diff --git a/plugins/android/withMessageServicePlugin.js b/plugins/android/withMessageServicePlugin.js new file mode 100644 index 0000000..4f4967b --- /dev/null +++ b/plugins/android/withMessageServicePlugin.js @@ -0,0 +1,113 @@ +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) { + let buildGradle = config.modResults.contents; + const firebaseDependency = `implementation("com.google.firebase:firebase-messaging:23.2.1")`; + + if (!buildGradle.includes(firebaseDependency)) { + buildGradle = buildGradle.replace(/dependencies\s?{/, (match) => { + return `${match}\n ${firebaseDependency}`; + }); + } + + const bcDependency = `implementation("org.bouncycastle:bcprov-jdk15to18:1.76")`; + if (!buildGradle.includes(bcDependency)) { + buildGradle = buildGradle.replace(/dependencies\s?{/, (match) => { + return `${match}\n ${bcDependency}`; + }); + } + + config.modResults.contents = buildGradle; + return config; +} diff --git a/plugins/ios/withOpenSSLPlugin.js b/plugins/ios/withOpenSSLPlugin.js new file mode 100644 index 0000000..07c7499 --- /dev/null +++ b/plugins/ios/withOpenSSLPlugin.js @@ -0,0 +1,46 @@ +const { withDangerousMod } = require("@expo/config-plugins"); +const fs = require("fs"); +const path = require("path"); + +const PODFILE_SNIPPET = ` + pod 'OpenSSL-Universal' + use_frameworks! :linkage => podfile_properties['ios.useFrameworks'].to_sym if podfile_properties['ios.useFrameworks'] +`; + +module.exports = function withOpenSSLPlugin(config, props = {}) { + config = withDangerousMod(config, [ + "ios", + async (config) => { + const iosPath = path.join(config.modRequest.projectRoot, "ios"); + const podfilePath = path.join(iosPath, "Podfile"); + + try { + let podfileContent = fs.readFileSync(podfilePath, "utf8"); + + if (!podfileContent.includes("pod 'OpenSSL-Universal'")) { + podfileContent = podfileContent.replace( + /(target 'AlbyGo' do[\s\S]*?use_react_native!\([\s\S]*?\))/m, + `$1\n ${PODFILE_SNIPPET}`, + ); + } + + if ( + !podfileContent.includes("target 'NotificationServiceExtension' do") + ) { + const notificationTarget = ` +target 'NotificationServiceExtension' do${PODFILE_SNIPPET}end +`; + podfileContent += notificationTarget; + } + + fs.writeFileSync(podfilePath, podfileContent, "utf8"); + } catch (error) { + console.error("Failed to update Podfile:", error); + } + + return config; + }, + ]); + + return config; +}; diff --git a/services/Notifications.ts b/services/Notifications.ts new file mode 100644 index 0000000..5e5df91 --- /dev/null +++ b/services/Notifications.ts @@ -0,0 +1,19 @@ +import { IS_EXPO_GO } from "~/lib/constants"; +import { errorToast } from "~/lib/errorToast"; + +export async function registerForPushNotificationsAsync(): Promise< + boolean | null +> { + if (IS_EXPO_GO) { + errorToast(new Error("Push notifications are disabled in Expo Go")); + return null; + } + + try { + const nativePushNotifications = require("~/native/notifications/service"); + return await nativePushNotifications.registerForPushNotificationsAsync(); + } catch (error) { + console.error("Error importing push notifications logic:", error); + return null; + } +} diff --git a/yarn.lock b/yarn.lock index 39ed398..91ad886 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7,6 +7,11 @@ resolved "https://registry.yarnpkg.com/@0no-co/graphql.web/-/graphql.web-1.0.11.tgz#035cbc6523af43358b81993f10b13e8d7a79c816" integrity sha512-xuSJ9WXwTmtngWkbdEoopMo6F8NLtjy84UNAMsAr5C3/2SgAL/dEU10TMqTIsipqPQ8HA/7WzeqQ9DEQxSvPPA== +"@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" @@ -961,19 +966,20 @@ node-forge "^1.2.1" nullthrows "^1.1.1" -"@expo/config-plugins@~9.0.10": - version "9.0.10" - resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-9.0.10.tgz#a25fd6061ea7f707213ff8344f562025f850fdc8" - integrity sha512-4piPSylJ8z3to+YZpl/6M2mLxASOdIFANA8FYihsTf9kWlyimV9L/+MGgPXJcieaHXYZZqOryf8hQFVeg/68+A== +"@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" "^52.0.0" - "@expo/json-file" "~9.0.0" - "@expo/plist" "^0.2.0" + "@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.5" + debug "^4.3.1" + find-up "~5.0.0" getenv "^1.0.0" - glob "^10.4.2" + glob "7.1.6" resolve-from "^5.0.0" semver "^7.5.4" slash "^3.0.0" @@ -981,7 +987,7 @@ xcode "^3.0.1" xml2js "0.6.0" -"@expo/config-plugins@~9.0.12": +"@expo/config-plugins@~9.0.10", "@expo/config-plugins@~9.0.12": version "9.0.12" resolved "https://registry.yarnpkg.com/@expo/config-plugins/-/config-plugins-9.0.12.tgz#f122b2dca22e135eadf6e73442da3ced0ce8aa0a" integrity sha512-/Ko/NM+GzvJyRkq8PITm8ms0KY5v0wmN1OQFYRMkcJqOi3PjlhndW+G6bHpJI9mkQXBaUnHwAiGLqIC3+MQ5Wg== @@ -1001,6 +1007,11 @@ xcode "^3.0.1" xml2js "0.6.0" +"@expo/config-types@^51.0.3": + version "51.0.3" + resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-51.0.3.tgz#520bdce5fd75f9d234fd81bd0347443086419450" + integrity sha512-hMfuq++b8VySb+m9uNNrlpbvGxYc8OcFCUX9yTmi9tlx6A4k8SDabWFBgmnr4ao3wEArvWrtUQIfQCVtPRdpKA== + "@expo/config-types@^52.0.0": version "52.0.1" resolved "https://registry.yarnpkg.com/@expo/config-types/-/config-types-52.0.1.tgz#327af1b72a3a9d4556f41e083e0e284dd8198b96" @@ -1089,6 +1100,23 @@ resolve-from "^5.0.0" semver "^7.6.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.6.0": version "0.6.3" resolved "https://registry.yarnpkg.com/@expo/image-utils/-/image-utils-0.6.3.tgz#89c744460beefc686989b969121357bbd5520c8a" @@ -1114,6 +1142,15 @@ json5 "^2.2.3" write-file-atomic "^2.3.0" +"@expo/json-file@~8.3.0": + version "8.3.3" + resolved "https://registry.yarnpkg.com/@expo/json-file/-/json-file-8.3.3.tgz#7926e3592f76030ce63d6b1308ac8f5d4d9341f4" + integrity sha512-eZ5dld9AD0PrVRiIWpRkm5aIoWBw3kAyd8VkuWEy92sEthBKDDDHAnK2a0dw0Eil6j7rK7lS/Qaq/Zzngv2h5A== + dependencies: + "@babel/code-frame" "~7.10.4" + json5 "^2.2.2" + write-file-atomic "^2.3.0" + "@expo/metro-config@0.19.8", "@expo/metro-config@~0.19.8": version "0.19.8" resolved "https://registry.yarnpkg.com/@expo/metro-config/-/metro-config-0.19.8.tgz#f1ea552b6fa5217093fe364ff5ca78a7e261a28b" @@ -1169,6 +1206,15 @@ split "^1.0.1" sudo-prompt "9.1.1" +"@expo/plist@^0.1.0": + version "0.1.3" + resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.1.3.tgz#b4fbee2c4f7a88512a4853d85319f4d95713c529" + integrity sha512-GW/7hVlAylYg1tUrEASclw1MMk9FP4ZwyFAY/SUTJIhPDQHtfOlXREyWV3hhrHdX/K+pS73GNgdfT6E/e+kBbg== + dependencies: + "@xmldom/xmldom" "~0.7.7" + base64-js "^1.2.3" + xmlbuilder "^14.0.0" + "@expo/plist@^0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@expo/plist/-/plist-0.2.0.tgz#beb014c0bfd56a993086c0972ec1ca3ef3f9d36c" @@ -1223,6 +1269,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.7.2": version "1.7.2" resolved "https://registry.yarnpkg.com/@expo/spawn-async/-/spawn-async-1.7.2.tgz#fcfe66c3e387245e72154b1a7eae8cada6a47f58" @@ -1247,15 +1300,20 @@ 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.1.2": version "5.1.2" resolved "https://registry.yarnpkg.com/@getalby/lightning-tools/-/lightning-tools-5.1.2.tgz#8a018e98d5c13097dd98d93192cf5e4e455f4c20" integrity sha512-BwGm8eGbPh59BVa1gI5yJMantBl/Fdps6X4p1ZACnmxz9vDINX8/3aFoOnDlF7yyA2boXWCsReVQSr26Q2yjiQ== -"@getalby/sdk@^3.8.2": - version "3.8.2" - resolved "https://registry.yarnpkg.com/@getalby/sdk/-/sdk-3.8.2.tgz#84a184c46fdebf18652d6c06b92f07ed36129d3d" - integrity sha512-0F4ub/e+t93V9wzR5Vr+Xdfhhy5kK+ZKls/J3yX2YBT27X1Rd3QIPLCTUFCb4RaV6a/e17aZAVJF8Af7r9BeAg== +"@getalby/sdk@^3.9.0": + version "3.9.0" + resolved "https://registry.yarnpkg.com/@getalby/sdk/-/sdk-3.9.0.tgz#4eb4dd9512fc41312c8a894b7f653360feb61eac" + integrity sha512-qgNXr4FsX0a+PPvWgb112Q8h1/ov31zVP4LjsDYr5+W0CkrRbW9pQnsHPycVPLB5H8k5WVRRNkxYBBoWIBAwyw== dependencies: emittery "^1.0.3" nostr-tools "2.9.4" @@ -1279,6 +1337,11 @@ resolved "https://registry.yarnpkg.com/@humanwhocodes/object-schema/-/object-schema-2.0.3.tgz#4a2868d75d6d6963e423bcf90b7fd1be343409d3" integrity sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA== +"@ide/backoff@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@ide/backoff/-/backoff-1.0.0.tgz#466842c25bd4a4833e0642fab41ccff064010176" + integrity sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g== + "@isaacs/cliui@^8.0.2": version "8.0.2" resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-8.0.2.tgz#b37667b7bc181c168782259bab42474fbf52b550" @@ -1563,6 +1626,13 @@ dependencies: "@noble/hashes" "1.3.2" +"@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/curves@~1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@noble/curves/-/curves-1.1.0.tgz#f13fc667c89184bc04cccb9b11e8e7bae27d8c3d" @@ -1580,6 +1650,11 @@ resolved "https://registry.yarnpkg.com/@noble/hashes/-/hashes-1.3.2.tgz#6f26dbc8fbc7205873ce3cee2f690eba0d421b39" integrity sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ== +"@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" @@ -2774,6 +2849,17 @@ asap@~2.0.3, asap@~2.0.6: resolved "https://registry.yarnpkg.com/asap/-/asap-2.0.6.tgz#e50347611d7e690943208bbdafebcbc2fb866d46" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== +assert@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/assert/-/assert-2.1.0.tgz#6d92a238d05dc02e7427c881fb8be81c8448b2dd" + integrity sha512-eLHpSK/Y4nhMJ07gDaAzoX/XAKS8PSaojml3M0DM4JpV1LAi5JOJ/p6H/XWrl8L+DzVEvVCW1z3vWAaB9oTsQw== + dependencies: + call-bind "^1.0.2" + is-nan "^1.3.2" + object-is "^1.1.5" + object.assign "^4.1.4" + util "^0.12.5" + ast-types@0.15.2: version "0.15.2" resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.15.2.tgz#39ae4809393c4b16df751ee563411423e85fb49d" @@ -2936,6 +3022,11 @@ babel-preset-jest@^29.6.3: babel-plugin-jest-hoist "^29.6.3" babel-preset-current-node-syntax "^1.0.0" +badgin@^1.1.5: + version "1.2.3" + resolved "https://registry.yarnpkg.com/badgin/-/badgin-1.2.3.tgz#994b5f519827d7d5422224825b2c8faea2bc43ad" + integrity sha512-NQGA7LcfCpSzIbGRbkgjgdWkjy7HI+Th5VLxTJfW5EeaAf3fnS+xWQaQOCYiny+q6QSvxqoSO04vCx+4u++EJw== + balanced-match@^1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee" @@ -3102,7 +3193,7 @@ cacache@^18.0.2: tar "^6.1.11" unique-filename "^3.0.0" -call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: +call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== @@ -3540,10 +3631,10 @@ cross-fetch@^3.1.5: dependencies: node-fetch "^2.6.12" -cross-spawn@^6.0.0: - version "6.0.6" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.6.tgz#30d0efa0712ddb7eb5a76e1e8721bffafa6b5d57" - integrity sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw== +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== dependencies: nice-try "^1.0.4" path-key "^2.0.1" @@ -3565,6 +3656,11 @@ crypt@0.0.2: resolved "https://registry.yarnpkg.com/crypt/-/crypt-0.0.2.tgz#88d7ff7ec0dfb86f713dc87bbb42d044d3e6c41b" integrity sha512-mCxBlsHFYh9C+HVpiEacem8FEBnMXgU9gy4zmNC+SXAZNB/1idgp/aulFJ4FgCi7GPEVbfyng092GqL2k2rmow== +crypto-random-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" + integrity sha512-GsVpkFPlycH7/fRR7Dhcmnoii54gV1nz7y4CWyeFS14N+JVBBhY+r8amRHE4BwSYal7BPTDp8isvAlCxyFt3Hg== + crypto-random-string@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-2.0.0.tgz#ef2a7a966ec11083388369baa02ebead229b30d5" @@ -4449,6 +4545,11 @@ expect@^29.0.0, expect@^29.7.0: jest-message-util "^29.7.0" jest-util "^29.7.0" +expo-application@~6.0.0: + version "6.0.1" + resolved "https://registry.yarnpkg.com/expo-application/-/expo-application-6.0.1.tgz#bb5e1f15636c51c571a0062e8f9d4e504da967e4" + integrity sha512-w+1quSmKp8SYKT+GAFHSN5c6u+PqoVRIfpsLyRQrQdOnBA9dA8Hw6JT9sHNFmA30A2v1b/sdYZE3qKuRJFNSWQ== + expo-asset@~11.0.1: version "11.0.1" resolved "https://registry.yarnpkg.com/expo-asset/-/expo-asset-11.0.1.tgz#8608f5ea4639698553725b6690dd621f6f70f206" @@ -4479,6 +4580,13 @@ expo-constants@~17.0.0, expo-constants@~17.0.3: "@expo/config" "~10.0.4" "@expo/env" "~0.4.0" +expo-device@~6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/expo-device/-/expo-device-6.0.2.tgz#9bc3eccd16509c2819c225cc2ca8f7c3e3bdd11e" + integrity sha512-sCt91CuTmAuMXX4SlFOn4lIos2UIr8vb0jDstDDZXys6kErcj0uynC7bQAMreU5uRUTKMAl4MAMpKt9ufCXPBw== + dependencies: + ua-parser-js "^0.7.33" + expo-file-system@~18.0.6: version "18.0.6" resolved "https://registry.yarnpkg.com/expo-file-system/-/expo-file-system-18.0.6.tgz#43f7718530d0e2aa1f49bca7ccb721007acabf2c" @@ -4539,6 +4647,26 @@ expo-modules-core@2.1.2: 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.29.11: + version "0.29.11" + resolved "https://registry.yarnpkg.com/expo-notifications/-/expo-notifications-0.29.11.tgz#0691f88c91f6598671cec8e2ff12922ea1493edf" + integrity sha512-u/Csc3YNOPjjuyjAeyj5ne7XR/Z0ABYVquhSnyjEj2Fp8mSldOPCMvaEA01pTFj+8HTlkjX5RZDvQ7cR62ngOA== + dependencies: + "@expo/image-utils" "^0.6.0" + "@ide/backoff" "^1.0.0" + abort-controller "^3.0.0" + assert "^2.0.0" + badgin "^1.1.5" + expo-application "~6.0.0" + expo-constants "~17.0.0" + expo-router@~4.0.15: version "4.0.15" resolved "https://registry.yarnpkg.com/expo-router/-/expo-router-4.0.15.tgz#bdc00b90bd60ab5ccb35ae51f31dcbc96c179949" @@ -4753,7 +4881,7 @@ find-up@^4.0.0, find-up@^4.1.0: locate-path "^5.0.0" path-exists "^4.0.0" -find-up@^5.0.0: +find-up@^5.0.0, find-up@~5.0.0: version "5.0.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-5.0.0.tgz#4c92819ecb7083561e4f4a240a86be5198f536fc" integrity sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng== @@ -4999,6 +5127,18 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" +glob@7.1.6: + version "7.1.6" + resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.6.tgz#141f33b81a7c2492e125594307480c46679278a6" + integrity sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + glob@^10.2.2, glob@^10.3.10, glob@^10.4.2: version "10.4.5" resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" @@ -5487,6 +5627,14 @@ is-map@^2.0.3: resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== +is-nan@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/is-nan/-/is-nan-1.3.2.tgz#043a54adea31748b55b6cd4e09aadafa69bd9e1d" + integrity sha512-E+zBKpQ2t6MEo1VsonYmluk9NxGrbzpeeLC2xIViuO2EjU2xsXsBPwTr3Ykv9l08UYEVEdWeRZNouaZqF6RN0w== + dependencies: + call-bind "^1.0.0" + define-properties "^1.1.3" + is-negative-zero@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" @@ -6268,7 +6416,7 @@ json5@^1.0.2: dependencies: minimist "^1.2.0" -json5@^2.2.3: +json5@^2.2.2, json5@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.3.tgz#78cd6f1a19bdc12b73db5ad0c61efd66c1e29283" integrity sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg== @@ -6904,6 +7052,11 @@ mime@1.6.0: resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== +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== + mimic-fn@^1.0.0: version "1.2.0" resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-1.2.0.tgz#820c86a39334640e99516928bd03fca88057d022" @@ -7086,7 +7239,7 @@ node-dir@^0.1.17: dependencies: minimatch "^3.0.2" -node-fetch@^2.2.0, node-fetch@^2.6.1, node-fetch@^2.6.12: +node-fetch@^2.2.0, node-fetch@^2.6.0, node-fetch@^2.6.1, node-fetch@^2.6.12: version "2.7.0" resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== @@ -7202,6 +7355,14 @@ object-inspect@^1.13.1, object-inspect@^1.13.3: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.3.tgz#f14c183de51130243d6d18ae149375ff50ea488a" integrity sha512-kDCGIbxkDSXE3euJZZXzc6to7fCrKHNI/hSRQnRuQ+BWjFNzZwiFF8fj/6o2t2G9/jTj8PSIYTfCLelLZEeRpA== +object-is@^1.1.5: + version "1.1.6" + resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.6.tgz#1a6a53aed2dd8f7e6775ff870bea58545956ab07" + integrity sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q== + dependencies: + call-bind "^1.0.7" + define-properties "^1.2.1" + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -8411,6 +8572,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" @@ -9093,6 +9259,11 @@ tar@^6.1.11, tar@^6.2.1: mkdirp "^1.0.3" yallist "^4.0.0" +temp-dir@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-1.0.0.tgz#0a7c0ea26d3a39afa7e0ebea9c1fc0bc4daa011d" + integrity sha512-xZFXEGbG7SNC3itwBzI3RYjq/cEhBkx2hJuKGIUOcEULmkQExXiHat2z/qkISYsuR+IKumhEfKKbV5qXmhICFQ== + temp-dir@^2.0.0, temp-dir@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/temp-dir/-/temp-dir-2.0.0.tgz#bde92b05bdfeb1516e804c9c00ad45177f31321e" @@ -9105,6 +9276,15 @@ temp@^0.8.4: dependencies: rimraf "~2.6.2" +tempy@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/tempy/-/tempy-0.3.0.tgz#6f6c5b295695a16130996ad5ab01a8bd726e8bf8" + integrity sha512-WrH/pui8YCwmeiAoxV+lpRH9HpRtgBhSR2ViBPgpGb/wnYDzp21R4MN45fsCGvLROvY67o3byhJRYRONJyImVQ== + dependencies: + temp-dir "^1.0.0" + type-fest "^0.3.1" + unique-string "^1.0.0" + tempy@^0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/tempy/-/tempy-0.7.1.tgz#5a654e6dbd1747cdd561efb112350b55cd9c1d46" @@ -9288,6 +9468,11 @@ type-fest@^0.21.3: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.21.3.tgz#d260a24b0198436e133fa26a524a6d65fa3b2e37" integrity sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w== +type-fest@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.3.1.tgz#63d00d204e059474fe5e1b7c011112bbd1dc29e1" + integrity sha512-cUGJnCdr4STbePCgqNFbpVNCepa+kAVohJs1sLhxzdH+gnEoOd8VhbYa7pD3zZYGiURWM2xzEII3fQcRizDkYQ== + type-fest@^0.7.1: version "0.7.1" resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-0.7.1.tgz#8dda65feaf03ed78f0a3f9678f1869147f7c5c48" @@ -9343,6 +9528,11 @@ typescript@~5.3.3: resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== +ua-parser-js@^0.7.33: + version "0.7.39" + resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-0.7.39.tgz#c71efb46ebeabc461c4612d22d54f88880fabe7e" + integrity sha512-IZ6acm6RhQHNibSt7+c09hhvsKy9WUr4DVbeq9U8o71qxyYtJpQeDxQnMrVqnIFMLcQjHO0I9wgfO2vIahht4w== + ua-parser-js@^1.0.35: version "1.0.39" resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.39.tgz#bfc07f361549bf249bd8f4589a4cccec18fd2018" @@ -9405,6 +9595,13 @@ unique-slug@^4.0.0: dependencies: imurmurhash "^0.1.4" +unique-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a" + integrity sha512-ODgiYu03y5g76A1I9Gt0/chLCzQjvzDy7DsZGsLOE/1MrF6wriEskSncj1+/C58Xk/kPZDppSctDybCwOSaGAg== + dependencies: + crypto-random-string "^1.0.0" + unique-string@^2.0.0, unique-string@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-2.0.0.tgz#39c6451f81afb2749de2b233e3f7c5e8843bd89d" @@ -9490,7 +9687,7 @@ util-deprecate@^1.0.2, util-deprecate@~1.0.1: resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== -util@^0.12.3: +util@^0.12.3, util@^0.12.5: version "0.12.5" resolved "https://registry.yarnpkg.com/util/-/util-0.12.5.tgz#5f17a6059b73db61a875668781a1c2b136bd6fbc" integrity sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==