mirror of
https://github.com/2dust/v2rayNG.git
synced 2026-10-29 22:49:23 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9fcb1a30f8 | ||
|
|
d4fc91502c | ||
|
|
75c9194ec0 | ||
|
|
590be2e9ae | ||
|
|
1ee1213563 | ||
|
|
37b42f04a7 | ||
|
|
3c91b51489 | ||
|
|
06a27ec4d4 | ||
|
|
7a07565f72 | ||
|
|
0456f835bc | ||
|
|
e4e3a36e93 | ||
|
|
4b3c8177db | ||
|
|
780d194213 | ||
|
|
c1374a73fb | ||
|
|
49fe45c49e | ||
|
|
36ab5615d9 | ||
|
|
91c2c93a62 | ||
|
|
10932f5764 | ||
|
|
8dcff4238f | ||
|
|
809bdcdb9d |
No files matched your search
+1
-1
Submodule AndroidLibXrayLite updated: b21389865e...c634d1baea.
@@ -13,8 +13,8 @@ android {
|
||||
applicationId = "com.v2ray.ang"
|
||||
minSdk = 24
|
||||
targetSdk = 37
|
||||
versionCode = 744
|
||||
versionName = "2.3.4"
|
||||
versionCode = 745
|
||||
versionName = "2.3.5"
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
splits {
|
||||
|
||||
@@ -33,10 +33,10 @@
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_start_24dp"
|
||||
android:shortcutDisabledMessage="@string/toast_services_start"
|
||||
android:shortcutDisabledMessage="@string/acc_start"
|
||||
android:shortcutId="shortcuts_start"
|
||||
android:shortcutLongLabel="@string/toast_services_start"
|
||||
android:shortcutShortLabel="@string/toast_services_start">
|
||||
android:shortcutLongLabel="@string/acc_start"
|
||||
android:shortcutShortLabel="@string/acc_start">
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
@@ -48,10 +48,10 @@
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_stop_24dp"
|
||||
android:shortcutDisabledMessage="@string/toast_services_stop"
|
||||
android:shortcutDisabledMessage="@string/acc_stop"
|
||||
android:shortcutId="shortcuts_stop"
|
||||
android:shortcutLongLabel="@string/toast_services_stop"
|
||||
android:shortcutShortLabel="@string/toast_services_stop">
|
||||
android:shortcutLongLabel="@string/acc_stop"
|
||||
android:shortcutShortLabel="@string/acc_stop">
|
||||
|
||||
<!--suppress AndroidDomInspection -->
|
||||
<intent
|
||||
@@ -60,4 +60,4 @@
|
||||
android:targetPackage="com.v2ray.ang.fdroid" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||
</shortcuts>
|
||||
@@ -5,9 +5,9 @@ import android.content.Context
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.handler.AppLocaleManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.ui.compose.ThemeManager
|
||||
|
||||
@@ -35,7 +35,7 @@ class AngApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
|
||||
MMKV.initialize(this)
|
||||
MmkvManager.initialize(this)
|
||||
|
||||
AppLocaleManager.initialize(this)
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.v2ray.ang.core
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.Service
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
@@ -30,6 +31,7 @@ import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import libv2ray.CoreCallbackHandler
|
||||
import libv2ray.CoreController
|
||||
@@ -371,6 +373,7 @@ object CoreServiceManager {
|
||||
* @return 0 for success, any other value for failure.
|
||||
*/
|
||||
override fun startup(): Long {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback startup")
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -379,14 +382,8 @@ object CoreServiceManager {
|
||||
* @return 0 for success, any other value for failure.
|
||||
*/
|
||||
override fun shutdown(): Long {
|
||||
val serviceControl = serviceControl?.get() ?: return -1
|
||||
return try {
|
||||
serviceControl.stopService()
|
||||
0
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
|
||||
-1
|
||||
}
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback shutdown")
|
||||
return 0
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -396,6 +393,7 @@ object CoreServiceManager {
|
||||
* @return Always returns 0.
|
||||
*/
|
||||
override fun onEmitStatus(l: Long, s: String?): Long {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: CoreCallback onEmitStatus $s")
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -474,9 +472,20 @@ object CoreServiceManager {
|
||||
|
||||
AppConfig.MSG_STATE_RESTART -> {
|
||||
LogUtil.i(AppConfig.TAG, "StartCore-Manager: Restart service")
|
||||
serviceControl.stopService()
|
||||
Thread.sleep(500L)
|
||||
LauncherManager.startService(serviceControl.getService())
|
||||
// The UI and daemon run in separate processes, so acknowledge the active
|
||||
// daemon before stopping it instead of relying on possibly stale UI state.
|
||||
if (isOrderedBroadcast) resultCode = Activity.RESULT_OK
|
||||
|
||||
val pendingResult = goAsync()
|
||||
CoroutineScope(Dispatchers.Default).launch {
|
||||
try {
|
||||
serviceControl.stopService()
|
||||
delay(500L)
|
||||
LauncherManager.startService(serviceControl.getService())
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_DELAY -> {
|
||||
|
||||
@@ -56,6 +56,18 @@ object LauncherManager {
|
||||
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
|
||||
}
|
||||
|
||||
/** Restarts the active daemon without starting a stopped service. */
|
||||
fun restartService(context: Context) {
|
||||
MessageHelper.sendMsg2Service(context, AppConfig.MSG_STATE_RESTART, "")
|
||||
}
|
||||
|
||||
/** Restarts the active daemon, or delegates to the caller's permission-aware start flow. */
|
||||
fun restartServiceOrStart(context: Context, startIfStopped: () -> Unit) {
|
||||
MessageHelper.sendMsg2ServiceForResult(context, AppConfig.MSG_STATE_RESTART, "") { handled ->
|
||||
if (!handled) startIfStopped()
|
||||
}
|
||||
}
|
||||
|
||||
@Throws(Exception::class)
|
||||
private fun startContextService(context: Context) {
|
||||
// Note: isRunning check is removed here to avoid loading Native libraries in the UI process.
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.v2ray.ang.enums.EConfigType
|
||||
import java.io.Serializable
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
const val THRESHOLD = 1000L
|
||||
const val DIVISOR = 1024.0
|
||||
@@ -76,3 +77,19 @@ fun EConfigType.isGroupType(): Boolean {
|
||||
fun EConfigType.isComplexType(): Boolean {
|
||||
return this == EConfigType.CUSTOM || this == EConfigType.POLICYGROUP || this == EConfigType.PROXYCHAIN
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for delay with Int milliseconds using Duration to avoid legacy Long overload warning.
|
||||
*/
|
||||
suspend fun delay(millis: Int) {
|
||||
kotlinx.coroutines.delay(millis.toLong().milliseconds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shorthand for delay with Long milliseconds using Duration to avoid legacy Long overload warning.
|
||||
*/
|
||||
suspend fun delay(millis: Long) {
|
||||
kotlinx.coroutines.delay(millis.milliseconds)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.text.TextUtils
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreConfigManager
|
||||
import com.v2ray.ang.dto.SubscriptionUpdateResult
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
@@ -31,6 +30,11 @@ import java.net.URI
|
||||
|
||||
object AngConfigManager {
|
||||
|
||||
private data class ParsedProfile(
|
||||
val profile: ProfileItem,
|
||||
val rawConfig: String? = null,
|
||||
)
|
||||
|
||||
// Parser mapping for different config types (lazy initialized)
|
||||
private val configFmtParsers: Map<String, (String) -> ProfileItem?> by lazy {
|
||||
mapOf(
|
||||
@@ -177,23 +181,28 @@ object AngConfigManager {
|
||||
* @return A pair containing the number of configurations and subscriptions imported.
|
||||
*/
|
||||
fun importBatchConfig(server: String?, subid: String, append: Boolean): Pair<Int, Int> {
|
||||
var count = parseBatchConfig(Utils.decode(server), subid, append)
|
||||
if (count <= 0) {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid, append)
|
||||
}
|
||||
return try {
|
||||
var count = parseBatchConfig(Utils.decode(server), subid, append)
|
||||
if (count <= 0) {
|
||||
count = parseBatchConfig(server, subid, append)
|
||||
}
|
||||
if (count <= 0) {
|
||||
count = parseCustomConfigServer(server, subid, append)
|
||||
}
|
||||
|
||||
var countSub = parseBatchSubscription(server)
|
||||
if (countSub <= 0) {
|
||||
countSub = parseBatchSubscription(Utils.decode(server))
|
||||
}
|
||||
if (countSub > 0) {
|
||||
updateConfigViaSubAll()
|
||||
}
|
||||
var countSub = parseBatchSubscription(server)
|
||||
if (countSub <= 0) {
|
||||
countSub = parseBatchSubscription(Utils.decode(server))
|
||||
}
|
||||
if (countSub > 0) {
|
||||
updateConfigViaSubAll()
|
||||
}
|
||||
|
||||
return count to countSub
|
||||
count to countSub
|
||||
} catch (e: ProfileStorageException) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to store imported profiles", e)
|
||||
0 to 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -236,9 +245,6 @@ object AngConfigManager {
|
||||
if (servers == null) {
|
||||
return 0
|
||||
}
|
||||
// Find the currently selected server that belongs to the same subscription before replacement.
|
||||
val removedSelected = getRemovedSelectedProfile(subid, append)
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subid)
|
||||
|
||||
// Parse all configs first (no I/O during parsing)
|
||||
@@ -253,17 +259,17 @@ object AngConfigManager {
|
||||
}
|
||||
}
|
||||
|
||||
// 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) }
|
||||
commitProfiles(
|
||||
configs = configs.map(::ParsedProfile),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
}
|
||||
|
||||
return configs.size
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse batch config", e)
|
||||
}
|
||||
@@ -271,112 +277,32 @@ object AngConfigManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch save configurations to reduce serverList read/write operations.
|
||||
* Reads serverList once, saves all configs, then writes serverList once.
|
||||
* Commits parsed profiles before removing the profiles they replace.
|
||||
*
|
||||
* @param configs The list of ProfileItem to save.
|
||||
* @param configs The parsed profiles to save.
|
||||
* @param subid The subscription ID.
|
||||
* @return Map of generated keys to their corresponding ProfileItem.
|
||||
* @param append Whether to append to the existing server list.
|
||||
*/
|
||||
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
|
||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||
private fun commitProfiles(
|
||||
configs: List<ParsedProfile>,
|
||||
subid: String,
|
||||
append: Boolean,
|
||||
) {
|
||||
val keyToProfile = linkedMapOf<String, ProfileItem>()
|
||||
val rawConfigs = mutableMapOf<String, String>()
|
||||
|
||||
// Read serverList once
|
||||
val serverList = MmkvManager.decodeServerList(subid)
|
||||
|
||||
configs.forEach { config ->
|
||||
configs.forEach { parsed ->
|
||||
val key = Utils.getUuid()
|
||||
// Save profile directly without updating serverList
|
||||
MmkvManager.encodeProfileDirect(key, JsonUtil.toJson(config))
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
}
|
||||
keyToProfile[key] = config
|
||||
keyToProfile[key] = parsed.profile
|
||||
parsed.rawConfig?.let { raw -> rawConfigs[key] = raw }
|
||||
}
|
||||
|
||||
// Write serverList once
|
||||
MmkvManager.encodeServerList(serverList, subid)
|
||||
return keyToProfile
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds a matched profile key from the given key-profile map using multi-level matching.
|
||||
* Matching priority (from highest to lowest):
|
||||
* 1. Exact match: server + port + password
|
||||
* 2. Match by remarks (exact match)
|
||||
* 3. Match by server + port
|
||||
* 4. Match by server only
|
||||
*
|
||||
* @param keyToProfile Map of server keys to their ProfileItem
|
||||
* @param target Target profile to match
|
||||
* @return Matched key or null
|
||||
*/
|
||||
private fun findMatchedProfileKey(keyToProfile: Map<String, ProfileItem>, target: ProfileItem?): String? {
|
||||
if (keyToProfile.isEmpty()) return null
|
||||
if (target == null) return null
|
||||
|
||||
// Level 0: Full match (remarks + server + port + password)
|
||||
if (target.remarks.isNotBlank()) {
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.remarks, target.remarks) &&
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort) &&
|
||||
isSameText(saved.password, target.password)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
// Level 1: Match by remarks
|
||||
if (target.remarks.isNotBlank()) {
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.remarks, target.remarks)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
// Level 2: Exact match (server + port + password)
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort) &&
|
||||
isSameText(saved.password, target.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// Level 3: Match by server + port
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server) &&
|
||||
isSameText(saved.serverPort, target.serverPort)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// Level 4: Match by server only
|
||||
keyToProfile.entries.firstOrNull { (_, saved) ->
|
||||
isSameText(saved.server, target.server)
|
||||
}?.key?.let { return it }
|
||||
|
||||
// If old selected node cannot be matched, fall back to the first imported config.
|
||||
return keyToProfile.keys.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the currently selected profile if it belongs to the target subscription and will be replaced.
|
||||
*/
|
||||
private fun getRemovedSelectedProfile(subid: String, append: Boolean): ProfileItem? {
|
||||
if (subid.isBlank() || append) return null
|
||||
|
||||
return MmkvManager.getSelectServer()
|
||||
.takeIf { it?.isNotBlank() == true }
|
||||
?.let { MmkvManager.decodeServerConfig(it) }
|
||||
?.takeIf { it.subscriptionId == subid }
|
||||
}
|
||||
|
||||
/**
|
||||
* Case-insensitive trimmed string comparison.
|
||||
*
|
||||
* @param left First string
|
||||
* @param right Second string
|
||||
* @return True if both are non-empty and equal (case-insensitive, trimmed)
|
||||
*/
|
||||
private fun isSameText(left: String?, right: String?): Boolean {
|
||||
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
|
||||
return left.trim().equals(right.trim(), ignoreCase = true)
|
||||
MmkvManager.saveServerProfiles(
|
||||
profiles = keyToProfile,
|
||||
rawConfigs = rawConfigs,
|
||||
subscriptionId = subid,
|
||||
append = append,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -400,56 +326,54 @@ object AngConfigManager {
|
||||
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
|
||||
|
||||
if (serverList.isNotEmpty()) {
|
||||
val removedSelected = getRemovedSelectedProfile(subid, append)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
var count = 0
|
||||
val keyToProfile = mutableMapOf<String, ProfileItem>()
|
||||
for (srv in serverList.reversed()) {
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
|
||||
val configs = serverList.reversed().map { srv ->
|
||||
val config = CustomFmt.parse(JsonUtil.toJson(srv))
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
|
||||
keyToProfile[key] = config
|
||||
count += 1
|
||||
ParsedProfile(
|
||||
profile = config,
|
||||
rawConfig = JsonUtil.toJsonPretty(srv) ?: "",
|
||||
)
|
||||
}
|
||||
if (count > 0) {
|
||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
}
|
||||
return count
|
||||
commitProfiles(configs, subid, append)
|
||||
return configs.size
|
||||
}
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
|
||||
}
|
||||
|
||||
try {
|
||||
// For compatibility
|
||||
val config = CustomFmt.parse(server) ?: return 0
|
||||
val config = CustomFmt.parse(server)
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
commitProfiles(
|
||||
configs = listOf(ParsedProfile(config, server)),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
return 1
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.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
|
||||
val config = WireguardFmt.parseWireguardConfFile(server)
|
||||
config.subscriptionId = subid
|
||||
config.description = generateDescription(config)
|
||||
if (!append) {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val key = MmkvManager.encodeServerConfig("", config)
|
||||
MmkvManager.encodeServerRaw(key, server)
|
||||
commitProfiles(
|
||||
configs = listOf(ParsedProfile(config, server)),
|
||||
subid = subid,
|
||||
append = append,
|
||||
)
|
||||
return 1
|
||||
} catch (e: ProfileStorageException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -7,9 +9,14 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.tencent.mmkv.MMKVHandler
|
||||
import com.tencent.mmkv.MMKVLogLevel
|
||||
import com.tencent.mmkv.MMKVRecoverStrategic
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
||||
import com.v2ray.ang.AppConfig.PREF_ROUTING_RULESET
|
||||
import com.v2ray.ang.AppConfig.TAG
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
@@ -24,6 +31,8 @@ import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
|
||||
internal class ProfileStorageException(message: String) : IllegalStateException(message)
|
||||
|
||||
object MmkvManager {
|
||||
|
||||
//region private
|
||||
@@ -41,6 +50,24 @@ object MmkvManager {
|
||||
private const val KEY_SUB_IDS = "SUB_IDS"
|
||||
private const val KEY_WEBDAV_CONFIG = "WEBDAV_CONFIG"
|
||||
|
||||
private val recoveryHandler = object : MMKVHandler {
|
||||
override fun onMMKVCRCCheckFail(mmapID: String) =
|
||||
recoverFromStorageError(mmapID, "CRC check")
|
||||
|
||||
override fun onMMKVFileLengthError(mmapID: String) =
|
||||
recoverFromStorageError(mmapID, "file length check")
|
||||
|
||||
override fun wantLogRedirecting(): Boolean = false
|
||||
|
||||
override fun mmkvLog(
|
||||
level: MMKVLogLevel,
|
||||
file: String,
|
||||
line: Int,
|
||||
function: String,
|
||||
message: String
|
||||
) = Unit
|
||||
}
|
||||
|
||||
private val mainStorage by lazy { MMKV.mmkvWithID(ID_MAIN, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val profileFullStorage by lazy { MMKV.mmkvWithID(ID_PROFILE_FULL_CONFIG, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val serverRawStorage by lazy { MMKV.mmkvWithID(ID_SERVER_RAW, MMKV.MULTI_PROCESS_MODE) }
|
||||
@@ -49,8 +76,83 @@ object MmkvManager {
|
||||
private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
|
||||
private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
|
||||
|
||||
private inline fun <T> withProfileIndexLock(block: () -> T): T {
|
||||
return synchronized(mainStorage) {
|
||||
mainStorage.lock()
|
||||
try {
|
||||
block()
|
||||
} finally {
|
||||
mainStorage.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun removeProfilePayloads(guids: Collection<String>) {
|
||||
if (guids.isEmpty()) return
|
||||
val keys = guids.toTypedArray()
|
||||
profileFullStorage.removeValuesForKeys(keys)
|
||||
serverAffStorage.removeValuesForKeys(keys)
|
||||
serverRawStorage.removeValuesForKeys(keys)
|
||||
}
|
||||
|
||||
private fun requireStorageWrite(success: Boolean, message: String) {
|
||||
if (!success) throw ProfileStorageException(message)
|
||||
}
|
||||
|
||||
private fun persistServerList(serverList: List<String>, subscriptionId: String): Boolean {
|
||||
return mainStorage.encode(serverListKey(subscriptionId), JsonUtil.toJson(serverList))
|
||||
}
|
||||
|
||||
private fun serverListKey(subscriptionId: String): String {
|
||||
return "$KEY_SUB_SERVER_PREFIX${getSubscriptionId(subscriptionId)}"
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns every server referenced outside the target group, or null if the raw indexes
|
||||
* cannot provide a complete view.
|
||||
*/
|
||||
private fun decodeServersReferencedByOtherGroups(subscriptionId: String): Set<String>? {
|
||||
val targetKey = serverListKey(subscriptionId)
|
||||
val keys = mainStorage.allKeys() ?: return null
|
||||
if (targetKey !in keys) return null
|
||||
|
||||
val referencedServers = mutableSetOf<String>()
|
||||
for (key in keys) {
|
||||
if (!key.startsWith(KEY_SUB_SERVER_PREFIX) || key == targetKey) continue
|
||||
|
||||
val json = mainStorage.decodeString(key)
|
||||
if (json.isNullOrBlank()) return null
|
||||
val serverIds = JsonUtil.fromJsonSafe(json, Array<String>::class.java) ?: return null
|
||||
referencedServers.addAll(serverIds)
|
||||
}
|
||||
return referencedServers
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
/**
|
||||
* Initializes MMKV with best-effort recovery so a damaged store is not silently discarded.
|
||||
*/
|
||||
fun initialize(context: Context) {
|
||||
val logLevel = if (BuildConfig.DEBUG) {
|
||||
MMKVLogLevel.LevelDebug
|
||||
} else {
|
||||
MMKVLogLevel.LevelInfo
|
||||
}
|
||||
MMKV.initialize(
|
||||
context,
|
||||
context.filesDir.resolve("mmkv").absolutePath,
|
||||
null,
|
||||
logLevel,
|
||||
recoveryHandler
|
||||
)
|
||||
}
|
||||
|
||||
private fun recoverFromStorageError(mmapID: String, error: String): MMKVRecoverStrategic {
|
||||
Log.e(TAG, "MMKV $error failed for $mmapID; attempting data recovery")
|
||||
return MMKVRecoverStrategic.OnErrorRecover
|
||||
}
|
||||
|
||||
//region Server
|
||||
|
||||
/**
|
||||
@@ -79,7 +181,9 @@ object MmkvManager {
|
||||
* @param guid The server GUID.
|
||||
*/
|
||||
fun setSelectServer(guid: String) {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid)
|
||||
withProfileIndexLock {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -90,9 +194,9 @@ object MmkvManager {
|
||||
* @param subscriptionId The subscription ID.
|
||||
*/
|
||||
fun encodeServerList(serverList: MutableList<String>, subscriptionId: String) {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||
mainStorage.encode(key, JsonUtil.toJson(serverList))
|
||||
withProfileIndexLock {
|
||||
persistServerList(serverList, subscriptionId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -105,9 +209,7 @@ object MmkvManager {
|
||||
* @return The list of server GUIDs.
|
||||
*/
|
||||
fun decodeServerList(subscriptionId: String): MutableList<String> {
|
||||
val subId = getSubscriptionId(subscriptionId)
|
||||
val key = "$KEY_SUB_SERVER_PREFIX$subId"
|
||||
val json = mainStorage.decodeString(key)
|
||||
val json = mainStorage.decodeString(serverListKey(subscriptionId))
|
||||
return if (json.isNullOrBlank()) {
|
||||
mutableListOf()
|
||||
} else {
|
||||
@@ -166,17 +268,28 @@ object MmkvManager {
|
||||
*/
|
||||
fun encodeServerConfig(guid: String, config: ProfileItem): String {
|
||||
val key = guid.ifBlank { Utils.getUuid() }
|
||||
profileFullStorage.encode(key, JsonUtil.toJson(config))
|
||||
withProfileIndexLock {
|
||||
requireStorageWrite(
|
||||
profileFullStorage.encode(key, JsonUtil.toJson(config)),
|
||||
"Failed to save profile payload",
|
||||
)
|
||||
|
||||
// Use default subscription for servers without subscription
|
||||
val subId = getSubscriptionId(config.subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
// Use default subscription for servers without subscription
|
||||
val subId = getSubscriptionId(config.subscriptionId)
|
||||
val serverList = decodeServerList(subId)
|
||||
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
encodeServerList(serverList, subId)
|
||||
if (getSelectServer().isNullOrBlank()) {
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, key)
|
||||
if (!serverList.contains(key)) {
|
||||
serverList.add(0, key)
|
||||
requireStorageWrite(
|
||||
persistServerList(serverList, subId),
|
||||
"Failed to publish profile index",
|
||||
)
|
||||
if (getSelectServer().isNullOrBlank()) {
|
||||
requireStorageWrite(
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, key),
|
||||
"Failed to update selected profile",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -184,13 +297,88 @@ object MmkvManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes the server configuration directly without updating serverList.
|
||||
* Saves a profile batch before publishing its group index and removing replaced payloads.
|
||||
*
|
||||
* @param key The server GUID.
|
||||
* @param configJson The server configuration JSON string.
|
||||
* @param profiles Generated GUIDs and parsed profiles, in insertion order.
|
||||
* @param rawConfigs Optional raw configuration payloads keyed by profile GUID.
|
||||
* @param subscriptionId The destination subscription ID.
|
||||
* @param append Whether to append to the existing group index.
|
||||
*/
|
||||
fun encodeProfileDirect(key: String, configJson: String) {
|
||||
profileFullStorage.encode(key, configJson)
|
||||
internal fun saveServerProfiles(
|
||||
profiles: Map<String, ProfileItem>,
|
||||
rawConfigs: Map<String, String>,
|
||||
subscriptionId: String,
|
||||
append: Boolean,
|
||||
) {
|
||||
if (profiles.isEmpty()) return
|
||||
|
||||
withProfileIndexLock {
|
||||
val replacedServers = if (append) {
|
||||
emptyList()
|
||||
} else {
|
||||
decodeServerList(subscriptionId).toList()
|
||||
}
|
||||
val previousSelection = getSelectServer()
|
||||
val selectedProfile = if (!append &&
|
||||
previousSelection != null &&
|
||||
previousSelection in replacedServers
|
||||
) {
|
||||
decodeServerConfig(previousSelection)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val replacementSelection = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = profiles,
|
||||
currentSelection = previousSelection,
|
||||
selectedProfile = selectedProfile,
|
||||
)
|
||||
|
||||
profiles.forEach { (guid, profile) ->
|
||||
requireStorageWrite(
|
||||
profileFullStorage.encode(guid, JsonUtil.toJson(profile)),
|
||||
"Failed to save profile payload",
|
||||
)
|
||||
rawConfigs[guid]?.let { raw ->
|
||||
requireStorageWrite(
|
||||
serverRawStorage.encode(guid, raw),
|
||||
"Failed to save raw profile payload",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val serverList = if (append) {
|
||||
decodeServerList(subscriptionId)
|
||||
} else {
|
||||
mutableListOf()
|
||||
}
|
||||
val indexedServers = serverList.toHashSet()
|
||||
profiles.keys.forEach { guid ->
|
||||
if (indexedServers.add(guid)) {
|
||||
serverList.add(0, guid)
|
||||
}
|
||||
}
|
||||
requireStorageWrite(
|
||||
persistServerList(serverList, subscriptionId),
|
||||
"Failed to publish profile index",
|
||||
)
|
||||
replacementSelection?.let { guid ->
|
||||
requireStorageWrite(
|
||||
mainStorage.encode(KEY_SELECTED_SERVER, guid),
|
||||
"Failed to update selected profile",
|
||||
)
|
||||
}
|
||||
if (replacedServers.isEmpty()) return@withProfileIndexLock
|
||||
|
||||
val protectedServer = replacementSelection ?: previousSelection
|
||||
val referencedByOtherGroups = decodeServersReferencedByOtherGroups(subscriptionId)
|
||||
val removablePayloads = ProfileReplacement.findRemovablePayloads(
|
||||
replacedServers = replacedServers,
|
||||
replacementServers = profiles.keys,
|
||||
protectedServer = protectedServer,
|
||||
serversReferencedByOtherGroups = referencedByOtherGroups,
|
||||
)
|
||||
removeProfilePayloads(removablePayloads)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -378,6 +566,56 @@ object MmkvManager {
|
||||
return serverRawStorage.decodeString(guid)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes profile payloads that are provably absent from their raw SUB_SERVERS_* index.
|
||||
*
|
||||
* SUB_IDS and SUB are intentionally ignored: either store can be missing after MMKV
|
||||
* recovery while the group indexes still identify live profiles. If any group index or
|
||||
* profile payload needed for a decision is unreadable, that data is preserved.
|
||||
*
|
||||
* @return The number of profile payloads removed, or null if cleanup could not run safely.
|
||||
*/
|
||||
internal fun removeOrphanedServerProfiles(): Int? = synchronized(mainStorage) {
|
||||
mainStorage.lock()
|
||||
try {
|
||||
val indexedServersBySubscription = mainStorage.allKeys().orEmpty()
|
||||
.asSequence()
|
||||
.filter { key -> key.startsWith(KEY_SUB_SERVER_PREFIX) }
|
||||
.associate { key ->
|
||||
val subscriptionId = key.removePrefix(KEY_SUB_SERVER_PREFIX)
|
||||
val json = mainStorage.decodeString(key)
|
||||
val serverIds = if (json.isNullOrBlank()) {
|
||||
null
|
||||
} else {
|
||||
JsonUtil.fromJsonSafe(json, Array<String>::class.java)?.toSet()
|
||||
}
|
||||
subscriptionId to serverIds
|
||||
}
|
||||
|
||||
val profiles = profileFullStorage.allKeys().orEmpty().map { guid ->
|
||||
StoredProfileReference(
|
||||
guid = guid,
|
||||
subscriptionId = decodeServerConfig(guid)?.subscriptionId,
|
||||
)
|
||||
}
|
||||
val orphans = OrphanProfileCleaner.findOrphans(
|
||||
profiles = profiles,
|
||||
indexedServersBySubscription = indexedServersBySubscription,
|
||||
selectedServer = getSelectServer(),
|
||||
) ?: return@synchronized null
|
||||
|
||||
if (orphans.isNotEmpty()) {
|
||||
val keys = orphans.toTypedArray()
|
||||
profileFullStorage.removeValuesForKeys(keys)
|
||||
serverAffStorage.removeValuesForKeys(keys)
|
||||
serverRawStorage.removeValuesForKeys(keys)
|
||||
}
|
||||
orphans.size
|
||||
} finally {
|
||||
mainStorage.unlock()
|
||||
}
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Subscriptions
|
||||
|
||||
@@ -21,7 +21,7 @@ import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.math.min
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
|
||||
internal data class StoredProfileReference(
|
||||
val guid: String,
|
||||
val subscriptionId: String?,
|
||||
)
|
||||
|
||||
internal object OrphanProfileCleaner {
|
||||
|
||||
/**
|
||||
* Finds profile payloads that are provably unreachable from the raw group indexes.
|
||||
*
|
||||
* A null subscription ID means that the profile payload could not be decoded and is
|
||||
* preserved. A null server set means that a group index could not be decoded, in which
|
||||
* case the entire classification returns null and no cleanup should run.
|
||||
* Subscription metadata is deliberately not an input because SUB and SUB_IDS can be
|
||||
* missing while the raw SUB_SERVERS_* indexes remain intact.
|
||||
*/
|
||||
fun findOrphans(
|
||||
profiles: Collection<StoredProfileReference>,
|
||||
indexedServersBySubscription: Map<String, Set<String>?>,
|
||||
selectedServer: String?,
|
||||
): Set<String>? {
|
||||
if (profiles.isEmpty()) return emptySet()
|
||||
|
||||
if (indexedServersBySubscription.isEmpty() ||
|
||||
indexedServersBySubscription.values.any { it == null }
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
val indexedServers = indexedServersBySubscription.values
|
||||
.filterNotNull()
|
||||
.flatten()
|
||||
.toSet()
|
||||
|
||||
return profiles.mapNotNullTo(linkedSetOf()) { profile ->
|
||||
if (profile.guid == selectedServer || profile.guid in indexedServers) {
|
||||
return@mapNotNullTo null
|
||||
}
|
||||
|
||||
val subscriptionId = profile.subscriptionId ?: return@mapNotNullTo null
|
||||
val groupId = subscriptionId.ifEmpty { DEFAULT_SUBSCRIPTION_ID }
|
||||
if (!indexedServersBySubscription.containsKey(groupId)) {
|
||||
return@mapNotNullTo null
|
||||
}
|
||||
|
||||
profile.guid
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
|
||||
internal object ProfileReplacement {
|
||||
|
||||
/**
|
||||
* Finds the profile that should become selected after publishing a replacement batch.
|
||||
* The first profile becomes selected when the store has no current selection.
|
||||
*/
|
||||
fun findSelectedReplacement(
|
||||
profiles: Map<String, ProfileItem>,
|
||||
currentSelection: String?,
|
||||
selectedProfile: ProfileItem?,
|
||||
): String? {
|
||||
if (profiles.isEmpty()) return null
|
||||
if (currentSelection.isNullOrBlank()) return profiles.keys.first()
|
||||
if (selectedProfile == null) return null
|
||||
|
||||
if (selectedProfile.remarks.isNotBlank()) {
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.remarks, selectedProfile.remarks) &&
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
|
||||
isSameText(candidate.password, selectedProfile.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.remarks, selectedProfile.remarks)
|
||||
}?.key?.let { return it }
|
||||
}
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort) &&
|
||||
isSameText(candidate.password, selectedProfile.password)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server) &&
|
||||
isSameText(candidate.serverPort, selectedProfile.serverPort)
|
||||
}?.key?.let { return it }
|
||||
|
||||
profiles.entries.firstOrNull { (_, candidate) ->
|
||||
isSameText(candidate.server, selectedProfile.server)
|
||||
}?.key?.let { return it }
|
||||
|
||||
return profiles.keys.firstOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Finds replaced payloads that are safe to remove.
|
||||
*
|
||||
* A null cross-group reference set means that at least one raw group index could not
|
||||
* be read. In that case deletion fails closed.
|
||||
*/
|
||||
fun findRemovablePayloads(
|
||||
replacedServers: Collection<String>,
|
||||
replacementServers: Set<String>,
|
||||
protectedServer: String?,
|
||||
serversReferencedByOtherGroups: Set<String>?,
|
||||
): Set<String> {
|
||||
if (serversReferencedByOtherGroups == null) return emptySet()
|
||||
|
||||
return replacedServers.filterTo(linkedSetOf()) { guid ->
|
||||
guid != protectedServer &&
|
||||
guid !in replacementServers &&
|
||||
guid !in serversReferencedByOtherGroups
|
||||
}
|
||||
}
|
||||
|
||||
private fun isSameText(left: String?, right: String?): Boolean {
|
||||
if (left.isNullOrBlank() || right.isNullOrBlank()) return false
|
||||
return left.trim().equals(right.trim(), ignoreCase = true)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -27,6 +29,37 @@ object MessageHelper {
|
||||
sendMsg(ctx, AppConfig.BROADCAST_ACTION_SERVICE, what, content)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends an ordered service message and reports whether a daemon receiver handled it.
|
||||
* With no running daemon, the initial canceled result reaches [onResult] unchanged.
|
||||
*/
|
||||
internal fun sendMsg2ServiceForResult(
|
||||
ctx: Context,
|
||||
what: Int,
|
||||
content: Serializable,
|
||||
onResult: (handled: Boolean) -> Unit,
|
||||
) {
|
||||
val resultReceiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
onResult(resultCode == Activity.RESULT_OK)
|
||||
}
|
||||
}
|
||||
try {
|
||||
ctx.sendOrderedBroadcast(
|
||||
messageIntent(AppConfig.BROADCAST_ACTION_SERVICE, what, content),
|
||||
null,
|
||||
resultReceiver,
|
||||
null,
|
||||
Activity.RESULT_CANCELED,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send ordered message to service", e)
|
||||
onResult(false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to the UI.
|
||||
*
|
||||
@@ -115,14 +148,16 @@ object MessageHelper {
|
||||
*/
|
||||
private fun sendMsg(ctx: Context, action: String, what: Int, content: Serializable) {
|
||||
try {
|
||||
val intent = Intent()
|
||||
intent.action = action
|
||||
intent.`package` = AppConfig.ANG_PACKAGE
|
||||
intent.putExtra("key", what)
|
||||
intent.putExtra("content", content)
|
||||
ctx.sendBroadcast(intent)
|
||||
ctx.sendBroadcast(messageIntent(action, what, content))
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to send message with action: $action", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun messageIntent(action: String, what: Int, content: Serializable): Intent =
|
||||
Intent(action).apply {
|
||||
`package` = AppConfig.ANG_PACKAGE
|
||||
putExtra("key", what)
|
||||
putExtra("content", content)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.delay
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
import okhttp3.Call
|
||||
|
||||
@@ -10,7 +10,7 @@ import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
|
||||
@@ -18,9 +18,26 @@ import kotlinx.coroutines.asCoroutineDispatcher
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.joinAll
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
internal object RealPingExecutionLimiter {
|
||||
private val customConfigMutex = Mutex()
|
||||
|
||||
suspend fun <T> run(configType: EConfigType, block: () -> T): T {
|
||||
// Custom profiles bypass speed-test trimming and start complete Xray configs.
|
||||
// Parallel teardown can abort the native probe process, so serialize their
|
||||
// JNI measurements globally across batches.
|
||||
return if (configType == EConfigType.CUSTOM) {
|
||||
customConfigMutex.withLock { block() }
|
||||
} else {
|
||||
block()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Worker that runs a batch of real-ping tests independently.
|
||||
* Each batch owns its own CoroutineScope/dispatcher and can be cancelled separately.
|
||||
@@ -87,7 +104,7 @@ class RealPingWorkerService(
|
||||
}
|
||||
}
|
||||
|
||||
private fun startRealPing(guid: String): Long {
|
||||
private suspend fun startRealPing(guid: String): Long {
|
||||
val retFailure = -1L
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(guid) ?: return retFailure
|
||||
@@ -110,7 +127,9 @@ class RealPingWorkerService(
|
||||
if (!configResult.status) {
|
||||
return retFailure
|
||||
}
|
||||
return CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
return RealPingExecutionLimiter.run(config.configType) {
|
||||
CoreNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
|
||||
}
|
||||
}
|
||||
|
||||
private fun startTcping(guid: String): Long {
|
||||
@@ -120,7 +139,7 @@ class RealPingWorkerService(
|
||||
if (!config.configType.isComplexType()
|
||||
&& config.configType != EConfigType.HYSTERIA2
|
||||
&& config.configType != EConfigType.WIREGUARD
|
||||
&& config.alpn?.startsWith("h3") != true
|
||||
&& config.alpn?.split(',')?.all { it.trim().startsWith("h3") } != true
|
||||
&& config.server.isNotNullEmpty()
|
||||
&& config.serverPort?.toIntOrNull() != null
|
||||
) {
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.v2ray.ang.ui
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
@@ -12,7 +14,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -32,6 +33,7 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.ui.compose.VersionInfoBlock
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -58,7 +60,7 @@ fun AboutScreen(onBackClick: () -> Unit) {
|
||||
val appIdText = BuildConfig.APPLICATION_ID
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_about),
|
||||
@@ -101,6 +103,7 @@ fun AboutScreen(onBackClick: () -> Unit) {
|
||||
versionText = versionText,
|
||||
appIdText = appIdText
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -16,7 +17,7 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -35,6 +36,7 @@ import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppListItem
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
|
||||
private enum class AppPickerMenuAction(@StringRes val labelRes: Int) {
|
||||
@@ -127,7 +129,7 @@ fun AppPickerScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = title,
|
||||
@@ -184,7 +186,8 @@ fun AppPickerScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
items(items = apps, key = { it.packageName }) { app ->
|
||||
val checked = selectedPackages.contains(app.packageName)
|
||||
|
||||
@@ -7,13 +7,13 @@ import androidx.activity.viewModels
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -38,7 +38,9 @@ import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.ui.base.HelperBaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.InputDialog
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.InputField
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
@@ -105,6 +107,7 @@ class BackupActivity : HelperBaseComponentActivity() {
|
||||
BackupLocation.WebDav -> viewModel.restoreViaWebDav(cacheDir)
|
||||
}
|
||||
},
|
||||
onCleanupProfiles = viewModel::cleanupProfileStorage,
|
||||
onWebDavSave = { config -> viewModel.saveWebDavConfig(config) },
|
||||
onBackClick = { finish() }
|
||||
)
|
||||
@@ -186,6 +189,7 @@ fun BackupScreen(
|
||||
onBackupOptionSelected: (BackupLocation) -> Unit,
|
||||
onShareClick: () -> Unit,
|
||||
onRestoreOptionSelected: (BackupLocation) -> Unit,
|
||||
onCleanupProfiles: () -> Unit,
|
||||
onWebDavSave: (WebDavConfig) -> Unit,
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
@@ -193,12 +197,13 @@ fun BackupScreen(
|
||||
val currentWebDavConfig by webDavConfigState.collectAsState()
|
||||
var showBackupDialog by remember { mutableStateOf(false) }
|
||||
var showRestoreDialog by remember { mutableStateOf(false) }
|
||||
var showCleanupDialog by remember { mutableStateOf(false) }
|
||||
var showWebDavDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val webDavSummary = currentWebDavConfig?.baseUrl
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_configuration_backup_restore),
|
||||
@@ -228,6 +233,12 @@ fun BackupScreen(
|
||||
title = stringResource(R.string.title_configuration_restore),
|
||||
onClick = { showRestoreDialog = true }
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_delete_24dp),
|
||||
title = stringResource(R.string.title_profile_storage_cleanup),
|
||||
subtitle = stringResource(R.string.summary_profile_storage_cleanup),
|
||||
onClick = { showCleanupDialog = true }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_settings_24dp),
|
||||
@@ -235,6 +246,7 @@ fun BackupScreen(
|
||||
subtitle = webDavSummary,
|
||||
onClick = { showWebDavDialog = true }
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -262,6 +274,13 @@ fun BackupScreen(
|
||||
onDismiss = { showRestoreDialog = false }
|
||||
)
|
||||
}
|
||||
if (showCleanupDialog) {
|
||||
DeleteConfirmDialog(
|
||||
message = stringResource(R.string.message_profile_storage_cleanup),
|
||||
onConfirm = onCleanupProfiles,
|
||||
onDismiss = { showCleanupDialog = false }
|
||||
)
|
||||
}
|
||||
if (showWebDavDialog) {
|
||||
WebDavInputDialog(
|
||||
initialConfig = currentWebDavConfig,
|
||||
|
||||
@@ -13,9 +13,11 @@ import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.ui.base.ViewModelEvent
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.ZipUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
@@ -37,6 +39,24 @@ class BackupViewModel(application: Application) : BaseViewModel(application) {
|
||||
toastSuccess(R.string.toast_success)
|
||||
}
|
||||
|
||||
fun cleanupProfileStorage() {
|
||||
launchLoading {
|
||||
try {
|
||||
val removed = withContext(Dispatchers.IO) {
|
||||
MmkvManager.removeOrphanedServerProfiles()
|
||||
}
|
||||
if (removed == null) {
|
||||
toastError(R.string.toast_profile_storage_cleanup_skipped)
|
||||
} else {
|
||||
toastSuccess(getString(R.string.toast_profile_storage_cleanup, removed))
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to clean up profile storage", e)
|
||||
toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun shareBackup(cacheDir: File, appName: String) {
|
||||
launchLoading {
|
||||
val ret = backupConfigurationToCache(cacheDir, appName)
|
||||
@@ -178,4 +198,4 @@ class BackupViewModel(application: Application) : BaseViewModel(application) {
|
||||
|
||||
return count > 0
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,8 @@ package com.v2ray.ang.ui.checkupdate
|
||||
import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -11,7 +13,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -26,6 +27,7 @@ import com.v2ray.ang.R
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.VersionInfoBlock
|
||||
@@ -65,7 +67,7 @@ fun CheckUpdateScreen(
|
||||
val versionText = "v${BuildConfig.VERSION_NAME} ($libVersion)"
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.update_check_for_update),
|
||||
@@ -92,6 +94,7 @@ fun CheckUpdateScreen(
|
||||
onClick = { viewModel.checkForUpdates() }
|
||||
)
|
||||
VersionInfoBlock(versionText = versionText)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,17 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.windowInsetsBottomHeight
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||
import androidx.compose.material3.Checkbox
|
||||
@@ -225,6 +230,17 @@ fun AppDivider(modifier: Modifier = Modifier) {
|
||||
HorizontalDivider(modifier = modifier.fillMaxWidth(), thickness = 1.dp, color = color)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationBarsSpacer(modifier: Modifier = Modifier) {
|
||||
Spacer(modifier = modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun NavigationBarsBottomPadding(): PaddingValues {
|
||||
val bottom = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
return PaddingValues(bottom = bottom)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VersionInfoBlock(
|
||||
versionText: String,
|
||||
|
||||
@@ -26,7 +26,7 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.compose.currentStateAsState
|
||||
import kotlinx.coroutines.delay
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
data class ScrollbarConfig(
|
||||
|
||||
@@ -33,7 +33,7 @@ import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -88,7 +88,7 @@ class AppSnackbarController(
|
||||
if (currentShowTime != 0L) {
|
||||
val elapsed = System.currentTimeMillis() - currentShowTime
|
||||
if (elapsed < SnackbarThrottleMs) {
|
||||
delay((SnackbarThrottleMs - elapsed).milliseconds)
|
||||
delay((SnackbarThrottleMs - elapsed))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -21,7 +22,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -43,6 +43,7 @@ import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -139,7 +140,7 @@ fun LogcatScreen(
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_logcat),
|
||||
@@ -209,7 +210,8 @@ fun LogcatScreen(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
itemsIndexed(items = logs, key = { index, _ -> index }) { _, log ->
|
||||
LogcatItem(log = log, onLongClick = { Utils.setClipboard(context, log) })
|
||||
|
||||
@@ -75,10 +75,10 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
val restartService = data.getBooleanExtra(
|
||||
ProfileEditorResult.EXTRA_RESTART_SERVICE, false
|
||||
)
|
||||
val selectedProfileSaved = action == ProfileEditorResult.ACTION_SAVED &&
|
||||
data.getStringExtra(ProfileEditorResult.EXTRA_GUID) == mainViewModel.uiState.value.selectedGuid
|
||||
mainViewModel.onAction(MainAction.RefreshGroups)
|
||||
if (restartService && mainViewModel.uiState.value.isRunning) {
|
||||
restartV2Ray()
|
||||
}
|
||||
if (restartService || selectedProfileSaved) LauncherManager.restartService(this)
|
||||
}
|
||||
|
||||
private val settingsActivityLauncher =
|
||||
@@ -87,7 +87,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
val refreshGroups = SettingsChangeManager.consumeSetupGroupTab()
|
||||
mainViewModel.refreshUiSettings()
|
||||
if (refreshGroups) mainViewModel.onAction(MainAction.RefreshGroups)
|
||||
if (restartService && mainViewModel.uiState.value.isRunning) restartV2Ray()
|
||||
if (restartService) LauncherManager.restartService(this)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
@@ -110,7 +110,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
MainAction.ImportClipboard -> importClipboard()
|
||||
MainAction.ImportConfigLocal -> importConfigLocal()
|
||||
is MainAction.ImportManually -> importManually(action.type)
|
||||
MainAction.RestartService -> restartV2Ray()
|
||||
MainAction.RestartService -> LauncherManager.restartServiceOrStart(this, ::requestServiceStart)
|
||||
MainAction.LocateSelectedServer -> mainViewModel.triggerLocateSelectedServer()
|
||||
is MainAction.SelectServer -> setSelectServer(action.guid)
|
||||
is MainAction.EditServer -> editServer(action.guid, action.profile)
|
||||
@@ -161,14 +161,20 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
private fun handleFabAction() {
|
||||
if (mainViewModel.uiState.value.isRunning) {
|
||||
LauncherManager.stopService(this)
|
||||
} else if (SettingsManager.isVpnMode()) {
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) startV2Ray() else requestVpnPermission.launch(intent)
|
||||
} else {
|
||||
startV2Ray()
|
||||
requestServiceStart()
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestServiceStart() {
|
||||
if (!SettingsManager.isVpnMode()) {
|
||||
startV2Ray()
|
||||
return
|
||||
}
|
||||
val intent = VpnService.prepare(this)
|
||||
if (intent == null) startV2Ray() else requestVpnPermission.launch(intent)
|
||||
}
|
||||
|
||||
private fun handleLayoutTestClick() {
|
||||
if (mainViewModel.uiState.value.isRunning) {
|
||||
mainViewModel.testCurrentServerRealPing()
|
||||
@@ -188,14 +194,6 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
LauncherManager.startService(this)
|
||||
}
|
||||
|
||||
private fun restartV2Ray() {
|
||||
if (mainViewModel.uiState.value.isRunning) LauncherManager.stopService(this)
|
||||
lifecycleScope.launch {
|
||||
kotlinx.coroutines.delay(500)
|
||||
startV2Ray()
|
||||
}
|
||||
}
|
||||
|
||||
private fun importManually(createConfigType: Int) {
|
||||
val intent = when (createConfigType) {
|
||||
EConfigType.POLICYGROUP.value -> Intent(this, ServerGroupActivity::class.java)
|
||||
@@ -275,7 +273,7 @@ class MainActivity : HelperBaseComponentActivity() {
|
||||
val selected = mainViewModel.uiState.value.selectedGuid
|
||||
if (guid != selected) {
|
||||
mainViewModel.updateSelectedGuid(guid)
|
||||
if (mainViewModel.uiState.value.isRunning) restartV2Ray()
|
||||
LauncherManager.restartService(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.v2ray.ang.ui.main
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -17,11 +18,12 @@ import androidx.compose.foundation.layout.windowInsetsPadding
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
@@ -40,26 +42,29 @@ fun MainBottomBar(
|
||||
onAction: (MainAction) -> Unit
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clickable(onClick = { onAction(MainAction.TestCurrentServer) })
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
) {
|
||||
AppDivider()
|
||||
Surface(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.windowInsetsPadding(WindowInsets.navigationBars)
|
||||
.height(64.dp)
|
||||
.clickable(onClick = { onAction(MainAction.TestCurrentServer) }),
|
||||
color = MaterialTheme.colorScheme.surface,
|
||||
tonalElevation = 0.dp
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(text = displayText, style = MaterialTheme.typography.bodyMedium)
|
||||
}
|
||||
Text(
|
||||
text = displayText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = displayText
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
FloatingActionButton(
|
||||
|
||||
@@ -30,7 +30,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.ui.compose.LocalDarkTheme
|
||||
import com.v2ray.ang.ui.compose.QRCodeDialog
|
||||
import kotlinx.coroutines.delay
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -130,7 +130,7 @@ fun MainScreen(
|
||||
lazyListStates[target.groupId] != null
|
||||
}
|
||||
if (ready) return@repeat
|
||||
delay(16L)
|
||||
delay(16)
|
||||
}
|
||||
|
||||
if (latestDoubleColumnDisplay) {
|
||||
@@ -149,7 +149,7 @@ fun MainScreen(
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
delay(32L)
|
||||
delay(32)
|
||||
locateInProgress = false
|
||||
mainViewModel.onAction(MainAction.LocateHandled(target))
|
||||
}
|
||||
|
||||
@@ -37,6 +37,8 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.LineBreak
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -319,11 +321,20 @@ fun ServerListItem(
|
||||
} else {
|
||||
stringResource(R.string.server_test_delay_value, testDelayMillis)
|
||||
}
|
||||
|
||||
val selectedStateDescription = if (isSelected) {
|
||||
stringResource(R.string.acc_selected_server)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(IntrinsicSize.Min)
|
||||
.semantics {
|
||||
if (selectedStateDescription != null) {
|
||||
stateDescription = selectedStateDescription
|
||||
}
|
||||
}
|
||||
.clickable(onClick = onClick)
|
||||
.then(dragModifier)
|
||||
) {
|
||||
@@ -355,12 +366,34 @@ fun ServerListItem(
|
||||
Text(remarks, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), maxLines = 2, overflow = TextOverflow.Ellipsis)
|
||||
if (doubleColumnDisplay) {
|
||||
IconButton(onClick = onMore, Modifier.size(36.dp)) {
|
||||
Icon(painterResource(R.drawable.ic_more_vert_24dp), null, Modifier.size(24.dp))
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
stringResource(R.string.acc_more),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = onShare, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_share_24dp), null, Modifier.size(24.dp)) }
|
||||
IconButton(onClick = onEdit, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_edit_24dp), null, Modifier.size(24.dp)) }
|
||||
IconButton(onClick = onRemove, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_delete_24dp), null, Modifier.size(24.dp)) }
|
||||
IconButton(onClick = onShare, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_share_24dp),
|
||||
stringResource(R.string.title_configuration_share),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onEdit, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_edit_24dp),
|
||||
stringResource(R.string.acc_edit),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
IconButton(onClick = onRemove, Modifier.size(36.dp)) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_delete_24dp),
|
||||
stringResource(R.string.acc_delete),
|
||||
Modifier.size(24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
|
||||
@@ -24,7 +24,7 @@ import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import com.v2ray.ang.extension.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
@@ -222,7 +222,7 @@ class MainViewModel(
|
||||
viewModelScope.launch(preloadDispatcher) {
|
||||
try {
|
||||
initialPageReady.await()
|
||||
delay(32L)
|
||||
delay(32)
|
||||
dataSource.initAssets()
|
||||
dataSource.syncSubscriptions()
|
||||
} catch (cancelled: CancellationException) {
|
||||
@@ -364,7 +364,7 @@ class MainViewModel(
|
||||
preloadJob = viewModelScope.launch(preloadDispatcher) {
|
||||
preloadOrder.forEach { groupId ->
|
||||
ensureActive()
|
||||
delay(32L)
|
||||
delay(32)
|
||||
val servers = loadGroup(groupId, forceRefresh)
|
||||
updateGroupUi(groupId, servers)
|
||||
}
|
||||
@@ -617,7 +617,7 @@ class MainViewModel(
|
||||
}
|
||||
order.forEachIndexed { index, groupId ->
|
||||
ensureActive()
|
||||
if (index > 0) delay(32L)
|
||||
if (index > 0) delay(32)
|
||||
updateGroupUi(groupId, loadGroup(groupId, forceRefresh = true))
|
||||
}
|
||||
}
|
||||
@@ -628,7 +628,7 @@ class MainViewModel(
|
||||
keywordFilter = keyword
|
||||
filterJob?.cancel()
|
||||
filterJob = viewModelScope.launch(defaultDispatcher) {
|
||||
delay(300L)
|
||||
delay(300)
|
||||
val snapshot = cacheMutex.withLock { groupDataCache.toMap() }
|
||||
ensureActive()
|
||||
snapshot.forEach { (groupId, servers) ->
|
||||
@@ -689,11 +689,17 @@ class MainViewModel(
|
||||
dataSource.cancelAllPing()
|
||||
val groupId = uiState.value.selectedGroupId
|
||||
val servers = currentServers()
|
||||
dataSource.clearAllTestDelayResults(servers.map { it.guid })
|
||||
if (servers.isEmpty()) {
|
||||
_uiState.update { it.copy(isTesting = false) }
|
||||
return
|
||||
}
|
||||
val serverGuids = servers.map { it.guid }
|
||||
mutableServersForGroup(groupId).update { current ->
|
||||
current.map { server ->
|
||||
if (server.testDelayMillis == 0L) server
|
||||
else server.copy(testDelayMillis = 0L)
|
||||
}
|
||||
}
|
||||
testingGroupId = groupId
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
@@ -702,12 +708,13 @@ class MainViewModel(
|
||||
)
|
||||
}
|
||||
viewModelScope.launch(ioDispatcher) {
|
||||
dataSource.clearAllTestDelayResults(serverGuids)
|
||||
cacheMutex.withLock { groupDataCache.remove(groupId) }
|
||||
dataSource.sendMsg2TestService(
|
||||
TestServiceMessage(
|
||||
key = AppConfig.MSG_MEASURE_CONFIG_START,
|
||||
subscriptionId = groupId,
|
||||
serverGuids = if (keywordFilter.isNotEmpty()) servers.map { it.guid } else emptyList(),
|
||||
serverGuids = if (keywordFilter.isNotEmpty()) serverGuids else emptyList(),
|
||||
onlyTcp = onlyTcp
|
||||
)
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -20,7 +21,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
@@ -49,6 +49,8 @@ import com.v2ray.ang.ui.compose.AppDropdownMenuItems
|
||||
import com.v2ray.ang.ui.compose.AppListItem
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.colorFabActive
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
@@ -136,7 +138,7 @@ fun PerAppProxyScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.per_app_proxy_settings),
|
||||
@@ -167,7 +169,7 @@ fun PerAppProxyScreen(
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
contentDescription = null
|
||||
contentDescription = stringResource(R.string.acc_more)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
@@ -263,7 +265,8 @@ fun PerAppProxyScreen(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
items(items = apps, key = { it.packageName }) { app ->
|
||||
val checked = blacklist.contains(app.packageName)
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -18,7 +19,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -46,6 +46,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormDropdownField
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -178,7 +179,7 @@ fun RoutingEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.routing_settings_rule_title),
|
||||
@@ -302,6 +303,7 @@ fun RoutingEditScreen(
|
||||
editable = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(36.dp))
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
|
||||
if (showDeleteConfirm) {
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -22,7 +23,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -58,6 +58,8 @@ import com.v2ray.ang.ui.compose.ReorderableListItem
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsListItem
|
||||
import com.v2ray.ang.ui.compose.colorConfigType
|
||||
import com.v2ray.ang.ui.compose.colorFabActive
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -215,7 +217,7 @@ fun RoutingSettingScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.routing_settings_title),
|
||||
@@ -231,7 +233,7 @@ fun RoutingSettingScreen(
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
contentDescription = null
|
||||
contentDescription = stringResource(R.string.acc_more)
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
@@ -259,7 +261,8 @@ fun RoutingSettingScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(lazyListState)
|
||||
.verticalScrollbar(lazyListState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
item(key = "domain_strategy") {
|
||||
SettingsListItem(
|
||||
|
||||
@@ -4,6 +4,8 @@ import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -14,7 +16,6 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -44,6 +45,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormDropdownField
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
@@ -448,7 +450,7 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
var showDeleteDialog by rememberSaveable { mutableStateOf(false) }
|
||||
val scrollState = rememberScrollState()
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = title,
|
||||
@@ -481,9 +483,11 @@ abstract class BaseServerActivity : BaseComponentActivity() {
|
||||
.verticalScroll(scrollState)
|
||||
.verticalScrollbar(scrollState)
|
||||
.padding(bottom = 36.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
content = content
|
||||
)
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
content()
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
if (showDeleteDialog) {
|
||||
DeleteConfirmDialog(
|
||||
|
||||
@@ -3,9 +3,12 @@ package com.v2ray.ang.ui.server
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
@@ -13,7 +16,6 @@ import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -339,7 +341,7 @@ fun ServerScreen(
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = configType.toString(),
|
||||
@@ -367,7 +369,7 @@ fun ServerScreen(
|
||||
.consumeWindowInsets(innerPadding)
|
||||
.imePadding()
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = PaddingValues(bottom = 36.dp),
|
||||
contentPadding = PaddingValues(bottom = 36.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
item { FormTextField(stringResource(R.string.server_lab_remarks), remarks, { remarks = it }) }
|
||||
|
||||
@@ -6,6 +6,8 @@ import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
@@ -25,7 +27,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
@@ -66,6 +67,7 @@ import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.horizontalScrollbar
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
@@ -310,7 +312,7 @@ fun ServerCustomConfigScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = EConfigType.CUSTOM.toString(),
|
||||
@@ -478,6 +480,7 @@ fun ServerCustomConfigScreen(
|
||||
.horizontalScrollbar(scrollState = horizontalScroll)
|
||||
)
|
||||
}
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ package com.v2ray.ang.ui.server
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -11,7 +13,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -39,6 +40,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormDropdownField
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
|
||||
class ServerGroupActivity : BaseComponentActivity() {
|
||||
@@ -237,7 +239,7 @@ fun ServerGroupScreen(
|
||||
val supportsObservatory = BalancerStrategyType.from(selectedType).supportsObservatory
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = EConfigType.POLICYGROUP.toString(),
|
||||
@@ -297,6 +299,7 @@ fun ServerGroupScreen(
|
||||
editable = true
|
||||
)
|
||||
}
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,13 @@ import android.os.Bundle
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.asPaddingValues
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBars
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -20,7 +23,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -220,7 +222,7 @@ fun ProxyChainScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = EConfigType.PROXYCHAIN.toString(),
|
||||
@@ -263,7 +265,7 @@ fun ProxyChainScreen(
|
||||
top = 8.dp,
|
||||
start = 16.dp,
|
||||
end = 16.dp,
|
||||
bottom = 36.dp
|
||||
bottom = 36.dp + WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding()
|
||||
)
|
||||
) {
|
||||
item(key = "remarks_field") {
|
||||
|
||||
@@ -5,6 +5,7 @@ import android.os.Bundle
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -12,7 +13,6 @@ import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -37,6 +37,7 @@ import com.v2ray.ang.root.RootManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.CollapsiblePreferenceGroupHeader
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsEditItem
|
||||
import com.v2ray.ang.ui.compose.SettingsListItem
|
||||
import com.v2ray.ang.ui.compose.SettingsMenuItem
|
||||
@@ -186,7 +187,7 @@ fun SettingsScreen(
|
||||
val modeValues = stringArrayResource(R.array.mode_value).toList()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_settings),
|
||||
@@ -677,6 +678,7 @@ fun SettingsScreen(
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -19,7 +20,6 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
@@ -35,6 +35,7 @@ import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
@@ -127,7 +128,7 @@ fun TaskerScreen(
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = "",
|
||||
@@ -154,7 +155,8 @@ fun TaskerScreen(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
itemsIndexed(items) { index, remarks ->
|
||||
Row(
|
||||
|
||||
@@ -3,6 +3,8 @@ package com.v2ray.ang.ui.subscription
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.consumeWindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.imePadding
|
||||
@@ -12,7 +14,6 @@ import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -40,6 +41,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormDropdownField
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -163,7 +165,7 @@ fun SubEditScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_sub_setting),
|
||||
@@ -239,6 +241,7 @@ fun SubEditScreen(
|
||||
editable = true,
|
||||
supportingText = stringResource(R.string.sub_setting_exit_proxy_tip)
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -20,7 +21,6 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
@@ -51,6 +51,8 @@ import com.v2ray.ang.ui.compose.QRCodeDialog
|
||||
import com.v2ray.ang.ui.compose.ReorderableListItem
|
||||
import com.v2ray.ang.ui.compose.SelectListDialog
|
||||
import com.v2ray.ang.ui.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.ui.compose.colorFabActive
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.QRCodeDecoder
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -126,7 +128,7 @@ fun SubSettingScreen(
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_sub_setting),
|
||||
@@ -148,7 +150,8 @@ fun SubSettingScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(lazyListState)
|
||||
.verticalScrollbar(lazyListState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = subscriptions,
|
||||
|
||||
@@ -10,6 +10,7 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
@@ -25,10 +26,8 @@ import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -58,6 +57,7 @@ import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.ItemDivider
|
||||
import com.v2ray.ang.ui.compose.SettingsListItem
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
|
||||
import com.v2ray.ang.ui.compose.verticalScrollbar
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
@@ -75,13 +75,14 @@ private enum class AddAssetMenuAction(@StringRes val labelRes: Int) {
|
||||
QRCode(R.string.menu_item_scan_qrcode)
|
||||
}
|
||||
|
||||
private data class AssetDeleteTarget(val guid: String, val name: String)
|
||||
|
||||
class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
|
||||
private val viewModel: UserAssetViewModel by viewModels()
|
||||
val extDir by lazy { File(Utils.userAssetPath(this)) }
|
||||
private val extDir by lazy { File(Utils.userAssetPath(this)) }
|
||||
private val isLoadingState = MutableStateFlow(false)
|
||||
private val geoFilesSourceState = MutableStateFlow("")
|
||||
private val refreshTrigger = MutableStateFlow(0)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -90,12 +91,13 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
val isLoading by isLoadingState.collectAsStateWithLifecycle()
|
||||
val geoFilesSource by geoFilesSourceState.collectAsStateWithLifecycle()
|
||||
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
UserAssetScreen(
|
||||
viewModel = viewModel,
|
||||
extDir = extDir,
|
||||
isLoadingState = isLoadingState,
|
||||
geoFilesSourceState = geoFilesSourceState,
|
||||
refreshTrigger = refreshTrigger,
|
||||
uiState = uiState,
|
||||
isLoading = isLoading,
|
||||
geoFilesSource = geoFilesSource,
|
||||
geoFilesSourcesList = AppConfig.GEO_FILES_SOURCES.toList(),
|
||||
onBackClick = { finish() },
|
||||
onGeoSourceSelected = { value ->
|
||||
@@ -110,13 +112,10 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
onEditAsset = { guid ->
|
||||
startActivity(Intent(this, UserAssetUrlActivity::class.java).putExtra("assetId", guid))
|
||||
},
|
||||
onRemoveAsset = { guid ->
|
||||
val asset = viewModel.getAssets().find { it.guid == guid }
|
||||
if (asset != null) {
|
||||
extDir.listFiles()?.find { it.name == asset.assetUrl.remarks }?.delete()
|
||||
MmkvManager.removeAssetUrl(guid)
|
||||
initAssets()
|
||||
}
|
||||
onRemoveAsset = { guid, name ->
|
||||
extDir.listFiles()?.find { it.name == name }?.delete()
|
||||
MmkvManager.removeAssetUrl(guid)
|
||||
initAssets()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -208,28 +207,28 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
}
|
||||
|
||||
private fun downloadGeoFiles() {
|
||||
refreshData()
|
||||
isLoadingState.value = true
|
||||
toast(R.string.msg_downloading_content)
|
||||
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
|
||||
withContext(Dispatchers.Main) {
|
||||
if (result.successCount > 0) {
|
||||
toast(getString(R.string.title_update_config_count, result.successCount))
|
||||
} else {
|
||||
toast(getString(R.string.toast_failure))
|
||||
}
|
||||
refreshData()
|
||||
isLoadingState.value = false
|
||||
lifecycleScope.launch {
|
||||
refreshData().join()
|
||||
val result = withContext(Dispatchers.IO) {
|
||||
viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
|
||||
}
|
||||
if (result.successCount > 0) {
|
||||
toast(getString(R.string.title_update_asset_count, result.successCount))
|
||||
} else {
|
||||
toast(getString(R.string.toast_failure))
|
||||
}
|
||||
refreshData().join()
|
||||
isLoadingState.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun initAssets() {
|
||||
private fun initAssets() {
|
||||
lifecycleScope.launch(Dispatchers.Default) {
|
||||
SettingsManager.initAssets(this@UserAssetActivity, assets)
|
||||
withContext(Dispatchers.Main) {
|
||||
@@ -238,19 +237,14 @@ class UserAssetActivity : HelperBaseComponentActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshData() {
|
||||
viewModel.reload(getGeoFilesSources())
|
||||
refreshTrigger.value++
|
||||
}
|
||||
private fun refreshData() = viewModel.reload(getGeoFilesSources(), extDir)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun UserAssetScreen(
|
||||
viewModel: UserAssetViewModel,
|
||||
extDir: File,
|
||||
isLoadingState: MutableStateFlow<Boolean>,
|
||||
geoFilesSourceState: MutableStateFlow<String>,
|
||||
refreshTrigger: MutableStateFlow<Int>,
|
||||
internal fun UserAssetScreen(
|
||||
uiState: UserAssetUiState,
|
||||
isLoading: Boolean,
|
||||
geoFilesSource: String,
|
||||
geoFilesSourcesList: List<String>,
|
||||
onBackClick: () -> Unit,
|
||||
onGeoSourceSelected: (String) -> Unit,
|
||||
@@ -259,19 +253,14 @@ fun UserAssetScreen(
|
||||
onAddQrcodeClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit,
|
||||
onEditAsset: (String) -> Unit,
|
||||
onRemoveAsset: (String) -> Unit
|
||||
onRemoveAsset: (String, String) -> Unit
|
||||
) {
|
||||
val isLoading by isLoadingState.collectAsState()
|
||||
val geoFilesSource by geoFilesSourceState.collectAsState()
|
||||
val assets by viewModel.assetsFlow.collectAsStateWithLifecycle()
|
||||
val trigger by refreshTrigger.collectAsState()
|
||||
|
||||
var showAddMenu by remember { mutableStateOf(false) }
|
||||
var deleteTargetGuid by remember { mutableStateOf<String?>(null) }
|
||||
var deleteTarget by remember { mutableStateOf<AssetDeleteTarget?>(null) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_user_asset_setting),
|
||||
@@ -311,9 +300,10 @@ fun UserAssetScreen(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(listState)
|
||||
.verticalScrollbar(listState),
|
||||
contentPadding = NavigationBarsBottomPadding()
|
||||
) {
|
||||
item(key = "geo_source_$trigger") {
|
||||
item(key = "geo_source") {
|
||||
SettingsListItem(
|
||||
title = stringResource(R.string.asset_geo_files_sources),
|
||||
entries = geoFilesSourcesList,
|
||||
@@ -329,12 +319,14 @@ fun UserAssetScreen(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
itemsIndexed(items = assets, key = { _, item -> "${item.guid}_$trigger" }) { _, item ->
|
||||
itemsIndexed(items = uiState.assets, key = { _, item -> item.guid }) { _, item ->
|
||||
UserAssetItem(
|
||||
item = item,
|
||||
extDir = extDir,
|
||||
fileMetadata = uiState.fileMetadata[item.guid],
|
||||
onEdit = { onEditAsset(item.guid) },
|
||||
onDeleteClick = { deleteTargetGuid = item.guid }
|
||||
onDeleteClick = {
|
||||
deleteTarget = AssetDeleteTarget(item.guid, item.assetUrl.remarks)
|
||||
}
|
||||
)
|
||||
ItemDivider()
|
||||
}
|
||||
@@ -342,13 +334,14 @@ fun UserAssetScreen(
|
||||
}
|
||||
|
||||
|
||||
if (deleteTargetGuid != null) {
|
||||
val guid = deleteTargetGuid!!
|
||||
val assetName = assets.find { it.guid == guid }?.assetUrl?.remarks ?: ""
|
||||
deleteTarget?.let { asset ->
|
||||
DeleteConfirmDialog(
|
||||
message = stringResource(R.string.confirm_delete_asset_file, assetName),
|
||||
onConfirm = { onRemoveAsset(guid) },
|
||||
onDismiss = { deleteTargetGuid = null }
|
||||
message = stringResource(R.string.confirm_delete_asset_file, asset.name),
|
||||
onConfirm = {
|
||||
deleteTarget = null
|
||||
onRemoveAsset(asset.guid, asset.name)
|
||||
},
|
||||
onDismiss = { deleteTarget = null }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -356,16 +349,15 @@ fun UserAssetScreen(
|
||||
@Composable
|
||||
private fun UserAssetItem(
|
||||
item: AssetUrlCache,
|
||||
extDir: File,
|
||||
fileMetadata: AssetFileMetadata?,
|
||||
onEdit: () -> Unit,
|
||||
onDeleteClick: () -> Unit
|
||||
) {
|
||||
val file = remember(item.guid, item.assetUrl.remarks) {
|
||||
extDir.listFiles()?.find { it.name == item.assetUrl.remarks }
|
||||
}
|
||||
val propertiesText = if (file != null) {
|
||||
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
|
||||
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}"
|
||||
val propertiesText = if (fileMetadata != null) {
|
||||
remember(fileMetadata) {
|
||||
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
|
||||
"${fileMetadata.length.toTrafficString()} • ${dateFormat.format(Date(fileMetadata.lastModified))}"
|
||||
}
|
||||
} else {
|
||||
stringResource(R.string.msg_file_not_found)
|
||||
}
|
||||
|
||||
@@ -3,12 +3,13 @@ package com.v2ray.ang.ui.userasset
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -28,6 +29,7 @@ import com.v2ray.ang.ui.base.BaseComponentActivity
|
||||
import com.v2ray.ang.ui.compose.AppTopBar
|
||||
import com.v2ray.ang.ui.compose.DeleteConfirmDialog
|
||||
import com.v2ray.ang.ui.compose.FormTextField
|
||||
import com.v2ray.ang.ui.compose.NavigationBarsSpacer
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.io.File
|
||||
@@ -142,7 +144,7 @@ fun UserAssetUrlScreen(
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
contentWindowInsets = WindowInsets(0),
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_user_asset_add_url),
|
||||
@@ -182,6 +184,7 @@ fun UserAssetUrlScreen(
|
||||
value = url,
|
||||
onValueChange = { url = it }
|
||||
)
|
||||
NavigationBarsSpacer()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.v2ray.ang.ui.userasset
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.dto.entities.AssetUrlCache
|
||||
@@ -11,30 +12,42 @@ import com.v2ray.ang.ui.base.BaseViewModel
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
|
||||
internal data class AssetFileMetadata(val length: Long, val lastModified: Long)
|
||||
|
||||
internal data class UserAssetUiState(
|
||||
val assets: List<AssetUrlCache> = emptyList(),
|
||||
val fileMetadata: Map<String, AssetFileMetadata> = emptyMap()
|
||||
)
|
||||
|
||||
class UserAssetViewModel(application: Application) : BaseViewModel(application) {
|
||||
private val assets = mutableListOf<AssetUrlCache>()
|
||||
private val builtInGeoFiles = listOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
||||
|
||||
private val _assetsFlow = MutableStateFlow<List<AssetUrlCache>>(emptyList())
|
||||
val assetsFlow: StateFlow<List<AssetUrlCache>> = _assetsFlow.asStateFlow()
|
||||
private val _uiState = MutableStateFlow(UserAssetUiState())
|
||||
internal val uiState: StateFlow<UserAssetUiState> = _uiState.asStateFlow()
|
||||
private var reloadJob: Job? = null
|
||||
|
||||
val itemCount: Int
|
||||
get() = assets.size
|
||||
|
||||
fun getAssets(): List<AssetUrlCache> = assets.toList()
|
||||
|
||||
fun getAsset(position: Int): AssetUrlCache? = assets.getOrNull(position)
|
||||
|
||||
fun reload(geoFilesSource: String) {
|
||||
val decoded = MmkvManager.decodeAssetUrls()
|
||||
assets.clear()
|
||||
assets.addAll(buildAssetList(decoded, geoFilesSource))
|
||||
_assetsFlow.value = assets.toList()
|
||||
fun reload(geoFilesSource: String, extDir: File): Job {
|
||||
reloadJob?.cancel()
|
||||
return viewModelScope.launch(Dispatchers.IO) {
|
||||
val snapshot = buildAssetList(MmkvManager.decodeAssetUrls(), geoFilesSource)
|
||||
val files = extDir.listFiles().orEmpty().associateBy { it.name }
|
||||
val metadata = snapshot.mapNotNull { asset ->
|
||||
files[asset.assetUrl.remarks]?.let { file ->
|
||||
asset.guid to AssetFileMetadata(file.length(), file.lastModified())
|
||||
}
|
||||
}.toMap()
|
||||
ensureActive()
|
||||
_uiState.value = UserAssetUiState(snapshot, metadata)
|
||||
}.also { reloadJob = it }
|
||||
}
|
||||
|
||||
private fun buildAssetList(
|
||||
@@ -74,7 +87,7 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application)
|
||||
proxyUsername: String? = null,
|
||||
proxyPassword: String? = null
|
||||
): GeoDownloadResult {
|
||||
val snapshot = getAssets()
|
||||
val snapshot = uiState.value.assets
|
||||
var successCount = 0
|
||||
val failures = mutableListOf<String>()
|
||||
|
||||
@@ -127,4 +140,4 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application)
|
||||
val failureCount: Int,
|
||||
val failedAssets: List<String>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -348,6 +348,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_update_asset_count">تم تحديث %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="toast_server_not_found_in_group">لم يُعثر على الخادم المحدد في المجموعة الحالية</string>
|
||||
@@ -435,6 +436,11 @@
|
||||
<string name="title_configuration_backup_restore">النسخ الاحتياطي والاستعادة</string>
|
||||
<string name="title_configuration_backup">نسخ التكوين احتياطيًا</string>
|
||||
<string name="title_configuration_restore">استعادة التكوين</string>
|
||||
<string name="title_profile_storage_cleanup">إصلاح بيانات ملفات التعريف</string>
|
||||
<string name="summary_profile_storage_cleanup">حذف بيانات ملفات التعريف غير المستخدمة</string>
|
||||
<string name="message_profile_storage_cleanup">هل تريد حذف بيانات ملفات التعريف التي لم تعد مستخدمة في أي مجموعة؟ سيُحتفظ بملف التعريف المحدد وأي بيانات يتعذر التأكد من أنها غير مستخدمة.</string>
|
||||
<string name="toast_profile_storage_cleanup">تم حذف ملفات التعريف غير المستخدمة: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">تعذّر تحديد بيانات ملفات التعريف غير المستخدمة بأمان. لم يُحذف أي شيء.</string>
|
||||
<string name="title_configuration_share">مشاركة التكوين</string>
|
||||
<string name="title_webdav_config_setting">إعدادات WebDAV</string>
|
||||
<string name="title_webdav_config_setting_unknown">يرجى إعداد WebDAV أولاً.</string>
|
||||
@@ -514,6 +520,7 @@
|
||||
<string name="acc_search">بحث</string>
|
||||
<string name="acc_add">إضافة تكوين</string>
|
||||
<string name="acc_more">المزيد من الخيارات</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">مشاركة رابط الاشتراك</string>
|
||||
<string name="acc_share_log">مشاركة السجل</string>
|
||||
<string name="acc_remove">إزالة عضو من سلسلة الوكلاء</string>
|
||||
|
||||
@@ -348,6 +348,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_update_asset_count">%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="toast_server_not_found_in_group">বর্তমান গ্রুপে নির্বাচিত সার্ভারটি পাওয়া যায়নি</string>
|
||||
@@ -435,6 +436,11 @@
|
||||
<string name="title_configuration_backup_restore">ব্যাকআপ ও পুনরুদ্ধার</string>
|
||||
<string name="title_configuration_backup">কনফিগারেশন ব্যাকআপ</string>
|
||||
<string name="title_configuration_restore">কনফিগারেশন পুনরুদ্ধার</string>
|
||||
<string name="title_profile_storage_cleanup">প্রোফাইল স্টোরেজ মেরামত করুন</string>
|
||||
<string name="summary_profile_storage_cleanup">অব্যবহৃত প্রোফাইল ডেটা মুছে ফেলুন</string>
|
||||
<string name="message_profile_storage_cleanup">কোনো গ্রুপে আর ব্যবহৃত হয় না এমন প্রোফাইল ডেটা মুছে ফেলবেন? নির্বাচিত প্রোফাইল এবং নিরাপদে অব্যবহৃত বলে নির্ধারণ করা যায় না এমন ডেটা রাখা হবে।</string>
|
||||
<string name="toast_profile_storage_cleanup">অব্যবহৃত প্রোফাইল মুছে ফেলা হয়েছে: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">কোন প্রোফাইল ডেটা অব্যবহৃত তা নিরাপদে নির্ধারণ করা যায়নি। কিছুই মুছে ফেলা হয়নি।</string>
|
||||
<string name="title_configuration_share">কনফিগারেশন শেয়ার করুন</string>
|
||||
<string name="title_webdav_config_setting">WebDAV সেটিংস</string>
|
||||
<string name="title_webdav_config_setting_unknown">প্রথমে WebDAV কনফিগার করুন।</string>
|
||||
@@ -514,6 +520,7 @@
|
||||
<string name="acc_search">অনুসন্ধান করুন</string>
|
||||
<string name="acc_add">কনফিগারেশন যোগ করুন</string>
|
||||
<string name="acc_more">আরও বিকল্প</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">সাবস্ক্রিপশন লিংক শেয়ার করুন</string>
|
||||
<string name="acc_share_log">লগ শেয়ার করুন</string>
|
||||
<string name="acc_remove">প্রক্সি চেইনের সদস্য সরান</string>
|
||||
|
||||
@@ -348,6 +348,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_update_asset_count">%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="toast_server_not_found_in_group">سرور پسند وابیڌه من بونکۊ سکویی نجۊرست</string>
|
||||
@@ -435,6 +436,11 @@
|
||||
<string name="title_configuration_backup_restore">لادراری گرؽڌن & وورگندن</string>
|
||||
<string name="title_configuration_backup">لادراری گرؽڌن ز کانفیگ</string>
|
||||
<string name="title_configuration_restore">وورگندن کانفیگ</string>
|
||||
<string name="title_profile_storage_cleanup">جۊر کردن داڌه یل کانفیگا</string>
|
||||
<string name="summary_profile_storage_cleanup">پاک کردن داڌه یل کانفیگا ک دی و کار نؽن</string>
|
||||
<string name="message_profile_storage_cleanup">داڌه یل کانفیگا ک دی من هیچ بونکۊی و کار نؽن پاک ابۊن، هنی هم اخۊی پاکسووݩ کۊنی؟ کانفیگا پسند وابیڌه وو داڌه یلی ک نتری وا موطمئنی بفهمی دی و کار نؽن پاک نابۊن.</string>
|
||||
<string name="toast_profile_storage_cleanup">کانفیگا ک دی و کار نؽن پاک وابیڌن: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">نتری وا موطمئنی بفهمی ک کۊی داڌه یل کانفیگا دی و کار نؽن. هیچ داڌه ای پاک نوابی.</string>
|
||||
<string name="title_configuration_share">یک رسۊوی کانفیگ</string>
|
||||
<string name="title_webdav_config_setting">WebDAV سامووا</string>
|
||||
<string name="title_webdav_config_setting_unknown">ٱول WebDAV ن کانفیگ کۊنین</string>
|
||||
@@ -514,6 +520,7 @@
|
||||
<string name="acc_search">پیتینیڌن</string>
|
||||
<string name="acc_add">ٱووردن کانفیگ</string>
|
||||
<string name="acc_more">امکووݩا بؽتر</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">یک رسۊوی لینک اشتراک</string>
|
||||
<string name="acc_share_log">یک رسۊوی گوزارش</string>
|
||||
<string name="acc_remove">پاک کردن عوزو زنجیره پروکسی</string>
|
||||
|
||||
@@ -348,6 +348,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_update_asset_count">%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="toast_server_not_found_in_group">سرور انتخابشده در گروه فعلی پیدا نشد</string>
|
||||
@@ -435,6 +436,11 @@
|
||||
<string name="title_configuration_backup_restore">پشتیبانگیری و بازیابی</string>
|
||||
<string name="title_configuration_backup">پشتیبان گیری از پیکربندی</string>
|
||||
<string name="title_configuration_restore">بازیابی پیکربندی</string>
|
||||
<string name="title_profile_storage_cleanup">ترمیم فضای ذخیرهسازی پروفایلها</string>
|
||||
<string name="summary_profile_storage_cleanup">حذف دادههای پروفایل بلااستفاده</string>
|
||||
<string name="message_profile_storage_cleanup">دادههای پروفایلی که دیگر در هیچ گروهی استفاده نمیشوند حذف شوند؟ پروفایل انتخابشده و دادههایی که نمیتوان با اطمینان بلااستفاده بودنشان را تشخیص داد حفظ خواهند شد.</string>
|
||||
<string name="toast_profile_storage_cleanup">پروفایلهای بلااستفاده حذف شدند: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">امکان تشخیص مطمئن دادههای پروفایل بلااستفاده وجود نداشت. هیچ موردی حذف نشد.</string>
|
||||
<string name="title_configuration_share">اشتراک گذاری پیکربندی</string>
|
||||
<string name="title_webdav_config_setting">تنظیمات WebDAV</string>
|
||||
<string name="title_webdav_config_setting_unknown">لطفاً ابتدا WebDAV را پیکربندی کنید.</string>
|
||||
@@ -514,6 +520,7 @@
|
||||
<string name="acc_search">جستجو</string>
|
||||
<string name="acc_add">افزودن کانفیگ</string>
|
||||
<string name="acc_more">گزینههای بیشتر</string>
|
||||
<string name="acc_selected_server">انتخاب شده</string>
|
||||
<string name="acc_share_subscription">اشتراکگذاری لینک اشتراک</string>
|
||||
<string name="acc_share_log">اشتراکگذاری گزارش</string>
|
||||
<string name="acc_remove">حذف عضو زنجیره پروکسی</string>
|
||||
|
||||
@@ -348,6 +348,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_update_asset_count">Обновлено файлов ресурсов: %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="toast_server_not_found_in_group">Выбранный профиль не найден в текущей группе</string>
|
||||
@@ -435,6 +436,11 @@
|
||||
<string name="title_configuration_backup_restore">Резервное копирование и восстановление</string>
|
||||
<string name="title_configuration_backup">Создать резервную копию</string>
|
||||
<string name="title_configuration_restore">Восстановление конфигурации</string>
|
||||
<string name="title_profile_storage_cleanup">Исправить хранилище профилей</string>
|
||||
<string name="summary_profile_storage_cleanup">Удалить данные неиспользуемых профилей</string>
|
||||
<string name="message_profile_storage_cleanup">Удалить данные профилей, которые больше не используются ни в одной группе? Выбранный профиль и данные, которые нельзя с уверенностью отнести к неиспользуемым, будут сохранены.</string>
|
||||
<string name="toast_profile_storage_cleanup">Удалено неиспользуемых профилей: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">Не удалось достоверно определить неиспользуемые данные профилей. Ничего не удалено.</string>
|
||||
<string name="title_configuration_share">Поделиться конфигурацией</string>
|
||||
<string name="title_webdav_config_setting">Настройки WebDAV</string>
|
||||
<string name="title_webdav_config_setting_unknown">Необходимо настроить WebDAV</string>
|
||||
@@ -514,6 +520,7 @@
|
||||
<string name="acc_search">Поиск</string>
|
||||
<string name="acc_add">Добавить профиль</string>
|
||||
<string name="acc_more">Дополнительные параметры</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">Поделиться ссылкой на подписку</string>
|
||||
<string name="acc_share_log">Поделиться журналом</string>
|
||||
<string name="acc_remove">Удалить участника цепочки прокси</string>
|
||||
|
||||
@@ -348,6 +348,7 @@
|
||||
<string name="title_import_config_count">Nhập %d cấu hình</string>
|
||||
<string name="title_export_config_count">Xuất %d cấu hình</string>
|
||||
<string name="title_update_config_count">Cập nhật %d cấu hình</string>
|
||||
<string name="title_update_asset_count">Đã cập nhật %d tệp tài nguyên</string>
|
||||
<string name="title_update_subscription_result">Đã cập nhật %1$d cấu hình (%2$d thành công, %3$d thất bại, %4$d bỏ qua)</string>
|
||||
<string name="title_update_subscription_no_subscription">Không có đăng ký</string>
|
||||
<string name="toast_server_not_found_in_group">Không tìm thấy máy chủ đã chọn trong nhóm hiện tại</string>
|
||||
@@ -435,6 +436,11 @@
|
||||
<string name="title_configuration_backup_restore">Sao lưu & khôi phục</string>
|
||||
<string name="title_configuration_backup">Sao lưu cấu hình</string>
|
||||
<string name="title_configuration_restore">Khôi phục cấu hình</string>
|
||||
<string name="title_profile_storage_cleanup">Sửa dữ liệu cấu hình</string>
|
||||
<string name="summary_profile_storage_cleanup">Xóa dữ liệu cấu hình không còn được sử dụng</string>
|
||||
<string name="message_profile_storage_cleanup">Xóa dữ liệu cấu hình không còn được nhóm nào sử dụng? Cấu hình đang chọn và dữ liệu không thể xác định chắc chắn là không còn được sử dụng sẽ được giữ lại.</string>
|
||||
<string name="toast_profile_storage_cleanup">Đã xóa cấu hình không dùng: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">Không thể xác định chắc chắn dữ liệu cấu hình không còn được sử dụng. Không có dữ liệu nào bị xóa.</string>
|
||||
<string name="title_configuration_share">Chia sẻ cấu hình</string>
|
||||
<string name="title_webdav_config_setting">Cài đặt WebDAV</string>
|
||||
<string name="title_webdav_config_setting_unknown">Vui lòng cấu hình WebDAV trước.</string>
|
||||
@@ -514,6 +520,7 @@
|
||||
<string name="acc_search">Tìm kiếm</string>
|
||||
<string name="acc_add">Thêm cấu hình</string>
|
||||
<string name="acc_more">Tùy chọn khác</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">Chia sẻ liên kết đăng ký</string>
|
||||
<string name="acc_share_log">Chia sẻ nhật ký</string>
|
||||
<string name="acc_remove">Xóa thành viên khỏi chuỗi proxy</string>
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
<string name="server_lab_xhttp_extra">XHTTP Extra 原始 JSON,格式: { XHTTPObject }</string>
|
||||
<string name="server_lab_final_mask">FinalMask 原始 JSON 格式: { FinalMaskObject }</string>
|
||||
<string name="server_lab_ech_config_list" translatable="false">echConfigList</string>
|
||||
<string name="server_lab_verify_peer_cert_by_name">按名称验证对端证书</string>
|
||||
<string name="server_lab_verify_peer_cert_by_name">Verify peer cert by name</string>
|
||||
<string name="server_lab_pinned_ca256">证书指纹 (SHA-256)</string>
|
||||
<string name="server_lab_browser_dialer">启用 Browser Dialer</string>
|
||||
<string name="server_lab_browser_dialer_tip">仅支持 xhttp(packet-up)和 ws 出站;TLS 相关设置可能会被忽略或产生冲突</string>
|
||||
@@ -233,7 +233,7 @@
|
||||
<string name="title_pref_dns_hosts">DNS 主机映射(格式:domain:address,…)</string>
|
||||
<string name="summary_pref_dns_hosts">domain: address,…</string>
|
||||
|
||||
<string name="title_pref_delay_test_url">真实延迟测试 URL</string>
|
||||
<string name="title_pref_delay_test_url">真连接延迟测试 URL</string>
|
||||
<string name="summary_pref_delay_test_url" translatable="false">URL</string>
|
||||
<string name="title_pref_real_ping_concurrency">真连接延迟并发测试数</string>
|
||||
|
||||
@@ -337,7 +337,7 @@
|
||||
<string name="toast_invalid_update_interval">更新间隔无效,最小值为 15 分钟。</string>
|
||||
<string name="title_sub_update">更新订阅</string>
|
||||
<string name="title_ping_all_server">测试 TCP 延迟(TCPing)</string>
|
||||
<string name="title_real_ping_all_server">测试实际延迟</string>
|
||||
<string name="title_real_ping_all_server">测试真连接延迟</string>
|
||||
<string name="title_user_asset_setting">资源文件</string>
|
||||
<string name="title_sort_by_test_results">按测试结果排序</string>
|
||||
<string name="title_filter_config">过滤配置</string>
|
||||
@@ -348,6 +348,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_update_asset_count">已更新 %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="toast_server_not_found_in_group">当前分组中未找到选中的服务器</string>
|
||||
@@ -386,7 +387,7 @@
|
||||
<string name="connection_test_testing">"测试中…"</string>
|
||||
<string name="connection_test_testing_count">测试 %d 个配置中…</string>
|
||||
<string name="connection_test_available">"连接成功:延时 %1$s"</string>
|
||||
<string name="server_test_delay_value">%1$d 毫秒</string>
|
||||
<string name="server_test_delay_value">%1$d ms</string>
|
||||
<string name="connection_test_error">连接测试失败:%s</string>
|
||||
<string name="connection_test_fail">无法访问互联网</string>
|
||||
<string name="connection_test_error_status_code">错误码:#%d</string>
|
||||
@@ -435,6 +436,11 @@
|
||||
<string name="title_configuration_backup_restore">备份 & 还原</string>
|
||||
<string name="title_configuration_backup">备份配置</string>
|
||||
<string name="title_configuration_restore">还原配置</string>
|
||||
<string name="title_profile_storage_cleanup">修复配置存储</string>
|
||||
<string name="summary_profile_storage_cleanup">删除未使用的配置数据</string>
|
||||
<string name="message_profile_storage_cleanup">要删除已不再被任何分组使用的配置数据吗?当前选中的配置以及无法安全判定为未使用的数据将会保留。</string>
|
||||
<string name="toast_profile_storage_cleanup">已删除未使用的配置:%1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">无法安全判定哪些配置数据未被使用。未删除任何数据。</string>
|
||||
<string name="title_configuration_share">分享配置</string>
|
||||
<string name="title_webdav_config_setting">WebDAV 设置</string>
|
||||
<string name="title_webdav_config_setting_unknown">请先设置 WebDAV</string>
|
||||
@@ -514,6 +520,7 @@
|
||||
<string name="acc_search">搜索</string>
|
||||
<string name="acc_add">添加配置</string>
|
||||
<string name="acc_more">更多选项</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">分享订阅链接</string>
|
||||
<string name="acc_share_log">分享日志</string>
|
||||
<string name="acc_remove">移除代理链成员</string>
|
||||
|
||||
@@ -128,7 +128,7 @@
|
||||
<string name="server_lab_xhttp_extra">XHTTP Extra 原始 JSON,格式: { XHTTPObject }</string>
|
||||
<string name="server_lab_final_mask">finalMask 原始 JSON,格式:{ FinalMaskObject }</string>
|
||||
<string name="server_lab_ech_config_list" translatable="false">echConfigList</string>
|
||||
<string name="server_lab_verify_peer_cert_by_name">依名稱驗證對端憑證</string>
|
||||
<string name="server_lab_verify_peer_cert_by_name">Verify peer cert by name</string>
|
||||
<string name="server_lab_pinned_ca256">憑證指紋 (SHA-256)</string>
|
||||
<string name="server_lab_browser_dialer">啟用 Browser Dialer</string>
|
||||
<string name="server_lab_browser_dialer_tip">僅支援 xhttp (packet-up) 和 ws 出站;與 TLS 相關的設定可能會被忽略或發生衝突</string>
|
||||
@@ -233,7 +233,7 @@
|
||||
<string name="title_pref_dns_hosts">DNS 主機對應(格式:domain:address,…)</string>
|
||||
<string name="summary_pref_dns_hosts">domain:address,…</string>
|
||||
|
||||
<string name="title_pref_delay_test_url">真實延遲測試 URL</string>
|
||||
<string name="title_pref_delay_test_url">真連接延遲測試 URL</string>
|
||||
<string name="summary_pref_delay_test_url" translatable="false">URL</string>
|
||||
<string name="title_pref_real_ping_concurrency">同時執行的真實延遲測試數</string>
|
||||
|
||||
@@ -337,7 +337,7 @@
|
||||
<string name="toast_invalid_update_interval">更新間隔無效,最小值為 15 分鐘。</string>
|
||||
<string name="title_sub_update">更新訂閱</string>
|
||||
<string name="title_ping_all_server">測試 TCP 延遲(TCPing)</string>
|
||||
<string name="title_real_ping_all_server">測試實際延遲</string>
|
||||
<string name="title_real_ping_all_server">測試真連接延遲</string>
|
||||
<string name="title_user_asset_setting">資源檔案</string>
|
||||
<string name="title_sort_by_test_results">依測試結果排序</string>
|
||||
<string name="title_filter_config">篩選設定</string>
|
||||
@@ -348,6 +348,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_update_asset_count">已更新 %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="toast_server_not_found_in_group">目前群組中找不到所選伺服器</string>
|
||||
@@ -386,7 +387,7 @@
|
||||
<string name="connection_test_testing">"測試中……"</string>
|
||||
<string name="connection_test_testing_count">正在測試 %d 個設定…</string>
|
||||
<string name="connection_test_available">"連線成功:延遲 %1$s"</string>
|
||||
<string name="server_test_delay_value">%1$d 毫秒</string>
|
||||
<string name="server_test_delay_value">%1$d ms</string>
|
||||
<string name="connection_test_error">連線測試失敗:%s</string>
|
||||
<string name="connection_test_fail">"無法使用網際網路"</string>
|
||||
<string name="connection_test_error_status_code">錯誤碼:#%d</string>
|
||||
@@ -435,6 +436,11 @@
|
||||
<string name="title_configuration_backup_restore">備份與還原</string>
|
||||
<string name="title_configuration_backup">備份設定</string>
|
||||
<string name="title_configuration_restore">還原設定</string>
|
||||
<string name="title_profile_storage_cleanup">修復設定檔儲存空間</string>
|
||||
<string name="summary_profile_storage_cleanup">刪除未使用的設定檔資料</string>
|
||||
<string name="message_profile_storage_cleanup">要刪除已不再由任何群組使用的設定檔資料嗎?目前選取的設定檔,以及無法安全判定為未使用的資料,都會予以保留。</string>
|
||||
<string name="toast_profile_storage_cleanup">已刪除未使用的設定檔:%1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">無法安全判定哪些設定檔資料未被使用。未刪除任何資料。</string>
|
||||
<string name="title_configuration_share">分享設定</string>
|
||||
<string name="title_webdav_config_setting">WebDAV 設定</string>
|
||||
<string name="title_webdav_config_setting_unknown">請先設定 WebDAV</string>
|
||||
@@ -514,6 +520,7 @@
|
||||
<string name="acc_search">搜尋</string>
|
||||
<string name="acc_add">新增設定</string>
|
||||
<string name="acc_more">更多選項</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">分享訂閱連結</string>
|
||||
<string name="acc_share_log">分享日誌</string>
|
||||
<string name="acc_remove">移除代理鏈成員</string>
|
||||
|
||||
@@ -348,6 +348,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_update_asset_count">Updated %d asset files</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>
|
||||
@@ -435,6 +436,11 @@
|
||||
<string name="title_configuration_backup_restore">Backup & Restore</string>
|
||||
<string name="title_configuration_backup">Backup config</string>
|
||||
<string name="title_configuration_restore">Restore config</string>
|
||||
<string name="title_profile_storage_cleanup">Repair profile storage</string>
|
||||
<string name="summary_profile_storage_cleanup">Remove unused profile data</string>
|
||||
<string name="message_profile_storage_cleanup">Delete profile data that is no longer used by any group? Selected profiles and data that cannot be safely classified will be kept.</string>
|
||||
<string name="toast_profile_storage_cleanup">Unused profiles removed: %1$d</string>
|
||||
<string name="toast_profile_storage_cleanup_skipped">Profile storage could not be safely classified. Nothing was removed.</string>
|
||||
<string name="title_configuration_share">Share config</string>
|
||||
<string name="title_webdav_config_setting">WebDAV Settings</string>
|
||||
<string name="title_webdav_config_setting_unknown">Please configure WebDAV first.</string>
|
||||
@@ -514,6 +520,7 @@
|
||||
<string name="acc_search">Search</string>
|
||||
<string name="acc_add">Add configuration</string>
|
||||
<string name="acc_more">More options</string>
|
||||
<string name="acc_selected_server">Selected</string>
|
||||
<string name="acc_share_subscription">Share subscription link</string>
|
||||
<string name="acc_share_log">Share log</string>
|
||||
<string name="acc_remove">Remove proxy chain member</string>
|
||||
|
||||
@@ -31,10 +31,10 @@
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_start_24dp"
|
||||
android:shortcutDisabledMessage="@string/toast_services_start"
|
||||
android:shortcutDisabledMessage="@string/acc_start"
|
||||
android:shortcutId="shortcuts_start"
|
||||
android:shortcutLongLabel="@string/toast_services_start"
|
||||
android:shortcutShortLabel="@string/toast_services_start">
|
||||
android:shortcutLongLabel="@string/acc_start"
|
||||
android:shortcutShortLabel="@string/acc_start">
|
||||
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
@@ -45,10 +45,10 @@
|
||||
<shortcut
|
||||
android:enabled="true"
|
||||
android:icon="@drawable/ic_qu_stop_24dp"
|
||||
android:shortcutDisabledMessage="@string/toast_services_stop"
|
||||
android:shortcutDisabledMessage="@string/acc_stop"
|
||||
android:shortcutId="shortcuts_stop"
|
||||
android:shortcutLongLabel="@string/toast_services_stop"
|
||||
android:shortcutShortLabel="@string/toast_services_stop">
|
||||
android:shortcutLongLabel="@string/acc_stop"
|
||||
android:shortcutShortLabel="@string/acc_stop">
|
||||
|
||||
<intent
|
||||
android:action="android.intent.action.VIEW"
|
||||
@@ -56,4 +56,4 @@
|
||||
android:targetPackage="com.v2ray.ang" />
|
||||
<categories android:name="android.shortcut.conversation" />
|
||||
</shortcut>
|
||||
</shortcuts>
|
||||
</shortcuts>
|
||||
@@ -0,0 +1,122 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class OrphanProfileCleanerTest {
|
||||
|
||||
@Test
|
||||
fun `reports no work when profile storage is empty`() {
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = emptyList(),
|
||||
indexedServersBySubscription = emptyMap(),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertEquals(emptySet<String>(), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uses raw group indexes when subscription metadata is missing`() {
|
||||
val profiles = listOf(
|
||||
StoredProfileReference("live", "group-a"),
|
||||
StoredProfileReference("orphan", "group-a"),
|
||||
)
|
||||
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = profiles,
|
||||
indexedServersBySubscription = mapOf("group-a" to setOf("live")),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertEquals(setOf("orphan"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps profiles whose group index is missing`() {
|
||||
val profiles = listOf(
|
||||
StoredProfileReference("known-orphan", "group-a"),
|
||||
StoredProfileReference("unknown", "missing-group"),
|
||||
)
|
||||
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = profiles,
|
||||
indexedServersBySubscription = mapOf("group-a" to emptySet()),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertEquals(setOf("known-orphan"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aborts cleanup when any raw group index is unreadable`() {
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = listOf(StoredProfileReference("candidate", "group-a")),
|
||||
indexedServersBySubscription = mapOf(
|
||||
"group-a" to emptySet(),
|
||||
"corrupt-group" to null,
|
||||
),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `aborts cleanup when no raw group indexes survive`() {
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = listOf(StoredProfileReference("candidate", "group-a")),
|
||||
indexedServersBySubscription = emptyMap(),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps selected and undecodable profiles`() {
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = listOf(
|
||||
StoredProfileReference("selected", "group-a"),
|
||||
StoredProfileReference("undecodable", null),
|
||||
StoredProfileReference("orphan", "group-a"),
|
||||
),
|
||||
indexedServersBySubscription = mapOf("group-a" to emptySet()),
|
||||
selectedServer = "selected",
|
||||
)
|
||||
|
||||
assertEquals(setOf("orphan"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalizes ungrouped profiles to the default group`() {
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = listOf(
|
||||
StoredProfileReference("live", ""),
|
||||
StoredProfileReference("orphan", ""),
|
||||
),
|
||||
indexedServersBySubscription = mapOf(
|
||||
DEFAULT_SUBSCRIPTION_ID to setOf("live"),
|
||||
),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertEquals(setOf("orphan"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps a profile indexed by a different group`() {
|
||||
val result = OrphanProfileCleaner.findOrphans(
|
||||
profiles = listOf(StoredProfileReference("mismatched", "group-a")),
|
||||
indexedServersBySubscription = mapOf(
|
||||
"group-a" to emptySet(),
|
||||
"group-b" to setOf("mismatched"),
|
||||
),
|
||||
selectedServer = null,
|
||||
)
|
||||
|
||||
assertEquals(emptySet<String>(), result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class ProfileReplacementTest {
|
||||
|
||||
@Test
|
||||
fun `prefers a full match over a remarks-only match`() {
|
||||
val profiles = linkedMapOf(
|
||||
"remarks" to profile(remarks = "selected", server = "other"),
|
||||
"full" to profile(remarks = "selected", server = "host", port = "443", password = "secret"),
|
||||
)
|
||||
|
||||
val result = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = profiles,
|
||||
currentSelection = "old",
|
||||
selectedProfile = profile(
|
||||
remarks = " selected ",
|
||||
server = "HOST",
|
||||
port = "443",
|
||||
password = "secret",
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("full", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `uses a remarks match when no full match exists`() {
|
||||
val profiles = linkedMapOf(
|
||||
"other" to profile(remarks = "other", server = "host"),
|
||||
"remarks" to profile(remarks = "selected", server = "other"),
|
||||
)
|
||||
|
||||
val result = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = profiles,
|
||||
currentSelection = "old",
|
||||
selectedProfile = profile(remarks = "selected", server = "host"),
|
||||
)
|
||||
|
||||
assertEquals("remarks", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `matches endpoint and password when remarks are unavailable`() {
|
||||
val profiles = linkedMapOf(
|
||||
"endpoint" to profile(server = "host", port = "443", password = "secret"),
|
||||
"other" to profile(server = "other", port = "443", password = "secret"),
|
||||
)
|
||||
|
||||
val result = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = profiles,
|
||||
currentSelection = "old",
|
||||
selectedProfile = profile(server = "HOST", port = "443", password = "secret"),
|
||||
)
|
||||
|
||||
assertEquals("endpoint", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `falls back to the first replacement profile`() {
|
||||
val profiles = linkedMapOf(
|
||||
"first" to profile(server = "first"),
|
||||
"second" to profile(server = "second"),
|
||||
)
|
||||
|
||||
val result = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = profiles,
|
||||
currentSelection = "old",
|
||||
selectedProfile = profile(server = "unmatched"),
|
||||
)
|
||||
|
||||
assertEquals("first", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selects the first profile when there is no current selection`() {
|
||||
val result = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = linkedMapOf(
|
||||
"first" to profile(server = "first"),
|
||||
"second" to profile(server = "second"),
|
||||
),
|
||||
currentSelection = null,
|
||||
selectedProfile = null,
|
||||
)
|
||||
|
||||
assertEquals("first", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps an existing selection when it is outside the replaced group`() {
|
||||
val result = ProfileReplacement.findSelectedReplacement(
|
||||
profiles = mapOf("candidate" to profile(server = "host")),
|
||||
currentSelection = "other-group",
|
||||
selectedProfile = null,
|
||||
)
|
||||
|
||||
assertNull(result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removes only unreferenced superseded payloads`() {
|
||||
val result = ProfileReplacement.findRemovablePayloads(
|
||||
replacedServers = listOf("orphan", "selected", "replacement", "cross-group"),
|
||||
replacementServers = setOf("replacement"),
|
||||
protectedServer = "selected",
|
||||
serversReferencedByOtherGroups = setOf("cross-group"),
|
||||
)
|
||||
|
||||
assertEquals(setOf("orphan"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `keeps all payloads when another group index is unreadable`() {
|
||||
val result = ProfileReplacement.findRemovablePayloads(
|
||||
replacedServers = listOf("candidate"),
|
||||
replacementServers = emptySet(),
|
||||
protectedServer = null,
|
||||
serversReferencedByOtherGroups = null,
|
||||
)
|
||||
|
||||
assertEquals(emptySet<String>(), result)
|
||||
}
|
||||
|
||||
private fun profile(
|
||||
remarks: String = "",
|
||||
server: String = "",
|
||||
port: String = "",
|
||||
password: String = "",
|
||||
) = ProfileItem.create(EConfigType.VMESS).apply {
|
||||
this.remarks = remarks
|
||||
this.server = server
|
||||
this.serverPort = port
|
||||
this.password = password
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.v2ray.ang.service
|
||||
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class RealPingExecutionLimiterTest {
|
||||
|
||||
@Test
|
||||
fun customConfigMeasurementsAreSerializedAcrossWorkers() {
|
||||
runBlocking {
|
||||
val active = AtomicInteger(0)
|
||||
val maxActive = AtomicInteger(0)
|
||||
|
||||
List(8) {
|
||||
async(Dispatchers.Default) {
|
||||
RealPingExecutionLimiter.run(EConfigType.CUSTOM) {
|
||||
val current = active.incrementAndGet()
|
||||
maxActive.accumulateAndGet(current, ::maxOf)
|
||||
Thread.sleep(20)
|
||||
active.decrementAndGet()
|
||||
}
|
||||
}
|
||||
}.awaitAll()
|
||||
|
||||
assertEquals(1, maxActive.get())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun generatedConfigMeasurementsRemainConcurrent() {
|
||||
runBlocking {
|
||||
val entered = CountDownLatch(2)
|
||||
val release = CountDownLatch(1)
|
||||
|
||||
val jobs = List(2) {
|
||||
async(Dispatchers.Default) {
|
||||
RealPingExecutionLimiter.run(EConfigType.VMESS) {
|
||||
entered.countDown()
|
||||
release.await(5, TimeUnit.SECONDS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
assertTrue(entered.await(5, TimeUnit.SECONDS))
|
||||
} finally {
|
||||
release.countDown()
|
||||
}
|
||||
jobs.awaitAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user