16 Commits

Author SHA1 Message Date
zarazaex69 cb4e3aab54 fix(viewmodel): reload server list when cache is empty before update 2026-04-11 13:05:50 +03:00
zarazaex69 732d7248af fix(service): handle missing service reference and notify UI on shutdown 2026-04-11 13:03:00 +03:00
zarazaex69 0c32ddc642 fix(config): add subscription-level synchronization locks 2026-04-11 12:35:33 +03:00
zarazaex69 153c545400 feat(subscription): add per-subscription update status tracking 2026-04-11 12:29:14 +03:00
zarazaex69 9af6472f8b feat(subscription): add update state management and UI feedback 2026-04-11 12:21:13 +03:00
zarazaex69 346be42e37 fix(config): preserve ping data when updating subscriptions 2026-04-11 12:12:20 +03:00
zarazaex69 dd9e076ab1 chore(build): update APK binary 2026-04-11 02:22:10 +03:00
zarazaex69 903b0d4414 style(colors): replace hardcoded colors with system accent references 2026-04-11 01:38:34 +03:00
zarazaex69 f58a4f8f6f style: fix indentation and mark non-translatable strings 2026-04-11 00:37:14 +03:00
zarazaex69 fdce3ea2c1 fix(service): prevent concurrent operations with synchronization locks 2026-04-11 00:31:28 +03:00
zarazaex69 f0d620676d chore(gradle): downgrade Kotlin version to 2.1.0 2026-04-10 18:41:54 +03:00
zarazaex69 b92a6cdfac fix(speedtest): improve connection test with IP validation 2026-04-10 18:36:36 +03:00
zarazaex69 45da2479dd fix(v2ray): remove unused VPN DNS server retrieval 2026-04-10 18:14:58 +03:00
zarazaex69 1c936b2b31 fix(v2ray): simplify DNS server configuration logic 2026-04-10 17:50:56 +03:00
zarazaex69 dbe109eedb chore(mmkv): update database files and checksums 2026-04-10 17:41:59 +03:00
zarazaex69 7705aded77 fix(settings): validate VPN DNS servers with IP address check 2026-04-10 17:28:56 +03:00
30 changed files with 504 additions and 202 deletions
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.
@@ -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,15 +284,23 @@ 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>()
val serverList = MmkvManager.decodeServerList(subid)
val serverList = if (append) {
MmkvManager.decodeServerList(subid)
} else {
mutableListOf()
}
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
val existingProfiles = serverList.mapNotNull { guid ->
MmkvManager.decodeServerConfig(guid)?.let { guid to it }
}.toMap()
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) ->
@@ -286,6 +308,7 @@ object AngConfigManager {
}?.key
if (existingKey != null) {
MmkvManager.encodeProfileDirect(existingKey, JsonUtil.toJson(config))
keyToProfile[existingKey] = config
} else {
val key = Utils.getUuid()
@@ -306,6 +329,35 @@ object AngConfigManager {
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):
@@ -370,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
}
}
@@ -542,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 { it.isNotBlank() }
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())
@@ -594,7 +594,6 @@ object V2rayConfigManager {
val remoteDns = SettingsManager.getRemoteDnsServers()
val domesticDns = SettingsManager.getDomesticDnsServers()
val vpnDns = if (SettingsManager.isVpnMode()) SettingsManager.getVpnDnsServers() else emptyList()
val proxyDomain = getUserRule2Domain(AppConfig.TAG_PROXY)
val directDomain = getUserRule2Domain(AppConfig.TAG_DIRECT)
val isCnRoutingMode = directDomain.contains(AppConfig.GEOSITE_CN)
@@ -621,14 +620,8 @@ object V2rayConfigManager {
)
}
if (vpnDns.isNotEmpty()) {
vpnDns.forEach {
servers.add(it)
}
} else {
remoteDns.forEach {
servers.add(it)
}
remoteDns.forEach {
servers.add(it)
}
val blkDomain = getUserRule2Domain(AppConfig.TAG_BLOCKED)
@@ -1318,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"
}
@@ -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 {
@@ -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>
@@ -1,26 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="color_fab_active">#88AC8A</color>
<color name="color_fab_active">@android:color/system_accent1_400</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">@android:color/system_accent1_400</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">@android:color/system_accent1_300</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">@android:color/system_accent1_200</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>
+6 -9
View File
@@ -1,30 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPing">#009966</color>
<color name="colorPing">@android:color/system_accent1_600</color>
<color name="colorPingRed">#FF0099</color>
<color name="colorConfigType">#88AC8A</color>
<color name="colorConfigType">@android:color/system_accent1_400</color>
<color name="colorWhite">#FFFFFF</color>
<color name="color_fab_active">#88AC8A</color>
<color name="color_fab_active">@android:color/system_accent1_400</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">@android:color/system_accent1_400</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">@android:color/system_accent1_600</color>
<color name="md_theme_onTertiary">#FFFFFF</color>
<color name="md_theme_tertiaryContainer">#A0F2D0</color>
<color name="md_theme_tertiaryContainer">@android:color/system_accent1_200</color>
<color name="md_theme_onTertiaryContainer">#00201A</color>
<!-- Error colors -->
+4 -3
View File
@@ -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>
+1 -1
View File
@@ -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"
BIN
View File
Binary file not shown.