12 Commits

19 changed files with 501 additions and 192 deletions
@@ -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 { 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. * Shares the configuration to the clipboard.
@@ -217,11 +225,11 @@ object AngConfigManager {
* @return The number of configurations parsed. * @return The number of configurations parsed.
*/ */
private fun parseBatchConfig(servers: String?, subid: String, append: Boolean): Int { private fun parseBatchConfig(servers: String?, subid: String, append: Boolean): Int {
return synchronized(getSubscriptionLock(subid)) {
try { try {
if (servers == null) { if (servers == null) {
return 0 return@synchronized 0
} }
// Find the currently selected server that matches the subscription ID
val removedSelected = if (subid.isNotBlank() && !append) { val removedSelected = if (subid.isNotBlank() && !append) {
MmkvManager.getSelectServer() MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true } .takeIf { it?.isNotBlank() == true }
@@ -233,7 +241,12 @@ object AngConfigManager {
val subItem = MmkvManager.decodeSubscription(subid) val subItem = MmkvManager.decodeSubscription(subid)
// Parse all configs first (no I/O during parsing) val oldPingData = if (!append) {
saveOldPingData(subid)
} else {
emptyMap()
}
val configs = mutableListOf<ProfileItem>() val configs = mutableListOf<ProfileItem>()
servers.lines() servers.lines()
.distinct() .distinct()
@@ -245,21 +258,22 @@ object AngConfigManager {
} }
} }
// Batch save all parsed configs (only one serverList read/write)
if (configs.isNotEmpty()) { if (configs.isNotEmpty()) {
if (!append) { if (!append) {
MmkvManager.removeServerViaSubid(subid) MmkvManager.removeServerViaSubid(subid)
} }
val keyToProfile = batchSaveConfigs(configs, subid) val keyToProfile = batchSaveConfigs(configs, subid, append)
restoreOldPingData(keyToProfile, oldPingData)
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected) val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) } matchKey?.let { MmkvManager.setSelectServer(it) }
} }
return configs.size return@synchronized configs.size
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse batch config", e) Log.e(AppConfig.TAG, "Failed to parse batch config", e)
} }
return 0 return@synchronized 0
}
} }
/** /**
@@ -270,15 +284,23 @@ object AngConfigManager {
* @param subid The subscription ID. * @param subid The subscription ID.
* @return Map of generated keys to their corresponding ProfileItem. * @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 keyToProfile = mutableMapOf<String, ProfileItem>()
val serverList = MmkvManager.decodeServerList(subid) val serverList = if (append) {
MmkvManager.decodeServerList(subid)
} else {
mutableListOf()
}
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank() var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
val existingProfiles = serverList.mapNotNull { guid -> val existingProfiles = if (append) {
serverList.mapNotNull { guid ->
MmkvManager.decodeServerConfig(guid)?.let { guid to it } MmkvManager.decodeServerConfig(guid)?.let { guid to it }
}.toMap() }.toMap()
} else {
emptyMap()
}
configs.forEach { config -> configs.forEach { config ->
val existingKey = existingProfiles.entries.firstOrNull { (_, existing) -> val existingKey = existingProfiles.entries.firstOrNull { (_, existing) ->
@@ -286,6 +308,7 @@ object AngConfigManager {
}?.key }?.key
if (existingKey != null) { if (existingKey != null) {
MmkvManager.encodeProfileDirect(existingKey, JsonUtil.toJson(config))
keyToProfile[existingKey] = config keyToProfile[existingKey] = config
} else { } else {
val key = Utils.getUuid() val key = Utils.getUuid()
@@ -306,6 +329,35 @@ object AngConfigManager {
return keyToProfile 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. * Finds a matched profile key from the given key-profile map using multi-level matching.
* Matching priority (from highest to lowest): * Matching priority (from highest to lowest):
@@ -370,8 +422,9 @@ object AngConfigManager {
* @return The number of configurations parsed. * @return The number of configurations parsed.
*/ */
private fun parseCustomConfigServer(server: String?, subid: String, append: Boolean): Int { private fun parseCustomConfigServer(server: String?, subid: String, append: Boolean): Int {
return synchronized(getSubscriptionLock(subid)) {
if (server == null) { if (server == null) {
return 0 return@synchronized 0
} }
if (server.contains("inbounds") if (server.contains("inbounds")
&& server.contains("outbounds") && server.contains("outbounds")
@@ -394,15 +447,14 @@ object AngConfigManager {
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "") MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
count += 1 count += 1
} }
return count return@synchronized count
} }
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e) Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
} }
try { try {
// For compatibility val config = CustomFmt.parse(server) ?: return@synchronized 0
val config = CustomFmt.parse(server) ?: return 0
config.subscriptionId = subid config.subscriptionId = subid
config.description = generateDescription(config) config.description = generateDescription(config)
if (!append) { if (!append) {
@@ -410,27 +462,28 @@ object AngConfigManager {
} }
val key = MmkvManager.encodeServerConfig("", config) val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server) MmkvManager.encodeServerRaw(key, server)
return 1 return@synchronized 1
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e) Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
} }
return 0 return@synchronized 0
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) { } else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try { try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol val config = WireguardFmt.parseWireguardConfFile(server) ?: return@synchronized R.string.toast_incorrect_protocol
config.description = generateDescription(config) config.description = generateDescription(config)
if (!append) { if (!append) {
MmkvManager.removeServerViaSubid(subid) MmkvManager.removeServerViaSubid(subid)
} }
val key = MmkvManager.encodeServerConfig("", config) val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server) MmkvManager.encodeServerRaw(key, server)
return 1 return@synchronized 1
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e) Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
} }
return 0 return@synchronized 0
} else { } else {
return 0 return@synchronized 0
}
} }
} }
@@ -542,16 +595,18 @@ object AngConfigManager {
Log.i(AppConfig.TAG, url) Log.i(AppConfig.TAG, url)
val userAgent = it.subscription.userAgent val userAgent = it.subscription.userAgent
val timeout = if (url.startsWith("https://key.zarazaex.xyz/sub")) 3000 else 15000
var configText = try { var configText = try {
val httpPort = SettingsManager.getHttpPort() val httpPort = SettingsManager.getHttpPort()
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort) HttpUtil.getUrlContentWithUserAgent(url, userAgent, timeout, httpPort)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e) Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
"" ""
} }
if (configText.isEmpty()) { if (configText.isEmpty()) {
configText = try { configText = try {
HttpUtil.getUrlContentWithUserAgent(url, userAgent) HttpUtil.getUrlContentWithUserAgent(url, userAgent, timeout)
} catch (e: Exception) { } catch (e: Exception) {
Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e) Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
"" ""
@@ -94,18 +94,23 @@ object SpeedtestManager {
var result: String var result: String
var elapsed = -1L 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 { try {
val start = SystemClock.elapsedRealtime() val start = SystemClock.elapsedRealtime()
val code = conn.responseCode 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 elapsed = SystemClock.elapsedRealtime() - start
result = when (code) { if (xyz.zarazaex.olc.util.Utils.isPureIpAddress(responseBody)) {
204 -> context.getString(R.string.connection_test_available, elapsed) result = context.getString(R.string.connection_test_available, elapsed)
200 if conn.contentLengthLong == 0L -> context.getString(R.string.connection_test_available, elapsed) } else {
else -> throw IOException( throw IOException("Invalid IP response: $responseBody")
context.getString(R.string.connection_test_error_status_code, code)
)
} }
} catch (e: IOException) { } catch (e: IOException) {
Log.e(AppConfig.TAG, "Connection test IOException", e) Log.e(AppConfig.TAG, "Connection test IOException", e)
@@ -30,6 +30,8 @@ object V2RayServiceManager {
private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback()) private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback())
private val mMsgReceive = ReceiveMessageHandler() private val mMsgReceive = ReceiveMessageHandler()
private var currentConfig: ProfileItem? = null private var currentConfig: ProfileItem? = null
private val operationLock = Any()
@Volatile private var isOperationInProgress = false
var serviceControl: SoftReference<ServiceControl>? = null var serviceControl: SoftReference<ServiceControl>? = null
set(value) { set(value) {
@@ -57,6 +59,15 @@ object V2RayServiceManager {
* @param guid The GUID of the server configuration to use (optional). * @param guid The GUID of the server configuration to use (optional).
*/ */
fun startVService(context: Context, guid: String? = null) { fun startVService(context: Context, guid: String? = null) {
synchronized(operationLock) {
if (isOperationInProgress) {
Log.w(AppConfig.TAG, "StartCore-Manager: Operation already in progress")
return
}
isOperationInProgress = true
}
try {
Log.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}") Log.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
if (guid != null) { if (guid != null) {
@@ -64,6 +75,11 @@ object V2RayServiceManager {
} }
startContextService(context) startContextService(context)
} finally {
synchronized(operationLock) {
isOperationInProgress = false
}
}
} }
/** /**
@@ -71,8 +87,26 @@ object V2RayServiceManager {
* @param context The context from which the service is stopped. * @param context The context from which the service is stopped.
*/ */
fun stopVService(context: Context) { fun stopVService(context: Context) {
//context.toast(R.string.toast_services_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, "") MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
} finally {
synchronized(operationLock) {
isOperationInProgress = false
}
}
} }
/** /**
@@ -385,11 +419,17 @@ object V2RayServiceManager {
AppConfig.MSG_STATE_STOP -> { AppConfig.MSG_STATE_STOP -> {
Log.i(AppConfig.TAG, "StartCore-Manager: Stop service") Log.i(AppConfig.TAG, "StartCore-Manager: Stop service")
synchronized(operationLock) {
isOperationInProgress = false
}
serviceControl.stopService() serviceControl.stopService()
} }
AppConfig.MSG_STATE_RESTART -> { AppConfig.MSG_STATE_RESTART -> {
Log.i(AppConfig.TAG, "StartCore-Manager: Restart service") Log.i(AppConfig.TAG, "StartCore-Manager: Restart service")
synchronized(operationLock) {
isOperationInProgress = false
}
serviceControl.stopService() serviceControl.stopService()
Thread.sleep(500L) Thread.sleep(500L)
startVService(serviceControl.getService()) startVService(serviceControl.getService())
@@ -25,6 +25,7 @@ import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.handler.NotificationManager import xyz.zarazaex.olc.handler.NotificationManager
import xyz.zarazaex.olc.handler.SettingsManager import xyz.zarazaex.olc.handler.SettingsManager
import xyz.zarazaex.olc.handler.V2RayServiceManager import xyz.zarazaex.olc.handler.V2RayServiceManager
import xyz.zarazaex.olc.util.MessageUtil
import xyz.zarazaex.olc.util.MyContextWrapper import xyz.zarazaex.olc.util.MyContextWrapper
import xyz.zarazaex.olc.util.Utils import xyz.zarazaex.olc.util.Utils
import java.lang.ref.SoftReference import java.lang.ref.SoftReference
@@ -94,6 +95,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
super.onDestroy() super.onDestroy()
Log.i(AppConfig.TAG, "StartCore-VPN: Service destroyed") Log.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
NotificationManager.cancelNotification() NotificationManager.cancelNotification()
MessageUtil.sendMsg2UI(this, AppConfig.MSG_STATE_STOP_SUCCESS, "")
} }
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -42,9 +42,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelectedListener { class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelectedListener {
private val binding by lazy { private val binding by lazy {ActivityMainBinding.inflate(layoutInflater)}
ActivityMainBinding.inflate(layoutInflater)
}
private var isLiteTesting = false private var isLiteTesting = false
private var easterEggClickCount = 0 private var easterEggClickCount = 0
private var isEasterEggActive = false private var isEasterEggActive = false
@@ -52,6 +50,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
val mainViewModel: MainViewModel by viewModels() val mainViewModel: MainViewModel by viewModels()
private lateinit var groupPagerAdapter: GroupPagerAdapter private lateinit var groupPagerAdapter: GroupPagerAdapter
private var tabMediator: TabLayoutMediator? = null private var tabMediator: TabLayoutMediator? = null
@Volatile private var isFabOperationInProgress = false
private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) { if (it.resultCode == RESULT_OK) {
@@ -201,13 +200,25 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
} }
private fun handleFabAction() { private fun handleFabAction() {
if (isFabOperationInProgress) {
return
}
isFabOperationInProgress = true
applyRunningState(isLoading = true, isRunning = false) applyRunningState(isLoading = true, isRunning = false)
lifecycleScope.launch {
try {
if (mainViewModel.isRunning.value == true) { if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this) V2RayServiceManager.stopVService(this@MainActivity)
} else { } else {
startV2RayWithPermission() startV2RayWithPermission()
} }
} finally {
delay(1000)
isFabOperationInProgress = false
}
}
} }
private fun handleLayoutTestClick() { private fun handleLayoutTestClick() {
@@ -220,15 +231,23 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
} }
private fun handleLiteAction() { private fun handleLiteAction() {
if (isFabOperationInProgress) {
return
}
isFabOperationInProgress = true
lifecycleScope.launch {
try {
if (mainViewModel.isRunning.value == true) { if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this) V2RayServiceManager.stopVService(this@MainActivity)
delay(1000)
} }
showStatus("Обновление профилей...") showStatus("Обновление профилей...")
showLoading() showLoading()
isLiteTesting = true isLiteTesting = true
lifecycleScope.launch(Dispatchers.IO) { launch(Dispatchers.IO) {
val result = mainViewModel.updateConfigViaSubAll() val result = mainViewModel.updateConfigViaSubAll()
delay(500L) delay(500L)
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
@@ -245,6 +264,11 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
mainViewModel.testAllRealPing() mainViewModel.testAllRealPing()
} }
} }
} finally {
delay(1000)
isFabOperationInProgress = false
}
}
} }
private fun startV2RayWithPermission() { private fun startV2RayWithPermission() {
@@ -269,12 +293,22 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
} }
fun restartV2Ray() { fun restartV2Ray() {
if (mainViewModel.isRunning.value == true) { if (isFabOperationInProgress) {
V2RayServiceManager.stopVService(this) return
} }
isFabOperationInProgress = true
lifecycleScope.launch { lifecycleScope.launch {
delay(500) try {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this@MainActivity)
}
delay(1000)
startV2Ray() startV2Ray()
} finally {
delay(500)
isFabOperationInProgress = false
}
} }
} }
@@ -41,7 +41,6 @@ class SubSettingActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting)) setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
adapter = SubSettingRecyclerAdapter(viewModel, ActivityAdapterListener()) adapter = SubSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
@@ -53,6 +52,14 @@ class SubSettingActivity : BaseActivity() {
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter)) mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView) mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
viewModel.isUpdating.observe(this) { isUpdating ->
adapter.setUpdating(isUpdating)
}
viewModel.subscriptionStatuses.observe(this) { statuses ->
adapter.notifyDataSetChanged()
}
} }
override fun onResume() { override fun onResume() {
@@ -62,44 +69,105 @@ class SubSettingActivity : BaseActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean { override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_sub_setting, menu) 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) return super.onCreateOptionsMenu(menu)
} }
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.add_config -> { R.id.add_config -> {
startActivity(Intent(this, SubEditActivity::class.java)) startActivity(Intent(this, SubEditActivity::class.java))
true true
} }
R.id.sub_update -> { R.id.sub_update -> {
showLoading() if (viewModel.isUpdating.value == true) {
return true
}
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
lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.updateConfigViaSubAll()
delay(500L)
launch(Dispatchers.Main) { launch(Dispatchers.Main) {
if (result.successCount + result.failureCount + result.skipCount == 0) { 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) toast(R.string.title_update_subscription_no_subscription)
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) { } else if (successCount > 0 && failureCount + skipCount == 0) {
toast(getString(R.string.title_update_config_count, result.configCount)) toast(getString(R.string.title_update_config_count, totalConfigCount))
} else { } else {
toast( toast(
getString( getString(
R.string.title_update_subscription_result, R.string.title_update_subscription_result,
result.configCount, result.successCount, result.failureCount, result.skipCount totalConfigCount, successCount, failureCount, skipCount
) )
) )
} }
hideLoading() hideLoading()
refreshData() refreshData()
} }
}
true true
} }
else -> super.onOptionsItemSelected(item) else -> super.onOptionsItemSelected(item)
}
} }
@SuppressLint("NotifyDataSetChanged") @SuppressLint("NotifyDataSetChanged")
@@ -8,8 +8,10 @@ import android.view.ViewGroup
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import xyz.zarazaex.olc.AppConfig import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.R
import xyz.zarazaex.olc.contracts.BaseAdapterListener import xyz.zarazaex.olc.contracts.BaseAdapterListener
import xyz.zarazaex.olc.databinding.ItemRecyclerSubSettingBinding import xyz.zarazaex.olc.databinding.ItemRecyclerSubSettingBinding
import xyz.zarazaex.olc.dto.SubscriptionUpdateStatus
import xyz.zarazaex.olc.helper.ItemTouchHelperAdapter import xyz.zarazaex.olc.helper.ItemTouchHelperAdapter
import xyz.zarazaex.olc.helper.ItemTouchHelperViewHolder import xyz.zarazaex.olc.helper.ItemTouchHelperViewHolder
import xyz.zarazaex.olc.util.Utils import xyz.zarazaex.olc.util.Utils
@@ -20,6 +22,15 @@ class SubSettingRecyclerAdapter(
private val adapterListener: BaseAdapterListener? private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter { ) : 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 getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) { override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
@@ -32,16 +43,60 @@ class SubSettingRecyclerAdapter(
holder.itemSubSettingBinding.tvLastUpdated.text = Utils.formatTimestamp(subItem.lastUpdated) holder.itemSubSettingBinding.tvLastUpdated.text = Utils.formatTimestamp(subItem.lastUpdated)
holder.itemView.setBackgroundColor(Color.TRANSPARENT) 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 { holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
if (isEnabled) {
adapterListener?.onEdit(subId, position) adapterListener?.onEdit(subId, position)
} }
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
adapterListener?.onRemove(subId, position)
} }
holder.itemSubSettingBinding.layoutRemove.isClickable = isEnabled
holder.itemSubSettingBinding.layoutRemove.alpha = if (isEnabled) 1.0f else 0.5f
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
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 -> holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
if (!it.isPressed) return@setOnCheckedChangeListener if (!it.isPressed || !isEnabled) return@setOnCheckedChangeListener
subItem.enabled = isChecked subItem.enabled = isChecked
viewModel.update(subId, subItem) viewModel.update(subId, subItem)
} }
@@ -56,11 +111,15 @@ class SubSettingRecyclerAdapter(
holder.itemSubSettingBinding.layoutShare.visibility = View.VISIBLE holder.itemSubSettingBinding.layoutShare.visibility = View.VISIBLE
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutLastUpdated.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 { holder.itemSubSettingBinding.layoutShare.setOnClickListener {
if (isEnabled) {
adapterListener?.onShare(subItem.url) adapterListener?.onShare(subItem.url)
} }
} }
} }
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder { override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
return MainViewHolder( return MainViewHolder(
@@ -266,6 +266,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
updateListAction.value = -1 updateListAction.value = -1
viewModelScope.launch(Dispatchers.Default) { viewModelScope.launch(Dispatchers.Default) {
if (serversCache.isEmpty()) {
withContext(Dispatchers.Main) { reloadServerList() }
}
if (serversCache.isEmpty()) { if (serversCache.isEmpty()) {
return@launch return@launch
} }
@@ -1,8 +1,11 @@
package xyz.zarazaex.olc.viewmodel package xyz.zarazaex.olc.viewmodel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import xyz.zarazaex.olc.dto.SubscriptionCache import xyz.zarazaex.olc.dto.SubscriptionCache
import xyz.zarazaex.olc.dto.SubscriptionItem 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.MmkvManager
import xyz.zarazaex.olc.handler.SettingsChangeManager import xyz.zarazaex.olc.handler.SettingsChangeManager
import xyz.zarazaex.olc.handler.SettingsManager import xyz.zarazaex.olc.handler.SettingsManager
@@ -11,11 +14,25 @@ class SubscriptionsViewModel : ViewModel() {
private val subscriptions: MutableList<SubscriptionCache> = private val subscriptions: MutableList<SubscriptionCache> =
MmkvManager.decodeSubscriptions().toMutableList() MmkvManager.decodeSubscriptions().toMutableList()
val isUpdating = MutableLiveData<Boolean>(false)
val subscriptionStatuses = MutableLiveData<Map<String, SubscriptionStatus>>(emptyMap())
fun getAll(): List<SubscriptionCache> = subscriptions.toList() fun getAll(): List<SubscriptionCache> = subscriptions.toList()
fun reload() { fun reload() {
subscriptions.clear() subscriptions.clear()
subscriptions.addAll(MmkvManager.decodeSubscriptions()) 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 { fun remove(subId: String): Boolean {
@@ -168,9 +168,26 @@
<TextView <TextView
android:id="@+id/tv_last_updated" 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_width="wrap_content"
android:layout_height="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> </LinearLayout>
@@ -1,26 +1,23 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <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="color_fab_inactive">#646464</color>
<color name="divider_color_light">#424242</color> <color name="divider_color_light">#424242</color>
<!-- Primary colors - main tone: gray -->
<color name="md_theme_primary">#C0C0C0</color> <color name="md_theme_primary">#C0C0C0</color>
<color name="md_theme_onPrimary">#303030</color> <color name="md_theme_onPrimary">#303030</color>
<color name="md_theme_primaryContainer">#474747</color> <color name="md_theme_primaryContainer">#474747</color>
<color name="md_theme_onPrimaryContainer">#E0E0E0</color> <color name="md_theme_onPrimaryContainer">#E0E0E0</color>
<!-- Secondary colors - accent color: green --> <color name="md_theme_secondary">@android:color/system_accent1_400</color>
<color name="md_theme_secondary">#88AC8A</color>
<color name="md_theme_onSecondary">#FFFFFF</color> <color name="md_theme_onSecondary">#FFFFFF</color>
<color name="md_theme_secondaryContainer">#6F3800</color> <color name="md_theme_secondaryContainer">#6F3800</color>
<color name="md_theme_onSecondaryContainer">#FFE8D6</color> <color name="md_theme_onSecondaryContainer">#FFE8D6</color>
<!-- Tertiary colors - tertiary color: green --> <color name="md_theme_tertiary">@android:color/system_accent1_300</color>
<color name="md_theme_tertiary">#83D6B5</color>
<color name="md_theme_onTertiary">#00382E</color> <color name="md_theme_onTertiary">#00382E</color>
<color name="md_theme_tertiaryContainer">#005143</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 --> <!-- Error colors -->
<color name="md_theme_error">#FFB4AB</color> <color name="md_theme_error">#FFB4AB</color>
@@ -298,6 +298,7 @@
<string name="title_import_config_count">Импортировано профилей: %d</string> <string name="title_import_config_count">Импортировано профилей: %d</string>
<string name="title_export_config_count">Экспортировано профилей: %d</string> <string name="title_export_config_count">Экспортировано профилей: %d</string>
<string name="title_update_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_result">Обновлено профилей: %1$d (успешно: %2$d, ошибок: %3$d, пропущено: %4$d)</string>
<string name="title_update_subscription_no_subscription">Нет подписок</string> <string name="title_update_subscription_no_subscription">Нет подписок</string>
<string name="toast_server_not_found_in_group">Выбранный профиль не найден в текущей группе</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"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<color name="colorPing">#009966</color> <color name="colorPing">@android:color/system_accent1_600</color>
<color name="colorPingRed">#FF0099</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="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="color_fab_inactive">#9C9C9C</color>
<color name="divider_color_light">#E0E0E0</color> <color name="divider_color_light">#E0E0E0</color>
<color name="colorIndicator">@color/md_theme_primary</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_primary">#000000</color>
<color name="md_theme_onPrimary">#FFFFFF</color> <color name="md_theme_onPrimary">#FFFFFF</color>
<color name="md_theme_primaryContainer">#E0E0E0</color> <color name="md_theme_primaryContainer">#E0E0E0</color>
<color name="md_theme_onPrimaryContainer">#000000</color> <color name="md_theme_onPrimaryContainer">#000000</color>
<!-- Secondary colors - accent color: green --> <color name="md_theme_secondary">@android:color/system_accent1_400</color>
<color name="md_theme_secondary">#88AC8A</color>
<color name="md_theme_onSecondary">#FFFFFF</color> <color name="md_theme_onSecondary">#FFFFFF</color>
<color name="md_theme_secondaryContainer">#FFE8D6</color> <color name="md_theme_secondaryContainer">#FFE8D6</color>
<color name="md_theme_onSecondaryContainer">#2B1700</color> <color name="md_theme_onSecondaryContainer">#2B1700</color>
<!-- Tertiary colors - accent color: green --> <color name="md_theme_tertiary">@android:color/system_accent1_600</color>
<color name="md_theme_tertiary">#009966</color>
<color name="md_theme_onTertiary">#FFFFFF</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> <color name="md_theme_onTertiaryContainer">#00201A</color>
<!-- Error colors --> <!-- Error colors -->
+4 -3
View File
@@ -7,8 +7,8 @@
<string name="navigation_drawer_open">Open navigation drawer</string> <string name="navigation_drawer_open">Open navigation drawer</string>
<string name="navigation_drawer_close">Close navigation drawer</string> <string name="navigation_drawer_close">Close navigation drawer</string>
<string name="migration_success">Data migration success!</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_forked_text" translatable="false">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_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="action_stop_service">Stop service</string>
<string name="migration_fail">Data migration failed!</string> <string name="migration_fail">Data migration failed!</string>
<string name="pull_down_to_refresh">Please pull down to refresh!</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="summary_pref_tg_group">Join Telegram Group</string>
<string name="toast_tg_app_not_found">Telegram app not found</string> <string name="toast_tg_app_not_found">Telegram app not found</string>
<string name="title_privacy_policy">Privacy policy</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_about">About</string>
<string name="title_source_code">Source code</string> <string name="title_source_code">Source code</string>
<string name="title_oss_license">Open Source licenses</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_import_config_count">Import %d configs</string>
<string name="title_export_config_count">Export %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_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_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="title_update_subscription_no_subscription">No subscriptions</string>
<string name="toast_server_not_found_in_group">Selected server not found in current group</string> <string name="toast_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="colorPrimaryContainer">@color/md_theme_primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/md_theme_onPrimaryContainer</item> <item name="colorOnPrimaryContainer">@color/md_theme_onPrimaryContainer</item>
<!-- Secondary colors - accent color: orange -->
<item name="colorSecondary">@color/md_theme_secondary</item> <item name="colorSecondary">@color/md_theme_secondary</item>
<item name="colorOnSecondary">@color/md_theme_onSecondary</item> <item name="colorOnSecondary">@color/md_theme_onSecondary</item>
<item name="colorSecondaryContainer">@color/md_theme_secondaryContainer</item> <item name="colorSecondaryContainer">@color/md_theme_secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/md_theme_onSecondaryContainer</item> <item name="colorOnSecondaryContainer">@color/md_theme_onSecondaryContainer</item>
<!-- Tertiary colors - tertiary color: green -->
<item name="colorTertiary">@color/md_theme_tertiary</item> <item name="colorTertiary">@color/md_theme_tertiary</item>
<item name="colorOnTertiary">@color/md_theme_onTertiary</item> <item name="colorOnTertiary">@color/md_theme_onTertiary</item>
<item name="colorTertiaryContainer">@color/md_theme_tertiaryContainer</item> <item name="colorTertiaryContainer">@color/md_theme_tertiaryContainer</item>
+1 -1
View File
@@ -2,7 +2,7 @@
agp = "9.1.0" agp = "9.1.0"
desugarJdkLibs = "2.1.5" desugarJdkLibs = "2.1.5"
gradleLicensePlugin = "0.9.8" gradleLicensePlugin = "0.9.8"
kotlin = "2.3.10" kotlin = "2.1.0"
coreKtx = "1.17.0" coreKtx = "1.17.0"
junit = "4.13.2" junit = "4.13.2"
junitVersion = "1.3.0" junitVersion = "1.3.0"
BIN
View File
Binary file not shown.