From 19aef69e3cdc59968be37d9024ac05fc222665b9 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 31 Oct 2026 16:30:05 +0000 Subject: [PATCH 1/8] feat: Start on boot, an opt-in setting that opens the app after the device boots (#255) A BOOT_COMPLETED receiver opens Decoder when the setting is on. Android 10+ needs the permission to draw over other apps for that start: turning the setting on opens the system screen for it, or shows the adb command where the device has none. /stats.json reports start_on_boot (on, overlay). --- README.md | 1 + README.ru.md | 1 + README.zh-CN.md | 1 + android/app/src/main/AndroidManifest.xml | 10 +++ .../com/openipc/decoder/BootReceiver.java | 62 +++++++++++++++++++ .../java/com/openipc/decoder/Decoder.java | 23 ++++++- .../java/com/openipc/decoder/MenuManager.java | 29 ++++++++- docs/debug.md | 1 + docs/usage.md | 17 +++++ 9 files changed, 143 insertions(+), 2 deletions(-) create mode 100644 android/app/src/main/java/com/openipc/decoder/BootReceiver.java diff --git a/README.md b/README.md index 7551eebc..7582e9b0 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,7 @@ A beta channel is available on [Google Play](https://play.google.com/apps/intern rtsp://username:password@ip_address:554/stream=0 ``` Under the URL you can choose the transport; Auto (UDP, falling back to TCP) is the default for a new slot. Below it, **Turn** turns the picture of a camera mounted sideways or upside down, and **Mirror** flips it left to right. Other URL types: [Transports](docs/transports.md). + **Start on boot** opens the app by itself after the device restarts, for a box that only shows cameras: [Start on boot](docs/usage.md#start-on-boot). 4. Press **Save**. The app connects straight away and reconnects automatically if the stream drops. ## Controls diff --git a/README.ru.md b/README.ru.md index 7741cf20..2c029025 100644 --- a/README.ru.md +++ b/README.ru.md @@ -66,6 +66,7 @@ rtsp://username:password@ip_address:554/stream=0 ``` Под адресом можно выбрать транспорт; у нового слота по умолчанию Auto (UDP с откатом на TCP). Ниже настройка **Turn** поворачивает картинку камеры, установленной боком или вверх ногами, а **Mirror** отражает её слева направо. Другие виды адресов — в [Transports](docs/transports.md) (на английском). + **Start on boot** открывает приложение само после перезапуска устройства — для приставки, которая только показывает камеры: [Start on boot](docs/usage.md#start-on-boot) (на английском). 4. Нажмите **Save**. Приложение сразу подключится и само переподключится, если поток прервётся. ## Управление diff --git a/README.zh-CN.md b/README.zh-CN.md index f23354ae..34f2e27a 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -66,6 +66,7 @@ rtsp://username:password@ip_address:554/stream=0 ``` 地址下方可选择传输方式,新槽位默认为 Auto(优先 UDP,必要时回退到 TCP)。其下的 **Turn** 可旋转画面,适用于侧装或倒装的摄像机;**Mirror** 可将画面左右镜像。其他地址格式见 [Transports](docs/transports.md)(英文)。 + **Start on boot** 可在设备重启后自动打开应用,适用于只用来显示摄像机画面的盒子:[Start on boot](docs/usage.md#start-on-boot)(英文)。 4. 点击 **Save**。应用会立即连接;视频流中断时会自动重连。 ## 操作说明 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e224db1e..a354971d 100755 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -6,6 +6,10 @@ + + + + + + + + + + diff --git a/android/app/src/main/java/com/openipc/decoder/BootReceiver.java b/android/app/src/main/java/com/openipc/decoder/BootReceiver.java new file mode 100644 index 00000000..4413f2cb --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/BootReceiver.java @@ -0,0 +1,62 @@ +/* + * + * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0 + * + * BootReceiver.java — opens the app after the device boots, when Start on boot is on (#255) + * + */ + +package com.openipc.decoder; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.os.Build; +import android.provider.Settings; +import android.util.Log; + +public class BootReceiver extends BroadcastReceiver { + private static final String TAG = "OpenIPCDecoder"; + /** In the "settings" preferences, so Reset (#171) turns it off too. */ + static final String PREF = "start_on_boot"; + + static boolean enabled(Context context) { + return context.getSharedPreferences("settings", Context.MODE_PRIVATE) + .getBoolean(PREF, false); + } + + static void setEnabled(Context context, boolean on) { + context.getSharedPreferences("settings", Context.MODE_PRIVATE).edit() + .putBoolean(PREF, on).apply(); + } + + /** + * Android 10+ starts an activity from the background only for an app that may draw over + * other apps; many TV boxes have no screen to allow it, adb does (appops). + */ + static boolean overlayNeeded() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q; + } + + static boolean overlayAllowed(Context context) { + return !overlayNeeded() || Settings.canDrawOverlays(context); + } + + @Override + public void onReceive(Context context, Intent intent) { + if (!Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) return; + if (!enabled(context)) return; + if (!overlayAllowed(context)) { + Log.w(TAG, "Start on boot: no permission to draw over other apps, Android may block the start"); + } + Log.i(TAG, "Start on boot: opening the app"); + // A firmware autorun may have opened it already: singleTop brings that one to the front + Intent start = new Intent(context, Decoder.class); + start.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + context.startActivity(start); + } catch (RuntimeException e) { + Log.w(TAG, "Start on boot failed", e); + } + } +} diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index c785280a..ead6217b 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -592,7 +592,8 @@ private void restartClean() { @Override public void onShowSettings() { - menuManager.showUrlEditor(mActive, mHosts, mTransports, mRotates, mMirrors); + menuManager.showUrlEditor(mActive, mHosts, mTransports, mRotates, mMirrors, + BootReceiver.enabled(this)); } @Override @@ -601,6 +602,21 @@ public void onShowSettings() { @Override public void onSaveSettings() { saveSettings(); } + @Override + public void onStartOnBoot(boolean on) { + BootReceiver.setEnabled(this, on); + Log.i(TAG, "Start on boot " + (on ? "on" : "off")); + if (!on || BootReceiver.overlayAllowed(this)) return; + // Android 10+ (#255): the system screen to allow drawing over other apps, if there is one + Intent allow = new Intent(android.provider.Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:" + getPackageName())); + try { + startActivity(allow); + } catch (ActivityNotFoundException e) { + menuManager.showBootPermissionHint(getPackageName()); + } + } + // ====================== Video lifecycle ====================== /** The single view's client for the active slot, with the slot's turn (#201). */ @@ -1598,6 +1614,11 @@ private Map debugState() { m.put("mode", !mResumed ? "paused" : udpViewShown ? "udp" : quadEnabled ? (slides ? "slides" : "multi-view") : "single"); m.put("active_slot", mActive + 1); + Map boot = new LinkedHashMap<>(); + boot.put("on", BootReceiver.enabled(this)); + boot.put("overlay", !BootReceiver.overlayNeeded() ? "not needed" + : BootReceiver.overlayAllowed(this) ? "allowed" : "missing"); + m.put("start_on_boot", boot); DecodePlanner planner = decodePlanner; if (quadEnabled && planner != null) { // Decoder capacity in macroblocks/s: declared, or learnt on this device (#85) diff --git a/android/app/src/main/java/com/openipc/decoder/MenuManager.java b/android/app/src/main/java/com/openipc/decoder/MenuManager.java index 7687e08f..4632711d 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -88,6 +88,8 @@ interface Listener { void onReset(); void onShowSettings(); void onSaveSettings(); + /** Start on boot turned on or off in Settings (#255). */ + void onStartOnBoot(boolean on); void onWebUI(); } @@ -543,7 +545,7 @@ private EditText createEdit(String title) { } void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports, - int[] mRotates, boolean[] mMirrors) { + int[] mRotates, boolean[] mMirrors, boolean startOnBoot) { AlertDialog.Builder builder = new AlertDialog.Builder(activity); builder.setTitle("Camera " + (mActive + 1) + " URL"); @@ -595,6 +597,11 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports, mirror.setText("Mirror"); mirror.setChecked(mMirrors[mActive]); content.addView(mirror); + // Start on boot (#255): for the app, not the slot + CheckBox boot = new CheckBox(activity); + boot.setText("Start on boot"); + boot.setChecked(startOnBoot); + content.addView(boot); builder.setView(content); final int slot = mActive; @@ -606,6 +613,9 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports, View turned = turn.findViewById(turn.getCheckedRadioButtonId()); if (turned != null) mRotates[slot] = (Integer) turned.getTag(); mMirrors[slot] = mirror.isChecked(); + if (listener != null && boot.isChecked() != startOnBoot) { + listener.onStartOnBoot(boot.isChecked()); + } if (listener != null) listener.onSaveSettings(); // triggers save + reconnect }); builder.setNegativeButton("Cancel", (dialog, which) -> dialog.cancel()); @@ -617,6 +627,23 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports, input.requestFocus(); } + /** + * Start on boot without the permission to draw over other apps (#255), on a device with no + * screen to allow it: the adb command that does. + */ + void showBootPermissionHint(String packageName) { + AlertDialog dialog = new AlertDialog.Builder(activity) + .setTitle("Start on boot") + .setMessage("Android opens the app after a reboot only if it may draw over other " + + "apps, and this device has no screen to allow it. Allow it over adb:\n\n" + + "adb shell appops set " + packageName + " SYSTEM_ALERT_WINDOW allow\n\n" + + "Or use the autorun of the device's own app list, if it has one.") + .setPositiveButton("OK", null) + .create(); + track(dialog); + dialog.show(); + } + /** * Asks before clearing the settings (#171). Cancel has the focus, so a stray OK on a remote * closes the dialog without deleting anything. diff --git a/docs/debug.md b/docs/debug.md index b906bed5..0a7bbc31 100644 --- a/docs/debug.md +++ b/docs/debug.md @@ -93,6 +93,7 @@ In the single view (and the U view) the page and the snapshot still show the who | `ui_thread` | string | `ok`, or `not responding within 1000 ms` (then only the fields above are present) | | `mode` | string | `single`, `multi-view`, `slides` (multi-view with Slides on), `udp` (U cell full-screen), `paused` (app in background); `no activity` when no activity is attached | | `active_slot` | number | active slot, 1–8 | +| `start_on_boot` | object | `on` (bool, [Start on boot](usage.md#start-on-boot)) and `overlay`: `allowed`, `missing` (Android may block the start) or `not needed` (before Android 10) | | `decoder` | object | multi-view only: `capacity` (macroblocks/s), `learnt` (bool), `failed_load` (macroblocks/s, 0: none), see [architecture.md](architecture.md#decode-planner) | | `decoder_stuck` | bool | multi-view only: frames arrive in at least two cells and none is decoded for 20 s | | `udp` | object | U view only: `port` (5600) and `addresses` (the device's IPs to send to) | diff --git a/docs/usage.md b/docs/usage.md index 2447b960..bc242507 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -10,6 +10,7 @@ - [WebUI](#webui) - [Opening a camera from another app](#opening-a-camera-from-another-app) - [Screenshots](#screenshots) +- [Start on boot](#start-on-boot) - [Reset](#reset) ## First camera @@ -19,6 +20,7 @@ 3. Enter the camera URL, for example `rtsp://user:password@192.168.1.10:554/stream=0`, and choose the transport under it (**TCP / UDP / Auto / Multicast**, a new slot starts on Auto). URL formats: [transports.md](transports.md). Below them, **Turn** (**Auto**, **0°**, **90° ↺**, **180°**, **270° ↺**, counter-clockwise) turns the picture of a camera mounted sideways or upside down ([#201](https://github.com/OpenIPC/decoder/issues/201)). **Auto** leaves it to the URL's `?rotate=` and the toys' defaults. It applies in the single view and in the grid, to H.264, H.265 and MJPEG, and to snapshots and screenshots; the decoder turns the picture (Android 6+; before that only MJPEG is turned). **Mirror** flips the picture left to right, after the turn ([#226](https://github.com/OpenIPC/decoder/issues/226)), in the single view and in the grid, for H.264, H.265 and MJPEG, and in snapshots and screenshots. A mirrored H.264/H.265 slot is drawn with OpenGL ES instead of straight by the decoder (this path also turns it, on any Android version); a mirrored MJPEG slot is decoded in software. + **Start on boot** is for the app, not the slot: see [Start on boot](#start-on-boot). 4. **Save**. The app connects at once and reconnects by itself when the stream drops (exponential back-off, 1 s up to 30 s). The URL dialog is titled `Camera N URL`. The picture is fitted to the screen whatever its shape, without cropping. URLs are stored encrypted, see [security.md](security.md#credential-store). @@ -118,6 +120,21 @@ adb shell am start -a android.intent.action.SEND -t text/plain \ A long press in the single view saves the picture to `Pictures/OpenIPC` (at most 3840×2160; a larger stream is scaled down). Screenshots need Android 7.0 or newer; Android 7–9 ask for the storage permission first. For a picture of the whole grid from another computer use `/snapshot.jpg` of the [debug page](debug.md#snapshot). +## Start on boot + +For a box that only shows cameras (a wall display, a stand): the app opens by itself after the device starts, with the saved slots and view ([#255](https://github.com/OpenIPC/decoder/issues/255)). + +- **The device's own autorun first.** Some TV-box firmwares have one in their app list: the Menu key on the Decoder tile, then **AUTORUN** (**CLEAN AUTORUN** turns it off). It needs nothing from the app. Do not turn on both: the app would be opened twice. +- **Otherwise, Start on boot** in **Settings** (any slot; off by default). It takes effect when the device has finished starting, which on a TV box can be a minute after power-on. +- Android 10 and newer open an app from the background only if it may draw over other apps. When you turn the setting on without that permission, the app opens the system screen *Display over other apps*. Many TV boxes have no such screen: the app then shows the adb command that allows it: + + ```sh + adb shell appops set com.openipc.decoder SYSTEM_ALERT_WINDOW allow + ``` + +- Whether it is on and allowed: `start_on_boot` in [`/stats.json`](debug.md#statsjson-fields). **Reset** turns it off. +- Waking a box from standby is up to the device: if it stays dark after power-on until a key is pressed, no app setting changes that. + ## Reset **Reset** asks *Start with a clean slate?*; **Clear settings** removes all camera slots, their transports and the other settings, and the app restarts as right after installing. The cameras are not changed. **Cancel** has the focus. From 12506f0585fc8323e13f7b84534c93e5ec93f780 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 31 Oct 2026 17:02:21 +0000 Subject: [PATCH 2/8] fix: Start on boot keeps the app op over a reboot, a second start does not blink the streams (#255) Android writes app ops to disk with a delay: an appops set followed soon by a reboot was lost on the test box, so the hint and the docs add appops write-settings. onNewIntent restarted the streams for any intent; a firmware autorun plus Start on boot opened the app twice and the second start restarted the decoder. Only a shared link (EXTRA_TEXT) restarts them now. --- android/app/src/main/java/com/openipc/decoder/Decoder.java | 5 +++-- .../app/src/main/java/com/openipc/decoder/MenuManager.java | 6 ++++-- docs/usage.md | 3 +++ 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index ead6217b..4395f821 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -2612,8 +2612,9 @@ protected void onNewIntent(Intent intent) { super.onNewIntent(intent); setIntent(intent); // Android 10+ may deliver it to the resumed activity without onPause()/onResume(): - // apply the shared link now - if (mResumed) { + // apply the shared link now. Without one (the launcher, Start on boot after a firmware + // autorun, #255) there is nothing to apply, and a restart would only blink the streams + if (mResumed && intent.hasExtra(Intent.EXTRA_TEXT)) { leaveUdpView(); stopStreams(); startStreams(); diff --git a/android/app/src/main/java/com/openipc/decoder/MenuManager.java b/android/app/src/main/java/com/openipc/decoder/MenuManager.java index 4632711d..dc0fcd7e 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -635,8 +635,10 @@ void showBootPermissionHint(String packageName) { AlertDialog dialog = new AlertDialog.Builder(activity) .setTitle("Start on boot") .setMessage("Android opens the app after a reboot only if it may draw over other " - + "apps, and this device has no screen to allow it. Allow it over adb:\n\n" - + "adb shell appops set " + packageName + " SYSTEM_ALERT_WINDOW allow\n\n" + + "apps, and this device has no screen to allow it. Allow it over adb " + + "(write-settings saves it at once, or a reboot soon after loses it):\n\n" + + "adb shell appops set " + packageName + " SYSTEM_ALERT_WINDOW allow\n" + + "adb shell appops write-settings\n\n" + "Or use the autorun of the device's own app list, if it has one.") .setPositiveButton("OK", null) .create(); diff --git a/docs/usage.md b/docs/usage.md index bc242507..83b9449d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -130,8 +130,11 @@ For a box that only shows cameras (a wall display, a stand): the app opens by it ```sh adb shell appops set com.openipc.decoder SYSTEM_ALERT_WINDOW allow + adb shell appops write-settings ``` + `write-settings` saves the change at once: Android writes app ops to disk with a delay, and a reboot soon after `appops set` loses it. + - Whether it is on and allowed: `start_on_boot` in [`/stats.json`](debug.md#statsjson-fields). **Reset** turns it off. - Waking a box from standby is up to the device: if it stays dark after power-on until a key is pressed, no app setting changes that. From bf02fbc5a70439b3b38548c8da809129b1ed9fb9 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 31 Oct 2026 17:10:04 +0000 Subject: [PATCH 3/8] fix: Start on boot leaves the app alone when a firmware autorun opened it already (#255) On the test box a second start paused and resumed the activity, which restarted every stream, so the receiver now does nothing while an activity of the app is on the screen. --- .../src/main/java/com/openipc/decoder/BootReceiver.java | 7 ++++++- android/app/src/main/java/com/openipc/decoder/Decoder.java | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/openipc/decoder/BootReceiver.java b/android/app/src/main/java/com/openipc/decoder/BootReceiver.java index 4413f2cb..eeb130c8 100644 --- a/android/app/src/main/java/com/openipc/decoder/BootReceiver.java +++ b/android/app/src/main/java/com/openipc/decoder/BootReceiver.java @@ -46,11 +46,16 @@ static boolean overlayAllowed(Context context) { public void onReceive(Context context, Intent intent) { if (!Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) return; if (!enabled(context)) return; + // A firmware autorun opened it already: another start would pause and resume it, + // which restarts every stream + if (Decoder.sShown) { + Log.i(TAG, "Start on boot: the app is open already"); + return; + } if (!overlayAllowed(context)) { Log.w(TAG, "Start on boot: no permission to draw over other apps, Android may block the start"); } Log.i(TAG, "Start on boot: opening the app"); - // A firmware autorun may have opened it already: singleTop brings that one to the front Intent start = new Intent(context, Decoder.class); start.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); try { diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 4395f821..0569754f 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -2503,6 +2503,7 @@ private boolean skipForKeyFrames(Frame buffer) { protected void onPause() { super.onPause(); mResumed = false; + sShown = false; streamProbes.stopAll(); menuManager.onPauseDismissBrowser(); // The UDP view is not a saved mode: after a resume the slot or grid comes back @@ -2660,12 +2661,15 @@ public void onWindowFocusChanged(boolean hasFocus) { protected void onResume() { super.onResume(); mResumed = true; + sShown = true; hideSystemBars(); startStreams(); updateDebugUrls(); } private boolean mResumed; + /** Some activity of the app is on the screen: Start on boot then leaves it alone (#255). */ + static volatile boolean sShown; private void startStreams() { singleStallSeconds = 0; From 0d7ab3881b7aab1ad5ea0bad4d8c529d62ddc949 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 31 Oct 2026 17:40:20 +0000 Subject: [PATCH 4/8] fix: the Settings dialog scrolls; a Start on boot section in the three READMEs (Qodo on #256) On a short landscape screen with the keyboard up, the new checkbox could end up below the dialog with no way to reach it. The READMEs get the short section the issue asked for: the device's own autorun first, otherwise the setting. --- README.md | 5 ++++- README.ru.md | 5 ++++- README.zh-CN.md | 5 ++++- .../app/src/main/java/com/openipc/decoder/MenuManager.java | 7 ++++++- 4 files changed, 18 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 7582e9b0..b9b0587f 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,6 @@ A beta channel is available on [Google Play](https://play.google.com/apps/intern rtsp://username:password@ip_address:554/stream=0 ``` Under the URL you can choose the transport; Auto (UDP, falling back to TCP) is the default for a new slot. Below it, **Turn** turns the picture of a camera mounted sideways or upside down, and **Mirror** flips it left to right. Other URL types: [Transports](docs/transports.md). - **Start on boot** opens the app by itself after the device restarts, for a box that only shows cameras: [Start on boot](docs/usage.md#start-on-boot). 4. Press **Save**. The app connects straight away and reconnects automatically if the stream drops. ## Controls @@ -88,6 +87,10 @@ A beta channel is available on [Google Play](https://play.google.com/apps/intern Every item, the remote, sharing a URL from another app and **Reset**: [Usage](docs/usage.md). +## Start on boot + +For a box that only shows cameras, the app can open by itself after the device starts. Use the device's own autorun first, where its app list has one (the Menu key on the Decoder tile → **AUTORUN**). Otherwise turn on **Start on boot** in **Settings**; Android 10 and newer also need the permission to draw over other apps, and the app shows how to give it. Details: [Start on boot](docs/usage.md#start-on-boot). + ## Known limitations > [!IMPORTANT] diff --git a/README.ru.md b/README.ru.md index 2c029025..96d73d09 100644 --- a/README.ru.md +++ b/README.ru.md @@ -66,7 +66,6 @@ rtsp://username:password@ip_address:554/stream=0 ``` Под адресом можно выбрать транспорт; у нового слота по умолчанию Auto (UDP с откатом на TCP). Ниже настройка **Turn** поворачивает картинку камеры, установленной боком или вверх ногами, а **Mirror** отражает её слева направо. Другие виды адресов — в [Transports](docs/transports.md) (на английском). - **Start on boot** открывает приложение само после перезапуска устройства — для приставки, которая только показывает камеры: [Start on boot](docs/usage.md#start-on-boot) (на английском). 4. Нажмите **Save**. Приложение сразу подключится и само переподключится, если поток прервётся. ## Управление @@ -88,6 +87,10 @@ Все пункты меню, пульт, передача адреса из другого приложения и **Reset** — в [Usage](docs/usage.md) (на английском). +## Запуск при включении + +Приставка, которая только показывает камеры, может открывать приложение сама после включения. Сначала используйте автозапуск самого устройства, если он есть в его списке приложений (кнопка Menu на плитке Decoder → **AUTORUN**). Если его нет — включите **Start on boot** в **Settings**; на Android 10 и новее нужно ещё разрешение показывать окна поверх других приложений, приложение подскажет, как его дать. Подробно: [Start on boot](docs/usage.md#start-on-boot) (на английском). + ## Известные ограничения > [!IMPORTANT] diff --git a/README.zh-CN.md b/README.zh-CN.md index 34f2e27a..3b8ff2ed 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -66,7 +66,6 @@ rtsp://username:password@ip_address:554/stream=0 ``` 地址下方可选择传输方式,新槽位默认为 Auto(优先 UDP,必要时回退到 TCP)。其下的 **Turn** 可旋转画面,适用于侧装或倒装的摄像机;**Mirror** 可将画面左右镜像。其他地址格式见 [Transports](docs/transports.md)(英文)。 - **Start on boot** 可在设备重启后自动打开应用,适用于只用来显示摄像机画面的盒子:[Start on boot](docs/usage.md#start-on-boot)(英文)。 4. 点击 **Save**。应用会立即连接;视频流中断时会自动重连。 ## 操作说明 @@ -88,6 +87,10 @@ 菜单各项、遥控器、从其他应用分享地址以及 **Reset** 的说明见 [Usage](docs/usage.md)(英文)。 +## 开机自启 + +只用来显示摄像机画面的盒子可以在开机后自动打开应用。如果设备自带的应用列表提供自启功能(在 Decoder 图标上按 Menu 键 → **AUTORUN**),请优先使用。否则在 **Settings** 中勾选 **Start on boot**;Android 10 及以上还需要"在其他应用上层显示"的权限,应用会提示如何授予。详见 [Start on boot](docs/usage.md#start-on-boot)(英文)。 + ## 已知限制 > [!IMPORTANT] diff --git a/android/app/src/main/java/com/openipc/decoder/MenuManager.java b/android/app/src/main/java/com/openipc/decoder/MenuManager.java index dc0fcd7e..8c70382f 100644 --- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java +++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java @@ -34,6 +34,7 @@ import android.widget.PopupWindow; import android.widget.RadioButton; import android.widget.RadioGroup; +import android.widget.ScrollView; import android.widget.TextView; import android.window.OnBackInvokedDispatcher; @@ -602,7 +603,11 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports, boot.setText("Start on boot"); boot.setChecked(startOnBoot); content.addView(boot); - builder.setView(content); + // A phone in landscape with the keyboard up is too short for all rows: they scroll + // (Qodo on #256) + ScrollView scroll = new ScrollView(activity); + scroll.addView(content); + builder.setView(scroll); final int slot = mActive; builder.setPositiveButton("Save", (dialog, which) -> { From 79f812b5b8a14608dd811c7bdc17ce017446d8a9 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 31 Oct 2026 17:50:38 +0000 Subject: [PATCH 5/8] fix: Start on boot checks for a live activity, not a resumed one; /stats.json has it without an activity (Qodo on #256) A firmware autorun that is still creating the activity when BOOT_COMPLETED arrives no longer gets a second start: the receiver checks for any Decoder activity alive (onCreate to onDestroy). The no-activity snapshot of the debug server reports start_on_boot too. --- .../java/com/openipc/decoder/BootReceiver.java | 14 +++++++++++++- .../java/com/openipc/decoder/DebugServer.java | 1 + .../main/java/com/openipc/decoder/Decoder.java | 18 +++++++++--------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/BootReceiver.java b/android/app/src/main/java/com/openipc/decoder/BootReceiver.java index eeb130c8..f83b169d 100644 --- a/android/app/src/main/java/com/openipc/decoder/BootReceiver.java +++ b/android/app/src/main/java/com/openipc/decoder/BootReceiver.java @@ -15,6 +15,9 @@ import android.provider.Settings; import android.util.Log; +import java.util.LinkedHashMap; +import java.util.Map; + public class BootReceiver extends BroadcastReceiver { private static final String TAG = "OpenIPCDecoder"; /** In the "settings" preferences, so Reset (#171) turns it off too. */ @@ -42,13 +45,22 @@ static boolean overlayAllowed(Context context) { return !overlayNeeded() || Settings.canDrawOverlays(context); } + /** For /stats.json, with or without an activity (Qodo on #256). */ + static Map state(Context context) { + Map m = new LinkedHashMap<>(); + m.put("on", enabled(context)); + m.put("overlay", !overlayNeeded() ? "not needed" + : overlayAllowed(context) ? "allowed" : "missing"); + return m; + } + @Override public void onReceive(Context context, Intent intent) { if (!Intent.ACTION_BOOT_COMPLETED.equals(intent.getAction())) return; if (!enabled(context)) return; // A firmware autorun opened it already: another start would pause and resume it, // which restarts every stream - if (Decoder.sShown) { + if (Decoder.sAlive.get() > 0) { Log.i(TAG, "Start on boot: the app is open already"); return; } diff --git a/android/app/src/main/java/com/openipc/decoder/DebugServer.java b/android/app/src/main/java/com/openipc/decoder/DebugServer.java index 07daace2..9423405a 100644 --- a/android/app/src/main/java/com/openipc/decoder/DebugServer.java +++ b/android/app/src/main/java/com/openipc/decoder/DebugServer.java @@ -304,6 +304,7 @@ private Map snapshot() { m.put("app", "OpenIPC Decoder"); m.put("server", state); m.put("mode", "no activity"); + if (App.context != null) m.put("start_on_boot", BootReceiver.state(App.context)); } return m; } diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 0569754f..e500e123 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -190,6 +190,7 @@ public class Decoder extends Activity @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); + sAlive.incrementAndGet(); setContentView(R.layout.decoder); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); @@ -1614,11 +1615,7 @@ private Map debugState() { m.put("mode", !mResumed ? "paused" : udpViewShown ? "udp" : quadEnabled ? (slides ? "slides" : "multi-view") : "single"); m.put("active_slot", mActive + 1); - Map boot = new LinkedHashMap<>(); - boot.put("on", BootReceiver.enabled(this)); - boot.put("overlay", !BootReceiver.overlayNeeded() ? "not needed" - : BootReceiver.overlayAllowed(this) ? "allowed" : "missing"); - m.put("start_on_boot", boot); + m.put("start_on_boot", BootReceiver.state(this)); DecodePlanner planner = decodePlanner; if (quadEnabled && planner != null) { // Decoder capacity in macroblocks/s: declared, or learnt on this device (#85) @@ -2503,7 +2500,6 @@ private boolean skipForKeyFrames(Frame buffer) { protected void onPause() { super.onPause(); mResumed = false; - sShown = false; streamProbes.stopAll(); menuManager.onPauseDismissBrowser(); // The UDP view is not a saved mode: after a resume the slot or grid comes back @@ -2602,6 +2598,7 @@ protected void onDestroy() { if (menuManager != null) menuManager.dismissAll(); getWindow().getDecorView().removeCallbacks(hideBarsRunnable); super.onDestroy(); + sAlive.decrementAndGet(); DebugServer.get().clearSource(debugSource); streamProbes.shutdown(); audioManager.shutdown(); @@ -2661,15 +2658,18 @@ public void onWindowFocusChanged(boolean hasFocus) { protected void onResume() { super.onResume(); mResumed = true; - sShown = true; hideSystemBars(); startStreams(); updateDebugUrls(); } private boolean mResumed; - /** Some activity of the app is on the screen: Start on boot then leaves it alone (#255). */ - static volatile boolean sShown; + /** + * Decoder activities alive, from onCreate to onDestroy: Start on boot leaves an app that a + * firmware autorun is opening alone, even before it resumes (#255, Qodo on #256). + */ + static final java.util.concurrent.atomic.AtomicInteger sAlive = + new java.util.concurrent.atomic.AtomicInteger(); private void startStreams() { singleStallSeconds = 0; From 35689ee860f820f9c994776f1a4927d99c5b7c96 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 31 Oct 2026 18:04:18 +0000 Subject: [PATCH 6/8] fix: Start on boot shows a 1x1 overlay window on Android 15+; start_on_boot also when the UI thread hangs (Qodo on #256) Android 15 lets an app that targets 35+ start an activity from the background with SYSTEM_ALERT_WINDOW only while one of its overlay windows is visible, so the receiver adds a transparent 1x1 overlay for two seconds around the start. start_on_boot moves to the part of /stats.json that needs no UI state. --- .../com/openipc/decoder/BootReceiver.java | 47 +++++++++++++++++++ .../java/com/openipc/decoder/Decoder.java | 3 +- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/android/app/src/main/java/com/openipc/decoder/BootReceiver.java b/android/app/src/main/java/com/openipc/decoder/BootReceiver.java index f83b169d..275b6a0b 100644 --- a/android/app/src/main/java/com/openipc/decoder/BootReceiver.java +++ b/android/app/src/main/java/com/openipc/decoder/BootReceiver.java @@ -8,12 +8,18 @@ package com.openipc.decoder; +import android.annotation.TargetApi; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; +import android.graphics.PixelFormat; import android.os.Build; +import android.os.Handler; +import android.os.Looper; import android.provider.Settings; import android.util.Log; +import android.view.View; +import android.view.WindowManager; import java.util.LinkedHashMap; import java.util.Map; @@ -70,10 +76,51 @@ public void onReceive(Context context, Intent intent) { Log.i(TAG, "Start on boot: opening the app"); Intent start = new Intent(context, Decoder.class); start.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + // Android 15+ lets an app that targets 35+ start from the background with this + // permission only while one of its overlay windows is visible (Qodo on #256) + View overlay = Build.VERSION.SDK_INT >= 35 && overlayAllowed(context) + ? showOverlay(context) : null; try { context.startActivity(start); } catch (RuntimeException e) { Log.w(TAG, "Start on boot failed", e); } + if (overlay != null) { + PendingResult pending = goAsync(); + new Handler(Looper.getMainLooper()).postDelayed(() -> { + removeOverlay(context, overlay); + pending.finish(); + }, OVERLAY_MS); + } + } + + /** Long enough for the start to pass the check, short enough to stay well under 10 s. */ + private static final long OVERLAY_MS = 2000; + + /** A 1×1 transparent window that takes no touch or focus; null if it cannot be shown. */ + @TargetApi(35) + private static View showOverlay(Context context) { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + View v = new View(context.getApplicationContext()); + WindowManager.LayoutParams lp = new WindowManager.LayoutParams(1, 1, + WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY, + WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE + | WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE, + PixelFormat.TRANSPARENT); + try { + wm.addView(v, lp); + return v; + } catch (RuntimeException e) { + Log.w(TAG, "Start on boot: no overlay window", e); + return null; + } + } + + private static void removeOverlay(Context context, View v) { + try { + ((WindowManager) context.getSystemService(Context.WINDOW_SERVICE)).removeView(v); + } catch (RuntimeException e) { + Log.w(TAG, "Start on boot: overlay window not removed", e); + } } } diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index e500e123..2e1fca8c 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -1605,6 +1605,8 @@ private Map debugDevice() { // The video decoders and what they can do: instances, adaptive playback (#88) List> decoders = CodecInfo.videoDecodersIfReady(); m.put("decoders", decoders != null ? decoders : "loading"); + // Preferences and a permission check: no UI state, so also when the UI thread hangs + m.put("start_on_boot", BootReceiver.state(this)); return m; } @@ -1615,7 +1617,6 @@ private Map debugState() { m.put("mode", !mResumed ? "paused" : udpViewShown ? "udp" : quadEnabled ? (slides ? "slides" : "multi-view") : "single"); m.put("active_slot", mActive + 1); - m.put("start_on_boot", BootReceiver.state(this)); DecodePlanner planner = decodePlanner; if (quadEnabled && planner != null) { // Decoder capacity in macroblocks/s: declared, or learnt on this device (#85) From 7b7ed049ee8cd6d466f23e82d2d53a4b29603af4 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 31 Oct 2026 18:08:41 +0000 Subject: [PATCH 7/8] fix: Start on boot skips an activity that is opening or shown, not one paused behind another app (Qodo on #256) --- .../java/com/openipc/decoder/BootReceiver.java | 2 +- .../main/java/com/openipc/decoder/Decoder.java | 17 +++++++++++------ 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/android/app/src/main/java/com/openipc/decoder/BootReceiver.java b/android/app/src/main/java/com/openipc/decoder/BootReceiver.java index 275b6a0b..34193a3e 100644 --- a/android/app/src/main/java/com/openipc/decoder/BootReceiver.java +++ b/android/app/src/main/java/com/openipc/decoder/BootReceiver.java @@ -66,7 +66,7 @@ public void onReceive(Context context, Intent intent) { if (!enabled(context)) return; // A firmware autorun opened it already: another start would pause and resume it, // which restarts every stream - if (Decoder.sAlive.get() > 0) { + if (Decoder.openOrOpening()) { Log.i(TAG, "Start on boot: the app is open already"); return; } diff --git a/android/app/src/main/java/com/openipc/decoder/Decoder.java b/android/app/src/main/java/com/openipc/decoder/Decoder.java index 2e1fca8c..efecde19 100755 --- a/android/app/src/main/java/com/openipc/decoder/Decoder.java +++ b/android/app/src/main/java/com/openipc/decoder/Decoder.java @@ -190,7 +190,7 @@ public class Decoder extends Activity @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); - sAlive.incrementAndGet(); + sOpening = true; setContentView(R.layout.decoder); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); @@ -2501,6 +2501,7 @@ private boolean skipForKeyFrames(Frame buffer) { protected void onPause() { super.onPause(); mResumed = false; + sShown = false; streamProbes.stopAll(); menuManager.onPauseDismissBrowser(); // The UDP view is not a saved mode: after a resume the slot or grid comes back @@ -2599,7 +2600,7 @@ protected void onDestroy() { if (menuManager != null) menuManager.dismissAll(); getWindow().getDecorView().removeCallbacks(hideBarsRunnable); super.onDestroy(); - sAlive.decrementAndGet(); + sOpening = false; DebugServer.get().clearSource(debugSource); streamProbes.shutdown(); audioManager.shutdown(); @@ -2659,6 +2660,8 @@ public void onWindowFocusChanged(boolean hasFocus) { protected void onResume() { super.onResume(); mResumed = true; + sOpening = false; + sShown = true; hideSystemBars(); startStreams(); updateDebugUrls(); @@ -2666,11 +2669,13 @@ protected void onResume() { private boolean mResumed; /** - * Decoder activities alive, from onCreate to onDestroy: Start on boot leaves an app that a - * firmware autorun is opening alone, even before it resumes (#255, Qodo on #256). + * The activity is being opened (onCreate until its first onResume) or is on the screen: + * Start on boot then leaves it alone, e.g. after a firmware autorun; one paused behind + * another app is brought back (#255, Qodo on #256). */ - static final java.util.concurrent.atomic.AtomicInteger sAlive = - new java.util.concurrent.atomic.AtomicInteger(); + static volatile boolean sOpening, sShown; + + static boolean openOrOpening() { return sOpening || sShown; } private void startStreams() { singleStallSeconds = 0; From cbee135b58614c53010f26be77e8fec2f6391f8d Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 31 Oct 2026 16:30:05 +0000 Subject: [PATCH 8/8] 2.19.0: Start on boot (#255) --- CHANGELOG.md | 10 ++++++++++ android/app/build.gradle | 4 ++-- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3e2716..fe91e5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ Versions after 2.17 are `MAJOR.MINOR.PATCH` (2.17 is followed by 2.17.1 or 2.18. `docs/testing.md`, "Versions and releases"); the headings below keep the numbers as they were released. +## 2.19.0 — 2026-10-31 + +- **Start on boot** in Settings, off by default: the app opens by itself after the device + starts, for a box that only shows cameras. Android 10+ needs the permission to draw over + other apps for it; turning the setting on opens that system screen, or shows the adb command + on devices without one. A firmware autorun, where the device has one, needs nothing from the + app. `/stats.json` adds `start_on_boot` (#255). +- Opening the app again while it plays (the launcher, a second autostart) no longer restarts + the streams; only a shared link does (#255). + ## 2.18.4 — 2026-10-22 - `udp://` slots and the U cell: the receive thread runs at the audio priority, so a push no diff --git a/android/app/build.gradle b/android/app/build.gradle index 728f6511..539f50de 100755 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -23,8 +23,8 @@ android { applicationId "com.openipc.decoder" minSdk 21 targetSdk 36 - versionCode 47 - versionName "2.18.4" + versionCode 48 + versionName "2.19.0" buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\"" }