diff --git a/adamant-wallets b/adamant-wallets index 8ccedce17..86467ccc9 160000 --- a/adamant-wallets +++ b/adamant-wallets @@ -1 +1 @@ -Subproject commit 8ccedce173e4d27adf263f101b0c1c32c6b638e7 +Subproject commit 86467ccc9016ba386039889eb6b4585cf9275120 diff --git a/android/app/build.gradle b/android/app/build.gradle index 212970acc..8ab223b68 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -40,6 +40,7 @@ dependencies { androidTestImplementation "androidx.test.ext:junit:$androidxJunitVersion" androidTestImplementation "androidx.test.espresso:espresso-core:$androidxEspressoCoreVersion" implementation project(':capacitor-cordova-android-plugins') + implementation 'com.google.firebase:firebase-messaging:23.4.1' } apply from: 'capacitor.build.gradle' diff --git a/android/app/capacitor.build.gradle b/android/app/capacitor.build.gradle index dae654977..a2bb2e150 100644 --- a/android/app/capacitor.build.gradle +++ b/android/app/capacitor.build.gradle @@ -10,7 +10,12 @@ android { apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle" dependencies { implementation project(':capacitor-community-file-opener') + implementation project(':capacitor-app') implementation project(':capacitor-filesystem') + implementation project(':capacitor-local-notifications') + implementation project(':capacitor-preferences') + implementation project(':capacitor-push-notifications') + implementation project(':capacitor-secure-storage-plugin') } diff --git a/android/app/google-services.json b/android/app/google-services.json new file mode 100644 index 000000000..b0a2da9cf --- /dev/null +++ b/android/app/google-services.json @@ -0,0 +1,24 @@ +{ + "project_info": { + "project_number": "987518845753", + "firebase_url": "https://adamant-messenger.firebaseio.com", + "project_id": "adamant-messenger", + "storage_bucket": "adamant-messenger.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:987518845753:android:6585b11ca36bac4c251ee8", + "android_client_info": { + "package_name": "im.adamant.adamantmessengerpwa" + } + }, + "api_key": [ + { + "current_key": "AIzaSyDgtB_hqwL1SS_YMYepRMmXYhmc7154wmU" + } + ] + } + ], + "configuration_version": "1" +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 05e63b05e..289461164 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -27,12 +27,34 @@ android:grantUriPermissions="true"> - - + + + + + + + + + + + + + + + - + + + + + \ No newline at end of file diff --git a/android/app/src/main/java/im/adamant/adamantmessengerpwa/AdamantFirebaseMessagingService.java b/android/app/src/main/java/im/adamant/adamantmessengerpwa/AdamantFirebaseMessagingService.java new file mode 100644 index 000000000..7e81c1648 --- /dev/null +++ b/android/app/src/main/java/im/adamant/adamantmessengerpwa/AdamantFirebaseMessagingService.java @@ -0,0 +1,308 @@ +package im.adamant.adamantmessengerpwa; + +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Build; +import android.util.Log; +import androidx.core.app.NotificationCompat; +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; +import java.util.HashSet; +import android.os.Handler; +import android.os.Looper; +import org.json.JSONObject; + +public class AdamantFirebaseMessagingService extends FirebaseMessagingService { + + private static final String TAG = "AdamantFirebaseMsg"; + private static final String CHANNEL_ID = "adamant_notifications"; + private static final long CLEANUP_INTERVAL = 2 * 60 * 1000; + + private static final HashSet processedEvents = new HashSet<>(); + private static Handler cleanupHandler = new Handler(Looper.getMainLooper()); + + static { + cleanupHandler.postDelayed(new Runnable() { + @Override + public void run() { + processedEvents.clear(); + cleanupHandler.postDelayed(this, CLEANUP_INTERVAL); + } + }, CLEANUP_INTERVAL); + } + + @Override + public void onCreate() { + super.onCreate(); + createNotificationChannel(); + } + + @Override + public void handleIntent(Intent intent) { + RemoteMessage remoteMessage = null; + try { + if (intent.getExtras() != null) { + remoteMessage = new RemoteMessage(intent.getExtras()); + } + } catch (Exception e) { + Log.e(TAG, "Error creating RemoteMessage", e); + } + + if (remoteMessage == null) { + super.handleIntent(intent); + return; + } + + if (areNotificationsDisabled()) { + return; + } + + String txnData = remoteMessage.getData().get("txn"); + if (txnData != null) { + String transactionId = extractTransactionId(txnData); + if (transactionId != null) { + if (processedEvents.contains(transactionId)) { + return; + } + processedEvents.add(transactionId); + + // Also check if this is a signal message that should be hidden + String body = formatNotificationText(txnData); + if (body == null) { + // Signal message - don't show notification but still mark as processed + return; + } + } + } + + if (isAppInForeground()) { + super.handleIntent(intent); + } else { + showBackgroundNotification(remoteMessage); + } + } + + @Override + public void onMessageReceived(RemoteMessage remoteMessage) { + super.onMessageReceived(remoteMessage); + } + + private void showBackgroundNotification(RemoteMessage remoteMessage) { + try { + String txnData = remoteMessage.getData().get("txn"); + if (txnData == null) { + return; + } + + String transactionId = extractTransactionId(txnData); + String senderId = extractSenderId(txnData); + if (transactionId == null || senderId == null) { + return; + } + + String title = senderId; + String body = formatNotificationText(txnData); + + Intent clickIntent = new Intent(this, MainActivity.class); + clickIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + clickIntent.putExtra("openChat", true); + clickIntent.putExtra("senderId", senderId); + clickIntent.putExtra("transactionId", transactionId); + + PendingIntent pendingIntent = PendingIntent.getActivity( + this, + transactionId.hashCode(), + clickIntent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + NotificationCompat.Builder builder = new NotificationCompat.Builder(this, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) + .setContentTitle(title) + .setContentText(body) + .setAutoCancel(true) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent); + + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + int notificationId = Math.abs(transactionId.hashCode()); + notificationManager.notify(notificationId, builder.build()); + + MainActivity.saveNotificationId(this, senderId, notificationId); + + } catch (Exception e) { + Log.e(TAG, "Error showing notification", e); + } + } + + private String formatNotificationText(String txnData) { + try { + JSONObject transaction = new JSONObject(txnData); + + int transactionType = transaction.optInt("type", -1); + long transactionAmount = transaction.optLong("amount", 0); + + // Type 0: Pure ADM Transfer (without comments) + if (transactionType == 0) { + return formatADMTransfer(transactionAmount); + } + + // Type 8: Chat Message (can include ADM transfer with comment) + if (transactionType == 8) { + // If amount > 0, it's ADM transfer with comment + if (transactionAmount > 0) { + return formatADMTransferWithComment(transactionAmount); + } + + // Regular chat message - check asset.chat.type + return formatChatMessage(transaction); + } + + return "New transaction"; + } catch (Exception e) { + Log.e(TAG, "Error parsing notification", e); + return "New message"; + } + } + + private String formatChatMessage(JSONObject transaction) { + try { + if (!transaction.has("asset")) { + return "New message"; + } + + JSONObject asset = transaction.getJSONObject("asset"); + if (!asset.has("chat")) { + return "New message"; + } + + JSONObject chat = asset.getJSONObject("chat"); + int chatType = chat.optInt("type", 1); + + switch (chatType) { + case 1: + // Basic Encrypted Message + return "New message"; + case 2: + // Rich Content Message (crypto transfers, etc.) + return "sent you crypto"; + case 3: + // Signal Message (should be hidden per documentation) + return null; // Don't show notification for signal messages + default: + return "New message"; + } + } catch (Exception e) { + return "New message"; + } + } + + private String formatADMTransfer(long amount) { + if (amount == 0) { + return "sent you ADM"; + } + double admAmount = amount / 100000000.0; + + java.math.BigDecimal bd = java.math.BigDecimal.valueOf(admAmount); + return String.format("sent you %s ADM", bd.stripTrailingZeros().toPlainString()); + } + + private String formatADMTransferWithComment(long amount) { + if (amount == 0) { + return "sent you ADM with message"; + } + double admAmount = amount / 100000000.0; + + java.math.BigDecimal bd = java.math.BigDecimal.valueOf(admAmount); + return String.format("sent you %s ADM with message", bd.stripTrailingZeros().toPlainString()); + } + + private boolean areNotificationsDisabled() { + try { + SharedPreferences prefs = getSharedPreferences("CapacitorStorage", Context.MODE_PRIVATE); + String notificationTypeStr = prefs.getString("allowNotificationType", "2"); + return "0".equals(notificationTypeStr); + } catch (Exception e) { + return false; + } + } + + private boolean isAppInForeground() { + try { + android.app.ActivityManager activityManager = (android.app.ActivityManager) getSystemService(Context.ACTIVITY_SERVICE); + java.util.List appProcesses = activityManager.getRunningAppProcesses(); + + if (appProcesses == null) { + return false; + } + + String packageName = getPackageName(); + for (android.app.ActivityManager.RunningAppProcessInfo appProcess : appProcesses) { + if (appProcess.processName.equals(packageName)) { + return appProcess.importance == android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND; + } + } + return false; + } catch (Exception e) { + return false; + } + } + + private String extractTransactionId(String txnData) { + try { + JSONObject transaction = new JSONObject(txnData); + return transaction.optString("id", null); + } catch (Exception e) { + // Fallback to string parsing + return extractFieldFromJson(txnData, "\"id\":\"", 6); + } + } + + private String extractSenderId(String txnData) { + try { + JSONObject transaction = new JSONObject(txnData); + return transaction.optString("senderId", null); + } catch (Exception e) { + // Fallback to string parsing + return extractFieldFromJson(txnData, "\"senderId\":\"", 12); + } + } + + private String extractFieldFromJson(String jsonData, String fieldPattern, int patternLength) { + try { + int startIndex = jsonData.indexOf(fieldPattern); + if (startIndex == -1) { + return null; + } + + startIndex += patternLength; + int endIndex = jsonData.indexOf("\"", startIndex); + return endIndex == -1 ? null : jsonData.substring(startIndex, endIndex); + } catch (Exception e) { + return null; + } + } + + private void createNotificationChannel() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + "ADAMANT Notifications", + NotificationManager.IMPORTANCE_DEFAULT + ); + channel.setDescription("Notifications for ADAMANT Messenger"); + + NotificationManager notificationManager = getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } + + @Override + public void onNewToken(String token) { + super.onNewToken(token); + } +} diff --git a/android/app/src/main/java/im/adamant/adamantmessengerpwa/MainActivity.java b/android/app/src/main/java/im/adamant/adamantmessengerpwa/MainActivity.java index 4d4d0d8e9..524db127b 100644 --- a/android/app/src/main/java/im/adamant/adamantmessengerpwa/MainActivity.java +++ b/android/app/src/main/java/im/adamant/adamantmessengerpwa/MainActivity.java @@ -1,5 +1,152 @@ package im.adamant.adamantmessengerpwa; +import android.app.NotificationManager; +import android.content.Context; +import android.content.Intent; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.os.Handler; +import android.util.Log; import com.getcapacitor.BridgeActivity; +import java.util.HashSet; +import java.util.Set; -public class MainActivity extends BridgeActivity {} +public class MainActivity extends BridgeActivity { + + private static final String TAG = "MainActivity"; + private static final String PREFS_NAME = "AdamantNotifications"; + private static final String KEY_SENDER_NOTIFICATIONS = "sender_notifications_"; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + clearAllNotifications(); + handleNotificationClick(getIntent()); + } + + @Override + public void onResume() { + super.onResume(); + clearAllNotifications(); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + handleNotificationClick(intent); + } + + private void clearAllNotifications() { + try { + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (notificationManager != null) { + notificationManager.cancelAll(); + clearAllSavedNotificationIds(); + } + } catch (Exception e) { + Log.e(TAG, "Error clearing notifications", e); + } + } + + private void handleNotificationClick(Intent intent) { + if (intent != null && intent.getBooleanExtra("openChat", false)) { + String senderId = intent.getStringExtra("senderId"); + String transactionId = intent.getStringExtra("transactionId"); + + if (senderId != null) { + clearNotificationsFromSender(senderId); + openChat(senderId, transactionId); + } + + intent.removeExtra("openChat"); + intent.removeExtra("senderId"); + intent.removeExtra("transactionId"); + } + } + + private void openChat(String senderId, String transactionId) { + String js = String.format( + "window.dispatchEvent(new CustomEvent('openChat', { detail: { partnerId: '%s', transactionId: '%s' } }));", + senderId, + transactionId != null ? transactionId : "" + ); + + if (getBridge() != null && getBridge().getWebView() != null) { + getBridge().getWebView().evaluateJavascript(js, null); + } else { + new Handler().postDelayed(() -> { + if (getBridge() != null && getBridge().getWebView() != null) { + getBridge().getWebView().evaluateJavascript(js, null); + } + }, 500); + } + } + + private void clearNotificationsFromSender(String senderId) { + try { + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (notificationManager == null) { + return; + } + + Set notificationIds = getSenderNotificationIds(senderId); + for (String notificationIdStr : notificationIds) { + try { + int notificationId = Integer.parseInt(notificationIdStr); + notificationManager.cancel(notificationId); + } catch (NumberFormatException e) { + Log.e(TAG, "Invalid notification ID: " + notificationIdStr); + } + } + clearSenderNotificationIds(senderId); + } catch (Exception e) { + Log.e(TAG, "Error clearing sender notifications", e); + } + } + + public static void saveNotificationId(Context context, String senderId, int notificationId) { + try { + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + Set existingIds = prefs.getStringSet(KEY_SENDER_NOTIFICATIONS + senderId, new HashSet<>()); + Set updatedIds = new HashSet<>(existingIds); + updatedIds.add(String.valueOf(notificationId)); + prefs.edit().putStringSet(KEY_SENDER_NOTIFICATIONS + senderId, updatedIds).apply(); + } catch (Exception e) { + Log.e(TAG, "Error saving notification ID", e); + } + } + + private Set getSenderNotificationIds(String senderId) { + try { + SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + return prefs.getStringSet(KEY_SENDER_NOTIFICATIONS + senderId, new HashSet<>()); + } catch (Exception e) { + return new HashSet<>(); + } + } + + private void clearSenderNotificationIds(String senderId) { + try { + SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + prefs.edit().remove(KEY_SENDER_NOTIFICATIONS + senderId).apply(); + } catch (Exception e) { + Log.e(TAG, "Error clearing sender notification IDs", e); + } + } + + private void clearAllSavedNotificationIds() { + try { + SharedPreferences prefs = getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = prefs.edit(); + for (String key : prefs.getAll().keySet()) { + if (key.startsWith(KEY_SENDER_NOTIFICATIONS)) { + editor.remove(key); + } + } + editor.apply(); + } catch (Exception e) { + Log.e(TAG, "Error clearing all notification IDs", e); + } + } +} diff --git a/android/capacitor.settings.gradle b/android/capacitor.settings.gradle index 77d7d777f..99c38067e 100644 --- a/android/capacitor.settings.gradle +++ b/android/capacitor.settings.gradle @@ -5,5 +5,20 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/ include ':capacitor-community-file-opener' project(':capacitor-community-file-opener').projectDir = new File('../node_modules/@capacitor-community/file-opener/android') +include ':capacitor-app' +project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android') + include ':capacitor-filesystem' project(':capacitor-filesystem').projectDir = new File('../node_modules/@capacitor/filesystem/android') + +include ':capacitor-local-notifications' +project(':capacitor-local-notifications').projectDir = new File('../node_modules/@capacitor/local-notifications/android') + +include ':capacitor-preferences' +project(':capacitor-preferences').projectDir = new File('../node_modules/@capacitor/preferences/android') + +include ':capacitor-push-notifications' +project(':capacitor-push-notifications').projectDir = new File('../node_modules/@capacitor/push-notifications/android') + +include ':capacitor-secure-storage-plugin' +project(':capacitor-secure-storage-plugin').projectDir = new File('../node_modules/capacitor-secure-storage-plugin/android') diff --git a/package-lock.json b/package-lock.json index 87664d5bf..1f761b73a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,8 +12,12 @@ "dependencies": { "@capacitor-community/file-opener": "^7.0.1", "@capacitor/android": "^7.3.0", + "@capacitor/app": "^7.0.1", "@capacitor/core": "^7.3.0", "@capacitor/filesystem": "^7.1.1", + "@capacitor/local-notifications": "^7.0.1", + "@capacitor/preferences": "^7.0.1", + "@capacitor/push-notifications": "^7.0.1", "@emoji-mart/data": "^1.2.1", "@klayr/codec": "^0.5.1", "@klayr/cryptography": "^4.1.1", @@ -33,6 +37,7 @@ "bitcoinjs-lib": "^6.1.7", "buffer": "^6.0.3", "bytebuffer": "^5.0.1", + "capacitor-secure-storage-plugin": "^0.12.0", "coininfo": "^5.2.1", "copy-to-clipboard": "^3.3.3", "core-js": "^3.40.0", @@ -45,6 +50,7 @@ "ed2curve": "^0.3.0", "emoji-mart": "^5.6.0", "file-saver": "^2.0.5", + "firebase": "^11.9.1", "hdkey": "^2.1.0", "https-browserify": "^1.0.0", "i": "^0.3.7", @@ -1845,6 +1851,15 @@ "@capacitor/core": "^7.3.0" } }, + "node_modules/@capacitor/app": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/app/-/app-7.0.1.tgz", + "integrity": "sha512-ArlVZAAla4MwQoKh26x2AaTDOBh5Vhp1VhMKR3RwqZSsZnazKTFGNrPbr9Ez5r1knnEDfApyjwp1uZnXK1WTYQ==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, "node_modules/@capacitor/assets": { "version": "3.0.5", "resolved": "https://registry.npmjs.org/@capacitor/assets/-/assets-3.0.5.tgz", @@ -2142,6 +2157,33 @@ "@capacitor/core": ">=7.0.0" } }, + "node_modules/@capacitor/local-notifications": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/local-notifications/-/local-notifications-7.0.1.tgz", + "integrity": "sha512-GJewoiqiTLXNNRxqeJDi6vxj1Y37jLFI3KSdAM2Omvxew4ewyBSCjwOtXMQaEg+lvzGHtK6FPrSc2v/2EcL0wA==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, + "node_modules/@capacitor/preferences": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/preferences/-/preferences-7.0.1.tgz", + "integrity": "sha512-XF9jOHzvoIBZLwZr/EX6aVaUO1d8Mx7TwBLQS33pYHOliCW5knT5KUkFOXNNYxh9qqODYesee9xuQIKNJpQBag==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, + "node_modules/@capacitor/push-notifications": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@capacitor/push-notifications/-/push-notifications-7.0.1.tgz", + "integrity": "sha512-nSHsMSrTHX5pOkX1Khse75/uvSx/JTcXG+9aT6a66CvzalH6MCs0ha8Jv+xu0k9xW8caO+qSUMjfj5Oy82Uxmw==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, "node_modules/@capacitor/synapse": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/@capacitor/synapse/-/synapse-1.0.3.tgz", @@ -3628,6 +3670,669 @@ "dev": true, "license": "MIT" }, + "node_modules/@firebase/ai": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@firebase/ai/-/ai-1.4.0.tgz", + "integrity": "sha512-wvF33gtU6TXb6Co8TEC1pcl4dnVstYmRE/vs9XjUGE7he7Sgf5TqSu+EoXk/fuzhw5tKr1LC5eG9KdYFM+eosw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/component": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/analytics": { + "version": "0.10.16", + "resolved": "https://registry.npmjs.org/@firebase/analytics/-/analytics-0.10.16.tgz", + "integrity": "sha512-cMtp19He7Fd6uaj/nDEul+8JwvJsN8aRSJyuA1QN3QrKvfDDp+efjVurJO61sJpkVftw9O9nNMdhFbRcTmTfRQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/installations": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/analytics-compat": { + "version": "0.2.22", + "resolved": "https://registry.npmjs.org/@firebase/analytics-compat/-/analytics-compat-0.2.22.tgz", + "integrity": "sha512-VogWHgwkdYhjWKh8O1XU04uPrRaiDihkWvE/EMMmtWtaUtVALnpLnUurc3QtSKdPnvTz5uaIGKlW84DGtSPFbw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/analytics": "0.10.16", + "@firebase/analytics-types": "0.8.3", + "@firebase/component": "0.6.17", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/analytics-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/analytics-types/-/analytics-types-0.8.3.tgz", + "integrity": "sha512-VrIp/d8iq2g501qO46uGz3hjbDb8xzYMrbu8Tp0ovzIzrvJZ2fvmj649gTjge/b7cCCcjT0H37g1gVtlNhnkbg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.13.1.tgz", + "integrity": "sha512-0O33PKrXLoIWkoOO5ByFaLjZehBctSYWnb+xJkIdx2SKP/K9l1UPFXPwASyrOIqyY3ws+7orF/1j7wI5EKzPYQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-check": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@firebase/app-check/-/app-check-0.10.0.tgz", + "integrity": "sha512-AZlRlVWKcu8BH4Yf8B5EI8sOi2UNGTS8oMuthV45tbt6OVUTSQwFPIEboZzhNJNKY+fPsg7hH8vixUWFZ3lrhw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/app-check-compat": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@firebase/app-check-compat/-/app-check-compat-0.3.25.tgz", + "integrity": "sha512-3zrsPZWAKfV7DVC20T2dgfjzjtQnSJS65OfMOiddMUtJL1S5i0nAZKsdX0bOEvvrd0SBIL8jYnfpfDeQRnhV3w==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check": "0.10.0", + "@firebase/app-check-types": "0.5.3", + "@firebase/component": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/app-check-interop-types": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-interop-types/-/app-check-interop-types-0.3.3.tgz", + "integrity": "sha512-gAlxfPLT2j8bTI/qfe3ahl2I2YcBQ8cFIBdhAQA4I2f3TndcO+22YizyGYuttLHPQEpWkhmpFW60VCFEPg4g5A==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-check-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/app-check-types/-/app-check-types-0.5.3.tgz", + "integrity": "sha512-hyl5rKSj0QmwPdsAxrI5x1otDlByQ7bvNvVt8G/XPO2CSwE++rmSVf3VEhaeOR4J8ZFaF0Z0NDSmLejPweZ3ng==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app-compat": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.4.1.tgz", + "integrity": "sha512-9VGjnY23Gc1XryoF/ABWtZVJYnaPOnjHM7dsqq9YALgKRtxI1FryvELUVkDaEIUf4In2bfkb9ZENF1S9M273Dw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app": "0.13.1", + "@firebase/component": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/app-types": { + "version": "0.9.3", + "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", + "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/app/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/@firebase/auth": { + "version": "1.10.7", + "resolved": "https://registry.npmjs.org/@firebase/auth/-/auth-1.10.7.tgz", + "integrity": "sha512-77o0aBKCfchdL1gkahARdawHyYefh+wRYn7o60tbwW6bfJNq2idbrRb3WSYCT4yBKWL0+9kKdwxBHPZ6DEiB+g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@react-native-async-storage/async-storage": "^1.18.1" + }, + "peerDependenciesMeta": { + "@react-native-async-storage/async-storage": { + "optional": true + } + } + }, + "node_modules/@firebase/auth-compat": { + "version": "0.5.27", + "resolved": "https://registry.npmjs.org/@firebase/auth-compat/-/auth-compat-0.5.27.tgz", + "integrity": "sha512-axZx/MgjNO7uPA8/nMQiuVotGCngUFMppt5w0pxFIoIPD0kac0bsFdSEh5S2ttuEE0Aq1iUB6Flzwn+wvMgXnQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth": "1.10.7", + "@firebase/auth-types": "0.13.0", + "@firebase/component": "0.6.17", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/auth-interop-types": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@firebase/auth-interop-types/-/auth-interop-types-0.2.4.tgz", + "integrity": "sha512-JPgcXKCuO+CWqGDnigBtvo09HeBs5u/Ktc2GaFj2m01hLarbxthLNm7Fk8iOP1aqAtXV+fnnGj7U28xmk7IwVA==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/auth-types": { + "version": "0.13.0", + "resolved": "https://registry.npmjs.org/@firebase/auth-types/-/auth-types-0.13.0.tgz", + "integrity": "sha512-S/PuIjni0AQRLF+l9ck0YpsMOdE8GO2KU6ubmBB7P+7TJUCQDa3R1dlgYm9UzGbbePMZsp0xzB93f2b/CgxMOg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/component": { + "version": "0.6.17", + "resolved": "https://registry.npmjs.org/@firebase/component/-/component-0.6.17.tgz", + "integrity": "sha512-M6DOg7OySrKEFS8kxA3MU5/xc37fiOpKPMz6cTsMUcsuKB6CiZxxNAvgFta8HGRgEpZbi8WjGIj6Uf+TpOhyzg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/data-connect": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@firebase/data-connect/-/data-connect-0.3.9.tgz", + "integrity": "sha512-B5tGEh5uQrQeH0i7RvlU8kbZrKOJUmoyxVIX4zLA8qQJIN6A7D+kfBlGXtSwbPdrvyaejcRPcbOtqsDQ9HPJKw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/database": { + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/@firebase/database/-/database-1.0.19.tgz", + "integrity": "sha512-khE+MIYK+XlIndVn/7mAQ9F1fwG5JHrGKaG72hblCC6JAlUBDd3SirICH6SMCf2PQ0iYkruTECth+cRhauacyQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "faye-websocket": "0.11.4", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-compat": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@firebase/database-compat/-/database-compat-2.0.10.tgz", + "integrity": "sha512-3sjl6oGaDDYJw/Ny0E5bO6v+KM3KoD4Qo/sAfHGdRFmcJ4QnfxOX9RbG9+ce/evI3m64mkPr24LlmTDduqMpog==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/database": "1.0.19", + "@firebase/database-types": "1.0.14", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/database-types": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/@firebase/database-types/-/database-types-1.0.14.tgz", + "integrity": "sha512-8a0Q1GrxM0akgF0RiQHliinhmZd+UQPrxEmUv7MnQBYfVFiLtKOgs3g6ghRt/WEGJHyQNslZ+0PocIwNfoDwKw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-types": "0.9.3", + "@firebase/util": "1.12.0" + } + }, + "node_modules/@firebase/firestore": { + "version": "4.7.17", + "resolved": "https://registry.npmjs.org/@firebase/firestore/-/firestore-4.7.17.tgz", + "integrity": "sha512-YhXWA7HlSnekExhZ5u4i0e+kpPxsh/qMrzeNDgsAva71JXK8OOuOx+yLyYBFhmu3Hr5JJDO2fsZA/wrWoQYHDg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "@firebase/webchannel-wrapper": "1.0.3", + "@grpc/grpc-js": "~1.9.0", + "@grpc/proto-loader": "^0.7.8", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/firestore-compat": { + "version": "0.3.52", + "resolved": "https://registry.npmjs.org/@firebase/firestore-compat/-/firestore-compat-0.3.52.tgz", + "integrity": "sha512-nzt3Sag+EBdm1Jkw/FnnKBPk0LpUUxOlMHMADPBXYhhXrLszxn1+vb64nJsbgRIHfsCn+rg8gyGrb+8frzXrjg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/firestore": "4.7.17", + "@firebase/firestore-types": "3.0.3", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/firestore-types": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@firebase/firestore-types/-/firestore-types-3.0.3.tgz", + "integrity": "sha512-hD2jGdiWRxB/eZWF89xcK9gF8wvENDJkzpVFb4aGkzfEaKxVRD1kjz1t1Wj8VZEp2LCB53Yx1zD8mrhQu87R6Q==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/functions": { + "version": "0.12.8", + "resolved": "https://registry.npmjs.org/@firebase/functions/-/functions-0.12.8.tgz", + "integrity": "sha512-p+ft6dQW0CJ3BLLxeDb5Hwk9ARw01kHTZjLqiUdPRzycR6w7Z75ThkegNmL6gCss3S0JEpldgvehgZ3kHybVhA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/app-check-interop-types": "0.3.3", + "@firebase/auth-interop-types": "0.2.4", + "@firebase/component": "0.6.17", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/functions-compat": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@firebase/functions-compat/-/functions-compat-0.3.25.tgz", + "integrity": "sha512-V0JKUw5W/7aznXf9BQ8LIYHCX6zVCM8Hdw7XUQ/LU1Y9TVP8WKRCnPB/qdPJ0xGjWWn7fhtwIYbgEw/syH4yTQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/functions": "0.12.8", + "@firebase/functions-types": "0.6.3", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/functions-types": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@firebase/functions-types/-/functions-types-0.6.3.tgz", + "integrity": "sha512-EZoDKQLUHFKNx6VLipQwrSMh01A1SaL3Wg6Hpi//x6/fJ6Ee4hrAeswK99I5Ht8roiniKHw4iO0B1Oxj5I4plg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/installations": { + "version": "0.6.17", + "resolved": "https://registry.npmjs.org/@firebase/installations/-/installations-0.6.17.tgz", + "integrity": "sha512-zfhqCNJZRe12KyADtRrtOj+SeSbD1H/K8J24oQAJVv/u02eQajEGlhZtcx9Qk7vhGWF5z9dvIygVDYqLL4o1XQ==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/util": "1.12.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/installations-compat": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/@firebase/installations-compat/-/installations-compat-0.2.17.tgz", + "integrity": "sha512-J7afeCXB7yq25FrrJAgbx8mn1nG1lZEubOLvYgG7ZHvyoOCK00sis5rj7TgDrLYJgdj/SJiGaO1BD3BAp55TeA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/installations": "0.6.17", + "@firebase/installations-types": "0.5.3", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/installations-types": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/@firebase/installations-types/-/installations-types-0.5.3.tgz", + "integrity": "sha512-2FJI7gkLqIE0iYsNQ1P751lO3hER+Umykel+TkLwHj6plzWVxqvfclPUZhcKFVQObqloEBTmpi2Ozn7EkCABAA==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x" + } + }, + "node_modules/@firebase/installations/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/@firebase/logger": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/@firebase/logger/-/logger-0.4.4.tgz", + "integrity": "sha512-mH0PEh1zoXGnaR8gD1DeGeNZtWFKbnz9hDO91dIml3iou1gpOnLqXQ2dJfB71dj6dpmUjcQ6phY3ZZJbjErr9g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/messaging": { + "version": "0.12.21", + "resolved": "https://registry.npmjs.org/@firebase/messaging/-/messaging-0.12.21.tgz", + "integrity": "sha512-bYJ2Evj167Z+lJ1ach6UglXz5dUKY1zrJZd15GagBUJSR7d9KfiM1W8dsyL0lDxcmhmA/sLaBYAAhF1uilwN0g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/installations": "0.6.17", + "@firebase/messaging-interop-types": "0.2.3", + "@firebase/util": "1.12.0", + "idb": "7.1.1", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/messaging-compat": { + "version": "0.2.21", + "resolved": "https://registry.npmjs.org/@firebase/messaging-compat/-/messaging-compat-0.2.21.tgz", + "integrity": "sha512-1yMne+4BGLbHbtyu/VyXWcLiefUE1+K3ZGfVTyKM4BH4ZwDFRGoWUGhhx+tKRX4Tu9z7+8JN67SjnwacyNWK5g==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/messaging": "0.12.21", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/messaging-interop-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/messaging-interop-types/-/messaging-interop-types-0.2.3.tgz", + "integrity": "sha512-xfzFaJpzcmtDjycpDeCUj0Ge10ATFi/VHVIvEEjDNc3hodVBQADZ7BWQU7CuFpjSHE+eLuBI13z5F/9xOoGX8Q==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/messaging/node_modules/idb": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz", + "integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==", + "license": "ISC" + }, + "node_modules/@firebase/performance": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/@firebase/performance/-/performance-0.7.6.tgz", + "integrity": "sha512-AsOz74dSTlyQGlnnbLWXiHFAsrxhpssPOsFFi4HgOJ5DjzkK7ZdZ/E9uMPrwFoXJyMVoybGRuqsL/wkIbFITsA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/installations": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0", + "web-vitals": "^4.2.4" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/performance-compat": { + "version": "0.2.19", + "resolved": "https://registry.npmjs.org/@firebase/performance-compat/-/performance-compat-0.2.19.tgz", + "integrity": "sha512-4cU0T0BJ+LZK/E/UwFcvpBCVdkStgBMQwBztM9fJPT6udrEUk3ugF5/HT+E2Z22FCXtIaXDukJbYkE/c3c6IHw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/performance": "0.7.6", + "@firebase/performance-types": "0.2.3", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/performance-types": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/@firebase/performance-types/-/performance-types-0.2.3.tgz", + "integrity": "sha512-IgkyTz6QZVPAq8GSkLYJvwSLr3LS9+V6vNPQr0x4YozZJiLF5jYixj0amDtATf1X0EtYHqoPO48a9ija8GocxQ==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/remote-config": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/@firebase/remote-config/-/remote-config-0.6.4.tgz", + "integrity": "sha512-ZyLJRT46wtycyz2+opEkGaoFUOqRQjt/0NX1WfUISOMCI/PuVoyDjqGpq24uK+e8D5NknyTpiXCVq5dowhScmg==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/installations": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/remote-config-compat": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-compat/-/remote-config-compat-0.2.17.tgz", + "integrity": "sha512-KelsBD0sXSC0u3esr/r6sJYGRN6pzn3bYuI/6pTvvmZbjBlxQkRabHAVH6d+YhLcjUXKIAYIjZszczd1QJtOyA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/logger": "0.4.4", + "@firebase/remote-config": "0.6.4", + "@firebase/remote-config-types": "0.4.0", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/remote-config-types": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@firebase/remote-config-types/-/remote-config-types-0.4.0.tgz", + "integrity": "sha512-7p3mRE/ldCNYt8fmWMQ/MSGRmXYlJ15Rvs9Rk17t8p0WwZDbeK7eRmoI1tvCPaDzn9Oqh+yD6Lw+sGLsLg4kKg==", + "license": "Apache-2.0" + }, + "node_modules/@firebase/storage": { + "version": "0.13.13", + "resolved": "https://registry.npmjs.org/@firebase/storage/-/storage-0.13.13.tgz", + "integrity": "sha512-E+MTNcBgpoAynicgVb2ZsHCuEOO4aAiUX5ahNwe/1dEyZpo2H4DwFqKQRNK/sdAIgBbjBwcfV2p0MdPFGIR0Ew==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app": "0.x" + } + }, + "node_modules/@firebase/storage-compat": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/@firebase/storage-compat/-/storage-compat-0.3.23.tgz", + "integrity": "sha512-B/ufkT/R/tSvc2av+vP6ZYybGn26FwB9YVDYg/6Bro+5TN3VEkCeNmfnX3XLa2DSdXUTZAdWCbMxW0povGa4MA==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/component": "0.6.17", + "@firebase/storage": "0.13.13", + "@firebase/storage-types": "0.8.3", + "@firebase/util": "1.12.0", + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "@firebase/app-compat": "0.x" + } + }, + "node_modules/@firebase/storage-types": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/@firebase/storage-types/-/storage-types-0.8.3.tgz", + "integrity": "sha512-+Muk7g9uwngTpd8xn9OdF/D48uiQ7I1Fae7ULsWPuKoCH3HU7bfFPhxtJYzyhjdniowhuDpQcfPmuNRAqZEfvg==", + "license": "Apache-2.0", + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "1.x" + } + }, + "node_modules/@firebase/util": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.12.0.tgz", + "integrity": "sha512-Z4rK23xBCwgKDqmzGVMef+Vb4xso2j5Q8OG0vVL4m4fA5ZjPMYQazu8OJJC3vtQRC3SQ/Pgx/6TPNVsCd70QRw==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@firebase/webchannel-wrapper": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@firebase/webchannel-wrapper/-/webchannel-wrapper-1.0.3.tgz", + "integrity": "sha512-2xCRM9q9FlzGZCdgDMJwc0gyUkWFtkosy7Xxr6sFgQwn+wMNIWd7xIvYNauU1r64B5L5rsGKy/n9TKJ0aAFeqQ==", + "license": "Apache-2.0" + }, + "node_modules/@grpc/grpc-js": { + "version": "1.9.15", + "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.9.15.tgz", + "integrity": "sha512-nqE7Hc0AzI+euzUwDAy0aY5hCp10r734gMGRdU+qOPX0XSceI2ULrcXB5U2xSc5VkWwalCj4M7GzCAygZl2KoQ==", + "license": "Apache-2.0", + "dependencies": { + "@grpc/proto-loader": "^0.7.8", + "@types/node": ">=12.12.47" + }, + "engines": { + "node": "^8.13.0 || >=10.10.0" + } + }, + "node_modules/@grpc/proto-loader": { + "version": "0.7.15", + "resolved": "https://registry.npmjs.org/@grpc/proto-loader/-/proto-loader-0.7.15.tgz", + "integrity": "sha512-tMXdRCfYVixjuFK+Hk0Q1s38gV9zDiDJfWL3h1rv4Qc39oILCu1TRTDt7+fGUI8K4G1Fj125Hx/ru3azECWTyQ==", + "license": "Apache-2.0", + "dependencies": { + "lodash.camelcase": "^4.3.0", + "long": "^5.0.0", + "protobufjs": "^7.2.5", + "yargs": "^17.7.2" + }, + "bin": { + "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/@grpc/proto-loader/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -5111,6 +5816,70 @@ "prettier": ">=2.4.0" } }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==", + "license": "BSD-3-Clause" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.16", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.16.tgz", @@ -9175,6 +9944,15 @@ ], "license": "CC-BY-4.0" }, + "node_modules/capacitor-secure-storage-plugin": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/capacitor-secure-storage-plugin/-/capacitor-secure-storage-plugin-0.12.0.tgz", + "integrity": "sha512-98rljshpX5uXdxUNc78mhq+nJGsf/hiaE1MfAkDH4s+Gqn6a2/VkxT7Iet5TskAZkc38b85adLljO/eVcrrzdg==", + "license": "MIT", + "peerDependencies": { + "@capacitor/core": ">=7.0.0" + } + }, "node_modules/chai": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/chai/-/chai-5.2.0.tgz", @@ -9404,7 +10182,6 @@ "version": "8.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", - "dev": true, "license": "ISC", "dependencies": { "string-width": "^4.2.0", @@ -12177,7 +12954,6 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12879,7 +13655,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -12892,7 +13667,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -13237,6 +14011,18 @@ "reusify": "^1.0.4" } }, + "node_modules/faye-websocket": { + "version": "0.11.4", + "resolved": "https://registry.npmjs.org/faye-websocket/-/faye-websocket-0.11.4.tgz", + "integrity": "sha512-CzbClwlXAuiRQAlUyfqPgvPoNKTckTPGfwZV4ZdAhVcP2lh9KUxJg2b5GkE7XbjKQ3YJnQ9z6D9ntLAlB+tP8g==", + "license": "Apache-2.0", + "dependencies": { + "websocket-driver": ">=0.5.1" + }, + "engines": { + "node": ">=0.8.0" + } + }, "node_modules/fd-slicer": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", @@ -13377,6 +14163,42 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/firebase": { + "version": "11.9.1", + "resolved": "https://registry.npmjs.org/firebase/-/firebase-11.9.1.tgz", + "integrity": "sha512-nbQbQxNlkHHRDn4cYwHdAKHwJPeZ0jRXxlNp6PCOb9CQx8Dc6Vjve97R34r1EZJnzOsPYZ3+ssJH7fkovDjvCw==", + "license": "Apache-2.0", + "dependencies": { + "@firebase/ai": "1.4.0", + "@firebase/analytics": "0.10.16", + "@firebase/analytics-compat": "0.2.22", + "@firebase/app": "0.13.1", + "@firebase/app-check": "0.10.0", + "@firebase/app-check-compat": "0.3.25", + "@firebase/app-compat": "0.4.1", + "@firebase/app-types": "0.9.3", + "@firebase/auth": "1.10.7", + "@firebase/auth-compat": "0.5.27", + "@firebase/data-connect": "0.3.9", + "@firebase/database": "1.0.19", + "@firebase/database-compat": "2.0.10", + "@firebase/firestore": "4.7.17", + "@firebase/firestore-compat": "0.3.52", + "@firebase/functions": "0.12.8", + "@firebase/functions-compat": "0.3.25", + "@firebase/installations": "0.6.17", + "@firebase/installations-compat": "0.2.17", + "@firebase/messaging": "0.12.21", + "@firebase/messaging-compat": "0.2.21", + "@firebase/performance": "0.7.6", + "@firebase/performance-compat": "0.2.19", + "@firebase/remote-config": "0.6.4", + "@firebase/remote-config-compat": "0.2.17", + "@firebase/storage": "0.13.13", + "@firebase/storage-compat": "0.3.23", + "@firebase/util": "1.12.0" + } + }, "node_modules/flat": { "version": "5.0.2", "resolved": "https://registry.npmjs.org/flat/-/flat-5.0.2.tgz", @@ -16587,7 +17409,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==", - "dev": true, "license": "MIT" }, "node_modules/lodash.clonedeep": { @@ -19201,6 +20022,36 @@ "dev": true, "license": "ISC" }, + "node_modules/protobufjs": { + "version": "7.5.3", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.5.3.tgz", + "integrity": "sha512-sildjKwVqOI2kmFDiXQ6aEB0fjYTafpEvIBs8tOR8qI4spuL9OPROLVu2qZqi/xgCfsHIwVqlaF8JBjWFHnKbw==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.4", + "@protobufjs/eventemitter": "^1.1.0", + "@protobufjs/fetch": "^1.1.0", + "@protobufjs/float": "^1.0.2", + "@protobufjs/inquire": "^1.1.0", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.0", + "@types/node": ">=13.7.0", + "long": "^5.0.0" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/protobufjs/node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, "node_modules/proxy-agent": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/proxy-agent/-/proxy-agent-6.5.0.tgz", @@ -24412,6 +25263,12 @@ "defaults": "^1.0.3" } }, + "node_modules/web-vitals": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", + "integrity": "sha512-r4DIlprAGwJ7YM11VZp4R884m0Vmgr6EAKe3P+kO0PPj3Unqyvv59rczf6UiGcb9Z8QxZVcqKNwv/g0WNdWwsw==", + "license": "Apache-2.0" + }, "node_modules/web3-core": { "version": "4.7.1", "resolved": "https://registry.npmjs.org/web3-core/-/web3-core-4.7.1.tgz", @@ -24797,6 +25654,28 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/websocket-driver": { + "version": "0.7.4", + "resolved": "https://registry.npmjs.org/websocket-driver/-/websocket-driver-0.7.4.tgz", + "integrity": "sha512-b17KeDIQVjvb0ssuSDF2cYXSg2iztliJ4B9WdsuB6J952qCPKmnVq4DyW5motImXHDC1cBT/1UezrJVsKw5zjg==", + "license": "Apache-2.0", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/websocket-driver/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", @@ -25519,7 +26398,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", - "dev": true, "license": "MIT", "dependencies": { "ansi-styles": "^4.0.0", @@ -25699,7 +26577,6 @@ "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", - "dev": true, "license": "ISC", "engines": { "node": ">=10" @@ -25736,7 +26613,6 @@ "version": "17.7.2", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", - "dev": true, "license": "MIT", "dependencies": { "cliui": "^8.0.1", @@ -25765,7 +26641,6 @@ "version": "21.1.1", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", - "dev": true, "license": "ISC", "engines": { "node": ">=12" diff --git a/package.json b/package.json index 50fa860af..d765791ff 100644 --- a/package.json +++ b/package.json @@ -41,8 +41,12 @@ "dependencies": { "@capacitor-community/file-opener": "^7.0.1", "@capacitor/android": "^7.3.0", + "@capacitor/app": "^7.0.1", "@capacitor/core": "^7.3.0", "@capacitor/filesystem": "^7.1.1", + "@capacitor/local-notifications": "^7.0.1", + "@capacitor/preferences": "^7.0.1", + "@capacitor/push-notifications": "^7.0.1", "@emoji-mart/data": "^1.2.1", "@klayr/codec": "^0.5.1", "@klayr/cryptography": "^4.1.1", @@ -62,6 +66,7 @@ "bitcoinjs-lib": "^6.1.7", "buffer": "^6.0.3", "bytebuffer": "^5.0.1", + "capacitor-secure-storage-plugin": "^0.12.0", "coininfo": "^5.2.1", "copy-to-clipboard": "^3.3.3", "core-js": "^3.40.0", @@ -74,6 +79,7 @@ "ed2curve": "^0.3.0", "emoji-mart": "^5.6.0", "file-saver": "^2.0.5", + "firebase": "^11.9.1", "hdkey": "^2.1.0", "https-browserify": "^1.0.0", "i": "^0.3.7", diff --git a/public/firebase/firebase-messaging-sw.js b/public/firebase/firebase-messaging-sw.js new file mode 100644 index 000000000..0d0ddaf59 --- /dev/null +++ b/public/firebase/firebase-messaging-sw.js @@ -0,0 +1,271 @@ +/* eslint-disable no-undef */ +importScripts('https://www.gstatic.com/firebasejs/9.22.1/firebase-app-compat.js') +importScripts('https://www.gstatic.com/firebasejs/9.22.1/firebase-messaging-compat.js') +importScripts('https://cdnjs.cloudflare.com/ajax/libs/tweetnacl/1.0.3/nacl.min.js') +importScripts('/js/ed2curve.min.js') + +const NOTIFICATION_TYPES = { + NO_NOTIFICATIONS: 0, + BACKGROUND_FETCH: 1, + PUSH: 2 +} + +// Checking double notifications +const processedEvents = new Set() +setInterval(() => processedEvents.clear(), 2 * 60 * 1000) // 2 minutes + +const firebaseConfig = { + apiKey: 'AIzaSyDgtB_hqwL1SS_YMYepRMmXYhmc7154wmU', + authDomain: 'adamant-messenger.firebaseapp.com', + databaseURL: 'https://adamant-messenger.firebaseio.com', + projectId: 'adamant-messenger', + storageBucket: 'adamant-messenger.appspot.com', + messagingSenderId: '987518845753', + appId: '1:987518845753:web:6585b11ca36bac4c251ee8' +} + +firebase.initializeApp(firebaseConfig) +const messaging = firebase.messaging() + +let privateKey = '' +let currentUserAddress = '' +let notificationSettings = { + type: 2, // Default: PUSH + initialized: false +} +const channel = new BroadcastChannel('adm_notifications') + +// Resolvers for settings initialization promises +let settingsInitializedResolvers = [] + +// Utillites for decoding +function hexToBytes(hex) { + const bytes = [] + for (let i = 0; i < hex.length; i += 2) { + bytes.push(parseInt(hex.substr(i, 2), 16)) + } + return Uint8Array.from(bytes) +} + +function decode(bytes) { + return new TextDecoder('utf-8').decode(bytes) +} + +// Adamant messages decrypting +function decryptMessage(transaction, privateKeyHex) { + try { + const chat = transaction.asset?.chat + if (!chat?.message || !transaction.senderPublicKey || !privateKeyHex) return null + + const { message, own_message } = chat + + const msgBytes = hexToBytes(message) + const nonceBytes = hexToBytes(own_message) + const senderKeyBytes = hexToBytes(transaction.senderPublicKey) + const privateKeyBytes = hexToBytes(privateKeyHex) + + const dhPublicKey = ed2curve.convertPublicKey(senderKeyBytes) + const dhSecretKey = ed2curve.convertSecretKey(privateKeyBytes) + + if (!dhPublicKey || !dhSecretKey) return null + + const decrypted = nacl.box.open(msgBytes, nonceBytes, dhPublicKey, dhSecretKey) + if (!decrypted) return null + + return decode(decrypted) + } catch (error) { + console.error('Decryption failed:', error) + return null + } +} + +async function isAppVisible() { + try { + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }) + return clients.some((client) => client.focused && client.visibilityState === 'visible') + } catch { + return false + } +} + +// Block firebase service notifications +const originalShowNotification = self.registration.showNotification.bind(self.registration) + +self.registration.showNotification = function (title, options) { + // Allow only our notifications with specific markers + if (options?.data?.type === 'push' && title === 'ADAMANT Messenger') { + return originalShowNotification(title, options) + } + + return Promise.resolve() // return promise as original functions returns promise +} + +// Sync settings witn the main app +channel.onmessage = (event) => { + const data = event.data + + if (data?.privateKey) { + privateKey = data.privateKey + } + + if (data?.clearPrivateKey) { + privateKey = '' + } + + if (data?.currentUserAddress) { + currentUserAddress = data.currentUserAddress + } + + if (data?.notificationType !== undefined) { + notificationSettings.type = data.notificationType + notificationSettings.initialized = true + + // Resolve all waiting promises + settingsInitializedResolvers.forEach((resolve) => resolve()) + settingsInitializedResolvers = [] + } +} + +function parseTransactionPayload(payload) { + if (!payload.data?.txn) { + return null + } + + try { + const transaction = JSON.parse(payload.data.txn) + return { + transaction, + eventId: transaction.id + } + } catch (error) { + console.warn('Invalid transaction JSON:', error) + return null + } +} + +function isEventProcessed(eventId) { + if (!eventId || processedEvents.has(eventId)) { + return true + } + processedEvents.add(eventId) + return false +} + +function waitForSettingsEvent() { + return new Promise((resolve) => { + settingsInitializedResolvers.push(resolve) + + // Timeout fallback + setTimeout(() => { + const index = settingsInitializedResolvers.indexOf(resolve) + if (index !== -1) { + settingsInitializedResolvers.splice(index, 1) + resolve() + } + }, 3000) + }) +} + +async function ensureSettingsInitialized() { + if (notificationSettings.initialized) { + return + } + + await waitForSettingsEvent() + + if (!notificationSettings.initialized) { + notificationSettings.initialized = true + } +} + +function isPushEnabled() { + return notificationSettings.type === NOTIFICATION_TYPES.PUSH +} + +messaging.onBackgroundMessage(async (payload) => { + const parsed = parseTransactionPayload(payload) + if (!parsed) return + + const { transaction, eventId } = parsed + + if (isEventProcessed(eventId)) return + + await ensureSettingsInitialized() + + if (!isPushEnabled() || (await isAppVisible())) { + return + } + + const senderId = transaction.senderId + const recipientId = transaction.recipientId + const transactionId = transaction.id + const senderName = payload.notification?.title || transaction.senderId + let messageText = `New message from ${senderName} (open app to decrypt)` + + const decryptedMessage = decryptMessage(transaction, privateKey) + if (decryptedMessage) { + const partnerTitle = payload.notification?.title || senderId + messageText = `${partnerTitle}: ${decryptedMessage}` + } + + const notificationOptions = { + body: messageText, + icon: '/img/icons/android-chrome-192x192.png', + tag: `adamant-push-${senderId}`, + badge: '/img/icons/android-chrome-192x192.png', + renotify: true, + data: { senderId, recipientId, transactionId, type: 'push' } + } + + try { + await self.registration.showNotification('ADAMANT Messenger', notificationOptions) + } catch (error) { + console.error('Failed to show notification:', error) + } +}) + +// Handling notification click +self.addEventListener('notificationclick', (event) => { + event.notification.close() + + const data = event.notification.data + + event.waitUntil( + (async () => { + try { + // Block click if notification is not for current user + if (data?.recipientId !== currentUserAddress && currentUserAddress) { + return + } + + const clients = await self.clients.matchAll({ type: 'window', includeUncontrolled: true }) + + // Set focus to the existing window + const existingClient = clients.find((client) => client.url.includes(self.location.origin)) + if (existingClient) { + await existingClient.focus() + + if (data?.senderId) { + existingClient.postMessage({ + action: 'OPEN_CHAT', + partnerId: data.senderId, + transactionId: data.transactionId, + fromNotification: true + }) + } + return + } + + // Open a new window (always root as user will need to login) + await self.clients.openWindow('/') + } catch (error) { + console.error('Click handler error:', error) + } + })() + ) +}) + +// Fetch notifications during the app starting +setTimeout(() => { + channel.postMessage({ requestCurrentSettings: true }) +}, 1000) diff --git a/public/js/ed2curve.min.js b/public/js/ed2curve.min.js new file mode 100644 index 000000000..35eaca64e --- /dev/null +++ b/public/js/ed2curve.min.js @@ -0,0 +1 @@ +!function(r,n){"use strict";"undefined"!=typeof module&&module.exports?module.exports=n(require("tweetnacl")):r.ed2curve=n(r.nacl)}(this,function(e){"use strict";if(!e)throw new Error("tweetnacl not loaded");var l=function(r){var n,o=new Float64Array(16);if(r)for(n=0;n>16&1),f[o-1]&=65535;f[15]=u[15]-32767-(f[14]>>16&1),e=f[15]>>16&1,f[14]&=65535,c(u,f,1-e)}for(o=0;o<16;o++)r[2*o]=255&u[o],r[2*o+1]=u[o]>>8}function f(r,n,o,t){return function(r,n,o,t,e){var f,u=0;for(f=0;f>>8)-1}(r,n,o,t,32)}function b(r,n){var o=new Uint8Array(32),t=new Uint8Array(32);return U(o,r),U(t,n),f(o,0,t,0)}function u(r,n){var o,t,e=l(),f=l(),u=l(),i=l(),c=l(),a=l(),v=l();if(!function(r,n){var o;for(o=0;o<16;o++)r[o]=0|n[o]}(r[2],w),function(r,n){var o;for(o=0;o<16;o++)r[o]=n[2*o]+(n[2*o+1]<<8);r[15]&=32767}(r[1],n),p(u,r[1]),h(i,u,s),K(u,u,r[2]),A(i,r[2],i),p(c,i),p(a,c),h(v,a,c),h(e,v,u),h(e,e,i),function(r,n){var o,t=l();for(o=0;o<16;o++)t[o]=n[o];for(o=250;0<=o;o--)p(t,t),1!==o&&h(t,t,n);for(o=0;o<16;o++)r[o]=t[o]}(e,e),h(e,e,u),h(e,e,i),h(e,e,i),h(r[0],e,i),p(f,r[0]),h(f,f,i),b(f,u)&&h(r[0],r[0],d),p(f,r[0]),h(f,f,i),b(f,u))return 1;o=r[0],U(t=new Uint8Array(32),o),(1&t[0])==n[31]>>7&&K(r[0],y,r[0]),h(r[3],r[0],r[1])}function o(r){var n=new Uint8Array(32),o=[l(),l(),l(),l()],t=l(),e=l();if(u(o,r))return null;var f=o[1];return A(t,w,f),K(e,w,f),function(r,n){var o,t=l();for(o=0;o<16;o++)t[o]=n[o];for(o=253;0<=o;o--)p(t,t),2!==o&&4!==o&&h(t,t,n);for(o=0;o<16;o++)r[o]=t[o]}(e,e),h(t,t,e),U(n,t),n}function t(r){var n,o=new Uint8Array(64),t=new Uint8Array(32);for(e.lowlevel.crypto_hash(o,r,32),o[0]&=248,o[31]&=127,o[31]|=64,n=0;n<32;n++)t[n]=o[n];for(n=0;n<64;n++)o[n]=0;return t}return{convertPublicKey:o,convertSecretKey:t,convertKeyPair:function(r){var n=o(r.publicKey);return n?{publicKey:n,secretKey:t(r.secretKey)}:null}}}); \ No newline at end of file diff --git a/release-notes.md b/release-notes.md index 8d9498653..9c0a261c3 100644 --- a/release-notes.md +++ b/release-notes.md @@ -5,11 +5,14 @@ All notable changes in this release are listed below. ## [Unreleased] ### New Features + - Test screen for developers is now available [#815](https://github.com/Adamant-im/adamant-im/pull/815) — [@Linhead](https://github.com/Linhead) - Universal macOS build added [#840](https://github.com/Adamant-im/adamant-im/pull/840) — [@S-FrontendDev](https://github.com/S-FrontendDev) - Release notes file added [#853](https://github.com/Adamant-im/adamant-im/pull/853) — [@S-FrontendDev](https://github.com/S-FrontendDev) +- Push notifications added [#802](https://github.com/Adamant-im/adamant-im/pull/802) - [@Linhead](https://github.com/Linhead) ### Improvements + - APK name changed in GitHub workflow [#839](https://github.com/Adamant-im/adamant-im/pull/839) — [@S-FrontendDev](https://github.com/S-FrontendDev) - Wallets UI updated for better usability [#846](https://github.com/Adamant-im/adamant-im/pull/846) — [@Linhead](https://github.com/Linhead), [@adamant-al](https://github.com/adamant-al) - ESLint updated to improve code quality [#849](https://github.com/Adamant-im/adamant-im/pull/849) — [@graycraft](https://github.com/graycraft) @@ -18,6 +21,7 @@ All notable changes in this release are listed below. - Updated wallets generating script [#864](https://github.com/Adamant-im/adamant-im/pull/864) — [@Linhead](https://github.com/Linhead) ### Bug Fixes + - Transaction fee calculation for ETH & ERC20 fixed [#805](https://github.com/Adamant-im/adamant-im/pull/805) — [@Linhead](https://github.com/Linhead) - Layout issue on "Export keys" page fixed [#841](https://github.com/Adamant-im/adamant-im/pull/841) — [@kalpovskii](https://github.com/kalpovskii), [@adamant-al](https://github.com/adamant-al) - Add disabled input field in the Welcome to ADAMANT chat, impoved paddings [#842](https://github.com/Adamant-im/adamant-im/pull/842) — [@kalpovskii](https://github.com/kalpovskii) diff --git a/src/App.vue b/src/App.vue index 9f9445802..f02a15e9d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -20,9 +20,11 @@ import { useStore } from 'vuex' import { useI18n } from 'vue-i18n' import { useResendPendingMessages } from '@/hooks/useResendPendingMessages' import { useTrackConnection } from '@/hooks/useTrackConnection' +import { usePushNotificationSetup } from '@/hooks/pushNotifications/usePushNotificationSetup' useResendPendingMessages() useTrackConnection() +usePushNotificationSetup() const store = useStore() const isSnackbarShowing = computed(() => store.state.snackbar.show) @@ -41,22 +43,24 @@ onMounted(() => { const instance = getCurrentInstance() if (instance) { - const notifications = new Notifications(instance.proxy) - notifications.start() + const notificationsInstance = new Notifications(instance.proxy) + notifications.value = notificationsInstance + notificationsInstance.start() } + + window.addEventListener('keydown', onKeydownHandler, true) }) onBeforeUnmount(() => { notifications.value?.stop() store.dispatch('stopInterval') + window.removeEventListener('keydown', onKeydownHandler, true) }) const onKeydownHandler = (e: KeyboardEvent) => { - if (e.key === 'Escape') { - if (isSnackbarShowing.value) { - e.stopPropagation() - store.commit('snackbar/changeState', false) - } + if (e.key === 'Escape' && isSnackbarShowing.value) { + e.stopPropagation() + store.commit('snackbar/changeState', false) } } @@ -71,14 +75,6 @@ const setLocale = () => { dayjs.locale(localeFromStorage) } -onMounted(() => { - window.addEventListener('keydown', onKeydownHandler, true) -}) - -onBeforeUnmount(() => { - window.removeEventListener('keydown', onKeydownHandler, true) -}) - setLocale() diff --git a/src/components/Chat/Chat.vue b/src/components/Chat/Chat.vue index 1d19f7587..3b2011a86 100644 --- a/src/components/Chat/Chat.vue +++ b/src/components/Chat/Chat.vue @@ -490,6 +490,34 @@ watchImmediate(messages, (updatedMessages) => { } }) +// Watch for query changes when chat is already open (handles notification clicks) +watch( + () => router.currentRoute.value.query, + async (query) => { + if (query.scrollToMessage || query.scrollToBottom) { + await handleScrollFromQuery() + } + } +) + +const handleScrollFromQuery = async () => { + const query = router.currentRoute.value.query + if (query.scrollToMessage) { + const transactionId = query.scrollToMessage as string + await nextTick() + const transactionIndex = store.getters['chat/indexOfMessage'](props.partnerId, transactionId) + if (transactionIndex !== -1) { + await chatRef.value.scrollToMessageEasy(transactionIndex) + highlightMessage(transactionId) + } + router.replace({ query: { ...query, scrollToMessage: undefined, scrollToBottom: undefined } }) + } else if (query.scrollToBottom === 'true') { + await nextTick() + chatRef.value.scrollToBottom() + router.replace({ query: { ...query, scrollToBottom: undefined } }) + } +} + onBeforeMount(() => { const cachedMessages: NormalizedChatMessageTransaction[] = store.getters['chat/messages']( props.partnerId @@ -528,6 +556,9 @@ onMounted(async () => { if (draftMessage) { replyMessageId.value = draftMessage } + + // Handle scroll from push notification + await handleScrollFromQuery() }) onBeforeUnmount(() => { window.removeEventListener('keydown', onKeyPress) diff --git a/src/firebase.ts b/src/firebase.ts new file mode 100644 index 000000000..6e260ad74 --- /dev/null +++ b/src/firebase.ts @@ -0,0 +1,25 @@ +import { initializeApp } from 'firebase/app' +import { getMessaging } from 'firebase/messaging' +import { getId, getInstallations } from 'firebase/installations' +import { firebaseConfig } from '@/lib/firebase-config' + +const firebaseApp = initializeApp(firebaseConfig) + +let fcm: ReturnType | null = null + +if (typeof navigator !== 'undefined' && 'serviceWorker' in navigator) { + try { + fcm = getMessaging(firebaseApp) + } catch (error) { + console.warn('Firebase Messaging not supported:', error) + fcm = null + } +} else { + console.warn('Service Workers not supported - Firebase Messaging unavailable') +} + +async function getDeviceId(): Promise { + return await getId(getInstallations(firebaseApp)) +} + +export { firebaseApp, fcm, getDeviceId } diff --git a/src/hooks/pushNotifications/useAndroidPushNotifications.ts b/src/hooks/pushNotifications/useAndroidPushNotifications.ts new file mode 100644 index 000000000..6238a7471 --- /dev/null +++ b/src/hooks/pushNotifications/useAndroidPushNotifications.ts @@ -0,0 +1,53 @@ +import { computed } from 'vue' +import { Capacitor } from '@capacitor/core' + +export function useAndroidPushNotifications() { + const isSupported = computed(() => Capacitor.getPlatform() === 'android') + + /** + * Gets reference to pushService (always AndroidPushService on Android) + */ + const getPushService = async () => { + if (!isSupported.value) return null + + const { pushService } = await import('@/lib/notifications/pushServiceFactory') + return pushService + } + + /** + * Sets private key - proxy to pushService.setPrivateKey() + */ + const setPrivateKey = async (key: string): Promise => { + if (!isSupported.value) return false + + const service = await getPushService() + if (service) { + service.setPrivateKey(key) + return true + } + + return false + } + + /** + * Clears private key - proxy to pushService.clearPrivateKey() + */ + const clearPrivateKey = async (): Promise => { + if (!isSupported.value) return false + + const service = await getPushService() + if (service) { + service.clearPrivateKey() + return true + } + + return false + } + + return { + isSupported, + setPrivateKey, + clearPrivateKey, + getPushService + } +} diff --git a/src/hooks/pushNotifications/usePrivateKeyManager.ts b/src/hooks/pushNotifications/usePrivateKeyManager.ts new file mode 100644 index 000000000..13e69fb64 --- /dev/null +++ b/src/hooks/pushNotifications/usePrivateKeyManager.ts @@ -0,0 +1,81 @@ +import { useStore } from 'vuex' +import { Capacitor } from '@capacitor/core' +import { useWebPushNotifications } from './useWebPushNotifications' +import { useAndroidPushNotifications } from './useAndroidPushNotifications' + +/** + * Composable for managing private key across platforms + * Handles sending/clearing private keys to both Service Worker and Push Services + */ +export function usePrivateKeyManager() { + const store = useStore() + const platform = Capacitor.getPlatform() + + // Platform-specific composables + const webPush = platform === 'web' ? useWebPushNotifications() : null + const androidPush = platform === 'android' ? useAndroidPushNotifications() : null + + /** + * Gets private key from store + */ + const getPrivateKey = async (): Promise => { + try { + return await store.dispatch('getPrivateKeyForPush') + } catch (error) { + console.error('Error getting private key:', error) + return null + } + } + + /** + * Sends private key to appropriate platform handlers + */ + const sendPrivateKey = async (): Promise => { + const privateKey = await getPrivateKey() + if (!privateKey) return false + + if (webPush) { + return webPush.setPrivateKey(privateKey) + } + if (androidPush) { + return await androidPush.setPrivateKey(privateKey) + } + + return false + } + + /** + * Clears private key from appropriate platform handlers + */ + const clearPrivateKey = async (): Promise => { + if (webPush) { + return webPush.clearPrivateKey() + } + if (androidPush) { + return await androidPush.clearPrivateKey() + } + + return false + } + + /** + * Syncs notification settings (web only) + */ + const syncNotificationSettings = (type: number) => { + if (webPush) { + const currentUserAddress = store.state.address + webPush.syncNotificationSettings({ + type, + currentUserAddress + }) + } + // Android settings are managed through pushService directly + } + + return { + getPrivateKey, + sendPrivateKey, + clearPrivateKey, + syncNotificationSettings + } +} diff --git a/src/hooks/pushNotifications/usePushEventHandlers.ts b/src/hooks/pushNotifications/usePushEventHandlers.ts new file mode 100644 index 000000000..3a6a3af5b --- /dev/null +++ b/src/hooks/pushNotifications/usePushEventHandlers.ts @@ -0,0 +1,63 @@ +import { onMounted, onBeforeUnmount } from 'vue' +import { useRouter } from 'vue-router' + +/** + * Composable for handling push notification events and navigation + */ +export function usePushEventHandlers() { + const router = useRouter() + + /** + * Opens chat with optional scroll to message + */ + const openChatWithScroll = (partnerId: string, transactionId?: string) => { + router.push({ + name: 'Chat', + params: { partnerId }, + query: transactionId ? { scrollToMessage: transactionId } : { scrollToBottom: 'true' } + }) + } + + /** + * Handles opening chat from notification click (Android Capacitor) + */ + const handleOpenChat = (event: Event) => { + const detail = (event as CustomEvent).detail + if (detail?.partnerId) { + openChatWithScroll(detail.partnerId, detail.transactionId) + } + } + + /** + * Handles messages from Service Worker (Web PWA) + */ + const handleServiceWorkerMessage = (event: MessageEvent) => { + if (!event.data) return + + const { action, partnerId, transactionId } = event.data + + if (action === 'OPEN_CHAT' && partnerId) { + openChatWithScroll(partnerId, transactionId) + window.focus() + } + } + + /** + * Sets up event listeners + */ + const setupEventListeners = () => { + window.addEventListener('openChat', handleOpenChat) + navigator.serviceWorker?.addEventListener('message', handleServiceWorkerMessage) + } + + /** + * Removes event listeners + */ + const removeEventListeners = () => { + window.removeEventListener('openChat', handleOpenChat) + navigator.serviceWorker?.removeEventListener('message', handleServiceWorkerMessage) + } + + onMounted(setupEventListeners) + onBeforeUnmount(removeEventListeners) +} diff --git a/src/hooks/pushNotifications/usePushNotificationSetup.ts b/src/hooks/pushNotifications/usePushNotificationSetup.ts new file mode 100644 index 000000000..bd88d8283 --- /dev/null +++ b/src/hooks/pushNotifications/usePushNotificationSetup.ts @@ -0,0 +1,172 @@ +import { ref, computed, watch, onMounted, readonly } from 'vue' +import { useStore } from 'vuex' +import { useI18n } from 'vue-i18n' +import { Capacitor } from '@capacitor/core' +import { NotificationType } from '@/lib/constants' +import { usePrivateKeyManager } from './usePrivateKeyManager' +import { usePushEventHandlers } from './usePushEventHandlers' +import { useWebPushNotifications } from './useWebPushNotifications' + +export function usePushNotificationSetup() { + const store = useStore() + const { t } = useI18n() + const platform = Capacitor.getPlatform() + const registrationInProgress = ref(false) + + usePushEventHandlers() + const { clearPrivateKey, getPrivateKey, sendPrivateKey, syncNotificationSettings } = + usePrivateKeyManager() + const webPush = platform === 'web' ? useWebPushNotifications() : null + + const isPushNotification = computed(() => { + return store.state.options.allowNotificationType === NotificationType['Push'] + }) + + /** + * Handles BroadcastChannel messages from Service Worker + */ + const handleChannelMessage = (event: MessageEvent) => { + const { data } = event + + if (data?.requestCurrentSettings) { + sendCurrentSettings() + } + } + + /** + * Sends current settings to Service Worker + */ + const sendCurrentSettings = async () => { + const currentNotificationType = store.state.options.allowNotificationType + const privateKey = store.state.passphrase ? await getPrivateKey() : undefined + const currentUserAddress = store.state.address + + const settings = { + type: currentNotificationType, + privateKey: privateKey || undefined, + currentUserAddress + } + + if (webPush) { + webPush.syncNotificationSettings(settings) + } + } + + /** + * Registers push notifications with retry logic + */ + const registerWithRetry = async (maxRetries = 2) => { + if (registrationInProgress.value) return + + registrationInProgress.value = true + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + await registerPushNotificationsOnLogin() + registrationInProgress.value = false + return + } catch (error) { + console.warn(`Push registration attempt ${attempt + 1} failed:`, error) + + if (attempt === maxRetries - 1) { + registrationInProgress.value = false + + store.dispatch('snackbar/show', { + message: t('options.push_register_retry_failed'), + timeout: 5000 + }) + return + } + + await new Promise((resolve) => setTimeout(resolve, 2000)) + } + } + } + + /** + * Registers push notifications on login + */ + const registerPushNotificationsOnLogin = async () => { + if (!isPushNotification.value) return + + const privateKey = await getPrivateKey() + if (!privateKey) return + + if (Capacitor.isNativePlatform()) { + const { pushService } = await import('@/lib/notifications/pushServiceFactory') + pushService.setPrivateKey(privateKey) + await pushService.initialize() + await pushService.registerDevice() + } else { + const { pushService } = await import('@/lib/notifications/pushServiceFactory') + pushService.setPrivateKey(privateKey) + await sendPrivateKey() + } + } + + /** + * Sets up watchers for automatic registration and settings sync + */ + const setupWatchers = () => { + // Auto-register on login + watch( + () => store.state.passphrase, + async (encodedPassphrase, oldPassphrase) => { + if (encodedPassphrase && !oldPassphrase) { + await registerWithRetry() + } + } + ) + + // Sync settings when notification type changes + watch( + () => store.state.options.allowNotificationType, + async (newType, oldType) => { + if (newType !== oldType) { + syncNotificationSettings(newType) + + // Clear key if push disabled + if (oldType === NotificationType['Push'] && newType !== NotificationType['Push']) { + await clearPrivateKey() + } + } + } + ) + + // Clear key on logout + watch( + () => store.state.passphrase, + async (newPassphrase, oldPassphrase) => { + if (!newPassphrase && oldPassphrase) { + await clearPrivateKey() + } + } + ) + } + + /** + * Initialize the composable + */ + const initialize = () => { + if (webPush) { + webPush.setMessageHandler(handleChannelMessage) + // Send settings when Service Worker is ready + if ('serviceWorker' in navigator) { + navigator.serviceWorker.ready + .then(() => sendCurrentSettings()) + .catch((err) => console.warn('[Push] Failed to send initial settings:', err)) + } + } + setupWatchers() + } + + onMounted(initialize) + + return { + sendPrivateKeyToFirebaseSW: sendPrivateKey, + clearPrivateKeyFromSW: clearPrivateKey, + isPushNotification, + registrationInProgress: readonly(registrationInProgress), + syncNotificationSettings: syncNotificationSettings + } +} diff --git a/src/hooks/pushNotifications/useWebPushNotifications.ts b/src/hooks/pushNotifications/useWebPushNotifications.ts new file mode 100644 index 000000000..9ec055294 --- /dev/null +++ b/src/hooks/pushNotifications/useWebPushNotifications.ts @@ -0,0 +1,162 @@ +import { ref, onMounted, onBeforeUnmount } from 'vue' + +export interface WebNotificationSettings { + type: number + privateKey?: string + currentUserAddress?: string +} + +export function useWebPushNotifications() { + const channel = ref(null) + const isSupported = typeof BroadcastChannel !== 'undefined' + + /** + * Closes the broadcast channel in case of error for preventing memory leaks + */ + const safeCloseChannel = () => { + if (channel.value) { + try { + channel.value.close() + } catch (error) { + console.warn('[Web Push] Error closing BroadcastChannel:', error) + } finally { + channel.value = null + } + } + } + + /** + * Gets reference to pushService (always WebPushService on web) + */ + const getPushService = async () => { + if (!isSupported) return null + + const { pushService } = await import('@/lib/notifications/pushServiceFactory') + return pushService + } + + /** + * Creates BroadcastChannel for Service Worker communication + */ + const setupChannel = () => { + if (!isSupported) return + + try { + channel.value = new BroadcastChannel('adm_notifications') + } catch (error) { + console.error('[Web Push] Failed to create BroadcastChannel:', error) + channel.value = null + } + } + + /** + * Sends private key to Service Worker via BroadcastChannel + */ + const sendPrivateKeyToSW = (privateKey: string): boolean => { + if (!channel.value) return false + + try { + channel.value.postMessage({ privateKey }) + return true + } catch (error) { + console.error('[Web Push] Failed to send private key to SW:', error) + safeCloseChannel() + return false + } + } + + /** + * Sets private key both in Service Worker and PushService + */ + const setPrivateKey = async (privateKey: string): Promise => { + if (!isSupported) return false + + // Send to Service Worker + const swResult = sendPrivateKeyToSW(privateKey) + + // Send to PushService + const service = await getPushService() + if (service) { + service.setPrivateKey(privateKey) + } + + return swResult + } + + /** + * Clears private key from both Service Worker and PushService + */ + const clearPrivateKey = async (): Promise => { + if (!channel.value) return false + + try { + // Clear from Service Worker + channel.value.postMessage({ clearPrivateKey: true }) + + // Clear from PushService + const service = await getPushService() + if (service) { + service.clearPrivateKey() + } + + return true + } catch (error) { + console.error('[Web Push] Failed to clear private key:', error) + safeCloseChannel() + return false + } + } + + /** + * Syncs notification settings with Service Worker + */ + const syncNotificationSettings = (settings: WebNotificationSettings): boolean => { + if (!channel.value) return false + + try { + channel.value.postMessage({ + notificationType: settings.type, + currentUserAddress: settings.currentUserAddress + }) + + if (settings.privateKey) { + sendPrivateKeyToSW(settings.privateKey) + } + + return true + } catch (error) { + console.error('[Web Push] Failed to sync settings:', error) + safeCloseChannel() + return false + } + } + + /** + * Sets message handler for Service Worker communication + */ + const setMessageHandler = (handler: (event: MessageEvent) => void): boolean => { + if (!channel.value) return false + + try { + channel.value.onmessage = handler + return true + } catch (error) { + console.error('[Web Push] Failed to set message handler:', error) + safeCloseChannel() + return false + } + } + + onMounted(setupChannel) + + onBeforeUnmount(safeCloseChannel) + + return { + isSupported, + setPrivateKey, + clearPrivateKey, + syncNotificationSettings, + setMessageHandler, + getPushService + } +} diff --git a/src/lib/adamant-api/asset.ts b/src/lib/adamant-api/asset.ts index 8b3b2fac2..31ec38c20 100644 --- a/src/lib/adamant-api/asset.ts +++ b/src/lib/adamant-api/asset.ts @@ -239,3 +239,12 @@ export function attachmentAsset( storage: { id: 'ipfs' } } } + +export function signalAsset(deviceId: string, token: string, provider: string, action: string) { + return { + deviceId, + token, + provider, + action + } +} diff --git a/src/lib/adamant-api/index.d.ts b/src/lib/adamant-api/index.d.ts index d123ffe94..9ea0f7d9c 100644 --- a/src/lib/adamant-api/index.d.ts +++ b/src/lib/adamant-api/index.d.ts @@ -107,7 +107,8 @@ export function encodeFile(file: Uint8Array, params: SendMessageParams): Promise export function sendSpecialMessage( to: string, - message: SendMessageParams['message'] + message: SendMessageParams['message'], + messageType: number ): ReturnType export function storeValue( @@ -200,3 +201,5 @@ export function getChatRoomMessages( params: GetChatRoomMessagesParams, recursive: boolean = false ): Promise>> + +export function getMyPrivateKey(): string diff --git a/src/lib/adamant-api/index.js b/src/lib/adamant-api/index.js index 1f66ec292..139e5a763 100644 --- a/src/lib/adamant-api/index.js +++ b/src/lib/adamant-api/index.js @@ -1,7 +1,13 @@ import Queue from 'promise-queue' import { Base64 } from 'js-base64' -import constants, { Transactions, Delegates, MessageType } from '@/lib/constants' +import constants, { + Transactions, + Delegates, + MessageType, + ADAMANT_NOTIFICATION_SERVICE_ADDRESS, + ADAMANT_NOTIFICATION_SERVICE_PUBLIC_KEY +} from '@/lib/constants' import utils from '@/lib/adamant' import client from '@/lib/nodes/adm' import { encryptPassword } from '@/lib/idb/crypto' @@ -235,9 +241,21 @@ export async function encodeFile(file, params) { * Sends special message with the specified payload * @param {string} to recipient address * @param {object} payload message payload + * @param {number} messageType message type */ -export function sendSpecialMessage(to, payload) { - return sendMessage({ to, message: payload, type: MessageType.RICH_CONTENT_MESSAGE }) +export async function sendSpecialMessage(to, payload, messageType) { + // Cache ANS public key to avoid redundant API calls + if (to === ADAMANT_NOTIFICATION_SERVICE_ADDRESS) { + store.commit('setPublicKey', { + adamantAddress: to, + publicKey: ADAMANT_NOTIFICATION_SERVICE_PUBLIC_KEY + }) + } else { + // For other addresses, fetch public key from API + await getPublicKey(to) + } + + return sendMessage({ to, message: payload, type: messageType }) } /** @@ -796,3 +814,10 @@ export async function getChatRoomMessages(address1, address2, paramsArg, recursi return loadMessages(lastOffset) } + +export function getMyPrivateKey() { + if (!myKeypair || !myKeypair.privateKey) { + return '' + } + return myKeypair.privateKey.toString('hex') +} diff --git a/src/lib/constants/index.ts b/src/lib/constants/index.ts index ed36be411..e208ce1b6 100644 --- a/src/lib/constants/index.ts +++ b/src/lib/constants/index.ts @@ -252,3 +252,14 @@ export const REACT_EMOJIS = { } as const export const sidebarLayoutKey = Symbol('sidebarLayout') + +export const ADAMANT_NOTIFICATION_SERVICE_ADDRESS = 'U922832474468487910' +export const ADAMANT_NOTIFICATION_SERVICE_PUBLIC_KEY = + 'e62976484f30862e26765ad98a5835fbccc43a50de59c56bd2e0b4989ee2e0af' +export const VAPID_KEY = + 'BOUaH-qBAFhcEzR3sETwqJDDP-WjWShYr3NAXFQwHTXT0ZIQirLuTTgL7U20kAzsFD5FE4nvTWbF1iSiAMGnyiY' +export const NotificationType = { + NoNotifications: 0, + BackgroundFetch: 1, + Push: 2 +} as const diff --git a/src/lib/firebase-config.ts b/src/lib/firebase-config.ts new file mode 100644 index 000000000..d99914b2f --- /dev/null +++ b/src/lib/firebase-config.ts @@ -0,0 +1,9 @@ +export const firebaseConfig = { + apiKey: 'AIzaSyDgtB_hqwL1SS_YMYepRMmXYhmc7154wmU', + authDomain: 'adamant-messenger.firebaseapp.com', + databaseURL: 'https://adamant-messenger.firebaseio.com', + projectId: 'adamant-messenger', + storageBucket: 'adamant-messenger.appspot.com', + messagingSenderId: '987518845753', + appId: '1:987518845753:web:6585b11ca36bac4c251ee8' +} diff --git a/src/lib/notifications.js b/src/lib/notifications.js index 9014a39e9..2994c49ae 100644 --- a/src/lib/notifications.js +++ b/src/lib/notifications.js @@ -6,6 +6,7 @@ import currency from '@/filters/currencyAmountWithSymbol' import { formatMessageBasic } from '@/lib/markdown' import { isAdamantChat } from '@/lib/chat/meta/utils' import { joinUrl } from '@/lib/urlFormatter.js' +import { NotificationType } from '@/lib/constants' let _this @@ -40,8 +41,8 @@ class Notification { return isAdmChat ? this.i18n.t(name) : name } - get pushAllowed() { - return this.store.state.options.allowPushNotifications + get bgFetchNotificationAllowed() { + return this.store.state.options.allowNotificationType === NotificationType['BackgroundFetch'] } get soundAllowed() { @@ -91,6 +92,9 @@ class PushNotification extends Notification { } notify(messageArrived) { + if (!this.bgFetchNotificationAllowed) { + return + } try { Notify.requestPermission( // Permission granted @@ -103,7 +107,7 @@ class PushNotification extends Notification { const notification = new Notify(this.i18n.t('app_title'), { body: this.messageBody, closeOnClick: true, - icon: joinUrl(import.meta.env.BASE_URL,'/img/icons/android-chrome-192x192.png'), + icon: joinUrl(import.meta.env.BASE_URL, '/img/icons/android-chrome-192x192.png'), notifyClick: () => { if (_this.$route.name !== 'Chat') { this.router.push({ @@ -128,8 +132,8 @@ class PushNotification extends Notification { message: this.i18n.t('options.push_denied') }) this.store.commit('options/updateOption', { - key: 'allowPushNotifications', - value: false + key: 'allowNotificationType', + value: NotificationType['NoNotifications'] // = 0 }) } ) @@ -140,8 +144,8 @@ class PushNotification extends Notification { message: this.i18n.t('options.push_not_supported') }) this.store.commit('options/updateOption', { - key: 'allowPushNotifications', - value: false + key: 'allowNotificationType', + value: NotificationType['NoNotifications'] // = 0 }) } } @@ -215,7 +219,7 @@ export default class extends Notification { start() { this.interval = window.setInterval(() => { - if (this.pushAllowed) { + if (this.bgFetchNotificationAllowed) { this.push.notify(this.messageArrived) } if (this.soundAllowed) { diff --git a/src/lib/notifications/pushServiceAndroid.ts b/src/lib/notifications/pushServiceAndroid.ts new file mode 100644 index 000000000..b9db43a05 --- /dev/null +++ b/src/lib/notifications/pushServiceAndroid.ts @@ -0,0 +1,156 @@ +import { sendSpecialMessage } from '../adamant-api' +import { ADAMANT_NOTIFICATION_SERVICE_ADDRESS, MessageType } from '../constants' +import { BasePushService } from './pushServiceBase' +import { PushNotifications } from '@capacitor/push-notifications' +import { LocalNotifications } from '@capacitor/local-notifications' +import { signalAsset } from '@/lib/adamant-api/asset' +import { Capacitor } from '@capacitor/core' + +const TOKEN_REGISTRATION_TIMEOUT = 10000 +const TOKEN_CHECK_INTERVAL = 100 + +export class AndroidPushService extends BasePushService { + private token: string | null = null + private privateKey: string | null = null + + async initialize(): Promise { + const baseInitialized = await super.initialize() + if (!baseInitialized) { + return false + } + + await LocalNotifications.requestPermissions() + await this.setupTokenRegistrationHandler() + + return true + } + + async requestPermissions(): Promise { + const permissionResult = await PushNotifications.requestPermissions() + return permissionResult.receive === 'granted' + } + + async registerDevice(): Promise { + await PushNotifications.register() + + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Token registration timeout')) + }, TOKEN_REGISTRATION_TIMEOUT) + + const checkToken = () => { + if (this.token) { + clearTimeout(timeout) + resolve() + } else { + setTimeout(checkToken, TOKEN_CHECK_INTERVAL) + } + } + + checkToken() + }) + } + + async unregisterDevice(): Promise { + if (!this.token || !this.deviceId) return false + + try { + await sendSpecialMessage( + ADAMANT_NOTIFICATION_SERVICE_ADDRESS, + signalAsset(this.deviceId!, this.token, 'FCM', 'remove'), + MessageType.SIGNAL_MESSAGE + ) + this.token = null + return true + } catch (error) { + console.error('Failed to unregister device:', error) + return false + } + } + + setPrivateKey(privateKey: string): void { + this.privateKey = privateKey + + if (Capacitor.getPlatform() === 'android') { + this.savePrivateKeyToAndroid(privateKey) + } + } + + clearPrivateKey(): void { + this.privateKey = null + + if (Capacitor.getPlatform() === 'android') { + this.clearPrivateKeyFromAndroid() + } + } + + private async savePrivateKeyToAndroid(privateKey: string): Promise { + try { + const { SecureStoragePlugin } = await import('capacitor-secure-storage-plugin') + await SecureStoragePlugin.set({ + key: 'adamant_private_key', + value: privateKey + }) + } catch (error) { + console.error('Failed to save private key:', error) + } + } + + private async clearPrivateKeyFromAndroid(): Promise { + try { + const { SecureStoragePlugin } = await import('capacitor-secure-storage-plugin') + await SecureStoragePlugin.remove({ key: 'adamant_private_key' }) + } catch (error) { + console.error('Failed to clear private key:', error) + } + } + + private async setupTokenRegistrationHandler(): Promise { + await PushNotifications.addListener('registration', async (tokenData) => { + const oldToken = this.token + this.token = tokenData.value + + try { + await this.handleTokenRegistration(oldToken, tokenData.value) + } catch (error) { + console.error('Failed to register token:', error) + throw error + } + }) + + await PushNotifications.addListener('registrationError', (error) => { + console.error('FCM token registration failed:', error) + this.token = null + }) + } + + private async handleTokenRegistration(oldToken: string | null, newToken: string): Promise { + if (oldToken && oldToken !== newToken) { + await sendSpecialMessage( + ADAMANT_NOTIFICATION_SERVICE_ADDRESS, + signalAsset(this.deviceId!, oldToken, 'FCM', 'remove'), + MessageType.SIGNAL_MESSAGE + ) + } + + await sendSpecialMessage( + ADAMANT_NOTIFICATION_SERVICE_ADDRESS, + signalAsset(this.deviceId!, newToken, 'FCM', 'add'), + MessageType.SIGNAL_MESSAGE + ) + } + + isInitialized(): boolean { + return super.isInitialized() + } + + getDeviceId(): string | null { + return super.getDeviceId() + } + + reset(): void { + super.reset() + this.token = null + this.privateKey = null + } +} diff --git a/src/lib/notifications/pushServiceBase.ts b/src/lib/notifications/pushServiceBase.ts new file mode 100644 index 000000000..67a2b6032 --- /dev/null +++ b/src/lib/notifications/pushServiceBase.ts @@ -0,0 +1,60 @@ +import { getDeviceId } from '@/firebase' + +export interface PushService { + // Initialization + initialize(): Promise + reset(): void + + // Permissions and registration + requestPermissions(): Promise + registerDevice(): Promise + unregisterDevice(): Promise + + // Private key management + setPrivateKey(privateKey: string): void + clearPrivateKey(): void + + // State getters + isInitialized(): boolean + getDeviceId(): string | null +} + +export abstract class BasePushService implements PushService { + protected initialized: boolean = false + protected deviceId: string | null = null + + abstract requestPermissions(): Promise + abstract registerDevice(): Promise + abstract unregisterDevice(): Promise + abstract setPrivateKey(privateKey: string): void + abstract clearPrivateKey(): void + + async initialize(): Promise { + if (this.initialized) { + return true + } + + try { + this.deviceId = await getDeviceId() + this.initialized = true + return true + } catch (error) { + console.error('Push service initialization failed:', error) + this.reset() + return false + } + } + + reset(): void { + this.initialized = false + this.deviceId = null + } + + isInitialized(): boolean { + return this.initialized + } + + getDeviceId(): string | null { + return this.deviceId + } +} diff --git a/src/lib/notifications/pushServiceFactory.ts b/src/lib/notifications/pushServiceFactory.ts new file mode 100644 index 000000000..520f2a598 --- /dev/null +++ b/src/lib/notifications/pushServiceFactory.ts @@ -0,0 +1,13 @@ +import { PushService } from './pushServiceBase' +import { Capacitor } from '@capacitor/core' +import { AndroidPushService } from './pushServiceAndroid' +import { WebPushService } from './pushServiceWeb' + +export function createPushService(): PushService { + if (Capacitor.getPlatform() === 'android') { + return new AndroidPushService() + } + return new WebPushService() +} + +export const pushService = createPushService() diff --git a/src/lib/notifications/pushServiceWeb.ts b/src/lib/notifications/pushServiceWeb.ts new file mode 100644 index 000000000..c6218a432 --- /dev/null +++ b/src/lib/notifications/pushServiceWeb.ts @@ -0,0 +1,133 @@ +import { BasePushService } from './pushServiceBase' +import { sendSpecialMessage } from '../adamant-api' +import { ADAMANT_NOTIFICATION_SERVICE_ADDRESS, VAPID_KEY, MessageType } from '../constants' +import { signalAsset } from '../adamant-api/asset' +import { fcm } from '@/firebase' +import { getToken, deleteToken } from 'firebase/messaging' +import { firebaseSwRegistrationPromise } from '@/main' + +export class WebPushService extends BasePushService { + private token: string | null = null + private privateKey: string | null = null + + async initialize(): Promise { + if (!fcm) { + console.warn('Firebase Messaging not available - Web Push unavailable') + return false + } + + const baseInitialized = await super.initialize() + if (!baseInitialized) { + return false + } + + return true + } + + async requestPermissions(): Promise { + const permission = await Notification.requestPermission() + return permission === 'granted' + } + + async registerDevice(): Promise { + if (!fcm) { + throw new Error('Firebase Messaging not available') + } + + try { + if (!firebaseSwRegistrationPromise) { + throw new Error('Service Workers not supported') + } + + const swRegistration = await firebaseSwRegistrationPromise + + if (!swRegistration) { + throw new Error('Firebase Service Worker registration failed') + } + + this.token = await getToken(fcm, { + vapidKey: VAPID_KEY, + serviceWorkerRegistration: swRegistration + }) + } catch (error) { + console.error('Failed to get FCM token:', error) + throw error + } + + if (!this.token) { + throw new Error('Failed to get Web Push token') + } + + if (this.deviceId) { + try { + const signalData = signalAsset(this.deviceId, this.token, 'FCM', 'add') + await sendSpecialMessage( + ADAMANT_NOTIFICATION_SERVICE_ADDRESS, + signalData, + MessageType.SIGNAL_MESSAGE + ) + } catch (error) { + console.error('Failed to register device with notification service:', error) + throw error + } + } + } + + async unregisterDevice(): Promise { + if (!this.token || !this.deviceId) return false + + try { + await sendSpecialMessage( + ADAMANT_NOTIFICATION_SERVICE_ADDRESS, + signalAsset(this.deviceId, this.token, 'FCM', 'remove'), + MessageType.SIGNAL_MESSAGE + ) + + const revoked = await this.revokeToken() + if (!revoked) { + throw new Error('Failed to revoke FCM token') + } + + this.token = null + this.privateKey = null + + return true + } catch (error) { + console.error('Failed to unregister device:', error) + return false + } + } + + private async revokeToken(): Promise { + if (!fcm) return false + + try { + await deleteToken(fcm) + return true + } catch { + return false + } + } + + setPrivateKey(privateKey: string): void { + this.privateKey = privateKey + } + + clearPrivateKey(): void { + this.privateKey = null + } + + isInitialized(): boolean { + return super.isInitialized() + } + + getDeviceId(): string | null { + return super.getDeviceId() + } + + reset(): void { + super.reset() + this.token = null + this.privateKey = null + } +} diff --git a/src/lib/notifications/pushTypes.ts b/src/lib/notifications/pushTypes.ts new file mode 100644 index 000000000..de7cde18e --- /dev/null +++ b/src/lib/notifications/pushTypes.ts @@ -0,0 +1,20 @@ +import { PushNotificationSchema } from '@capacitor/push-notifications' + +export interface ParsedPushData { + transactionId: string + senderId: string + senderPublicKey: string + encryptedMessage: string + nonce: string +} + +export interface NotificationData { + title: string + body: string + senderId: string + transactionId: string +} + +export interface PushNotification extends PushNotificationSchema { + data: NotificationData +} diff --git a/src/lib/notifications/pushUtils.ts b/src/lib/notifications/pushUtils.ts new file mode 100644 index 000000000..d7d091388 --- /dev/null +++ b/src/lib/notifications/pushUtils.ts @@ -0,0 +1,121 @@ +import utils from '@/lib/adamant' +import type { ParsedPushData, NotificationData } from './pushTypes' + +export function parsePushPayload(payload: any): ParsedPushData | null { + const txnString = payload.data?.txn || payload.txn + if (!txnString) return null + + const txnData = typeof txnString === 'string' ? JSON.parse(txnString) : txnString + const { id, senderId, senderPublicKey, asset } = txnData + + if (!id || !senderId || !senderPublicKey || !asset?.chat) return null + + return { + transactionId: id, + senderId, + senderPublicKey, + encryptedMessage: asset.chat.message, + nonce: asset.chat.own_message + } +} + +export function decryptPushMessage(parsedData: ParsedPushData, privateKey: string): string | null { + try { + if (!privateKey) { + console.warn('Cannot decrypt: private key not provided') + return null + } + + const decrypted = utils.decodeMessage( + parsedData.encryptedMessage, + parsedData.senderPublicKey, + privateKey, + parsedData.nonce + ) + + if (!decrypted) { + console.warn('Decryption returned empty result') + return null + } + + console.log('Message decrypted successfully') + return decrypted + } catch (error) { + console.error('Error decrypting push message:', error) + return null + } +} + +export function createNotificationData( + decryptedMessage: string, + { senderId, transactionId }: ParsedPushData +): NotificationData { + return { + title: 'ADAMANT Messenger', + body: decryptedMessage, + senderId: senderId, + transactionId: transactionId + } +} + +export function navigateToChat(senderId: string, transactionId?: string): void { + try { + if (typeof window !== 'undefined') { + window.dispatchEvent( + new CustomEvent('openChat', { + detail: { + partnerId: senderId, + transactionId: transactionId + } + }) + ) + } + } catch (error) { + console.error('Error navigating to chat:', error) + } +} + +export function notifyClient(client: any, senderId: string, transactionId?: string): void { + try { + client.postMessage({ + action: 'OPEN_CHAT', + partnerId: senderId, + transactionId: transactionId, + fromNotification: true + }) + console.log(`Message sent to client for chat: ${senderId}`) + } catch (error) { + console.error('Error sending message to client:', error) + } +} + +export async function processPushNotification( + payload: any, + privateKey: string, + isAppVisible: boolean, + showNotificationFn: (data: NotificationData) => Promise +): Promise { + try { + if (isAppVisible) { + return false + } + + const parsedData = parsePushPayload(payload) + if (!parsedData) { + return false + } + + const decryptedMessage = decryptPushMessage(parsedData, privateKey) + if (!decryptedMessage) { + return false + } + + const notificationData = createNotificationData(decryptedMessage, parsedData) + + await showNotificationFn(notificationData) + return true + } catch (error) { + console.error('Error processing push notification:', error) + return false + } +} diff --git a/src/locales/en.json b/src/locales/en.json index 7f14889b5..45b18dd22 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -283,7 +283,7 @@ "nodes_list": "Nodes and services", "notification_title": "Notifications", "page_title": "Settings", - "push_denied": "Notifications disabled — update browser permissions", + "push_denied": "Notifications disabled — update permissions", "push_not_supported": "Notifications not supported in this browser or device", "security_title": "Security", "send_on_enter": "Send with Enter", @@ -295,7 +295,11 @@ "version": "App Version:", "vote_for_delegates_button": "Vote for delegates", "wallets_list": "Wallet list", - "dev_screens": "Dev screens" + "dev_screens": "Dev screens", + "notifications_info": "Notification modes\n\nDisabled\nNo notifications.\n\nBackground Fetch\nYour device fetchs for new messages by itself. No external calls.\n\nPush\nNotifications sent to your device by ADAMANT Notification Service. You will receive notification almost instantly after a message was sent and approved by the Blockchain — a few seconds delay. But this mode requires your device to register it's Device Token in the Service's database. Device tokens are safe and secure, and this option is recommended in most cases.\n\nYou can read more about device registration on ADAMANT's Github page.\n\n", + "push_subscribe_success": "Successfully subscribed to push notifications", + "push_register_error": "Failed to register device for push notifications", + "push_unsubscribe_success": "Successfully unsubscribed from push notifications" }, "region": "en-US", "rows_per_page": "Rows per page", diff --git a/src/locales/ru.json b/src/locales/ru.json index 4c2c3f688..86c8fd01c 100644 --- a/src/locales/ru.json +++ b/src/locales/ru.json @@ -283,7 +283,7 @@ "nodes_list": "Узлы и сервисы", "notification_title": "Уведомления", "page_title": "Настройки", - "push_denied": "Уведомления запрещены в настройках браузера", + "push_denied": "Уведомления запрещены в настройках устройства", "push_not_supported": "Уведомления не поддерживаются браузером или устройством", "security_title": "Безопасность", "send_on_enter": "Отправлять клавишей Enter", @@ -295,7 +295,11 @@ "version": "Версия приложения:", "vote_for_delegates_button": "Голосовать за делегатов", "wallets_list": "Список кошельков", - "dev_screens": "Экраны разработчика" + "dev_screens": "Экраны разработчика", + "notifications_info": "Режимы уведомлений\n\nОтключены\nНе присылать никаких уведомлений.\n\nФоновое обновление\nПроверка новых сообщений производится самим устройством.\n\nPush\nУведомления о новых сообщениях присылаются сервисом ADAMANT Notification Service. Уведомления приходят практически мгновенно (несколько секунд), но необходима регистрация устройства в сервисе. Это безопасно и сохраняет высокий уровень секретности, для большинства пользователей это предпочтительный вариант.\n\nО регистрации устройств вы можете прочитать больше на странице проекта в Github.\n\n", + "push_subscribe_success": "Успешная подписка на push-уведомления", + "push_register_error": "Ошибка регистрации устройства для push-уведомлений", + "push_unsubscribe_success": "Успешная отписка от push-уведомлений" }, "region": "ru-RU", "rows_per_page": "Строк на страницу", diff --git a/src/main.ts b/src/main.ts index a64e78ca3..501149e47 100644 --- a/src/main.ts +++ b/src/main.ts @@ -36,6 +36,21 @@ registerGlobalComponents(app) app.mount('#app') +export let firebaseSwRegistrationPromise: Promise | null = null + +if ('serviceWorker' in navigator) { + firebaseSwRegistrationPromise = navigator.serviceWorker + .register('/firebase/firebase-messaging-sw.js', { scope: '/firebase/' }) + .then((reg) => { + console.log('[firebase SW] registered', reg.scope) + return reg + }) + .catch((err) => { + console.error('[firebase SW] registration failed', err) + return null + }) +} + window.ep = app document.title = i18n.global.t('app_title') diff --git a/src/store/index.js b/src/store/index.js index 730f573d9..b1c0de97b 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -8,7 +8,7 @@ import { sendSpecialMessage, getCurrentAccount } from '@/lib/adamant-api' -import { CryptosInfo, Fees, FetchStatus } from '@/lib/constants' +import { CryptosInfo, Fees, FetchStatus, MessageType } from '@/lib/constants' import { encryptPassword } from '@/lib/idb/crypto' import { flushCryptoAddresses, validateStoredCryptoAddresses } from '@/lib/store-crypto-address' import { registerCryptoModules } from './utils/registerCryptoModules' @@ -188,7 +188,7 @@ const store = { ? replyWithCryptoTransferAsset(payload.replyToId, transferPayload) : cryptoTransferAsset(transferPayload) - return sendSpecialMessage(payload.address, asset).then((result) => { + return sendSpecialMessage(payload.address, asset, MessageType.RICH_CONTENT_MESSAGE).then((result) => { if (!result.success) { throw new Error(`Failed to send "${asset.type}"`) } @@ -258,6 +258,17 @@ const store = { handler() { clearTimeout(interval) } + }, + async getPrivateKeyForPush({ state }) { + if (!state.passphrase) return '' + + const { getMyPrivateKey, isReady } = await import('@/lib/adamant-api') + + if (!isReady()) { + return '' + } + + return getMyPrivateKey() } }, modules: { diff --git a/src/store/modules/options/index.js b/src/store/modules/options/index.js index ab589d42f..727f4ca6b 100644 --- a/src/store/modules/options/index.js +++ b/src/store/modules/options/index.js @@ -5,7 +5,7 @@ const state = () => ({ sendMessageOnEnter: true, allowSoundNotifications: true, allowTabNotifications: true, - allowPushNotifications: false, + allowNotificationType: 0, // 0 - No Notifications, 1 - Background Fetch, 2 - Push darkTheme: true, formatMessages: true, useFullDate: false, diff --git a/src/store/plugins/localStorage.js b/src/store/plugins/localStorage.js index 1966e0126..1d0cdc990 100644 --- a/src/store/plugins/localStorage.js +++ b/src/store/plugins/localStorage.js @@ -12,13 +12,12 @@ const vuexPersistence = new VuexPersistence({ sendMessageOnEnter: state.options.sendMessageOnEnter, allowSoundNotifications: state.options.allowSoundNotifications, allowTabNotifications: state.options.allowTabNotifications, - allowPushNotifications: state.options.allowPushNotifications, + allowNotificationType: state.options.allowNotificationType, darkTheme: state.options.darkTheme, formatMessages: state.options.formatMessages, useFullDate: state.options.useFullDate, useSocketConnection: state.options.useSocketConnection, - suppressWarningOnAddressesNotification: - state.options.suppressWarningOnAddressesNotification, + suppressWarningOnAddressesNotification: state.options.suppressWarningOnAddressesNotification, currentRate: state.options.currentRate } } diff --git a/src/views/Options.vue b/src/views/Options.vue index 5476f3cfd..190dbd1ca 100644 --- a/src/views/Options.vue +++ b/src/views/Options.vue @@ -124,14 +124,36 @@ - - + + +
+ {{ t('options.notification_title') }} +
+ + + +
+ + + + + +
{{ t('options.enable_push_tooltip') }}
@@ -201,7 +223,7 @@