mirror of
https://git.quad4.io/RNS-Things/MeshChatX.git
synced 2026-04-26 23:55:46 +00:00
feat(android): implement foreground service and notification handling for message synchronization
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user