Compare commits

...

9 Commits

Author SHA1 Message Date
2dust fa4432584e up 2.0.5 2026-01-21 17:44:03 +08:00
2dust bb1c3d915f Refactor user asset management with ViewModel and adapter 2026-01-21 17:43:34 +08:00
2dust d8983a4761 Bug fix
https://github.com/2dust/v2rayNG/issues/5186
2026-01-20 21:07:51 +08:00
2dust 9815041c77 Remove unused imports and commented debug logs 2026-01-20 10:52:52 +08:00
2dust 41b0983582 Refactor logcat handling to use LogcatViewModel 2026-01-20 10:46:46 +08:00
2dust bb1f677d45 Refactor RoutingSetting adapter to use listener interface 2026-01-20 10:17:57 +08:00
2dust de1c8bcfcc Refactor VPN mode checks and TUN config logic
Centralizes VPN mode detection in SettingsManager with a new isVpnMode() method and updates all usages to reference it. Refactors TUN configuration logic in V2rayConfigManager for clarity and correctness, ensuring proper config and routing rules are applied based on VPN and HEV TUN settings.
2026-01-19 19:43:09 +08:00
2dust ca6e963575 Refactor remarks assignment using ifEmpty in formatters 2026-01-19 19:18:26 +08:00
2dust bfb6813dc7 Fix HevTun settings to preferences UI 2026-01-19 19:18:15 +08:00
27 changed files with 425 additions and 280 deletions
+2 -2
View File
@@ -12,8 +12,8 @@ android {
applicationId = "com.v2ray.ang" applicationId = "com.v2ray.ang"
minSdk = 24 minSdk = 24
targetSdk = 36 targetSdk = 36
versionCode = 704 versionCode = 705
versionName = "2.0.4" versionName = "2.0.5"
multiDexEnabled = true multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';') val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
@@ -26,7 +26,7 @@ object Hysteria2Fmt : FmtBase() {
val config = ProfileItem.create(EConfigType.HYSTERIA2) val config = ProfileItem.create(EConfigType.HYSTERIA2)
val uri = URI(Utils.fixIllegalUrl(str)) val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -36,7 +36,7 @@ object ShadowsocksFmt : FmtBase() {
if (uri.port <= 0) return null if (uri.port <= 0) return null
if (uri.userInfo.isNullOrEmpty()) return null if (uri.userInfo.isNullOrEmpty()) return null
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
@@ -23,7 +23,7 @@ object SocksFmt : FmtBase() {
if (uri.idnHost.isEmpty()) return null if (uri.idnHost.isEmpty()) return null
if (uri.port <= 0) return null if (uri.port <= 0) return null
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
@@ -23,7 +23,7 @@ object TrojanFmt : FmtBase() {
val config = ProfileItem.create(EConfigType.TROJAN) val config = ProfileItem.create(EConfigType.TROJAN)
val uri = URI(Utils.fixIllegalUrl(str)) val uri = URI(Utils.fixIllegalUrl(str))
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -26,7 +26,7 @@ object VlessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -163,7 +163,7 @@ object VmessFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
config.password = uri.userInfo config.password = uri.userInfo
@@ -25,7 +25,7 @@ object WireguardFmt : FmtBase() {
if (uri.rawQuery.isNullOrEmpty()) return null if (uri.rawQuery.isNullOrEmpty()) return null
val queryParam = getQueryParam(uri) val queryParam = getQueryParam(uri)
config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { if (it.isEmpty()) "none" else it } config.remarks = Utils.urlDecode(uri.fragment.orEmpty()).let { it.ifEmpty { "none" } }
config.server = uri.idnHost config.server = uri.idnHost
config.serverPort = uri.port.toString() config.serverPort = uri.port.toString()
@@ -10,6 +10,7 @@ import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.GEOIP_PRIVATE import com.v2ray.ang.AppConfig.GEOIP_PRIVATE
import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE import com.v2ray.ang.AppConfig.GEOSITE_PRIVATE
import com.v2ray.ang.AppConfig.TAG_DIRECT import com.v2ray.ang.AppConfig.TAG_DIRECT
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.dto.EConfigType
import com.v2ray.ang.dto.Language import com.v2ray.ang.dto.Language
import com.v2ray.ang.dto.ProfileItem import com.v2ray.ang.dto.ProfileItem
@@ -386,6 +387,15 @@ object SettingsManager {
return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true) return MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
} }
/**
* Check if VPN mode is enabled.
* @return True if VPN mode is enabled, false otherwise.
*/
fun isVpnMode(): Boolean {
val mode = MmkvManager.decodeSettingsString(AppConfig.PREF_MODE)
return mode == null || mode == VPN
}
/** /**
* Ensure default settings are present in MMKV. * Ensure default settings are present in MMKV.
*/ */
@@ -108,7 +108,7 @@ object V2RayServiceManager {
} else { } else {
context.toast(R.string.toast_services_start) context.toast(R.string.toast_services_start)
} }
val intent = if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: AppConfig.VPN) == AppConfig.VPN) { val intent = if (SettingsManager.isVpnMode()) {
Intent(context.applicationContext, V2RayVpnService::class.java) Intent(context.applicationContext, V2RayVpnService::class.java)
} else { } else {
Intent(context.applicationContext, V2RayProxyOnlyService::class.java) Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
@@ -91,7 +91,7 @@ object V2rayConfigManager {
private fun getV2rayCustomConfig(context: Context, guid: String, config: ProfileItem): ConfigResult { private fun getV2rayCustomConfig(context: Context, guid: String, config: ProfileItem): ConfigResult {
val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false) val raw = MmkvManager.decodeServerRaw(guid) ?: return ConfigResult(false)
val result = ConfigResult(true, guid, raw) val result = ConfigResult(true, guid, raw)
if (SettingsManager.isUsingHevTun()) { if (!needTun()) {
return result return result
} }
@@ -334,18 +334,18 @@ object V2rayConfigManager {
*/ */
private fun initV2rayConfig(context: Context): V2rayConfig? { private fun initV2rayConfig(context: Context): V2rayConfig? {
var assets = "" var assets = ""
if (SettingsManager.isUsingHevTun()) { if (needTun()) {
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
if (TextUtils.isEmpty(assets)) {
return null
}
initConfigCache = assets
} else {
assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json") assets = initConfigCacheWithTun ?: Utils.readTextFromAssets(context, "v2ray_config_with_tun.json")
if (TextUtils.isEmpty(assets)) { if (TextUtils.isEmpty(assets)) {
return null return null
} }
initConfigCacheWithTun = assets initConfigCacheWithTun = assets
} else {
assets = initConfigCache ?: Utils.readTextFromAssets(context, "v2ray_config.json")
if (TextUtils.isEmpty(assets)) {
return null
}
initConfigCache = assets
} }
val config = JsonUtil.fromJson(assets, V2rayConfig::class.java) val config = JsonUtil.fromJson(assets, V2rayConfig::class.java)
return config return config
@@ -357,6 +357,10 @@ object V2rayConfigManager {
//region some sub function //region some sub function
private fun needTun(): Boolean {
return SettingsManager.isVpnMode() && !SettingsManager.isUsingHevTun()
}
/** /**
* Configures the inbound settings for V2ray. * Configures the inbound settings for V2ray.
* *
@@ -395,7 +399,7 @@ object V2rayConfigManager {
v2rayConfig.inbounds.add(inbound2) v2rayConfig.inbounds.add(inbound2)
} }
if (!SettingsManager.isUsingHevTun()) { if (needTun()) {
val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" } val inboundTun = v2rayConfig.inbounds.firstOrNull { e -> e.tag == "tun" }
inboundTun?.settings?.mtu = SettingsManager.getVpnMtu() inboundTun?.settings?.mtu = SettingsManager.getVpnMtu()
inboundTun?.sniffing = inbound1.sniffing inboundTun?.sniffing = inbound1.sniffing
@@ -520,23 +524,25 @@ object V2rayConfigManager {
) )
} }
if (SettingsManager.isUsingHevTun()) { if(SettingsManager.isVpnMode()) {
//hev-socks5-tunnel dns routing if (SettingsManager.isUsingHevTun()) {
v2rayConfig.routing.rules.add( //hev-socks5-tunnel dns routing
0, RulesBean( v2rayConfig.routing.rules.add(
inboundTag = arrayListOf("socks"), 0, RulesBean(
outboundTag = "dns-out", inboundTag = arrayListOf("socks"),
port = "53", outboundTag = "dns-out",
port = "53",
)
) )
) } else {
} else { v2rayConfig.routing.rules.add(
v2rayConfig.routing.rules.add( 0, RulesBean(
0, RulesBean( inboundTag = arrayListOf("tun"),
inboundTag = arrayListOf("tun"), outboundTag = "dns-out",
outboundTag = "dns-out", port = "53",
port = "53", )
) )
) }
} }
// DNS outbound // DNS outbound
@@ -8,8 +8,9 @@ interface BaseAdapterListener {
/** /**
* Request the host to edit the specified item. * Request the host to edit the specified item.
* @param guid Unique identifier (GUID) of the item * @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/ */
fun onEdit(guid: String) fun onEdit(guid: String, position: Int)
/** /**
* Request the host to remove the specified item. Position is provided for optional animation or validation. * Request the host to remove the specified item. Position is provided for optional animation or validation.
@@ -11,6 +11,8 @@ import com.v2ray.ang.dto.GroupMapItem
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) { class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = groups.size override fun getItemCount(): Int = groups.size
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id) override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
override fun getItemId(position: Int): Long = groups[position].id.hashCode().toLong()
override fun containsItem(itemId: Long): Boolean = groups.any { it.id.hashCode().toLong() == itemId }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun update(groups: List<GroupMapItem>) { fun update(groups: List<GroupMapItem>) {
@@ -1,7 +1,6 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
@@ -9,7 +8,6 @@ import androidx.fragment.app.activityViewModels
import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.ItemTouchHelper
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.TAG
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.FragmentGroupServerBinding import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
@@ -51,14 +49,14 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
if (mainViewModel.subscriptionId != subId) { if (mainViewModel.subscriptionId != subId) {
return@observe return@observe
} }
Log.d(TAG, "GroupServerFragment updateListAction subId=$subId") // Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
adapter.setData(mainViewModel.serversCache, index) adapter.setData(mainViewModel.serversCache, index)
} }
mainViewModel.isRunning.observe(viewLifecycleOwner) { isRunning -> mainViewModel.isRunning.observe(viewLifecycleOwner) { isRunning ->
adapter.isRunning = isRunning adapter.isRunning = isRunning
} }
Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId") // Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
} }
override fun onResume() { override fun onResume() {
@@ -2,37 +2,34 @@ package com.v2ray.ang.ui
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Bundle import android.os.Bundle
import android.util.Log
import android.view.Menu import android.view.Menu
import android.view.MenuItem import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityLogcatBinding import com.v2ray.ang.databinding.ActivityLogcatBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.LogcatViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.IOException
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener { class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) } private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
private val viewModel: LogcatViewModel by viewModels()
private var logsetsAll: MutableList<String> = mutableListOf() private lateinit var adapter: LogcatRecyclerAdapter
var logsets: MutableList<String> = mutableListOf()
private val adapter by lazy { LogcatRecyclerAdapter(this) }
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat))
adapter = LogcatRecyclerAdapter(viewModel, ::onLogLongClick)
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider) addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
@@ -40,58 +37,12 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
binding.refreshLayout.setOnRefreshListener(this) binding.refreshLayout.setOnRefreshListener(this)
logsets.add(getString(R.string.pull_down_to_refresh)) toast(getString(R.string.pull_down_to_refresh))
} }
private fun getLogcat() { private fun onLogLongClick(log: String): Boolean {
Utils.setClipboard(this, log)
try { return true
binding.refreshLayout.isRefreshing = true
lifecycleScope.launch(Dispatchers.Default) {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-d")
lst.add("-v")
lst.add("time")
lst.add("-s")
lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err")
val process = withContext(Dispatchers.IO) {
Runtime.getRuntime().exec(lst.toTypedArray())
}
val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed()
launch(Dispatchers.Main) {
logsetsAll = allText.toMutableList()
logsets = allText.toMutableList()
refreshData()
binding.refreshLayout.isRefreshing = false
}
}
} catch (e: IOException) {
Log.e(AppConfig.TAG, "Failed to get logcat", e)
}
}
private fun clearLogcat() {
try {
lifecycleScope.launch(Dispatchers.Default) {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-c")
withContext(Dispatchers.IO) {
val process = Runtime.getRuntime().exec(lst.toTypedArray())
process.waitFor()
}
launch(Dispatchers.Main) {
logsetsAll.clear()
logsets.clear()
refreshData()
}
}
} catch (e: IOException) {
Log.e(AppConfig.TAG, "Failed to clear logcat", e)
}
} }
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
@@ -104,12 +55,14 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
override fun onQueryTextSubmit(query: String?): Boolean = false override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean { override fun onQueryTextChange(newText: String?): Boolean {
filterLogs(newText) viewModel.filter(newText)
refreshData()
return false return false
} }
}) })
searchView.setOnCloseListener { searchView.setOnCloseListener {
filterLogs("") viewModel.filter("")
refreshData()
false false
} }
} }
@@ -119,33 +72,33 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.copy_all -> { R.id.copy_all -> {
Utils.setClipboard(this, logsets.joinToString("\n")) val all = viewModel.getAll().joinToString("\n")
Utils.setClipboard(this, all)
toastSuccess(R.string.toast_success) toastSuccess(R.string.toast_success)
true true
} }
R.id.clear_all -> { R.id.clear_all -> {
clearLogcat() lifecycleScope.launch(Dispatchers.IO) {
viewModel.clearLogcat()
withContext(Dispatchers.Main) {
refreshData()
}
}
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
} }
private fun filterLogs(content: String?): Boolean {
val key = content?.trim()
logsets = if (key.isNullOrEmpty()) {
logsetsAll.toMutableList()
} else {
logsetsAll.filter { it.contains(key) }.toMutableList()
}
refreshData()
return true
}
override fun onRefresh() { override fun onRefresh() {
getLogcat() lifecycleScope.launch(Dispatchers.IO) {
viewModel.loadLogcat()
withContext(Dispatchers.Main) {
binding.refreshLayout.isRefreshing = false
refreshData()
}
}
} }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
@@ -6,17 +6,20 @@ import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
import com.v2ray.ang.util.Utils import com.v2ray.ang.viewmodel.LogcatViewModel
class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() { class LogcatRecyclerAdapter(
private var mActivity: LogcatActivity = activity private val viewModel: LogcatViewModel,
private val onLongClick: ((String) -> Boolean)? = null
) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
override fun getItemCount() = viewModel.getAll().size
override fun getItemCount() = mActivity.logsets.size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
try { try {
val log = mActivity.logsets[position] val logs = viewModel.getAll()
val log = logs[position]
if (log.isEmpty()) { if (log.isEmpty()) {
holder.itemSubSettingBinding.logTag.text = "" holder.itemSubSettingBinding.logTag.text = ""
holder.itemSubSettingBinding.logContent.text = "" holder.itemSubSettingBinding.logContent.text = ""
@@ -27,8 +30,7 @@ class LogcatRecyclerAdapter(val activity: LogcatActivity) : RecyclerView.Adapter
} }
holder.itemView.setOnLongClickListener { holder.itemView.setOnLongClickListener {
Utils.setClipboard(mActivity, log) onLongClick?.invoke(log) ?: false
true
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Error binding log view data", e) Log.e(AppConfig.TAG, "Error binding log view data", e)
@@ -25,7 +25,6 @@ import androidx.lifecycle.lifecycleScope
import com.google.android.material.navigation.NavigationView import com.google.android.material.navigation.NavigationView
import com.google.android.material.tabs.TabLayoutMediator import com.google.android.material.tabs.TabLayoutMediator
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityMainBinding import com.v2ray.ang.databinding.ActivityMainBinding
import com.v2ray.ang.dto.EConfigType import com.v2ray.ang.dto.EConfigType
@@ -34,6 +33,7 @@ import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.handler.V2RayServiceManager import com.v2ray.ang.handler.V2RayServiceManager
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.MainViewModel import com.v2ray.ang.viewmodel.MainViewModel
@@ -188,7 +188,7 @@ class MainActivity : BaseActivity(), NavigationView.OnNavigationItemSelectedList
if (mainViewModel.isRunning.value == true) { if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this) V2RayServiceManager.stopVService(this)
} else if ((MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) ?: VPN) == VPN) { } else if (SettingsManager.isVpnMode()) {
val intent = VpnService.prepare(this) val intent = VpnService.prepare(this)
if (intent == null) { if (intent == null) {
startV2Ray() startV2Ray()
@@ -31,7 +31,8 @@ import kotlinx.coroutines.withContext
class RoutingSettingActivity : BaseActivity() { class RoutingSettingActivity : BaseActivity() {
private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) } private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
private val ownerActivity: RoutingSettingActivity
get() = this
private val viewModel: RoutingSettingsViewModel by viewModels() private val viewModel: RoutingSettingsViewModel by viewModels()
private lateinit var adapter: RoutingSettingRecyclerAdapter private lateinit var adapter: RoutingSettingRecyclerAdapter
private var mItemTouchHelper: ItemTouchHelper? = null private var mItemTouchHelper: ItemTouchHelper? = null
@@ -57,7 +58,7 @@ class RoutingSettingActivity : BaseActivity() {
//setContentView(binding.root) //setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title))
adapter = RoutingSettingRecyclerAdapter(this, viewModel) adapter = RoutingSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
@@ -202,4 +203,23 @@ class RoutingSettingActivity : BaseActivity() {
viewModel.reload() viewModel.reload()
adapter.notifyDataSetChanged() adapter.notifyDataSetChanged()
} }
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
startActivity(
Intent(ownerActivity, RoutingEditActivity::class.java)
.putExtra("position", position)
)
}
override fun onRemove(guid: String, position: Int) {
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
refreshData()
}
}
} }
@@ -1,6 +1,5 @@
package com.v2ray.ang.ui package com.v2ray.ang.ui
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View import android.view.View
@@ -13,12 +12,11 @@ import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
class RoutingSettingRecyclerAdapter( class RoutingSettingRecyclerAdapter(
val activity: RoutingSettingActivity, private val viewModel: RoutingSettingsViewModel,
private val viewModel: RoutingSettingsViewModel private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(), ) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(),
ItemTouchHelperAdapter { ItemTouchHelperAdapter {
private var mActivity: RoutingSettingActivity = activity
override fun getItemCount() = viewModel.getAll().size override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
@@ -33,10 +31,7 @@ class RoutingSettingRecyclerAdapter(
holder.itemView.setBackgroundColor(Color.TRANSPARENT) holder.itemView.setBackgroundColor(Color.TRANSPARENT)
holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener { holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
mActivity.startActivity( adapterListener?.onEdit("", position)
Intent(mActivity, RoutingEditActivity::class.java)
.putExtra("position", position)
)
} }
holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked -> holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
@@ -76,7 +71,7 @@ class RoutingSettingRecyclerAdapter(
} }
override fun onItemMoveCompleted() { override fun onItemMoveCompleted() {
mActivity.refreshData() adapterListener?.onRefreshData()
} }
override fun onItemDismiss(position: Int) { override fun onItemDismiss(position: Int) {
@@ -52,6 +52,10 @@ class SettingsActivity : BaseActivity() {
private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) } private val autoUpdateInterval by lazy { findPreference<EditTextPreference>(AppConfig.SUBSCRIPTION_AUTO_UPDATE_INTERVAL) }
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) } private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
private val useHevTun by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_USE_HEV_TUNNEL) }
override fun onCreatePreferences(bundle: Bundle?, s: String?) { override fun onCreatePreferences(bundle: Bundle?, s: String?) {
// Use MMKV as the storage backend for all Preferences // Use MMKV as the storage backend for all Preferences
// This prevents inconsistencies between SharedPreferences and MMKV // This prevents inconsistencies between SharedPreferences and MMKV
@@ -93,12 +97,21 @@ class SettingsActivity : BaseActivity() {
} }
true true
} }
mode?.setOnPreferenceChangeListener { _, newValue -> mode?.setOnPreferenceChangeListener { pref, newValue ->
updateMode(newValue.toString()) val valueStr = newValue.toString()
(pref as? ListPreference)?.let { lp ->
val idx = lp.findIndexOfValue(valueStr)
lp.summary = if (idx >= 0) lp.entries[idx] else valueStr
}
updateMode(valueStr)
true true
} }
mode?.dialogLayoutResource = R.layout.preference_with_help_link mode?.dialogLayoutResource = R.layout.preference_with_help_link
useHevTun?.setOnPreferenceChangeListener { _, newValue ->
updateHevTunSettings(newValue as Boolean)
true
}
} }
private fun initPreferenceSummaries() { private fun initPreferenceSummaries() {
@@ -141,6 +154,8 @@ class SettingsActivity : BaseActivity() {
override fun onStart() { override fun onStart() {
super.onStart() super.onStart()
updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true))
// Initialize mode-dependent UI states // Initialize mode-dependent UI states
updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN)) updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
@@ -152,11 +167,10 @@ class SettingsActivity : BaseActivity() {
// Initialize auto-update interval state // Initialize auto-update interval state
autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false) autoUpdateInterval?.isEnabled = MmkvManager.decodeSettingsBool(AppConfig.SUBSCRIPTION_AUTO_UPDATE, false)
} }
private fun updateMode(mode: String?) { private fun updateMode(value: String?) {
val vpn = mode == VPN val vpn = value == VPN
localDns?.isEnabled = vpn localDns?.isEnabled = vpn
fakeDns?.isEnabled = vpn fakeDns?.isEnabled = vpn
appendHttpProxy?.isEnabled = vpn appendHttpProxy?.isEnabled = vpn
@@ -165,6 +179,8 @@ class SettingsActivity : BaseActivity() {
vpnBypassLan?.isEnabled = vpn vpnBypassLan?.isEnabled = vpn
vpnInterfaceAddress?.isEnabled = vpn vpnInterfaceAddress?.isEnabled = vpn
vpnMtu?.isEnabled = vpn vpnMtu?.isEnabled = vpn
useHevTun?.isEnabled = vpn
updateHevTunSettings(false)
if (vpn) { if (vpn) {
updateLocalDns( updateLocalDns(
MmkvManager.decodeSettingsBool( MmkvManager.decodeSettingsBool(
@@ -172,6 +188,12 @@ class SettingsActivity : BaseActivity() {
false false
) )
) )
updateHevTunSettings(
MmkvManager.decodeSettingsBool(
AppConfig.PREF_USE_HEV_TUNNEL,
false
)
)
} }
} }
@@ -235,6 +257,11 @@ class SettingsActivity : BaseActivity() {
fragmentLength?.isEnabled = enabled fragmentLength?.isEnabled = enabled
fragmentInterval?.isEnabled = enabled fragmentInterval?.isEnabled = enabled
} }
private fun updateHevTunSettings(enabled: Boolean) {
hevTunLogLevel?.isEnabled = enabled
hevTunRwTimeout?.isEnabled = enabled
}
} }
fun onModeHelpClicked(view: View) { fun onModeHelpClicked(view: View) {
@@ -103,7 +103,7 @@ class SubSettingActivity : BaseActivity() {
} }
private inner class ActivityAdapterListener : BaseAdapterListener { private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String) { override fun onEdit(guid: String, position: Int) {
startActivity( startActivity(
Intent(ownerActivity, SubEditActivity::class.java) Intent(ownerActivity, SubEditActivity::class.java)
.putExtra("subId", guid) .putExtra("subId", guid)
@@ -30,7 +30,7 @@ class SubSettingRecyclerAdapter(
holder.itemView.setBackgroundColor(Color.TRANSPARENT) holder.itemView.setBackgroundColor(Color.TRANSPARENT)
holder.itemSubSettingBinding.layoutEdit.setOnClickListener { holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
adapterListener?.onEdit(subId) adapterListener?.onEdit(subId, position)
} }
holder.itemSubSettingBinding.layoutRemove.setOnClickListener { holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
@@ -9,44 +9,36 @@ import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.util.Log import android.util.Log
import android.view.LayoutInflater
import android.view.Menu import android.view.Menu
import android.view.MenuItem 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.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig
import com.v2ray.ang.R import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityUserAssetBinding import com.v2ray.ang.databinding.ActivityUserAssetBinding
import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
import com.v2ray.ang.dto.AssetUrlItem 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.toast
import com.v2ray.ang.extension.toastError import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.Utils import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.UserAssetViewModel
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import java.io.File import java.io.File
import java.io.FileOutputStream
import java.net.HttpURLConnection
import java.text.DateFormat
import java.util.Date
class UserAssetActivity : BaseActivity() { class UserAssetActivity : BaseActivity() {
private val binding by lazy { ActivityUserAssetBinding.inflate(layoutInflater) } 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 extDir by lazy { File(Utils.userAssetPath(this)) }
val builtInGeoFiles = arrayOf("geosite.dat", "geoip.dat")
private val requestStoragePermissionLauncher = registerForActivityResult( private val requestStoragePermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission() ActivityResultContracts.RequestPermission()
@@ -83,13 +75,13 @@ class UserAssetActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting))
binding.recyclerView.setHasFixedSize(true) binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this) binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider) 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.tvGeoFilesSourcesSummary.text = getGeoFilesSources()
binding.layoutGeoFilesSources.setOnClickListener { binding.layoutGeoFilesSources.setOnClickListener {
@@ -219,81 +211,25 @@ class UserAssetActivity : BaseActivity() {
} }
private fun downloadGeoFiles() { private fun downloadGeoFiles() {
refreshData()
showLoading() showLoading()
toast(R.string.msg_downloading_content) toast(R.string.msg_downloading_content)
val httpPort = SettingsManager.getHttpPort() val httpPort = SettingsManager.getHttpPort()
var assets = MmkvManager.decodeAssetUrls()
assets = addBuiltInGeoItems(assets)
var resultCount = 0
lifecycleScope.launch(Dispatchers.IO) { lifecycleScope.launch(Dispatchers.IO) {
assets.forEach { val result = viewModel.downloadGeoFiles(extDir, httpPort)
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)
}
}
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
if (resultCount > 0) { if (result.successCount > 0) {
toast(getString(R.string.title_update_config_count, resultCount)) toast(getString(R.string.title_update_config_count, result.successCount))
refreshData()
} else { } else {
toast(getString(R.string.toast_failure)) toast(getString(R.string.toast_failure))
} }
refreshData()
hideLoading() 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<Pair<String, AssetUrlItem>>): List<Pair<String, AssetUrlItem>> {
val list = mutableListOf<Pair<String, AssetUrlItem>>()
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() { fun initAssets() {
lifecycleScope.launch(Dispatchers.Default) { lifecycleScope.launch(Dispatchers.Default) {
SettingsManager.initAssets(this@UserAssetActivity, assets) SettingsManager.initAssets(this@UserAssetActivity, assets)
@@ -305,72 +241,41 @@ class UserAssetActivity : BaseActivity() {
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
fun refreshData() { fun refreshData() {
binding.recyclerView.adapter?.notifyDataSetChanged() viewModel.reload(getGeoFilesSources())
adapter.notifyDataSetChanged()
} }
inner class UserAssetAdapter : RecyclerView.Adapter<UserAssetViewHolder>() { private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder { override fun onEdit(guid: String, position: Int) {
return UserAssetViewHolder( startActivity(
ItemRecyclerUserAssetBinding.inflate( Intent(ownerActivity, UserAssetUrlActivity::class.java)
LayoutInflater.from(parent.context), .putExtra("assetId", guid)
parent,
false
)
) )
} }
@SuppressLint("SetTextI18n") override fun onRemove(guid: String, position: Int) {
override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) { val asset = viewModel.getAsset(position)?.takeIf { it.first == guid }
var assets = MmkvManager.decodeAssetUrls() ?: viewModel.getAssets().find { it.first == guid }
assets = addBuiltInGeoItems(assets) ?: return
val item = assets.getOrNull(position) ?: return val file = extDir.listFiles()?.find { it.name == asset.second.remarks }
// file with name == item.second.remarks
val file = extDir.listFiles()?.find { it.name == item.second.remarks }
holder.itemUserAssetBinding.assetName.text = item.second.remarks AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
if (file != null) { file?.delete()
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM) MmkvManager.removeAssetUrl(guid)
holder.itemUserAssetBinding.assetProperties.text = initAssets()
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}" }
} else { .setNegativeButton(android.R.string.cancel) { _, _ ->
holder.itemUserAssetBinding.assetProperties.text = getString(R.string.msg_file_not_found) // do nothing
} }
.show()
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()
}
} }
override fun getItemCount(): Int { override fun onShare(url: String) {
var assets = MmkvManager.decodeAssetUrls() }
assets = addBuiltInGeoItems(assets)
return assets.size override fun onRefreshData() {
refreshData()
} }
} }
class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) :
RecyclerView.ViewHolder(itemUserAssetBinding.root)
} }
@@ -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<UserAssetAdapter.UserAssetViewHolder>() {
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)
}
@@ -0,0 +1,62 @@
package com.v2ray.ang.viewmodel
import androidx.lifecycle.ViewModel
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import java.io.IOException
class LogcatViewModel : ViewModel() {
private val logsetsAll: MutableList<String> = mutableListOf()
private var filteredLogs: List<String> = emptyList()
private var currentFilter: String = ""
fun getAll(): List<String> = filteredLogs
fun loadLogcat() {
try {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-d")
lst.add("-v")
lst.add("time")
lst.add("-s")
lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err")
val process = Runtime.getRuntime().exec(lst.toTypedArray())
val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed()
logsetsAll.clear()
logsetsAll.addAll(allText)
applyFilter()
} catch (e: IOException) {
android.util.Log.e(AppConfig.TAG, "Failed to get logcat", e)
}
}
fun clearLogcat() {
try {
val lst = LinkedHashSet<String>()
lst.add("logcat")
lst.add("-c")
val process = Runtime.getRuntime().exec(lst.toTypedArray())
process.waitFor()
logsetsAll.clear()
filteredLogs = emptyList()
} catch (e: IOException) {
android.util.Log.e(AppConfig.TAG, "Failed to clear logcat", e)
}
}
fun filter(content: String?) {
currentFilter = content?.trim() ?: ""
applyFilter()
}
private fun applyFilter() {
filteredLogs = if (currentFilter.isEmpty()) {
logsetsAll.toList()
} else {
logsetsAll.filter { it.contains(currentFilter) }
}
}
}
@@ -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<Pair<String, AssetUrlItem>>()
private val builtInGeoFiles = listOf("geosite.dat", "geoip.dat")
val itemCount: Int
get() = assets.size
fun getAssets(): List<Pair<String, AssetUrlItem>> = assets.toList()
fun getAsset(position: Int): Pair<String, AssetUrlItem>? = assets.getOrNull(position)
fun reload(geoFilesSource: String) {
val decoded = MmkvManager.decodeAssetUrls()
assets.clear()
assets.addAll(buildAssetList(decoded, geoFilesSource))
}
private fun buildAssetList(
decodedAssets: List<Pair<String, AssetUrlItem>>?,
geoFilesSource: String
): List<Pair<String, AssetUrlItem>> {
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<String>()
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<String>
)
}