diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b3e271..fe91e5e 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/README.md b/README.md index 7551eeb..b9b0587 100644 --- a/README.md +++ b/README.md @@ -87,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 7741cf2..96d73d0 100644 --- a/README.ru.md +++ b/README.ru.md @@ -87,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 f23354a..3b8ff2e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -87,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/build.gradle b/android/app/build.gradle index 728f651..539f50d 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()}\"" } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index e224db1..a354971 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 0000000..34193a3 --- /dev/null +++ b/android/app/src/main/java/com/openipc/decoder/BootReceiver.java @@ -0,0 +1,126 @@ +/* + * + * 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.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; + +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); + } + + /** 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.openOrOpening()) { + 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"); + 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/DebugServer.java b/android/app/src/main/java/com/openipc/decoder/DebugServer.java index 07daace..9423405 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 c785280..efecde1 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); + sOpening = true; setContentView(R.layout.decoder); getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); @@ -592,7 +593,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 +603,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). */ @@ -1588,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; } @@ -2482,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 @@ -2580,6 +2600,7 @@ protected void onDestroy() { if (menuManager != null) menuManager.dismissAll(); getWindow().getDecorView().removeCallbacks(hideBarsRunnable); super.onDestroy(); + sOpening = false; DebugServer.get().clearSource(debugSource); streamProbes.shutdown(); audioManager.shutdown(); @@ -2591,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(); @@ -2638,12 +2660,22 @@ public void onWindowFocusChanged(boolean hasFocus) { protected void onResume() { super.onResume(); mResumed = true; + sOpening = false; + sShown = true; hideSystemBars(); startStreams(); updateDebugUrls(); } private boolean mResumed; + /** + * 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 volatile boolean sOpening, sShown; + + static boolean openOrOpening() { return sOpening || sShown; } private void startStreams() { singleStallSeconds = 0; 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 7687e08..8c70382 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; @@ -88,6 +89,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 +546,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,7 +598,16 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports, mirror.setText("Mirror"); mirror.setChecked(mMirrors[mActive]); content.addView(mirror); - builder.setView(content); + // 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); + // 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) -> { @@ -606,6 +618,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 +632,25 @@ 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 " + + "(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(); + 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 b906bed..0a7bbc3 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 2447b96..83b9449 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,24 @@ 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 + 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. + ## 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.