Compare commits

...
20 Commits
Author SHA1 Message Date
2dust 9fcb1a30f8 up 2.3.5 2026-08-20 17:09:55 +08:00
2dust d4fc91502c Update AndroidLibXrayLite 2026-08-20 15:21:33 +08:00
2dust 75c9194ec0 Log CoreCallback events and noop shutdown 2026-08-20 15:16:47 +08:00
aliRahimi1997 590be2e9ae fix(accessibility): announce selected server state (#6108) 2026-08-19 20:28:38 +08:00
aliRahimi1997 1ee1213563 fix(accessibility): label more options buttons (#6095) 2026-08-19 20:14:59 +08:00
eliotcougar 37b42f04a7 Load user asset metadata off the UI thread (#6106)
Load asset metadata on Dispatchers.IO and publish the asset list and file details as one lifecycle-aware UI state.

Remove refresh-key workarounds, await reloads before downloads, and keep the content composable stateless.
2026-08-19 14:16:41 +08:00
eliotcougar 3c91b51489 Optimize large-group probing startup (#6102)
Clear visible delays immediately, but move the per-profile MMKV reset to the existing I/O coroutine. Finish the reset before starting the test service so newly reported results cannot be erased.
2026-08-19 13:57:06 +08:00
eliotcougar 06a27ec4d4 Allow TCPing mixed H3/H2 profiles (#6101) 2026-08-19 13:55:26 +08:00
eliotcougar 7a07565f72 Harden profile replacement ordering (#6093) 2026-08-17 10:20:43 +08:00
eliotcougar 0456f835bc Add manual profile storage cleanup (#6094) 2026-08-17 10:06:56 +08:00
aliRahimi1997 e4e3a36e93 fix(accessibility): label server action buttons (#6091) 2026-08-16 19:44:24 +08:00
2dust 4b3c8177db Add Duration-based delay extension helpers 2026-08-16 15:32:35 +08:00
eliotcougarand2dust 780d194213 Make explicit service restart daemon-authoritative (#6072)
* Make service restarts daemon-authoritative

* Update CoreServiceManager.kt

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
2026-08-16 15:26:58 +08:00
eliotcougar c1374a73fb fix(ui): use asset-specific update toast (#6088) 2026-08-16 10:55:31 +08:00
eliotcougar 49fe45c49e Recover MMKV data on storage errors (#6085) 2026-08-16 10:30:56 +08:00
eliotcougar 36ab5615d9 Fix custom real-delay probe crash (#6083) 2026-08-16 10:21:17 +08:00
2dust 91c2c93a62 Adjust string 2026-08-16 10:14:32 +08:00
aliRahimi1997 10932f5764 fix(accessibility): announce server test status changes (#6082) 2026-08-15 19:53:38 +08:00
Xiaochang 8dcff4238f Fix bottom bar background v2 (#6075)
* fix: extend bottom bar background and content to screen edge for edge-to-edge

* Extract NavigationBarsSpacer and navigationBarsBottomPadding to reduce duplicate code

* Rename navigationBarsBottomPadding to NavigationBarsBottomPadding for naming consistency
2026-08-15 10:39:14 +08:00
eliotcougar 809bdcdb9d Use action labels for service shortcuts (#6074) 2026-08-15 10:32:03 +08:00
58 changed files with 1327 additions and 409 deletions

No files matched your search

+2 -2
View File
@@ -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 {
+7 -7
View File
@@ -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">لادراری گرؽڌن &amp; وورگندن</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 &amp; 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">备份 &amp; 还原</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 &amp; 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>
+7 -7
View File
@@ -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()
}
}
}