Skip to content
Merged
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 4 additions & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
4 changes: 2 additions & 2 deletions android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()}\""
}
Expand Down
10 changes: 10 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!-- Multicast RTP over Wi-Fi (#68): without a multicast lock the driver drops it -->
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
<!-- Start on boot (#255): the receiver opens the app when the setting is on -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<!-- Android 10+ lets an app start an activity from a boot receiver only with this (#255) -->
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
Expand Down Expand Up @@ -59,6 +63,12 @@
<data android:mimeType="text/plain" />
</intent-filter>
</activity>
<!-- Start on boot (#255); BOOT_COMPLETED is a protected broadcast: only the system sends it -->
<receiver android:name="BootReceiver" android:exported="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED" />
</intent-filter>
</receiver>
</application>

</manifest>
126 changes: 126 additions & 0 deletions android/app/src/main/java/com/openipc/decoder/BootReceiver.java
Original file line number Diff line number Diff line change
@@ -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);
}
Comment thread
compot-dev marked this conversation as resolved.
Resolved

/** For /stats.json, with or without an activity (Qodo on #256). */
static Map<String, Object> state(Context context) {
Map<String, Object> 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;
Comment thread
compot-dev marked this conversation as resolved.
Resolved
}
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);
Comment on lines +81 to +84

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

4. Boot start on android 15+ may still be blocked 🐞 Bug ≡ Correctness

onReceive calls showOverlay() and then calls context.startActivity(start) straight away, in
the same main-thread turn. wm.addView only queues the window: the 1×1 view has not been laid out,
drawn or put on screen when startActivity runs, so the overlay may not count as visible yet when
Android 15+ decides whether to allow the background start. In that case the start is blocked even
with the permission granted, the 2 s postDelayed only removes the overlay afterwards, and the
Android 10 device test does not cover this path.
Agent Prompt
## Issue description
On Android 15+ the boot receiver adds a transparent overlay window and calls startActivity in the same main-thread turn. The window has not been drawn yet, so the background start may still be blocked.

## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/BootReceiver.java[81-94]

## Recommended Fix
When an overlay is shown, call goAsync() right away. Then call startActivity only once the view has been drawn: for example from a ViewTreeObserver.OnDrawListener / OnPreDrawListener on the overlay view, or from a short postDelayed (a few hundred ms) on the main Handler. Remove the overlay and call pending.finish() OVERLAY_MS after the start. Keep the immediate start when no overlay is shown. Log when the start is attempted, so a failed start on Android 15+ can be diagnosed.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

} 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ private Map<String, Object> 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;
}
Expand Down
38 changes: 35 additions & 3 deletions android/app/src/main/java/com/openipc/decoder/Decoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand All @@ -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). */
Expand Down Expand Up @@ -1588,6 +1605,8 @@ private Map<String, Object> debugDevice() {
// The video decoders and what they can do: instances, adaptive playback (#88)
List<Map<String, Object>> 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;
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
38 changes: 36 additions & 2 deletions android/app/src/main/java/com/openipc/decoder/MenuManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
}

Expand Down Expand Up @@ -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");

Expand Down Expand Up @@ -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);
Comment thread
compot-dev marked this conversation as resolved.
Resolved
// 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) -> {
Expand All @@ -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());
Expand All @@ -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.
Expand Down
Loading
Loading