From dba102f5802ca46f95d64dd68c2ba47a999e5a8e Mon Sep 17 00:00:00 2001 From: Ivan Date: Fri, 24 Apr 2026 17:59:09 -0500 Subject: [PATCH] feat(android): implement foreground service and notification handling for message synchronization --- android/app/src/main/AndroidManifest.xml | 12 +- .../meshchatx/AndroidNotificationBridge.java | 70 ++++++++++++ .../main/java/com/meshchatx/MainActivity.java | 107 +++++++++++++++--- .../com/meshchatx/MeshChatApplication.java | 53 +++++++++ .../meshchatx/MeshChatForegroundService.java | 59 ++++++++++ 5 files changed, 281 insertions(+), 20 deletions(-) create mode 100644 android/app/src/main/java/com/meshchatx/AndroidNotificationBridge.java create mode 100644 android/app/src/main/java/com/meshchatx/MeshChatApplication.java create mode 100644 android/app/src/main/java/com/meshchatx/MeshChatForegroundService.java diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 57567d1..65d52ad 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -8,6 +8,7 @@ + @@ -18,12 +19,13 @@ + + android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|uiMode|keyboard|keyboardHidden|navigation"> @@ -62,6 +64,12 @@ + + diff --git a/android/app/src/main/java/com/meshchatx/AndroidNotificationBridge.java b/android/app/src/main/java/com/meshchatx/AndroidNotificationBridge.java new file mode 100644 index 0000000..cc51431 --- /dev/null +++ b/android/app/src/main/java/com/meshchatx/AndroidNotificationBridge.java @@ -0,0 +1,70 @@ +package com.meshchatx; + +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.os.Handler; +import android.os.Looper; +import android.text.TextUtils; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +public final class AndroidNotificationBridge { + private static final int NOTIFY_BASE_ID = 0x4d434800; + + private AndroidNotificationBridge() { + } + + public static void showInboundMessage(String title, String body, @Nullable String dedupeHex) { + Context ctx = MeshChatApplication.getAppContext(); + if (ctx == null) { + return; + } + String safeTitle = TextUtils.isEmpty(title) ? ctx.getString(R.string.app_name) : title; + String safeBody = TextUtils.isEmpty(body) ? ctx.getString(R.string.notification_new_message_fallback) : body; + + new Handler(Looper.getMainLooper()).post(() -> postInboundMessage(ctx, safeTitle, safeBody, dedupeHex)); + } + + private static void postInboundMessage(Context ctx, String title, String body, @Nullable String dedupeHex) { + NotificationManager nm = ctx.getSystemService(NotificationManager.class); + if (nm == null) { + return; + } + + Intent open = new Intent(ctx, MainActivity.class); + open.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); + PendingIntent pi = PendingIntent.getActivity( + ctx, + 0, + open, + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + + NotificationCompat.Builder b = new NotificationCompat.Builder(ctx, MeshChatApplication.CHANNEL_ID_MESSAGES) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(title) + .setContentText(body) + .setStyle(new NotificationCompat.BigTextStyle().bigText(body)) + .setContentIntent(pi) + .setAutoCancel(true) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setVisibility(NotificationCompat.VISIBILITY_PRIVATE); + + int id = NOTIFY_BASE_ID; + if (dedupeHex != null && dedupeHex.length() >= 8) { + try { + id = NOTIFY_BASE_ID + (int) (Long.parseLong(dedupeHex.substring(0, Math.min(8, dedupeHex.length())), 16) & 0x7fff_ffff); + } catch (NumberFormatException ignored) { + id = NOTIFY_BASE_ID + (dedupeHex.hashCode() & 0x7fff_ffff); + } + } + + try { + nm.notify(id, b.build()); + } catch (SecurityException ignored) { + } + } +} diff --git a/android/app/src/main/java/com/meshchatx/MainActivity.java b/android/app/src/main/java/com/meshchatx/MainActivity.java index 8d1039f..3f7fada 100644 --- a/android/app/src/main/java/com/meshchatx/MainActivity.java +++ b/android/app/src/main/java/com/meshchatx/MainActivity.java @@ -8,6 +8,7 @@ import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.pm.PackageManager; +import android.content.res.Configuration; import android.hardware.usb.UsbManager; import android.net.Uri; import android.os.Build; @@ -149,7 +150,6 @@ public class MainActivity extends AppCompatActivity { Python.start(new AndroidPlatform(this)); } requestRuntimePermissionsIfNeeded(); - requestBatteryOptimizationExemptionIfNeeded(); WebSettings webSettings = webView.getSettings(); webSettings.setJavaScriptEnabled(true); @@ -286,7 +286,12 @@ public class MainActivity extends AppCompatActivity { missingPermissions.add(Manifest.permission.CAMERA); } if (missingPermissions.isEmpty()) { - request.grant(request.getResources()); + String[] allowed = grantableWebPermissionResources(request); + if (allowed.length > 0) { + request.grant(allowed); + } else { + request.deny(); + } return; } @@ -299,6 +304,15 @@ public class MainActivity extends AppCompatActivity { }); } + @Override + public void onPermissionRequestCanceled(PermissionRequest request) { + runOnUiThread(() -> { + if (pendingWebPermissionRequest == request) { + pendingWebPermissionRequest = null; + } + }); + } + @Override public boolean onShowFileChooser( WebView webView, @@ -346,6 +360,20 @@ public class MainActivity extends AppCompatActivity { scheduleConnectionRetry("Connecting to local server..."); } + @Override + protected void onStart() { + super.onStart(); + stopService(new Intent(this, MeshChatForegroundService.class)); + } + + @Override + protected void onStop() { + if (!isFinishing() && !backendFailed) { + ContextCompat.startForegroundService(this, new Intent(this, MeshChatForegroundService.class)); + } + super.onStop(); + } + @Override protected void onNewIntent(Intent intent) { super.onNewIntent(intent); @@ -372,6 +400,8 @@ public class MainActivity extends AppCompatActivity { missingPermissions.toArray(new String[0]), RUNTIME_PERMISSIONS_REQUEST_CODE ); + } else { + requestBatteryOptimizationExemptionIfNeeded(); } } @@ -410,6 +440,42 @@ public class MainActivity extends AppCompatActivity { } } + private String[] grantableWebPermissionResources(PermissionRequest request) { + if (request == null) { + return new String[0]; + } + List allowed = new ArrayList<>(); + for (String resource : request.getResources()) { + if (PermissionRequest.RESOURCE_AUDIO_CAPTURE.equals(resource)) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) + == PackageManager.PERMISSION_GRANTED) { + allowed.add(resource); + } + } else if (PermissionRequest.RESOURCE_VIDEO_CAPTURE.equals(resource)) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) + == PackageManager.PERMISSION_GRANTED) { + allowed.add(resource); + } + } else { + allowed.add(resource); + } + } + return allowed.toArray(new String[0]); + } + + private void completePendingWebPermissionRequestFromRuntimeState() { + if (pendingWebPermissionRequest == null) { + return; + } + String[] allowed = grantableWebPermissionResources(pendingWebPermissionRequest); + if (allowed.length > 0) { + pendingWebPermissionRequest.grant(allowed); + } else { + pendingWebPermissionRequest.deny(); + } + pendingWebPermissionRequest = null; + } + @Override public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { super.onRequestPermissionsResult(requestCode, permissions, grantResults); @@ -425,7 +491,12 @@ public class MainActivity extends AppCompatActivity { } } if (granted) { - pendingWebPermissionRequest.grant(pendingWebPermissionRequest.getResources()); + String[] allowed = grantableWebPermissionResources(pendingWebPermissionRequest); + if (allowed.length > 0) { + pendingWebPermissionRequest.grant(allowed); + } else { + pendingWebPermissionRequest.deny(); + } } else { pendingWebPermissionRequest.deny(); } @@ -435,21 +506,8 @@ public class MainActivity extends AppCompatActivity { if (requestCode != RUNTIME_PERMISSIONS_REQUEST_CODE) { return; } - for (int i = 0; i < permissions.length; i++) { - if (grantResults[i] != PackageManager.PERMISSION_GRANTED) { - if (Manifest.permission.RECORD_AUDIO.equals(permissions[i]) && pendingWebPermissionRequest != null) { - pendingWebPermissionRequest.deny(); - pendingWebPermissionRequest = null; - } - } - } - if ( - pendingWebPermissionRequest != null && - ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED - ) { - pendingWebPermissionRequest.grant(pendingWebPermissionRequest.getResources()); - pendingWebPermissionRequest = null; - } + requestBatteryOptimizationExemptionIfNeeded(); + completePendingWebPermissionRequestFromRuntimeState(); } private void startMeshChatServer() { @@ -581,8 +639,21 @@ public class MainActivity extends AppCompatActivity { } } + @Override + public void onConfigurationChanged(Configuration newConfig) { + super.onConfigurationChanged(newConfig); + if (webView != null) { + webView.post(() -> { + if (webView != null) { + webView.requestLayout(); + } + }); + } + } + @Override protected void onDestroy() { + stopService(new Intent(this, MeshChatForegroundService.class)); super.onDestroy(); mainHandler.removeCallbacksAndMessages(null); if (pendingWebPermissionRequest != null) { diff --git a/android/app/src/main/java/com/meshchatx/MeshChatApplication.java b/android/app/src/main/java/com/meshchatx/MeshChatApplication.java new file mode 100644 index 0000000..ad04105 --- /dev/null +++ b/android/app/src/main/java/com/meshchatx/MeshChatApplication.java @@ -0,0 +1,53 @@ +package com.meshchatx; + +import android.app.Application; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; + +import com.chaquo.python.android.PyApplication; + +public class MeshChatApplication extends PyApplication { + public static final String CHANNEL_ID_MESSAGES = "meshchatx_messages"; + public static final String CHANNEL_ID_BACKGROUND = "meshchatx_background"; + + private static volatile Context appContext; + + public static Context getAppContext() { + return appContext; + } + + @Override + public void onCreate() { + super.onCreate(); + appContext = getApplicationContext(); + createNotificationChannels(); + } + + private void createNotificationChannels() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + return; + } + NotificationManager nm = getSystemService(NotificationManager.class); + if (nm == null) { + return; + } + + NotificationChannel background = new NotificationChannel( + CHANNEL_ID_BACKGROUND, + getString(R.string.notification_channel_background_name), + NotificationManager.IMPORTANCE_LOW + ); + background.setDescription(getString(R.string.notification_channel_background_desc)); + nm.createNotificationChannel(background); + + NotificationChannel messages = new NotificationChannel( + CHANNEL_ID_MESSAGES, + getString(R.string.notification_channel_messages_name), + NotificationManager.IMPORTANCE_DEFAULT + ); + messages.setDescription(getString(R.string.notification_channel_messages_desc)); + nm.createNotificationChannel(messages); + } +} diff --git a/android/app/src/main/java/com/meshchatx/MeshChatForegroundService.java b/android/app/src/main/java/com/meshchatx/MeshChatForegroundService.java new file mode 100644 index 0000000..11ed06c --- /dev/null +++ b/android/app/src/main/java/com/meshchatx/MeshChatForegroundService.java @@ -0,0 +1,59 @@ +package com.meshchatx; + +import android.app.Notification; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Intent; +import android.content.pm.ServiceInfo; +import android.os.Build; +import android.os.IBinder; + +import androidx.annotation.Nullable; +import androidx.core.app.NotificationCompat; + +public class MeshChatForegroundService extends Service { + private static final int FG_NOTIFICATION_ID = 0x4d434801; + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + Notification notification = buildForegroundNotification(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + FG_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ); + } else { + startForeground(FG_NOTIFICATION_ID, notification); + } + return START_STICKY; + } + + private Notification buildForegroundNotification() { + PendingIntent pi = PendingIntent.getActivity( + this, + 0, + new Intent(this, MainActivity.class).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP), + PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE + ); + return new NotificationCompat.Builder(this, MeshChatApplication.CHANNEL_ID_BACKGROUND) + .setSmallIcon(R.drawable.ic_launcher_foreground) + .setContentTitle(getString(R.string.notification_background_title)) + .setContentText(getString(R.string.notification_background_text)) + .setOngoing(true) + .setContentIntent(pi) + .build(); + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + return null; + } + + @Override + public void onDestroy() { + stopForeground(STOP_FOREGROUND_REMOVE); + super.onDestroy(); + } +}