From ef745bbbec78e7174427a9170d34f924670fba36 Mon Sep 17 00:00:00 2001 From: ghshhf-bot Date: Tue, 28 Jul 2026 03:32:04 +0800 Subject: [PATCH] feat: add 'keep chat images' toggle to preserve chat images across restarts (#563) Implement upstream issue #563: a user setting that, when enabled, stops ImagePoolManager from clearing the on-disk image cache on startup and from deleting disk files when the in-memory LRU pool evicts entries. Plumbing: new KEEP_CHAT_IMAGES DataStore preference, injected into ImagePoolManager at Application startup. UI: a Switch added to ChatHistorySettingsScreen with en/base string resources. Also adds JsCalculator JVM regression tests under core/tools. --- .../core/application/OperitApplication.kt | 6 ++ .../preferences/UserPreferencesManager.kt | 13 +++ .../screens/ChatHistorySettingsScreen.kt | 37 ++++++++ .../operit/util/ImagePoolManager.kt | 18 +++- app/src/main/res/values-en/strings.xml | 4 + app/src/main/res/values/strings.xml | 3 + .../calculator/JsCalculatorRegressionTest.kt | 94 +++++++++++++++++++ 7 files changed, 173 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/ai/assistance/operit/core/tools/calculator/JsCalculatorRegressionTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt b/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt index ebcea5312..598e2a6ee 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/application/OperitApplication.kt @@ -326,6 +326,12 @@ class OperitApplication : Application(), ImageLoaderFactory, WorkConfiguration.P AppLogger.d(TAG, "【启动计时】全局图片加载器初始化完成(超时配置:连接30s/读取60s) - ${System.currentTimeMillis() - startTime}ms") // 初始化图片池管理器,支持本地持久化缓存 + // 先读取「保留聊天图片」设置再初始化,避免启动即清空磁盘图片 + val keepChatImages = + runBlocking(Dispatchers.IO) { + UserPreferencesManager.getInstance(applicationContext).keepChatImages.first() + } + ImagePoolManager.keepChatImages = keepChatImages ImagePoolManager.initialize(filesDir, preloadNow = false) AppLogger.d(TAG, "【启动计时】图片池管理器初始化完成 - ${System.currentTimeMillis() - startTime}ms") diff --git a/app/src/main/java/com/ai/assistance/operit/data/preferences/UserPreferencesManager.kt b/app/src/main/java/com/ai/assistance/operit/data/preferences/UserPreferencesManager.kt index 92e551726..e3edfcb3f 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/preferences/UserPreferencesManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/preferences/UserPreferencesManager.kt @@ -88,6 +88,7 @@ class UserPreferencesManager private constructor(private val context: Context) { // 主题设置相关键 private val THEME_MODE = stringPreferencesKey("theme_mode") private val USE_SYSTEM_THEME = booleanPreferencesKey("use_system_theme") + private val KEEP_CHAT_IMAGES = booleanPreferencesKey("keep_chat_images") private val CUSTOM_PRIMARY_COLOR = intPreferencesKey("custom_primary_color") private val CUSTOM_SECONDARY_COLOR = intPreferencesKey("custom_secondary_color") private val USE_CUSTOM_COLORS = booleanPreferencesKey("use_custom_colors") @@ -557,6 +558,18 @@ class UserPreferencesManager private constructor(private val context: Context) { preferences[USE_SYSTEM_THEME] ?: true } + // 保留聊天图片(开启后图片不在重启/缓存淘汰时丢失) + val keepChatImages: Flow = + context.userPreferencesDataStore.data.map { preferences -> + preferences[KEEP_CHAT_IMAGES] ?: false + } + + suspend fun setKeepChatImages(value: Boolean) { + context.userPreferencesDataStore.edit { preferences -> + preferences[KEEP_CHAT_IMAGES] = value + } + } + val customPrimaryColor: Flow = context.userPreferencesDataStore.data.map { preferences -> preferences[CUSTOM_PRIMARY_COLOR] diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatHistorySettingsScreen.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatHistorySettingsScreen.kt index af068f99f..3c73d3332 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatHistorySettingsScreen.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/settings/screens/ChatHistorySettingsScreen.kt @@ -83,6 +83,9 @@ fun ChatHistorySettingsScreen() { val userPreferencesManager = remember { UserPreferencesManager.getInstance(context) } val activeProfileId by userPreferencesManager.activeMemorySpaceIdFlow.collectAsState(initial = "default") + // 保留聊天图片开关状态 + val keepChatImages by userPreferencesManager.keepChatImages.collectAsState(initial = false) + val characterCardStatsState by chatHistoryManager.characterCardStatsFlow .collectAsState(initial = null as List?) val characterCardStats = characterCardStatsState ?: emptyList() @@ -275,6 +278,40 @@ fun ChatHistorySettingsScreen() { activeProfileName = activeProfileName ) } + + // 保留聊天图片设置 + item { + ElevatedCard(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f).padding(end = 16.dp)) { + Text( + text = stringResource(id = R.string.keep_chat_images), + style = MaterialTheme.typography.titleMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = stringResource(id = R.string.keep_chat_images_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = keepChatImages, + onCheckedChange = { enabled -> + scope.launch { + userPreferencesManager.setKeepChatImages(enabled) + } + } + ) + } + } + } item { CharacterCardStatsCard( stats = characterCardStats, diff --git a/app/src/main/java/com/ai/assistance/operit/util/ImagePoolManager.kt b/app/src/main/java/com/ai/assistance/operit/util/ImagePoolManager.kt index 90fabf0cd..b68528806 100644 --- a/app/src/main/java/com/ai/assistance/operit/util/ImagePoolManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/util/ImagePoolManager.kt @@ -57,6 +57,14 @@ object ImagePoolManager { } } + /** + * 是否保留聊天图片。开启后: + * 1) 应用启动时不再清空磁盘图片缓存; + * 2) LRU 淘汰时仅将图片移出内存池,不删除磁盘文件。 + * 由用户设置控制,默认 false(保持原有行为)。 + */ + var keepChatImages: Boolean = false + private var cacheDir: File? = null private var hasResetCacheOnInitialize = false @@ -86,7 +94,10 @@ object ImagePoolManager { val shouldRemove = size > maxPoolSize if (shouldRemove && eldest != null) { AppLogger.d(TAG, "池子已满,移除最旧的图片: ${eldest.key}") - deleteFromDisk(eldest.key) + // 保留聊天图片模式下,仅移出内存池,不删除磁盘文件 + if (!keepChatImages) { + deleteFromDisk(eldest.key) + } } return shouldRemove } @@ -109,7 +120,10 @@ object ImagePoolManager { if (shouldResetCache) { imagePool.clear() - clearDiskCache() + // 保留聊天图片模式下,启动不清空磁盘缓存,避免用户已保存的图片丢失 + if (!keepChatImages) { + clearDiskCache() + } hasResetCacheOnInitialize = true AppLogger.d(TAG, "启动时已清空旧图片池缓存") } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 58b277cd3..dc0fbb00f 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -7960,6 +7960,7 @@ use_package call failed: %1$s use_package returned empty. %1$d. Package %2$s (hit %3$d times) + Minify and Obfuscate for Publishing Resubmit @@ -8003,4 +8004,7 @@ Rejected Changes Required Featured + + Keep chat images + When enabled, images in chats are not deleted on app restart or cache eviction, and are kept locally for the long term. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 640828988..e0463009f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -7983,4 +7983,7 @@ 需要修改 入选精选 + 保留聊天图片 + 开启后,聊天中的图片不会在应用重启或缓存淘汰时被删除,会长期保留在本地。 + diff --git a/app/src/test/java/com/ai/assistance/operit/core/tools/calculator/JsCalculatorRegressionTest.kt b/app/src/test/java/com/ai/assistance/operit/core/tools/calculator/JsCalculatorRegressionTest.kt new file mode 100644 index 000000000..091fe6dca --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/core/tools/calculator/JsCalculatorRegressionTest.kt @@ -0,0 +1,94 @@ +package com.ai.assistance.operit.core.tools.calculator + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +/** + * JsCalculator 回归测试。 + * + * 覆盖表达式求值的核心路径(运算符优先级、括号、幂、三元、变量、内置函数、 + * 统计函数、常量、无穷大与错误输入),防止后续对 core/tools 的重构破坏 + * 计算器工具的基础行为。 + * + * 注意:JsCalculator / ExpressionContext 均为纯 JVM 实现(无 Android 依赖), + * 因此可在单元测试中直接运行。 + */ +class JsCalculatorRegressionTest { + + @Before + fun setUp() { + // ExpressionContext 持有全局可变变量,测试间需重置以避免污染 + JsCalculator.clearVariables() + } + + @Test + fun arithmeticPrecedence_multiplicationBeforeAddition() { + assertEquals("7", JsCalculator.calc("1 + 2 * 3")) + } + + @Test + fun parentheses_overridePrecedence() { + assertEquals("9", JsCalculator.calc("(1 + 2) * 3")) + } + + @Test + fun division_producesFractionalResult() { + assertEquals("2.5", JsCalculator.calc("10 / 4")) + } + + @Test + fun power_operator() { + assertEquals("1024", JsCalculator.calc("2 ^ 10")) + } + + @Test + fun ternary_operator_selectsBranch() { + assertEquals("20", JsCalculator.calc("1 > 2 ? 10 : 20")) + assertEquals("10", JsCalculator.calc("2 > 1 ? 10 : 20")) + } + + @Test + fun variable_assignmentAndReadback() { + JsCalculator.setVariable("x", 5.0) + assertEquals("10", JsCalculator.calc("x * 2")) + } + + @Test + fun builtinMathFunctions() { + assertEquals("4", JsCalculator.calc("sqrt(16)")) + assertEquals("7", JsCalculator.calc("abs(-7)")) + assertEquals("9", JsCalculator.calc("max(3, 9, 4)")) + assertEquals("3", JsCalculator.calc("min(3, 9, 4)")) + assertEquals("3", JsCalculator.calc("round(2.6)")) + } + + @Test + fun statsMean_function() { + assertEquals("2.5", JsCalculator.calc("stats.mean(1, 2, 3, 4)")) + } + + @Test + fun constants_piIsAvailable() { + assertTrue(JsCalculator.calc("PI").startsWith("3.14159")) + } + + @Test + fun divisionByZero_yieldsInfinity() { + assertEquals("Infinity", JsCalculator.calc("1 / 0")) + } + + @Test + fun malformedExpression_throws() { + assertThrows(RuntimeException::class.java) { + JsCalculator.evaluate("1 +") + } + } + + @Test + fun evaluateReturnsDouble_forArithmetic() { + assertEquals(7.0, JsCalculator.evaluate("1 + 2 * 3"), 0.0) + } +}