From ab685fb0e9342b590c28bf74d3ad47749681613a Mon Sep 17 00:00:00 2001 From: gold Date: Thu, 1 Oct 2026 01:50:14 +0800 Subject: [PATCH] [Feature] (plugin): the server survives a dozing phone - a notification the user allows and a battery exemption the user grants Two permissions decide whether a server on Android lives or dies, and both are invisible until something goes wrong: without POST_NOTIFICATIONS (13+) a foreground service cannot show the notification that is its reason to exist, and without the battery exemption a dozing phone freezes the process - the listener stays in /proc/net and stops accepting, which from the other side of the socket looks exactly like a network fault. The control screen now shows the state of both and asks for whichever is missing, in the order that makes sense: the notification prompt is a dialog of ours, the battery one leaves the app entirely, and coming back from it re-runs the other branch through onResume. A denial is said out loud rather than left to be discovered. The manifest carries the two permissions; the service's own notification was already there. --- .../app/src/main/AndroidManifest.xml | 6 ++ .../plugin/ServerControlActivity.java | 97 +++++++++++++++++++ 2 files changed, 103 insertions(+) diff --git a/android-plugin/app/src/main/AndroidManifest.xml b/android-plugin/app/src/main/AndroidManifest.xml index 3eabdf3d..586dc70f 100644 --- a/android-plugin/app/src/main/AndroidManifest.xml +++ b/android-plugin/app/src/main/AndroidManifest.xml @@ -8,6 +8,12 @@ + + + requestKeepAlive()); + LinearLayout.LayoutParams allowParams = new LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT); + allowParams.topMargin = dp(6); + contentLayout.addView(allowBackgroundButton, allowParams); + LinearLayout buttons = new LinearLayout(this); buttons.setOrientation(LinearLayout.HORIZONTAL); LinearLayout.LayoutParams buttonsParams = new LinearLayout.LayoutParams( @@ -514,6 +544,73 @@ public final class ServerControlActivity extends Activity { statusView.setTextColor(color); } + // ---- keeping the server alive ---------------------------------------------------------- + + private static final int NOTIFICATION_PERMISSION_REQUEST = 40614; + + private boolean notificationsAllowed() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true; + return checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) + == PackageManager.PERMISSION_GRANTED; + } + + private boolean batteryExempt() { + PowerManager power = getSystemService(PowerManager.class); + return power != null && power.isIgnoringBatteryOptimizations(getPackageName()); + } + + private void refreshKeepAlive() { + if (keepAliveView == null) return; + boolean notifications = notificationsAllowed(); + boolean exempt = batteryExempt(); + keepAliveView.setText("Keep alive: notification " + (notifications ? "allowed" : "NOT allowed") + + ", battery exemption " + (exempt ? "granted" : "NOT granted")); + keepAliveView.setTextColor(notifications && exempt ? COLOR_PASS : COLOR_WARN); + if (allowBackgroundButton != null) allowBackgroundButton.setEnabled(!(notifications && exempt)); + } + + private void requestKeepAlive() { + // One at a time, and in this order: the notification prompt is a system dialog of ours, + // while the battery one leaves the app entirely - so it goes second, and coming back from + // it re-runs the other branch through onResume. + if (!notificationsAllowed() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + requestPermissions(new String[] { Manifest.permission.POST_NOTIFICATIONS }, + NOTIFICATION_PERMISSION_REQUEST); + return; + } + if (!batteryExempt()) { + try { + startActivity(new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, + Uri.parse("package:" + getPackageName()))); + } catch (RuntimeException missingDialog) { + // Some builds ship without that dialog; the list is the honest fallback rather than + // a button that does nothing. + Log.w(TAG, "no battery-optimization dialog; opening the list", missingDialog); + startActivity(new Intent(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)); + } + return; + } + setStatus("Already kept alive: the notification is allowed and the battery exemption is granted.", + COLOR_PASS); + } + + @Override + protected void onResume() { + super.onResume(); + refreshKeepAlive(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] results) { + super.onRequestPermissionsResult(requestCode, permissions, results); + if (requestCode != NOTIFICATION_PERMISSION_REQUEST) return; + refreshKeepAlive(); + // The denial is worth saying out loud: the server still runs, but its notification is gone, + // and a service the system cannot show is a service it may stop. + if (!notificationsAllowed()) + setStatus("Notifications are denied, so the server has no visible notification. " + + "Android may stop it in the background.", COLOR_WARN); + } private TextView makeText(String text, int sizeSp, int color, boolean bold) { TextView view = new TextView(this); view.setText(text);