From 37b42f04a701c82fe52d01c5010bc0d6e3afd021 Mon Sep 17 00:00:00 2001 From: eliotcougar Date: Wed, 19 Aug 2026 09:16:41 +0300 Subject: [PATCH] Load user asset metadata off the UI thread (#6106) Load asset metadata on Dispatchers.IO and publish the asset list and file details as one lifecycle-aware UI state. Remove refresh-key workarounds, await reloads before downloads, and keep the content composable stateless. --- .../ang/ui/userasset/UserAssetActivity.kt | 112 ++++++++---------- .../ang/ui/userasset/UserAssetViewModel.kt | 47 +++++--- 2 files changed, 81 insertions(+), 78 deletions(-) diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/userasset/UserAssetActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/userasset/UserAssetActivity.kt index 27ba91ec..09eda357 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/userasset/UserAssetActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/userasset/UserAssetActivity.kt @@ -28,7 +28,6 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -76,13 +75,14 @@ private enum class AddAssetMenuAction(@StringRes val labelRes: Int) { QRCode(R.string.menu_item_scan_qrcode) } +private data class AssetDeleteTarget(val guid: String, val name: String) + class UserAssetActivity : HelperBaseComponentActivity() { private val viewModel: UserAssetViewModel by viewModels() - val extDir by lazy { File(Utils.userAssetPath(this)) } + private val extDir by lazy { File(Utils.userAssetPath(this)) } private val isLoadingState = MutableStateFlow(false) private val geoFilesSourceState = MutableStateFlow("") - private val refreshTrigger = MutableStateFlow(0) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -91,12 +91,13 @@ class UserAssetActivity : HelperBaseComponentActivity() { @Composable override fun ScreenContent() { + val isLoading by isLoadingState.collectAsStateWithLifecycle() + val geoFilesSource by geoFilesSourceState.collectAsStateWithLifecycle() + val uiState by viewModel.uiState.collectAsStateWithLifecycle() UserAssetScreen( - viewModel = viewModel, - extDir = extDir, - isLoadingState = isLoadingState, - geoFilesSourceState = geoFilesSourceState, - refreshTrigger = refreshTrigger, + uiState = uiState, + isLoading = isLoading, + geoFilesSource = geoFilesSource, geoFilesSourcesList = AppConfig.GEO_FILES_SOURCES.toList(), onBackClick = { finish() }, onGeoSourceSelected = { value -> @@ -111,13 +112,10 @@ class UserAssetActivity : HelperBaseComponentActivity() { onEditAsset = { guid -> startActivity(Intent(this, UserAssetUrlActivity::class.java).putExtra("assetId", guid)) }, - onRemoveAsset = { guid -> - val asset = viewModel.getAssets().find { it.guid == guid } - if (asset != null) { - extDir.listFiles()?.find { it.name == asset.assetUrl.remarks }?.delete() - MmkvManager.removeAssetUrl(guid) - initAssets() - } + onRemoveAsset = { guid, name -> + extDir.listFiles()?.find { it.name == name }?.delete() + MmkvManager.removeAssetUrl(guid) + initAssets() } ) } @@ -209,28 +207,28 @@ class UserAssetActivity : HelperBaseComponentActivity() { } private fun downloadGeoFiles() { - refreshData() isLoadingState.value = true toast(R.string.msg_downloading_content) val proxyUsername = SettingsManager.getSocksUsername() val proxyPassword = SettingsManager.getSocksPassword() val httpPort = SettingsManager.getHttpPort() - lifecycleScope.launch(Dispatchers.IO) { - val result = viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword) - withContext(Dispatchers.Main) { - if (result.successCount > 0) { - toast(getString(R.string.title_update_asset_count, result.successCount)) - } else { - toast(getString(R.string.toast_failure)) - } - refreshData() - isLoadingState.value = false + lifecycleScope.launch { + refreshData().join() + val result = withContext(Dispatchers.IO) { + viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword) } + if (result.successCount > 0) { + toast(getString(R.string.title_update_asset_count, result.successCount)) + } else { + toast(getString(R.string.toast_failure)) + } + refreshData().join() + isLoadingState.value = false } } - fun initAssets() { + private fun initAssets() { lifecycleScope.launch(Dispatchers.Default) { SettingsManager.initAssets(this@UserAssetActivity, assets) withContext(Dispatchers.Main) { @@ -239,19 +237,14 @@ class UserAssetActivity : HelperBaseComponentActivity() { } } - fun refreshData() { - viewModel.reload(getGeoFilesSources()) - refreshTrigger.value++ - } + private fun refreshData() = viewModel.reload(getGeoFilesSources(), extDir) } @Composable -fun UserAssetScreen( - viewModel: UserAssetViewModel, - extDir: File, - isLoadingState: MutableStateFlow, - geoFilesSourceState: MutableStateFlow, - refreshTrigger: MutableStateFlow, +internal fun UserAssetScreen( + uiState: UserAssetUiState, + isLoading: Boolean, + geoFilesSource: String, geoFilesSourcesList: List, onBackClick: () -> Unit, onGeoSourceSelected: (String) -> Unit, @@ -260,15 +253,10 @@ fun UserAssetScreen( onAddQrcodeClick: () -> Unit, onDownloadClick: () -> Unit, onEditAsset: (String) -> Unit, - onRemoveAsset: (String) -> Unit + onRemoveAsset: (String, String) -> Unit ) { - val isLoading by isLoadingState.collectAsState() - val geoFilesSource by geoFilesSourceState.collectAsState() - val assets by viewModel.assetsFlow.collectAsStateWithLifecycle() - val trigger by refreshTrigger.collectAsState() - var showAddMenu by remember { mutableStateOf(false) } - var deleteTargetGuid by remember { mutableStateOf(null) } + var deleteTarget by remember { mutableStateOf(null) } val listState = rememberLazyListState() Scaffold( @@ -315,7 +303,7 @@ fun UserAssetScreen( .verticalScrollbar(listState), contentPadding = NavigationBarsBottomPadding() ) { - item(key = "geo_source_$trigger") { + item(key = "geo_source") { SettingsListItem( title = stringResource(R.string.asset_geo_files_sources), entries = geoFilesSourcesList, @@ -331,12 +319,14 @@ fun UserAssetScreen( modifier = Modifier.padding(16.dp) ) } - itemsIndexed(items = assets, key = { _, item -> "${item.guid}_$trigger" }) { _, item -> + itemsIndexed(items = uiState.assets, key = { _, item -> item.guid }) { _, item -> UserAssetItem( item = item, - extDir = extDir, + fileMetadata = uiState.fileMetadata[item.guid], onEdit = { onEditAsset(item.guid) }, - onDeleteClick = { deleteTargetGuid = item.guid } + onDeleteClick = { + deleteTarget = AssetDeleteTarget(item.guid, item.assetUrl.remarks) + } ) ItemDivider() } @@ -344,13 +334,14 @@ fun UserAssetScreen( } - if (deleteTargetGuid != null) { - val guid = deleteTargetGuid!! - val assetName = assets.find { it.guid == guid }?.assetUrl?.remarks ?: "" + deleteTarget?.let { asset -> DeleteConfirmDialog( - message = stringResource(R.string.confirm_delete_asset_file, assetName), - onConfirm = { onRemoveAsset(guid) }, - onDismiss = { deleteTargetGuid = null } + message = stringResource(R.string.confirm_delete_asset_file, asset.name), + onConfirm = { + deleteTarget = null + onRemoveAsset(asset.guid, asset.name) + }, + onDismiss = { deleteTarget = null } ) } } @@ -358,16 +349,15 @@ fun UserAssetScreen( @Composable private fun UserAssetItem( item: AssetUrlCache, - extDir: File, + fileMetadata: AssetFileMetadata?, onEdit: () -> Unit, onDeleteClick: () -> Unit ) { - val file = remember(item.guid, item.assetUrl.remarks) { - extDir.listFiles()?.find { it.name == item.assetUrl.remarks } - } - val propertiesText = if (file != null) { - val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM) - "${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}" + val propertiesText = if (fileMetadata != null) { + remember(fileMetadata) { + val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM) + "${fileMetadata.length.toTrafficString()} • ${dateFormat.format(Date(fileMetadata.lastModified))}" + } } else { stringResource(R.string.msg_file_not_found) } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/userasset/UserAssetViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/userasset/UserAssetViewModel.kt index cf258a6b..89c4e507 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/userasset/UserAssetViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/userasset/UserAssetViewModel.kt @@ -1,6 +1,7 @@ package com.v2ray.ang.ui.userasset import android.app.Application +import androidx.lifecycle.viewModelScope import com.v2ray.ang.AppConfig import com.v2ray.ang.dto.UrlContentRequest import com.v2ray.ang.dto.entities.AssetUrlCache @@ -11,30 +12,42 @@ import com.v2ray.ang.ui.base.BaseViewModel import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.Utils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch import java.io.File +internal data class AssetFileMetadata(val length: Long, val lastModified: Long) + +internal data class UserAssetUiState( + val assets: List = emptyList(), + val fileMetadata: Map = emptyMap() +) + class UserAssetViewModel(application: Application) : BaseViewModel(application) { - private val assets = mutableListOf() private val builtInGeoFiles = listOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT) - private val _assetsFlow = MutableStateFlow>(emptyList()) - val assetsFlow: StateFlow> = _assetsFlow.asStateFlow() + private val _uiState = MutableStateFlow(UserAssetUiState()) + internal val uiState: StateFlow = _uiState.asStateFlow() + private var reloadJob: Job? = null - val itemCount: Int - get() = assets.size - - fun getAssets(): List = assets.toList() - - fun getAsset(position: Int): AssetUrlCache? = assets.getOrNull(position) - - fun reload(geoFilesSource: String) { - val decoded = MmkvManager.decodeAssetUrls() - assets.clear() - assets.addAll(buildAssetList(decoded, geoFilesSource)) - _assetsFlow.value = assets.toList() + fun reload(geoFilesSource: String, extDir: File): Job { + reloadJob?.cancel() + return viewModelScope.launch(Dispatchers.IO) { + val snapshot = buildAssetList(MmkvManager.decodeAssetUrls(), geoFilesSource) + val files = extDir.listFiles().orEmpty().associateBy { it.name } + val metadata = snapshot.mapNotNull { asset -> + files[asset.assetUrl.remarks]?.let { file -> + asset.guid to AssetFileMetadata(file.length(), file.lastModified()) + } + }.toMap() + ensureActive() + _uiState.value = UserAssetUiState(snapshot, metadata) + }.also { reloadJob = it } } private fun buildAssetList( @@ -74,7 +87,7 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application) proxyUsername: String? = null, proxyPassword: String? = null ): GeoDownloadResult { - val snapshot = getAssets() + val snapshot = uiState.value.assets var successCount = 0 val failures = mutableListOf() @@ -127,4 +140,4 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application) val failureCount: Int, val failedAssets: List ) -} \ No newline at end of file +}