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..cd91352378 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,142 @@ 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, + ImportResult.InProgress -> 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 +160,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 +187,7 @@ private fun MetaFeatureSettingsScreen( } }, dismissButton = { - TextButton(onClick = { showResetConfirmDialog = false }) { + TextButton(onClick = { onShowResetConfirmDialogChange(false) }) { Text(stringResource(R.string.cancel)) } }, @@ -124,81 +196,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 +273,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 +296,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 +319,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 +381,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 +391,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 +401,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 +411,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 +466,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..6b81c4b1a6 --- /dev/null +++ b/app/src/main/kotlin/com/github/kr328/clash/settings/vm/MetaFeatureSettingsViewModel.kt @@ -0,0 +1,220 @@ +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.FileOutputStream +import kotlinx.coroutines.CoroutineScope +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) : + AndroidViewModel(app), MetaFeatureSettingsActions { + private val appContext = app + private val validDatabaseExtensions = listOf(".metadb", ".db", ".dat", ".mmdb") + @Volatile 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 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 + // Intended to use non-viewModel scope as the action might be called on disposed. + CoroutineScope(Dispatchers.IO).launch { + withClash { clearOverride(Clash.OverrideSlot.Persist) } + } + } + + fun importGeoFile(uri: Uri?, importType: ImportType) { + viewModelScope.launch(Dispatchers.IO) { + importResult.value = ImportResult.InProgress + 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(".").lowercase() + + 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 = 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) } } + return@use ImportResult.Success(displayName) + } + } catch (_: Exception) { + importResult.value = ImportResult.Failed + } + } + } + + override fun updateUnifiedDelay(value: Boolean?) = uiState.update { + it.copy(unifiedDelay = value) + } + + override fun updateGeodataMode(value: Boolean?) = uiState.update { it.copy(geodataMode = value) } + + override fun updateTcpConcurrent(value: Boolean?) = uiState.update { + it.copy(tcpConcurrent = value) + } + + override fun updateFindProcessMode(value: UiState.FindProcessMode?) = uiState.update { + it.copy(findProcessMode = value) + } + + override fun updateSnifferEnable(value: Boolean?) = uiState.update { + it.copy(sniffer = it.sniffer.copy(enable = value)) + } + + override fun updateSniffHttpPorts(value: List?) = uiState.update { + it.copy( + sniffer = + it.sniffer.copy( + sniff = it.sniffer.sniff.copy(http = it.sniffer.sniff.http.copy(ports = value)) + ) + ) + } + + override fun updateSniffHttpOverrideDestination(value: Boolean?) = uiState.update { + it.copy( + sniffer = + it.sniffer.copy( + sniff = + it.sniffer.sniff.copy(http = it.sniffer.sniff.http.copy(overrideDestination = value)) + ) + ) + } + + override fun updateSniffTlsPorts(value: List?) = uiState.update { + it.copy( + sniffer = + it.sniffer.copy( + sniff = it.sniffer.sniff.copy(tls = it.sniffer.sniff.tls.copy(ports = value)) + ) + ) + } + + override fun updateSniffTlsOverrideDestination(value: Boolean?) = uiState.update { + it.copy( + sniffer = + it.sniffer.copy( + sniff = + it.sniffer.sniff.copy(tls = it.sniffer.sniff.tls.copy(overrideDestination = value)) + ) + ) + } + + override fun updateSniffQuicPorts(value: List?) = uiState.update { + it.copy( + sniffer = + it.sniffer.copy( + sniff = it.sniffer.sniff.copy(quic = it.sniffer.sniff.quic.copy(ports = value)) + ) + ) + } + + override fun updateSniffQuicOverrideDestination(value: Boolean?) = uiState.update { + it.copy( + sniffer = + it.sniffer.copy( + sniff = + it.sniffer.sniff.copy(quic = it.sniffer.sniff.quic.copy(overrideDestination = value)) + ) + ) + } + + override fun updateForceDnsMapping(value: Boolean?) = uiState.update { + it.copy(sniffer = it.sniffer.copy(forceDnsMapping = value)) + } + + override fun updateParsePureIp(value: Boolean?) = uiState.update { + it.copy(sniffer = it.sniffer.copy(parsePureIp = value)) + } + + override fun updateOverrideDestination(value: Boolean?) = uiState.update { + it.copy(sniffer = it.sniffer.copy(overrideDestination = value)) + } + + override fun updateForceDomain(value: List?) = uiState.update { + it.copy(sniffer = it.sniffer.copy(forceDomain = value)) + } + + override fun updateSkipDomain(value: List?) = uiState.update { + it.copy(sniffer = it.sniffer.copy(skipDomain = value)) + } + + override fun updateSkipSrcAddress(value: List?) = uiState.update { + it.copy(sniffer = it.sniffer.copy(skipSrcAddress = value)) + } + + override fun updateSkipDstAddress(value: List?) = uiState.update { + it.copy(sniffer = it.sniffer.copy(skipDstAddress = value)) + } + + enum class ImportType { + GeoIp, + GeoSite, + Country, + ASN, + } + + 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 + + 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,