diff --git a/mobile/android/qt6/AndroidManifest.xml b/mobile/android/qt6/AndroidManifest.xml index 39f90e1954c..cb1a78ee1e2 100644 --- a/mobile/android/qt6/AndroidManifest.xml +++ b/mobile/android/qt6/AndroidManifest.xml @@ -72,5 +72,14 @@ android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/qtprovider_paths"/> + + + + + + + diff --git a/mobile/android/qt6/build.gradle b/mobile/android/qt6/build.gradle index 1656e3faaf2..0fb63ba364f 100644 --- a/mobile/android/qt6/build.gradle +++ b/mobile/android/qt6/build.gradle @@ -6,6 +6,8 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.8.0' + // Add Firebase Google Services plugin + classpath 'com.google.gms:google-services:4.4.0' } } @@ -29,6 +31,10 @@ dependencies { implementation 'androidx.core:core-splashscreen:1.0.1' implementation 'com.google.android.material:material:1.12.0' implementation 'androidx.documentfile:documentfile:1.0.1' + + // Firebase dependencies for Push Notifications + implementation platform('com.google.firebase:firebase-bom:33.7.0') + implementation 'com.google.firebase:firebase-messaging' } android { @@ -101,3 +107,6 @@ android { } } } + +// Apply Google Services plugin (must be at the bottom) +apply plugin: 'com.google.gms.google-services' diff --git a/mobile/android/qt6/google-services.json b/mobile/android/qt6/google-services.json new file mode 100644 index 00000000000..29eb3e43cc9 --- /dev/null +++ b/mobile/android/qt6/google-services.json @@ -0,0 +1,155 @@ +{ + "project_info": { + "project_number": "854811651919", + "firebase_url": "https://status-react-app.firebaseio.com", + "project_id": "status-react-app", + "storage_bucket": "status-react-app.appspot.com" + }, + "client": [ + { + "client_info": { + "mobilesdk_app_id": "1:854811651919:android:b1652e09df58f195005f3a", + "android_client_info": { + "package_name": "app.status.mobile" + } + }, + "oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAOF4W1j8GYeXzzVKRfNKlXywD6bx0rJtQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "854811651919-30s20e3l0me0ins0vc4185jbnj7ja49o.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "im.status.ethereum" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:854811651919:android:11ee7444ded8a00a", + "android_client_info": { + "package_name": "im.status.ethereum" + } + }, + "oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAOF4W1j8GYeXzzVKRfNKlXywD6bx0rJtQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "854811651919-30s20e3l0me0ins0vc4185jbnj7ja49o.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "im.status.ethereum" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:854811651919:android:15dbe4af1e06ca3e005f3a", + "android_client_info": { + "package_name": "im.status.ethereum.debug" + } + }, + "oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAOF4W1j8GYeXzzVKRfNKlXywD6bx0rJtQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "854811651919-30s20e3l0me0ins0vc4185jbnj7ja49o.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "im.status.ethereum" + } + } + ] + } + } + }, + { + "client_info": { + "mobilesdk_app_id": "1:854811651919:android:1d0d69fe8c1bb89b005f3a", + "android_client_info": { + "package_name": "im.status.ethereum.pr" + } + }, + "oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + } + ], + "api_key": [ + { + "current_key": "AIzaSyAOF4W1j8GYeXzzVKRfNKlXywD6bx0rJtQ" + } + ], + "services": { + "appinvite_service": { + "other_platform_oauth_client": [ + { + "client_id": "854811651919-gua52csicclb5p9gr4eeu33ukk0aaphj.apps.googleusercontent.com", + "client_type": 3 + }, + { + "client_id": "854811651919-30s20e3l0me0ins0vc4185jbnj7ja49o.apps.googleusercontent.com", + "client_type": 2, + "ios_info": { + "bundle_id": "im.status.ethereum" + } + } + ] + } + } + } + ], + "configuration_version": "1" +} \ No newline at end of file diff --git a/mobile/android/qt6/src/app/status/mobile/PushNotificationHelper.java b/mobile/android/qt6/src/app/status/mobile/PushNotificationHelper.java new file mode 100644 index 00000000000..916a2d532da --- /dev/null +++ b/mobile/android/qt6/src/app/status/mobile/PushNotificationHelper.java @@ -0,0 +1,293 @@ +package app.status.mobile; + +import android.Manifest; +import android.app.Activity; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.os.Build; +import android.util.Log; +import androidx.core.app.ActivityCompat; +import androidx.core.app.NotificationCompat; +import androidx.core.app.NotificationManagerCompat; +import androidx.core.content.ContextCompat; + +/** + * Helper class for managing push notifications + * + * This class provides: + * - JNI bridge between Java and C++/Qt + * - Notification display functionality + * - Notification channel management + * - Deep link handling + */ +public class PushNotificationHelper { + private static final String TAG = "PushNotificationHelper"; + private static final String CHANNEL_ID = "status-messages"; + private static final String CHANNEL_NAME = "Status Messages"; + + // Store application context (set by PushNotificationService) + private static Context sApplicationContext = null; + + /** + * Initialize with application context + * Should be called from PushNotificationService.onCreate() + */ + public static void initialize(Context context) { + if (context != null) { + sApplicationContext = context.getApplicationContext(); + Log.d(TAG, "PushNotificationHelper initialized with context"); + } + } + + /** + * Check if notification permission is granted (Android 13+) + * Returns true if permission is granted or not required (Android 12-) + */ + public static boolean hasNotificationPermission(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Android 13+ + int result = ContextCompat.checkSelfPermission( + context, + Manifest.permission.POST_NOTIFICATIONS + ); + boolean hasPermission = result == PackageManager.PERMISSION_GRANTED; + return hasPermission; + } + return true; + } + + /** + * Request notification permission (Android 13+ only) + * For Android 12 and below, this does nothing as permission is not required + */ + public static void requestNotificationPermission(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { // Android 13+ + if (context instanceof Activity) { + Activity activity = (Activity) context; + if (!hasNotificationPermission(context)) { + Log.d(TAG, "Requesting POST_NOTIFICATIONS permission..."); + ActivityCompat.requestPermissions( + activity, + new String[]{Manifest.permission.POST_NOTIFICATIONS}, + 1001 // Request code + ); + } else { + Log.d(TAG, "Notification permission already granted"); + } + } else { + Log.w(TAG, "Cannot request permission: context is not an Activity"); + } + } else { + Log.d(TAG, "Android 12- : notification permission not required"); + } + } + + /** + * Request FCM token at app startup + * This method can be called from C++ layer to explicitly request the token + * The token is delivered via onFCMTokenReceived callback + */ + public static void requestFCMToken(Context context) { + Log.d(TAG, "Requesting FCM token from Firebase..."); + + // Store context if not already set + if (sApplicationContext == null && context != null) { + sApplicationContext = context.getApplicationContext(); + } + + // Check notification permission first + if (!hasNotificationPermission(context)) { + Log.w(TAG, "Notification permission not granted. Token request may fail."); + } + + // Request token with completion listener + // This works whether the token is cached or needs to be fetched + com.google.firebase.messaging.FirebaseMessaging.getInstance().getToken() + .addOnCompleteListener(task -> { + if (task.isSuccessful() && task.getResult() != null) { + String token = task.getResult(); + Log.d(TAG, "FCM token obtained: " + token); + // Pass token to native layer + onFCMTokenReceived(token); + } else { + Log.e(TAG, "Failed to get FCM token", task.getException()); + } + }); + } + + /** + * Called from FirebaseMessagingService when new token is received + * This method calls into C++/Qt layer via JNI + */ + public static void onFCMTokenReceived(String token) { + Log.d(TAG, "FCM Token ready to pass to native layer: " + token); + + // Call native C++ method (implemented in C++ via JNI registration) + nativeOnFCMTokenReceived(token); + } + + /** + * Called from FirebaseMessagingService when push notification received + * Passes encrypted data to status-go for processing + */ + public static void onPushNotificationReceived(String encryptedMessage, + String chatId, + String publicKey) { + Log.d(TAG, "Push notification received, passing to native layer"); + + // Call native C++ method to forward to status-go + nativeOnPushNotificationReceived(encryptedMessage, chatId, publicKey); + } + + /** + * Called from C++/Qt layer to display a notification + * This is called after status-go processes and decrypts the message + * + * @param title Notification title (chat name or sender name) + * @param message Notification message text + * @param identifier JSON string containing notification metadata for handling clicks + */ + public static void showNotification(String title, String message, String identifier) { + Log.d(TAG, "showNotification called - Title: " + title + ", Message: " + message); + + try { + // Get application context from Qt + Context context = getApplicationContext(); + if (context == null) { + Log.e(TAG, "Failed to get application context"); + return; + } + + // Create notification channel (required for Android O+) + createNotificationChannel(context); + + // Parse identifier to get deep link + String deepLink = extractDeepLinkFromIdentifier(identifier); + + // Create intent for when notification is tapped + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setData(android.net.Uri.parse(deepLink)); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + + PendingIntent pendingIntent = PendingIntent.getActivity( + context, + identifier.hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + // Build notification + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(android.R.drawable.ic_dialog_info) // TODO: Use app icon + .setContentTitle(title) + .setContentText(message) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setAutoCancel(true) + .setContentIntent(pendingIntent); + + // Show notification + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + notificationManager.notify(identifier.hashCode(), builder.build()); + + Log.d(TAG, "Notification displayed successfully"); + + } catch (Exception e) { + Log.e(TAG, "Error showing notification", e); + } + } + + /** + * Clear all notifications for a specific chat + * Called when user opens a chat + */ + public static void clearNotifications(String chatId) { + Log.d(TAG, "Clearing notifications for chat: " + chatId); + + try { + Context context = getApplicationContext(); + if (context == null) return; + + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + // For now, just cancel by chat ID hash + // In a full implementation, we'd track notification IDs + notificationManager.cancel(chatId.hashCode()); + + } catch (Exception e) { + Log.e(TAG, "Error clearing notifications", e); + } + } + + /** + * Create notification channel (required for Android O+) + */ + private static void createNotificationChannel(Context context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + NotificationChannel channel = new NotificationChannel( + CHANNEL_ID, + CHANNEL_NAME, + NotificationManager.IMPORTANCE_HIGH + ); + channel.setDescription("Status chat message notifications"); + channel.enableVibration(true); + channel.setShowBadge(true); + + NotificationManager notificationManager = context.getSystemService(NotificationManager.class); + if (notificationManager != null) { + notificationManager.createNotificationChannel(channel); + } + } + } + + /** + * Extract deep link from notification identifier JSON + * The identifier contains metadata like: {"chatId": "...", "deepLink": "status-app://..."} + */ + private static String extractDeepLinkFromIdentifier(String identifier) { + try { + org.json.JSONObject json = new org.json.JSONObject(identifier); + if (json.has("chatId")) { + String chatId = json.getString("chatId"); + return "status-app://chat/" + chatId; + } + } catch (Exception e) { + Log.w(TAG, "Failed to parse identifier, using default deep link", e); + } + return "status-app://"; + } + + /** + * Get application context + * Returns the context stored during initialization + */ + private static Context getApplicationContext() { + if (sApplicationContext == null) { + Log.e(TAG, "PushNotificationHelper not initialized! Call initialize() first."); + } + return sApplicationContext; + } + + // ============================================================================ + // Native methods (implemented in C++ and registered via JNI) + // ============================================================================ + + /** + * Called when FCM token is received + * Implemented in pushnotification_android.cpp + */ + private static native void nativeOnFCMTokenReceived(String token); + + /** + * Called when push notification is received + * Implemented in pushnotification_android.cpp + */ + private static native void nativeOnPushNotificationReceived( + String encryptedMessage, + String chatId, + String publicKey + ); +} + diff --git a/mobile/android/qt6/src/app/status/mobile/PushNotificationService.java b/mobile/android/qt6/src/app/status/mobile/PushNotificationService.java new file mode 100644 index 00000000000..dbf75672b6f --- /dev/null +++ b/mobile/android/qt6/src/app/status/mobile/PushNotificationService.java @@ -0,0 +1,110 @@ +package app.status.mobile; + +import android.util.Log; +import androidx.annotation.NonNull; +import com.google.firebase.messaging.FirebaseMessagingService; +import com.google.firebase.messaging.RemoteMessage; + +/** + * FCM Service for receiving push notifications and device tokens + * + * This service handles: + * - New FCM token generation (onNewToken) + * - Incoming push notifications (onMessageReceived) + * + * The token is passed to C++/Qt layer via JNI for status-go registration + */ +public class PushNotificationService extends FirebaseMessagingService { + private static final String TAG = "PushNotificationService"; + + /** + * Called when the service is created by an FCM event + * Note: This is NOT called on app startup, only when FCM events occur + */ + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "PushNotificationService created by FCM event"); + // Initialize helper if not already done + PushNotificationHelper.initialize(this); + } + + /** + * Called when FCM generates a new token for this device + * This happens: + * - On first app install + * - When user reinstalls app + * - When user clears app data + * - When Firebase decides to rotate the token + */ + @Override + public void onNewToken(@NonNull String token) { + super.onNewToken(token); + Log.d(TAG, "New FCM token received: " + token); + + // Pass token to C++ layer which will forward to status-go + PushNotificationHelper.onFCMTokenReceived(token); + } + + /** + * Called when a push notification is received + * + * For Status, this will contain encrypted message data: + * - encryptedMessage: The encrypted message payload + * - chatId: The chat identifier + * - publicKey: Sender's public key + * + * status-go will decrypt and process the message, then emit + * a localNotifications signal that our Nim layer handles + */ + @Override + public void onMessageReceived(@NonNull RemoteMessage remoteMessage) { + super.onMessageReceived(remoteMessage); + + Log.d(TAG, "Push notification received from: " + remoteMessage.getFrom()); + + // Check if message contains data payload + if (remoteMessage.getData().size() > 0) { + Log.d(TAG, "Message data payload: " + remoteMessage.getData()); + + // Extract push notification data + String encryptedMessage = remoteMessage.getData().get("encryptedMessage"); + String chatId = remoteMessage.getData().get("chatId"); + String publicKey = remoteMessage.getData().get("publicKey"); + + if (encryptedMessage != null && chatId != null && publicKey != null) { + // Pass to C++ layer to forward to status-go + PushNotificationHelper.onPushNotificationReceived( + encryptedMessage, + chatId, + publicKey + ); + } else { + Log.w(TAG, "Push notification missing required fields"); + } + } + + // Check if message contains a notification payload + if (remoteMessage.getNotification() != null) { + String title = remoteMessage.getNotification().getTitle(); + String body = remoteMessage.getNotification().getBody(); + Log.d(TAG, "Message Notification - Title: " + title + ", Body: " + body); + + // For Status, we typically don't use the notification payload + // as status-go generates local notifications after decrypting + // But we log it for debugging + } + } + + /** + * Called when a message is deleted on the server + * This can happen if the message couldn't be delivered within the TTL + */ + @Override + public void onDeletedMessages() { + super.onDeletedMessages(); + Log.d(TAG, "Messages were deleted on the server"); + // For Status, we don't need to handle this as messages are on Waku + } +} + diff --git a/src/app/android/push_notifications.nim b/src/app/android/push_notifications.nim new file mode 100644 index 00000000000..216d5706426 --- /dev/null +++ b/src/app/android/push_notifications.nim @@ -0,0 +1,136 @@ +import chronicles, json +import ../../backend/backend +import ../../backend/settings +import ../../statusq_bridge + +logScope: + topics = "android-push-notifications" + +# Push notification token types (from protobuf.PushNotificationRegistration_TokenType) +const + UNKNOWN_TOKEN_TYPE* = 0 + APN_TOKEN* = 1 + FIREBASE_TOKEN* = 2 + +# Global state - stores the FCM token until user logs in +var g_fcmToken: string = "" +var g_tokenRegistered: bool = false + +# Global callback handlers - must use cdecl calling convention +proc onPushNotificationTokenReceived(token: cstring) {.cdecl, exportc.} = + # Initialize Nim GC for foreign thread calls + when declared(setupForeignThreadGc): + setupForeignThreadGc() + when declared(nimGC_setStackBottom): + var locals {.volatile, noinit.}: pointer + locals = addr(locals) + nimGC_setStackBottom(locals) + + let tokenStr = $token + # Store the token globally - we'll register it after user logs in + g_fcmToken = tokenStr + g_tokenRegistered = false + + debug "FCM token received, will register after user login" + +proc onPushNotificationReceived(encryptedMessage: cstring, chatId: cstring, publicKey: cstring) {.cdecl, exportc.} = + # Initialize Nim GC for foreign thread calls + when declared(setupForeignThreadGc): + setupForeignThreadGc() + when declared(nimGC_setStackBottom): + var locals {.volatile, noinit.}: pointer + locals = addr(locals) + nimGC_setStackBottom(locals) + + debug "Push notification received", + encryptedMessage = $encryptedMessage, + chatId = $chatId, + publicKey = $publicKey + + # NOTE: In most cases, you don't need to process this manually! + # + # The push notification serves as a WAKE-UP CALL: + # 1. It wakes up the app (if backgrounded/closed) + # 2. status-go connects to Waku automatically + # 3. Waku delivers messages through normal flow + # 4. status-go decrypts and generates local notifications + # + # The encrypted data here is for: + # - Quick preview before Waku sync (future enhancement) + # - Message prioritization + # - Offline handling (future enhancement) + # + # For now, just logging is sufficient. status-go handles the rest! + +proc registerPushNotificationToken*(): bool = + ## Register the stored FCM token with status-go + ## This should be called after user login when messenger is ready + ## Returns true if registration was attempted, false if no token available + + if g_fcmToken.len == 0: + debug "No FCM token available to register" + return false + + if g_tokenRegistered: + debug "FCM token already registered" + return false + + debug "Registering FCM token with status-go (post-login)...", token=g_fcmToken + + try: + # First, ensure messenger notifications are enabled + # This is required for the push notification client to be initialized + # TODO: This should be done by the user through the onboarding/settings + debug "Enabling messenger notifications in settings..." + let enableResponse = saveSettings("messenger-notifications-enabled?", true) + if not enableResponse.error.isNil: + error "Failed to enable messenger notifications", error=enableResponse.error + return false + debug "Messenger notifications enabled" + + # Now register with status-go using the proper backend API + # Parameters: + # - deviceToken: FCM token from Firebase + # - apnTopic: empty string for Android (only used for iOS) + # - tokenType: FIREBASE_TOKEN (2) for Android + debug "Registering FCM token with status-go..." + let response = registerForPushNotifications(g_fcmToken, "", FIREBASE_TOKEN) + + debug "Successfully registered for push notifications", response=response + g_tokenRegistered = true + return true + except Exception as e: + error "Failed to register for push notifications", error=e.msg + return false + +proc requestNotificationPermission*() = + ## Request notification permission (Android 13+ only) + ## On Android 12 and below, this is a no-op + ## Should be called before requesting FCM token + debug "Requesting notification permission..." + statusq_requestNotificationPermission() + +proc hasNotificationPermission*(): bool = + ## Check if notification permission is granted + ## Returns true on Android 12- (permission not required) + ## Returns actual permission status on Android 13+ + statusq_hasNotificationPermission() + +proc initializeAndroidPushNotifications*() = + ## Initialize push notifications on Android + ## This should be called once during app startup + debug "Initializing Android push notifications..." + + # Register our callbacks with StatusQ C++ layer + statusq_initPushNotifications( + onPushNotificationTokenReceived, + onPushNotificationReceived + ) + + # Request notification permission (Android 13+ only) + # This will show a system dialog on Android 13+ + # On Android 12 and below, this does nothing + requestNotificationPermission() + + debug "Android push notifications initialized" + diff --git a/src/app/boot/app_controller.nim b/src/app/boot/app_controller.nim index 1d2c1658229..5034a7bd65e 100644 --- a/src/app/boot/app_controller.nim +++ b/src/app/boot/app_controller.nim @@ -48,6 +48,9 @@ import app/core/[main] import constants as main_constants +when defined(android): + import app/android/push_notifications + logScope: topics = "app-controller" @@ -438,6 +441,11 @@ proc load(self: AppController) = proc userLoggedIn*(self: AppController): string = try: self.generalService.startMessenger() + + # After messenger is started, register push notification token if available + when defined(android): + discard registerPushNotificationToken() + return "" except Exception as e: let errDescription = e.msg diff --git a/src/backend/backend.nim b/src/backend/backend.nim index 8a270807be1..17c751703bf 100644 --- a/src/backend/backend.nim +++ b/src/backend/backend.nim @@ -326,3 +326,18 @@ rpc(fetchMarketTokenPageAsync, "wallet"): rpc(unsubscribeFromLeaderboard, "wallet"): discard + +# Push Notifications +rpc(registerForPushNotifications, "wakuext"): + deviceToken: string + apnTopic: string + tokenType: int + +rpc(unregisterFromPushNotifications, "wakuext"): + discard + +rpc(enablePushNotificationsFromContactsOnly, "wakuext"): + discard + +rpc(disablePushNotificationsFromContactsOnly, "wakuext"): + discard diff --git a/src/nim_status_client.nim b/src/nim_status_client.nim index 98edb11d40b..da2b6a0c4cc 100644 --- a/src/nim_status_client.nim +++ b/src/nim_status_client.nim @@ -21,6 +21,9 @@ featureGuard KEYCARD_ENABLED: var keycardServiceQObjPointer: pointer var keycardServiceV2QObjPointer: pointer +when defined(android): + import app/android/push_notifications + when defined(macosx) and defined(arm64): import posix @@ -252,6 +255,10 @@ proc mainProc() = singletonInstance.engine.setRootContextProperty("featureFlagsRootContextProperty", newQVariant(singletonInstance.featureFlags())) statusq_registerQmlTypes() + + # Initialize push notifications (Android only) + when defined(android): + initializeAndroidPushNotifications() app.installEventFilter(urlSchemeEvent) diff --git a/src/statusq_bridge.nim b/src/statusq_bridge.nim index b25947b0998..64802f2f355 100644 --- a/src/statusq_bridge.nim +++ b/src/statusq_bridge.nim @@ -1,3 +1,19 @@ # Declarations of methods exposed from StatusQ proc statusq_registerQmlTypes*() {.cdecl, importc.} + +when defined(android): + # Push notification callback types + type + PushNotificationTokenCallback* = proc(token: cstring) {.cdecl.} + PushNotificationReceivedCallback* = proc(encryptedMessage: cstring, chatId: cstring, publicKey: cstring) {.cdecl.} + + # Android push notification initialization + proc statusq_initPushNotifications*( + tokenCallback: PushNotificationTokenCallback, + receivedCallback: PushNotificationReceivedCallback + ) {.cdecl, importc.} + + # Android notification permission (Android 13+) + proc statusq_requestNotificationPermission*() {.cdecl, importc.} + proc statusq_hasNotificationPermission*(): bool {.cdecl, importc.} diff --git a/ui/StatusQ/CMakeLists.txt b/ui/StatusQ/CMakeLists.txt index a48c67e61b1..c15333e4933 100644 --- a/ui/StatusQ/CMakeLists.txt +++ b/ui/StatusQ/CMakeLists.txt @@ -186,6 +186,7 @@ add_library(StatusQ ${LIB_TYPE} include/StatusQ/typesregistration.h include/StatusQ/undefinedfilter.h include/StatusQ/urlutils.h + include/StatusQ/pushnotification_android.h src/audioutils.cpp src/clipboardutils.cpp src/constantrole.cpp @@ -250,6 +251,7 @@ elseif (${CMAKE_SYSTEM_NAME} MATCHES "Android") target_sources(StatusQ PRIVATE src/keychain_android.cpp src/safutils_android.cpp + src/pushnotification_android.cpp ) else () target_sources(StatusQ PRIVATE diff --git a/ui/StatusQ/include/StatusQ/pushnotification_android.h b/ui/StatusQ/include/StatusQ/pushnotification_android.h new file mode 100644 index 00000000000..fa0086f2682 --- /dev/null +++ b/ui/StatusQ/include/StatusQ/pushnotification_android.h @@ -0,0 +1,178 @@ +#pragma once + +#include +#include + +#ifdef Q_OS_ANDROID +#include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +// C API for Nim integration +typedef void (*PushNotificationTokenCallback)(const char* token); +typedef void (*PushNotificationReceivedCallback)(const char* encryptedMessage, const char* chatId, const char* publicKey); + +/** + * Initialize push notifications with callbacks + * This is called from Nim code with cdecl callback functions + * + * @param tokenCallback Function to call when FCM token is received + * @param receivedCallback Function to call when push notification is received + */ +void statusq_initPushNotifications( + PushNotificationTokenCallback tokenCallback, + PushNotificationReceivedCallback receivedCallback +); + +/** + * Show a notification (called from Nim via existing OSNotification) + * + * @param title Notification title + * @param message Notification message + * @param identifier JSON metadata + */ +void statusq_showAndroidNotification(const char* title, const char* message, const char* identifier); + +#ifdef __cplusplus +} +#endif + +#ifdef Q_OS_ANDROID + +/** + * Android Push Notification Bridge + * + * This class bridges between: + * - Java Android code (FCM, NotificationManager) + * - Qt/C++ layer + * - Nim backend + * + * It handles: + * - FCM token registration + * - Notification display + * - JNI callbacks from Java + */ +class PushNotificationAndroid : public QObject +{ + Q_OBJECT + +public: + /** + * Get singleton instance + */ + static PushNotificationAndroid* instance(); + + /** + * Initialize push notifications with callbacks + * - Stores Nim callbacks + * - Registers JNI native methods + * - Requests FCM token + * + * @param tokenCallback Callback for FCM token reception + * @param receivedCallback Callback for push notification reception + */ + void initialize(PushNotificationTokenCallback tokenCallback, + PushNotificationReceivedCallback receivedCallback); + + /** + * Check if notification permission is granted (Android 13+ only) + * @return true if permission granted or not required (Android 12-) + */ + bool hasNotificationPermission(); + + /** + * Request notification permission (Android 13+ only) + * Shows system permission dialog on Android 13+ + * Does nothing on Android 12 and below + */ + void requestNotificationPermission(); + + /** + * Request FCM token from Firebase + * The token will be delivered via tokenReceived() signal + */ + void requestFCMToken(); + + /** + * Show a notification (called from Nim layer) + * + * @param title Notification title + * @param message Notification message + * @param identifier JSON string with metadata (chatId, etc.) + */ + void showNotification(const QString& title, const QString& message, const QString& identifier); + + /** + * Clear notifications for a specific chat + * + * @param chatId The chat identifier + */ + void clearNotifications(const QString& chatId); + +signals: + /** + * Emitted when FCM token is received from Firebase + * + * @param token The FCM device token + */ + void tokenReceived(const QString& token); + + /** + * Emitted when a push notification is received (via FCM) + * This is for the encrypted payload that needs to be processed by status-go + * + * @param encryptedMessage The encrypted message data + * @param chatId The chat identifier + * @param publicKey The sender's public key + */ + void pushNotificationReceived(const QString& encryptedMessage, + const QString& chatId, + const QString& publicKey); + + /** + * Emitted when user taps a notification + * + * @param identifier The notification identifier (contains chatId, etc.) + */ + void notificationTapped(const QString& identifier); + +private: + PushNotificationAndroid(QObject* parent = nullptr); + ~PushNotificationAndroid() = default; + + /** + * Register JNI native methods + * This allows Java code to call C++ methods + */ + void registerNativeMethods(); + + /** + * Check if Google Play Services is available + */ + bool isGooglePlayServicesAvailable(); + + // JNI callback handlers (called from Java) + friend void jni_onFCMTokenReceived(JNIEnv* env, jobject obj, jstring token); + friend void jni_onPushNotificationReceived(JNIEnv* env, jobject obj, + jstring encryptedMessage, + jstring chatId, + jstring publicKey); + + static PushNotificationAndroid* s_instance; + bool m_initialized; +}; + +// JNI callback functions (implemented in .cpp) +// Note: These are C++ functions, not extern "C", because they're registered via JNI RegisterNatives +// which handles the calling convention. They're declared as friends above. +void jni_onFCMTokenReceived(JNIEnv* env, jobject obj, jstring token); +void jni_onPushNotificationReceived(JNIEnv* env, jobject obj, + jstring encryptedMessage, + jstring chatId, + jstring publicKey); + +#endif // Q_OS_ANDROID + diff --git a/ui/StatusQ/src/externc.cpp b/ui/StatusQ/src/externc.cpp index baa2ab6218b..8289330dec9 100644 --- a/ui/StatusQ/src/externc.cpp +++ b/ui/StatusQ/src/externc.cpp @@ -1,8 +1,13 @@ #include +#include #include #include +#ifdef Q_OS_ANDROID +#include +#endif + extern "C" { Q_DECL_EXPORT void statusq_registerQmlTypes() { @@ -13,4 +18,65 @@ Q_DECL_EXPORT float statusq_getMobileUIScaleFactor(float baseWidth, float baseDp return MobileUI::getSmartScaleFactor(baseWidth, baseDpi, baseScale); } +// ============================================================================ +// Android Push Notifications C API +// ============================================================================ + +Q_DECL_EXPORT void statusq_initPushNotifications( + PushNotificationTokenCallback tokenCallback, + PushNotificationReceivedCallback receivedCallback) +{ +#ifdef Q_OS_ANDROID + qDebug() << "[StatusQ C API] Initializing Android push notifications..."; + PushNotificationAndroid::instance()->initialize(tokenCallback, receivedCallback); +#else + Q_UNUSED(tokenCallback); + Q_UNUSED(receivedCallback); + qDebug() << "[StatusQ C API] Push notifications not available on this platform"; +#endif +} + +Q_DECL_EXPORT void statusq_requestNotificationPermission() +{ +#ifdef Q_OS_ANDROID + qDebug() << "[StatusQ C API] Requesting notification permission..."; + PushNotificationAndroid::instance()->requestNotificationPermission(); +#else + qDebug() << "[StatusQ C API] Permission request not needed on this platform"; +#endif +} + +Q_DECL_EXPORT bool statusq_hasNotificationPermission() +{ +#ifdef Q_OS_ANDROID + return PushNotificationAndroid::instance()->hasNotificationPermission(); +#else + return true; // Other platforms don't require permission +#endif +} + +Q_DECL_EXPORT void statusq_showAndroidNotification( + const char* title, + const char* message, + const char* identifier) +{ +#ifdef Q_OS_ANDROID + if (!title || !message || !identifier) { + qWarning() << "[StatusQ C API] Invalid notification parameters"; + return; + } + + PushNotificationAndroid::instance()->showNotification( + QString::fromUtf8(title), + QString::fromUtf8(message), + QString::fromUtf8(identifier) + ); +#else + Q_UNUSED(title); + Q_UNUSED(message); + Q_UNUSED(identifier); + qDebug() << "[StatusQ C API] showNotification not available on this platform"; +#endif +} + } // extern "C" diff --git a/ui/StatusQ/src/pushnotification_android.cpp b/ui/StatusQ/src/pushnotification_android.cpp new file mode 100644 index 00000000000..7850f095b89 --- /dev/null +++ b/ui/StatusQ/src/pushnotification_android.cpp @@ -0,0 +1,373 @@ +#include "StatusQ/pushnotification_android.h" + +#ifdef Q_OS_ANDROID + +#include +#include +#include +#include + +// Static instance and callbacks +PushNotificationAndroid* PushNotificationAndroid::s_instance = nullptr; +static PushNotificationTokenCallback s_tokenCallback = nullptr; +static PushNotificationReceivedCallback s_receivedCallback = nullptr; + +PushNotificationAndroid::PushNotificationAndroid(QObject* parent) + : QObject(parent) + , m_initialized(false) +{ +} + +PushNotificationAndroid* PushNotificationAndroid::instance() +{ + if (!s_instance) { + s_instance = new PushNotificationAndroid(qApp); + } + return s_instance; +} + +void PushNotificationAndroid::initialize(PushNotificationTokenCallback tokenCallback, + PushNotificationReceivedCallback receivedCallback) +{ + if (m_initialized) { + qDebug() << "[PushNotificationAndroid] Already initialized"; + return; + } + + qDebug() << "[PushNotificationAndroid] Initializing..."; + + // Store callbacks + s_tokenCallback = tokenCallback; + s_receivedCallback = receivedCallback; + + // Check if Google Play Services is available + if (!isGooglePlayServicesAvailable()) { + qWarning() << "[PushNotificationAndroid] Google Play Services not available"; + return; + } + + // Register JNI native methods + registerNativeMethods(); + + // Request FCM token + requestFCMToken(); + + m_initialized = true; + qDebug() << "[PushNotificationAndroid] Initialization complete"; +} + +bool PushNotificationAndroid::hasNotificationPermission() +{ + QJniEnvironment env; + if (!env.isValid()) { + qWarning() << "[PushNotificationAndroid] Invalid JNI environment"; + return false; + } + + QJniObject activity = QNativeInterface::QAndroidApplication::context(); + if (!activity.isValid()) { + qWarning() << "[PushNotificationAndroid] Failed to get Android context"; + return false; + } + + jboolean result = QJniObject::callStaticMethod( + "app/status/mobile/PushNotificationHelper", + "hasNotificationPermission", + "(Landroid/content/Context;)Z", + activity.object() + ); + + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + return false; + } + + return result; +} + +void PushNotificationAndroid::requestNotificationPermission() +{ + qDebug() << "[PushNotificationAndroid] Requesting notification permission..."; + + QJniEnvironment env; + if (!env.isValid()) { + qWarning() << "[PushNotificationAndroid] Invalid JNI environment"; + return; + } + + QJniObject activity = QNativeInterface::QAndroidApplication::context(); + if (!activity.isValid()) { + qWarning() << "[PushNotificationAndroid] Failed to get Android context"; + return; + } + + QJniObject::callStaticMethod( + "app/status/mobile/PushNotificationHelper", + "requestNotificationPermission", + "(Landroid/content/Context;)V", + activity.object() + ); + + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + qWarning() << "[PushNotificationAndroid] Exception requesting permission"; + return; + } + + qDebug() << "[PushNotificationAndroid] Permission request sent"; +} + +void PushNotificationAndroid::requestFCMToken() +{ + qDebug() << "[PushNotificationAndroid] Requesting FCM token via Java layer..."; + + QJniEnvironment env; + if (!env.isValid()) { + qWarning() << "[PushNotificationAndroid] Invalid JNI environment"; + return; + } + + // Get Android application context + QJniObject activity = QNativeInterface::QAndroidApplication::context(); + if (!activity.isValid()) { + qWarning() << "[PushNotificationAndroid] Failed to get Android context"; + return; + } + + // Check permission first + if (!hasNotificationPermission()) { + qWarning() << "[PushNotificationAndroid] Notification permission not granted!"; + qWarning() << "[PushNotificationAndroid] Call requestNotificationPermission() first"; + } + + // Call PushNotificationHelper.requestFCMToken() which handles the Task listener + QJniObject::callStaticMethod( + "app/status/mobile/PushNotificationHelper", + "requestFCMToken", + "(Landroid/content/Context;)V", + activity.object() + ); + + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + qWarning() << "[PushNotificationAndroid] Exception requesting FCM token"; + return; + } + + qDebug() << "[PushNotificationAndroid] FCM token request sent, waiting for callback..."; +} + +void PushNotificationAndroid::showNotification(const QString& title, + const QString& message, + const QString& identifier) +{ + qDebug() << "[PushNotificationAndroid] Showing notification:" << title; + + QJniEnvironment env; + if (!env.isValid()) { + qWarning() << "[PushNotificationAndroid] Invalid JNI environment"; + return; + } + + // Call Java helper method to display notification + QJniObject::callStaticMethod( + "app/status/mobile/PushNotificationHelper", + "showNotification", + "(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V", + QJniObject::fromString(title).object(), + QJniObject::fromString(message).object(), + QJniObject::fromString(identifier).object() + ); + + // Check for exceptions + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + qWarning() << "[PushNotificationAndroid] Exception showing notification"; + } +} + +void PushNotificationAndroid::clearNotifications(const QString& chatId) +{ + qDebug() << "[PushNotificationAndroid] Clearing notifications for chat:" << chatId; + + QJniEnvironment env; + if (!env.isValid()) { + return; + } + + QJniObject::callStaticMethod( + "app/status/mobile/PushNotificationHelper", + "clearNotifications", + "(Ljava/lang/String;)V", + QJniObject::fromString(chatId).object() + ); + + if (env->ExceptionCheck()) { + env->ExceptionDescribe(); + env->ExceptionClear(); + } +} + +void PushNotificationAndroid::registerNativeMethods() +{ + qDebug() << "[PushNotificationAndroid] Registering JNI native methods..."; + + QJniEnvironment env; + if (!env.isValid()) { + qWarning() << "[PushNotificationAndroid] Invalid JNI environment"; + return; + } + + // Find the PushNotificationHelper class + jclass helperClass = env->FindClass("app/status/mobile/PushNotificationHelper"); + if (!helperClass) { + qWarning() << "[PushNotificationAndroid] Could not find PushNotificationHelper class"; + env->ExceptionDescribe(); + env->ExceptionClear(); + return; + } + + // Define native methods + JNINativeMethod methods[] = { + { + const_cast("nativeOnFCMTokenReceived"), + const_cast("(Ljava/lang/String;)V"), + reinterpret_cast(jni_onFCMTokenReceived) + }, + { + const_cast("nativeOnPushNotificationReceived"), + const_cast("(Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V"), + reinterpret_cast(jni_onPushNotificationReceived) + } + }; + + // Register methods + jint result = env->RegisterNatives(helperClass, methods, 2); + if (result != JNI_OK) { + qWarning() << "[PushNotificationAndroid] Failed to register native methods:" << result; + env->ExceptionDescribe(); + env->ExceptionClear(); + } else { + qDebug() << "[PushNotificationAndroid] Native methods registered successfully"; + } + + env->DeleteLocalRef(helperClass); +} + +bool PushNotificationAndroid::isGooglePlayServicesAvailable() +{ + QJniEnvironment env; + if (!env.isValid()) { + return false; + } + + // Check if Google Play Services is available + // This is a simplified check - in production, you might want more thorough checking + try { + QJniObject context = QJniObject::callStaticObjectMethod( + "org/qtproject/qt/android/QtNative", + "activity", + "()Landroid/app/Activity;" + ); + + if (!context.isValid()) { + return false; + } + + // Try to get FirebaseApp - if this works, Firebase is available + QJniObject firebaseApp = QJniObject::callStaticObjectMethod( + "com/google/firebase/FirebaseApp", + "getInstance", + "()Lcom/google/firebase/FirebaseApp;" + ); + + return firebaseApp.isValid(); + + } catch (...) { + qWarning() << "[PushNotificationAndroid] Exception checking Play Services"; + return false; + } +} + +// ============================================================================ +// JNI Callback Implementations +// ============================================================================ + +void jni_onFCMTokenReceived(JNIEnv* env, jobject obj, jstring token) +{ + Q_UNUSED(obj); + + if (!env || !token) { + qWarning() << "[PushNotificationAndroid] Invalid parameters in onFCMTokenReceived"; + return; + } + + // Convert jstring to QString and const char* + const char* tokenChars = env->GetStringUTFChars(token, nullptr); + QString tokenStr = QString::fromUtf8(tokenChars); + + qDebug() << "[PushNotificationAndroid] FCM Token received:" << tokenStr; + + // Call Nim callback if registered + if (s_tokenCallback != nullptr) { + qDebug() << "[PushNotificationAndroid] Calling Nim callback with token"; + s_tokenCallback(tokenChars); + } else { + qWarning() << "[PushNotificationAndroid] No callback registered for token!"; + } + + env->ReleaseStringUTFChars(token, tokenChars); + + // Also emit signal for backward compatibility + if (PushNotificationAndroid::s_instance) { + emit PushNotificationAndroid::s_instance->tokenReceived(tokenStr); + } +} + +void jni_onPushNotificationReceived(JNIEnv* env, jobject obj, + jstring encryptedMessage, + jstring chatId, + jstring publicKey) +{ + Q_UNUSED(obj); + + if (!env || !encryptedMessage || !chatId || !publicKey) { + qWarning() << "[PushNotificationAndroid] Invalid parameters in onPushNotificationReceived"; + return; + } + + // Convert jstrings to const char* + const char* encMsgChars = env->GetStringUTFChars(encryptedMessage, nullptr); + const char* chatIdChars = env->GetStringUTFChars(chatId, nullptr); + const char* pubKeyChars = env->GetStringUTFChars(publicKey, nullptr); + + qDebug() << "[PushNotificationAndroid] Push notification received for chat:" << chatIdChars; + + // Call Nim callback if registered + if (s_receivedCallback != nullptr) { + qDebug() << "[PushNotificationAndroid] Calling Nim callback with notification data"; + s_receivedCallback(encMsgChars, chatIdChars, pubKeyChars); + } else { + qWarning() << "[PushNotificationAndroid] No callback registered for notifications!"; + } + + env->ReleaseStringUTFChars(encryptedMessage, encMsgChars); + env->ReleaseStringUTFChars(chatId, chatIdChars); + env->ReleaseStringUTFChars(publicKey, pubKeyChars); + + // Also emit signal for backward compatibility + if (PushNotificationAndroid::s_instance) { + emit PushNotificationAndroid::s_instance->pushNotificationReceived( + QString::fromUtf8(encMsgChars), + QString::fromUtf8(chatIdChars), + QString::fromUtf8(pubKeyChars) + ); + } +} + +#endif // Q_OS_ANDROID +