Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions V2rayNG/app/src/main/java/com/v2ray/ang/AppConfig.kt
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ object AppConfig {

const val MSG_SUB_UPDATE_START = 8
const val MSG_SUB_UPDATE_CANCEL = 81
const val MSG_SUB_UPDATE_DATA_CHANGED = 82

/** Notification channel IDs and names. */
// Use a new ID because Android does not let an app raise an existing channel's importance.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package com.v2ray.ang.core

import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import java.net.InetAddress

/**
* Mirrors the insecure-outbound validation in the Xray-core revision pinned by
* AndroidLibXrayLite (5ca6f4b7d4dc).
*/
internal object XrayOutboundCompatibility {
private val tlsProfileTypes = setOf(
EConfigType.VMESS,
EConfigType.VLESS,
EConfigType.SHADOWSOCKS,
EConfigType.TROJAN,
EConfigType.HYSTERIA2,
)

private val privateIpPrefixes = listOf(
IpPrefix("0.0.0.0", 8),
IpPrefix("10.0.0.0", 8),
IpPrefix("100.64.0.0", 10),
IpPrefix("127.0.0.0", 8),
IpPrefix("169.254.0.0", 16),
IpPrefix("172.16.0.0", 12),
IpPrefix("192.0.0.0", 24),
IpPrefix("192.0.2.0", 24),
IpPrefix("192.88.99.0", 24),
IpPrefix("192.168.0.0", 16),
IpPrefix("198.18.0.0", 15),
IpPrefix("198.51.100.0", 24),
IpPrefix("203.0.113.0", 24),
IpPrefix("224.0.0.0", 3),
IpPrefix("::", 127),
IpPrefix("fc00::", 7),
IpPrefix("fe80::", 10),
IpPrefix("ff00::", 8),
)

private val privateDomainSuffixes = setOf(
"lan",
"localdomain",
"example",
"invalid",
"localhost",
"test",
"local",
"home.arpa",
"internal",
)

private val dotlessPrivateDomain = Regex("^[a-z]([a-z0-9-]{0,61}[a-z0-9])?$")

fun isDeprecated(profile: ProfileItem): Boolean {
if (usesRemovedAllowInsecure(profile)) {
return true
}

if (hasTransportSecurity(profile) || !requiresTransportSecurity(profile.server)) {
return false
}

return when (profile.configType) {
EConfigType.VLESS -> profile.method.isNullOrEmpty() || profile.method == "none"
EConfigType.TROJAN -> true
else -> false
}
}

/** Mirrors the value emitted by CoreOutboundBuilder.populateTlsSettings. */
private fun usesRemovedAllowInsecure(profile: ProfileItem): Boolean =
profile.configType in tlsProfileTypes &&
profile.security == AppConfig.TLS &&
profile.insecure == true &&
profile.pinnedCA256.isNullOrEmpty()

private fun hasTransportSecurity(profile: ProfileItem): Boolean =
profile.security.equals(AppConfig.TLS, ignoreCase = true) ||
profile.security.equals(AppConfig.REALITY, ignoreCase = true)

private fun requiresTransportSecurity(server: String?): Boolean {
if (server.isNullOrEmpty()) return false
val address = normalizeAddress(server)

parseIpLiteral(address)?.let { ip ->
return privateIpPrefixes.none { it.matches(ip) }
}

val domain = address.lowercase().removeSuffix(".")
val isPrivateDomain = dotlessPrivateDomain.matches(domain) ||
privateDomainSuffixes.any { suffix ->
domain == suffix || domain.endsWith(".$suffix")
}
return !isPrivateDomain
}

/** Mirrors Xray's bracket removal and conditional whitespace trimming. */
private fun normalizeAddress(server: String): String {
var address = server
if (address.startsWith('[') && address.endsWith(']')) {
address = address.substring(1, address.length - 1)
}
if (address.isNotEmpty() && (!address.first().isAsciiAlphaNumeric() || !address.last().isAsciiAlphaNumeric())) {
address = address.trim()
}
return address
}

private fun Char.isAsciiAlphaNumeric(): Boolean =
this in '0'..'9' || this in 'a'..'z' || this in 'A'..'Z'

private fun parseIpLiteral(address: String): ByteArray? {
if (':' in address) {
return runCatching { InetAddress.getByName(address).address }.getOrNull()
}

val octets = address.split('.')
if (octets.size != 4) return null
val bytes = ByteArray(4)
octets.forEachIndexed { index, octet ->
if (octet.isEmpty() || octet.any { !it.isDigit() }) return null
if (octet.length > 1 && octet.startsWith('0')) return null
val value = octet.toIntOrNull()?.takeIf { it in 0..255 } ?: return null
bytes[index] = value.toByte()
}
return bytes
}

private class IpPrefix(address: String, private val prefixLength: Int) {
private val addressBytes = checkNotNull(parseIpLiteral(address))

fun matches(candidate: ByteArray): Boolean {
if (candidate.size != addressBytes.size) return false

val fullBytes = prefixLength / Byte.SIZE_BITS
for (index in 0 until fullBytes) {
if (candidate[index] != addressBytes[index]) return false
}

val remainingBits = prefixLength % Byte.SIZE_BITS
if (remainingBits == 0) return true
val mask = (0xff shl (Byte.SIZE_BITS - remainingBits)) and 0xff
return (candidate[fullBytes].toInt() and mask) ==
(addressBytes[fullBytes].toInt() and mask)
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ package com.v2ray.ang.dto.entities
data class ServersCache(
val guid: String,
val profile: ProfileItem,
/** Derived once for this profile snapshot and reused by list recompositions. */
val isDeprecated: Boolean,
val testDelayMillis: Long = 0L,
val testDelayString: String = "",
)
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import com.v2ray.ang.extension.serializable
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.AppLocaleManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.helper.NotificationHelper
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.CompletableDeferred
Expand Down Expand Up @@ -126,37 +127,45 @@ class SubscriptionUpdateService : Service() {
content = getString(R.string.subscription_update_updating, subItem.remarks)
)

if (forcedUpdate || MmkvManager.decodeSettingsBool(AppConfig.PREF_UPDATE_SUBSCRIPTION, false)) {
AngConfigManager.updateConfigViaSub(sub)
}

if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION, false)) {
testSubscriptionServers(sub)
var dataChanged = false
try {
if (forcedUpdate || MmkvManager.decodeSettingsBool(AppConfig.PREF_UPDATE_SUBSCRIPTION, false)) {
dataChanged = AngConfigManager.updateConfigViaSub(sub).configCount > 0
}

if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: removing invalid servers for ${subItem.remarks}")
showNotification(
context = this,
titleResId = R.string.title_del_invalid_config,
content = subItem.remarks
)
AngConfigManager.removeInvalidServer(subId)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_TEST_AFTER_UPDATE_SUBSCRIPTION, false)) {
val testedServers = testSubscriptionServers(sub)
dataChanged = dataChanged || testedServers

if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: removing invalid servers for ${subItem.remarks}")
showNotification(
context = this,
titleResId = R.string.title_del_invalid_config,
content = subItem.remarks
)
AngConfigManager.removeInvalidServer(subId)
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: sorting servers for ${subItem.remarks}")
showNotification(
context = this,
titleResId = R.string.title_sort_by_test_results,
content = subItem.remarks
)
AngConfigManager.sortByTestResultsForSub(subId)
}
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)) {
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: sorting servers for ${subItem.remarks}")
showNotification(
context = this,
titleResId = R.string.title_sort_by_test_results,
content = subItem.remarks
)
AngConfigManager.sortByTestResultsForSub(subId)

LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: Finished ${subItem.remarks}")
} finally {
if (dataChanged) {
MessageHelper.sendMsg2UI(this, AppConfig.MSG_SUB_UPDATE_DATA_CHANGED, subId)
}
}

LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: Finished ${subItem.remarks}")
}

private suspend fun testSubscriptionServers(sub: SubscriptionCache) {
private suspend fun testSubscriptionServers(sub: SubscriptionCache): Boolean {
val subId = sub.guid
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: starting test phase for ${sub.subscription.remarks}")
showNotification(
Expand All @@ -166,24 +175,25 @@ class SubscriptionUpdateService : Service() {
)

val guids = MmkvManager.decodeServerList(subId)
if (guids.isNotEmpty()) {
val deferred = CompletableDeferred<Unit>()
lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(
context = this,
guids = guids,
onEvent = { event ->
handleWorkerEvent(event, sub.subscription.remarks) {
activeWorkers.remove(worker)
deferred.complete(Unit)
}
if (guids.isEmpty()) return false

val deferred = CompletableDeferred<Unit>()
lateinit var worker: RealPingWorkerService
worker = RealPingWorkerService(
context = this,
guids = guids,
onEvent = { event ->
handleWorkerEvent(event, sub.subscription.remarks) {
activeWorkers.remove(worker)
deferred.complete(Unit)
}
)
activeWorkers.add(worker)
worker.start()
deferred.await()
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: test phase finished for ${sub.subscription.remarks}")
}
}
)
activeWorkers.add(worker)
worker.start()
deferred.await()
LogUtil.i(AppConfig.TAG, "SubscriptionUpdateService: test phase finished for ${sub.subscription.remarks}")
return true
}

private fun handleWorkerEvent(event: RealPingEvent, remarks: String, onWorkerDone: () -> Unit) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ class MainRepository(
safeIntent.getStringExtra("content")
)

AppConfig.MSG_SUB_UPDATE_DATA_CHANGED -> safeIntent.getStringExtra("content")
?.takeIf { it.isNotBlank() }
?.let(MainServiceEvent::SubscriptionDataChanged)

else -> null
}
event?.let { _mainServiceEvent.tryEmit(it) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.LineBreak
import androidx.compose.ui.text.style.TextOverflow
Expand Down Expand Up @@ -252,6 +253,7 @@ private fun ServerItemRow(
testResult = serverCache.testDelayString,
testDelayMillis = serverCache.testDelayMillis,
isSelected = serverCache.guid == selectedGuid,
isDeprecated = serverCache.isDeprecated,
subscriptionRemarks = subRemarks,
doubleColumnDisplay = false,
onClick = { onSelectServer(serverCache.guid) },
Expand Down Expand Up @@ -286,6 +288,7 @@ private fun ServerItemColumn(
testResult = serverCache.testDelayString,
testDelayMillis = serverCache.testDelayMillis,
isSelected = serverCache.guid == selectedGuid,
isDeprecated = serverCache.isDeprecated,
subscriptionRemarks = subRemarks,
doubleColumnDisplay = doubleColumnDisplay,
onClick = { onSelectServer(serverCache.guid) },
Expand All @@ -306,6 +309,7 @@ fun ServerListItem(
testResult: String,
testDelayMillis: Long,
isSelected: Boolean,
isDeprecated: Boolean,
subscriptionRemarks: String,
doubleColumnDisplay: Boolean,
onClick: () -> Unit,
Expand All @@ -320,6 +324,11 @@ fun ServerListItem(
modifier = modifier
.fillMaxWidth()
.height(IntrinsicSize.Min)
.then(
if (isDeprecated) {
Modifier.background(MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.55f))
} else Modifier
)
.clickable(onClick = onClick)
.then(dragModifier)
) {
Expand Down Expand Up @@ -375,7 +384,17 @@ fun ServerListItem(
}
Spacer(modifier = Modifier.height(6.dp))
Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
Text(typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
text = if (isDeprecated) {
stringResource(R.string.profile_deprecated)
} else {
typeDescription
},
style = MaterialTheme.typography.bodySmall,
color = if (isDeprecated) MaterialTheme.colorScheme.error else colorConfigType,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ sealed class MainServiceEvent {
data object MeasureConfigSuccess : MainServiceEvent()
data class MeasureConfigNotify(val progress: String) : MainServiceEvent()
data class MeasureConfigFinish(val finishedCount: String?) : MainServiceEvent()
data class SubscriptionDataChanged(val subscriptionId: String) : MainServiceEvent()
}
Loading