From 8d1df825ffad9662f8528340d0b5a91ca0a7eed4 Mon Sep 17 00:00:00 2001 From: Goooler Date: Thu, 23 Apr 2026 22:59:26 +0800 Subject: [PATCH 01/11] Unwrap MetaFeatureSettingsScreen --- .../settings/MetaFeatureSettingsActivity.kt | 123 +------ ...Design.kt => MetaFeatureSettingsScreen.kt} | 337 +++++++++++------- .../vm/MetaFeatureSettingsViewModel.kt | 214 +++++++++++ .../clash/ui/component/SettingsPreference.kt | 52 +++ 4 files changed, 485 insertions(+), 241 deletions(-) rename app/src/main/kotlin/com/github/kr328/clash/settings/ui/{MetaFeatureSettingsDesign.kt => MetaFeatureSettingsScreen.kt} (55%) create mode 100644 app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/MetaFeatureSettingsActivity.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/MetaFeatureSettingsActivity.kt index c3668895c6..ae1e3ef180 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/MetaFeatureSettingsActivity.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/MetaFeatureSettingsActivity.kt @@ -1,112 +1,21 @@ package com.github.kr328.clash.settings -import android.database.Cursor -import android.net.Uri -import android.provider.OpenableColumns -import androidx.activity.result.contract.ActivityResultContracts -import com.github.kr328.clash.R -import com.github.kr328.clash.core.Clash -import com.github.kr328.clash.settings.ui.MetaFeatureSettingsDesign -import com.github.kr328.clash.ui.DesignActivity -import com.github.kr328.clash.util.clashDir -import com.github.kr328.clash.util.toast -import com.github.kr328.clash.util.withClash -import com.google.android.material.dialog.MaterialAlertDialogBuilder -import java.io.File -import java.io.FileOutputStream -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.isActive -import kotlinx.coroutines.selects.select -import kotlinx.coroutines.withContext - -class MetaFeatureSettingsActivity : DesignActivity() { - override suspend fun main() { - val configuration = withClash { queryOverride(Clash.OverrideSlot.Persist) } - - defer { withClash { patchOverride(Clash.OverrideSlot.Persist, configuration) } } - - val design = MetaFeatureSettingsDesign(this, configuration) - - setContentDesign(design) - - while (isActive) { - select { - events.onReceive {} - - design.requests.onReceive { - when (it) { - MetaFeatureSettingsDesign.Request.ResetOverride -> { - defer { withClash { clearOverride(Clash.OverrideSlot.Persist) } } - finish() - } - - MetaFeatureSettingsDesign.Request.ImportGeoIp -> { - val uri = ActivityResultContracts.GetContent().startForResult("*/*") - importGeoFile(uri, MetaFeatureSettingsDesign.Request.ImportGeoIp) - } - - MetaFeatureSettingsDesign.Request.ImportGeoSite -> { - val uri = ActivityResultContracts.GetContent().startForResult("*/*") - importGeoFile(uri, MetaFeatureSettingsDesign.Request.ImportGeoSite) - } - - MetaFeatureSettingsDesign.Request.ImportCountry -> { - val uri = ActivityResultContracts.GetContent().startForResult("*/*") - importGeoFile(uri, MetaFeatureSettingsDesign.Request.ImportCountry) - } - - MetaFeatureSettingsDesign.Request.ImportASN -> { - val uri = ActivityResultContracts.GetContent().startForResult("*/*") - importGeoFile(uri, MetaFeatureSettingsDesign.Request.ImportASN) - } - } - } - } - } - } - - private val validDatabaseExtensions = listOf(".metadb", ".db", ".dat", ".mmdb") - - private suspend fun importGeoFile(uri: Uri?, importType: MetaFeatureSettingsDesign.Request) { - val cursor: Cursor? = uri?.let { contentResolver.query(it, null, null, null, null, null) } - cursor?.use { - if (it.moveToFirst()) { - val columnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val displayName: String = if (columnIndex != -1) it.getString(columnIndex) else "" - val ext = "." + displayName.substringAfterLast(".") - - if (!validDatabaseExtensions.contains(ext)) { - MaterialAlertDialogBuilder(this) - .setTitle(R.string.geofile_unknown_db_format) - .setMessage( - getString( - R.string.geofile_unknown_db_format_message, - validDatabaseExtensions.joinToString("/"), - ) - ) - .setPositiveButton("OK") { _, _ -> } - .show() - return - } - val outputFileName = - when (importType) { - MetaFeatureSettingsDesign.Request.ImportGeoIp -> "geoip$ext" - MetaFeatureSettingsDesign.Request.ImportGeoSite -> "geosite$ext" - MetaFeatureSettingsDesign.Request.ImportCountry -> "country$ext" - MetaFeatureSettingsDesign.Request.ImportASN -> "ASN$ext" - else -> "" - } - - withContext(Dispatchers.IO) { - val outputFile = File(clashDir, outputFileName) - contentResolver.openInputStream(uri).use { ins -> - FileOutputStream(outputFile).use { outs -> ins?.copyTo(outs) } - } - } - toast(getString(R.string.geofile_imported, displayName)) - return - } +import android.os.Bundle +import androidx.activity.compose.setContent +import androidx.activity.viewModels +import com.github.kr328.clash.settings.ui.MetaFeatureSettingsScreen +import com.github.kr328.clash.settings.vm.MetaFeatureSettingsViewModel +import com.github.kr328.clash.ui.BaseActivity +import com.github.kr328.clash.ui.theme.MihomoTheme + +class MetaFeatureSettingsActivity : BaseActivity() { + private val viewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setContent { + MihomoTheme { MetaFeatureSettingsScreen(viewModel = viewModel, onResetCompleted = ::finish) } } - toast(R.string.geofile_import_failed) } } diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/ui/MetaFeatureSettingsDesign.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/ui/MetaFeatureSettingsScreen.kt similarity index 55% rename from app/src/main/kotlin/com/github/kr328/clash/settings/ui/MetaFeatureSettingsDesign.kt rename to app/src/main/kotlin/com/github/kr328/clash/settings/ui/MetaFeatureSettingsScreen.kt index a0f698f45f..73d83af6a2 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/ui/MetaFeatureSettingsDesign.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/ui/MetaFeatureSettingsScreen.kt @@ -1,6 +1,7 @@ package com.github.kr328.clash.settings.ui -import android.content.Context +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts.GetContent import androidx.annotation.StringRes import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -14,76 +15,141 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.github.kr328.clash.R import com.github.kr328.clash.core.model.ConfigurationOverride -import com.github.kr328.clash.ui.Design +import com.github.kr328.clash.settings.vm.MetaFeatureSettingsViewModel +import com.github.kr328.clash.settings.vm.MetaFeatureSettingsViewModel.ImportResult +import com.github.kr328.clash.settings.vm.MetaFeatureSettingsViewModel.ImportType import com.github.kr328.clash.ui.component.MihomoScaffold import com.github.kr328.clash.ui.component.SettingsClickablePreferenceItem import com.github.kr328.clash.ui.component.SettingsEditTextListPreferenceItem import com.github.kr328.clash.ui.component.SettingsListPreferenceItem -import com.github.kr328.clash.ui.component.rememberWriteThroughState import com.github.kr328.clash.ui.theme.MihomoTheme import com.github.kr328.clash.ui.theme.PreviewMihomo +import com.github.kr328.clash.util.toast import me.zhanghai.compose.preference.ProvidePreferenceLocals import me.zhanghai.compose.preference.preferenceCategory -class MetaFeatureSettingsDesign( - context: Context, - private val configuration: ConfigurationOverride, -) : Design(context) { - sealed interface Request { - data object ResetOverride : Request +@Composable +@OptIn(ExperimentalMaterial3Api::class) +fun MetaFeatureSettingsScreen( + modifier: Modifier = Modifier, + viewModel: MetaFeatureSettingsViewModel = viewModel(), + onResetCompleted: () -> Unit, +) { + val context = LocalContext.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val importResult by viewModel.importResult.collectAsStateWithLifecycle() + val importedText = stringResource(R.string.geofile_imported) + var pendingImportType by remember { mutableStateOf(null) } + var showUnsupportedFormatDialog by remember { mutableStateOf(false) } + var validExtensionsSummary by remember { mutableStateOf("") } + var showResetConfirmDialog by remember { mutableStateOf(false) } + + DisposableEffect(viewModel) { onDispose { viewModel.persistOverride() } } - data object ImportGeoIp : Request + LaunchedEffect(importResult) { + when (val result = importResult) { + ImportResult.NotStart -> Unit - data object ImportGeoSite : Request + is ImportResult.Success -> { + context.toast(importedText.format(result.displayName)) + } - data object ImportCountry : Request + is ImportResult.UnsupportedFormat -> { + validExtensionsSummary = result.summary + showUnsupportedFormatDialog = true + } - data object ImportASN : Request + ImportResult.Failed -> context.toast(R.string.geofile_import_failed) + } } - @Composable - override fun Content() = MihomoTheme { - MetaFeatureSettingsScreen( - configuration = configuration, - onResetConfirmed = { requests.trySend(Request.ResetOverride) }, - onImportGeoIp = { requests.trySend(Request.ImportGeoIp) }, - onImportGeoSite = { requests.trySend(Request.ImportGeoSite) }, - onImportCountry = { requests.trySend(Request.ImportCountry) }, - onImportASN = { requests.trySend(Request.ImportASN) }, + val importLauncher = + rememberLauncherForActivityResult(GetContent()) { uri -> + val type = pendingImportType ?: return@rememberLauncherForActivityResult + pendingImportType = null + viewModel.importGeoFile(uri, type) + } + + MetaFeatureSettingsContent( + configuration = uiState, + actions = viewModel, + modifier = modifier, + showResetConfirmDialog = showResetConfirmDialog, + onShowResetConfirmDialogChange = { showResetConfirmDialog = it }, + onResetConfirmed = { + viewModel.resetOverride() + onResetCompleted() + }, + onImportGeoIp = { + pendingImportType = ImportType.GeoIp + importLauncher.launch("*/*") + }, + onImportGeoSite = { + pendingImportType = ImportType.GeoSite + importLauncher.launch("*/*") + }, + onImportCountry = { + pendingImportType = ImportType.Country + importLauncher.launch("*/*") + }, + onImportASN = { + pendingImportType = ImportType.ASN + importLauncher.launch("*/*") + }, + ) + + if (showUnsupportedFormatDialog) { + AlertDialog( + onDismissRequest = { showUnsupportedFormatDialog = false }, + title = { Text(stringResource(R.string.geofile_unknown_db_format)) }, + text = { + Text(stringResource(R.string.geofile_unknown_db_format_message, validExtensionsSummary)) + }, + confirmButton = { + TextButton(onClick = { showUnsupportedFormatDialog = false }) { + Text(text = stringResource(R.string.ok)) + } + }, ) } } @Composable @OptIn(ExperimentalMaterial3Api::class) -private fun MetaFeatureSettingsScreen( +private fun MetaFeatureSettingsContent( configuration: ConfigurationOverride, + actions: MetaFeatureSettingsActions, + modifier: Modifier = Modifier, + showResetConfirmDialog: Boolean, + onShowResetConfirmDialogChange: (Boolean) -> Unit, onResetConfirmed: () -> Unit, onImportGeoIp: () -> Unit, onImportGeoSite: () -> Unit, onImportCountry: () -> Unit, onImportASN: () -> Unit, - modifier: Modifier = Modifier, ) { - var showResetConfirmDialog by remember { mutableStateOf(false) } - val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior() MihomoScaffold( title = stringResource(R.string.meta_features), modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), scrollBehavior = scrollBehavior, actions = { - IconButton(onClick = { showResetConfirmDialog = true }) { + IconButton(onClick = { onShowResetConfirmDialogChange(true) }) { Icon( painter = painterResource(R.drawable.ic_baseline_replay), contentDescription = stringResource(R.string.reset), @@ -93,21 +159,26 @@ private fun MetaFeatureSettingsScreen( ) { innerPadding -> ProvidePreferenceLocals { LazyColumn(modifier = Modifier.fillMaxSize(), contentPadding = innerPadding) { - metaBasicPreferenceItems(configuration) - metaSnifferPreferenceItems(configuration) - metaGeoFileItems(onImportGeoIp, onImportGeoSite, onImportCountry, onImportASN) + metaBasicPreferenceItems(configuration, actions) + metaSnifferPreferenceItems(configuration, actions) + metaGeoFileItems( + onImportGeoIp = onImportGeoIp, + onImportGeoSite = onImportGeoSite, + onImportCountry = onImportCountry, + onImportASN = onImportASN, + ) } } if (showResetConfirmDialog) { AlertDialog( - onDismissRequest = { showResetConfirmDialog = false }, + onDismissRequest = { onShowResetConfirmDialogChange(false) }, title = { Text(stringResource(R.string.reset_override_settings)) }, text = { Text(stringResource(R.string.reset_override_settings_message)) }, confirmButton = { TextButton( onClick = { - showResetConfirmDialog = false + onShowResetConfirmDialogChange(false) onResetConfirmed() } ) { @@ -115,7 +186,7 @@ private fun MetaFeatureSettingsScreen( } }, dismissButton = { - TextButton(onClick = { showResetConfirmDialog = false }) { + TextButton(onClick = { onShowResetConfirmDialogChange(false) }) { Text(stringResource(R.string.cancel)) } }, @@ -124,81 +195,75 @@ private fun MetaFeatureSettingsScreen( } } -private fun LazyListScope.metaBasicPreferenceItems(configuration: ConfigurationOverride) { +private fun LazyListScope.metaBasicPreferenceItems( + configuration: ConfigurationOverride, + actions: MetaFeatureSettingsActions, +) { preferenceCategory(key = "cat_settings", title = { Text(stringResource(R.string.settings)) }) item(key = "unifiedDelay", contentType = "ListPreference") { - val state = - rememberWriteThroughState(configuration.unifiedDelay) { configuration.unifiedDelay = it } - val value by state SettingsListPreferenceItem( - state = state, + value = configuration.unifiedDelay, + onValueChange = actions::updateUnifiedDelay, values = booleanOptions, modifier = Modifier.fillMaxWidth(), title = R.string.unified_delay, - summary = value.textRes, + summary = configuration.unifiedDelay.textRes, valueToText = { it.textRes }, ) } item(key = "geodataMode", contentType = "ListPreference") { - val state = - rememberWriteThroughState(configuration.geodataMode) { configuration.geodataMode = it } - val value by state SettingsListPreferenceItem( - state = state, + value = configuration.geodataMode, + onValueChange = actions::updateGeodataMode, values = booleanOptions, modifier = Modifier.fillMaxWidth(), title = R.string.geodata_mode, - summary = value.textRes, + summary = configuration.geodataMode.textRes, valueToText = { it.textRes }, ) } item(key = "tcpConcurrent", contentType = "ListPreference") { - val state = - rememberWriteThroughState(configuration.tcpConcurrent) { configuration.tcpConcurrent = it } - val value by state SettingsListPreferenceItem( - state = state, + value = configuration.tcpConcurrent, + onValueChange = actions::updateTcpConcurrent, values = booleanOptions, modifier = Modifier.fillMaxWidth(), title = R.string.tcp_concurrent, - summary = value.textRes, + summary = configuration.tcpConcurrent.textRes, valueToText = { it.textRes }, ) } item(key = "findProcessMode", contentType = "ListPreference") { - val state = - rememberWriteThroughState(configuration.findProcessMode) { - configuration.findProcessMode = it - } - val value by state SettingsListPreferenceItem( - state = state, + value = configuration.findProcessMode, + onValueChange = actions::updateFindProcessMode, values = ConfigurationOverride.FindProcessMode.entries, modifier = Modifier.fillMaxWidth(), title = R.string.find_process_mode, - summary = value.textRes, + summary = configuration.findProcessMode.textRes, valueToText = { it.textRes }, ) } } -private fun LazyListScope.metaSnifferPreferenceItems(configuration: ConfigurationOverride) { +private fun LazyListScope.metaSnifferPreferenceItems( + configuration: ConfigurationOverride, + actions: MetaFeatureSettingsActions, +) { preferenceCategory( key = "cat_sniffer", title = { Text(stringResource(R.string.sniffer_setting)) }, ) item(key = "snifferEnable", contentType = "ListPreference") { - val state = - rememberWriteThroughState(configuration.sniffer.enable) { configuration.sniffer.enable = it } - val value by state SettingsListPreferenceItem( - state = state, + value = configuration.sniffer.enable, + onValueChange = actions::updateSnifferEnable, values = booleanOptions, modifier = Modifier.fillMaxWidth(), title = R.string.strategy, - summary = value.textRes, + summary = configuration.sniffer.enable.textRes, valueToText = { it.textRes }, ) } @@ -207,27 +272,21 @@ private fun LazyListScope.metaSnifferPreferenceItems(configuration: Configuratio SettingsEditTextListPreferenceItem( title = R.string.sniff_http_ports, placeholder = R.string.dont_modify, - state = - rememberWriteThroughState(configuration.sniffer.sniff.http.ports) { - configuration.sniffer.sniff.http.ports = it - }, + value = configuration.sniffer.sniff.http.ports, + onValueChange = actions::updateSniffHttpPorts, enabled = enabled, ) } item(key = "sniffHttpOverrideDestination", contentType = "ListPreference") { val enabled = configuration.sniffer.enable != false - val state = - rememberWriteThroughState(configuration.sniffer.sniff.http.overrideDestination) { - configuration.sniffer.sniff.http.overrideDestination = it - } - val value by state SettingsListPreferenceItem( - state = state, + value = configuration.sniffer.sniff.http.overrideDestination, + onValueChange = actions::updateSniffHttpOverrideDestination, values = booleanOptions, modifier = Modifier.fillMaxWidth(), enabled = enabled, title = R.string.sniff_http_override_destination, - summary = value.textRes, + summary = configuration.sniffer.sniff.http.overrideDestination.textRes, valueToText = { it.textRes }, ) } @@ -236,27 +295,21 @@ private fun LazyListScope.metaSnifferPreferenceItems(configuration: Configuratio SettingsEditTextListPreferenceItem( title = R.string.sniff_tls_ports, placeholder = R.string.dont_modify, - state = - rememberWriteThroughState(configuration.sniffer.sniff.tls.ports) { - configuration.sniffer.sniff.tls.ports = it - }, + value = configuration.sniffer.sniff.tls.ports, + onValueChange = actions::updateSniffTlsPorts, enabled = enabled, ) } item(key = "sniffTlsOverrideDestination", contentType = "ListPreference") { val enabled = configuration.sniffer.enable != false - val state = - rememberWriteThroughState(configuration.sniffer.sniff.tls.overrideDestination) { - configuration.sniffer.sniff.tls.overrideDestination = it - } - val value by state SettingsListPreferenceItem( - state = state, + value = configuration.sniffer.sniff.tls.overrideDestination, + onValueChange = actions::updateSniffTlsOverrideDestination, values = booleanOptions, modifier = Modifier.fillMaxWidth(), enabled = enabled, title = R.string.sniff_tls_override_destination, - summary = value.textRes, + summary = configuration.sniffer.sniff.tls.overrideDestination.textRes, valueToText = { it.textRes }, ) } @@ -265,78 +318,60 @@ private fun LazyListScope.metaSnifferPreferenceItems(configuration: Configuratio SettingsEditTextListPreferenceItem( title = R.string.sniff_quic_ports, placeholder = R.string.dont_modify, - state = - rememberWriteThroughState(configuration.sniffer.sniff.quic.ports) { - configuration.sniffer.sniff.quic.ports = it - }, + value = configuration.sniffer.sniff.quic.ports, + onValueChange = actions::updateSniffQuicPorts, enabled = enabled, ) } item(key = "sniffQuicOverrideDestination", contentType = "ListPreference") { val enabled = configuration.sniffer.enable != false - val state = - rememberWriteThroughState(configuration.sniffer.sniff.quic.overrideDestination) { - configuration.sniffer.sniff.quic.overrideDestination = it - } - val value by state SettingsListPreferenceItem( - state = state, + value = configuration.sniffer.sniff.quic.overrideDestination, + onValueChange = actions::updateSniffQuicOverrideDestination, values = booleanOptions, modifier = Modifier.fillMaxWidth(), enabled = enabled, title = R.string.sniff_quic_override_destination, - summary = value.textRes, + summary = configuration.sniffer.sniff.quic.overrideDestination.textRes, valueToText = { it.textRes }, ) } item(key = "forceDnsMapping", contentType = "ListPreference") { val enabled = configuration.sniffer.enable != false - val state = - rememberWriteThroughState(configuration.sniffer.forceDnsMapping) { - configuration.sniffer.forceDnsMapping = it - } - val value by state SettingsListPreferenceItem( - state = state, + value = configuration.sniffer.forceDnsMapping, + onValueChange = actions::updateForceDnsMapping, values = booleanOptions, modifier = Modifier.fillMaxWidth(), enabled = enabled, title = R.string.force_dns_mapping, - summary = value.textRes, + summary = configuration.sniffer.forceDnsMapping.textRes, valueToText = { it.textRes }, ) } item(key = "parsePureIp", contentType = "ListPreference") { val enabled = configuration.sniffer.enable != false - val state = - rememberWriteThroughState(configuration.sniffer.parsePureIp) { - configuration.sniffer.parsePureIp = it - } - val value by state SettingsListPreferenceItem( - state = state, + value = configuration.sniffer.parsePureIp, + onValueChange = actions::updateParsePureIp, values = booleanOptions, modifier = Modifier.fillMaxWidth(), enabled = enabled, title = R.string.parse_pure_ip, - summary = value.textRes, + summary = configuration.sniffer.parsePureIp.textRes, valueToText = { it.textRes }, ) } item(key = "overrideDestination", contentType = "ListPreference") { val enabled = configuration.sniffer.enable != false - val state = - rememberWriteThroughState(configuration.sniffer.overrideDestination) { - configuration.sniffer.overrideDestination = it - } - val value by state SettingsListPreferenceItem( - state = state, + value = configuration.sniffer.overrideDestination, + onValueChange = actions::updateOverrideDestination, values = booleanOptions, modifier = Modifier.fillMaxWidth(), enabled = enabled, title = R.string.override_destination, - summary = value.textRes, + summary = configuration.sniffer.overrideDestination.textRes, valueToText = { it.textRes }, ) } @@ -345,10 +380,8 @@ private fun LazyListScope.metaSnifferPreferenceItems(configuration: Configuratio SettingsEditTextListPreferenceItem( title = R.string.force_domain, placeholder = R.string.dont_modify, - state = - rememberWriteThroughState(configuration.sniffer.forceDomain) { - configuration.sniffer.forceDomain = it - }, + value = configuration.sniffer.forceDomain, + onValueChange = actions::updateForceDomain, enabled = enabled, ) } @@ -357,10 +390,8 @@ private fun LazyListScope.metaSnifferPreferenceItems(configuration: Configuratio SettingsEditTextListPreferenceItem( title = R.string.skip_domain, placeholder = R.string.dont_modify, - state = - rememberWriteThroughState(configuration.sniffer.skipDomain) { - configuration.sniffer.skipDomain = it - }, + value = configuration.sniffer.skipDomain, + onValueChange = actions::updateSkipDomain, enabled = enabled, ) } @@ -369,10 +400,8 @@ private fun LazyListScope.metaSnifferPreferenceItems(configuration: Configuratio SettingsEditTextListPreferenceItem( title = R.string.skip_src_address, placeholder = R.string.dont_modify, - state = - rememberWriteThroughState(configuration.sniffer.skipSrcAddress) { - configuration.sniffer.skipSrcAddress = it - }, + value = configuration.sniffer.skipSrcAddress, + onValueChange = actions::updateSkipSrcAddress, enabled = enabled, ) } @@ -381,10 +410,8 @@ private fun LazyListScope.metaSnifferPreferenceItems(configuration: Configuratio SettingsEditTextListPreferenceItem( title = R.string.skip_dst_address, placeholder = R.string.dont_modify, - state = - rememberWriteThroughState(configuration.sniffer.skipDstAddress) { - configuration.sniffer.skipDstAddress = it - }, + value = configuration.sniffer.skipDstAddress, + onValueChange = actions::updateSkipDstAddress, enabled = enabled, ) } @@ -438,11 +465,53 @@ private val ConfigurationOverride.FindProcessMode?.textRes: Int null -> R.string.dont_modify } +interface MetaFeatureSettingsActions { + fun updateUnifiedDelay(value: Boolean?) = Unit + + fun updateGeodataMode(value: Boolean?) = Unit + + fun updateTcpConcurrent(value: Boolean?) = Unit + + fun updateFindProcessMode(value: ConfigurationOverride.FindProcessMode?) = Unit + + fun updateSnifferEnable(value: Boolean?) = Unit + + fun updateSniffHttpPorts(value: List?) = Unit + + fun updateSniffHttpOverrideDestination(value: Boolean?) = Unit + + fun updateSniffTlsPorts(value: List?) = Unit + + fun updateSniffTlsOverrideDestination(value: Boolean?) = Unit + + fun updateSniffQuicPorts(value: List?) = Unit + + fun updateSniffQuicOverrideDestination(value: Boolean?) = Unit + + fun updateForceDnsMapping(value: Boolean?) = Unit + + fun updateParsePureIp(value: Boolean?) = Unit + + fun updateOverrideDestination(value: Boolean?) = Unit + + fun updateForceDomain(value: List?) = Unit + + fun updateSkipDomain(value: List?) = Unit + + fun updateSkipSrcAddress(value: List?) = Unit + + fun updateSkipDstAddress(value: List?) = Unit +} + @PreviewMihomo @Composable -private fun MetaFeatureSettingsScreenPreview() = MihomoTheme { - MetaFeatureSettingsScreen( +@OptIn(ExperimentalMaterial3Api::class) +private fun MetaFeatureSettingsContentPreview() = MihomoTheme { + MetaFeatureSettingsContent( configuration = ConfigurationOverride(), + actions = object : MetaFeatureSettingsActions {}, + showResetConfirmDialog = false, + onShowResetConfirmDialogChange = {}, onResetConfirmed = {}, onImportGeoIp = {}, onImportGeoSite = {}, diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt new file mode 100644 index 0000000000..d9416f935f --- /dev/null +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt @@ -0,0 +1,214 @@ +package com.github.kr328.clash.settings.vm + +import android.app.Application +import android.database.Cursor +import android.net.Uri +import android.provider.OpenableColumns +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.github.kr328.clash.core.Clash +import com.github.kr328.clash.core.model.ConfigurationOverride as UiState +import com.github.kr328.clash.settings.ui.MetaFeatureSettingsActions +import com.github.kr328.clash.util.clashDir +import com.github.kr328.clash.util.withClash +import java.io.File +import java.io.FileOutputStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.launch + +class MetaFeatureSettingsViewModel(app: Application) : + AndroidViewModel(app), MetaFeatureSettingsActions { + private val appContext = app + private val validDatabaseExtensions = listOf(".metadb", ".db", ".dat", ".mmdb") + private var skipPersist = false + + val uiState: StateFlow + field = MutableStateFlow(UiState()) + + val importResult: StateFlow + field = MutableStateFlow(ImportResult.NotStart) + + init { + viewModelScope.launch { + uiState.value = withClash { queryOverride(Clash.OverrideSlot.Persist) } + } + } + + fun importGeoFile(uri: Uri?, importType: ImportType) { + viewModelScope.launch(Dispatchers.IO) { + val resolver = appContext.contentResolver + val cursor: Cursor = + uri?.let { resolver.query(it, null, null, null, null, null) } + ?: run { + importResult.value = ImportResult.Failed + return@launch + } + + importResult.value = cursor.use { + if (!it.moveToFirst()) return@use ImportResult.Failed + + val columnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val displayName = if (columnIndex != -1) it.getString(columnIndex) else "" + val ext = "." + displayName.substringAfterLast(".") + + if (!validDatabaseExtensions.contains(ext)) { + return@use ImportResult.UnsupportedFormat(validDatabaseExtensions.joinToString("/")) + } + + val outputFileName = + when (importType) { + ImportType.GeoIp -> "geoip$ext" + ImportType.GeoSite -> "geosite$ext" + ImportType.Country -> "country$ext" + ImportType.ASN -> "ASN$ext" + } + + val outputFile = File(appContext.clashDir, outputFileName) + outputFile.parentFile?.mkdirs() + resolver.openInputStream(uri).use { ins -> + FileOutputStream(outputFile).use { outs -> + if (ins == null) return@use ImportResult.Failed + ins.copyTo(outs) + } + } + return@use ImportResult.Success(displayName) + } + } + } + + override fun updateUnifiedDelay(value: Boolean?) = updateState { it.copy(unifiedDelay = value) } + + override fun updateGeodataMode(value: Boolean?) = updateState { it.copy(geodataMode = value) } + + override fun updateTcpConcurrent(value: Boolean?) = updateState { it.copy(tcpConcurrent = value) } + + override fun updateFindProcessMode(value: UiState.FindProcessMode?) = updateState { + it.copy(findProcessMode = value) + } + + override fun updateSnifferEnable(value: Boolean?) = updateState { + it.copy(sniffer = it.sniffer.copy(enable = value)) + } + + override fun updateSniffHttpPorts(value: List?) = updateState { + it.copy( + sniffer = + it.sniffer.copy( + sniff = it.sniffer.sniff.copy(http = it.sniffer.sniff.http.copy(ports = value)) + ) + ) + } + + override fun updateSniffHttpOverrideDestination(value: Boolean?) = updateState { + it.copy( + sniffer = + it.sniffer.copy( + sniff = + it.sniffer.sniff.copy(http = it.sniffer.sniff.http.copy(overrideDestination = value)) + ) + ) + } + + override fun updateSniffTlsPorts(value: List?) = updateState { + it.copy( + sniffer = + it.sniffer.copy( + sniff = it.sniffer.sniff.copy(tls = it.sniffer.sniff.tls.copy(ports = value)) + ) + ) + } + + override fun updateSniffTlsOverrideDestination(value: Boolean?) = updateState { + it.copy( + sniffer = + it.sniffer.copy( + sniff = + it.sniffer.sniff.copy(tls = it.sniffer.sniff.tls.copy(overrideDestination = value)) + ) + ) + } + + override fun updateSniffQuicPorts(value: List?) = updateState { + it.copy( + sniffer = + it.sniffer.copy( + sniff = it.sniffer.sniff.copy(quic = it.sniffer.sniff.quic.copy(ports = value)) + ) + ) + } + + override fun updateSniffQuicOverrideDestination(value: Boolean?) = updateState { + it.copy( + sniffer = + it.sniffer.copy( + sniff = + it.sniffer.sniff.copy(quic = it.sniffer.sniff.quic.copy(overrideDestination = value)) + ) + ) + } + + override fun updateForceDnsMapping(value: Boolean?) = updateState { + it.copy(sniffer = it.sniffer.copy(forceDnsMapping = value)) + } + + override fun updateParsePureIp(value: Boolean?) = updateState { + it.copy(sniffer = it.sniffer.copy(parsePureIp = value)) + } + + override fun updateOverrideDestination(value: Boolean?) = updateState { + it.copy(sniffer = it.sniffer.copy(overrideDestination = value)) + } + + override fun updateForceDomain(value: List?) = updateState { + it.copy(sniffer = it.sniffer.copy(forceDomain = value)) + } + + override fun updateSkipDomain(value: List?) = updateState { + it.copy(sniffer = it.sniffer.copy(skipDomain = value)) + } + + override fun updateSkipSrcAddress(value: List?) = updateState { + it.copy(sniffer = it.sniffer.copy(skipSrcAddress = value)) + } + + override fun updateSkipDstAddress(value: List?) = updateState { + it.copy(sniffer = it.sniffer.copy(skipDstAddress = value)) + } + + fun persistOverride() { + viewModelScope.launch(Dispatchers.IO) { + if (skipPersist) return@launch + withClash { patchOverride(Clash.OverrideSlot.Persist, uiState.value) } + } + } + + fun resetOverride() { + viewModelScope.launch(Dispatchers.IO) { + skipPersist = true + withClash { clearOverride(Clash.OverrideSlot.Persist) } + } + } + + private inline fun updateState(transform: (UiState) -> UiState) { + uiState.value = transform(uiState.value) + } + + enum class ImportType { + GeoIp, + GeoSite, + Country, + ASN, + } + + sealed interface ImportResult { + data object NotStart : ImportResult + + data class Success(val displayName: String) : ImportResult + + data class UnsupportedFormat(val summary: String) : ImportResult + + data object Failed : ImportResult + } +} diff --git a/app/src/main/kotlin/com/github/kr328/clash/ui/component/SettingsPreference.kt b/app/src/main/kotlin/com/github/kr328/clash/ui/component/SettingsPreference.kt index 44996e24d1..8af6362c86 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/ui/component/SettingsPreference.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/ui/component/SettingsPreference.kt @@ -61,6 +61,29 @@ fun rememberWriteThroughState(initial: T, sync: (T) -> Unit): MutableState SettingsListPreferenceItem( + value: T, + values: List, + @StringRes title: Int, + @StringRes summary: Int, + modifier: Modifier = Modifier, + enabled: Boolean = true, + valueToText: @Composable (T) -> Int, + onValueChange: (T) -> Unit, +) { + ListPreference( + value = value, + onValueChange = onValueChange, + values = values, + modifier = modifier, + enabled = enabled, + title = { Text(stringResource(title)) }, + summary = { Text(stringResource(summary)) }, + valueToText = { AnnotatedString(stringResource(valueToText(it))) }, + ) +} + @Composable fun SettingsListPreferenceItem( state: MutableState, @@ -82,6 +105,35 @@ fun SettingsListPreferenceItem( ) } +@Composable +fun SettingsEditTextListPreferenceItem( + @StringRes title: Int, + @StringRes placeholder: Int, + value: List?, + onValueChange: (List?) -> Unit, + enabled: Boolean = true, +) { + var showDialog by remember { mutableStateOf(false) } + Preference( + modifier = Modifier.fillMaxWidth(), + title = { Text(stringResource(title)) }, + summary = { Text(value.summary(placeholder)) }, + enabled = enabled, + onClick = { showDialog = true }, + ) + if (showDialog) { + EditableTextListDialog( + title = title, + initialValues = value, + onDismiss = { showDialog = false }, + onApply = { + onValueChange(it) + showDialog = false + }, + ) + } +} + @Composable fun SettingsEditTextListPreferenceItem( @StringRes title: Int, From 665fd7ac639e358bda2353ec8bbd056c09f848cc Mon Sep 17 00:00:00 2001 From: Zongle Wang Date: Fri, 24 Apr 2026 15:34:10 +0800 Subject: [PATCH 02/11] Apply suggestions from code review Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt index d9416f935f..37b7ff7a30 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt @@ -67,9 +67,9 @@ class MetaFeatureSettingsViewModel(app: Application) : val outputFile = File(appContext.clashDir, outputFileName) outputFile.parentFile?.mkdirs() - resolver.openInputStream(uri).use { ins -> + val inputStream = resolver.openInputStream(uri) ?: return@use ImportResult.Failed + inputStream.use { ins -> FileOutputStream(outputFile).use { outs -> - if (ins == null) return@use ImportResult.Failed ins.copyTo(outs) } } @@ -185,8 +185,8 @@ class MetaFeatureSettingsViewModel(app: Application) : } fun resetOverride() { + skipPersist = true viewModelScope.launch(Dispatchers.IO) { - skipPersist = true withClash { clearOverride(Clash.OverrideSlot.Persist) } } } From 534b402f2e9fc9300a56021e4b1bcd0a25672608 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 24 Apr 2026 07:37:34 +0000 Subject: [PATCH 03/11] fix: address review comments on MetaFeatureSettingsViewModel - Wrap importGeoFile() body in try/catch, setting importResult to Failed on any exception so the UI is never left in a stale state - Use uiState.update { ... } in updateState() for atomic read-modify-write Agent-Logs-Url: https://github.com/Goooler/MihomoForAndroid/sessions/ae75f819-f7f0-4f0a-a5fb-374c5b0bfe06 Co-authored-by: Goooler <10363352+Goooler@users.noreply.github.com> --- .../vm/MetaFeatureSettingsViewModel.kt | 71 ++++++++++--------- 1 file changed, 38 insertions(+), 33 deletions(-) diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt index 37b7ff7a30..5988d4dbaf 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt @@ -16,6 +16,7 @@ import java.io.FileOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch class MetaFeatureSettingsViewModel(app: Application) : @@ -38,42 +39,46 @@ class MetaFeatureSettingsViewModel(app: Application) : fun importGeoFile(uri: Uri?, importType: ImportType) { viewModelScope.launch(Dispatchers.IO) { - val resolver = appContext.contentResolver - val cursor: Cursor = - uri?.let { resolver.query(it, null, null, null, null, null) } - ?: run { - importResult.value = ImportResult.Failed - return@launch + try { + val resolver = appContext.contentResolver + val cursor: Cursor = + uri?.let { resolver.query(it, null, null, null, null, null) } + ?: run { + importResult.value = ImportResult.Failed + return@launch + } + + importResult.value = cursor.use { + if (!it.moveToFirst()) return@use ImportResult.Failed + + val columnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) + val displayName = if (columnIndex != -1) it.getString(columnIndex) else "" + val ext = "." + displayName.substringAfterLast(".") + + if (!validDatabaseExtensions.contains(ext)) { + return@use ImportResult.UnsupportedFormat(validDatabaseExtensions.joinToString("/")) } - importResult.value = cursor.use { - if (!it.moveToFirst()) return@use ImportResult.Failed - - val columnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) - val displayName = if (columnIndex != -1) it.getString(columnIndex) else "" - val ext = "." + displayName.substringAfterLast(".") - - if (!validDatabaseExtensions.contains(ext)) { - return@use ImportResult.UnsupportedFormat(validDatabaseExtensions.joinToString("/")) - } - - val outputFileName = - when (importType) { - ImportType.GeoIp -> "geoip$ext" - ImportType.GeoSite -> "geosite$ext" - ImportType.Country -> "country$ext" - ImportType.ASN -> "ASN$ext" - } - - val outputFile = File(appContext.clashDir, outputFileName) - outputFile.parentFile?.mkdirs() - val inputStream = resolver.openInputStream(uri) ?: return@use ImportResult.Failed - inputStream.use { ins -> - FileOutputStream(outputFile).use { outs -> - ins.copyTo(outs) + val outputFileName = + when (importType) { + ImportType.GeoIp -> "geoip$ext" + ImportType.GeoSite -> "geosite$ext" + ImportType.Country -> "country$ext" + ImportType.ASN -> "ASN$ext" + } + + val outputFile = File(appContext.clashDir, outputFileName) + outputFile.parentFile?.mkdirs() + val inputStream = resolver.openInputStream(uri) ?: return@use ImportResult.Failed + inputStream.use { ins -> + FileOutputStream(outputFile).use { outs -> + ins.copyTo(outs) + } } + return@use ImportResult.Success(displayName) } - return@use ImportResult.Success(displayName) + } catch (e: Exception) { + importResult.value = ImportResult.Failed } } } @@ -192,7 +197,7 @@ class MetaFeatureSettingsViewModel(app: Application) : } private inline fun updateState(transform: (UiState) -> UiState) { - uiState.value = transform(uiState.value) + uiState.update(transform) } enum class ImportType { From 762bff0c9c1997452b186eb2cb0d82044ac89c80 Mon Sep 17 00:00:00 2001 From: Goooler Date: Fri, 24 Apr 2026 15:40:09 +0800 Subject: [PATCH 04/11] Cleanups --- .../clash/settings/vm/MetaFeatureSettingsViewModel.kt | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt index 5988d4dbaf..c72bf91396 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt @@ -11,7 +11,6 @@ import com.github.kr328.clash.core.model.ConfigurationOverride as UiState import com.github.kr328.clash.settings.ui.MetaFeatureSettingsActions import com.github.kr328.clash.util.clashDir import com.github.kr328.clash.util.withClash -import java.io.File import java.io.FileOutputStream import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -67,17 +66,13 @@ class MetaFeatureSettingsViewModel(app: Application) : ImportType.ASN -> "ASN$ext" } - val outputFile = File(appContext.clashDir, outputFileName) + val outputFile = appContext.clashDir.resolve(outputFileName) outputFile.parentFile?.mkdirs() val inputStream = resolver.openInputStream(uri) ?: return@use ImportResult.Failed - inputStream.use { ins -> - FileOutputStream(outputFile).use { outs -> - ins.copyTo(outs) - } - } + inputStream.use { ins -> FileOutputStream(outputFile).use { outs -> ins.copyTo(outs) } } return@use ImportResult.Success(displayName) } - } catch (e: Exception) { + } catch (_: Exception) { importResult.value = ImportResult.Failed } } From 484c50f292a7bba5e1cbca387c8718290cee9845 Mon Sep 17 00:00:00 2001 From: Goooler Date: Fri, 24 Apr 2026 15:41:35 +0800 Subject: [PATCH 05/11] Inline updateState --- .../vm/MetaFeatureSettingsViewModel.kt | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt index c72bf91396..56cb8ecbb2 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt @@ -78,21 +78,25 @@ class MetaFeatureSettingsViewModel(app: Application) : } } - override fun updateUnifiedDelay(value: Boolean?) = updateState { it.copy(unifiedDelay = value) } + override fun updateUnifiedDelay(value: Boolean?) = uiState.update { + it.copy(unifiedDelay = value) + } - override fun updateGeodataMode(value: Boolean?) = updateState { it.copy(geodataMode = value) } + override fun updateGeodataMode(value: Boolean?) = uiState.update { it.copy(geodataMode = value) } - override fun updateTcpConcurrent(value: Boolean?) = updateState { it.copy(tcpConcurrent = value) } + override fun updateTcpConcurrent(value: Boolean?) = uiState.update { + it.copy(tcpConcurrent = value) + } - override fun updateFindProcessMode(value: UiState.FindProcessMode?) = updateState { + override fun updateFindProcessMode(value: UiState.FindProcessMode?) = uiState.update { it.copy(findProcessMode = value) } - override fun updateSnifferEnable(value: Boolean?) = updateState { + override fun updateSnifferEnable(value: Boolean?) = uiState.update { it.copy(sniffer = it.sniffer.copy(enable = value)) } - override fun updateSniffHttpPorts(value: List?) = updateState { + override fun updateSniffHttpPorts(value: List?) = uiState.update { it.copy( sniffer = it.sniffer.copy( @@ -101,7 +105,7 @@ class MetaFeatureSettingsViewModel(app: Application) : ) } - override fun updateSniffHttpOverrideDestination(value: Boolean?) = updateState { + override fun updateSniffHttpOverrideDestination(value: Boolean?) = uiState.update { it.copy( sniffer = it.sniffer.copy( @@ -111,7 +115,7 @@ class MetaFeatureSettingsViewModel(app: Application) : ) } - override fun updateSniffTlsPorts(value: List?) = updateState { + override fun updateSniffTlsPorts(value: List?) = uiState.update { it.copy( sniffer = it.sniffer.copy( @@ -120,7 +124,7 @@ class MetaFeatureSettingsViewModel(app: Application) : ) } - override fun updateSniffTlsOverrideDestination(value: Boolean?) = updateState { + override fun updateSniffTlsOverrideDestination(value: Boolean?) = uiState.update { it.copy( sniffer = it.sniffer.copy( @@ -130,7 +134,7 @@ class MetaFeatureSettingsViewModel(app: Application) : ) } - override fun updateSniffQuicPorts(value: List?) = updateState { + override fun updateSniffQuicPorts(value: List?) = uiState.update { it.copy( sniffer = it.sniffer.copy( @@ -139,7 +143,7 @@ class MetaFeatureSettingsViewModel(app: Application) : ) } - override fun updateSniffQuicOverrideDestination(value: Boolean?) = updateState { + override fun updateSniffQuicOverrideDestination(value: Boolean?) = uiState.update { it.copy( sniffer = it.sniffer.copy( @@ -149,31 +153,31 @@ class MetaFeatureSettingsViewModel(app: Application) : ) } - override fun updateForceDnsMapping(value: Boolean?) = updateState { + override fun updateForceDnsMapping(value: Boolean?) = uiState.update { it.copy(sniffer = it.sniffer.copy(forceDnsMapping = value)) } - override fun updateParsePureIp(value: Boolean?) = updateState { + override fun updateParsePureIp(value: Boolean?) = uiState.update { it.copy(sniffer = it.sniffer.copy(parsePureIp = value)) } - override fun updateOverrideDestination(value: Boolean?) = updateState { + override fun updateOverrideDestination(value: Boolean?) = uiState.update { it.copy(sniffer = it.sniffer.copy(overrideDestination = value)) } - override fun updateForceDomain(value: List?) = updateState { + override fun updateForceDomain(value: List?) = uiState.update { it.copy(sniffer = it.sniffer.copy(forceDomain = value)) } - override fun updateSkipDomain(value: List?) = updateState { + override fun updateSkipDomain(value: List?) = uiState.update { it.copy(sniffer = it.sniffer.copy(skipDomain = value)) } - override fun updateSkipSrcAddress(value: List?) = updateState { + override fun updateSkipSrcAddress(value: List?) = uiState.update { it.copy(sniffer = it.sniffer.copy(skipSrcAddress = value)) } - override fun updateSkipDstAddress(value: List?) = updateState { + override fun updateSkipDstAddress(value: List?) = uiState.update { it.copy(sniffer = it.sniffer.copy(skipDstAddress = value)) } @@ -191,10 +195,6 @@ class MetaFeatureSettingsViewModel(app: Application) : } } - private inline fun updateState(transform: (UiState) -> UiState) { - uiState.update(transform) - } - enum class ImportType { GeoIp, GeoSite, From 2fd1e13e6cf640c1ddd55c99ed5ee4963f9cdbab Mon Sep 17 00:00:00 2001 From: Goooler Date: Fri, 24 Apr 2026 15:49:15 +0800 Subject: [PATCH 06/11] Add InProgress state --- .../kr328/clash/settings/ui/MetaFeatureSettingsScreen.kt | 3 ++- .../kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt | 3 +++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/ui/MetaFeatureSettingsScreen.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/ui/MetaFeatureSettingsScreen.kt index 73d83af6a2..cd91352378 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/ui/MetaFeatureSettingsScreen.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/ui/MetaFeatureSettingsScreen.kt @@ -63,7 +63,8 @@ fun MetaFeatureSettingsScreen( LaunchedEffect(importResult) { when (val result = importResult) { - ImportResult.NotStart -> Unit + ImportResult.NotStart, + ImportResult.InProgress -> Unit is ImportResult.Success -> { context.toast(importedText.format(result.displayName)) diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt index 56cb8ecbb2..d1970b51d2 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt @@ -38,6 +38,7 @@ class MetaFeatureSettingsViewModel(app: Application) : fun importGeoFile(uri: Uri?, importType: ImportType) { viewModelScope.launch(Dispatchers.IO) { + importResult.value = ImportResult.InProgress try { val resolver = appContext.contentResolver val cursor: Cursor = @@ -205,6 +206,8 @@ class MetaFeatureSettingsViewModel(app: Application) : sealed interface ImportResult { data object NotStart : ImportResult + data object InProgress : ImportResult + data class Success(val displayName: String) : ImportResult data class UnsupportedFormat(val summary: String) : ImportResult From 46e38f21c69ef1cafff879007fb620575a63b4ce Mon Sep 17 00:00:00 2001 From: Goooler Date: Fri, 24 Apr 2026 15:50:42 +0800 Subject: [PATCH 07/11] Call lowercase --- .../kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt index d1970b51d2..5e4f05c49e 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt @@ -53,7 +53,7 @@ class MetaFeatureSettingsViewModel(app: Application) : val columnIndex = it.getColumnIndex(OpenableColumns.DISPLAY_NAME) val displayName = if (columnIndex != -1) it.getString(columnIndex) else "" - val ext = "." + displayName.substringAfterLast(".") + val ext = "." + displayName.substringAfterLast(".").lowercase() if (!validDatabaseExtensions.contains(ext)) { return@use ImportResult.UnsupportedFormat(validDatabaseExtensions.joinToString("/")) From ad872aa3b7bb34d7544358cafd8d8af440d4afd4 Mon Sep 17 00:00:00 2001 From: Zongle Wang Date: Fri, 24 Apr 2026 16:02:49 +0800 Subject: [PATCH 08/11] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt index 5e4f05c49e..2961041ca2 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt @@ -22,7 +22,7 @@ class MetaFeatureSettingsViewModel(app: Application) : AndroidViewModel(app), MetaFeatureSettingsActions { private val appContext = app private val validDatabaseExtensions = listOf(".metadb", ".db", ".dat", ".mmdb") - private var skipPersist = false + @Volatile private var skipPersist = false val uiState: StateFlow field = MutableStateFlow(UiState()) From 2fe087784c380f43393e4de8a43ca94a6566cf43 Mon Sep 17 00:00:00 2001 From: Goooler Date: Fri, 24 Apr 2026 16:02:35 +0800 Subject: [PATCH 09/11] Launch on CoroutineScope(Dispatchers.IO) --- .../kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt index 2961041ca2..37e58143e0 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt @@ -12,6 +12,7 @@ import com.github.kr328.clash.settings.ui.MetaFeatureSettingsActions import com.github.kr328.clash.util.clashDir import com.github.kr328.clash.util.withClash import java.io.FileOutputStream +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -183,7 +184,8 @@ class MetaFeatureSettingsViewModel(app: Application) : } fun persistOverride() { - viewModelScope.launch(Dispatchers.IO) { + // Intended to use non-viewModel scope as we need the action to be called on disposed. + CoroutineScope(Dispatchers.IO).launch { if (skipPersist) return@launch withClash { patchOverride(Clash.OverrideSlot.Persist, uiState.value) } } From a18ca0c92b955dabacc3c523cee0ba2c49512718 Mon Sep 17 00:00:00 2001 From: Goooler Date: Fri, 24 Apr 2026 16:03:52 +0800 Subject: [PATCH 10/11] Move --- .../vm/MetaFeatureSettingsViewModel.kt | 30 +++++++++---------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt index 37e58143e0..5ac8f3dce4 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt @@ -37,6 +37,21 @@ class MetaFeatureSettingsViewModel(app: Application) : } } + fun persistOverride() { + // Intended to use non-viewModel scope as we need the action to be called on disposed. + CoroutineScope(Dispatchers.IO).launch { + if (skipPersist) return@launch + withClash { patchOverride(Clash.OverrideSlot.Persist, uiState.value) } + } + } + + fun resetOverride() { + skipPersist = true + viewModelScope.launch(Dispatchers.IO) { + withClash { clearOverride(Clash.OverrideSlot.Persist) } + } + } + fun importGeoFile(uri: Uri?, importType: ImportType) { viewModelScope.launch(Dispatchers.IO) { importResult.value = ImportResult.InProgress @@ -183,21 +198,6 @@ class MetaFeatureSettingsViewModel(app: Application) : it.copy(sniffer = it.sniffer.copy(skipDstAddress = value)) } - fun persistOverride() { - // Intended to use non-viewModel scope as we need the action to be called on disposed. - CoroutineScope(Dispatchers.IO).launch { - if (skipPersist) return@launch - withClash { patchOverride(Clash.OverrideSlot.Persist, uiState.value) } - } - } - - fun resetOverride() { - skipPersist = true - viewModelScope.launch(Dispatchers.IO) { - withClash { clearOverride(Clash.OverrideSlot.Persist) } - } - } - enum class ImportType { GeoIp, GeoSite, From 55b713e462f093c238734a1e744ffe991854fb28 Mon Sep 17 00:00:00 2001 From: Goooler Date: Fri, 24 Apr 2026 16:10:58 +0800 Subject: [PATCH 11/11] Call on IO again --- .../kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt index 5ac8f3dce4..6b81c4b1a6 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt @@ -47,7 +47,8 @@ class MetaFeatureSettingsViewModel(app: Application) : fun resetOverride() { skipPersist = true - viewModelScope.launch(Dispatchers.IO) { + // Intended to use non-viewModel scope as the action might be called on disposed. + CoroutineScope(Dispatchers.IO).launch { withClash { clearOverride(Clash.OverrideSlot.Persist) } } }