Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
| **S** | Режим воспроизведения: все кадры или только ключевые (картинка обновляется раз в интервал ключевых кадров, MJPEG — раз в секунду), минимальная нагрузка на декодер. Работает и в мультивиде, и в одиночном режиме, вид не меняет |
| **Settings** | Изменить адрес и транспорт активного слота (только в режиме одной камеры) |
| **WebUI** | Открыть веб-интерфейс камеры; логин и пароль берутся из RTSP-адреса (только в режиме одной камеры) |
| **Reset** | Очистить все настройки после подтверждения (фокус на **Cancel**), чтобы настроить приложение с чистого листа: удаляются слоты камер, транспорты и остальные настройки, приложение открывается как сразу после установки. Сами камеры не меняются |
| **Exit** | Закрыть приложение. Мелкий текст рядом — версия приложения и хеш сборки. |

Включённые пункты — D, Q, S, U и активный слот — залиты оранжевым; пункт, на котором стоит пульт, обведён голубой рамкой.
Expand Down
1 change: 1 addition & 0 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
| **S** | 播放模式:全部帧或仅关键帧(画面按关键帧间隔更新,MJPEG 每秒一帧),解码器负载最低。多画面和单画面均适用,不切换视图 |
| **Settings** | 编辑当前槽位的地址和传输方式(仅单路模式) |
| **WebUI** | 打开摄像机网页界面,用户名和密码取自 RTSP 地址(仅单路模式) |
| **Reset** | 确认后清除所有设置(默认焦点在 **Cancel**),以便从头重新配置:删除摄像机槽位、传输方式和其他设置,应用像刚安装后一样打开。摄像机本身不受影响 |
| **Exit** | 退出应用。旁边的小字是应用版本号和构建哈希。 |

已开启的项目(D、Q、S、U 和当前槽位)以橙色填充;遥控器焦点所在的项目带浅蓝色边框。
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 29
versionName "2.04"
versionCode 30
versionName "2.05"

buildConfigField "String", "GIT_HASH", "\"${getGitHash()}\""
}
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
tools:targetApi="33">
<!-- Separate process: it survives the crashed main process being killed -->
<activity android:name="Crash" android:exported="false" android:process=":crash" />
<!-- Reset (#171): ends the main process and opens the app again from another one -->
<activity android:name="Restart" android:exported="false" android:process=":restart"
android:excludeFromRecents="true" android:noHistory="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<activity
android:name="Decoder"
android:launchMode="singleTop"
Expand Down
50 changes: 50 additions & 0 deletions android/app/src/main/java/com/openipc/decoder/Decoder.java
Original file line number Diff line number Diff line change
Expand Up @@ -478,6 +478,56 @@ private boolean leaveUdpView() {
@Override
public void onExit() { finishAndRemoveTask(); }

/**
* Reset (#171): stops every stream, deletes the stored settings, the key of the encrypted
* camera URLs and what WebUI kept (cookies, HTTP logins), then starts the app in a new
* process, so nothing kept in memory survives either.
*/
@Override
public void onReset() {
Log.i(TAG, "Reset: clearing all settings");
// commit(), not apply(): the process ends soon after. If the write fails, nothing
// else is touched and the app stays as it was, to try again (Qodo on #172)
if (!getSharedPreferences("settings", MODE_PRIVATE).edit().clear().commit()) {
Log.w(TAG, "Reset: the settings could not be cleared");
Toast.makeText(this, "Could not clear the settings, please try again",
Toast.LENGTH_LONG).show();
return;
}
if (quadEnabled) stopQuad();
if (rtspClient != null) rtspClient.stop();
closeDecoder();
closeAudio();
if (Build.VERSION.SDK_INT >= 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());
}
Comment on lines +501 to +507

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. Reset can retain the url encryption key 📎 Requirement gap ⛨ Security

onStartOver() catches every exception from KeystoreCipher.deleteKey() and continues with the
restart. If the keystore operation fails, the camera URL encryption key remains while the app still
proceeds as though the reset completed.
Agent Prompt
## Issue description
The reset path logs a failed camera URL key deletion and continues, so the required encryption key can remain after the app restarts.

## Fix Focus Areas
- android/app/src/main/java/com/openipc/decoder/Decoder.java[495-500]

## Recommended Fix
Treat key deletion failure as a failed reset: report the error to the user, do not launch or terminate the replacement process, and allow the user to retry after the keystore operation succeeds.

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
74 changes: 64 additions & 10 deletions android/app/src/main/java/com/openipc/decoder/MenuManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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();
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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();
});
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down
37 changes: 37 additions & 0 deletions android/app/src/main/java/com/openipc/decoder/Restart.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading