feat(android): implement foreground service and notification handling for message synchronization

This commit is contained in:
Ivan
2026-04-24 17:59:09 -05:00
parent c4a0116f51
commit dba102f580
5 changed files with 281 additions and 20 deletions
+10 -2
View File
@@ -8,6 +8,7 @@
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" />
@@ -18,12 +19,13 @@
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_ADVERTISE" />
<uses-feature android:name="android.hardware.microphone" android:required="false" />
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="false" />
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
<application
android:name="com.chaquo.python.android.PyApplication"
android:name="com.meshchatx.MeshChatApplication"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
@@ -37,7 +39,7 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:configChanges="orientation|screenSize|keyboardHidden">
android:configChanges="orientation|screenSize|smallestScreenSize|screenLayout|density|uiMode|keyboard|keyboardHidden|navigation">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
@@ -62,6 +64,12 @@
</intent-filter>
</activity>
<service
android:name=".MeshChatForegroundService"
android:exported="false"
android:foregroundServiceType="dataSync"
android:stopWithTask="true" />
</application>
</manifest>
@@ -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) {
}
}
}
@@ -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<String> 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) {
@@ -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);
}
}
@@ -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();
}
}