12 Commits

28 changed files with 449 additions and 219 deletions
-1
View File
@@ -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
+23 -52
View File
@@ -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)
@@ -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
}
@@ -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)
""
@@ -96,6 +96,11 @@ object V2RayServiceManager {
}
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) {
@@ -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 {
@@ -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>
+6 -9
View File
@@ -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 -->
@@ -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>
-8
View File
@@ -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)
}
}
+1 -14
View File
@@ -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
BIN
View File
Binary file not shown.