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();
+ }
+}