Add manual profile storage cleanup (#6094)

This commit is contained in:
eliotcougar authored and GitHub committed 2026-08-17 10:06:56 +08:00
1 parent e4e3a36e93
commit 0456f835bc
14 files changed
+308 -1

No files matched your search

@@ -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<String>::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
@@ -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<StoredProfileReference>,
indexedServersBySubscription: Map<String, Set<String>?>,
selectedServer: String?,
): Set<String>? {
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
}
}
}
@@ -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,
@@ -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
}
}
}
@@ -436,6 +436,11 @@
<string name="title_configuration_backup_restore">النسخ الاحتياطي والاستعادة</string>
<string name="title_configuration_backup">نسخ التكوين احتياطيًا</string>
<string name="title_configuration_restore">استعادة التكوين</string>
<string name="title_profile_storage_cleanup">إصلاح بيانات ملفات التعريف</string>
<string name="summary_profile_storage_cleanup">حذف بيانات ملفات التعريف غير المستخدمة</string>
<string name="message_profile_storage_cleanup">هل تريد حذف بيانات ملفات التعريف التي لم تعد مستخدمة في أي مجموعة؟ سيُحتفظ بملف التعريف المحدد وأي بيانات يتعذر التأكد من أنها غير مستخدمة.</string>
<string name="toast_profile_storage_cleanup">تم حذف ملفات التعريف غير المستخدمة: %1$d</string>
<string name="toast_profile_storage_cleanup_skipped">تعذّر تحديد بيانات ملفات التعريف غير المستخدمة بأمان. لم يُحذف أي شيء.</string>
<string name="title_configuration_share">مشاركة التكوين</string>
<string name="title_webdav_config_setting">إعدادات WebDAV</string>
<string name="title_webdav_config_setting_unknown">يرجى إعداد WebDAV أولاً.</string>
@@ -436,6 +436,11 @@
<string name="title_configuration_backup_restore">ব্যাকআপ ও পুনরুদ্ধার</string>
<string name="title_configuration_backup">কনফিগারেশন ব্যাকআপ</string>
<string name="title_configuration_restore">কনফিগারেশন পুনরুদ্ধার</string>
<string name="title_profile_storage_cleanup">প্রোফাইল স্টোরেজ মেরামত করুন</string>
<string name="summary_profile_storage_cleanup">অব্যবহৃত প্রোফাইল ডেটা মুছে ফেলুন</string>
<string name="message_profile_storage_cleanup">কোনো গ্রুপে আর ব্যবহৃত হয় না এমন প্রোফাইল ডেটা মুছে ফেলবেন? নির্বাচিত প্রোফাইল এবং নিরাপদে অব্যবহৃত বলে নির্ধারণ করা যায় না এমন ডেটা রাখা হবে।</string>
<string name="toast_profile_storage_cleanup">অব্যবহৃত প্রোফাইল মুছে ফেলা হয়েছে: %1$d</string>
<string name="toast_profile_storage_cleanup_skipped">কোন প্রোফাইল ডেটা অব্যবহৃত তা নিরাপদে নির্ধারণ করা যায়নি। কিছুই মুছে ফেলা হয়নি।</string>
<string name="title_configuration_share">কনফিগারেশন শেয়ার করুন</string>
<string name="title_webdav_config_setting">WebDAV সেটিংস</string>
<string name="title_webdav_config_setting_unknown">প্রথমে WebDAV কনফিগার করুন।</string>
@@ -436,6 +436,11 @@
<string name="title_configuration_backup_restore">لادراری گرؽڌن &amp; وورگندن</string>
<string name="title_configuration_backup">لادراری گرؽڌن ز کانفیگ</string>
<string name="title_configuration_restore">وورگندن کانفیگ</string>
<string name="title_profile_storage_cleanup">جۊر کردن داڌه یل کانفیگا</string>
<string name="summary_profile_storage_cleanup">پاک کردن داڌه یل کانفیگا ک دی و کار نؽن</string>
<string name="message_profile_storage_cleanup">داڌه یل کانفیگا ک دی من هیچ بونکۊی و کار نؽن پاک ابۊن، هنی هم اخۊی پاکسووݩ کۊنی؟ کانفیگا پسند وابیڌه وو داڌه یلی ک نتری وا موطمئنی بفهمی دی و کار نؽن پاک نابۊن.</string>
<string name="toast_profile_storage_cleanup">کانفیگا ک دی و کار نؽن پاک وابیڌن: %1$d</string>
<string name="toast_profile_storage_cleanup_skipped">نتری وا موطمئنی بفهمی ک کۊی داڌه یل کانفیگا دی و کار نؽن. هیچ داڌه ای پاک نوابی.</string>
<string name="title_configuration_share">یک رسۊوی کانفیگ</string>
<string name="title_webdav_config_setting">WebDAV سامووا</string>
<string name="title_webdav_config_setting_unknown">ٱول WebDAV ن کانفیگ کۊنین</string>
@@ -436,6 +436,11 @@
<string name="title_configuration_backup_restore">پشتیبان‌گیری و بازیابی</string>
<string name="title_configuration_backup">پشتیبان گیری از پیکربندی</string>
<string name="title_configuration_restore">بازیابی پیکربندی</string>
<string name="title_profile_storage_cleanup">ترمیم فضای ذخیره‌سازی پروفایل‌ها</string>
<string name="summary_profile_storage_cleanup">حذف داده‌های پروفایل بلااستفاده</string>
<string name="message_profile_storage_cleanup">داده‌های پروفایلی که دیگر در هیچ گروهی استفاده نمی‌شوند حذف شوند؟ پروفایل انتخاب‌شده و داده‌هایی که نمی‌توان با اطمینان بلااستفاده بودنشان را تشخیص داد حفظ خواهند شد.</string>
<string name="toast_profile_storage_cleanup">پروفایل‌های بلااستفاده حذف شدند: %1$d</string>
<string name="toast_profile_storage_cleanup_skipped">امکان تشخیص مطمئن داده‌های پروفایل بلااستفاده وجود نداشت. هیچ موردی حذف نشد.</string>
<string name="title_configuration_share">اشتراک گذاری پیکربندی</string>
<string name="title_webdav_config_setting">تنظیمات WebDAV</string>
<string name="title_webdav_config_setting_unknown">لطفاً ابتدا WebDAV را پیکربندی کنید.</string>
@@ -436,6 +436,11 @@
<string name="title_configuration_backup_restore">Резервное копирование и восстановление</string>
<string name="title_configuration_backup">Создать резервную копию</string>
<string name="title_configuration_restore">Восстановление конфигурации</string>
<string name="title_profile_storage_cleanup">Исправить хранилище профилей</string>
<string name="summary_profile_storage_cleanup">Удалить данные неиспользуемых профилей</string>
<string name="message_profile_storage_cleanup">Удалить данные профилей, которые больше не используются ни в одной группе? Выбранный профиль и данные, которые нельзя с уверенностью отнести к неиспользуемым, будут сохранены.</string>
<string name="toast_profile_storage_cleanup">Удалено неиспользуемых профилей: %1$d</string>
<string name="toast_profile_storage_cleanup_skipped">Не удалось достоверно определить неиспользуемые данные профилей. Ничего не удалено.</string>
<string name="title_configuration_share">Поделиться конфигурацией</string>
<string name="title_webdav_config_setting">Настройки WebDAV</string>
<string name="title_webdav_config_setting_unknown">Необходимо настроить WebDAV</string>
@@ -436,6 +436,11 @@
<string name="title_configuration_backup_restore">Sao lưu &amp; khôi phục</string>
<string name="title_configuration_backup">Sao lưu cấu hình</string>
<string name="title_configuration_restore">Khôi phục cấu hình</string>
<string name="title_profile_storage_cleanup">Sửa dữ liệu cấu hình</string>
<string name="summary_profile_storage_cleanup">Xóa dữ liệu cấu hình không còn được sử dụng</string>
<string name="message_profile_storage_cleanup">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.</string>
<string name="toast_profile_storage_cleanup">Đã xóa cấu hình không dùng: %1$d</string>
<string name="toast_profile_storage_cleanup_skipped">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.</string>
<string name="title_configuration_share">Chia sẻ cấu hình</string>
<string name="title_webdav_config_setting">Cài đặt WebDAV</string>
<string name="title_webdav_config_setting_unknown">Vui lòng cấu hình WebDAV trước.</string>
@@ -436,6 +436,11 @@
<string name="title_configuration_backup_restore">备份 &amp; 还原</string>
<string name="title_configuration_backup">备份配置</string>
<string name="title_configuration_restore">还原配置</string>
<string name="title_profile_storage_cleanup">修复配置存储</string>
<string name="summary_profile_storage_cleanup">删除未使用的配置数据</string>
<string name="message_profile_storage_cleanup">要删除已不再被任何分组使用的配置数据吗?当前选中的配置以及无法安全判定为未使用的数据将会保留。</string>
<string name="toast_profile_storage_cleanup">已删除未使用的配置:%1$d</string>
<string name="toast_profile_storage_cleanup_skipped">无法安全判定哪些配置数据未被使用。未删除任何数据。</string>
<string name="title_configuration_share">分享配置</string>
<string name="title_webdav_config_setting">WebDAV 设置</string>
<string name="title_webdav_config_setting_unknown">请先设置 WebDAV</string>
@@ -436,6 +436,11 @@
<string name="title_configuration_backup_restore">備份與還原</string>
<string name="title_configuration_backup">備份設定</string>
<string name="title_configuration_restore">還原設定</string>
<string name="title_profile_storage_cleanup">修復設定檔儲存空間</string>
<string name="summary_profile_storage_cleanup">刪除未使用的設定檔資料</string>
<string name="message_profile_storage_cleanup">要刪除已不再由任何群組使用的設定檔資料嗎?目前選取的設定檔,以及無法安全判定為未使用的資料,都會予以保留。</string>
<string name="toast_profile_storage_cleanup">已刪除未使用的設定檔:%1$d</string>
<string name="toast_profile_storage_cleanup_skipped">無法安全判定哪些設定檔資料未被使用。未刪除任何資料。</string>
<string name="title_configuration_share">分享設定</string>
<string name="title_webdav_config_setting">WebDAV 設定</string>
<string name="title_webdav_config_setting_unknown">請先設定 WebDAV</string>
@@ -436,6 +436,11 @@
<string name="title_configuration_backup_restore">Backup &amp; Restore</string>
<string name="title_configuration_backup">Backup config</string>
<string name="title_configuration_restore">Restore config</string>
<string name="title_profile_storage_cleanup">Repair profile storage</string>
<string name="summary_profile_storage_cleanup">Remove unused profile data</string>
<string name="message_profile_storage_cleanup">Delete profile data that is no longer used by any group? Selected profiles and data that cannot be safely classified will be kept.</string>
<string name="toast_profile_storage_cleanup">Unused profiles removed: %1$d</string>
<string name="toast_profile_storage_cleanup_skipped">Profile storage could not be safely classified. Nothing was removed.</string>
<string name="title_configuration_share">Share config</string>
<string name="title_webdav_config_setting">WebDAV Settings</string>
<string name="title_webdav_config_setting_unknown">Please configure WebDAV first.</string>
@@ -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<String>(), 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<String>(), result)
}
}