10 Commits
Author SHA1 Message Date
nerpa c9621b7ea9 bump: AndroidLibXrayLite (xray-core-awg v26.9.9-awg1)
Build APK / sign-and-release (push) Skipped
Build APK / build (push) Successful in 13m50s
2026-09-14 16:59:36 +03:00
2dust a690040468 up 2.3.8 2026-09-14 16:56:50 +03:00
eliotcougar 62093d09eb Use completed-action wording and plurals for update results (#6196)
Extract the config and asset update-result resources from local/talkback-all-10 in all nine supported locales. Describe completed updates instead of an update command, and select singular or plural forms using the actual config or successful asset count.

Route both subscription update entry points through the existing localized ViewModel context and use activity resources for asset results. Preserve update execution, counts, partial-failure summaries, error handling, and toast delivery; no accessibility announcement infrastructure is included.
2026-09-14 16:56:50 +03:00
eliotcougar 6a6a44a380 Keep proxy-chain deletion targets tied to stable row keys (#6200)
Store the pending member key instead of its list position and resolve that key when deletion is confirmed. Remove the member and its corresponding row key together; if the target no longer exists, leave the draft unchanged.

Extract the deletion fix from the accessibility assembly without importing accessibility actions, dialog wording changes, or drag refactors. Reuse the same key-based removal for blank members.

Validation: 75 JVM tests passed, including five deletion regressions; Play Store debug Kotlin compilation and x86_64 assembly passed. Emulator smoke check covered confirmation across rotation and immediate blank-member removal. Keyboard/D-pad and runtime reorder-during-confirmation scenarios were not run.
2026-09-14 16:53:36 +03:00
eliotcougar 1fd05e16b5 Keep built-in asset row identities stable across reloads (#6199)
Use a builtin: filename-based ID instead of generating a fresh UUID each time the asset list is rebuilt. This lets the existing GUID-keyed list and file-metadata map recognize the same built-in asset after reloads and download-source changes.

Extract the fix and its two JVM regression tests from local/talkback-all-10. Preserve stored asset GUIDs, matching built-in replacement, locked flags, and URL behavior. No persisted IDs, accessibility semantics, metadata formatting, or editor behavior are changed.
2026-09-14 16:53:36 +03:00
eliotcougar e7dcec1eff Format asset metadata with localized file sizes and minute precision (#6197)
Extract the visible asset metadata formatting from local/talkback-all-10. Use Android file-size formatting and derive the date/time pattern from the app configuration locale and the device 12/24-hour preference, without seconds.

Remove the metadata-only remember cache so recomposition uses the current formatting context. Preserve the existing size/date separator, missing-file message, row layout, action buttons, and accessibility semantics. No update-result or asset-editor changes are included.
2026-09-14 16:53:36 +03:00
eliotcougar 7d9fa970dc Generate subscription QR codes off the UI thread (#6194)
Move subscription QR bitmap generation from the synchronous share callback into the existing ViewModel, running CPU-bound encoding on Dispatchers.Default and exposing the completed bitmap through StateFlow.

Cancel pending result delivery when the dialog is dismissed, a newer share request replaces it, or the ViewModel is cleared. Keep dialog rendering lifecycle-aware and reuse the existing localized failure message when generation returns no bitmap.

Extract only QR generation and dialog-state wiring from local/talkback-all-10. Leave subscription row accessibility, custom actions, update handling, and clipboard sharing unchanged.
2026-09-14 16:53:36 +03:00
eliotcougar 6400d33b87 UI/UX: Preserve asset-editor drafts during recompose (#6193)
* Preserve asset-editor drafts and handle the keyboard

Keep the asset name, URL, and delete-confirmation visibility in saveable Compose state so activity recreation does not discard an unfinished edit. Key the state by asset identity and initial field values.

Make the form vertically scrollable, consume the Scaffold padding, and apply IME padding so its fields remain reachable when the keyboard reduces the available height. Extract these presentation changes from local/talkback-all-10 without changing accessibility labels, deletion wording, or asset-save validation order.

* Resize the asset editor when the keyboard opens

Declare stateUnchanged|adjustResize for UserAssetUrlActivity, matching the other editor activities. Without an explicit resize policy, opening the IME in landscape pans the entire window and moves the toolbar Save action off-screen despite the scrollable Compose form.

Verified on Android 13 Pixel 6a: rotate with an unsaved draft, reopen the landscape keyboard, scroll between fields, edit the first field, save without hiding the keyboard, and reopen persisted values. Portrait keyboard and Save also pass. Debug assembly succeeded.
2026-09-14 16:53:35 +03:00
eliotcougar b7c69486bf Show per-app proxy help in a dialog (#6192)
Replace the short-lived info toast with the existing titleless confirmation dialog and a single OK button so users can read the routing explanation at their own pace. Keep dialog visibility in saveable screen state and reuse the existing localized help and action strings.

Extract only the help-dialog presentation from local/talkback-all-10; leave app-row semantics, switches, routing descriptions, and notification infrastructure in the accessibility branches.
2026-09-14 16:53:35 +03:00
eliotcougar 5b99841f06 fix: validate asset edits before deleting the cached file (#6190)
Reject duplicate names and empty names or URLs before invalidating the existing asset cache. Previously a rejected save left the stored source unchanged but deleted its downloaded file.

Preserve the validation messages and their order, duplicate-name exclusion by asset ID, and successful-save behavior. Renames still invalidate the old filename before updating the asset record.
2026-09-14 16:53:35 +03:00
24 changed files with 324 additions and 75 deletions
+2 -2
View File
@@ -13,8 +13,8 @@ android {
applicationId = "com.v2ray.ang"
minSdk = 24
targetSdk = 37
versionCode = 747
versionName = "2.3.7"
versionCode = 748
versionName = "2.3.8"
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
+1
View File
@@ -157,6 +157,7 @@
android:exported="false" />
<activity
android:name=".ui.userasset.UserAssetUrlActivity"
android:windowSoftInputMode="stateUnchanged|adjustResize"
android:exported="false" />
<activity
@@ -96,6 +96,13 @@ abstract class BaseViewModel(application: Application) : AndroidViewModel(applic
return localizedContext.getString(resId, *formatArgs)
}
/**
* Get a localized quantity string from a plurals resource.
*/
fun getQuantityString(resId: Int, quantity: Int, vararg formatArgs: Any): String {
return localizedContext.resources.getQuantityString(resId, quantity, *formatArgs)
}
/**
* Send finish activity event.
*/
@@ -464,7 +464,13 @@ class MainViewModel(
toast(R.string.title_update_subscription_no_subscription)
result.successCount > 0 && result.failureCount + result.skipCount == 0 ->
toast(dataSource.getString(R.string.title_update_config_count, result.configCount))
toast(
getQuantityString(
R.plurals.title_update_config_count,
result.configCount,
result.configCount,
)
)
else ->
toast(dataSource.getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
@@ -41,13 +41,13 @@ import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.R
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.extension.toastInfo
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.ui.base.BaseComponentActivity
import com.v2ray.ang.ui.compose.AppDivider
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.ConfirmDialog
import com.v2ray.ang.ui.compose.ItemDivider
import com.v2ray.ang.ui.compose.NavigationBarsBottomPadding
import com.v2ray.ang.ui.compose.verticalScrollbar
@@ -87,9 +87,6 @@ class PerAppProxyActivity : BaseComponentActivity() {
onBackClick = { finish() },
onPerAppProxyChanged = { viewModel.setPerAppProxyEnabled(it) },
onBypassAppsChanged = { viewModel.setBypassAppsEnabled(it) },
onInfoClick = {
toastInfo(R.string.summary_pref_per_app_proxy)
},
onToggleApp = { viewModel.toggle(it) },
onSearch = { viewModel.filterApps(it) },
onSelectAll = { viewModel.selectAll() },
@@ -118,7 +115,6 @@ fun PerAppProxyScreen(
onBackClick: () -> Unit,
onPerAppProxyChanged: (Boolean) -> Unit,
onBypassAppsChanged: (Boolean) -> Unit,
onInfoClick: () -> Unit,
onToggleApp: (String) -> Unit,
onSearch: (String) -> Unit,
onSelectAll: () -> Unit,
@@ -130,6 +126,8 @@ fun PerAppProxyScreen(
var showSearch by rememberSaveable { mutableStateOf(false) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var showMenu by remember { mutableStateOf(false) }
var showInfoDialog by rememberSaveable { mutableStateOf(false) }
val onInfoClick = { showInfoDialog = true }
val listState = rememberLazyListState()
LaunchedEffect(Unit) {
@@ -281,4 +279,13 @@ fun PerAppProxyScreen(
}
}
}
if (showInfoDialog) {
ConfirmDialog(
message = stringResource(R.string.summary_pref_per_app_proxy),
dismissText = null,
onConfirm = {},
onDismiss = { showInfoDialog = false },
)
}
}
@@ -0,0 +1,14 @@
package com.v2ray.ang.ui.server
/** Removes one draft member and its row key together, resolving its current position at confirmation. */
internal fun withoutProxyChainMember(
members: List<String>,
memberKeys: List<String>,
memberKey: String,
): Pair<List<String>, List<String>> {
val index = memberKeys.indexOf(memberKey)
if (index < 0) return members to memberKeys
return members.toMutableList().also { it.removeAt(index) } to
memberKeys.toMutableList().also { it.removeAt(index) }
}
@@ -203,7 +203,7 @@ fun ProxyChainScreen(
var members by rememberSaveable { mutableStateOf(initialMembers) }
var memberKeys by rememberSaveable { mutableStateOf(List(initialMembers.size) { UUID.randomUUID().toString() }) }
var showProfileDeleteConfirm by remember { mutableStateOf(false) }
var memberToDeleteIndex by rememberSaveable { mutableStateOf<Int?>(null) }
var memberToDeleteKey by rememberSaveable { mutableStateOf<String?>(null) }
val showDelete = editGuid.isNotEmpty() && !isRunning
val lazyListState = rememberLazyListState()
@@ -285,7 +285,8 @@ fun ProxyChainScreen(
}
itemsIndexed(items = members, key = { index, _ -> memberKeys[index] }) { index, member ->
ReorderableItem(reorderableState, key = memberKeys[index]) { isDragging ->
val memberKey = memberKeys[index]
ReorderableItem(reorderableState, key = memberKey) { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row(
@@ -314,10 +315,11 @@ fun ProxyChainScreen(
)
IconButton(onClick = {
if (member.isBlank()) {
members = members.toMutableList().also { it.removeAt(index) }
memberKeys = memberKeys.toMutableList().also { it.removeAt(index) }
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, memberKeys, memberKey)
members = remainingMembers
memberKeys = remainingKeys
} else {
memberToDeleteIndex = index
memberToDeleteKey = memberKey
}
}) {
Icon(
@@ -339,15 +341,16 @@ fun ProxyChainScreen(
onDismiss = { showProfileDeleteConfirm = false }
)
}
memberToDeleteIndex?.let { index ->
memberToDeleteKey?.let { memberKey ->
DeleteConfirmDialog(
message = stringResource(R.string.confirm_delete_proxy_chain_member),
onConfirm = {
members = members.toMutableList().also { it.removeAt(index) }
memberKeys = memberKeys.toMutableList().also { it.removeAt(index) }
memberToDeleteIndex = null
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, memberKeys, memberKey)
members = remainingMembers
memberKeys = remainingKeys
memberToDeleteKey = null
},
onDismiss = { memberToDeleteIndex = null }
onDismiss = { memberToDeleteKey = null }
)
}
}
@@ -1,7 +1,6 @@
package com.v2ray.ang.ui.subscription
import android.content.Intent
import android.graphics.Bitmap
import android.os.Bundle
import androidx.activity.viewModels
import androidx.annotation.StringRes
@@ -53,7 +52,6 @@ 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.verticalScrollbar
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
@@ -83,7 +81,7 @@ class SubSettingActivity : BaseComponentActivity() {
startActivity(Intent(this, SubEditActivity::class.java).putExtra("subId", subId))
},
onRemoveSub = { subId -> removeSub(subId) },
onShareQRCode = { url -> QRCodeDecoder.createQRCode(url) },
onShareQRCode = viewModel::shareQRCode,
onShareClipboard = { url ->
Utils.setClipboard(this, url)
toast(getString(R.string.toast_success))
@@ -110,7 +108,7 @@ fun SubSettingScreen(
onSubUpdate: () -> Unit,
onEditSub: (String) -> Unit,
onRemoveSub: (String) -> Unit,
onShareQRCode: (String) -> Bitmap?,
onShareQRCode: (String) -> Unit,
onShareClipboard: (String) -> Unit
) {
val subscriptions by viewModel.subsFlow.collectAsStateWithLifecycle()
@@ -119,7 +117,7 @@ fun SubSettingScreen(
val confirmRemove = MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE, false)
var shareTarget by remember { mutableStateOf<Pair<String, String>?>(null) }
var showQRCodeBitmap by remember { mutableStateOf<Bitmap?>(null) }
val qrCodeBitmap by viewModel.qrCode.collectAsStateWithLifecycle()
val lazyListState = rememberLazyListState()
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
@@ -254,7 +252,7 @@ fun SubSettingScreen(
onSelected = { action ->
shareTarget = null
when (action) {
SubscriptionShareAction.QRCode -> showQRCodeBitmap = onShareQRCode(url)
SubscriptionShareAction.QRCode -> onShareQRCode(url)
SubscriptionShareAction.Clipboard -> onShareClipboard(url)
}
},
@@ -263,10 +261,10 @@ fun SubSettingScreen(
}
// QR Code Dialog
if (showQRCodeBitmap != null) {
if (qrCodeBitmap != null) {
QRCodeDialog(
bitmap = showQRCodeBitmap,
onDismiss = { showQRCodeBitmap = null }
bitmap = qrCodeBitmap,
onDismiss = viewModel::dismissQRCode
)
}
@@ -1,6 +1,8 @@
package com.v2ray.ang.ui.subscription
import android.app.Application
import android.graphics.Bitmap
import androidx.lifecycle.viewModelScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.SubscriptionUpdateMessage
@@ -14,14 +16,20 @@ import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.MessageHelper
import com.v2ray.ang.ui.base.BaseViewModel
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SubscriptionsViewModel(application: Application) : BaseViewModel(application) {
private var qrCodeJob: Job? = null
private val _qrCode = MutableStateFlow<Bitmap?>(null)
internal val qrCode = _qrCode.asStateFlow()
private val subscriptions: MutableList<SubscriptionCache> =
MmkvManager.decodeSubscriptions().toMutableList()
@@ -55,6 +63,21 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
_subsFlow.value = subscriptions.toList()
}
internal fun shareQRCode(url: String) {
dismissQRCode()
qrCodeJob = viewModelScope.launch {
val bitmap = withContext(Dispatchers.Default) { QRCodeDecoder.createQRCode(url) }
if (bitmap == null) toastError(R.string.toast_failure)
_qrCode.value = bitmap
}
}
internal fun dismissQRCode() {
qrCodeJob?.cancel()
qrCodeJob = null
_qrCode.value = null
}
fun move(fromPosition: Int, toPosition: Int) {
if (subscriptions.moveItem(fromPosition, toPosition)) {
MmkvManager.encodeSubsList(subscriptions.mapTo(mutableListOf()) { it.guid })
@@ -87,7 +110,13 @@ class SubscriptionsViewModel(application: Application) : BaseViewModel(applicati
toast(R.string.title_update_subscription_no_subscription)
result.successCount > 0 && result.failureCount + result.skipCount == 0 ->
toast(getString(R.string.title_update_config_count, result.configCount))
toast(
getQuantityString(
R.plurals.title_update_config_count,
result.configCount,
result.configCount,
)
)
else ->
toast(getString(R.string.title_update_subscription_result, result.configCount, result.successCount, result.failureCount, result.skipCount))
@@ -4,6 +4,8 @@ import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.text.format.DateFormat
import android.text.format.Formatter
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Box
@@ -34,6 +36,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
@@ -45,7 +49,6 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem
import com.v2ray.ang.extension.toTrafficString
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
@@ -66,7 +69,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
private enum class AddAssetMenuAction(@StringRes val labelRes: Int) {
@@ -219,7 +222,13 @@ class UserAssetActivity : HelperBaseComponentActivity() {
viewModel.downloadGeoFiles(extDir, httpPort, proxyUsername, proxyPassword)
}
if (result.successCount > 0) {
toast(getString(R.string.title_update_asset_count, result.successCount))
toast(
resources.getQuantityString(
R.plurals.title_update_asset_count,
result.successCount,
result.successCount,
)
)
} else {
toast(getString(R.string.toast_failure))
}
@@ -353,11 +362,14 @@ private fun UserAssetItem(
onEdit: () -> Unit,
onDeleteClick: () -> Unit
) {
val context = LocalContext.current
val locale = LocalConfiguration.current.locales[0]
val propertiesText = if (fileMetadata != null) {
remember(fileMetadata) {
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
"${fileMetadata.length.toTrafficString()} • ${dateFormat.format(Date(fileMetadata.lastModified))}"
}
val fileSize = Formatter.formatFileSize(context, fileMetadata.length)
val skeleton = if (DateFormat.is24HourFormat(context)) "yMMMdHm" else "yMMMdhm"
val formattedDate = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale)
.format(Date(fileMetadata.lastModified))
"$fileSize • $formattedDate"
} else {
stringResource(R.string.msg_file_not_found)
}
@@ -4,15 +4,19 @@ import android.os.Bundle
import android.text.TextUtils
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
@@ -81,6 +85,20 @@ class UserAssetUrlActivity : BaseComponentActivity() {
}
private fun saveServer(remarks: String, url: String): Boolean {
val assetList = MmkvManager.decodeAssetUrls()
if (assetList.any { it.assetUrl.remarks == remarks && it.guid != editAssetId }) {
toast(R.string.msg_remark_is_duplicate)
return false
}
if (TextUtils.isEmpty(remarks)) {
toast(R.string.sub_setting_remarks)
return false
}
if (TextUtils.isEmpty(url)) {
toast(R.string.title_url)
return false
}
var assetItem = MmkvManager.decodeAsset(editAssetId)
var assetId = editAssetId
if (assetItem != null) {
@@ -100,20 +118,6 @@ class UserAssetUrlActivity : BaseComponentActivity() {
assetItem.remarks = remarks
assetItem.url = url
val assetList = MmkvManager.decodeAssetUrls()
if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != assetId }) {
toast(R.string.msg_remark_is_duplicate)
return false
}
if (TextUtils.isEmpty(assetItem.remarks)) {
toast(R.string.sub_setting_remarks)
return false
}
if (TextUtils.isEmpty(assetItem.url)) {
toast(R.string.title_url)
return false
}
MmkvManager.encodeAsset(assetId, assetItem)
toastSuccess(R.string.toast_success)
finish()
@@ -138,9 +142,9 @@ fun UserAssetUrlScreen(
onSave: (String, String) -> Boolean,
onDelete: () -> Unit
) {
var remarks by remember { mutableStateOf(initialRemarks) }
var url by remember { mutableStateOf(initialUrl) }
var showDeleteConfirm by remember { mutableStateOf(false) }
var remarks by rememberSaveable(editAssetId, initialRemarks) { mutableStateOf(initialRemarks) }
var url by rememberSaveable(editAssetId, initialUrl) { mutableStateOf(initialUrl) }
var showDeleteConfirm by rememberSaveable(editAssetId) { mutableStateOf(false) }
Scaffold(
contentWindowInsets = WindowInsets(0),
@@ -171,6 +175,9 @@ fun UserAssetUrlScreen(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.consumeWindowInsets(innerPadding)
.imePadding()
.verticalScroll(rememberScrollState())
.padding(vertical = 8.dp)
) {
FormTextField(
@@ -11,7 +11,6 @@ import com.v2ray.ang.handler.MmkvManager
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
@@ -50,7 +49,7 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application)
}.also { reloadJob = it }
}
private fun buildAssetList(
internal fun buildAssetList(
decodedAssets: List<AssetUrlCache>?,
geoFilesSource: String
): List<AssetUrlCache> {
@@ -59,7 +58,8 @@ class UserAssetViewModel(application: Application) : BaseViewModel(application)
.filter { geoFile -> savedAssets.none { it.assetUrl.remarks == geoFile } }
.map {
AssetUrlCache(
Utils.getUuid(),
// Built-in rows have no persisted GUID; keep their UI identity across reloads.
"builtin:$it",
AssetUrlItem(
it,
String.format(AppConfig.GITHUB_DOWNLOAD_URL, geoFilesSource).concatUrl(it),
+16 -2
View File
@@ -351,8 +351,22 @@
<string name="title_del_config_count">حذف %d من التكوينات</string>
<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>
<plurals name="title_update_config_count">
<item quantity="zero">لم يتم تحديث أي تكوينات</item>
<item quantity="one">تم تحديث تكوين واحد</item>
<item quantity="two">تم تحديث تكوينين</item>
<item quantity="few">تم تحديث %1$d تكوينات</item>
<item quantity="many">تم تحديث %1$d تكوينًا</item>
<item quantity="other">تم تحديث %1$d تكوين</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="zero">لم يتم تحديث أي ملفات أصول</item>
<item quantity="one">تم تحديث ملف أصول واحد</item>
<item quantity="two">تم تحديث ملفي أصول</item>
<item quantity="few">تم تحديث %1$d ملفات أصول</item>
<item quantity="many">تم تحديث %1$d ملف أصول</item>
<item quantity="other">تم تحديث %1$d ملف أصول</item>
</plurals>
<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>
@@ -351,8 +351,14 @@
<string name="title_del_config_count">%dটি কনফিগারেশন মুছুন</string>
<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>
<plurals name="title_update_config_count">
<item quantity="one">%1$dটি কনফিগারেশন আপডেট হয়েছে</item>
<item quantity="other">%1$dটি কনফিগারেশন আপডেট হয়েছে</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="one">%1$dটি অ্যাসেট ফাইল আপডেট হয়েছে</item>
<item quantity="other">%1$dটি অ্যাসেট ফাইল আপডেট হয়েছে</item>
</plurals>
<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>
@@ -351,8 +351,14 @@
<string name="title_del_config_count">پاک کردن %d کانفیگ</string>
<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>
<plurals name="title_update_config_count">
<item quantity="one">%1$d کانفیگ ورۊ وابی</item>
<item quantity="other">%1$d کانفیگ ورۊ وابی</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="one">%1$d فایل بونچک جوقرافیایی ورۊ وابی</item>
<item quantity="other">%1$d فایل بونچک جوقرافیایی ورۊ وابی</item>
</plurals>
<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>
@@ -351,8 +351,14 @@
<string name="title_del_config_count">حذف %d کانفیگ</string>
<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>
<plurals name="title_update_config_count">
<item quantity="one">%1$d کانفیگ به‌روزرسانی شد</item>
<item quantity="other">%1$d کانفیگ به‌روزرسانی شد</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="one">%1$d فایل منبع به‌روزرسانی شد</item>
<item quantity="other">%1$d فایل منبع به‌روزرسانی شد</item>
</plurals>
<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>
+12 -2
View File
@@ -352,8 +352,18 @@
<string name="title_del_config_count">Удалено профилей: %d</string>
<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>
<plurals name="title_update_config_count">
<item quantity="one">Обновлён %1$d профиль</item>
<item quantity="few">Обновлено %1$d профиля</item>
<item quantity="many">Обновлено %1$d профилей</item>
<item quantity="other">Обновлено %1$d профиля</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="one">Обновлён %1$d файл ресурсов</item>
<item quantity="few">Обновлено %1$d файла ресурсов</item>
<item quantity="many">Обновлено %1$d файлов ресурсов</item>
<item quantity="other">Обновлено %1$d файла ресурсов</item>
</plurals>
<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>
@@ -351,8 +351,12 @@
<string name="title_del_config_count">Xóa %d cấu hình</string>
<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>
<plurals name="title_update_config_count">
<item quantity="other">Đã cập nhật %1$d cấu hình</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="other">Đã cập nhật %1$d tệp tài nguyên</item>
</plurals>
<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>
@@ -351,8 +351,12 @@
<string name="title_del_config_count">删除 %d 个配置</string>
<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>
<plurals name="title_update_config_count">
<item quantity="other">已更新 %1$d 个配置</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="other">已更新 %1$d 个资源文件</item>
</plurals>
<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>
@@ -351,8 +351,12 @@
<string name="title_del_config_count">刪除 %d 個設定</string>
<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>
<plurals name="title_update_config_count">
<item quantity="other">已更新 %1$d 個設定</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="other">已更新 %1$d 個資源檔案</item>
</plurals>
<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>
+8 -2
View File
@@ -368,8 +368,14 @@
<string name="title_del_config_count">Delete %d configs</string>
<string name="title_import_config_count">Import %d configs</string>
<string name="title_export_config_count">Export %d configs</string>
<string name="title_update_config_count">Update %d configs</string>
<string name="title_update_asset_count">Updated %d asset files</string>
<plurals name="title_update_config_count">
<item quantity="one">Updated %1$d config</item>
<item quantity="other">Updated %1$d configs</item>
</plurals>
<plurals name="title_update_asset_count">
<item quantity="one">Updated %1$d asset file</item>
<item quantity="other">Updated %1$d asset files</item>
</plurals>
<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>
@@ -0,0 +1,60 @@
package com.v2ray.ang.ui.server
import org.junit.Assert.assertEquals
import org.junit.Assert.assertSame
import org.junit.Test
class ProxyChainMembersTest {
@Test
fun removalFollowsThePendingKeyAfterReordering() {
val pendingKey = "two"
val members = listOf("Two", "One", "Three")
val keys = listOf("two", "one", "three")
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, keys, pendingKey)
assertEquals(listOf("One", "Three"), remainingMembers)
assertEquals(listOf("one", "three"), remainingKeys)
assertEquals(listOf("Two", "One", "Three"), members)
assertEquals(listOf("two", "one", "three"), keys)
}
@Test
fun duplicateNamesDoNotChangeWhichMemberIsRemoved() {
val (members, keys) = withoutProxyChainMember(
listOf("Same", "Same", "Other"),
listOf("first", "second", "third"),
"second",
)
assertEquals(listOf("Same", "Other"), members)
assertEquals(listOf("first", "third"), keys)
}
@Test
fun missingMemberLeavesBothListsUnchanged() {
val members = listOf("One", "Three")
val keys = listOf("one", "three")
val (remainingMembers, remainingKeys) = withoutProxyChainMember(members, keys, "two")
assertSame(members, remainingMembers)
assertSame(keys, remainingKeys)
}
@Test
fun emptyChainRemainsEmpty() {
assertEquals(
emptyList<String>() to emptyList<String>(),
withoutProxyChainMember(emptyList(), emptyList(), "missing"),
)
}
@Test
fun blankMemberIsRemovedWithItsOwnKey() {
assertEquals(
listOf("One") to listOf("one"),
withoutProxyChainMember(listOf("One", ""), listOf("one", "blank"), "blank"),
)
}
}
@@ -0,0 +1,45 @@
package com.v2ray.ang.ui.userasset
import android.app.Application
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.mockito.Mockito.mock
class UserAssetViewModelTest {
private val viewModel = UserAssetViewModel(mock(Application::class.java))
@Test
fun builtInIdentitySurvivesReloadAndSourceChange() {
val first = viewModel.buildAssetList(null, "first/source")
val reloaded = viewModel.buildAssetList(emptyList(), "second/source")
assertEquals(3, first.size)
assertEquals(first.size, first.map { it.guid }.distinct().size)
assertEquals(first.map { it.guid }, reloaded.map { it.guid })
assertTrue(reloaded.all { it.assetUrl.locked == true })
assertEquals(
AppConfig.GEOIP_ONLY_CN_PRIVATE_URL,
reloaded.single { it.assetUrl.remarks == AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT }.assetUrl.url,
)
}
@Test
fun savedAssetKeepsItsIdentityAndReplacesMatchingBuiltIn() {
val saved = AssetUrlCache("saved-guid", AssetUrlItem(AppConfig.GEOSITE_DAT, "https://example.invalid/geosite.dat"))
val custom = AssetUrlCache("custom-guid", AssetUrlItem("custom.dat", "file"))
val builtIns = viewModel.buildAssetList(emptyList(), "source")
val rows = viewModel.buildAssetList(listOf(saved, custom), "source")
assertEquals(4, rows.size)
assertEquals(saved, rows.single { it.assetUrl.remarks == saved.assetUrl.remarks })
assertEquals(custom, rows.single { it.guid == custom.guid })
assertEquals(
builtIns.filter { it.assetUrl.remarks != AppConfig.GEOSITE_DAT }.map { it.guid },
rows.filter { it.assetUrl.locked == true }.map { it.guid },
)
}
}