From bb1c3d915fbb7a4c67e47575de6aca9b44146e30 Mon Sep 17 00:00:00 2001 From: 2dust <31833384+2dust@users.noreply.github.com> Date: Wed, 21 Jan 2026 17:43:34 +0800 Subject: [PATCH] Refactor user asset management with ViewModel and adapter --- .../com/v2ray/ang/ui/UserAssetActivity.kt | 175 ++++-------------- .../java/com/v2ray/ang/ui/UserAssetAdapter.kt | 70 +++++++ .../v2ray/ang/viewmodel/UserAssetViewModel.kt | 94 ++++++++++ 3 files changed, 204 insertions(+), 135 deletions(-) create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetAdapter.kt create mode 100644 V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/UserAssetViewModel.kt diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt index 626567f0..a285dd56 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt @@ -9,44 +9,36 @@ import android.os.Build import android.os.Bundle import android.provider.OpenableColumns import android.util.Log -import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem -import android.view.View.GONE -import android.view.View.VISIBLE -import android.view.ViewGroup import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager -import androidx.recyclerview.widget.RecyclerView import com.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.databinding.ActivityUserAssetBinding -import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding import com.v2ray.ang.dto.AssetUrlItem -import com.v2ray.ang.extension.concatUrl -import com.v2ray.ang.extension.toTrafficString import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsManager -import com.v2ray.ang.util.HttpUtil import com.v2ray.ang.util.Utils +import com.v2ray.ang.viewmodel.UserAssetViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File -import java.io.FileOutputStream -import java.net.HttpURLConnection -import java.text.DateFormat -import java.util.Date class UserAssetActivity : BaseActivity() { private val binding by lazy { ActivityUserAssetBinding.inflate(layoutInflater) } + private val ownerActivity: UserAssetActivity + get() = this + private val viewModel: UserAssetViewModel by viewModels() + private lateinit var adapter: UserAssetAdapter val extDir by lazy { File(Utils.userAssetPath(this)) } - val builtInGeoFiles = arrayOf("geosite.dat", "geoip.dat") private val requestStoragePermissionLauncher = registerForActivityResult( ActivityResultContracts.RequestPermission() @@ -83,13 +75,13 @@ class UserAssetActivity : BaseActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - //setContentView(binding.root) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting)) binding.recyclerView.setHasFixedSize(true) binding.recyclerView.layoutManager = LinearLayoutManager(this) addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider) - binding.recyclerView.adapter = UserAssetAdapter() + adapter = UserAssetAdapter(viewModel, extDir, ActivityAdapterListener()) + binding.recyclerView.adapter = adapter binding.tvGeoFilesSourcesSummary.text = getGeoFilesSources() binding.layoutGeoFilesSources.setOnClickListener { @@ -219,81 +211,25 @@ class UserAssetActivity : BaseActivity() { } private fun downloadGeoFiles() { + refreshData() showLoading() toast(R.string.msg_downloading_content) val httpPort = SettingsManager.getHttpPort() - var assets = MmkvManager.decodeAssetUrls() - assets = addBuiltInGeoItems(assets) - - var resultCount = 0 lifecycleScope.launch(Dispatchers.IO) { - assets.forEach { - try { - var result = downloadGeo(it.second, 15000, httpPort) - if (!result) { - result = downloadGeo(it.second, 15000, 0) - } - if (result) - resultCount++ - } catch (e: Exception) { - Log.e(AppConfig.TAG, "Failed to download geo file: ${it.second.remarks}", e) - } - } + val result = viewModel.downloadGeoFiles(extDir, httpPort) withContext(Dispatchers.Main) { - if (resultCount > 0) { - toast(getString(R.string.title_update_config_count, resultCount)) - refreshData() + if (result.successCount > 0) { + toast(getString(R.string.title_update_config_count, result.successCount)) } else { toast(getString(R.string.toast_failure)) } + refreshData() hideLoading() } } } - private fun downloadGeo(item: AssetUrlItem, timeout: Int, httpPort: Int): Boolean { - val targetTemp = File(extDir, item.remarks + "_temp") - val target = File(extDir, item.remarks) - Log.i(AppConfig.TAG, "Downloading geo file: ${item.remarks} from ${item.url}") - - val conn = HttpUtil.createProxyConnection(item.url, httpPort, timeout, timeout, needStream = true) ?: return false - try { - val inputStream = conn.inputStream - val responseCode = conn.responseCode - if (responseCode == HttpURLConnection.HTTP_OK) { - FileOutputStream(targetTemp).use { output -> - inputStream.copyTo(output) - } - - targetTemp.renameTo(target) - } - return true - } catch (e: Exception) { - Log.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e) - return false - } finally { - conn.disconnect() - } - } - - private fun addBuiltInGeoItems(assets: List>): List> { - val list = mutableListOf>() - builtInGeoFiles - .filter { geoFile -> assets.none { it.second.remarks == geoFile } } - .forEach { - list.add( - Utils.getUuid() to AssetUrlItem( - it, - String.format(AppConfig.GITHUB_DOWNLOAD_URL, getGeoFilesSources()).concatUrl(it), - locked = true - ) - ) - } - - return list + assets - } - fun initAssets() { lifecycleScope.launch(Dispatchers.Default) { SettingsManager.initAssets(this@UserAssetActivity, assets) @@ -305,72 +241,41 @@ class UserAssetActivity : BaseActivity() { @SuppressLint("NotifyDataSetChanged") fun refreshData() { - binding.recyclerView.adapter?.notifyDataSetChanged() + viewModel.reload(getGeoFilesSources()) + adapter.notifyDataSetChanged() } - inner class UserAssetAdapter : RecyclerView.Adapter() { - override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder { - return UserAssetViewHolder( - ItemRecyclerUserAssetBinding.inflate( - LayoutInflater.from(parent.context), - parent, - false - ) + private inner class ActivityAdapterListener : BaseAdapterListener { + override fun onEdit(guid: String, position: Int) { + startActivity( + Intent(ownerActivity, UserAssetUrlActivity::class.java) + .putExtra("assetId", guid) ) } - @SuppressLint("SetTextI18n") - override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) { - var assets = MmkvManager.decodeAssetUrls() - assets = addBuiltInGeoItems(assets) - val item = assets.getOrNull(position) ?: return -// file with name == item.second.remarks - val file = extDir.listFiles()?.find { it.name == item.second.remarks } + override fun onRemove(guid: String, position: Int) { + val asset = viewModel.getAsset(position)?.takeIf { it.first == guid } + ?: viewModel.getAssets().find { it.first == guid } + ?: return + val file = extDir.listFiles()?.find { it.name == asset.second.remarks } - holder.itemUserAssetBinding.assetName.text = item.second.remarks - - if (file != null) { - val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM) - holder.itemUserAssetBinding.assetProperties.text = - "${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}" - } else { - holder.itemUserAssetBinding.assetProperties.text = getString(R.string.msg_file_not_found) - } - - if (item.second.locked == true) { - holder.itemUserAssetBinding.layoutEdit.visibility = GONE - //holder.itemUserAssetBinding.layoutRemove.visibility = GONE - } else { - holder.itemUserAssetBinding.layoutEdit.visibility = item.second.url.let { if (it == "file") GONE else VISIBLE } - //holder.itemUserAssetBinding.layoutRemove.visibility = VISIBLE - } - - holder.itemUserAssetBinding.layoutEdit.setOnClickListener { - val intent = Intent(this@UserAssetActivity, UserAssetUrlActivity::class.java) - intent.putExtra("assetId", item.first) - startActivity(intent) - } - holder.itemUserAssetBinding.layoutRemove.setOnClickListener { - AlertDialog.Builder(this@UserAssetActivity).setMessage(R.string.del_config_comfirm) - .setPositiveButton(android.R.string.ok) { _, _ -> - file?.delete() - MmkvManager.removeAssetUrl(item.first) - initAssets() - } - .setNegativeButton(android.R.string.cancel) { _, _ -> - //do noting - } - .show() - } + AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm) + .setPositiveButton(android.R.string.ok) { _, _ -> + file?.delete() + MmkvManager.removeAssetUrl(guid) + initAssets() + } + .setNegativeButton(android.R.string.cancel) { _, _ -> + // do nothing + } + .show() } - override fun getItemCount(): Int { - var assets = MmkvManager.decodeAssetUrls() - assets = addBuiltInGeoItems(assets) - return assets.size + override fun onShare(url: String) { + } + + override fun onRefreshData() { + refreshData() } } - - class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) : - RecyclerView.ViewHolder(itemUserAssetBinding.root) } \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetAdapter.kt new file mode 100644 index 00000000..6ba38088 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetAdapter.kt @@ -0,0 +1,70 @@ +package com.v2ray.ang.ui + +import android.annotation.SuppressLint +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.recyclerview.widget.RecyclerView +import com.v2ray.ang.R +import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding +import com.v2ray.ang.extension.toTrafficString +import com.v2ray.ang.viewmodel.UserAssetViewModel +import java.io.File +import java.text.DateFormat +import java.util.Date + +class UserAssetAdapter( + private val viewModel: UserAssetViewModel, + private val extDir: File, + private val adapterListener: BaseAdapterListener? +) : RecyclerView.Adapter() { + + override fun getItemCount() = viewModel.itemCount + + override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder { + return UserAssetViewHolder( + ItemRecyclerUserAssetBinding.inflate( + LayoutInflater.from(parent.context), + parent, + false + ) + ) + } + + @SuppressLint("SetTextI18n") + override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) { + val item = viewModel.getAsset(position) ?: return + val file = extDir.listFiles()?.find { it.name == item.second.remarks } + + holder.itemUserAssetBinding.assetName.text = item.second.remarks + + if (file != null) { + val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM) + holder.itemUserAssetBinding.assetProperties.text = + "${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}" + } else { + holder.itemUserAssetBinding.assetProperties.text = + holder.itemUserAssetBinding.root.context.getString(R.string.msg_file_not_found) + } + + if (item.second.locked == true) { + holder.itemUserAssetBinding.layoutEdit.visibility = View.GONE + } else { + holder.itemUserAssetBinding.layoutEdit.visibility = if (item.second.url == "file") { + View.GONE + } else { + View.VISIBLE + } + } + + holder.itemUserAssetBinding.layoutEdit.setOnClickListener { + adapterListener?.onEdit(item.first, position) + } + holder.itemUserAssetBinding.layoutRemove.setOnClickListener { + adapterListener?.onRemove(item.first, position) + } + } + + class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) : + RecyclerView.ViewHolder(itemUserAssetBinding.root) +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/UserAssetViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/UserAssetViewModel.kt new file mode 100644 index 00000000..faf03a74 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/UserAssetViewModel.kt @@ -0,0 +1,94 @@ +package com.v2ray.ang.viewmodel + +import android.util.Log +import androidx.lifecycle.ViewModel +import com.v2ray.ang.AppConfig +import com.v2ray.ang.dto.AssetUrlItem +import com.v2ray.ang.extension.concatUrl +import com.v2ray.ang.handler.MmkvManager +import com.v2ray.ang.util.HttpUtil +import com.v2ray.ang.util.Utils +import java.io.File +import java.io.FileOutputStream +import java.net.HttpURLConnection + +class UserAssetViewModel : ViewModel() { + private val assets = mutableListOf>() + private val builtInGeoFiles = listOf("geosite.dat", "geoip.dat") + + val itemCount: Int + get() = assets.size + + fun getAssets(): List> = assets.toList() + + fun getAsset(position: Int): Pair? = assets.getOrNull(position) + + fun reload(geoFilesSource: String) { + val decoded = MmkvManager.decodeAssetUrls() + assets.clear() + assets.addAll(buildAssetList(decoded, geoFilesSource)) + } + + private fun buildAssetList( + decodedAssets: List>?, + geoFilesSource: String + ): List> { + val savedAssets = decodedAssets ?: emptyList() + val builtInItems = builtInGeoFiles + .filter { geoFile -> savedAssets.none { it.second.remarks == geoFile } } + .map { + Utils.getUuid() to AssetUrlItem( + it, + String.format(AppConfig.GITHUB_DOWNLOAD_URL, geoFilesSource).concatUrl(it), + locked = true + ) + } + return builtInItems + savedAssets + } + + fun downloadGeoFiles(extDir: File, httpPort: Int): GeoDownloadResult { + val snapshot = getAssets() + var successCount = 0 + val failures = mutableListOf() + + snapshot.forEach { (_, item) -> + val portsToTry = if (httpPort == 0) listOf(0) else listOf(httpPort, 0) + if (portsToTry.any { tryDownload(item, extDir, it) }) { + successCount++ + } else { + failures.add(item.remarks) + } + } + + return GeoDownloadResult(successCount, failures.size, failures) + } + + private fun tryDownload(item: AssetUrlItem, extDir: File, httpPort: Int): Boolean { + val targetTemp = File(extDir, item.remarks + "_temp") + val target = File(extDir, item.remarks) + val conn = HttpUtil.createProxyConnection(item.url, httpPort, 15000, 15000, needStream = true) ?: return false + try { + val responseCode = conn.responseCode + if (responseCode == HttpURLConnection.HTTP_OK) { + conn.inputStream.use { inputStream -> + FileOutputStream(targetTemp).use { output -> + inputStream.copyTo(output) + } + } + targetTemp.renameTo(target) + return true + } + } catch (e: Exception) { + Log.e(AppConfig.TAG, "Failed to download geo file: ${item.remarks}", e) + } finally { + conn.disconnect() + } + return false + } + + data class GeoDownloadResult( + val successCount: Int, + val failureCount: Int, + val failedAssets: List + ) +}