Skip to content
Open
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
f92a5cb
feat: add push notifications for web and android
Linhead Jun 16, 2025
76c493e
fix: code-review fixes
Linhead Jun 20, 2025
e50ccc5
fix: code review fix
Linhead Jun 20, 2025
47b290e
feat: unify PWA and Firebase service workers
Linhead Jun 23, 2025
6824162
fix: code-review fixes
Linhead Jun 24, 2025
6dbd81f
update: update capacitor
Linhead Jun 24, 2025
c07a706
fix: fix crash on android
Linhead Jul 1, 2025
1249aa8
refactor/feat/fix: fix bugs, refactoring, creting separate firebase S…
Linhead Jul 3, 2025
ce1ab78
fix: add loader when select notifications type + change default messa…
Linhead Jul 25, 2025
a281a67
refactor: add usePushNotificationSetup + add handling user cases with…
Linhead Jul 25, 2025
6c8fdca
refactor: eliminate push notification privateKey duplication using pl…
Linhead Jul 29, 2025
a4aa11f
refactor: clean up push notifications code and fix redundant checks
Linhead Jul 30, 2025
956329a
feat: add push notifications for android app
Linhead Aug 5, 2025
221649a
fix: remove useless deep links for android
Linhead Aug 11, 2025
d8c25c4
fix: add safe closing broadcast channel in case of error during the p…
Linhead Aug 11, 2025
030c3dd
fix: code-review fixes
Linhead Aug 25, 2025
7f754dd
fix: code-revies fixes
Linhead Sep 1, 2025
3b5ae72
fix: code-review fixes
Linhead Sep 3, 2025
ccf4f8c
fix: ensure public key is loaded before sending push notifications
Linhead Sep 25, 2025
ac4ceca
feat: add block notification clicks for different user accounts for w…
Linhead Sep 25, 2025
181201b
fix: prevent Firebase service notifications from leaking through
Linhead Sep 25, 2025
7850419
fix: remove unnecessary firebase-analytics dependency
Linhead Oct 14, 2025
20eccc1
fix: add event-driven settings initialization in service worker
Linhead Oct 15, 2025
1a04336
fix: use full ADAMANT address in notifications
Linhead Oct 15, 2025
1c3d698
Update public/firebase/firebase-messaging-sw.js
Linhead Oct 15, 2025
4a26ee9
Update public/firebase/firebase-messaging-sw.js
Linhead Oct 15, 2025
1dbe881
feat: implement scroll to message on notification click + refactoring
Linhead Oct 15, 2025
2d82222
feat: add secure private key storage for Android push notifications
Linhead Oct 16, 2025
dfc8aee
fix: disable unsupported Background Fetch option on Android
Linhead Oct 16, 2025
d67f29b
chore: store ANS public key as constant to avoid redundant API calls
Linhead Oct 16, 2025
997cb58
refactor: replace timeout with Promise-based Service Worker registrat…
Linhead Oct 17, 2025
fd99e09
fix: fix bug with enabled background fetch item in notifications list…
Linhead Oct 17, 2025
1817080
fix: replace setTimeout with navigator.serviceWorker.ready for push n…
Linhead Oct 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion adamant-wallets
2 changes: 2 additions & 0 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ 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'
implementation 'com.google.firebase:firebase-analytics:21.5.0'
Copy link
Member

Choose a reason for hiding this comment

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

Is analytics necessary?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Removed it

}

apply from: 'capacitor.build.gradle'
Expand Down
4 changes: 4 additions & 0 deletions android/app/capacitor.build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ 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')

}

Expand Down
24 changes: 24 additions & 0 deletions android/app/google-services.json
Original file line number Diff line number Diff line change
@@ -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"
Copy link
Member

Choose a reason for hiding this comment

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

Is it correct to store plain API key in the repository?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This API key is already used in two other places in the repository: src/lib/firebase-config.ts and public/firebase/firebase-messaging-sw.js. So there's no point in hiding it here while it's already exposed in the other files.

According to Firebase documentation (https://firebase.google.com/docs/projects/api-keys), API keys for Firebase services are safe to include in code since they're used only for project identification, not authorization. All Firebase-provisioned API keys have API restrictions applied by default, and we can set additional restrictions through Google Cloud Console if needed.

I've analyzed potential security risks and concluded that exposure is minimal since ADAMANT only uses Firebase for FCM (no data storage), and the main theoretical risk is quota abuse (but FCM has unlimited free tier).

}
]
}
],
"configuration_version": "1"
}
31 changes: 28 additions & 3 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,37 @@
android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
</provider>
</application>

<!-- Permissions -->
<!-- Custom Firebase Messaging Service for ADAMANT Push Notifications -->
<service
android:name=".AdamantFirebaseMessagingService"
android:exported="false"
android:stopWithTask="false">
<intent-filter android:priority="1000">
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>

<!-- Disable Firebase automatic features to use custom implementation -->
<meta-data
android:name="firebase_messaging_auto_init_enabled"
android:value="false" />
<meta-data
Copy link
Member

Choose a reason for hiding this comment

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

Is firebase_analytics necessary?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Removed it

android:name="firebase_analytics_collection_enabled"
android:value="false" />

</application>

<!-- Basic permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />

<!-- Push notification permissions -->
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.VIBRATE" />
</manifest>

<!-- Notification permissions for Android 13+ (API 33+) -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

</manifest>
Original file line number Diff line number Diff line change
@@ -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<String> 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<android.app.ActivityManager.RunningAppProcessInfo> 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);
}
}
Loading