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); + } +}