From 0456f835bc2141425c961826feab5b89536576fc Mon Sep 17 00:00:00 2001 From: eliotcougar Date: Mon, 17 Aug 2026 05:06:56 +0300 Subject: [PATCH] Add manual profile storage cleanup (#6094) --- .../java/com/v2ray/ang/handler/MmkvManager.kt | 50 +++++++ .../v2ray/ang/handler/OrphanProfileCleaner.kt | 53 ++++++++ .../com/v2ray/ang/ui/backup/BackupActivity.kt | 17 +++ .../v2ray/ang/ui/backup/BackupViewModel.kt | 22 +++- .../app/src/main/res/values-ar/strings.xml | 5 + .../app/src/main/res/values-bn/strings.xml | 5 + .../src/main/res/values-bqi-rIR/strings.xml | 5 + .../app/src/main/res/values-fa/strings.xml | 5 + .../app/src/main/res/values-ru/strings.xml | 5 + .../app/src/main/res/values-vi/strings.xml | 5 + .../src/main/res/values-zh-rCN/strings.xml | 5 + .../src/main/res/values-zh-rTW/strings.xml | 5 + V2rayNG/app/src/main/res/values/strings.xml | 5 + .../ang/handler/OrphanProfileCleanerTest.kt | 122 ++++++++++++++++++ 14 files changed, 308 insertions(+), 1 deletion(-) create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/handler/OrphanProfileCleaner.kt create mode 100644 V2rayNG/app/src/test/java/com/v2ray/ang/handler/OrphanProfileCleanerTest.kt diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt index 007178de..e0c9be02 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt @@ -426,6 +426,56 @@ object MmkvManager { return serverRawStorage.decodeString(guid) } + /** + * Removes profile payloads that are provably absent from their raw SUB_SERVERS_* index. + * + * SUB_IDS and SUB are intentionally ignored: either store can be missing after MMKV + * recovery while the group indexes still identify live profiles. If any group index or + * profile payload needed for a decision is unreadable, that data is preserved. + * + * @return The number of profile payloads removed, or null if cleanup could not run safely. + */ + internal fun removeOrphanedServerProfiles(): Int? = synchronized(mainStorage) { + mainStorage.lock() + try { + val indexedServersBySubscription = mainStorage.allKeys().orEmpty() + .asSequence() + .filter { key -> key.startsWith(KEY_SUB_SERVER_PREFIX) } + .associate { key -> + val subscriptionId = key.removePrefix(KEY_SUB_SERVER_PREFIX) + val json = mainStorage.decodeString(key) + val serverIds = if (json.isNullOrBlank()) { + null + } else { + JsonUtil.fromJsonSafe(json, Array::class.java)?.toSet() + } + subscriptionId to serverIds + } + + val profiles = profileFullStorage.allKeys().orEmpty().map { guid -> + StoredProfileReference( + guid = guid, + subscriptionId = decodeServerConfig(guid)?.subscriptionId, + ) + } + val orphans = OrphanProfileCleaner.findOrphans( + profiles = profiles, + indexedServersBySubscription = indexedServersBySubscription, + selectedServer = getSelectServer(), + ) ?: return@synchronized null + + if (orphans.isNotEmpty()) { + val keys = orphans.toTypedArray() + profileFullStorage.removeValuesForKeys(keys) + serverAffStorage.removeValuesForKeys(keys) + serverRawStorage.removeValuesForKeys(keys) + } + orphans.size + } finally { + mainStorage.unlock() + } + } + //endregion //region Subscriptions diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/OrphanProfileCleaner.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/OrphanProfileCleaner.kt new file mode 100644 index 00000000..d5e9f900 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/OrphanProfileCleaner.kt @@ -0,0 +1,53 @@ +package com.v2ray.ang.handler + +import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID + +internal data class StoredProfileReference( + val guid: String, + val subscriptionId: String?, +) + +internal object OrphanProfileCleaner { + + /** + * Finds profile payloads that are provably unreachable from the raw group indexes. + * + * A null subscription ID means that the profile payload could not be decoded and is + * preserved. A null server set means that a group index could not be decoded, in which + * case the entire classification returns null and no cleanup should run. + * Subscription metadata is deliberately not an input because SUB and SUB_IDS can be + * missing while the raw SUB_SERVERS_* indexes remain intact. + */ + fun findOrphans( + profiles: Collection, + indexedServersBySubscription: Map?>, + selectedServer: String?, + ): Set? { + if (profiles.isEmpty()) return emptySet() + + if (indexedServersBySubscription.isEmpty() || + indexedServersBySubscription.values.any { it == null } + ) { + return null + } + + val indexedServers = indexedServersBySubscription.values + .filterNotNull() + .flatten() + .toSet() + + return profiles.mapNotNullTo(linkedSetOf()) { profile -> + if (profile.guid == selectedServer || profile.guid in indexedServers) { + return@mapNotNullTo null + } + + val subscriptionId = profile.subscriptionId ?: return@mapNotNullTo null + val groupId = subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID } + if (!indexedServersBySubscription.containsKey(groupId)) { + return@mapNotNullTo null + } + + profile.guid + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/backup/BackupActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/backup/BackupActivity.kt index eb09f69c..f5a5894a 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/backup/BackupActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/backup/BackupActivity.kt @@ -38,6 +38,7 @@ import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.ui.base.HelperBaseComponentActivity import com.v2ray.ang.ui.compose.AppTopBar +import com.v2ray.ang.ui.compose.DeleteConfirmDialog import com.v2ray.ang.ui.compose.InputDialog import com.v2ray.ang.ui.compose.NavigationBarsSpacer import com.v2ray.ang.ui.compose.InputField @@ -106,6 +107,7 @@ class BackupActivity : HelperBaseComponentActivity() { BackupLocation.WebDav -> viewModel.restoreViaWebDav(cacheDir) } }, + onCleanupProfiles = viewModel::cleanupProfileStorage, onWebDavSave = { config -> viewModel.saveWebDavConfig(config) }, onBackClick = { finish() } ) @@ -187,6 +189,7 @@ fun BackupScreen( onBackupOptionSelected: (BackupLocation) -> Unit, onShareClick: () -> Unit, onRestoreOptionSelected: (BackupLocation) -> Unit, + onCleanupProfiles: () -> Unit, onWebDavSave: (WebDavConfig) -> Unit, onBackClick: () -> Unit ) { @@ -194,6 +197,7 @@ fun BackupScreen( val currentWebDavConfig by webDavConfigState.collectAsState() var showBackupDialog by remember { mutableStateOf(false) } var showRestoreDialog by remember { mutableStateOf(false) } + var showCleanupDialog by remember { mutableStateOf(false) } var showWebDavDialog by remember { mutableStateOf(false) } val webDavSummary = currentWebDavConfig?.baseUrl @@ -229,6 +233,12 @@ fun BackupScreen( title = stringResource(R.string.title_configuration_restore), onClick = { showRestoreDialog = true } ) + SettingsMenuItem( + icon = painterResource(R.drawable.ic_delete_24dp), + title = stringResource(R.string.title_profile_storage_cleanup), + subtitle = stringResource(R.string.summary_profile_storage_cleanup), + onClick = { showCleanupDialog = true } + ) Spacer(modifier = Modifier.height(16.dp)) SettingsMenuItem( icon = painterResource(R.drawable.ic_settings_24dp), @@ -264,6 +274,13 @@ fun BackupScreen( onDismiss = { showRestoreDialog = false } ) } + if (showCleanupDialog) { + DeleteConfirmDialog( + message = stringResource(R.string.message_profile_storage_cleanup), + onConfirm = onCleanupProfiles, + onDismiss = { showCleanupDialog = false } + ) + } if (showWebDavDialog) { WebDavInputDialog( initialConfig = currentWebDavConfig, diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/backup/BackupViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/backup/BackupViewModel.kt index ef2f869c..2db5ca87 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/backup/BackupViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/backup/BackupViewModel.kt @@ -13,9 +13,11 @@ import com.v2ray.ang.ui.base.BaseViewModel import com.v2ray.ang.ui.base.ViewModelEvent import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.ZipUtil +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.withContext import java.io.File import java.text.SimpleDateFormat import java.util.Locale @@ -37,6 +39,24 @@ class BackupViewModel(application: Application) : BaseViewModel(application) { toastSuccess(R.string.toast_success) } + fun cleanupProfileStorage() { + launchLoading { + try { + val removed = withContext(Dispatchers.IO) { + MmkvManager.removeOrphanedServerProfiles() + } + if (removed == null) { + toastError(R.string.toast_profile_storage_cleanup_skipped) + } else { + toastSuccess(getString(R.string.toast_profile_storage_cleanup, removed)) + } + } catch (e: Exception) { + LogUtil.e(AppConfig.TAG, "Failed to clean up profile storage", e) + toastError(R.string.toast_failure) + } + } + } + fun shareBackup(cacheDir: File, appName: String) { launchLoading { val ret = backupConfigurationToCache(cacheDir, appName) @@ -178,4 +198,4 @@ class BackupViewModel(application: Application) : BaseViewModel(application) { return count > 0 } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/res/values-ar/strings.xml b/V2rayNG/app/src/main/res/values-ar/strings.xml index f43da9d3..23eb1b3b 100644 --- a/V2rayNG/app/src/main/res/values-ar/strings.xml +++ b/V2rayNG/app/src/main/res/values-ar/strings.xml @@ -436,6 +436,11 @@ النسخ الاحتياطي والاستعادة نسخ التكوين احتياطيًا استعادة التكوين + إصلاح بيانات ملفات التعريف + حذف بيانات ملفات التعريف غير المستخدمة + هل تريد حذف بيانات ملفات التعريف التي لم تعد مستخدمة في أي مجموعة؟ سيُحتفظ بملف التعريف المحدد وأي بيانات يتعذر التأكد من أنها غير مستخدمة. + تم حذف ملفات التعريف غير المستخدمة: %1$d + تعذّر تحديد بيانات ملفات التعريف غير المستخدمة بأمان. لم يُحذف أي شيء. مشاركة التكوين إعدادات WebDAV يرجى إعداد WebDAV أولاً. diff --git a/V2rayNG/app/src/main/res/values-bn/strings.xml b/V2rayNG/app/src/main/res/values-bn/strings.xml index cad8a1a1..92471b3b 100644 --- a/V2rayNG/app/src/main/res/values-bn/strings.xml +++ b/V2rayNG/app/src/main/res/values-bn/strings.xml @@ -436,6 +436,11 @@ ব্যাকআপ ও পুনরুদ্ধার কনফিগারেশন ব্যাকআপ কনফিগারেশন পুনরুদ্ধার + প্রোফাইল স্টোরেজ মেরামত করুন + অব্যবহৃত প্রোফাইল ডেটা মুছে ফেলুন + কোনো গ্রুপে আর ব্যবহৃত হয় না এমন প্রোফাইল ডেটা মুছে ফেলবেন? নির্বাচিত প্রোফাইল এবং নিরাপদে অব্যবহৃত বলে নির্ধারণ করা যায় না এমন ডেটা রাখা হবে। + অব্যবহৃত প্রোফাইল মুছে ফেলা হয়েছে: %1$d + কোন প্রোফাইল ডেটা অব্যবহৃত তা নিরাপদে নির্ধারণ করা যায়নি। কিছুই মুছে ফেলা হয়নি। কনফিগারেশন শেয়ার করুন WebDAV সেটিংস প্রথমে WebDAV কনফিগার করুন। diff --git a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml index b653b050..38810853 100644 --- a/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml +++ b/V2rayNG/app/src/main/res/values-bqi-rIR/strings.xml @@ -436,6 +436,11 @@ لادراری گرؽڌن & وورگندن لادراری گرؽڌن ز کانفیگ وورگندن کانفیگ + جۊر کردن داڌه یل کانفیگا + پاک کردن داڌه یل کانفیگا ک دی و کار نؽن + داڌه یل کانفیگا ک دی من هیچ بونکۊی و کار نؽن پاک ابۊن، هنی هم اخۊی پاکسووݩ کۊنی؟ کانفیگا پسند وابیڌه وو داڌه یلی ک نتری وا موطمئنی بفهمی دی و کار نؽن پاک نابۊن. + کانفیگا ک دی و کار نؽن پاک وابیڌن: %1$d + نتری وا موطمئنی بفهمی ک کۊی داڌه یل کانفیگا دی و کار نؽن. هیچ داڌه ای پاک نوابی. یک رسۊوی کانفیگ WebDAV سامووا ٱول WebDAV ن کانفیگ کۊنین diff --git a/V2rayNG/app/src/main/res/values-fa/strings.xml b/V2rayNG/app/src/main/res/values-fa/strings.xml index a5e1a2ac..3cc4d3e6 100644 --- a/V2rayNG/app/src/main/res/values-fa/strings.xml +++ b/V2rayNG/app/src/main/res/values-fa/strings.xml @@ -436,6 +436,11 @@ پشتیبان‌گیری و بازیابی پشتیبان گیری از پیکربندی بازیابی پیکربندی + ترمیم فضای ذخیره‌سازی پروفایل‌ها + حذف داده‌های پروفایل بلااستفاده + داده‌های پروفایلی که دیگر در هیچ گروهی استفاده نمی‌شوند حذف شوند؟ پروفایل انتخاب‌شده و داده‌هایی که نمی‌توان با اطمینان بلااستفاده بودنشان را تشخیص داد حفظ خواهند شد. + پروفایل‌های بلااستفاده حذف شدند: %1$d + امکان تشخیص مطمئن داده‌های پروفایل بلااستفاده وجود نداشت. هیچ موردی حذف نشد. اشتراک گذاری پیکربندی تنظیمات WebDAV لطفاً ابتدا WebDAV را پیکربندی کنید. diff --git a/V2rayNG/app/src/main/res/values-ru/strings.xml b/V2rayNG/app/src/main/res/values-ru/strings.xml index d79716ca..bf1b9d57 100644 --- a/V2rayNG/app/src/main/res/values-ru/strings.xml +++ b/V2rayNG/app/src/main/res/values-ru/strings.xml @@ -436,6 +436,11 @@ Резервное копирование и восстановление Создать резервную копию Восстановление конфигурации + Исправить хранилище профилей + Удалить данные неиспользуемых профилей + Удалить данные профилей, которые больше не используются ни в одной группе? Выбранный профиль и данные, которые нельзя с уверенностью отнести к неиспользуемым, будут сохранены. + Удалено неиспользуемых профилей: %1$d + Не удалось достоверно определить неиспользуемые данные профилей. Ничего не удалено. Поделиться конфигурацией Настройки WebDAV Необходимо настроить WebDAV diff --git a/V2rayNG/app/src/main/res/values-vi/strings.xml b/V2rayNG/app/src/main/res/values-vi/strings.xml index 6e9fd39e..3f180cb5 100644 --- a/V2rayNG/app/src/main/res/values-vi/strings.xml +++ b/V2rayNG/app/src/main/res/values-vi/strings.xml @@ -436,6 +436,11 @@ Sao lưu & khôi phục Sao lưu cấu hình Khôi phục cấu hình + Sửa dữ liệu cấu hình + Xóa dữ liệu cấu hình không còn được sử dụng + Xóa dữ liệu cấu hình không còn được nhóm nào sử dụng? Cấu hình đang chọn và dữ liệu không thể xác định chắc chắn là không còn được sử dụng sẽ được giữ lại. + Đã xóa cấu hình không dùng: %1$d + Không thể xác định chắc chắn dữ liệu cấu hình không còn được sử dụng. Không có dữ liệu nào bị xóa. Chia sẻ cấu hình Cài đặt WebDAV Vui lòng cấu hình WebDAV trước. diff --git a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml index 510c22c1..942027eb 100644 --- a/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml +++ b/V2rayNG/app/src/main/res/values-zh-rCN/strings.xml @@ -436,6 +436,11 @@ 备份 & 还原 备份配置 还原配置 + 修复配置存储 + 删除未使用的配置数据 + 要删除已不再被任何分组使用的配置数据吗?当前选中的配置以及无法安全判定为未使用的数据将会保留。 + 已删除未使用的配置:%1$d + 无法安全判定哪些配置数据未被使用。未删除任何数据。 分享配置 WebDAV 设置 请先设置 WebDAV diff --git a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml index 2220867f..072c7e08 100644 --- a/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml +++ b/V2rayNG/app/src/main/res/values-zh-rTW/strings.xml @@ -436,6 +436,11 @@ 備份與還原 備份設定 還原設定 + 修復設定檔儲存空間 + 刪除未使用的設定檔資料 + 要刪除已不再由任何群組使用的設定檔資料嗎?目前選取的設定檔,以及無法安全判定為未使用的資料,都會予以保留。 + 已刪除未使用的設定檔:%1$d + 無法安全判定哪些設定檔資料未被使用。未刪除任何資料。 分享設定 WebDAV 設定 請先設定 WebDAV diff --git a/V2rayNG/app/src/main/res/values/strings.xml b/V2rayNG/app/src/main/res/values/strings.xml index a5bbaa99..43523733 100644 --- a/V2rayNG/app/src/main/res/values/strings.xml +++ b/V2rayNG/app/src/main/res/values/strings.xml @@ -436,6 +436,11 @@ Backup & Restore Backup config Restore config + Repair profile storage + Remove unused profile data + Delete profile data that is no longer used by any group? Selected profiles and data that cannot be safely classified will be kept. + Unused profiles removed: %1$d + Profile storage could not be safely classified. Nothing was removed. Share config WebDAV Settings Please configure WebDAV first. diff --git a/V2rayNG/app/src/test/java/com/v2ray/ang/handler/OrphanProfileCleanerTest.kt b/V2rayNG/app/src/test/java/com/v2ray/ang/handler/OrphanProfileCleanerTest.kt new file mode 100644 index 00000000..3a3ac059 --- /dev/null +++ b/V2rayNG/app/src/test/java/com/v2ray/ang/handler/OrphanProfileCleanerTest.kt @@ -0,0 +1,122 @@ +package com.v2ray.ang.handler + +import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class OrphanProfileCleanerTest { + + @Test + fun `reports no work when profile storage is empty`() { + val result = OrphanProfileCleaner.findOrphans( + profiles = emptyList(), + indexedServersBySubscription = emptyMap(), + selectedServer = null, + ) + + assertEquals(emptySet(), result) + } + + @Test + fun `uses raw group indexes when subscription metadata is missing`() { + val profiles = listOf( + StoredProfileReference("live", "group-a"), + StoredProfileReference("orphan", "group-a"), + ) + + val result = OrphanProfileCleaner.findOrphans( + profiles = profiles, + indexedServersBySubscription = mapOf("group-a" to setOf("live")), + selectedServer = null, + ) + + assertEquals(setOf("orphan"), result) + } + + @Test + fun `keeps profiles whose group index is missing`() { + val profiles = listOf( + StoredProfileReference("known-orphan", "group-a"), + StoredProfileReference("unknown", "missing-group"), + ) + + val result = OrphanProfileCleaner.findOrphans( + profiles = profiles, + indexedServersBySubscription = mapOf("group-a" to emptySet()), + selectedServer = null, + ) + + assertEquals(setOf("known-orphan"), result) + } + + @Test + fun `aborts cleanup when any raw group index is unreadable`() { + val result = OrphanProfileCleaner.findOrphans( + profiles = listOf(StoredProfileReference("candidate", "group-a")), + indexedServersBySubscription = mapOf( + "group-a" to emptySet(), + "corrupt-group" to null, + ), + selectedServer = null, + ) + + assertNull(result) + } + + @Test + fun `aborts cleanup when no raw group indexes survive`() { + val result = OrphanProfileCleaner.findOrphans( + profiles = listOf(StoredProfileReference("candidate", "group-a")), + indexedServersBySubscription = emptyMap(), + selectedServer = null, + ) + + assertNull(result) + } + + @Test + fun `keeps selected and undecodable profiles`() { + val result = OrphanProfileCleaner.findOrphans( + profiles = listOf( + StoredProfileReference("selected", "group-a"), + StoredProfileReference("undecodable", null), + StoredProfileReference("orphan", "group-a"), + ), + indexedServersBySubscription = mapOf("group-a" to emptySet()), + selectedServer = "selected", + ) + + assertEquals(setOf("orphan"), result) + } + + @Test + fun `normalizes ungrouped profiles to the default group`() { + val result = OrphanProfileCleaner.findOrphans( + profiles = listOf( + StoredProfileReference("live", ""), + StoredProfileReference("orphan", ""), + ), + indexedServersBySubscription = mapOf( + DEFAULT_SUBSCRIPTION_ID to setOf("live"), + ), + selectedServer = null, + ) + + assertEquals(setOf("orphan"), result) + } + + @Test + fun `keeps a profile indexed by a different group`() { + val result = OrphanProfileCleaner.findOrphans( + profiles = listOf(StoredProfileReference("mismatched", "group-a")), + indexedServersBySubscription = mapOf( + "group-a" to emptySet(), + "group-b" to setOf("mismatched"), + ), + selectedServer = null, + ) + + assertEquals(emptySet(), result) + } +}