Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8dd1df1451 | |||
| d4a73493b5 | |||
| 9fce03d0ed | |||
| a12ca3ea04 | |||
| 4e9df84d3e | |||
| d3118b53f2 | |||
| cd45b51487 | |||
| 6aa26d2621 | |||
| 0b452707c3 | |||
| 303b642ade | |||
| 80eec036dd | |||
| f9a85366ea | |||
| 378c891399 | |||
| d04b5eee37 | |||
| 405cd7f55e | |||
| 850096789b | |||
| 3d42ac9dba | |||
| d0265265f3 | |||
| 2d6dd33a7b |
+1
-1
Submodule AndroidLibXrayLite updated: d783dc8ea7...a42c6ce5ed
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 36
|
||||
versionCode = 712
|
||||
versionName = "2.0.12"
|
||||
versionCode = 716
|
||||
versionName = "2.0.16"
|
||||
multiDexEnabled = true
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
|
||||
@@ -132,6 +132,12 @@ object AppConfig {
|
||||
const val GEOIP_PRIVATE = "geoip:private"
|
||||
const val GEOIP_CN = "geoip:cn"
|
||||
|
||||
/** Geo data file names. */
|
||||
const val GEOSITE_DAT = "geosite.dat"
|
||||
const val GEOIP_DAT = "geoip.dat"
|
||||
const val GEOIP_ONLY_CN_PRIVATE_DAT = "geoip-only-cn-private.dat"
|
||||
const val GEOIP_ONLY_CN_PRIVATE_URL = "$GITHUB_RAW_URL/Loyalsoldier/geoip/release/$GEOIP_ONLY_CN_PRIVATE_DAT"
|
||||
|
||||
/** Ports and addresses for various services. */
|
||||
const val PORT_LOCAL_DNS = "10853"
|
||||
const val PORT_SOCKS = "10808"
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
import java.io.Serializable
|
||||
|
||||
data class TestServiceMessage(
|
||||
val key: Int,
|
||||
val subscriptionId: String = "",
|
||||
val serverGuids: List<String> = emptyList()
|
||||
) : Serializable
|
||||
|
||||
@@ -189,4 +189,15 @@ fun String.concatUrl(vararg paths: String): String {
|
||||
}
|
||||
|
||||
return builder.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to match text either by Regex or literal string.
|
||||
*/
|
||||
fun String.matchesPattern(regex: Regex?, keyword: String?, ignoreCase: Boolean = true): Boolean {
|
||||
if (keyword.isNullOrEmpty()) {
|
||||
return true
|
||||
}
|
||||
return regex?.containsMatchIn(this)
|
||||
?: this.contains(keyword, ignoreCase = ignoreCase)
|
||||
}
|
||||
@@ -221,21 +221,14 @@ object AngConfigManager {
|
||||
if (servers == null) {
|
||||
return 0
|
||||
}
|
||||
val removedSelectedServer =
|
||||
if (!TextUtils.isEmpty(subid) && !append) {
|
||||
MmkvManager.decodeServerConfig(
|
||||
MmkvManager.getSelectServer().orEmpty()
|
||||
)?.let {
|
||||
if (it.subscriptionId == subid) {
|
||||
return@let it
|
||||
}
|
||||
return@let null
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
// Find the currently selected server that matches the subscription ID
|
||||
val removedSelected = if (subid.isNotBlank() && !append) {
|
||||
MmkvManager.getSelectServer()
|
||||
.takeIf { it?.isNotBlank() == true }
|
||||
?.let { MmkvManager.decodeServerConfig(it) }
|
||||
?.takeIf { it.subscriptionId == subid }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subid)
|
||||
@@ -254,18 +247,12 @@ object AngConfigManager {
|
||||
|
||||
// Batch save all parsed configs (only one serverList read/write)
|
||||
if (configs.isNotEmpty()) {
|
||||
val keys = batchSaveConfigs(configs, subid)
|
||||
|
||||
// Handle removed selected server
|
||||
removedSelectedServer?.let { removed ->
|
||||
val matchKey = keys.find { key ->
|
||||
val savedConfig = MmkvManager.decodeServerConfig(key)
|
||||
savedConfig != null &&
|
||||
savedConfig.server == removed.server &&
|
||||
savedConfig.serverPort == removed.serverPort
|
||||
}
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val keyToProfile = batchSaveConfigs(configs, subid)
|
||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
}
|
||||
|
||||
return configs.size
|
||||
@@ -281,10 +268,10 @@ object AngConfigManager {
|
||||
*
|
||||
* @param configs The list of ProfileItem to save.
|
||||
* @param subid The subscription ID.
|
||||
* @return The list of generated keys.
|
||||
* @return Map of generated keys to their corresponding ProfileItem.
|
||||
*/
|
||||
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): List<String> {
|
||||
val keys = mutableListOf<String>()
|
||||
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
|
||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||
|
||||
// Read serverList once
|
||||
val serverList = MmkvManager.decodeServerList(subid)
|
||||
@@ -302,12 +289,67 @@ object AngConfigManager {
|
||||
needSetSelected = false
|
||||
}
|
||||
}
|
||||
keys.add(key)
|
||||
keyToProfile[key] = config
|
||||
}
|
||||
|
||||
// Write serverList once
|
||||
MmkvManager.encodeServerList(serverList, subid)
|
||||
return keys
|
||||
return keyToProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a matched profile key from the given key-profile map using multi-level matching.
|
||||
* Matching priority (from highest to lowest):
|
||||
* 1. Exact match: server + port + password
|
||||
* 2. Match by remarks (exact match)
|
||||
* 3. Match by server + port
|
||||
* 4. Match by server only
|
||||
*
|
||||
* @param keyToProfile Map of server keys to their ProfileItem
|
||||
* @param target Target profile to match
|
||||
* @return Matched key or null
|
||||
*/
|
||||
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
|
||||
if (keyToProfile.isEmpty() || target == null) return null
|
||||
|
||||
// Level 1: Match by remarks
|
||||
if (target.remarks.isNotBlank()) {
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.remarks, target.remarks)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
// Level 2: Exact match (server + port + password)
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort) &&
|
||||
isSameText(saved.password, target.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// Level 3: Match by server + port
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// Level 4: Match by server only
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server)
|
||||
}?.key?.let { return it }
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive trimmed string comparison.
|
||||
*
|
||||
* @param left First string
|
||||
* @param right Second string
|
||||
* @return True if both are non-empty and equal (case-insensitive, trimmed)
|
||||
*/
|
||||
private fun isSameText(left: String?, right: String?): Boolean {
|
||||
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
|
||||
return left.trim().equals(right.trim(), ignoreCase = true)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -114,9 +114,15 @@ object MmkvManager {
|
||||
*/
|
||||
fun decodeAllServerList(): MutableList<String> {
|
||||
val allServers = mutableListOf<String>()
|
||||
val subsList = decodeSubsList()
|
||||
|
||||
// Add servers from all subscriptions (including default subscription)
|
||||
decodeSubsList().forEach { guid ->
|
||||
// If DEFAULT_SUBSCRIPTION_ID is not in the subscriptions list, add its servers
|
||||
if (!subsList.contains(DEFAULT_SUBSCRIPTION_ID)) {
|
||||
allServers.addAll(decodeServerList(DEFAULT_SUBSCRIPTION_ID))
|
||||
}
|
||||
|
||||
// Add servers from all subscriptions
|
||||
subsList.forEach { guid ->
|
||||
allServers.addAll(decodeServerList(guid))
|
||||
}
|
||||
|
||||
@@ -381,11 +387,6 @@ object MmkvManager {
|
||||
* @param subid The subscription ID.
|
||||
*/
|
||||
fun removeSubscription(subid: String) {
|
||||
// Protect default subscription from being deleted
|
||||
if (subid == DEFAULT_SUBSCRIPTION_ID) {
|
||||
return
|
||||
}
|
||||
|
||||
subStorage.remove(subid)
|
||||
val subsList = decodeSubsList()
|
||||
subsList.remove(subid)
|
||||
|
||||
@@ -25,6 +25,7 @@ import com.v2ray.ang.handler.MmkvManager.decodeServerConfig
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeSubsList
|
||||
import com.v2ray.ang.handler.MmkvManager.decodeSubscription
|
||||
import com.v2ray.ang.handler.MmkvManager.encodeSubscription
|
||||
import com.v2ray.ang.handler.MmkvManager.removeSubscription
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.io.File
|
||||
@@ -36,7 +37,7 @@ object SettingsManager {
|
||||
|
||||
fun initApp(context: Context) {
|
||||
ensureDefaultSettings()
|
||||
ensureDefaultSubscription()
|
||||
//ensureDefaultSubscription()
|
||||
initRoutingRulesets(context)
|
||||
migrateServerListToSubscriptions()
|
||||
migrateHysteria2PinSHA256()
|
||||
@@ -240,6 +241,33 @@ object SettingsManager {
|
||||
.firstOrNull { it.remarks == remarks }
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the subscription.
|
||||
* If there are no remaining subscriptions,
|
||||
* it creates a new default subscription to ensure that ungroup
|
||||
**/
|
||||
fun removeSubscriptionWithDefault(subid: String) {
|
||||
// val subsList = decodeSubsList()
|
||||
// if (subsList.size == 1 && subsList.first() == DEFAULT_SUBSCRIPTION_ID) {
|
||||
// Log.i(ANG_PACKAGE,"Attempted to remove the only existing default subscription, operation ignored.")
|
||||
// return
|
||||
// }
|
||||
|
||||
// Remove the subscription
|
||||
removeSubscription(subid)
|
||||
|
||||
// After removal, check if there are any subscriptions left. If not, create a default subscription.
|
||||
val subsList2 = decodeSubsList()
|
||||
if (subsList2.isNotEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
val defaultSub = SubscriptionItem(
|
||||
remarks = "Default",
|
||||
)
|
||||
encodeSubscription(DEFAULT_SUBSCRIPTION_ID, defaultSub)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the SOCKS port.
|
||||
* @return The SOCKS port.
|
||||
@@ -265,7 +293,7 @@ object SettingsManager {
|
||||
val extFolder = Utils.userAssetPath(context)
|
||||
|
||||
try {
|
||||
val geo = arrayOf("geosite.dat", "geoip.dat")
|
||||
val geo = arrayOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
||||
assets.list("")
|
||||
?.filter { geo.contains(it) }
|
||||
?.filter { !File(extFolder, it).exists() }
|
||||
|
||||
@@ -57,9 +57,12 @@ object V2RayServiceManager {
|
||||
* @param guid The GUID of the server configuration to use (optional).
|
||||
*/
|
||||
fun startVService(context: Context, guid: String? = null) {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
|
||||
|
||||
if (guid != null) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
}
|
||||
|
||||
startContextService(context)
|
||||
}
|
||||
|
||||
@@ -91,15 +94,30 @@ object V2RayServiceManager {
|
||||
*/
|
||||
private fun startContextService(context: Context) {
|
||||
if (coreController.isRunning) {
|
||||
Log.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||
return
|
||||
}
|
||||
val guid = MmkvManager.getSelectServer() ?: return
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return
|
||||
|
||||
val guid = MmkvManager.getSelectServer()
|
||||
if (guid == null) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||
return
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid)
|
||||
if (config == null) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
|
||||
return
|
||||
}
|
||||
|
||||
if (config.configType != EConfigType.CUSTOM
|
||||
&& config.configType != EConfigType.POLICYGROUP
|
||||
&& !Utils.isValidUrl(config.server)
|
||||
&& !Utils.isPureIpAddress(config.server.orEmpty())
|
||||
) return
|
||||
) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Invalid server configuration")
|
||||
return
|
||||
}
|
||||
// val result = V2rayConfigUtil.getV2rayConfig(context, guid)
|
||||
// if (!result.status) return
|
||||
|
||||
@@ -108,12 +126,21 @@ object V2RayServiceManager {
|
||||
} else {
|
||||
context.toast(R.string.toast_services_start)
|
||||
}
|
||||
val intent = if (SettingsManager.isVpnMode()) {
|
||||
|
||||
val isVpnMode = SettingsManager.isVpnMode()
|
||||
val intent = if (isVpnMode) {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Starting VPN service")
|
||||
Intent(context.applicationContext, V2RayVpnService::class.java)
|
||||
} else {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Starting Proxy service")
|
||||
Intent(context.applicationContext, V2RayProxyOnlyService::class.java)
|
||||
}
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
|
||||
try {
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to start service", e)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -123,15 +150,34 @@ object V2RayServiceManager {
|
||||
*/
|
||||
fun startCoreLoop(vpnInterface: ParcelFileDescriptor?): Boolean {
|
||||
if (coreController.isRunning) {
|
||||
Log.w(AppConfig.TAG, "StartCore-Manager: Core already running")
|
||||
return false
|
||||
}
|
||||
|
||||
val service = getService() ?: return false
|
||||
val guid = MmkvManager.getSelectServer() ?: return false
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return false
|
||||
val result = V2rayConfigManager.getV2rayConfig(service, guid)
|
||||
if (!result.status)
|
||||
val service = getService()
|
||||
if (service == null) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Service is null")
|
||||
return false
|
||||
}
|
||||
|
||||
val guid = MmkvManager.getSelectServer()
|
||||
if (guid == null) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: No server selected")
|
||||
return false
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid)
|
||||
if (config == null) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to decode server config")
|
||||
return false
|
||||
}
|
||||
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Starting core loop for ${config.remarks}")
|
||||
val result = V2rayConfigManager.getV2rayConfig(service, guid)
|
||||
if (!result.status) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to get V2Ray config")
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_SERVICE)
|
||||
@@ -140,7 +186,7 @@ object V2RayServiceManager {
|
||||
mFilter.addAction(Intent.ACTION_USER_PRESENT)
|
||||
ContextCompat.registerReceiver(service, mMsgReceive, mFilter, Utils.receiverFlags())
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to register broadcast receiver", e)
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to register receiver", e)
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -154,11 +200,12 @@ object V2RayServiceManager {
|
||||
NotificationManager.showNotification(currentConfig)
|
||||
coreController.startLoop(result.content, tunFd)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to start Core loop", e)
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to start core loop", e)
|
||||
return false
|
||||
}
|
||||
|
||||
if (coreController.isRunning == false) {
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Core failed to start")
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_FAILURE, "")
|
||||
NotificationManager.cancelNotification()
|
||||
return false
|
||||
@@ -166,11 +213,10 @@ object V2RayServiceManager {
|
||||
|
||||
try {
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_START_SUCCESS, "")
|
||||
//NotificationManager.showNotification(currentConfig)
|
||||
NotificationManager.startSpeedNotification(currentConfig)
|
||||
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Core started successfully")
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to startup service", e)
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to complete startup", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -189,7 +235,7 @@ object V2RayServiceManager {
|
||||
try {
|
||||
coreController.stopLoop()
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to stop V2Ray loop", e)
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,7 +246,7 @@ object V2RayServiceManager {
|
||||
try {
|
||||
service.unregisterReceiver(mMsgReceive)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to unregister broadcast receiver", e)
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
|
||||
}
|
||||
|
||||
return true
|
||||
@@ -234,14 +280,14 @@ object V2RayServiceManager {
|
||||
try {
|
||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl())
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to measure delay with primary URL", e)
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||
}
|
||||
if (time == -1L) {
|
||||
try {
|
||||
time = coreController.measureDelay(SettingsManager.getDelayTestUrl(true))
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to measure delay with alternative URL", e)
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to measure delay", e)
|
||||
errorStr = e.message?.substringAfter("\":") ?: "empty message"
|
||||
}
|
||||
}
|
||||
@@ -293,7 +339,7 @@ object V2RayServiceManager {
|
||||
serviceControl.stopService()
|
||||
0
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to stop service in callback", e)
|
||||
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
|
||||
-1
|
||||
}
|
||||
}
|
||||
@@ -340,12 +386,12 @@ object V2RayServiceManager {
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_STOP -> {
|
||||
Log.i(AppConfig.TAG, "Stop Service")
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Stop service")
|
||||
serviceControl.stopService()
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_RESTART -> {
|
||||
Log.i(AppConfig.TAG, "Restart Service")
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||
serviceControl.stopService()
|
||||
Thread.sleep(500L)
|
||||
startVService(serviceControl.getService())
|
||||
@@ -358,12 +404,12 @@ object V2RayServiceManager {
|
||||
|
||||
when (intent?.action) {
|
||||
Intent.ACTION_SCREEN_OFF -> {
|
||||
Log.i(AppConfig.TAG, "SCREEN_OFF, stop querying stats")
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Screen off")
|
||||
NotificationManager.stopSpeedNotification(currentConfig)
|
||||
}
|
||||
|
||||
Intent.ACTION_SCREEN_ON -> {
|
||||
Log.i(AppConfig.TAG, "SCREEN_ON, start querying stats")
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Screen on")
|
||||
NotificationManager.startSpeedNotification(currentConfig)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,6 +467,19 @@ object V2rayConfigManager {
|
||||
|
||||
val rule = JsonUtil.fromJson(JsonUtil.toJson(item), RulesBean::class.java) ?: return
|
||||
|
||||
// Replace specific geoip rules with ext versions
|
||||
rule.ip?.let { ipList ->
|
||||
val updatedIpList = ArrayList<String>()
|
||||
ipList.forEach { ip ->
|
||||
when (ip) {
|
||||
AppConfig.GEOIP_CN -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:cn")
|
||||
AppConfig.GEOIP_PRIVATE -> updatedIpList.add("ext:${AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT}:private")
|
||||
else -> updatedIpList.add(ip)
|
||||
}
|
||||
}
|
||||
rule.ip = updatedIpList
|
||||
}
|
||||
|
||||
v2rayConfig.routing.rules.add(rule)
|
||||
|
||||
} catch (e: Exception) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.v2ray.ang.receiver
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
|
||||
@@ -16,8 +18,24 @@ class BootReceiver : BroadcastReceiver() {
|
||||
* @param intent The Intent being received.
|
||||
*/
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
if (!MmkvManager.decodeStartOnBoot() || MmkvManager.getSelectServer().isNullOrEmpty()) return
|
||||
Log.i(AppConfig.TAG, "BootReceiver received: ${intent?.action}")
|
||||
|
||||
if (context == null || intent?.action != Intent.ACTION_BOOT_COMPLETED) {
|
||||
Log.w(AppConfig.TAG, "BootReceiver: Invalid context or action")
|
||||
return
|
||||
}
|
||||
|
||||
if (!MmkvManager.decodeStartOnBoot()) {
|
||||
Log.i(AppConfig.TAG, "BootReceiver: Auto-start on boot is disabled")
|
||||
return
|
||||
}
|
||||
|
||||
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
|
||||
Log.w(AppConfig.TAG, "BootReceiver: No server selected")
|
||||
return
|
||||
}
|
||||
|
||||
Log.i(AppConfig.TAG, "BootReceiver: Starting V2Ray service")
|
||||
V2RayServiceManager.startVService(context)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.contracts.ServiceControl
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.handler.V2RayServiceManager
|
||||
@@ -16,6 +18,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
|
||||
*/
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(AppConfig.TAG, "StartCore-Proxy: Service created")
|
||||
V2RayServiceManager.serviceControl = SoftReference(this)
|
||||
}
|
||||
|
||||
@@ -27,6 +30,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
|
||||
* @return The start mode.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
|
||||
V2RayServiceManager.startCoreLoop(null)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.os.IBinder
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG
|
||||
import com.v2ray.ang.AppConfig.MSG_MEASURE_CONFIG_CANCEL
|
||||
import com.v2ray.ang.dto.TestServiceMessage
|
||||
import com.v2ray.ang.extension.serializable
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.V2RayNativeManager
|
||||
import com.v2ray.ang.util.MessageUtil
|
||||
@@ -52,13 +54,15 @@ class V2RayTestService : Service() {
|
||||
* @return The start mode.
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
when (intent?.getIntExtra("key", 0)) {
|
||||
val message = intent?.serializable<TestServiceMessage>("content") ?: return super.onStartCommand(intent, flags, startId)
|
||||
when (message.key) {
|
||||
MSG_MEASURE_CONFIG -> {
|
||||
val subscriptionId = intent.getStringExtra("content").orEmpty()
|
||||
val guidsList = if (subscriptionId.isEmpty()) {
|
||||
MmkvManager.decodeAllServerList()
|
||||
val guidsList = if (message.serverGuids.isNotEmpty()) {
|
||||
message.serverGuids
|
||||
} else if (message.subscriptionId.isNotEmpty()) {
|
||||
MmkvManager.decodeServerList(message.subscriptionId)
|
||||
} else {
|
||||
MmkvManager.decodeServerList(subscriptionId)
|
||||
MmkvManager.decodeAllServerList()
|
||||
}
|
||||
|
||||
if (guidsList.isNotEmpty()) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -28,6 +29,7 @@ import com.v2ray.ang.util.MyContextWrapper
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.lang.ref.SoftReference
|
||||
|
||||
@SuppressLint("VpnServicePolicy")
|
||||
class V2RayVpnService : VpnService(), ServiceControl {
|
||||
private lateinit var mInterface: ParcelFileDescriptor
|
||||
private var isRunning = false
|
||||
@@ -72,12 +74,14 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(AppConfig.TAG, "StartCore-VPN: Service created")
|
||||
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
|
||||
StrictMode.setThreadPolicy(policy)
|
||||
V2RayServiceManager.serviceControl = SoftReference(this)
|
||||
}
|
||||
|
||||
override fun onRevoke() {
|
||||
Log.w(AppConfig.TAG, "StartCore-VPN: Permission revoked")
|
||||
stopAllService()
|
||||
}
|
||||
|
||||
@@ -88,10 +92,12 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
|
||||
NotificationManager.cancelNotification()
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
Log.i(AppConfig.TAG, "StartCore-VPN: Service command received")
|
||||
setupVpnService()
|
||||
startService()
|
||||
return START_STICKY
|
||||
@@ -103,12 +109,12 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
}
|
||||
|
||||
override fun startService() {
|
||||
if (mInterface == null) {
|
||||
Log.e(AppConfig.TAG, "Failed to create VPN interface")
|
||||
if (!::mInterface.isInitialized) {
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Interface not initialized")
|
||||
return
|
||||
}
|
||||
if (!V2RayServiceManager.startCoreLoop(mInterface)) {
|
||||
Log.e(AppConfig.TAG, "Failed to start V2Ray core loop")
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to start core loop")
|
||||
stopAllService()
|
||||
return
|
||||
}
|
||||
@@ -136,13 +142,13 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
private fun setupVpnService() {
|
||||
val prepare = prepare(this)
|
||||
if (prepare != null) {
|
||||
Log.e(AppConfig.TAG, "VPN preparation failed")
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Permission not granted")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
|
||||
if (configureVpnService() != true) {
|
||||
Log.e(AppConfig.TAG, "VPN configuration failed")
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Configuration failed")
|
||||
stopSelf()
|
||||
return
|
||||
}
|
||||
@@ -165,9 +171,11 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
|
||||
// Close the old interface since the parameters have been changed
|
||||
try {
|
||||
mInterface.close()
|
||||
} catch (ignored: Exception) {
|
||||
// ignored
|
||||
if (::mInterface.isInitialized) {
|
||||
mInterface.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(AppConfig.TAG, "Failed to close old interface", e)
|
||||
}
|
||||
|
||||
// Configure platform-specific features
|
||||
@@ -244,7 +252,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
try {
|
||||
connectivity.requestNetwork(defaultNetworkRequest, defaultNetworkCallback)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to request default network", e)
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to request network", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,7 +305,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
builder.addAllowedApplication(it)
|
||||
}
|
||||
} catch (e: PackageManager.NameNotFoundException) {
|
||||
Log.e(AppConfig.TAG, "Failed to configure app in VPN: ${e.localizedMessage}", e)
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to configure app", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -330,8 +338,8 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
|
||||
try {
|
||||
connectivity.unregisterNetworkCallback(defaultNetworkCallback)
|
||||
} catch (ignored: Exception) {
|
||||
// ignored
|
||||
} catch (e: Exception) {
|
||||
Log.w(AppConfig.TAG, "StartCore-VPN: Failed to unregister callback", e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -349,9 +357,11 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
stopSelf()
|
||||
|
||||
try {
|
||||
mInterface.close()
|
||||
if (::mInterface.isInitialized) {
|
||||
mInterface.close()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to close VPN interface", e)
|
||||
Log.e(AppConfig.TAG, "StartCore-VPN: Failed to close interface", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.MainAdapterListener
|
||||
@@ -28,7 +29,8 @@ import com.v2ray.ang.viewmodel.MainViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
SwipeRefreshLayout.OnRefreshListener {
|
||||
private val ownerActivity: MainActivity
|
||||
get() = requireActivity() as MainActivity
|
||||
private val mainViewModel: MainViewModel by activityViewModels()
|
||||
@@ -68,6 +70,10 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
|
||||
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
||||
|
||||
binding.refreshLayout.setOnRefreshListener(this)
|
||||
// Set the distance to trigger sync to 160dp
|
||||
binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
|
||||
|
||||
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
|
||||
if (mainViewModel.subscriptionId != subId) {
|
||||
return@observe
|
||||
@@ -142,7 +148,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
* @param guid The server unique identifier
|
||||
*/
|
||||
private fun shareFullContent(guid: String) {
|
||||
ownerActivity.lifecycleScope.launch(Dispatchers.IO) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
|
||||
launch(Dispatchers.Main) {
|
||||
if (result == 0) {
|
||||
@@ -212,7 +218,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
* @param position The position in the list
|
||||
*/
|
||||
private fun removeServerSub(guid: String, position: Int) {
|
||||
ownerActivity.mainViewModel.removeServer(guid)
|
||||
mainViewModel.removeServer(guid)
|
||||
adapter.removeServerSub(guid, position)
|
||||
}
|
||||
|
||||
@@ -271,4 +277,43 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>() {
|
||||
shareServer(guid, profile, position, shareOptions, skip)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
ownerActivity.importConfigViaSub()
|
||||
binding.refreshLayout.isRefreshing = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the currently selected server in the RecyclerView
|
||||
*/
|
||||
fun scrollToSelectedServer() {
|
||||
val selectedGuid = MmkvManager.getSelectServer()
|
||||
if (selectedGuid.isNullOrEmpty()) {
|
||||
ownerActivity.toast(R.string.title_file_chooser)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the position of the selected server
|
||||
val serversCache = mainViewModel.serversCache
|
||||
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
|
||||
val recyclerView = binding.recyclerView
|
||||
|
||||
if (position >= 0) {
|
||||
// Get the layout manager
|
||||
val layoutManager = recyclerView.layoutManager as? GridLayoutManager
|
||||
|
||||
if (layoutManager != null) {
|
||||
// Scroll to position with offset to center it on screen
|
||||
// First scroll to position, then adjust to center
|
||||
recyclerView.post {
|
||||
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 3)
|
||||
}
|
||||
} else {
|
||||
// Fallback to smooth scroll if layout manager is not GridLayoutManager
|
||||
recyclerView.smoothScrollToPosition(position)
|
||||
}
|
||||
} else {
|
||||
ownerActivity.toast(R.string.toast_server_not_found_in_group)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
})
|
||||
|
||||
binding.fab.setOnClickListener { handleFabAction() }
|
||||
binding.fabLocate.setOnClickListener { locateSelectedServer() }
|
||||
binding.layoutTest.setOnClickListener { handleLayoutTestClick() }
|
||||
|
||||
setupGroupTab()
|
||||
@@ -433,7 +434,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
/**
|
||||
* import config from sub
|
||||
*/
|
||||
private fun importConfigViaSub(): Boolean {
|
||||
fun importConfigViaSub(): Boolean {
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
@@ -569,6 +570,47 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Locates and scrolls to the currently selected server.
|
||||
* If the selected server is in a different group, automatically switches to that group first.
|
||||
*/
|
||||
private fun locateSelectedServer() {
|
||||
val targetSubscriptionId = mainViewModel.findSubscriptionIdBySelect()
|
||||
if (targetSubscriptionId.isNullOrEmpty()) {
|
||||
toast(R.string.title_file_chooser)
|
||||
return
|
||||
}
|
||||
|
||||
val targetGroupIndex = groupPagerAdapter.groups.indexOfFirst { it.id == targetSubscriptionId }
|
||||
if (targetGroupIndex < 0) {
|
||||
toast(R.string.toast_server_not_found_in_group)
|
||||
return
|
||||
}
|
||||
|
||||
// Switch to target group if needed, then scroll to the server
|
||||
if (binding.viewPager.currentItem != targetGroupIndex) {
|
||||
binding.viewPager.setCurrentItem(targetGroupIndex, true)
|
||||
binding.viewPager.postDelayed({ scrollToSelectedServer(targetGroupIndex) }, 1000)
|
||||
} else {
|
||||
scrollToSelectedServer(targetGroupIndex)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the selected server in the specified fragment.
|
||||
* @param groupIndex The index of the group/fragment to scroll in
|
||||
*/
|
||||
private fun scrollToSelectedServer(groupIndex: Int) {
|
||||
val itemId = groupPagerAdapter.getItemId(groupIndex)
|
||||
val fragment = supportFragmentManager.findFragmentByTag("f$itemId") as? GroupServerFragment
|
||||
|
||||
if (fragment?.isAdded == true && fragment.view != null) {
|
||||
fragment.scrollToSelectedServer()
|
||||
} else {
|
||||
toast(R.string.toast_fragment_not_available)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B) {
|
||||
moveTaskToBack(false)
|
||||
|
||||
@@ -14,6 +14,7 @@ import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -118,7 +119,7 @@ class SubEditActivity : BaseActivity() {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
MmkvManager.removeSubscription(editSubId)
|
||||
SettingsManager.removeSubscriptionWithDefault(editSubId)
|
||||
launch(Dispatchers.Main) {
|
||||
finish()
|
||||
}
|
||||
@@ -130,7 +131,7 @@ class SubEditActivity : BaseActivity() {
|
||||
.show()
|
||||
} else {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
MmkvManager.removeSubscription(editSubId)
|
||||
SettingsManager.removeSubscriptionWithDefault(editSubId)
|
||||
launch(Dispatchers.Main) {
|
||||
finish()
|
||||
}
|
||||
@@ -145,10 +146,6 @@ class SubEditActivity : BaseActivity() {
|
||||
del_config = menu.findItem(R.id.del_config)
|
||||
save_config = menu.findItem(R.id.save_config)
|
||||
|
||||
if (editSubId.isEmpty() || editSubId == AppConfig.DEFAULT_SUBSCRIPTION_ID) {
|
||||
del_config?.isVisible = false
|
||||
}
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ class SubSettingActivity : BaseActivity() {
|
||||
)
|
||||
}
|
||||
hideLoading()
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,6 @@ class SubSettingRecyclerAdapter(
|
||||
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
|
||||
adapterListener?.onRemove(subId, position)
|
||||
}
|
||||
holder.itemSubSettingBinding.layoutRemove.isVisible = subId != AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
|
||||
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
|
||||
if (!it.isPressed) return@setOnCheckedChangeListener
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.TestServiceMessage
|
||||
import com.v2ray.ang.service.V2RayTestService
|
||||
import java.io.Serializable
|
||||
|
||||
@@ -37,15 +38,13 @@ object MessageUtil {
|
||||
* Sends a message to the test service.
|
||||
*
|
||||
* @param ctx The context.
|
||||
* @param what The message identifier.
|
||||
* @param content The message content.
|
||||
* @param message The test service message containing key, subscriptionId, and serverGuids.
|
||||
*/
|
||||
fun sendMsg2TestService(ctx: Context, what: Int, content: Serializable) {
|
||||
fun sendMsg2TestService(ctx: Context, message: TestServiceMessage) {
|
||||
try {
|
||||
val intent = Intent()
|
||||
intent.component = ComponentName(ctx, V2RayTestService::class.java)
|
||||
intent.putExtra("key", what)
|
||||
intent.putExtra("content", content)
|
||||
intent.putExtra("content", message)
|
||||
ctx.startService(intent)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to send message to test service", e)
|
||||
|
||||
@@ -18,6 +18,8 @@ import com.v2ray.ang.dto.GroupMapItem
|
||||
import com.v2ray.ang.dto.ServersCache
|
||||
import com.v2ray.ang.dto.SubscriptionCache
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||
import com.v2ray.ang.dto.TestServiceMessage
|
||||
import com.v2ray.ang.extension.matchesPattern
|
||||
import com.v2ray.ang.extension.serializable
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
@@ -34,6 +36,7 @@ import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.Collections
|
||||
import java.util.regex.PatternSyntaxException
|
||||
|
||||
class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var serverList = mutableListOf<String>() // MmkvManager.decodeServerList()
|
||||
@@ -116,7 +119,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
@Synchronized
|
||||
fun updateCache() {
|
||||
serversCache.clear()
|
||||
val kw = keywordFilter.trim().lowercase()
|
||||
val kw = keywordFilter.trim()
|
||||
val searchRegex = try {
|
||||
if (kw.isNotEmpty()) Regex(kw, setOf(RegexOption.IGNORE_CASE)) else null
|
||||
} catch (e: PatternSyntaxException) {
|
||||
null // Fallback to literal search if regex is invalid
|
||||
}
|
||||
for (guid in serverList) {
|
||||
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
|
||||
if (kw.isEmpty()) {
|
||||
@@ -124,11 +132,15 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
continue
|
||||
}
|
||||
|
||||
val remarks = profile.remarks.lowercase()
|
||||
val description = profile.description.orEmpty().lowercase()
|
||||
val server = profile.server.orEmpty().lowercase()
|
||||
|
||||
if (remarks.contains(kw) || description.contains(kw) || server.contains(kw)) {
|
||||
val remarks = profile.remarks
|
||||
val description = profile.description.orEmpty()
|
||||
val server = profile.server.orEmpty()
|
||||
val protocol = profile.configType.name
|
||||
if (remarks.matchesPattern(searchRegex, kw)
|
||||
|| description.matchesPattern(searchRegex, kw)
|
||||
|| server.matchesPattern(searchRegex, kw)
|
||||
|| protocol.matchesPattern(searchRegex, kw)
|
||||
) {
|
||||
serversCache.add(ServersCache(guid, profile))
|
||||
}
|
||||
}
|
||||
@@ -196,7 +208,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* Tests the real ping for all servers.
|
||||
*/
|
||||
fun testAllRealPing() {
|
||||
MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG_CANCEL, "")
|
||||
MessageUtil.sendMsg2TestService(
|
||||
getApplication(),
|
||||
TestServiceMessage(key = AppConfig.MSG_MEASURE_CONFIG_CANCEL)
|
||||
)
|
||||
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
|
||||
updateListAction.value = -1
|
||||
|
||||
@@ -204,7 +219,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (serversCache.isEmpty()) {
|
||||
return@launch
|
||||
}
|
||||
MessageUtil.sendMsg2TestService(getApplication(), AppConfig.MSG_MEASURE_CONFIG, subscriptionId)
|
||||
MessageUtil.sendMsg2TestService(
|
||||
getApplication(),
|
||||
TestServiceMessage(
|
||||
key = AppConfig.MSG_MEASURE_CONFIG,
|
||||
subscriptionId = subscriptionId,
|
||||
serverGuids = if (keywordFilter.isNotEmpty()) serversCache.map { it.guid } else emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -241,9 +263,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
val groups = mutableListOf<GroupMapItem>()
|
||||
if (subscriptions.size > 1
|
||||
&& MmkvManager.decodeSettingsBool(AppConfig.PREF_GROUP_ALL_DISPLAY)
|
||||
) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_GROUP_ALL_DISPLAY)) {
|
||||
groups.add(
|
||||
GroupMapItem(
|
||||
id = "",
|
||||
@@ -393,6 +413,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
reloadServerList()
|
||||
}
|
||||
|
||||
fun findSubscriptionIdBySelect(): String? {
|
||||
// Get the selected server GUID
|
||||
val selectedGuid = MmkvManager.getSelectServer()
|
||||
if (selectedGuid.isNullOrEmpty()) {
|
||||
return null
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(selectedGuid)
|
||||
return config?.subscriptionId
|
||||
}
|
||||
|
||||
fun onTestsFinished() {
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST)) {
|
||||
|
||||
@@ -21,7 +21,7 @@ class SubscriptionsViewModel : ViewModel() {
|
||||
fun remove(subId: String): Boolean {
|
||||
val changed = subscriptions.removeAll { it.guid == subId }
|
||||
if (changed) {
|
||||
MmkvManager.removeSubscription(subId)
|
||||
SettingsManager.removeSubscriptionWithDefault(subId)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
}
|
||||
return changed
|
||||
|
||||
@@ -15,7 +15,7 @@ import java.net.HttpURLConnection
|
||||
|
||||
class UserAssetViewModel : ViewModel() {
|
||||
private val assets = mutableListOf<AssetUrlCache>()
|
||||
private val builtInGeoFiles = listOf("geosite.dat", "geoip.dat")
|
||||
private val builtInGeoFiles = listOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
||||
|
||||
val itemCount: Int
|
||||
get() = assets.size
|
||||
@@ -47,7 +47,18 @@ class UserAssetViewModel : ViewModel() {
|
||||
)
|
||||
)
|
||||
}
|
||||
return builtInItems + savedAssets
|
||||
// Force update URL for geoip-only-cn-private.dat
|
||||
return (builtInItems + savedAssets).map { cache ->
|
||||
if (cache.assetUrl.remarks == AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT) {
|
||||
cache.copy(
|
||||
assetUrl = cache.assetUrl.copy(
|
||||
url = AppConfig.GEOIP_ONLY_CN_PRIVATE_URL
|
||||
)
|
||||
)
|
||||
} else {
|
||||
cache
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun downloadGeoFiles(extDir: File, httpPort: Int): GeoDownloadResult {
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
tools:context=".ui.LogcatActivity">
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/refreshLayout"
|
||||
android:id="@+id/refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
|
||||
@@ -96,6 +96,18 @@
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginEnd="@dimen/padding_spacing_dp16">
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fab_locate"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginBottom="@dimen/view_height_dp120"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/title_server"
|
||||
android:focusable="true"
|
||||
android:src="@android:drawable/ic_menu_mylocation"
|
||||
app:layout_anchorGravity="bottom|right|end" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fab"
|
||||
android:layout_width="wrap_content"
|
||||
|
||||
@@ -3,10 +3,17 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="vertical" />
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="vertical" />
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
@@ -299,6 +299,8 @@
|
||||
<string name="title_update_config_count">Update %d configurations</string>
|
||||
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
|
||||
<string name="title_update_subscription_no_subscription">No subscriptions</string>
|
||||
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
|
||||
<string name="toast_fragment_not_available">Unable to locate current view</string>
|
||||
<string name="tasker_start_service">بدء الخدمة</string>
|
||||
<string name="tasker_setting_confirm">تأكيد</string>
|
||||
|
||||
|
||||
@@ -298,6 +298,8 @@
|
||||
<string name="title_update_config_count">Update %d configurations</string>
|
||||
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
|
||||
<string name="title_update_subscription_no_subscription">No subscriptions</string>
|
||||
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
|
||||
<string name="toast_fragment_not_available">Unable to locate current view</string>
|
||||
<string name="tasker_start_service">সার্ভিস শুরু করুন</string>
|
||||
<string name="tasker_setting_confirm">নিশ্চিত করুন</string>
|
||||
|
||||
|
||||
@@ -299,6 +299,8 @@
|
||||
<string name="title_update_config_count">ورۊ کردن %d کانفیگ</string>
|
||||
<string name="title_update_subscription_result">%1$d کانفیگ ورۊ وابی (مووفق: %2$d، شکست خرده: %3$d، رڌ وابیڌه: %4$d)</string>
|
||||
<string name="title_update_subscription_no_subscription">بؽ اشتراک</string>
|
||||
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
|
||||
<string name="toast_fragment_not_available">Unable to locate current view</string>
|
||||
<string name="tasker_start_service">ره وندن خدمات</string>
|
||||
<string name="tasker_setting_confirm">قوۊل</string>
|
||||
|
||||
|
||||
@@ -296,6 +296,8 @@
|
||||
<string name="title_update_config_count">آپدیت کردن %d کانفیگ</string>
|
||||
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
|
||||
<string name="title_update_subscription_no_subscription">No subscriptions</string>
|
||||
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
|
||||
<string name="toast_fragment_not_available">Unable to locate current view</string>
|
||||
<string name="tasker_start_service">شروع خدمات</string>
|
||||
<string name="tasker_setting_confirm">تایید</string>
|
||||
|
||||
|
||||
@@ -297,6 +297,8 @@
|
||||
<string name="title_update_config_count">Обновлено профилей: %d</string>
|
||||
<string name="title_update_subscription_result">Обновлено профилей: %1$d (успешно: %2$d, ошибок: %3$d, пропущено: %4$d)</string>
|
||||
<string name="title_update_subscription_no_subscription">Нет подписок</string>
|
||||
<string name="toast_server_not_found_in_group">Выбранный профиль не найден в текущей группе</string>
|
||||
<string name="toast_fragment_not_available">Фрагмент недоступен</string>
|
||||
|
||||
<string name="tasker_start_service">Запуск службы</string>
|
||||
<string name="tasker_setting_confirm">Подтвердить</string>
|
||||
|
||||
@@ -299,6 +299,8 @@
|
||||
<string name="title_update_config_count">Update %d configurations</string>
|
||||
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
|
||||
<string name="title_update_subscription_no_subscription">No subscriptions</string>
|
||||
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
|
||||
<string name="toast_fragment_not_available">Unable to locate current view</string>
|
||||
<string name="tasker_start_service">Khởi động v2rayNG</string>
|
||||
<string name="tasker_setting_confirm">Xác nhận</string>
|
||||
|
||||
|
||||
@@ -297,6 +297,8 @@
|
||||
<string name="title_update_config_count">更新 %d 个配置</string>
|
||||
<string name="title_update_subscription_result">更新了 %1$d 个配置(%2$d 个成功,%3$d 个失败,%4$d 个跳过)</string>
|
||||
<string name="title_update_subscription_no_subscription">无订阅</string>
|
||||
<string name="toast_server_not_found_in_group">当前分组中未找到选中的服务器</string>
|
||||
<string name="toast_fragment_not_available">无法定位当前视图</string>
|
||||
<string name="tasker_start_service">启动服务</string>
|
||||
<string name="tasker_setting_confirm">确定</string>
|
||||
|
||||
|
||||
@@ -297,6 +297,8 @@
|
||||
<string name="title_update_config_count">更新 %d 個配置</string>
|
||||
<string name="title_update_subscription_result">更新了 %1$d 個配置(%2$d 個成功,%3$d 個失敗,%4$d 個跳過)</string>
|
||||
<string name="title_update_subscription_no_subscription">無訂閱</string>
|
||||
<string name="toast_server_not_found_in_group">當前分組中未找到選中的伺服器</string>
|
||||
<string name="toast_fragment_not_available">無法定位當前視圖</string>
|
||||
<string name="tasker_start_service">啟動服務</string>
|
||||
<string name="tasker_setting_confirm">確定</string>
|
||||
|
||||
|
||||
@@ -7,5 +7,6 @@
|
||||
<dimen name="view_height_dp36">36dp</dimen>
|
||||
<dimen name="view_height_dp48">48dp</dimen>
|
||||
<dimen name="view_height_dp64">64dp</dimen>
|
||||
<dimen name="view_height_dp120">120dp</dimen>
|
||||
<dimen name="view_height_dp160">160dp</dimen>
|
||||
</resources>
|
||||
|
||||
@@ -303,6 +303,8 @@
|
||||
<string name="title_update_config_count">Update %d configs</string>
|
||||
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
|
||||
<string name="title_update_subscription_no_subscription">No subscriptions</string>
|
||||
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
|
||||
<string name="toast_fragment_not_available">Unable to locate current view</string>
|
||||
|
||||
<string name="tasker_start_service">Start Service</string>
|
||||
<string name="tasker_setting_confirm">Confirm</string>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[versions]
|
||||
agp = "9.0.1"
|
||||
agp = "9.1.0"
|
||||
desugarJdkLibs = "2.1.5"
|
||||
gradleLicensePlugin = "0.9.8"
|
||||
kotlin = "2.3.10"
|
||||
|
||||
+1
-1
Submodule hev-socks5-tunnel updated: 4d6c334dbf...5ec615875f
Reference in New Issue
Block a user