diff --git a/app/src/main/kotlin/com/github/kr328/clash/profile/PropertiesActivity.kt b/app/src/main/kotlin/com/github/kr328/clash/profile/PropertiesActivity.kt index b83ffec438..837105ffbe 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/profile/PropertiesActivity.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/profile/PropertiesActivity.kt @@ -1,97 +1,37 @@ package com.github.kr328.clash.profile -import com.github.kr328.clash.R +import android.os.Bundle +import androidx.activity.compose.setContent import com.github.kr328.clash.common.util.intent import com.github.kr328.clash.common.util.setUUID import com.github.kr328.clash.common.util.uuid import com.github.kr328.clash.files.FilesActivity -import com.github.kr328.clash.profile.ui.PropertiesDesign -import com.github.kr328.clash.service.model.Profile -import com.github.kr328.clash.ui.DesignActivity -import com.github.kr328.clash.util.showExceptionSnackbar -import com.github.kr328.clash.util.withProfile -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.selects.select +import com.github.kr328.clash.profile.ui.PropertiesScreen +import com.github.kr328.clash.ui.BaseActivity +import com.github.kr328.clash.ui.theme.MihomoTheme -class PropertiesActivity : DesignActivity() { - private var canceled: Boolean = false - private lateinit var original: Profile +class PropertiesActivity : BaseActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) - override suspend fun main() { setResult(RESULT_CANCELED) - val uuid = intent.uuid ?: return finish() - val design = PropertiesDesign(this) - - original = withProfile { queryByUUID(uuid) } ?: return finish() - - design.profile = original - - setContentDesign(design) - - defer { - canceled = true - - withProfile { release(uuid) } - } - - while (isActive) { - select { - events.onReceive { - when (it) { - Event.ActivityStop -> { - val profile = design.profile - - if (!canceled && profile != original) { - withProfile { patch(profile.uuid, profile.name, profile.source, profile.interval) } - } - } - Event.ServiceRecreated -> { - finish() - } - else -> Unit - } - } - design.requests.onReceive { - when (it) { - PropertiesDesign.Request.BrowseFiles -> { - startActivity(FilesActivity::class.intent.setUUID(uuid)) - } - PropertiesDesign.Request.Commit -> { - design.verifyAndCommit() + val uuid = checkNotNull(intent.uuid) { "The uuid must not be null." } + + setContent { + MihomoTheme { + PropertiesScreen( + uuid = uuid, + onBrowseFiles = { profileUuid -> + startActivity(FilesActivity::class.intent.setUUID(profileUuid)) + }, + onFinish = { success -> + if (success) { + setResult(RESULT_OK) } - } - } - } - } - } - - private suspend fun PropertiesDesign.verifyAndCommit() { - when { - profile.name.isBlank() -> { - snackbar(R.string.empty_name) - } - profile.type != Profile.Type.File && profile.source.isBlank() -> { - snackbar(R.string.invalid_url) - } - else -> { - try { - withProcessing { updateStatus -> - withProfile { - patch(profile.uuid, profile.name, profile.source, profile.interval) - - coroutineScope { commit(profile.uuid) { launch { updateStatus(it) } } } - } - } - - setResult(RESULT_OK) - - finish() - } catch (e: Exception) { - showExceptionSnackbar(e) - } + finish() + }, + ) } } } diff --git a/app/src/main/kotlin/com/github/kr328/clash/profile/ui/PropertiesDesign.kt b/app/src/main/kotlin/com/github/kr328/clash/profile/ui/PropertiesScreen.kt similarity index 75% rename from app/src/main/kotlin/com/github/kr328/clash/profile/ui/PropertiesDesign.kt rename to app/src/main/kotlin/com/github/kr328/clash/profile/ui/PropertiesScreen.kt index 8cd1326ff5..03324d39d9 100644 --- a/app/src/main/kotlin/com/github/kr328/clash/profile/ui/PropertiesDesign.kt +++ b/app/src/main/kotlin/com/github/kr328/clash/profile/ui/PropertiesScreen.kt @@ -1,7 +1,5 @@ package com.github.kr328.clash.profile.ui -import android.app.Activity -import android.content.Context import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column @@ -25,22 +23,28 @@ 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.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.fromHtml import androidx.compose.ui.unit.Dp +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewmodel.compose.viewModel import com.github.kr328.clash.R -import com.github.kr328.clash.core.model.FetchStatus +import com.github.kr328.clash.profile.vm.PropertiesViewModel import com.github.kr328.clash.service.model.Profile -import com.github.kr328.clash.ui.Design import com.github.kr328.clash.ui.component.MihomoScaffold import com.github.kr328.clash.ui.component.ModelProgressBarDialog import com.github.kr328.clash.ui.component.ModelProgressBarState @@ -52,108 +56,72 @@ import com.github.kr328.clash.ui.theme.mihomoDimens import com.github.kr328.clash.util.ValidatorAutoUpdateInterval import com.github.kr328.clash.util.ValidatorHttpUrl import com.github.kr328.clash.util.ValidatorNotBlank +import com.github.kr328.clash.util.toast import java.util.UUID import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.minutes -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -class PropertiesDesign(context: Context) : Design(context) { - sealed interface Request { - data object Commit : Request - - data object BrowseFiles : Request - } +@Composable +fun PropertiesScreen( + uuid: UUID, + modifier: Modifier = Modifier, + viewModel: PropertiesViewModel = viewModel(), + onBrowseFiles: (UUID) -> Unit, + onFinish: (Boolean) -> Unit, +) { + val lifecycleOwner = LocalLifecycleOwner.current + val uiState by viewModel.uiState.collectAsStateWithLifecycle() + val eventState by viewModel.eventState.collectAsStateWithLifecycle() + val context = LocalContext.current - private var profileState by mutableStateOf(null) - private var originalProfileState by mutableStateOf(null) - private var processingState by mutableStateOf(false) - private val progressBarState = ModelProgressBarState() + LaunchedEffect(uuid) { viewModel.init(uuid = uuid) } - @Composable - override fun Content() = MihomoTheme { - profileState?.let { profile -> - PropertiesScreen( - profile = profile, - processing = processingState, - progressBarState = progressBarState, - hasUnsavedChanges = - originalProfileState?.let { original -> hasUnsavedChanges(profile, original) } == true, - onBrowseFiles = { requests.trySend(Request.BrowseFiles) }, - onCommit = { requests.trySend(Request.Commit) }, - onRequestClose = { (context as? Activity)?.finish() }, - onNameChanged = { name -> this@PropertiesDesign.profile = profile.copy(name = name) }, - onUrlChanged = { url -> this@PropertiesDesign.profile = profile.copy(source = url) }, - onIntervalChanged = { interval -> - this@PropertiesDesign.profile = profile.copy(interval = interval) - }, - ) - } + DisposableEffect(lifecycleOwner, viewModel) { + lifecycleOwner.lifecycle.addObserver(viewModel) + onDispose { lifecycleOwner.lifecycle.removeObserver(viewModel) } } - var profile: Profile - get() = requireNotNull(profileState) - set(value) { - if (originalProfileState == null) { - originalProfileState = value.copy() + LaunchedEffect(eventState) { + when (val event = eventState) { + PropertiesViewModel.EventState.Idle -> Unit + is PropertiesViewModel.EventState.BrowseFiles -> { + onBrowseFiles(event.uuid) } - profileState = value - } - - suspend fun withProcessing(executeTask: suspend (suspend (FetchStatus) -> Unit) -> Unit) = - try { - withContext(Dispatchers.Main) { - processingState = true - progressBarState.visible = true - progressBarState.isIndeterminate = true - progressBarState.text = context.getString(R.string.initializing) - progressBarState.progress = 0 - progressBarState.max = 0 + is PropertiesViewModel.EventState.Finish -> { + onFinish(event.success) } - - executeTask { status -> withContext(Dispatchers.Main) { progressBarState.applyFrom(status) } } - } finally { - withContext(Dispatchers.Main) { - progressBarState.visible = false - progressBarState.text = null - processingState = false + is PropertiesViewModel.EventState.ShowMessage -> { + context.toast(event.message) } } - - private fun hasUnsavedChanges(profile: Profile, original: Profile): Boolean { - return profile.name != original.name || - profile.source != original.source || - profile.interval != original.interval + viewModel.consumeEvent() } - private fun ModelProgressBarState.applyFrom(status: FetchStatus) { - when (status.action) { - FetchStatus.Action.FetchConfiguration -> { - text = context.getString(R.string.format_fetching_configuration, status.args[0]) - isIndeterminate = true - } - FetchStatus.Action.FetchProviders -> { - text = context.getString(R.string.format_fetching_provider, status.args[0]) - isIndeterminate = false - max = status.max - progress = status.progress - } - FetchStatus.Action.Verifying -> { - text = context.getString(R.string.verifying) - isIndeterminate = false - max = status.max - progress = status.progress - } - } + val profile = uiState.profile + if (profile != null) { + PropertiesContent( + modifier = modifier, + profile = profile, + processing = uiState.processing, + progressState = uiState.progress, + hasUnsavedChanges = uiState.hasUnsavedChanges, + onBrowseFiles = viewModel::onBrowseFiles, + onCommit = viewModel::onCommit, + onRequestClose = viewModel::onRequestClose, + onNameChanged = viewModel::onNameChanged, + onUrlChanged = viewModel::onUrlChanged, + onIntervalChanged = viewModel::onIntervalChanged, + ) } } @Composable @OptIn(ExperimentalMaterial3Api::class) -private fun PropertiesScreen( +private fun PropertiesContent( + modifier: Modifier = Modifier, profile: Profile, processing: Boolean, - progressBarState: ModelProgressBarState, + progressState: PropertiesViewModel.ProgressState, hasUnsavedChanges: Boolean, onBrowseFiles: () -> Unit, onCommit: () -> Unit, @@ -170,6 +138,15 @@ private fun PropertiesScreen( var showInputUrlDialog by rememberSaveable { mutableStateOf(false) } var showInputIntervalDialog by rememberSaveable { mutableStateOf(false) } + val progressBarState = remember { ModelProgressBarState() } + with(progressBarState) { + visible = progressState.visible + isIndeterminate = progressState.isIndeterminate + text = progressState.text + progress = progressState.progress + max = progressState.max + } + val onBack = { when { processing -> Unit @@ -182,6 +159,7 @@ private fun PropertiesScreen( BackHandler(onBack = onBack) MihomoScaffold( + modifier = modifier, title = stringResource(R.string.properties), onBack = onBack, scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(), @@ -378,8 +356,8 @@ private fun PropertiesActionItem( @PreviewMihomo @Composable -private fun PropertiesScreenPreview() = MihomoTheme { - PropertiesScreen( +private fun PropertiesContentPreview() = MihomoTheme { + PropertiesContent( profile = Profile( uuid = UUID(0, 0), @@ -397,7 +375,7 @@ private fun PropertiesScreenPreview() = MihomoTheme { pending = false, ), processing = false, - progressBarState = ModelProgressBarState(), + progressState = PropertiesViewModel.ProgressState(), hasUnsavedChanges = false, onBrowseFiles = {}, onCommit = {}, diff --git a/app/src/main/kotlin/com/github/kr328/clash/profile/vm/PropertiesViewModel.kt b/app/src/main/kotlin/com/github/kr328/clash/profile/vm/PropertiesViewModel.kt new file mode 100644 index 0000000000..964bf800e0 --- /dev/null +++ b/app/src/main/kotlin/com/github/kr328/clash/profile/vm/PropertiesViewModel.kt @@ -0,0 +1,247 @@ +package com.github.kr328.clash.profile.vm + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.viewModelScope +import com.github.kr328.clash.R +import com.github.kr328.clash.core.model.FetchStatus +import com.github.kr328.clash.service.model.Profile +import com.github.kr328.clash.util.withProfile +import java.util.UUID +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class PropertiesViewModel(app: Application) : AndroidViewModel(app), DefaultLifecycleObserver { + private var rootUuid: UUID? = null + private var canceled = false + + val uiState: StateFlow + field = MutableStateFlow(UiState()) + + val eventState: StateFlow + field = MutableStateFlow(EventState.Idle) + + fun init(uuid: UUID) { + if (rootUuid != null) return + rootUuid = uuid + + viewModelScope.launch { + val profile = withProfile { queryByUUID(uuid) } + if (profile == null) { + eventState.value = EventState.Finish(false) + return@launch + } + uiState.update { it.copy(profile = profile, originalProfile = profile.copy()) } + } + } + + fun consumeEvent() { + eventState.value = EventState.Idle + } + + override fun onStop(owner: LifecycleOwner) { + if (!canceled && uiState.value.hasUnsavedChanges) { + val profile = uiState.value.profile ?: return + viewModelScope.launch { + runCatching { + withProfile { patch(profile.uuid, profile.name, profile.source, profile.interval) } + } + } + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun onCleared() { + rootUuid?.let { uuid -> + GlobalScope.launch(Dispatchers.IO) { + try { + withProfile { release(uuid) } + } catch (e: Exception) { + // ignore + } + } + } + super.onCleared() + } + + fun onNameChanged(name: String) { + uiState.update { current -> + val profile = current.profile?.copy(name = name) ?: return@update current + current.copy( + profile = profile, + hasUnsavedChanges = hasUnsavedChanges(profile, current.originalProfile), + ) + } + } + + fun onUrlChanged(url: String) { + uiState.update { current -> + val profile = current.profile?.copy(source = url) ?: return@update current + current.copy( + profile = profile, + hasUnsavedChanges = hasUnsavedChanges(profile, current.originalProfile), + ) + } + } + + fun onIntervalChanged(interval: Long) { + uiState.update { current -> + val profile = current.profile?.copy(interval = interval) ?: return@update current + current.copy( + profile = profile, + hasUnsavedChanges = hasUnsavedChanges(profile, current.originalProfile), + ) + } + } + + fun onBrowseFiles() { + val uuid = rootUuid ?: return + eventState.value = EventState.BrowseFiles(uuid) + } + + fun onRequestClose() { + canceled = true + eventState.value = EventState.Finish(false) + } + + fun onCommit() { + val profile = uiState.value.profile ?: return + + if (profile.name.isBlank()) { + eventState.value = + EventState.ShowMessage(getApplication().getString(R.string.empty_name)) + return + } + + if (profile.type != Profile.Type.File && profile.source.isBlank()) { + eventState.value = + EventState.ShowMessage(getApplication().getString(R.string.invalid_url)) + return + } + + viewModelScope.launch { + try { + withProcessing { updateStatus -> + withProfile { + patch(profile.uuid, profile.name, profile.source, profile.interval) + coroutineScope { commit(profile.uuid) { launch { updateStatus(it) } } } + } + } + canceled = true + eventState.value = EventState.Finish(true) + } catch (e: Exception) { + eventState.value = EventState.ShowMessage(e.message ?: "Unknown error") + } + } + } + + private suspend fun withProcessing(executeTask: suspend (suspend (FetchStatus) -> Unit) -> Unit) { + try { + withContext(Dispatchers.Main) { + uiState.update { + it.copy( + processing = true, + progress = + ProgressState( + visible = true, + isIndeterminate = true, + text = getApplication().getString(R.string.initializing), + progress = 0, + max = 0, + ), + ) + } + } + + executeTask { status -> withContext(Dispatchers.Main) { applyProgressStatus(status) } } + } finally { + withContext(Dispatchers.Main) { + uiState.update { + it.copy(processing = false, progress = it.progress.copy(visible = false, text = null)) + } + } + } + } + + private fun applyProgressStatus(status: FetchStatus) { + uiState.update { current -> + val context = getApplication() + val newProgress = + when (status.action) { + FetchStatus.Action.FetchConfiguration -> { + current.progress.copy( + text = + context.getString( + R.string.format_fetching_configuration, + status.args.getOrNull(0) ?: "", + ), + isIndeterminate = true, + ) + } + FetchStatus.Action.FetchProviders -> { + current.progress.copy( + text = + context.getString( + R.string.format_fetching_provider, + status.args.getOrNull(0) ?: "", + ), + isIndeterminate = false, + max = status.max, + progress = status.progress, + ) + } + FetchStatus.Action.Verifying -> { + current.progress.copy( + text = context.getString(R.string.verifying), + isIndeterminate = false, + max = status.max, + progress = status.progress, + ) + } + } + current.copy(progress = newProgress) + } + } + + private fun hasUnsavedChanges(profile: Profile, original: Profile?): Boolean { + if (original == null) return false + return profile.name != original.name || + profile.source != original.source || + profile.interval != original.interval + } + + data class UiState( + val profile: Profile? = null, + val originalProfile: Profile? = null, + val processing: Boolean = false, + val progress: ProgressState = ProgressState(), + val hasUnsavedChanges: Boolean = false, + ) + + data class ProgressState( + val visible: Boolean = false, + val isIndeterminate: Boolean = false, + val text: String? = null, + val progress: Int = 0, + val max: Int = 0, + ) + + sealed interface EventState { + data object Idle : EventState + + data class Finish(val success: Boolean) : EventState + + data class BrowseFiles(val uuid: UUID) : EventState + + data class ShowMessage(val message: String) : EventState + } +}