mirror of
https://github.com/openlibrecommunity/olcng.git
synced 2026-07-03 14:05:17 +02:00
Compare commits
30 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fea86944f | |||
| ff2fea8c03 | |||
| 415f2230e6 | |||
| fb827c1fb1 | |||
| cb4e3aab54 | |||
| 732d7248af | |||
| 0c32ddc642 | |||
| 153c545400 | |||
| 9af6472f8b | |||
| 346be42e37 | |||
| dd9e076ab1 | |||
| 903b0d4414 | |||
| f58a4f8f6f | |||
| fdce3ea2c1 | |||
| f0d620676d | |||
| b92a6cdfac | |||
| 45da2479dd | |||
| 1c936b2b31 | |||
| dbe109eedb | |||
| 7705aded77 | |||
| 4a2d62b671 | |||
| 4acca4e554 | |||
| b875613fb3 | |||
| d9d21061fa | |||
| fc7804fc1e | |||
| 4e9de615a4 | |||
| 7617ce898c | |||
| 8d284fd68a | |||
| 0f28310801 | |||
| a46123aeab |
@@ -138,7 +138,6 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
chmod 755 gradlew
|
||||
./gradlew licenseFdroidReleaseReport
|
||||
./gradlew assembleRelease --info 2>&1 | grep -i "signing\|keystore" || true
|
||||
|
||||
- name: Upload arm64-v8a APK
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
id("com.jaredsburrows.license")
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -18,8 +16,6 @@ android {
|
||||
|
||||
versionCode = envVersionCode?.toIntOrNull() ?: 717
|
||||
versionName = envVersionName ?: "2.0.17"
|
||||
|
||||
multiDexEnabled = true
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
splits {
|
||||
@@ -90,7 +86,7 @@ android {
|
||||
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
jniLibs.srcDirs("libs")
|
||||
jniLibs.directories.add("libs")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,50 +102,6 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
applicationVariants.all {
|
||||
val variant = this
|
||||
val isFdroid = variant.productFlavors.any { it.name == "fdroid" }
|
||||
if (isFdroid) {
|
||||
val versionCodes =
|
||||
mapOf(
|
||||
"armeabi-v7a" to 2, "arm64-v8a" to 1, "x86" to 4, "x86_64" to 3, "universal" to 0
|
||||
)
|
||||
|
||||
variant.outputs
|
||||
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val abi = output.getFilter("ABI") ?: "universal"
|
||||
output.outputFileName = "v2rayNG_${variant.versionName}-fdroid_${abi}.apk"
|
||||
if (versionCodes.containsKey(abi)) {
|
||||
output.versionCodeOverride =
|
||||
(100 * variant.versionCode + versionCodes[abi]!!).plus(5000000)
|
||||
} else {
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val versionCodes =
|
||||
mapOf("armeabi-v7a" to 4, "arm64-v8a" to 4, "x86" to 4, "x86_64" to 4, "universal" to 4)
|
||||
|
||||
variant.outputs
|
||||
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
|
||||
.forEach { output ->
|
||||
val abi = if (output.getFilter("ABI") != null)
|
||||
output.getFilter("ABI")
|
||||
else
|
||||
"universal"
|
||||
|
||||
output.outputFileName = "v2rayNG_${variant.versionName}_${abi}.apk"
|
||||
if (versionCodes.containsKey(abi)) {
|
||||
output.versionCodeOverride =
|
||||
(1000000 * versionCodes[abi]!!).plus(variant.versionCode)
|
||||
} else {
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
@@ -163,6 +115,28 @@ android {
|
||||
|
||||
}
|
||||
|
||||
androidComponents {
|
||||
onVariants { variant ->
|
||||
val isFdroid = variant.productFlavors.any { it.second == "fdroid" }
|
||||
variant.outputs.forEach { output ->
|
||||
val abi = output.filters.find {
|
||||
it.filterType == com.android.build.api.variant.FilterConfiguration.FilterType.ABI
|
||||
}?.identifier ?: "universal"
|
||||
|
||||
if (isFdroid) {
|
||||
val versionCodes = mapOf(
|
||||
"armeabi-v7a" to 2, "arm64-v8a" to 1, "x86" to 4, "x86_64" to 3, "universal" to 0
|
||||
)
|
||||
versionCodes[abi]?.let { code ->
|
||||
output.versionCode.set((100 * (output.versionCode.get() ?: 0) + code) + 5000000)
|
||||
}
|
||||
} else {
|
||||
output.versionCode.set(1000000 * 4 + (output.versionCode.get() ?: 0))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Core Libraries
|
||||
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar", "*.jar"))))
|
||||
@@ -210,9 +184,6 @@ dependencies {
|
||||
implementation(libs.work.runtime.ktx)
|
||||
implementation(libs.work.multiprocess)
|
||||
|
||||
// Multidex Support
|
||||
implementation(libs.multidex)
|
||||
|
||||
// Testing Libraries
|
||||
testImplementation(libs.junit)
|
||||
androidTestImplementation(libs.androidx.junit)
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,7 +1,7 @@
|
||||
package xyz.zarazaex.olc
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.multidex.MultiDexApplication
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import com.google.android.material.color.DynamicColors
|
||||
@@ -9,7 +9,7 @@ import com.tencent.mmkv.MMKV
|
||||
import xyz.zarazaex.olc.AppConfig.ANG_PACKAGE
|
||||
import xyz.zarazaex.olc.handler.SettingsManager
|
||||
|
||||
class AngApplication : MultiDexApplication() {
|
||||
class AngApplication : Application() {
|
||||
companion object {
|
||||
lateinit var application: AngApplication
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ object AppConfig {
|
||||
|
||||
const val DNS_PROXY = "https://1.1.1.1/dns-query"
|
||||
const val DNS_DIRECT = "223.5.5.5"
|
||||
const val DNS_VPN = "https://1.1.1.1/dns-query"
|
||||
const val DNS_VPN = "1.1.1.1"
|
||||
const val GEOSITE_PRIVATE = "geosite:private"
|
||||
const val GEOSITE_CN = "geosite:cn"
|
||||
const val GEOIP_PRIVATE = "geoip:private"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package xyz.zarazaex.olc.dto
|
||||
|
||||
enum class SubscriptionUpdateStatus {
|
||||
IDLE,
|
||||
LOADING,
|
||||
SUCCESS,
|
||||
FAILED,
|
||||
SKIPPED
|
||||
}
|
||||
|
||||
data class SubscriptionStatus(
|
||||
val guid: String,
|
||||
val status: SubscriptionUpdateStatus = SubscriptionUpdateStatus.IDLE,
|
||||
val configCount: Int = 0
|
||||
)
|
||||
@@ -29,6 +29,14 @@ import java.net.URI
|
||||
|
||||
object AngConfigManager {
|
||||
|
||||
private val subscriptionLocks = mutableMapOf<String, Any>()
|
||||
|
||||
private fun getSubscriptionLock(subid: String): Any {
|
||||
return synchronized(subscriptionLocks) {
|
||||
subscriptionLocks.getOrPut(subid) { Any() }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Shares the configuration to the clipboard.
|
||||
@@ -217,49 +225,55 @@ object AngConfigManager {
|
||||
* @return The number of configurations parsed.
|
||||
*/
|
||||
private fun parseBatchConfig(servers: String?, subid: String, append: Boolean): Int {
|
||||
try {
|
||||
if (servers == null) {
|
||||
return 0
|
||||
}
|
||||
// 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
|
||||
}
|
||||
return synchronized(getSubscriptionLock(subid)) {
|
||||
try {
|
||||
if (servers == null) {
|
||||
return@synchronized 0
|
||||
}
|
||||
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)
|
||||
val subItem = MmkvManager.decodeSubscription(subid)
|
||||
|
||||
// Parse all configs first (no I/O during parsing)
|
||||
val configs = mutableListOf<ProfileItem>()
|
||||
servers.lines()
|
||||
.distinct()
|
||||
.reversed()
|
||||
.forEach {
|
||||
val config = parseConfig(it, subid, subItem)
|
||||
if (config != null) {
|
||||
configs.add(config)
|
||||
val oldPingData = if (!append) {
|
||||
saveOldPingData(subid)
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
val configs = mutableListOf<ProfileItem>()
|
||||
servers.lines()
|
||||
.distinct()
|
||||
.reversed()
|
||||
.forEach {
|
||||
val config = parseConfig(it, subid, subItem)
|
||||
if (config != null) {
|
||||
configs.add(config)
|
||||
}
|
||||
}
|
||||
|
||||
if (configs.isNotEmpty()) {
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val keyToProfile = batchSaveConfigs(configs, subid, append)
|
||||
restoreOldPingData(keyToProfile, oldPingData)
|
||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
}
|
||||
|
||||
// Batch save all parsed configs (only one serverList read/write)
|
||||
if (configs.isNotEmpty()) {
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val keyToProfile = batchSaveConfigs(configs, subid)
|
||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
return@synchronized configs.size
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||
}
|
||||
|
||||
return configs.size
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||
return@synchronized 0
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -270,33 +284,80 @@ object AngConfigManager {
|
||||
* @param subid The subscription ID.
|
||||
* @return Map of generated keys to their corresponding ProfileItem.
|
||||
*/
|
||||
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
|
||||
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String, append: Boolean): Map<String, ProfileItem> {
|
||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||
|
||||
// Read serverList once
|
||||
val serverList = MmkvManager.decodeServerList(subid)
|
||||
val serverList = if (append) {
|
||||
MmkvManager.decodeServerList(subid)
|
||||
} else {
|
||||
mutableListOf()
|
||||
}
|
||||
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
|
||||
|
||||
configs.forEach { config ->
|
||||
val key = Utils.getUuid()
|
||||
// Save profile directly without updating serverList
|
||||
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
if (needSetSelected) {
|
||||
MmkvManager.setSelectServer(key)
|
||||
needSetSelected = false
|
||||
}
|
||||
}
|
||||
keyToProfile[key] = config
|
||||
val existingProfiles = if (append) {
|
||||
serverList.mapNotNull { guid ->
|
||||
MmkvManager.decodeServerConfig(guid)?.let { guid to it }
|
||||
}.toMap()
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
configs.forEach { config ->
|
||||
val existingKey = existingProfiles.entries.firstOrNull { (_, existing) ->
|
||||
existing == config
|
||||
}?.key
|
||||
|
||||
if (existingKey != null) {
|
||||
MmkvManager.encodeProfileDirect(existingKey, JsonUtil.toJson(config))
|
||||
keyToProfile[existingKey] = config
|
||||
} else {
|
||||
val key = Utils.getUuid()
|
||||
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
if (needSetSelected) {
|
||||
MmkvManager.setSelectServer(key)
|
||||
needSetSelected = false
|
||||
}
|
||||
}
|
||||
keyToProfile[key] = config
|
||||
}
|
||||
}
|
||||
|
||||
// Write serverList once
|
||||
MmkvManager.encodeServerList(serverList, subid)
|
||||
return keyToProfile
|
||||
}
|
||||
|
||||
private fun saveOldPingData(subid: String): Map<ProfileItem, Long> {
|
||||
val pingData = mutableMapOf<ProfileItem, Long>()
|
||||
val serverList = MmkvManager.decodeServerList(subid)
|
||||
|
||||
serverList.forEach { guid ->
|
||||
val profile = MmkvManager.decodeServerConfig(guid)
|
||||
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
|
||||
if (profile != null && aff != null && aff.testDelayMillis > 0) {
|
||||
pingData[profile] = aff.testDelayMillis
|
||||
}
|
||||
}
|
||||
|
||||
return pingData
|
||||
}
|
||||
|
||||
private fun restoreOldPingData(keyToProfile: Map<String, ProfileItem>, oldPingData: Map<ProfileItem, Long>) {
|
||||
if (oldPingData.isEmpty()) return
|
||||
|
||||
keyToProfile.forEach { (key, newProfile) ->
|
||||
val oldPing = oldPingData.entries.firstOrNull { (oldProfile, _) ->
|
||||
oldProfile == newProfile
|
||||
}?.value
|
||||
|
||||
if (oldPing != null && oldPing > 0) {
|
||||
MmkvManager.encodeServerTestDelayMillis(key, oldPing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a matched profile key from the given key-profile map using multi-level matching.
|
||||
* Matching priority (from highest to lowest):
|
||||
@@ -361,67 +422,68 @@ object AngConfigManager {
|
||||
* @return The number of configurations parsed.
|
||||
*/
|
||||
private fun parseCustomConfigServer(server: String?, subid: String, append: Boolean): Int {
|
||||
if (server == null) {
|
||||
return 0
|
||||
}
|
||||
if (server.contains("inbounds")
|
||||
&& server.contains("outbounds")
|
||||
&& server.contains("routing")
|
||||
) {
|
||||
try {
|
||||
val serverList: Array<Any> =
|
||||
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||
return synchronized(getSubscriptionLock(subid)) {
|
||||
if (server == null) {
|
||||
return@synchronized 0
|
||||
}
|
||||
if (server.contains("inbounds")
|
||||
&& server.contains("outbounds")
|
||||
&& server.contains("routing")
|
||||
) {
|
||||
try {
|
||||
val serverList: Array<Any> =
|
||||
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||
|
||||
if (serverList.isNotEmpty()) {
|
||||
if (serverList.isNotEmpty()) {
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
var count = 0
|
||||
for (srv in serverList.reversed()) {
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
||||
count += 1
|
||||
}
|
||||
return@synchronized count
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||
}
|
||||
|
||||
try {
|
||||
val config = CustomFmt.parse(server) ?: return@synchronized 0
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
var count = 0
|
||||
for (srv in serverList.reversed()) {
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
||||
count += 1
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
return@synchronized 1
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
||||
}
|
||||
return@synchronized 0
|
||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||
try {
|
||||
val config = WireguardFmt.parseWireguardConfFile(server) ?: return@synchronized R.string.toast_incorrect_protocol
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
return count
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
return@synchronized 1
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||
return@synchronized 0
|
||||
} else {
|
||||
return@synchronized 0
|
||||
}
|
||||
|
||||
try {
|
||||
// For compatibility
|
||||
val config = CustomFmt.parse(server) ?: return 0
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
return 1
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
|
||||
}
|
||||
return 0
|
||||
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
|
||||
try {
|
||||
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
return 1
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
||||
}
|
||||
return 0
|
||||
} else {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -533,16 +595,18 @@ object AngConfigManager {
|
||||
Log.i(AppConfig.TAG, url)
|
||||
val userAgent = it.subscription.userAgent
|
||||
|
||||
val timeout = if (url.startsWith("https://key.zarazaex.xyz/sub")) 3000 else 15000
|
||||
|
||||
var configText = try {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort)
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent, timeout, httpPort)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
|
||||
""
|
||||
}
|
||||
if (configText.isEmpty()) {
|
||||
configText = try {
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent, timeout)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
|
||||
""
|
||||
|
||||
@@ -346,7 +346,7 @@ object SettingsManager {
|
||||
*/
|
||||
fun getVpnDnsServers(): List<String> {
|
||||
val vpnDns = MmkvManager.decodeSettingsString(AppConfig.PREF_VPN_DNS) ?: AppConfig.DNS_VPN
|
||||
return vpnDns.split(",").filter { Utils.isPureIpAddress(it) }
|
||||
return vpnDns.split(",").filter { it.isNotBlank() && Utils.isPureIpAddress(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -94,18 +94,23 @@ object SpeedtestManager {
|
||||
var result: String
|
||||
var elapsed = -1L
|
||||
|
||||
val conn = HttpUtil.createProxyConnection(SettingsManager.getDelayTestUrl(), port, 15000, 15000) ?: return Pair(elapsed, "")
|
||||
val testUrl = "https://icanhazip.com"
|
||||
val conn = HttpUtil.createProxyConnection(testUrl, port, 15000, 15000) ?: return Pair(elapsed, "")
|
||||
try {
|
||||
val start = SystemClock.elapsedRealtime()
|
||||
val code = conn.responseCode
|
||||
|
||||
if (code != 200) {
|
||||
throw IOException(context.getString(R.string.connection_test_error_status_code, code))
|
||||
}
|
||||
|
||||
val responseBody = conn.inputStream.bufferedReader().readText().trim()
|
||||
elapsed = SystemClock.elapsedRealtime() - start
|
||||
|
||||
result = when (code) {
|
||||
204 -> context.getString(R.string.connection_test_available, elapsed)
|
||||
200 if conn.contentLengthLong == 0L -> context.getString(R.string.connection_test_available, elapsed)
|
||||
else -> throw IOException(
|
||||
context.getString(R.string.connection_test_error_status_code, code)
|
||||
)
|
||||
|
||||
if (xyz.zarazaex.olc.util.Utils.isPureIpAddress(responseBody)) {
|
||||
result = context.getString(R.string.connection_test_available, elapsed)
|
||||
} else {
|
||||
throw IOException("Invalid IP response: $responseBody")
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Log.e(AppConfig.TAG, "Connection test IOException", e)
|
||||
|
||||
@@ -30,6 +30,8 @@ object V2RayServiceManager {
|
||||
private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback())
|
||||
private val mMsgReceive = ReceiveMessageHandler()
|
||||
private var currentConfig: ProfileItem? = null
|
||||
private val operationLock = Any()
|
||||
@Volatile private var isOperationInProgress = false
|
||||
|
||||
var serviceControl: SoftReference<ServiceControl>? = null
|
||||
set(value) {
|
||||
@@ -57,13 +59,27 @@ 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)
|
||||
synchronized(operationLock) {
|
||||
if (isOperationInProgress) {
|
||||
Log.w(AppConfig.TAG, "StartCore-Manager: Operation already in progress")
|
||||
return
|
||||
}
|
||||
isOperationInProgress = true
|
||||
}
|
||||
|
||||
startContextService(context)
|
||||
try {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
|
||||
|
||||
if (guid != null) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
}
|
||||
|
||||
startContextService(context)
|
||||
} finally {
|
||||
synchronized(operationLock) {
|
||||
isOperationInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,8 +87,26 @@ object V2RayServiceManager {
|
||||
* @param context The context from which the service is stopped.
|
||||
*/
|
||||
fun stopVService(context: Context) {
|
||||
//context.toast(R.string.toast_services_stop)
|
||||
MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
|
||||
synchronized(operationLock) {
|
||||
if (isOperationInProgress) {
|
||||
Log.w(AppConfig.TAG, "StartCore-Manager: Operation already in progress")
|
||||
return
|
||||
}
|
||||
isOperationInProgress = true
|
||||
}
|
||||
|
||||
try {
|
||||
if (serviceControl?.get() == null) {
|
||||
Log.w(AppConfig.TAG, "StartCore-Manager: Service not running, resetting UI state")
|
||||
MessageUtil.sendMsg2UI(context, AppConfig.MSG_STATE_STOP_SUCCESS, "")
|
||||
return
|
||||
}
|
||||
MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
|
||||
} finally {
|
||||
synchronized(operationLock) {
|
||||
isOperationInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -385,11 +419,17 @@ object V2RayServiceManager {
|
||||
|
||||
AppConfig.MSG_STATE_STOP -> {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Stop service")
|
||||
synchronized(operationLock) {
|
||||
isOperationInProgress = false
|
||||
}
|
||||
serviceControl.stopService()
|
||||
}
|
||||
|
||||
AppConfig.MSG_STATE_RESTART -> {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||
synchronized(operationLock) {
|
||||
isOperationInProgress = false
|
||||
}
|
||||
serviceControl.stopService()
|
||||
Thread.sleep(500L)
|
||||
startVService(serviceControl.getService())
|
||||
|
||||
@@ -1311,7 +1311,7 @@ object V2rayConfigManager {
|
||||
if (start != null && end != null) {
|
||||
val minStart = maxOf(5, start)
|
||||
val minEnd = maxOf(minStart, end)
|
||||
"$minStart-$minEnd"
|
||||
"$minStart-$minEnd"
|
||||
} else {
|
||||
"30"
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@@ -75,13 +76,51 @@ class RealPingWorkerService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRealPing(guid: String): Long {
|
||||
private suspend fun startRealPing(guid: String): Long {
|
||||
val retFailure = -1L
|
||||
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
|
||||
if (!configResult.status) {
|
||||
return retFailure
|
||||
}
|
||||
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
|
||||
var bestDelay = retFailure
|
||||
|
||||
for (attempt in 0 until 2) {
|
||||
try {
|
||||
val delay = withTimeout(10000L) {
|
||||
V2RayNativeManager.measureOutboundDelay(
|
||||
configResult.content,
|
||||
SettingsManager.getDelayTestUrl()
|
||||
)
|
||||
}
|
||||
|
||||
if (delay > 0 && (bestDelay == retFailure || delay < bestDelay)) {
|
||||
bestDelay = delay
|
||||
}
|
||||
|
||||
if (bestDelay > 0) {
|
||||
break
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (attempt == 0) {
|
||||
try {
|
||||
val delay = withTimeout(10000L) {
|
||||
V2RayNativeManager.measureOutboundDelay(
|
||||
configResult.content,
|
||||
SettingsManager.getDelayTestUrl(true)
|
||||
)
|
||||
}
|
||||
|
||||
if (delay > 0 && (bestDelay == retFailure || delay < bestDelay)) {
|
||||
bestDelay = delay
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestDelay
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import xyz.zarazaex.olc.handler.MmkvManager
|
||||
import xyz.zarazaex.olc.handler.NotificationManager
|
||||
import xyz.zarazaex.olc.handler.SettingsManager
|
||||
import xyz.zarazaex.olc.handler.V2RayServiceManager
|
||||
import xyz.zarazaex.olc.util.MessageUtil
|
||||
import xyz.zarazaex.olc.util.MyContextWrapper
|
||||
import xyz.zarazaex.olc.util.Utils
|
||||
import java.lang.ref.SoftReference
|
||||
@@ -94,6 +95,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
super.onDestroy()
|
||||
Log.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
|
||||
NotificationManager.cancelNotification()
|
||||
MessageUtil.sendMsg2UI(this, AppConfig.MSG_STATE_STOP_SUCCESS, "")
|
||||
}
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
@@ -228,13 +230,13 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
}
|
||||
}
|
||||
|
||||
// Configure DNS servers
|
||||
//if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
|
||||
// builder.addDnsServer(PRIVATE_VLAN4_ROUTER)
|
||||
//} else {
|
||||
SettingsManager.getVpnDnsServers().forEach {
|
||||
if (Utils.isPureIpAddress(it)) {
|
||||
builder.addDnsServer(it)
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_LOCAL_DNS_ENABLED) == true) {
|
||||
builder.addDnsServer(vpnConfig.ipv4Router)
|
||||
} else {
|
||||
SettingsManager.getVpnDnsServers().forEach {
|
||||
if (Utils.isPureIpAddress(it)) {
|
||||
builder.addDnsServer(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,9 +42,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelectedListener {
|
||||
private val binding by lazy {
|
||||
ActivityMainBinding.inflate(layoutInflater)
|
||||
}
|
||||
private val binding by lazy {ActivityMainBinding.inflate(layoutInflater)}
|
||||
private var isLiteTesting = false
|
||||
private var easterEggClickCount = 0
|
||||
private var isEasterEggActive = false
|
||||
@@ -52,6 +50,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
val mainViewModel: MainViewModel by viewModels()
|
||||
private lateinit var groupPagerAdapter: GroupPagerAdapter
|
||||
private var tabMediator: TabLayoutMediator? = null
|
||||
@Volatile private var isFabOperationInProgress = false
|
||||
|
||||
private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (it.resultCode == RESULT_OK) {
|
||||
@@ -201,12 +200,24 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
}
|
||||
|
||||
private fun handleFabAction() {
|
||||
if (isFabOperationInProgress) {
|
||||
return
|
||||
}
|
||||
isFabOperationInProgress = true
|
||||
|
||||
applyRunningState(isLoading = true, isRunning = false)
|
||||
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
V2RayServiceManager.stopVService(this)
|
||||
} else {
|
||||
startV2RayWithPermission()
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
V2RayServiceManager.stopVService(this@MainActivity)
|
||||
} else {
|
||||
startV2RayWithPermission()
|
||||
}
|
||||
} finally {
|
||||
delay(1000)
|
||||
isFabOperationInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,29 +231,42 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
}
|
||||
|
||||
private fun handleLiteAction() {
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
V2RayServiceManager.stopVService(this)
|
||||
if (isFabOperationInProgress) {
|
||||
return
|
||||
}
|
||||
|
||||
showStatus("Обновление профилей...")
|
||||
showLoading()
|
||||
isLiteTesting = true
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = mainViewModel.updateConfigViaSubAll()
|
||||
delay(500L)
|
||||
launch(Dispatchers.Main) {
|
||||
if (result.configCount > 0) {
|
||||
mainViewModel.reloadServerList()
|
||||
showStatus("Обновлено ${result.configCount} профилей. Запуск теста...")
|
||||
} else {
|
||||
showStatus("Запуск теста...")
|
||||
isFabOperationInProgress = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
V2RayServiceManager.stopVService(this@MainActivity)
|
||||
delay(1000)
|
||||
}
|
||||
hideLoading()
|
||||
|
||||
delay(500L)
|
||||
showStatus("Выполняется замер задержки. Ожидаем завершения...")
|
||||
mainViewModel.testAllRealPing()
|
||||
|
||||
showStatus("Обновление профилей...")
|
||||
showLoading()
|
||||
isLiteTesting = true
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
val result = mainViewModel.updateConfigViaSubAll()
|
||||
delay(500L)
|
||||
launch(Dispatchers.Main) {
|
||||
if (result.configCount > 0) {
|
||||
mainViewModel.reloadServerList()
|
||||
showStatus("Обновлено ${result.configCount} профилей. Запуск теста...")
|
||||
} else {
|
||||
showStatus("Запуск теста...")
|
||||
}
|
||||
hideLoading()
|
||||
|
||||
delay(500L)
|
||||
showStatus("Выполняется замер задержки. Ожидаем завершения...")
|
||||
mainViewModel.testAllRealPing()
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
delay(1000)
|
||||
isFabOperationInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -269,12 +293,22 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
}
|
||||
|
||||
fun restartV2Ray() {
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
V2RayServiceManager.stopVService(this)
|
||||
if (isFabOperationInProgress) {
|
||||
return
|
||||
}
|
||||
isFabOperationInProgress = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
delay(500)
|
||||
startV2Ray()
|
||||
try {
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
V2RayServiceManager.stopVService(this@MainActivity)
|
||||
}
|
||||
delay(1000)
|
||||
startV2Ray()
|
||||
} finally {
|
||||
delay(500)
|
||||
isFabOperationInProgress = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,6 @@ class SubSettingActivity : BaseActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
|
||||
|
||||
adapter = SubSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
|
||||
@@ -53,6 +52,14 @@ class SubSettingActivity : BaseActivity() {
|
||||
|
||||
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
|
||||
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
||||
|
||||
viewModel.isUpdating.observe(this) { isUpdating ->
|
||||
adapter.setUpdating(isUpdating)
|
||||
}
|
||||
|
||||
viewModel.subscriptionStatuses.observe(this) { statuses ->
|
||||
adapter.notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
@@ -62,44 +69,105 @@ class SubSettingActivity : BaseActivity() {
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.action_sub_setting, menu)
|
||||
viewModel.isUpdating.observe(this) { isUpdating ->
|
||||
menu.findItem(R.id.sub_update)?.isEnabled = !isUpdating
|
||||
menu.findItem(R.id.add_config)?.isEnabled = !isUpdating
|
||||
}
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.add_config -> {
|
||||
startActivity(Intent(this, SubEditActivity::class.java))
|
||||
true
|
||||
}
|
||||
override fun onOptionsItemSelected(item: MenuItem): Boolean {
|
||||
return when (item.itemId) {
|
||||
R.id.add_config -> {
|
||||
startActivity(Intent(this, SubEditActivity::class.java))
|
||||
true
|
||||
}
|
||||
|
||||
R.id.sub_update -> {
|
||||
showLoading()
|
||||
R.id.sub_update -> {
|
||||
if (viewModel.isUpdating.value == true) {
|
||||
return true
|
||||
}
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = AngConfigManager.updateConfigViaSubAll()
|
||||
delay(500L)
|
||||
launch(Dispatchers.Main) {
|
||||
if (result.successCount + result.failureCount + result.skipCount == 0) {
|
||||
showLoading()
|
||||
viewModel.isUpdating.value = true
|
||||
viewModel.subscriptionStatuses.value = emptyMap()
|
||||
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
val subscriptions = viewModel.getAll()
|
||||
var totalConfigCount = 0
|
||||
var successCount = 0
|
||||
var failureCount = 0
|
||||
var skipCount = 0
|
||||
|
||||
val jobs = subscriptions.map { subscription ->
|
||||
launch(Dispatchers.IO) {
|
||||
val subId = subscription.guid
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
viewModel.updateSubscriptionStatus(subId, xyz.zarazaex.olc.dto.SubscriptionUpdateStatus.LOADING)
|
||||
}
|
||||
|
||||
val result = AngConfigManager.updateConfigViaSub(subscription)
|
||||
|
||||
launch(Dispatchers.Main) {
|
||||
when {
|
||||
result.successCount > 0 -> {
|
||||
viewModel.updateSubscriptionStatus(
|
||||
subId,
|
||||
xyz.zarazaex.olc.dto.SubscriptionUpdateStatus.SUCCESS,
|
||||
result.configCount
|
||||
)
|
||||
}
|
||||
result.skipCount > 0 -> {
|
||||
viewModel.updateSubscriptionStatus(
|
||||
subId,
|
||||
xyz.zarazaex.olc.dto.SubscriptionUpdateStatus.SKIPPED
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
viewModel.updateSubscriptionStatus(
|
||||
subId,
|
||||
xyz.zarazaex.olc.dto.SubscriptionUpdateStatus.FAILED
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
synchronized(this@SubSettingActivity) {
|
||||
totalConfigCount += result.configCount
|
||||
successCount += result.successCount
|
||||
failureCount += result.failureCount
|
||||
skipCount += result.skipCount
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
jobs.forEach { it.join() }
|
||||
|
||||
delay(500L)
|
||||
viewModel.isUpdating.value = false
|
||||
|
||||
if (successCount + failureCount + skipCount == 0) {
|
||||
toast(R.string.title_update_subscription_no_subscription)
|
||||
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
|
||||
toast(getString(R.string.title_update_config_count, result.configCount))
|
||||
} else if (successCount > 0 && failureCount + skipCount == 0) {
|
||||
toast(getString(R.string.title_update_config_count, totalConfigCount))
|
||||
} else {
|
||||
toast(
|
||||
getString(
|
||||
R.string.title_update_subscription_result,
|
||||
result.configCount, result.successCount, result.failureCount, result.skipCount
|
||||
totalConfigCount, successCount, failureCount, skipCount
|
||||
)
|
||||
)
|
||||
}
|
||||
hideLoading()
|
||||
refreshData()
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
true
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
|
||||
}
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
|
||||
@@ -8,8 +8,10 @@ import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import xyz.zarazaex.olc.AppConfig
|
||||
import xyz.zarazaex.olc.R
|
||||
import xyz.zarazaex.olc.contracts.BaseAdapterListener
|
||||
import xyz.zarazaex.olc.databinding.ItemRecyclerSubSettingBinding
|
||||
import xyz.zarazaex.olc.dto.SubscriptionUpdateStatus
|
||||
import xyz.zarazaex.olc.helper.ItemTouchHelperAdapter
|
||||
import xyz.zarazaex.olc.helper.ItemTouchHelperViewHolder
|
||||
import xyz.zarazaex.olc.util.Utils
|
||||
@@ -20,6 +22,15 @@ class SubSettingRecyclerAdapter(
|
||||
private val adapterListener: BaseAdapterListener?
|
||||
) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter {
|
||||
|
||||
private var isUpdating = false
|
||||
|
||||
fun setUpdating(updating: Boolean) {
|
||||
if (isUpdating != updating) {
|
||||
isUpdating = updating
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = viewModel.getAll().size
|
||||
|
||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||
@@ -32,16 +43,60 @@ class SubSettingRecyclerAdapter(
|
||||
holder.itemSubSettingBinding.tvLastUpdated.text = Utils.formatTimestamp(subItem.lastUpdated)
|
||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
val subStatus = viewModel.getSubscriptionStatus(subId)
|
||||
when (subStatus?.status) {
|
||||
SubscriptionUpdateStatus.LOADING -> {
|
||||
holder.itemSubSettingBinding.progressBar.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.text = holder.itemView.context.getString(R.string.title_updating)
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.setTextColor(Color.GRAY)
|
||||
}
|
||||
SubscriptionUpdateStatus.SUCCESS -> {
|
||||
holder.itemSubSettingBinding.progressBar.visibility = View.GONE
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.text = "✓ ${subStatus.configCount}"
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.setTextColor(Color.parseColor("#4CAF50"))
|
||||
}
|
||||
SubscriptionUpdateStatus.FAILED -> {
|
||||
holder.itemSubSettingBinding.progressBar.visibility = View.GONE
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.text = "✗"
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.setTextColor(Color.parseColor("#F44336"))
|
||||
}
|
||||
SubscriptionUpdateStatus.SKIPPED -> {
|
||||
holder.itemSubSettingBinding.progressBar.visibility = View.GONE
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.text = "—"
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.setTextColor(Color.GRAY)
|
||||
}
|
||||
else -> {
|
||||
holder.itemSubSettingBinding.progressBar.visibility = View.GONE
|
||||
holder.itemSubSettingBinding.tvUpdateStatus.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
val isEnabled = !isUpdating
|
||||
|
||||
holder.itemSubSettingBinding.layoutEdit.isClickable = isEnabled
|
||||
holder.itemSubSettingBinding.layoutEdit.alpha = if (isEnabled) 1.0f else 0.5f
|
||||
holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
|
||||
adapterListener?.onEdit(subId, position)
|
||||
if (isEnabled) {
|
||||
adapterListener?.onEdit(subId, position)
|
||||
}
|
||||
}
|
||||
|
||||
holder.itemSubSettingBinding.layoutRemove.isClickable = isEnabled
|
||||
holder.itemSubSettingBinding.layoutRemove.alpha = if (isEnabled) 1.0f else 0.5f
|
||||
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
|
||||
adapterListener?.onRemove(subId, position)
|
||||
if (isEnabled) {
|
||||
adapterListener?.onRemove(subId, position)
|
||||
}
|
||||
}
|
||||
|
||||
holder.itemSubSettingBinding.chkEnable.isEnabled = isEnabled
|
||||
holder.itemSubSettingBinding.chkEnable.alpha = if (isEnabled) 1.0f else 0.5f
|
||||
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
|
||||
if (!it.isPressed) return@setOnCheckedChangeListener
|
||||
if (!it.isPressed || !isEnabled) return@setOnCheckedChangeListener
|
||||
subItem.enabled = isChecked
|
||||
viewModel.update(subId, subItem)
|
||||
}
|
||||
@@ -56,8 +111,12 @@ class SubSettingRecyclerAdapter(
|
||||
holder.itemSubSettingBinding.layoutShare.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.layoutShare.isClickable = isEnabled
|
||||
holder.itemSubSettingBinding.layoutShare.alpha = if (isEnabled) 1.0f else 0.5f
|
||||
holder.itemSubSettingBinding.layoutShare.setOnClickListener {
|
||||
adapterListener?.onShare(subItem.url)
|
||||
if (isEnabled) {
|
||||
adapterListener?.onShare(subItem.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,6 +266,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
updateListAction.value = -1
|
||||
|
||||
viewModelScope.launch(Dispatchers.Default) {
|
||||
if (serversCache.isEmpty()) {
|
||||
withContext(Dispatchers.Main) { reloadServerList() }
|
||||
}
|
||||
if (serversCache.isEmpty()) {
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package xyz.zarazaex.olc.viewmodel
|
||||
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import xyz.zarazaex.olc.dto.SubscriptionCache
|
||||
import xyz.zarazaex.olc.dto.SubscriptionItem
|
||||
import xyz.zarazaex.olc.dto.SubscriptionStatus
|
||||
import xyz.zarazaex.olc.dto.SubscriptionUpdateStatus
|
||||
import xyz.zarazaex.olc.handler.MmkvManager
|
||||
import xyz.zarazaex.olc.handler.SettingsChangeManager
|
||||
import xyz.zarazaex.olc.handler.SettingsManager
|
||||
@@ -11,11 +14,25 @@ class SubscriptionsViewModel : ViewModel() {
|
||||
private val subscriptions: MutableList<SubscriptionCache> =
|
||||
MmkvManager.decodeSubscriptions().toMutableList()
|
||||
|
||||
val isUpdating = MutableLiveData<Boolean>(false)
|
||||
val subscriptionStatuses = MutableLiveData<Map<String, SubscriptionStatus>>(emptyMap())
|
||||
|
||||
fun getAll(): List<SubscriptionCache> = subscriptions.toList()
|
||||
|
||||
fun reload() {
|
||||
subscriptions.clear()
|
||||
subscriptions.addAll(MmkvManager.decodeSubscriptions())
|
||||
subscriptionStatuses.value = emptyMap()
|
||||
}
|
||||
|
||||
fun updateSubscriptionStatus(guid: String, status: SubscriptionUpdateStatus, configCount: Int = 0) {
|
||||
val currentStatuses = subscriptionStatuses.value?.toMutableMap() ?: mutableMapOf()
|
||||
currentStatuses[guid] = SubscriptionStatus(guid, status, configCount)
|
||||
subscriptionStatuses.postValue(currentStatuses)
|
||||
}
|
||||
|
||||
fun getSubscriptionStatus(guid: String): SubscriptionStatus? {
|
||||
return subscriptionStatuses.value?.get(guid)
|
||||
}
|
||||
|
||||
fun remove(subId: String): Boolean {
|
||||
|
||||
@@ -168,9 +168,26 @@
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_last_updated"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Caption"/>
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_bar"
|
||||
style="?android:attr/progressBarStyleSmall"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="@dimen/padding_spacing_dp8"
|
||||
android:visibility="gone"/>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_update_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Caption"/>
|
||||
android:layout_marginStart="@dimen/padding_spacing_dp8"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
|
||||
android:visibility="gone"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
@@ -417,4 +417,5 @@
|
||||
<item>WebDAV</item>
|
||||
</string-array>
|
||||
|
||||
<string name="title_updating">جارٍ التحديث…</string>
|
||||
</resources>
|
||||
|
||||
@@ -423,4 +423,5 @@
|
||||
<item>WebDAV</item>
|
||||
</string-array>
|
||||
|
||||
<string name="title_updating">আপডেট হচ্ছে…</string>
|
||||
</resources>
|
||||
@@ -433,4 +433,5 @@
|
||||
<item>WebDAV</item>
|
||||
</string-array>
|
||||
|
||||
<string name="title_updating">در حال بهروزرسانی…</string>
|
||||
</resources>
|
||||
|
||||
@@ -432,4 +432,5 @@
|
||||
<item>WebDAV</item>
|
||||
</string-array>
|
||||
|
||||
<string name="title_updating">در حال بهروزرسانی…</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="color_fab_active">#88AC8A</color>
|
||||
<color name="color_fab_active">#90CAF9</color>
|
||||
<color name="color_fab_inactive">#646464</color>
|
||||
<color name="divider_color_light">#424242</color>
|
||||
|
||||
<!-- Primary colors - main tone: gray -->
|
||||
<color name="md_theme_primary">#C0C0C0</color>
|
||||
<color name="md_theme_onPrimary">#303030</color>
|
||||
<color name="md_theme_primaryContainer">#474747</color>
|
||||
<color name="md_theme_onPrimaryContainer">#E0E0E0</color>
|
||||
|
||||
<!-- Secondary colors - accent color: green -->
|
||||
<color name="md_theme_secondary">#88AC8A</color>
|
||||
<color name="md_theme_secondary">#90CAF9</color>
|
||||
<color name="md_theme_onSecondary">#FFFFFF</color>
|
||||
<color name="md_theme_secondaryContainer">#6F3800</color>
|
||||
<color name="md_theme_onSecondaryContainer">#FFE8D6</color>
|
||||
|
||||
<!-- Tertiary colors - tertiary color: green -->
|
||||
<color name="md_theme_tertiary">#83D6B5</color>
|
||||
<color name="md_theme_tertiary">#64B5F6</color>
|
||||
<color name="md_theme_onTertiary">#00382E</color>
|
||||
<color name="md_theme_tertiaryContainer">#005143</color>
|
||||
<color name="md_theme_onTertiaryContainer">#A0F2D0</color>
|
||||
<color name="md_theme_onTertiaryContainer">#BBDEFB</color>
|
||||
|
||||
<!-- Error colors -->
|
||||
<color name="md_theme_error">#FFB4AB</color>
|
||||
|
||||
@@ -298,6 +298,7 @@
|
||||
<string name="title_import_config_count">Импортировано профилей: %d</string>
|
||||
<string name="title_export_config_count">Экспортировано профилей: %d</string>
|
||||
<string name="title_update_config_count">Обновлено профилей: %d</string>
|
||||
<string name="title_updating">Обновление…</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>
|
||||
|
||||
@@ -419,4 +419,5 @@
|
||||
<item>WebDAV</item>
|
||||
</string-array>
|
||||
|
||||
<string name="title_updating">Đang cập nhật…</string>
|
||||
</resources>
|
||||
|
||||
@@ -425,4 +425,5 @@
|
||||
<item>WebDAV</item>
|
||||
</string-array>
|
||||
|
||||
<string name="title_updating">更新中…</string>
|
||||
</resources>
|
||||
|
||||
@@ -425,4 +425,5 @@
|
||||
<item>WebDAV</item>
|
||||
</string-array>
|
||||
|
||||
<string name="title_updating">更新中…</string>
|
||||
</resources>
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPing">#009966</color>
|
||||
<color name="colorPing">#1565C0</color>
|
||||
<color name="colorPingRed">#FF0099</color>
|
||||
<color name="colorConfigType">#88AC8A</color>
|
||||
<color name="colorConfigType">#1976D2</color>
|
||||
<color name="colorWhite">#FFFFFF</color>
|
||||
<color name="color_fab_active">#88AC8A</color>
|
||||
<color name="color_fab_active">#1976D2</color>
|
||||
<color name="color_fab_inactive">#9C9C9C</color>
|
||||
<color name="divider_color_light">#E0E0E0</color>
|
||||
<color name="colorIndicator">@color/md_theme_primary</color>
|
||||
|
||||
<!-- Primary colors - main tone: black -->
|
||||
<color name="md_theme_primary">#000000</color>
|
||||
<color name="md_theme_onPrimary">#FFFFFF</color>
|
||||
<color name="md_theme_primaryContainer">#E0E0E0</color>
|
||||
<color name="md_theme_onPrimaryContainer">#000000</color>
|
||||
|
||||
<!-- Secondary colors - accent color: green -->
|
||||
<color name="md_theme_secondary">#88AC8A</color>
|
||||
<color name="md_theme_secondary">#1976D2</color>
|
||||
<color name="md_theme_onSecondary">#FFFFFF</color>
|
||||
<color name="md_theme_secondaryContainer">#FFE8D6</color>
|
||||
<color name="md_theme_onSecondaryContainer">#2B1700</color>
|
||||
|
||||
<!-- Tertiary colors - accent color: green -->
|
||||
<color name="md_theme_tertiary">#009966</color>
|
||||
<color name="md_theme_tertiary">#1565C0</color>
|
||||
<color name="md_theme_onTertiary">#FFFFFF</color>
|
||||
<color name="md_theme_tertiaryContainer">#A0F2D0</color>
|
||||
<color name="md_theme_tertiaryContainer">#BBDEFB</color>
|
||||
<color name="md_theme_onTertiaryContainer">#00201A</color>
|
||||
|
||||
<!-- Error colors -->
|
||||
|
||||
@@ -7,8 +7,8 @@
|
||||
<string name="navigation_drawer_open">Open navigation drawer</string>
|
||||
<string name="navigation_drawer_close">Close navigation drawer</string>
|
||||
<string name="migration_success">Data migration success!</string>
|
||||
<string name="drawer_forked_text">forked from <a href="https://github.com/2dust/v2rayng">V2RayNG</a></string>
|
||||
<string name="drawer_developed_text">developed by developers from <a href="https://t.me/openlibrecommunity">Olc</a></string>
|
||||
<string name="drawer_forked_text" translatable="false">forked from <a href="https://github.com/2dust/v2rayng">V2RayNG</a></string>
|
||||
<string name="drawer_developed_text" translatable="false">developed by developers from <a href="https://t.me/openlibrecommunity">Olc</a></string>
|
||||
<string name="action_stop_service">Stop service</string>
|
||||
<string name="migration_fail">Data migration failed!</string>
|
||||
<string name="pull_down_to_refresh">Please pull down to refresh!</string>
|
||||
@@ -249,7 +249,7 @@
|
||||
<string name="summary_pref_tg_group">Join Telegram Group</string>
|
||||
<string name="toast_tg_app_not_found">Telegram app not found</string>
|
||||
<string name="title_privacy_policy">Privacy policy</string>
|
||||
<string name="title_qr_code">QR code</string>
|
||||
<string name="title_qr_code" translatable="false">QR code</string>
|
||||
<string name="title_about">About</string>
|
||||
<string name="title_source_code">Source code</string>
|
||||
<string name="title_oss_license">Open Source licenses</string>
|
||||
@@ -304,6 +304,7 @@
|
||||
<string name="title_import_config_count">Import %d configs</string>
|
||||
<string name="title_export_config_count">Export %d configs</string>
|
||||
<string name="title_update_config_count">Update %d configs</string>
|
||||
<string name="title_updating">Updating…</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>
|
||||
|
||||
@@ -8,13 +8,11 @@
|
||||
<item name="colorPrimaryContainer">@color/md_theme_primaryContainer</item>
|
||||
<item name="colorOnPrimaryContainer">@color/md_theme_onPrimaryContainer</item>
|
||||
|
||||
<!-- Secondary colors - accent color: orange -->
|
||||
<item name="colorSecondary">@color/md_theme_secondary</item>
|
||||
<item name="colorOnSecondary">@color/md_theme_onSecondary</item>
|
||||
<item name="colorSecondaryContainer">@color/md_theme_secondaryContainer</item>
|
||||
<item name="colorOnSecondaryContainer">@color/md_theme_onSecondaryContainer</item>
|
||||
|
||||
<!-- Tertiary colors - tertiary color: green -->
|
||||
<item name="colorTertiary">@color/md_theme_tertiary</item>
|
||||
<item name="colorOnTertiary">@color/md_theme_onTertiary</item>
|
||||
<item name="colorTertiaryContainer">@color/md_theme_tertiaryContainer</item>
|
||||
|
||||
@@ -2,12 +2,4 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.android.library) apply false
|
||||
alias(libs.plugins.kotlin.android) apply false
|
||||
}
|
||||
|
||||
buildscript {
|
||||
dependencies {
|
||||
classpath(libs.gradle.license.plugin)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -15,20 +15,7 @@ org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
|
||||
# Android operating system, and which are packaged with your app's APK
|
||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||
android.useAndroidX=true
|
||||
# Kotlin code style for this project: "official" or "obsolete":
|
||||
kotlin.code.style=official
|
||||
# Enables namespacing of each library's R class so that its R class includes only the
|
||||
# resources declared in the library itself and none from the library's dependencies,
|
||||
# thereby reducing the size of the R class for that library
|
||||
android.nonTransitiveRClass=true
|
||||
kotlin.incremental=true
|
||||
android.defaults.buildfeatures.resvalues=true
|
||||
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
|
||||
android.enableAppCompileTimeRClass=false
|
||||
android.usesSdkInManifest.disallowed=false
|
||||
android.uniquePackageNames=false
|
||||
android.dependency.useConstraints=true
|
||||
android.r8.strictFullModeForKeepRules=false
|
||||
android.r8.optimizedResourceShrinking=false
|
||||
android.builtInKotlin=false
|
||||
android.newDsl=false
|
||||
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
|
||||
@@ -2,7 +2,7 @@
|
||||
agp = "9.1.0"
|
||||
desugarJdkLibs = "2.1.5"
|
||||
gradleLicensePlugin = "0.9.8"
|
||||
kotlin = "2.3.10"
|
||||
kotlin = "2.1.0"
|
||||
coreKtx = "1.17.0"
|
||||
junit = "4.13.2"
|
||||
junitVersion = "1.3.0"
|
||||
|
||||
@@ -136,9 +136,17 @@ fun main() {
|
||||
)
|
||||
println("Добавлена подписка БЕЛЫЕ W: $guid2")
|
||||
|
||||
val guid3 = manager.addSubscription(
|
||||
remarks = "KEY",
|
||||
url = "https://key.zarazaex.xyz/sub",
|
||||
autoUpdate = true
|
||||
)
|
||||
println("Добавлена подписка KEY: $guid3")
|
||||
|
||||
println("\nОбновление подписок...")
|
||||
manager.updateSubscription(guid1, "https://raw.githubusercontent.com/zieng2/wl/refs/heads/main/vless_universal.txt")
|
||||
manager.updateSubscription(guid2, "https://raw.githubusercontent.com/whoahaow/rjsxrd/refs/heads/main/githubmirror/bypass/bypass-all.txt")
|
||||
manager.updateSubscription(guid3, "https://key.zarazaex.xyz/sub")
|
||||
|
||||
println("\nПодписки успешно добавлены и обновлены в $mmkvPath")
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
ТЫ
|
||||
Reference in New Issue
Block a user