[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.
This commit is contained in:
gold
2026-10-01 01:50:14 +08:00
parent 1ae7a8eaa4
commit ab685fb0e9
2 changed files with 103 additions and 0 deletions
@@ -8,6 +8,12 @@
<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" />
<!-- The two permissions that decide whether the server survives a dozing phone: the
notification one lets the foreground service show the notification that is its reason to
exist (Android 13+), and the exemption one is what the user grants from the control screen
so the system stops freezing the process. -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application
android:hasCode="true"
@@ -1,9 +1,15 @@
package top.mobilegl.plugin;
import android.Manifest;
import android.app.Activity;
import android.app.ActivityManager;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.os.Build;
import android.os.PowerManager;
import android.provider.Settings;
import android.graphics.Typeface;
import android.net.LocalSocket;
import android.net.LocalSocketAddress;
@@ -58,6 +64,8 @@ public final class ServerControlActivity extends Activity {
private Button stopButton;
private TextView statusView;
private volatile Thread probeThread;
private Button allowBackgroundButton;
private TextView keepAliveView;
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -108,6 +116,28 @@ public final class ServerControlActivity extends Activity {
boxParams.topMargin = dp(8);
contentLayout.addView(onScreenBox, boxParams);
// KEEPING THE SERVER ALIVE IS TWO PERMISSIONS THE USER GIVES, and both are invisible
// until something goes wrong: without the notification one (Android 13+) the service
// notification cannot be shown, 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. This section shows the state of
// both, and asks for whichever is missing.
keepAliveView = makeText("", 12, COLOR_INFO, false);
LinearLayout.LayoutParams keepParams = new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT);
keepParams.topMargin = dp(12);
contentLayout.addView(keepAliveView, keepParams);
allowBackgroundButton = new Button(this);
allowBackgroundButton.setText("Allow background (notification + battery)");
allowBackgroundButton.setAllCaps(false);
allowBackgroundButton.setTextSize(TypedValue.COMPLEX_UNIT_SP, 13);
allowBackgroundButton.setOnClickListener(v -> 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);