diff --git a/README.md b/README.md
index 4af1eba8..26d60abb 100644
--- a/README.md
+++ b/README.md
@@ -68,6 +68,7 @@ A beta channel is available on [Google Play](https://play.google.com/apps/intern
| **S** | The playback mode: every frame, or key frames only (the picture updates once per key frame interval, MJPEG once a second), the lowest decoder load. Works in multi-view and in the single view and never changes the view |
| **Settings** | Edit the URL and the transport of the active slot (single-camera mode only) |
| **WebUI** | Open the camera's web interface; credentials come from the RTSP URL (single-camera mode only) |
+| **Reset** | Clear all settings after a confirmation (**Cancel** has the focus) to set the app up again from scratch: camera slots, transports and the other settings are removed, and the app opens as right after installing. The cameras are not changed |
| **Exit** | Close the app. The small text next to it is the app version and build hash. |
Items that are on — D, Q, S, U and the active slot — have an orange fill; the item the remote is on has a light blue frame.
diff --git a/README.ru.md b/README.ru.md
index 16fb009f..8d62df0a 100644
--- a/README.ru.md
+++ b/README.ru.md
@@ -68,6 +68,7 @@
| **S** | Режим воспроизведения: все кадры или только ключевые (картинка обновляется раз в интервал ключевых кадров, MJPEG — раз в секунду), минимальная нагрузка на декодер. Работает и в мультивиде, и в одиночном режиме, вид не меняет |
| **Settings** | Изменить адрес и транспорт активного слота (только в режиме одной камеры) |
| **WebUI** | Открыть веб-интерфейс камеры; логин и пароль берутся из RTSP-адреса (только в режиме одной камеры) |
+| **Reset** | Очистить все настройки после подтверждения (фокус на **Cancel**), чтобы настроить приложение с чистого листа: удаляются слоты камер, транспорты и остальные настройки, приложение открывается как сразу после установки. Сами камеры не меняются |
| **Exit** | Закрыть приложение. Мелкий текст рядом — версия приложения и хеш сборки. |
Включённые пункты — D, Q, S, U и активный слот — залиты оранжевым; пункт, на котором стоит пульт, обведён голубой рамкой.
diff --git a/README.zh-CN.md b/README.zh-CN.md
index f1f01458..82700195 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -68,6 +68,7 @@
| **S** | 播放模式:全部帧或仅关键帧(画面按关键帧间隔更新,MJPEG 每秒一帧),解码器负载最低。多画面和单画面均适用,不切换视图 |
| **Settings** | 编辑当前槽位的地址和传输方式(仅单路模式) |
| **WebUI** | 打开摄像机网页界面,用户名和密码取自 RTSP 地址(仅单路模式) |
+| **Reset** | 确认后清除所有设置(默认焦点在 **Cancel**),以便从头重新配置:删除摄像机槽位、传输方式和其他设置,应用像刚安装后一样打开。摄像机本身不受影响 |
| **Exit** | 退出应用。旁边的小字是应用版本号和构建哈希。 |
已开启的项目(D、Q、S、U 和当前槽位)以橙色填充;遥控器焦点所在的项目带浅蓝色边框。
diff --git a/android/app/build.gradle b/android/app/build.gradle
index e3fc69f7..00cccadb 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 29
- versionName "2.04"
+ versionCode 30
+ versionName "2.05"
buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
}
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 33e1180b..922a4fe1 100755
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -27,6 +27,10 @@
tools:targetApi="33">
+
+
= Build.VERSION_CODES.M) {
+ try {
+ KeystoreCipher.deleteKey();
+ } catch (Exception e) {
+ // The URLs it encrypted are gone already; the next save reuses the key
+ Log.w(TAG, "Reset: cannot delete the URL key: " + e.getMessage());
+ }
+ }
+ try {
+ android.webkit.WebViewDatabase.getInstance(this).clearHttpAuthUsernamePassword();
+ android.webkit.CookieManager cookies = android.webkit.CookieManager.getInstance();
+ // Restarted only once the cookies are really gone (Qodo on #172)
+ cookies.removeAllCookies(removed -> {
+ cookies.flush();
+ restartClean();
+ });
+ } catch (Exception e) {
+ // No WebView on this device (#15): nothing stored there either
+ restartClean();
+ }
+ }
+
+ /** Starts the app again in a new process after a reset (#171), through {@link Restart}. */
+ private void restartClean() {
+ Intent restart = new Intent(this, Restart.class);
+ restart.putExtra(Restart.EXTRA_PID, android.os.Process.myPid());
+ restart.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
+ startActivity(restart);
+ }
+
@Override
public void onShowSettings() { menuManager.showUrlEditor(mActive, mHosts, mTransports); }
diff --git a/android/app/src/main/java/com/openipc/decoder/KeystoreCipher.java b/android/app/src/main/java/com/openipc/decoder/KeystoreCipher.java
index 1e15b573..e8080186 100644
--- a/android/app/src/main/java/com/openipc/decoder/KeystoreCipher.java
+++ b/android/app/src/main/java/com/openipc/decoder/KeystoreCipher.java
@@ -53,6 +53,13 @@ class KeystoreCipher implements CredentialStore.Cipherer {
if (key == null) throw new IllegalStateException("No key in the Android Keystore");
}
+ /** Deletes the key, so stored URLs can never be read again (Reset, #171). */
+ static void deleteKey() throws Exception {
+ KeyStore ks = KeyStore.getInstance(KEYSTORE);
+ ks.load(null);
+ if (ks.containsAlias(ALIAS)) ks.deleteEntry(ALIAS);
+ }
+
@Override
public byte[] encrypt(byte[] plain) throws Exception {
Cipher c = Cipher.getInstance(TRANSFORMATION);
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 6b9f13f0..830bb8d9 100644
--- a/android/app/src/main/java/com/openipc/decoder/MenuManager.java
+++ b/android/app/src/main/java/com/openipc/decoder/MenuManager.java
@@ -66,6 +66,8 @@ class MenuManager {
/** The item a remote starts from: the active slot, Q or U (#91). */
private TextView mFocusItem;
private TextView mSettingsBtn;
+ /** Settings | WebUI, shown in the single view only (#173). */
+ private LinearLayout mSettingsRow;
private TextView mWebUiBtn;
TextView[] camButtons;
private Dialog mBrowserDialog;
@@ -81,6 +83,8 @@ interface Listener {
/** D: debug text (resolution, codec, fps) in every multi-view cell on or off. */
void onToggleStats();
void onExit();
+ /** Reset: every stored setting cleared, the app restarted as on first start (#171). */
+ void onReset();
void onShowSettings();
void onSaveSettings();
void onWebUI();
@@ -191,16 +195,17 @@ public boolean dispatchKeyEvent(KeyEvent event) {
LinearLayout.LayoutParams.WRAP_CONTENT));
mSettingsBtn = createItem("Settings");
- layout.addView(mSettingsBtn);
- mSettingsBtn.setVisibility(single ? View.VISIBLE : View.GONE);
final TextView quadBtn = createItem("Q");
quadBtn.setGravity(Gravity.CENTER);
quadBtn.setPadding(dp(12), dp(8), dp(12), dp(8));
mWebUiBtn = createItem("WebUI");
- layout.addView(mWebUiBtn);
- mWebUiBtn.setVisibility(single ? View.VISIBLE : View.GONE);
+ // Two items per row keep the menu short (#173); the row is for the single view only
+ mSettingsRow = pairRow(mSettingsBtn, mWebUiBtn);
+ layout.addView(mSettingsRow, new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
+ mSettingsRow.setVisibility(single ? View.VISIBLE : View.GONE);
camButtons = new TextView[CAM_COUNT];
mCachedCamButtons = new TextView[CAM_COUNT];
@@ -295,9 +300,18 @@ public boolean dispatchKeyEvent(KeyEvent event) {
layout.addView(divider, new LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT, dp(1)));
+ // Two deliberate presses: Reset, then Clear settings in the confirmation (#171)
+ TextView reset = createItem("Reset");
+ reset.setOnClickListener(v -> {
+ PopupWindow p = mMenuPopup;
+ if (p != null) p.dismiss();
+ confirmReset();
+ });
+
TextView exit = createItem("Exit");
- layout.addView(exit);
exit.setText(s);
+ layout.addView(pairRow(exit, reset), new LinearLayout.LayoutParams(
+ LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT));
exit.setOnClickListener(v -> {
if (listener != null) listener.onExit();
});
@@ -322,11 +336,8 @@ public boolean dispatchKeyEvent(KeyEvent event) {
private void updateCachedMenu(boolean quadEnabled, int mActive, String[] mHosts) {
boolean single = !quadEnabled && !udpShown;
- if (mSettingsBtn != null) {
- mSettingsBtn.setVisibility(single ? View.VISIBLE : View.GONE);
- }
- if (mWebUiBtn != null) {
- mWebUiBtn.setVisibility(single ? View.VISIBLE : View.GONE);
+ if (mSettingsRow != null) {
+ mSettingsRow.setVisibility(single ? View.VISIBLE : View.GONE);
}
if (mCachedQuadBtn != null) {
if (quadEnabled) highlightItem(mCachedQuadBtn);
@@ -393,6 +404,26 @@ static boolean isNavigationKey(int keyCode) {
}
}
+ /**
+ * Two menu items side by side, split by a thin line exactly in the middle (#173): both get
+ * half the width. Left / Right move between them with a remote.
+ */
+ private LinearLayout pairRow(TextView left, TextView right) {
+ LinearLayout row = new LinearLayout(activity);
+ row.setOrientation(LinearLayout.HORIZONTAL);
+ row.addView(left, new LinearLayout.LayoutParams(0,
+ LinearLayout.LayoutParams.WRAP_CONTENT, 1));
+ View line = new View(activity);
+ line.setBackgroundColor(Color.DKGRAY);
+ row.addView(line, new LinearLayout.LayoutParams(dp(1),
+ LinearLayout.LayoutParams.MATCH_PARENT));
+ row.addView(right, new LinearLayout.LayoutParams(0,
+ LinearLayout.LayoutParams.WRAP_CONTENT, 1));
+ left.setSingleLine(true);
+ right.setSingleLine(true);
+ return row;
+ }
+
private TextView createItem(String title) {
TextView text = new TextView(activity);
text.setText(title);
@@ -510,6 +541,29 @@ void showUrlEditor(int mActive, String[] mHosts, Transport.Mode[] mTransports) {
input.requestFocus();
}
+ /**
+ * Asks before clearing the settings (#171). Cancel has the focus, so a stray OK on a remote
+ * closes the dialog without deleting anything.
+ */
+ private void confirmReset() {
+ AlertDialog dialog = new AlertDialog.Builder(activity)
+ .setTitle("Start with a clean slate?")
+ .setMessage("This clears all camera slots, their transports and the other "
+ + "settings, so you can set the app up again from scratch. The app "
+ + "restarts and opens as right after installing. Your cameras are not "
+ + "changed.")
+ .setNegativeButton("Cancel", (d, which) -> d.cancel())
+ .setPositiveButton("Clear settings", (d, which) -> {
+ if (listener != null) listener.onReset();
+ })
+ .create();
+ dialog.setOnShowListener(d -> {
+ View cancel = dialog.getButton(AlertDialog.BUTTON_NEGATIVE);
+ if (cancel != null) cancel.requestFocus();
+ });
+ dialog.show();
+ }
+
private void focusChange(View item) {
paintItem(item, false);
item.setOnFocusChangeListener((v, hasFocus) -> paintItem(v, hasFocus));
diff --git a/android/app/src/main/java/com/openipc/decoder/Restart.java b/android/app/src/main/java/com/openipc/decoder/Restart.java
new file mode 100644
index 00000000..3ff28b9b
--- /dev/null
+++ b/android/app/src/main/java/com/openipc/decoder/Restart.java
@@ -0,0 +1,37 @@
+/*
+ *
+ * Copyright (c) OpenIPC https://openipc.org Prosperity Public License 3.0.0
+ *
+ * Restart.java — starts the app again in a new process after Reset (#171)
+ *
+ */
+
+package com.openipc.decoder;
+
+import android.app.Activity;
+import android.content.Intent;
+import android.os.Bundle;
+import android.os.Process;
+
+/**
+ * Runs in a process of its own (":restart"), like {@link Crash}: it ends the main process,
+ * then opens the app, which therefore starts in a fresh process with nothing kept in memory.
+ * Ending the main process from inside it right after startActivity() could take the new
+ * activity down with it (Qodo on #172, seen on Android 15).
+ */
+public class Restart extends Activity {
+ static final String EXTRA_PID = "pid";
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ int pid = getIntent() != null ? getIntent().getIntExtra(EXTRA_PID, -1) : -1;
+ if (pid > 0 && pid != Process.myPid()) Process.killProcess(pid);
+ Intent open = new Intent(this, Decoder.class);
+ open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
+ startActivity(open);
+ finish();
+ // This helper process is not needed any more
+ Runtime.getRuntime().exit(0);
+ }
+}