Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9621b7ea9 | ||
|
|
a690040468 | ||
|
|
62093d09eb | ||
|
|
6a6a44a380 | ||
|
|
1fd05e16b5 | ||
|
|
e7dcec1eff | ||
|
|
7d9fa970dc | ||
|
|
6400d33b87 | ||
|
|
b7c69486bf | ||
|
|
5b99841f06 |
+1
-1
Submodule AndroidLibXrayLite updated: 1991d93327...f9903e48b9
@@ -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 {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user