migrate to compose (#5866)

* add composeui dependencies and component

* remove multidex

* BaseComponentActivity and HelperBaseComponentActivity

* migrate AppPicker/PerAppProxy Activity and use Snackbar

* migrate AboutActivity to composeui

* migrate BackupActivity to composeui

* migrate CheckUpdateActivity to composeui

* migrate Logcat to composeui

* migrate Scanner to composeui and camerax

* migrate sc start/stop/switch to compose

* migrate TaskerActivity to composeui

* migrate UrlSchemeActivity to composeui

* migrate UserAsset to composeui

* migrate ServerActivity to composeui

* migrate ServerCustomConfigActivity to composeui

* migrate ServerGroupActivity to composeui

* migrate ServerProxyChainActivity to composeui

* migrate SettingsActivity to composeui

* migrate SubEdit/SubSetting Activity to composeui

* migrate routingEdit/routingSetting Activity to composeui

* migrate MainActivity to composeui

* clode clean and chore change

* Fix

* add scrollbar

* fix imePadding

* fix FormDropdownField scrollbar and ime conflict‌

* update servercostomconfigactivity with linenum and scrollbar

* fix AppPickerActivity filter wrong toogle

* fix App theme xml

* fix scrollbar not work in SettingsActivity

* final inset

* fix servercustomconfigactivity when open not begin line

* try optimize mainactivity performance

* use launch intent and fix not refresh when second edit use equals

* fix a ghost error

* update string

* update miss string

* optimize mainactivity and mainviewmodel

---------

Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
This commit is contained in:
fuilloi
2026-07-16 15:18:56 +08:00
committed by GitHub
co-authored by 2dust
parent 8d726fa829
commit b95db14005
159 changed files with 10000 additions and 10965 deletions
+22 -25
View File
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.jaredsburrows.license")
}
@@ -14,7 +15,6 @@ android {
targetSdk = 37
versionCode = 736
versionName = "2.2.6"
multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
@@ -124,8 +124,8 @@ android {
}
buildFeatures {
viewBinding = true
buildConfig = true
compose = true
}
packaging {
@@ -142,20 +142,19 @@ dependencies {
// AndroidX Core Libraries
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.androidx.activity)
implementation(libs.androidx.constraintlayout)
implementation(libs.preference.ktx)
implementation(libs.recyclerview)
implementation(libs.androidx.swiperefreshlayout)
implementation(libs.androidx.viewpager2)
implementation(libs.androidx.fragment)
// UI Libraries
implementation(libs.material)
implementation(libs.toasty)
implementation(libs.editorkit)
implementation(libs.flexbox)
// Compose Libraries
implementation(platform(libs.androidx.compose.bom))
androidTestImplementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.lifecycle.runtime.compose)
debugImplementation(libs.androidx.compose.ui.tooling)
// Data and Storage Libraries
implementation(libs.mmkv.static)
@@ -166,25 +165,23 @@ dependencies {
implementation(libs.kotlinx.coroutines.android)
implementation(libs.kotlinx.coroutines.core)
// Language and Processing Libraries
implementation(libs.language.base)
implementation(libs.language.json)
// Intent and Utility Libraries
implementation(libs.quickie.foss)
implementation(libs.core)
// QR Code: CameraX + ZXing
implementation(libs.camerax.core)
implementation(libs.camerax.camera2)
implementation(libs.camerax.lifecycle)
implementation(libs.camerax.compose)
implementation(libs.core) // zxing core
// AndroidX Lifecycle and Architecture Components
implementation(libs.lifecycle.viewmodel.ktx)
implementation(libs.lifecycle.livedata.ktx)
implementation(libs.lifecycle.runtime.ktx)
// Background Task Libraries
implementation(libs.work.runtime.ktx)
implementation(libs.work.multiprocess)
// Multidex Support
implementation(libs.multidex)
// Reorderable list
implementation(libs.reorderable)
// Testing Libraries
testImplementation(libs.junit)
@@ -1,14 +1,15 @@
package com.v2ray.ang
import android.app.Application
import android.content.Context
import androidx.multidex.MultiDexApplication
import androidx.work.Configuration
import androidx.work.WorkManager
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.compose.ThemeManager
import com.v2ray.ang.handler.SettingsManager
class AngApplication : MultiDexApplication() {
class AngApplication : Application() {
companion object {
lateinit var application: AngApplication
}
@@ -39,10 +40,8 @@ class AngApplication : MultiDexApplication() {
// Ensure critical preference defaults are present in MMKV early
SettingsManager.initApp(this)
SettingsManager.setNightMode()
es.dmoral.toasty.Toasty.Config.getInstance()
.setGravity(android.view.Gravity.BOTTOM, 0, 300)
.apply()
// Initialize theme state from MMKV
ThemeManager.refresh()
}
}
@@ -0,0 +1,307 @@
package com.v2ray.ang.compose
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.drawable.BitmapDrawable
import android.graphics.drawable.Drawable
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.Checkbox
import androidx.compose.material3.CheckboxDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.v2ray.ang.R
import sh.calvin.reorderable.ReorderableCollectionItemScope
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppTopBar(
title: String,
onBackClick: () -> Unit,
isLoading: Boolean = false,
isSearchActive: Boolean = false,
searchQuery: String = "",
onSearchQueryChange: (String) -> Unit = {},
onSearchClose: () -> Unit = {},
searchPlaceholder: String? = null,
navigationIcon: @Composable (() -> Unit)? = null,
actions: @Composable RowScope.() -> Unit = {}
) {
Column {
TopAppBar(
title = {
if (isSearchActive) {
SearchInputField(
query = searchQuery,
onQueryChange = onSearchQueryChange,
placeholder = searchPlaceholder
)
} else {
Text(text = title)
}
},
navigationIcon = {
if (navigationIcon != null) {
navigationIcon()
} else {
IconButton(onClick = if (isSearchActive) onSearchClose else onBackClick) {
Icon(
painter = painterResource(R.drawable.ic_arrow_back_24dp),
contentDescription = "Back"
)
}
}
},
actions = actions,
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.surface,
titleContentColor = MaterialTheme.colorScheme.onSurface,
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
actionIconContentColor = MaterialTheme.colorScheme.onSurface
)
)
AnimatedVisibility(
visible = isLoading,
enter = expandVertically(),
exit = shrinkVertically()
) {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = colorFabActive)
}
}
}
@Composable
private fun SearchInputField(
query: String,
onQueryChange: (String) -> Unit,
placeholder: String?
) {
val focusRequester = remember { FocusRequester() }
LaunchedEffect(Unit) { focusRequester.requestFocus() }
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = query,
onValueChange = onQueryChange,
singleLine = true,
textStyle = TextStyle(color = MaterialTheme.colorScheme.onSurface, fontSize = 16.sp),
placeholder = { if (placeholder != null) Text(placeholder, style = TextStyle(color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp)) },
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
focusedBorderColor = Color.Transparent,
unfocusedBorderColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.secondary,
selectionColors = TextSelectionColors(
handleColor = MaterialTheme.colorScheme.secondary,
backgroundColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.4f)
)
),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
modifier = Modifier.weight(1f).focusRequester(focusRequester)
)
if (query.isNotEmpty()) {
IconButton(onClick = { onQueryChange("") }) {
Icon(painterResource(android.R.drawable.ic_menu_close_clear_cancel), "Clear")
}
}
}
}
@Composable
fun AppListItem(
appName: String,
packageName: String,
icon: Any?,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable { onCheckedChange(!checked) }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
when (icon) {
is Bitmap -> {
Image(
bitmap = icon.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(40.dp),
contentScale = ContentScale.Fit
)
}
is Int -> {
Image(
painter = painterResource(id = icon),
contentDescription = null,
modifier = Modifier.size(40.dp),
contentScale = ContentScale.Fit
)
}
is Drawable -> {
val bitmap = remember(icon) { createBitmapFromDrawable(icon) }
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.size(40.dp),
contentScale = ContentScale.Fit
)
}
else -> {
Box(modifier = Modifier.size(40.dp))
}
}
}
Spacer(modifier = Modifier.width(16.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
text = appName,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(2.dp))
Text(
text = packageName,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
colors = CheckboxDefaults.colors(checkedColor = colorFabActive)
)
}
}
private fun createBitmapFromDrawable(drawable: Drawable): Bitmap {
if (drawable is BitmapDrawable) {
return drawable.bitmap
}
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 96
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 96
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
val canvas = Canvas(bitmap)
drawable.setBounds(0, 0, canvas.width, canvas.height)
drawable.draw(canvas)
return bitmap
}
@Composable
fun AppDivider(modifier: Modifier = Modifier) {
val color = if (LocalDarkTheme.current) dividerColorDark else dividerColorLight
HorizontalDivider(modifier = modifier.fillMaxWidth(), thickness = 1.dp, color = color)
}
@Composable
fun VersionInfoBlock(
versionText: String,
appIdText: String? = null,
modifier: Modifier = Modifier
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = versionText, style = MaterialTheme.typography.bodySmall)
if (appIdText != null) {
Spacer(modifier = Modifier.height(4.dp))
Text(text = appIdText, style = MaterialTheme.typography.bodySmall)
}
}
}
@Composable
private fun reorderableElevation(isDragging: Boolean) = animateDpAsState(
targetValue = if (isDragging) 4.dp else 0.dp,
label = "ReorderableElevation"
)
@Composable
fun ReorderableListItem(
scope: ReorderableCollectionItemScope,
isDragging: Boolean,
content: @Composable RowScope.() -> Unit
) {
val elevation by reorderableElevation(isDragging)
Surface(
modifier = Modifier.fillMaxWidth(),
shadowElevation = elevation
) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(with(scope) { Modifier.longPressDraggableHandle() }),
verticalAlignment = Alignment.CenterVertically,
content = content
)
}
}
@Composable
fun ReorderableGridItem(
scope: ReorderableCollectionItemScope,
isDragging: Boolean,
content: @Composable () -> Unit
) {
val elevation by reorderableElevation(isDragging)
Surface(
modifier = Modifier
.fillMaxWidth()
.then(with(scope) { Modifier.longPressDraggableHandle() }),
shadowElevation = elevation
) {
content()
}
}
@@ -0,0 +1,193 @@
package com.v2ray.ang.compose
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.v2ray.ang.R
@Composable
fun ConfirmDialog(
title: String? = null,
message: String,
confirmText: String,
dismissText: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = title?.let { { Text(it) } },
text = { Text(message, style = MaterialTheme.typography.bodyMedium) },
confirmButton = {
TextButton(onClick = { onConfirm(); onDismiss() }) { Text(confirmText) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(dismissText) }
},
containerColor = MaterialTheme.colorScheme.surface
)
}
data class InputField(
val label: String,
val value: String,
val singleLine: Boolean = true,
val visualTransformation: VisualTransformation = VisualTransformation.None
)
@Composable
fun InputDialog(
title: String,
fields: List<InputField>,
onFieldChange: (Int, String) -> Unit,
confirmText: String,
dismissText: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
fields.forEachIndexed { index, field ->
OutlinedTextField(
value = field.value,
onValueChange = { onFieldChange(index, it) },
label = { Text(field.label) },
singleLine = false,
maxLines = 5,
visualTransformation = field.visualTransformation,
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.secondary,
selectionColors = TextSelectionColors(
handleColor = MaterialTheme.colorScheme.secondary,
backgroundColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.4f)
)
),
modifier = Modifier.fillMaxWidth()
)
}
}
},
confirmButton = {
TextButton(onClick = onConfirm) { Text(confirmText) }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text(dismissText) }
},
containerColor = MaterialTheme.colorScheme.surface
)
}
@Composable
fun QRCodeDialog(
bitmap: Bitmap?,
onDismiss: () -> Unit
) {
if (bitmap == null) return
AlertDialog(
onDismissRequest = onDismiss,
text = {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = "QR Code",
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
)
},
confirmButton = {
TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.ok)) }
},
containerColor = MaterialTheme.colorScheme.surface
)
}
/**
* When showRadio is true, displays RadioButton (single selection mode);
* otherwise, plain clickable list mode.
* The selectedOption parameter is used to highlight the selected item only when showRadio is true.
*/
@Composable
fun SelectListDialog(
title: String? = null,
options: List<String>,
selectedOption: String = "",
onSelected: (Int, String) -> Unit,
onDismiss: () -> Unit,
showRadio: Boolean = false
) {
val selectedIndex = if (showRadio) options.indexOf(selectedOption) else -1
AlertDialog(
onDismissRequest = onDismiss,
title = title?.let { { Text(it) } },
text = {
LazyColumn {
itemsIndexed(options) { index, option ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelected(index, option) }
.padding(vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (showRadio) {
RadioButton(
selected = index == selectedIndex,
onClick = { onSelected(index, option) }
)
Spacer(modifier = Modifier.width(8.dp))
}
Text(
text = option,
style = MaterialTheme.typography.bodyMedium,
modifier = if (!showRadio)
Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 4.dp)
else Modifier
)
}
}
}
},
confirmButton = {},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(android.R.string.cancel))
}
},
containerColor = MaterialTheme.colorScheme.surface
)
}
@@ -0,0 +1,148 @@
package com.v2ray.ang.compose
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExposedDropdownMenuAnchorType
import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.ExposedDropdownMenuDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
@Composable
fun FormTextField(
label: String,
value: String,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
keyboardType: KeyboardType = KeyboardType.Text,
placeholder: String? = null,
maxLines: Int = 5,
) {
Box(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
label = { Text(label) },
placeholder = placeholder?.let { { Text(it) } },
singleLine = false,
maxLines = maxLines,
enabled = enabled,
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.secondary,
selectionColors = TextSelectionColors(
handleColor = MaterialTheme.colorScheme.secondary,
backgroundColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.4f)
)
),
modifier = Modifier.fillMaxWidth()
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun FormDropdownField(
label: String,
value: String,
options: List<String>,
onValueChange: (String) -> Unit,
modifier: Modifier = Modifier,
editable: Boolean = false,
enabled: Boolean = true,
placeholder: String? = null,
) {
var expanded by rememberSaveable { mutableStateOf(false) }
val menuScrollState = rememberScrollState()
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
ExposedDropdownMenuBox(
expanded = expanded,
onExpandedChange = { newExpanded ->
if (!enabled) return@ExposedDropdownMenuBox
if (!editable && newExpanded) {
keyboardController?.hide()
}
expanded = newExpanded
},
modifier = modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 4.dp)
) {
OutlinedTextField(
value = value,
onValueChange = { if (editable) onValueChange(it) },
readOnly = !editable,
enabled = enabled,
label = { Text(label) },
placeholder = { if (placeholder != null) Text(placeholder) },
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
colors = OutlinedTextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
cursorColor = MaterialTheme.colorScheme.secondary,
selectionColors = TextSelectionColors(
handleColor = MaterialTheme.colorScheme.secondary,
backgroundColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.4f)
)
),
modifier = Modifier
.menuAnchor(
type = if (editable) ExposedDropdownMenuAnchorType.PrimaryEditable
else ExposedDropdownMenuAnchorType.PrimaryNotEditable
)
.fillMaxWidth()
.onFocusChanged { focusState ->
if (!editable && focusState.isFocused) {
keyboardController?.hide()
}
}
)
ExposedDropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false },
modifier = Modifier.verticalScrollbar(menuScrollState),
scrollState = menuScrollState,
containerColor = MaterialTheme.colorScheme.surface
) {
options.forEach { option ->
DropdownMenuItem(
text = { Text(option) },
onClick = {
onValueChange(option)
expanded = false
focusManager.clearFocus()
}
)
}
}
}
}
@@ -0,0 +1,355 @@
package com.v2ray.ang.compose
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ScrollState
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.collectLatest
data class ScrollbarConfig(
val thickness: Dp = 4.dp,
val minThumbSize: Dp = 24.dp,
val thumbColor: Color = Color.Unspecified,
val trackColor: Color = Color.Transparent,
val padding: Dp = 2.dp,
val cornerRadius: Dp = 2.dp,
val fadeOutDurationMs: Int = 1500,
val fadeAnimDurationMs: Int = 300,
)
fun Modifier.verticalScrollbar(
scrollState: ScrollState,
config: ScrollbarConfig = ScrollbarConfig(),
): Modifier = composed {
val actualThumbColor = if (config.thumbColor == Color.Unspecified) {
MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
} else {
config.thumbColor
}
val alpha = remember { Animatable(0f) }
val scrollChanged = remember {
MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
LaunchedEffect(scrollState) {
snapshotFlow { scrollState.value }
.collect { scrollChanged.tryEmit(Unit) }
}
LaunchedEffect(Unit) {
scrollChanged.collectLatest {
alpha.snapTo(1f)
delay(config.fadeOutDurationMs.toLong())
alpha.animateTo(0f, animationSpec = tween(config.fadeAnimDurationMs))
}
}
drawWithContent {
drawContent()
val maxScroll = scrollState.maxValue.toFloat()
if (maxScroll <= 0f) return@drawWithContent
if (alpha.value <= 0f) return@drawWithContent
val viewportHeight = size.height
val contentHeight = viewportHeight + maxScroll
val maxThumbHeight = viewportHeight * 0.5f
val thumbHeight = (viewportHeight / contentHeight * viewportHeight)
.coerceAtLeast(config.minThumbSize.toPx())
.coerceAtMost(maxThumbHeight)
val scrollFraction = scrollState.value / maxScroll
val maxThumbOffset = viewportHeight - thumbHeight
val thumbOffset = scrollFraction * maxThumbOffset
val thickness = config.thickness.toPx()
val padding = config.padding.toPx()
val cornerRadius = config.cornerRadius.toPx()
val currentAlpha = alpha.value
if (config.trackColor != Color.Transparent) {
drawRoundRect(
color = config.trackColor.copy(alpha = config.trackColor.alpha * currentAlpha),
topLeft = Offset(size.width - thickness - padding, 0f),
size = Size(thickness, viewportHeight),
cornerRadius = CornerRadius(cornerRadius)
)
}
drawRoundRect(
color = actualThumbColor.copy(alpha = actualThumbColor.alpha * currentAlpha),
topLeft = Offset(size.width - thickness - padding, thumbOffset),
size = Size(thickness, thumbHeight),
cornerRadius = CornerRadius(cornerRadius)
)
}
}
fun Modifier.horizontalScrollbar(
scrollState: ScrollState,
config: ScrollbarConfig = ScrollbarConfig(),
): Modifier = composed {
val actualThumbColor = if (config.thumbColor == Color.Unspecified) {
MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
} else {
config.thumbColor
}
val alpha = remember { Animatable(0f) }
val scrollChanged = remember {
MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
LaunchedEffect(scrollState) {
snapshotFlow { scrollState.value }
.collect { scrollChanged.tryEmit(Unit) }
}
LaunchedEffect(Unit) {
scrollChanged.collectLatest {
alpha.snapTo(1f)
delay(config.fadeOutDurationMs.toLong())
alpha.animateTo(0f, animationSpec = tween(config.fadeAnimDurationMs))
}
}
drawWithContent {
drawContent()
val maxScroll = scrollState.maxValue.toFloat()
if (maxScroll <= 0f) return@drawWithContent
if (alpha.value <= 0f) return@drawWithContent
val viewportWidth = size.width
val contentWidth = viewportWidth + maxScroll
val maxThumbWidth = viewportWidth * 0.5f
val thumbWidth = (viewportWidth / contentWidth * viewportWidth)
.coerceAtLeast(config.minThumbSize.toPx())
.coerceAtMost(maxThumbWidth)
val scrollFraction = scrollState.value / maxScroll
val maxThumbOffset = viewportWidth - thumbWidth
val thumbOffset = scrollFraction * maxThumbOffset
val thickness = config.thickness.toPx()
val padding = config.padding.toPx()
val cornerRadius = config.cornerRadius.toPx()
val currentAlpha = alpha.value
if (config.trackColor != Color.Transparent) {
drawRoundRect(
color = config.trackColor.copy(alpha = config.trackColor.alpha * currentAlpha),
topLeft = Offset(0f, size.height - thickness - padding),
size = Size(viewportWidth, thickness),
cornerRadius = CornerRadius(cornerRadius)
)
}
drawRoundRect(
color = actualThumbColor.copy(alpha = actualThumbColor.alpha * currentAlpha),
topLeft = Offset(thumbOffset, size.height - thickness - padding),
size = Size(thumbWidth, thickness),
cornerRadius = CornerRadius(cornerRadius)
)
}
}
fun Modifier.verticalScrollbar(
lazyListState: LazyListState,
config: ScrollbarConfig = ScrollbarConfig(),
): Modifier = composed {
val actualThumbColor = if (config.thumbColor == Color.Unspecified) {
MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
} else {
config.thumbColor
}
val alpha = remember { Animatable(0f) }
val scrollChanged = remember {
MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
LaunchedEffect(lazyListState) {
snapshotFlow {
lazyListState.firstVisibleItemIndex to lazyListState.firstVisibleItemScrollOffset
}.collect { scrollChanged.tryEmit(Unit) }
}
LaunchedEffect(Unit) {
scrollChanged.collectLatest {
alpha.snapTo(1f)
delay(config.fadeOutDurationMs.toLong())
alpha.animateTo(0f, animationSpec = tween(config.fadeAnimDurationMs))
}
}
drawWithContent {
drawContent()
val layoutInfo = lazyListState.layoutInfo
val totalItems = layoutInfo.totalItemsCount
if (totalItems == 0) return@drawWithContent
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) return@drawWithContent
if (alpha.value <= 0f) return@drawWithContent
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
val viewportStartOffset = layoutInfo.viewportStartOffset.toFloat()
val averageItemHeight = visibleItems.sumOf { it.size } / visibleItems.size.toFloat()
val estimatedTotalHeight = averageItemHeight * totalItems
if (estimatedTotalHeight <= viewportHeight) return@drawWithContent
val maxThumbHeight = viewportHeight * 0.5f
val thumbHeight = (viewportHeight / estimatedTotalHeight * viewportHeight)
.coerceAtLeast(config.minThumbSize.toPx())
.coerceAtMost(maxThumbHeight)
val firstItem = visibleItems.first()
val scrolledPx = firstItem.index * averageItemHeight + (viewportStartOffset - firstItem.offset)
val maxScroll = estimatedTotalHeight - viewportHeight
val scrollFraction = (scrolledPx / maxScroll).coerceIn(0f, 1f)
val maxThumbOffset = viewportHeight - thumbHeight
val thumbOffset = scrollFraction * maxThumbOffset
val thickness = config.thickness.toPx()
val padding = config.padding.toPx()
val cornerRadius = config.cornerRadius.toPx()
val currentAlpha = alpha.value
if (config.trackColor != Color.Transparent) {
drawRoundRect(
color = config.trackColor.copy(alpha = config.trackColor.alpha * currentAlpha),
topLeft = Offset(size.width - thickness - padding, 0f),
size = Size(thickness, viewportHeight),
cornerRadius = CornerRadius(cornerRadius)
)
}
drawRoundRect(
color = actualThumbColor.copy(alpha = actualThumbColor.alpha * currentAlpha),
topLeft = Offset(size.width - thickness - padding, thumbOffset),
size = Size(thickness, thumbHeight),
cornerRadius = CornerRadius(cornerRadius)
)
}
}
fun Modifier.verticalScrollbar(
lazyGridState: LazyGridState,
config: ScrollbarConfig = ScrollbarConfig(),
): Modifier = composed {
val actualThumbColor = if (config.thumbColor == Color.Unspecified) {
MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
} else {
config.thumbColor
}
val alpha = remember { Animatable(0f) }
val scrollChanged = remember {
MutableSharedFlow<Unit>(
extraBufferCapacity = 1,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
}
LaunchedEffect(lazyGridState) {
snapshotFlow {
lazyGridState.firstVisibleItemIndex to lazyGridState.firstVisibleItemScrollOffset
}.collect { scrollChanged.tryEmit(Unit) }
}
LaunchedEffect(Unit) {
scrollChanged.collectLatest {
alpha.snapTo(1f)
delay(config.fadeOutDurationMs.toLong())
alpha.animateTo(0f, animationSpec = tween(config.fadeAnimDurationMs))
}
}
drawWithContent {
drawContent()
val layoutInfo = lazyGridState.layoutInfo
val totalItems = layoutInfo.totalItemsCount
if (totalItems == 0) return@drawWithContent
val visibleItems = layoutInfo.visibleItemsInfo
if (visibleItems.isEmpty()) return@drawWithContent
if (alpha.value <= 0f) return@drawWithContent
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
val viewportStartOffset = layoutInfo.viewportStartOffset.toFloat()
val rows = visibleItems.groupBy { it.row }
val avgRowHeight = rows.values.map { row -> row.maxOf { it.size.height } }.average().toFloat()
val columnsPerRow = rows.values.firstOrNull()?.size ?: 1
val totalRows = (totalItems + columnsPerRow - 1) / columnsPerRow
val estimatedTotalHeight = avgRowHeight * totalRows
if (estimatedTotalHeight <= viewportHeight) return@drawWithContent
val maxThumbHeight = viewportHeight * 0.5f
val thumbHeight = (viewportHeight / estimatedTotalHeight * viewportHeight)
.coerceAtLeast(config.minThumbSize.toPx())
.coerceAtMost(maxThumbHeight)
val firstItem = visibleItems.first()
val firstRow = firstItem.row
val scrolledPx = firstRow * avgRowHeight + (viewportStartOffset - firstItem.offset.y)
val maxScroll = estimatedTotalHeight - viewportHeight
val scrollFraction = (scrolledPx / maxScroll).coerceIn(0f, 1f)
val maxThumbOffset = viewportHeight - thumbHeight
val thumbOffset = scrollFraction * maxThumbOffset
val thickness = config.thickness.toPx()
val padding = config.padding.toPx()
val cornerRadius = config.cornerRadius.toPx()
val currentAlpha = alpha.value
if (config.trackColor != Color.Transparent) {
drawRoundRect(
color = config.trackColor.copy(alpha = config.trackColor.alpha * currentAlpha),
topLeft = Offset(size.width - thickness - padding, 0f),
size = Size(thickness, viewportHeight),
cornerRadius = CornerRadius(cornerRadius)
)
}
drawRoundRect(
color = actualThumbColor.copy(alpha = actualThumbColor.alpha * currentAlpha),
topLeft = Offset(size.width - thickness - padding, thumbOffset),
size = Size(thickness, thumbHeight),
cornerRadius = CornerRadius(cornerRadius)
)
}
}
@@ -0,0 +1,225 @@
package com.v2ray.ang.compose
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.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.SwitchDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import com.v2ray.ang.R
@Composable
fun PreferenceGroupHeader(title: String, modifier: Modifier = Modifier) {
Text(
text = title,
style = MaterialTheme.typography.titleSmall,
color = colorFabActive,
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
)
}
@Composable
private fun SettingsItemRow(
icon: Painter?,
title: String,
description: String?,
enabled: Boolean,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
trailing: @Composable (() -> Unit)? = null
) {
val contentColor = if (enabled) MaterialTheme.colorScheme.onSurface
else MaterialTheme.colorScheme.onSurfaceVariant
Row(
modifier = modifier
.fillMaxWidth()
.then(if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick) else Modifier)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (icon != null) {
Icon(
painter = icon,
contentDescription = null,
modifier = Modifier.size(24.dp),
tint = contentColor
)
Spacer(modifier = Modifier.width(16.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = title,
style = MaterialTheme.typography.bodyLarge,
color = contentColor
)
if (!description.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
trailing?.invoke()
}
}
@Composable
fun SettingsEditItem(
icon: Painter? = null,
title: String,
value: String,
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
isPassword: Boolean = false,
keyboardNumber: Boolean = false
) {
var showDialog by remember { mutableStateOf(false) }
val description = if (isPassword) {
if (value.isEmpty()) null else "******"
} else {
value.ifEmpty { null }
}
SettingsItemRow(
icon = icon,
title = title,
description = description,
enabled = enabled,
onClick = if (enabled) { { showDialog = true } } else null,
modifier = modifier
)
if (showDialog) {
var text by remember { mutableStateOf(value) }
InputDialog(
title = title,
fields = listOf(
InputField(
label = title,
value = text,
visualTransformation = VisualTransformation.None
)
),
onFieldChange = { _, v -> text = v },
confirmText = stringResource(android.R.string.ok),
dismissText = stringResource(android.R.string.cancel),
onConfirm = { showDialog = false; onValueChanged(text) },
onDismiss = { showDialog = false }
)
}
}
@Composable
fun SettingsListItem(
icon: Painter? = null,
title: String,
entries: List<String>,
values: List<String>,
selectedValue: String,
onSelected: (String) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
var showDialog by remember { mutableStateOf(false) }
val selectedIndex = values.indexOf(selectedValue).let { if (it >= 0) it else 0 }
val summary = entries.getOrNull(selectedIndex).orEmpty()
SettingsItemRow(
icon = icon,
title = title,
description = summary.ifEmpty { null },
enabled = enabled,
onClick = if (enabled) { { showDialog = true } } else null,
modifier = modifier
)
if (showDialog) {
SelectListDialog(
title = title,
options = entries,
selectedOption = summary,
onSelected = { index, _ ->
showDialog = false
values.getOrNull(index)?.let(onSelected)
},
onDismiss = { showDialog = false },
showRadio = true
)
}
}
@Composable
fun SettingsMenuItem(
icon: Painter? = null,
title: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
subtitle: String? = null
) {
SettingsItemRow(
icon = icon,
title = title,
description = subtitle,
enabled = true,
onClick = onClick,
modifier = modifier
)
}
@Composable
fun SettingsSwitchItem(
icon: Painter? = null,
title: String,
summary: String? = null,
checked: Boolean,
onCheckedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true
) {
SettingsItemRow(
icon = icon,
title = title,
description = summary,
enabled = enabled,
onClick = if (enabled) { { onCheckedChange(!checked) } } else null,
modifier = modifier,
trailing = {
Switch(
checked = checked,
onCheckedChange = if (enabled) onCheckedChange else null,
modifier = Modifier.scale(0.8f),
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
checkedTrackColor = MaterialTheme.colorScheme.secondary
),
enabled = enabled
)
}
)
}
@@ -0,0 +1,235 @@
package com.v2ray.ang.compose
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import java.util.concurrent.atomic.AtomicInteger
enum class ToastType {
NORMAL, SUCCESS, ERROR, INFO
}
data class AppSnackbarMessage(
val message: CharSequence,
val type: ToastType = ToastType.NORMAL,
val long: Boolean = false,
)
object AppSnackbarManager {
private val activeHosts = AtomicInteger(0)
private val _messages = MutableSharedFlow<AppSnackbarMessage>(
replay = 0,
extraBufferCapacity = 32,
onBufferOverflow = BufferOverflow.DROP_OLDEST
)
val messages = _messages.asSharedFlow()
fun registerHost() {
activeHosts.incrementAndGet()
}
fun unregisterHost() {
val current = activeHosts.decrementAndGet()
if (current < 0) activeHosts.set(0)
}
fun hasActiveHost(): Boolean = activeHosts.get() > 0
fun show(
message: CharSequence,
type: ToastType = ToastType.NORMAL,
long: Boolean = false,
): Boolean {
if (!hasActiveHost()) return false
return _messages.tryEmit(
AppSnackbarMessage(
message = message,
type = type,
long = long
)
)
}
}
class AppSnackbarController(
val hostState: SnackbarHostState,
private val scope: CoroutineScope,
) {
fun show(
message: CharSequence,
type: ToastType = ToastType.NORMAL,
long: Boolean = false,
) {
scope.launch {
hostState.currentSnackbarData?.dismiss()
hostState.showSnackbar(
message = message.toString(),
actionLabel = type.name,
duration = if (long) SnackbarDuration.Long else SnackbarDuration.Short,
withDismissAction = false,
)
}
}
}
val LocalAppSnackbar = staticCompositionLocalOf<AppSnackbarController> {
error("AppSnackbarController not provided. Wrap your content in AppTheme.")
}
@Composable
fun rememberAppSnackbarController(): AppSnackbarController {
val hostState = remember { SnackbarHostState() }
val scope = rememberCoroutineScope()
return remember(hostState, scope) { AppSnackbarController(hostState, scope) }
}
@Composable
fun AppSnackbarBridge(
controller: AppSnackbarController
) {
DisposableEffect(Unit) {
AppSnackbarManager.registerHost()
onDispose { AppSnackbarManager.unregisterHost() }
}
LaunchedEffect(controller) {
AppSnackbarManager.messages.collect { event ->
controller.show(
message = event.message,
type = event.type,
long = event.long
)
}
}
}
private val ToastCornerRadius = 24.dp
private val ToastHorizontalPad = 16.dp
private val ToastVerticalPad = 12.dp
private val ToastIconSize = 24.dp
private val ToastIconSpacing = 10.dp
private val ToastMaxLines = 8
private val ToastMaxWidthFraction = 0.75f
private val ToastBottomOffset = 100.dp
@Composable
fun AppSnackbarHost(
hostState: SnackbarHostState,
modifier: Modifier = Modifier
) {
BoxWithConstraints(modifier = modifier) {
val maxSnackbarWidth = maxWidth * ToastMaxWidthFraction
SnackbarHost(
hostState = hostState,
modifier = Modifier.fillMaxSize()
) { data ->
val type = runCatching {
ToastType.valueOf(data.visuals.actionLabel.orEmpty())
}.getOrDefault(ToastType.NORMAL)
val isDark = LocalDarkTheme.current
val bgColor = when (type) {
ToastType.NORMAL -> if (isDark) toastNormalBgDark else toastNormalBgLight
ToastType.SUCCESS -> toastSuccessBg
ToastType.ERROR -> toastErrorBg
ToastType.INFO -> toastInfoBg
}
val iconText = when (type) {
ToastType.SUCCESS -> "✓"
ToastType.ERROR -> "✕"
ToastType.INFO -> "ℹ"
ToastType.NORMAL -> null
}
Box(
modifier = Modifier
.fillMaxSize()
.padding(bottom = ToastBottomOffset),
contentAlignment = Alignment.BottomCenter
) {
Surface(
modifier = Modifier
.wrapContentWidth()
.widthIn(max = maxSnackbarWidth),
shape = RoundedCornerShape(ToastCornerRadius),
color = bgColor,
shadowElevation = 0.dp,
) {
Row(
modifier = Modifier.padding(
horizontal = ToastHorizontalPad,
vertical = ToastVerticalPad
),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
if (iconText != null) {
Box(
modifier = Modifier
.size(ToastIconSize)
.clip(CircleShape)
.background(toastIconCircleBg),
contentAlignment = Alignment.Center
) {
Text(
text = iconText,
color = toastTextColor,
fontSize = 14.sp,
fontWeight = FontWeight.Bold
)
}
Spacer(modifier = Modifier.width(ToastIconSpacing))
}
Text(
text = data.visuals.message,
color = toastTextColor,
fontSize = 14.sp,
maxLines = ToastMaxLines,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.wrapContentWidth()
)
}
}
}
}
}
}
@@ -0,0 +1,183 @@
package com.v2ray.ang.compose
import android.app.Activity
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.compositionLocalOf
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
private val LightColor = lightColorScheme(
primary = Color(0xFF000000), // Black 黑色
onPrimary = Color(0xFFFFFFFF), // White 白色
primaryContainer = Color(0xFFE0E0E0), // Light Gray 浅灰色
onPrimaryContainer = Color(0xFF000000), // Black 黑色
secondary = Color(0xFFf97910), // Orange 橙色
onSecondary = Color(0xFFFFFFFF), // White 白色
secondaryContainer = Color(0xFFFFE8D6), // Pale Orange 淡橙色
onSecondaryContainer = Color(0xFF2B1700), // Dark Brown 深棕色
tertiary = Color(0xFF009966), // Green 绿色
onTertiary = Color(0xFFFFFFFF), // White 白色
tertiaryContainer = Color(0xFFA0F2D0), // Light Green 浅绿色
onTertiaryContainer = Color(0xFF00201A), // Dark Teal 深蓝绿色
error = Color(0xFFBA1A1A), // Red 红色
errorContainer = Color(0xFFFFDAD6), // Light Red 浅红色
onError = Color(0xFFFFFFFF), // White 白色
onErrorContainer = Color(0xFF410002), // Dark Red 暗红色
background = Color(0xFFFFFFFF), // White 白色
onBackground = Color(0xFF1C1B1F), // Near Black 近黑色
surface = Color(0xFFFFFFFF), // White 白色
onSurface = Color(0xFF1C1B1F), // Near Black 近黑色
surfaceVariant = Color(0xFFE7E0EC), // Light Purple Gray 浅紫灰色
onSurfaceVariant = Color(0xFF49454F), // Dark Gray 深灰色
outline = Color(0xFF79747E), // Medium Gray 中灰色
outlineVariant = Color(0xFFCAC4D0), // Light Gray 浅灰色
inverseSurface = Color(0xFF313033), // Dark Gray 深灰色
inverseOnSurface = Color(0xFFF4EFF4), // Very Light Gray 极浅灰色
inversePrimary = Color(0xFFC0C0C0), // Silver Gray 银灰色
scrim = Color(0xFF000000), // Black 黑色
surfaceTint = Color(0xFF000000), // Black 黑色
surfaceContainerLowest = Color(0xFFFFFFFF), // White 白色
surfaceContainerLow = Color(0xFFF7F7F7), // Very Light Gray 极浅灰色
surfaceContainer = Color(0xFFF1F1F1), // Light Gray 浅灰色
surfaceContainerHigh = Color(0xFFEBEBEB), // Light Gray 浅灰色
surfaceContainerHighest = Color(0xFFE5E5E5), // Light Gray 浅灰色
)
private val DarkColor = darkColorScheme(
primary = Color(0xFFC0C0C0), // Silver Gray 银灰色
onPrimary = Color(0xFF303030), // Dark Gray 深灰色
primaryContainer = Color(0xFF474747), // Gray 灰色
onPrimaryContainer = Color(0xFFE0E0E0), // Light Gray 浅灰色
secondary = Color(0xFFf97910), // Orange 橙色
onSecondary = Color(0xFF4E2600), // Dark Brown 深棕色
secondaryContainer = Color(0xFF6F3800), // Brown 棕色
onSecondaryContainer = Color(0xFFFFE8D6), // Pale Orange 淡橙色
tertiary = Color(0xFF83D6B5), // Mint Green 薄荷绿
onTertiary = Color(0xFF00382E), // Dark Teal 深蓝绿色
tertiaryContainer = Color(0xFF005143), // Teal 蓝绿色
onTertiaryContainer = Color(0xFFA0F2D0), // Light Green 浅绿色
error = Color(0xFFFFB4AB), // Light Red 浅红色
errorContainer = Color(0xFF93000A), // Dark Red 暗红色
onError = Color(0xFF690005), // Deep Red 深红色
onErrorContainer = Color(0xFFFFDAD6), // Light Red 浅红色
background = Color(0xFF1C1B1F), // Near Black 近黑色
onBackground = Color(0xFFE6E1E5), // Light Gray 浅灰色
surface = Color(0xFF1C1B1F), // Near Black 近黑色
onSurface = Color(0xFFE6E1E5), // Light Gray 浅灰色
surfaceVariant = Color(0xFF49454F), // Dark Gray 深灰色
onSurfaceVariant = Color(0xFFCAC4D0), // Light Gray 浅灰色
outline = Color(0xFF938F99), // Grayish Purple 灰紫色
outlineVariant = Color(0xFF49454F), // Dark Gray 深灰色
inverseSurface = Color(0xFFE6E1E5), // Light Gray 浅灰色
inverseOnSurface = Color(0xFF1C1B1F), // Near Black 近黑色
inversePrimary = Color(0xFF000000), // Black 黑色
scrim = Color(0xFF000000), // Black 黑色
surfaceTint = Color(0xFFC0C0C0), // Silver Gray 银灰色
surfaceContainerLowest = Color(0xFF0F0F12), // Near Black 近黑色
surfaceContainerLow = Color(0xFF1A191D), // Dark Gray 深灰色
surfaceContainer = Color(0xFF1E1D21), // Dark Gray 深灰色
surfaceContainerHigh = Color(0xFF282729), // Dark Gray 深灰色
surfaceContainerHighest = Color(0xFF333234), // Dark Gray 深灰色
)
// Semantic Colors
val colorPing = Color(0xFF009966) // Green 绿色
val colorPingRed = Color(0xFFFF0099) // Pink Red 粉红色
val colorConfigType = Color(0xFFf97910) // Orange 橙色
val colorFabActive = Color(0xFFf97910) // Orange 橙色
val colorFabInactiveLight = Color(0xFF9C9C9C) // Gray 灰色
val colorFabInactiveDark = Color(0xFF646464) // Dark Gray 深灰色
val dividerColorLight = Color(0xFFE0E0E0) // Light Gray 浅灰色
val dividerColorDark = Color(0xFF424242) // Dark Gray 深灰色
// Toast Colors 70%
val toastNormalBgLight = Color(0xB3353A3E) // Dark Gray 深灰色
val toastNormalBgDark = Color(0xB34A4F54) // Darker Gray 暗灰色
val toastSuccessBg = Color(0xB3388E3C) // Green 绿色
val toastErrorBg = Color(0xB3D50000) // Red 红色
val toastInfoBg = Color(0xB33F51B5) // Indigo Blue 靛蓝色
val toastIconCircleBg = Color(0x33FFFFFF) // Semi-transparent White 半透明白色
val toastTextColor = Color.White // White 白色
object ThemeManager {
private val _themeMode = MutableStateFlow(
MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0") ?: "0"
)
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
fun setThemeMode(mode: String) {
MmkvManager.encodeSettings(AppConfig.PREF_UI_MODE_NIGHT, mode)
_themeMode.value = mode
}
fun refresh() {
_themeMode.value =
MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0") ?: "0"
}
}
@Composable
fun resolveDarkTheme(): Boolean {
val mode by ThemeManager.themeMode.collectAsState()
return when (mode) {
"1" -> false
"2" -> true
else -> isSystemInDarkTheme()
}
}
val LocalDarkTheme = compositionLocalOf { false }
@Composable
fun AppTheme(
darkTheme: Boolean = resolveDarkTheme(),
content: @Composable () -> Unit
) {
val colorScheme = if (darkTheme) DarkColor else LightColor
val snackbarController = rememberAppSnackbarController()
val view = LocalView.current
if (!view.isInEditMode) {
SideEffect {
val activity = view.context as? Activity ?: return@SideEffect
val window = activity.window
WindowCompat.getInsetsController(window, view).apply {
isAppearanceLightStatusBars = !darkTheme
isAppearanceLightNavigationBars = !darkTheme
}
}
}
CompositionLocalProvider(
LocalDarkTheme provides darkTheme,
LocalAppSnackbar provides snackbarController
) {
MaterialTheme(
colorScheme = colorScheme
) {
Box(modifier = Modifier.fillMaxSize()) {
content()
AppSnackbarBridge(controller = snackbarController)
AppSnackbarHost(hostState = snackbarController.hostState)
}
}
}
}
@@ -1,32 +0,0 @@
package com.v2ray.ang.contracts
/**
* A common Adapter -> host callback interface that includes common actions: edit, remove and refresh.
* Extend this interface or define more specific interfaces for different adapters as needed.
*/
interface BaseAdapterListener {
/**
* Request the host to edit the specified item.
* @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/
fun onEdit(guid: String, position: Int)
/**
* Request the host to remove the specified item. Position is provided for optional animation or validation.
* @param guid Unique identifier (GUID) of the item
* @param position Current position in the adapter (optional; host should validate it)
*/
fun onRemove(guid: String, position: Int)
/**
* Request the host to share the specified URL.
* @param url The URL to be shared
*/
fun onShare(url: String)
/**
* Request the host to refresh data (for example, reload from the ViewModel or call notifyDataSetChanged).
*/
fun onRefreshData()
}
@@ -1,13 +0,0 @@
package com.v2ray.ang.contracts
import com.v2ray.ang.dto.entities.ProfileItem
interface MainAdapterListener : BaseAdapterListener {
fun onEdit(guid: String, position: Int, profile: ProfileItem)
fun onSelectServer(guid: String)
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
}
@@ -0,0 +1,7 @@
package com.v2ray.ang.dto
data class LocateTarget(
val groupId: String,
val groupIndex: Int,
val itemPosition: Int
)
@@ -36,6 +36,7 @@ data class ProfileItem(
var xhttpMode: String? = null,
var xhttpExtra: String? = null,
var finalMask: String? = null,
var security: String? = null,
var sni: String? = null,
var alpn: String? = null,
@@ -69,63 +70,40 @@ data class ProfileItem(
var proxyChainProfiles: String? = null,
var browserDialerMode: String? = null,
) {
companion object {
fun create(configType: EConfigType): ProfileItem {
return ProfileItem(configType = configType)
}
fun create(configType: EConfigType): ProfileItem =
ProfileItem(configType = configType)
}
fun getServerAddressAndPort(): String {
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
return "${AppConfig.LOOPBACK}:${AppConfig.PORT_SOCKS}"
}
return Utils.getIpv6Address(server) + ":" + serverPort
return "${Utils.getIpv6Address(server)}:$serverPort"
}
override fun equals(other: Any?): Boolean {
if (other == null) return false
val obj = other as ProfileItem
return (this.server == obj.server
&& this.serverPort == obj.serverPort
&& this.password == obj.password
&& this.method == obj.method
&& this.flow == obj.flow
&& this.username == obj.username
&& this.network == obj.network
&& this.headerType == obj.headerType
&& this.host == obj.host
&& this.path == obj.path
&& this.seed == obj.seed
&& this.kcpMtu == obj.kcpMtu
&& this.kcpTti == obj.kcpTti
&& this.quicSecurity == obj.quicSecurity
&& this.quicKey == obj.quicKey
&& this.mode == obj.mode
&& this.serviceName == obj.serviceName
&& this.authority == obj.authority
&& this.xhttpMode == obj.xhttpMode
&& this.security == obj.security
&& this.sni == obj.sni
&& this.alpn == obj.alpn
&& this.fingerPrint == obj.fingerPrint
&& this.publicKey == obj.publicKey
&& this.shortId == obj.shortId
&& this.secretKey == obj.secretKey
&& this.localAddress == obj.localAddress
&& this.reserved == obj.reserved
&& this.mtu == obj.mtu
&& this.obfsPassword == obj.obfsPassword
&& this.portHopping == obj.portHopping
&& this.portHoppingInterval == obj.portHoppingInterval
&& this.pinnedCA256 == obj.pinnedCA256
&& this.proxyChainProfiles == obj.proxyChainProfiles
/**
* Dedicated identity for "remove duplicate configurations".
*
* Ignores metadata that does not affect connection:
* - configVersion
* - subscriptionId
* - addedTime
* - remarks
* - description
*
* All other fields, including configType, are included in the comparison.
*
* Returns a copy; the caller must not modify it further.
*/
fun duplicateIdentity(): ProfileItem =
copy(
configVersion = 0,
subscriptionId = "",
addedTime = 0L,
remarks = "",
description = null
)
}
}
@@ -1,6 +1,7 @@
package com.v2ray.ang.dto.entities
data class RulesetItem(
var id: String = "",
var remarks: String? = "",
var ip: List<String>? = null,
var domain: List<String>? = null,
@@ -2,5 +2,7 @@ package com.v2ray.ang.dto.entities
data class ServersCache(
val guid: String,
val profile: ProfileItem
val profile: ProfileItem,
val testDelayMillis: Long = 0L,
val testDelayString: String = "",
)
@@ -6,10 +6,13 @@ import android.content.Intent
import android.content.IntentFilter
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.Looper
import android.widget.Toast
import com.v2ray.ang.AngApplication
import com.v2ray.ang.compose.AppSnackbarManager
import com.v2ray.ang.compose.ToastType
import com.v2ray.ang.enums.EConfigType
import es.dmoral.toasty.Toasty
import java.io.Serializable
import java.net.URI
import java.util.Locale
@@ -17,13 +20,40 @@ import java.util.Locale
val Context.v2RayApplication: AngApplication?
get() = applicationContext as? AngApplication
private inline fun runOnMain(crossinline block: () -> Unit) {
if (Looper.myLooper() == Looper.getMainLooper()) {
block()
} else {
Handler(Looper.getMainLooper()).post { block() }
}
}
private inline fun Context.dispatchMessage(
message: CharSequence,
type: ToastType,
long: Boolean = false,
crossinline fallback: () -> Unit
) {
val handledBySnackbar = AppSnackbarManager.show(
message = message,
type = type,
long = long
)
if (!handledBySnackbar) {
runOnMain { fallback() }
}
}
/**
* Shows a toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toast(message: Int) {
Toasty.normal(this, message).show()
val text = getString(message)
dispatchMessage(text, ToastType.NORMAL) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
@@ -32,7 +62,9 @@ fun Context.toast(message: Int) {
* @param message The text of the message to show.
*/
fun Context.toast(message: CharSequence) {
Toasty.normal(this, message).show()
dispatchMessage(message, ToastType.NORMAL) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
@@ -41,7 +73,10 @@ fun Context.toast(message: CharSequence) {
* @param message The resource ID of the message to show.
*/
fun Context.toastSuccess(message: Int) {
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
val text = getString(message)
dispatchMessage(text, ToastType.SUCCESS) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
@@ -50,7 +85,9 @@ fun Context.toastSuccess(message: Int) {
* @param message The text of the message to show.
*/
fun Context.toastSuccess(message: CharSequence) {
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
dispatchMessage(message, ToastType.SUCCESS) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
@@ -59,7 +96,10 @@ fun Context.toastSuccess(message: CharSequence) {
* @param message The resource ID of the message to show.
*/
fun Context.toastError(message: Int) {
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
val text = getString(message)
dispatchMessage(text, ToastType.ERROR) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
@@ -68,7 +108,32 @@ fun Context.toastError(message: Int) {
* @param message The text of the message to show.
*/
fun Context.toastError(message: CharSequence) {
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
dispatchMessage(message, ToastType.ERROR) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows an info toast message with the given resource ID.
*
* @param message The resource ID of the message to show.
*/
fun Context.toastInfo(message: Int) {
val text = getString(message)
dispatchMessage(text, ToastType.INFO) {
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
}
}
/**
* Shows an info toast message with the given text.
*
* @param message The text of the message to show.
*/
fun Context.toastInfo(message: CharSequence) {
dispatchMessage(message, ToastType.INFO) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
const val THRESHOLD = 1000L
@@ -1,5 +1,11 @@
package com.v2ray.ang.handler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.snapshotFlow
import com.tencent.mmkv.MMKV
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
@@ -14,6 +20,9 @@ import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.dto.entities.WebDavConfig
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
object MmkvManager {
@@ -722,4 +731,56 @@ object MmkvManager {
}
//endregion
//region Compose helpers for Settings
/**
* MMKV-backed String state, auto-persists and notifies on change.
*/
@Composable
fun rememberMmkvString(
key: String,
default: String = ""
): MutableState<String> {
val state = remember(key) {
mutableStateOf(decodeSettingsString(key, default) ?: default)
}
LaunchedEffect(key) {
snapshotFlow { state.value }
.drop(1)
.distinctUntilChanged()
.collectLatest { value ->
encodeSettings(key, value)
SettingsChangeManager.notifySettingChanged(key)
}
}
return state
}
/**
* MMKV-backed Boolean state, auto-persists and notifies on change.
*/
@Composable
fun rememberMmkvBool(
key: String,
default: Boolean = false
): MutableState<Boolean> {
val state = remember(key) {
mutableStateOf(decodeSettingsBool(key, default))
}
LaunchedEffect(key) {
snapshotFlow { state.value }
.drop(1)
.distinctUntilChanged()
.collectLatest { value ->
encodeSettings(key, value)
SettingsChangeManager.notifySettingChanged(key)
}
}
return state
}
//endregion
}
@@ -1,32 +1,60 @@
package com.v2ray.ang.handler
import kotlinx.coroutines.flow.MutableStateFlow
import com.v2ray.ang.AppConfig
import java.util.concurrent.atomic.AtomicBoolean
/**
* Manages global flags for actions triggered by setting changes.
* Uses AtomicBoolean for thread-safe consume operations.
*/
object SettingsChangeManager {
private val _restartService = MutableStateFlow(false)
private val _setupGroupTab = MutableStateFlow(false)
// Mark restartService as requiring a restart
private val restartService = AtomicBoolean(false)
private val setupGroupTab = AtomicBoolean(false)
// Keys that affect only UI behavior and do not require core service restart.
private val uiOnlyKeys = setOf(
AppConfig.PREF_CONFIRM_REMOVE,
AppConfig.PREF_START_SCAN_IMMEDIATE,
AppConfig.PREF_DOUBLE_COLUMN_DISPLAY,
AppConfig.PREF_GROUP_ALL_DISPLAY,
AppConfig.PREF_LANGUAGE,
AppConfig.PREF_UI_MODE_NIGHT,
AppConfig.PREF_IS_BOOTED,
AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST,
AppConfig.PREF_AUTO_SORT_AFTER_TEST,
)
/**
* Called when a setting value changes.
* Triggers service restart if the key is not UI-only, and always refreshes UI tabs.
*/
fun notifySettingChanged(key: String) {
if (key !in uiOnlyKeys) {
makeRestartService()
}
makeSetupGroupTab()
}
fun makeRestartService() {
_restartService.value = true
restartService.set(true)
}
// Read and clear the restartService flag
fun consumeRestartService(): Boolean {
val v = _restartService.value
_restartService.value = false
return v
}
/**
* Atomically consumes the restart flag.
* @return true if a restart was requested, false otherwise.
*/
fun consumeRestartService(): Boolean =
restartService.compareAndSet(true, false)
// Mark reinitGroupTab as requiring tab reinitialization
fun makeSetupGroupTab() {
_setupGroupTab.value = true
setupGroupTab.set(true)
}
// Read and clear the reinitGroupTab flag
fun consumeSetupGroupTab(): Boolean {
val v = _setupGroupTab.value
_setupGroupTab.value = false
return v
}
/**
* Atomically consumes the setup-group-tab flag.
* @return true if UI refresh was requested, false otherwise.
*/
fun consumeSetupGroupTab(): Boolean =
setupGroupTab.compareAndSet(true, false)
}
@@ -4,7 +4,6 @@ import android.content.Context
import android.content.res.AssetManager
import android.os.Build
import android.text.TextUtils
import androidx.appcompat.app.AppCompatDelegate
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
@@ -439,17 +438,6 @@ object SettingsManager {
}
}
/**
* Set night mode.
*/
fun setNightMode() {
when (MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0")) {
"0" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
"1" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
"2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
}
}
/**
* Retrieves the currently selected VPN interface address configuration.
* This method reads the user's preference for VPN interface addressing and returns
@@ -1,68 +0,0 @@
package com.v2ray.ang.helper
import android.graphics.Canvas
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.view.View
import androidx.recyclerview.widget.RecyclerView
class CustomDividerItemDecoration(
private val divider: Drawable,
private val orientation: Int
) : RecyclerView.ItemDecoration() {
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
if (orientation == RecyclerView.VERTICAL) {
drawVerticalDividers(canvas, parent)
} else {
drawHorizontalDividers(canvas, parent)
}
}
private fun drawVerticalDividers(canvas: Canvas, parent: RecyclerView) {
val left = parent.paddingLeft
val right = parent.width - parent.paddingRight
val childCount = parent.childCount
for (i in 0 until childCount - 1) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val top = child.bottom + params.bottomMargin
val bottom = top + divider.intrinsicHeight
divider.setBounds(left, top, right, bottom)
divider.draw(canvas)
}
}
private fun drawHorizontalDividers(canvas: Canvas, parent: RecyclerView) {
val top = parent.paddingTop
val bottom = parent.height - parent.paddingBottom
val childCount = parent.childCount
for (i in 0 until childCount - 1) {
val child = parent.getChildAt(i)
val params = child.layoutParams as RecyclerView.LayoutParams
val left = child.right + params.rightMargin
val right = left + divider.intrinsicWidth
divider.setBounds(left, top, right, bottom)
divider.draw(canvas)
}
}
override fun getItemOffsets(
outRect: Rect,
view: View,
parent: RecyclerView,
state: RecyclerView.State
) {
if (orientation == RecyclerView.VERTICAL) {
outRect.set(0, 0, 0, divider.intrinsicHeight)
} else {
outRect.set(0, 0, divider.intrinsicWidth, 0)
}
}
}
@@ -1,11 +1,12 @@
package com.v2ray.ang.helper
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.extension.toast
@@ -15,14 +16,14 @@ import com.v2ray.ang.util.LogUtil
* Helper for choosing and creating files using Android Storage Access Framework.
* Supports both file selection (ACTION_GET_CONTENT) and file creation (CreateDocument).
*/
class FileChooserHelper(private val activity: AppCompatActivity) {
class FileChooserHelper(private val activity: ComponentActivity) {
private var fileChooserCallback: ((Uri?) -> Unit)? = null
private var documentCreateCallback: ((Uri?) -> Unit)? = null
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
val uri = result.data?.data
if (result.resultCode == AppCompatActivity.RESULT_OK && uri != null) {
if (result.resultCode == Activity.RESULT_OK && uri != null) {
fileChooserCallback?.invoke(uri)
} else {
fileChooserCallback?.invoke(null)
@@ -1,53 +0,0 @@
/*
* Copyright (C) 2015 Paul Burke
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.v2ray.ang.helper
/**
* Interface to listen for a move or dismissal event from a [ItemTouchHelper.Callback].
*
* @author Paul Burke (ipaulpro)
*/
interface ItemTouchHelperAdapter {
/**
* Called when an item has been dragged far enough to trigger a move. This is called every time
* an item is shifted, and **not** at the end of a "drop" event.<br></br>
* <br></br>
* Implementations should call [RecyclerView.Adapter.notifyItemMoved] after
* adjusting the underlying data to reflect this move.
*
* @param fromPosition The start position of the moved item.
* @param toPosition Then resolved position of the moved item.
* @return True if the item was moved to the new adapter position.
* @see RecyclerView.getAdapterPositionFor
* @see RecyclerView.ViewHolder.getAdapterPosition
*/
fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
fun onItemMoveCompleted()
/**
* Called when an item has been dismissed by a swipe.<br></br>
* <br></br>
* Implementations should call [RecyclerView.Adapter.notifyItemRemoved] after
* adjusting the underlying data to reflect this removal.
*
* @param position The position of the item dismissed.
* @see RecyclerView.getAdapterPositionFor
* @see RecyclerView.ViewHolder.getAdapterPosition
*/
fun onItemDismiss(position: Int)
}
@@ -1,38 +0,0 @@
/*
* Copyright (C) 2015 Paul Burke
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.v2ray.ang.helper
import androidx.recyclerview.widget.ItemTouchHelper
/**
* Interface to notify an item ViewHolder of relevant callbacks from [ ].
*
* @author Paul Burke (ipaulpro)
*/
interface ItemTouchHelperViewHolder {
/**
* Called when the [ItemTouchHelper] first registers an item as being moved or swiped.
* Implementations should update the item view to indicate it's active state.
*/
fun onItemSelected()
/**
* Called when the [ItemTouchHelper] has completed the move or swipe, and the active item
* state should be cleared.
*/
fun onItemClear()
}
@@ -1,89 +0,0 @@
package com.v2ray.ang.helper
import androidx.preference.PreferenceDataStore
import com.v2ray.ang.AppConfig
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
/**
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
* This ensures that all Preference UI operations read/write directly from/to MMKV,
* avoiding inconsistencies between SharedPreferences and MMKV.
*/
class MmkvPreferenceDataStore : PreferenceDataStore() {
override fun putString(key: String, value: String?) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getString(key: String, defaultValue: String?): String? {
return MmkvManager.decodeSettingsString(key, defaultValue)
}
override fun putInt(key: String, value: Int) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getInt(key: String, defaultValue: Int): Int {
return MmkvManager.decodeSettingsInt(key, defaultValue)
}
override fun putLong(key: String, value: Long) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getLong(key: String, defaultValue: Long): Long {
return MmkvManager.decodeSettingsLong(key, defaultValue)
}
override fun putFloat(key: String, value: Float) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getFloat(key: String, defaultValue: Float): Float {
return MmkvManager.decodeSettingsFloat(key, defaultValue)
}
override fun putBoolean(key: String, value: Boolean) {
MmkvManager.encodeSettings(key, value)
notifySettingChanged(key)
}
override fun getBoolean(key: String, defaultValue: Boolean): Boolean {
return MmkvManager.decodeSettingsBool(key, defaultValue)
}
override fun putStringSet(key: String, values: MutableSet<String>?) {
if (values == null) {
MmkvManager.encodeSettings(key, null as String?)
} else {
MmkvManager.encodeSettings(key, values)
}
notifySettingChanged(key)
}
override fun getStringSet(key: String, defaultValues: MutableSet<String>?): MutableSet<String>? {
return MmkvManager.decodeSettingsStringSet(key) ?: defaultValues
}
// Internal helper: notify other modules about setting changes
private fun notifySettingChanged(key: String) {
if (key == AppConfig.PREF_LOGLEVEL) {
LogUtil.refreshLogLevel()
}
// Call SettingsManager.setNightMode if UI mode changed
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
SettingsManager.setNightMode()
}
// Notify listeners that require service restart or reinit
SettingsChangeManager.makeRestartService()
SettingsChangeManager.makeSetupGroupTab()
}
}
@@ -1,9 +1,9 @@
package com.v2ray.ang.helper
import android.content.pm.PackageManager
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import androidx.core.content.ContextCompat
import com.v2ray.ang.R
import com.v2ray.ang.enums.PermissionType
@@ -12,7 +12,7 @@ import com.v2ray.ang.extension.toast
/**
* Helper for requesting permissions.
*/
class PermissionHelper(private val activity: AppCompatActivity) {
class PermissionHelper(private val activity: ComponentActivity) {
private var permissionCallback: ((Boolean) -> Unit)? = null
private val permissionLauncher: ActivityResultLauncher<String> =
@@ -1,9 +1,10 @@
package com.v2ray.ang.helper
import android.app.Activity
import android.content.Intent
import androidx.activity.ComponentActivity
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AppCompatActivity
import com.v2ray.ang.ui.ScannerActivity
/**
@@ -12,12 +13,12 @@ import com.v2ray.ang.ui.ScannerActivity
* This class encapsulates the logic for launching the QR code scanner activity
* and handling the scan result.
*/
class QRCodeScannerHelper(private val activity: AppCompatActivity) {
class QRCodeScannerHelper(private val activity: ComponentActivity) {
private var scanCallback: ((String?) -> Unit)? = null
private val scanLauncher: ActivityResultLauncher<Intent> =
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == AppCompatActivity.RESULT_OK) {
if (result.resultCode == Activity.RESULT_OK) {
val scanResult = result.data?.getStringExtra("SCAN_RESULT")
scanCallback?.invoke(scanResult)
} else {
@@ -1,147 +0,0 @@
/*
* Copyright (C) 2015 Paul Burke
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.v2ray.ang.helper
import android.animation.ValueAnimator
import android.graphics.Canvas
import android.view.animation.DecelerateInterpolator
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import kotlin.math.abs
import kotlin.math.min
import kotlin.math.sign
/**
* An implementation of [ItemTouchHelper.Callback] that enables basic drag & drop and
* swipe-to-dismiss. Drag events are automatically started by an item long-press.<br></br>
*
* Expects the `RecyclerView.Adapter` to listen for [ ] callbacks and the `RecyclerView.ViewHolder` to implement
* [ItemTouchHelperViewHolder].
*
* @author Paul Burke (ipaulpro)
*/
class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter, private val allowSwipe: Boolean = false) : ItemTouchHelper.Callback() {
private var mReturnAnimator: ValueAnimator? = null
override fun isLongPressDragEnabled(): Boolean = true
override fun isItemViewSwipeEnabled(): Boolean = allowSwipe
override fun getMovementFlags(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder
): Int {
val dragFlags: Int
val swipeFlags: Int
if (recyclerView.layoutManager is GridLayoutManager) {
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
} else {
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
}
return makeMovementFlags(dragFlags, swipeFlags)
}
override fun onMove(
recyclerView: RecyclerView,
source: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
return if (source.itemViewType != target.itemViewType) {
false
} else {
mAdapter.onItemMove(source.bindingAdapterPosition, target.bindingAdapterPosition)
true
}
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
// Do not delete; simply return item to original position
returnViewToOriginalPosition(viewHolder)
}
override fun onChildDraw(
c: Canvas, recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean
) {
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
val maxSwipeDistance = viewHolder.itemView.width * SWIPE_THRESHOLD
val swipeAmount = abs(dX)
val direction = sign(dX)
// Limit maximum swipe distance
val translationX = min(swipeAmount, maxSwipeDistance) * direction
val alpha = ALPHA_FULL - min(swipeAmount, maxSwipeDistance) / maxSwipeDistance
viewHolder.itemView.translationX = translationX
viewHolder.itemView.alpha = alpha
if (swipeAmount >= maxSwipeDistance && isCurrentlyActive) {
returnViewToOriginalPosition(viewHolder)
}
} else {
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
}
}
private fun returnViewToOriginalPosition(viewHolder: RecyclerView.ViewHolder) {
mReturnAnimator?.takeIf { it.isRunning }?.cancel()
mReturnAnimator = ValueAnimator.ofFloat(viewHolder.itemView.translationX, 0f).apply {
addUpdateListener { animation ->
val value = animation.animatedValue as Float
viewHolder.itemView.translationX = value
viewHolder.itemView.alpha = 1f - abs(value) / (viewHolder.itemView.width * SWIPE_THRESHOLD)
}
interpolator = DecelerateInterpolator()
duration = ANIMATION_DURATION
start()
}
}
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
if (actionState != ItemTouchHelper.ACTION_STATE_IDLE && viewHolder is ItemTouchHelperViewHolder) {
viewHolder.onItemSelected()
}
super.onSelectedChanged(viewHolder, actionState)
}
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
super.clearView(recyclerView, viewHolder)
viewHolder.itemView.alpha = ALPHA_FULL
if (viewHolder is ItemTouchHelperViewHolder) {
viewHolder.onItemClear()
}
mAdapter.onItemMoveCompleted()
}
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
return 1.1f // Set a value greater than 1 to prevent default swipe delete
}
override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
return defaultValue * 10 // Increase swipe escape velocity to make swipe harder to trigger
}
companion object {
private const val ALPHA_FULL = 1.0f
private const val SWIPE_THRESHOLD = 0.25f
private const val ANIMATION_DURATION: Long = 200
}
}
@@ -1,52 +1,128 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.webkit.WebView
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
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
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.SettingsMenuItem
import com.v2ray.ang.compose.VersionInfoBlock
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.databinding.ActivityAboutBinding
import com.v2ray.ang.util.Utils
class AboutActivity : BaseActivity() {
private val binding by lazy { ActivityAboutBinding.inflate(layoutInflater) }
class AboutActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_about))
binding.layoutSoureCcode.setOnClickListener {
Utils.openUri(this, AppConfig.APP_URL)
}
binding.layoutFeedback.setOnClickListener {
Utils.openUri(this, AppConfig.APP_ISSUES_URL)
@Composable
override fun ScreenContent() {
AboutScreen(onBackClick = { finish() })
}
}
binding.layoutOssLicenses.setOnClickListener {
val webView = android.webkit.WebView(this)
webView.loadUrl("file:///android_asset/open_source_licenses.html")
android.app.AlertDialog.Builder(this)
.setTitle("Open source licenses")
.setView(webView)
.setPositiveButton("OK") { dialog, _ -> dialog.dismiss() }
.show()
@Composable
fun AboutScreen(onBackClick: () -> Unit) {
val context = LocalContext.current
var showOssDialog by remember { mutableStateOf(false) }
val versionText = "v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})"
val appIdText = BuildConfig.APPLICATION_ID
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.title_about),
onBackClick = onBackClick
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
) {
SettingsMenuItem(
icon = painterResource(R.drawable.ic_source_code_24dp),
title = stringResource(R.string.title_source_code),
onClick = { Utils.openUri(context, AppConfig.APP_URL) }
)
SettingsMenuItem(
icon = painterResource(R.drawable.license_24px),
title = stringResource(R.string.title_oss_license),
onClick = { showOssDialog = true }
)
SettingsMenuItem(
icon = painterResource(R.drawable.ic_feedback_24dp),
title = stringResource(R.string.title_pref_feedback),
onClick = { Utils.openUri(context, AppConfig.APP_ISSUES_URL) }
)
SettingsMenuItem(
icon = painterResource(R.drawable.ic_telegram_24dp),
title = stringResource(R.string.title_tg_channel),
onClick = { Utils.openUri(context, AppConfig.TG_CHANNEL_URL) }
)
SettingsMenuItem(
icon = painterResource(R.drawable.ic_privacy_24dp),
title = stringResource(R.string.title_privacy_policy),
onClick = { Utils.openUri(context, AppConfig.APP_PRIVACY_POLICY) }
)
VersionInfoBlock(
versionText = versionText,
appIdText = appIdText
)
}
}
binding.layoutTgChannel.setOnClickListener {
Utils.openUri(this, AppConfig.TG_CHANNEL_URL)
if (showOssDialog) {
AlertDialog(
onDismissRequest = { showOssDialog = false },
title = { Text(stringResource(R.string.title_oss_license)) },
text = {
AndroidView(
factory = { ctx ->
WebView(ctx).apply {
loadUrl("file:///android_asset/open_source_licenses.html")
}
binding.layoutPrivacyPolicy.setOnClickListener {
Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY)
}
"v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
BuildConfig.APPLICATION_ID.also {
binding.tvAppId.text = it
},
modifier = Modifier.fillMaxWidth().heightIn(min = 300.dp)
)
},
confirmButton = {
TextButton(onClick = { showOssDialog = false }) {
Text(stringResource(android.R.string.ok))
}
},
containerColor = MaterialTheme.colorScheme.surface,
modifier = Modifier.padding(bottom = 60.dp)
)
}
}
@@ -1,25 +1,45 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityAppPickerBinding
import com.v2ray.ang.compose.AppDivider
import com.v2ray.ang.compose.AppListItem
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.Collator
import com.v2ray.ang.viewmodel.AppPickerViewModel
class AppPickerActivity : BaseComponentActivity() {
class AppPickerActivity : BaseActivity() {
companion object {
private const val EXTRA_SELECTED_PACKAGES = "selected_packages"
private const val EXTRA_PICKER_TITLE = "picker_title"
@@ -38,160 +58,141 @@ class AppPickerActivity : BaseActivity() {
}
}
private val binding by lazy { ActivityAppPickerBinding.inflate(layoutInflater) }
private val initialSelectedPackages by lazy {
intent.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
}
private val selectedPackages = LinkedHashSet<String>()
private var appsAll: List<AppInfo> = emptyList()
private val adapter = AppSelectorAdapter(selectedPackages)
private val viewModel: AppPickerViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = resolveScreenTitle())
selectedPackages.addAll(initialSelectedPackages)
setupRecyclerView()
loadApps()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_app_picker, menu)
val searchItem = menu.findItem(R.id.search_view)
if (searchItem != null) {
val searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
filterApps(newText.orEmpty())
return false
}
})
}
return true
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.select_all -> {
selectAllVisible()
true
}
R.id.invert_selection -> {
invertVisibleSelection()
true
}
else -> super.onOptionsItemSelected(item)
val initial = intent.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
viewModel.initialize(initial)
viewModel.loadApps(this)
}
override fun finish() {
setResult(
RESULT_OK,
Intent().apply {
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, getSelectedPackages())
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, ArrayList(viewModel.getSelectedPackages()))
}
)
super.finish()
}
private fun setupRecyclerView() {
binding.recyclerView.adapter = adapter
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
}
@Composable
override fun ScreenContent() {
val apps by viewModel.displayedApps.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val selectedPackages by viewModel.selectedPackages.collectAsStateWithLifecycle()
@SuppressLint("UseCompatLoadingForDrawables")
private fun createSpecialItemUnidentified(): AppInfo {
val icon = requireNotNull(
getDrawable(android.R.drawable.ic_menu_help)
?: getDrawable(android.R.drawable.sym_def_app_icon)
) { "No fallback drawable available" }
return AppInfo(
appName = getString(R.string.app_picker_unknown_app),
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
appIcon = icon,
isSystemApp = false,
isSelected = 0
AppPickerScreen(
title = resolveScreenTitle(),
apps = apps,
isLoading = isLoading,
selectedPackages = selectedPackages,
onBackClick = { finish() },
onToggleApp = { viewModel.toggleApp(it) },
onSearch = { viewModel.filterApps(it) },
onSelectAll = { viewModel.selectAll() },
onInvertSelection = { viewModel.invertSelection() }
)
}
private fun loadApps() {
showLoading()
lifecycleScope.launch {
try {
val apps = withContext(Dispatchers.IO) {
val appsList = AppManagerUtil.loadNetworkAppList(this@AppPickerActivity)
val sortedApps = sortApps(appsList)
listOf(createSpecialItemUnidentified()) + sortedApps
}
appsAll = apps
updateDisplayedApps(apps)
} catch (e: Exception) {
LogUtil.e("AppPickerActivity", "Failed to load app list", e)
} finally {
hideLoading()
}
}
}
private fun filterApps(content: String) {
val key = content.uppercase()
val filteredApps = appsAll.filter { app ->
key.isEmpty() || matchesSearch(app, key)
}
updateDisplayedApps(filteredApps)
}
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
val collator = Collator.getInstance()
return apps.sortedWith { p1, p2 ->
val p1Selected = selectedPackages.contains(p1.packageName)
val p2Selected = selectedPackages.contains(p2.packageName)
when {
p1Selected && !p2Selected -> -1
!p1Selected && p2Selected -> 1
p1.isSystemApp && !p2.isSystemApp -> 1
!p1.isSystemApp && p2.isSystemApp -> -1
else -> collator.compare(p1.appName, p2.appName)
}
}
}
private fun matchesSearch(app: AppInfo, keyword: String): Boolean {
return app.appName.uppercase().contains(keyword) || app.packageName.uppercase().contains(keyword)
}
private fun updateDisplayedApps(apps: List<AppInfo>) {
adapter.submitList(apps)
}
private fun selectAllVisible() {
adapter.apps.forEach { app -> selectedPackages.add(app.packageName) }
adapter.refreshSelection()
}
private fun invertVisibleSelection() {
adapter.apps.forEach { app ->
if (selectedPackages.contains(app.packageName)) {
selectedPackages.remove(app.packageName)
} else {
selectedPackages.add(app.packageName)
}
}
adapter.refreshSelection()
}
private fun getSelectedPackages(): ArrayList<String> {
return ArrayList(selectedPackages.sorted())
}
private fun resolveScreenTitle(): String {
return intent.getStringExtra(EXTRA_PICKER_TITLE) ?: getString(R.string.per_app_proxy_settings)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun AppPickerScreen(
title: String,
apps: List<AppInfo>,
isLoading: Boolean,
selectedPackages: Set<String>,
onBackClick: () -> Unit,
onToggleApp: (String) -> Unit,
onSearch: (String) -> Unit,
onSelectAll: () -> Unit,
onInvertSelection: () -> Unit
) {
var showSearch by rememberSaveable { mutableStateOf(false) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var showMenu by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
LaunchedEffect(searchQuery) {
onSearch(searchQuery)
}
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = title,
onBackClick = onBackClick,
isLoading = isLoading,
isSearchActive = showSearch,
searchQuery = searchQuery,
onSearchQueryChange = { query ->
searchQuery = query
},
onSearchClose = {
searchQuery = ""
showSearch = false
},
searchPlaceholder = stringResource(R.string.menu_item_search),
actions = {
if (!showSearch) {
IconButton(onClick = { showSearch = true }) {
Icon(
painterResource(R.drawable.ic_search_24dp),
contentDescription = stringResource(R.string.menu_item_search)
)
}
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
painterResource(R.drawable.ic_more_vert_24dp),
contentDescription = null
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
containerColor = MaterialTheme.colorScheme.surface
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_select_all)) },
onClick = { showMenu = false; onSelectAll() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_invert_selection)) },
onClick = { showMenu = false; onInvertSelection() }
)
}
}
}
)
}
) { innerPadding ->
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScrollbar(listState)
) {
items(items = apps, key = { it.packageName }) { app ->
val checked = selectedPackages.contains(app.packageName)
AppListItem(
appName = app.appName,
packageName = app.packageName,
icon = app.appIcon,
checked = checked,
onCheckedChange = { onToggleApp(app.packageName) }
)
AppDivider()
}
}
}
}
@@ -1,64 +0,0 @@
package com.v2ray.ang.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
import com.v2ray.ang.dto.AppInfo
class AppSelectorAdapter(
private val selectedPackages: MutableSet<String>
) : RecyclerView.Adapter<AppSelectorAdapter.AppViewHolder>() {
var apps: List<AppInfo> = emptyList()
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
val binding = ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
return AppViewHolder(binding)
}
override fun getItemCount(): Int = apps.size
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
holder.bind(apps[position])
}
fun submitList(newApps: List<AppInfo>) {
apps = newApps
notifyDataSetChanged()
}
fun refreshSelection() {
notifyDataSetChanged()
}
inner class AppViewHolder(private val binding: ItemRecyclerBypassListBinding) : RecyclerView.ViewHolder(binding.root),
View.OnClickListener {
private lateinit var appInfo: AppInfo
fun bind(item: AppInfo) {
appInfo = item
binding.icon.setImageDrawable(item.appIcon)
binding.name.text = if (item.isSystemApp) {
String.format("** %s", item.appName)
} else {
item.appName
}
binding.packageName.text = item.packageName
binding.checkBox.isChecked = selectedPackages.contains(item.packageName)
itemView.setOnClickListener(this)
}
override fun onClick(v: View?) {
val packageName = appInfo.packageName
if (selectedPackages.contains(packageName)) {
selectedPackages.remove(packageName)
} else {
selectedPackages.add(packageName)
}
binding.checkBox.isChecked = selectedPackages.contains(packageName)
}
}
}
@@ -1,8 +1,28 @@
package com.v2ray.ang.ui
import android.app.AlertDialog
import android.content.Intent
import android.os.Bundle
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.MaterialTheme
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.lifecycle.lifecycleScope
import com.tencent.mmkv.MMKV
@@ -10,8 +30,11 @@ import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.WEBDAV_BACKUP_FILE_NAME
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityBackupBinding
import com.v2ray.ang.databinding.DialogWebdavBinding
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.InputDialog
import com.v2ray.ang.compose.InputField
import com.v2ray.ang.compose.SelectListDialog
import com.v2ray.ang.compose.SettingsMenuItem
import com.v2ray.ang.dto.entities.WebDavConfig
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
@@ -22,38 +45,57 @@ import com.v2ray.ang.handler.WebDavManager
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.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.SimpleDateFormat
import java.util.Locale
class BackupActivity : HelperBaseActivity() {
private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) }
class BackupActivity : HelperBaseComponentActivity() {
private val config_backup_options: Array<out String> by lazy {
private val isLoadingState = MutableStateFlow(false)
private val webDavConfigState = MutableStateFlow(MmkvManager.decodeWebDavConfig())
private val configBackupOptions: Array<out String> by lazy {
resources.getStringArray(R.array.config_backup_options)
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore))
}
binding.layoutBackup.setOnClickListener {
AlertDialog.Builder(this)
.setTitle(R.string.title_configuration_backup)
.setItems(config_backup_options) { dialog, which ->
@Composable
override fun ScreenContent() {
BackupScreen(
isLoadingState = isLoadingState,
webDavConfigState = webDavConfigState,
backupOptions = configBackupOptions.toList(),
onBackupOptionSelected = { which ->
when (which) {
0 -> backupViaLocal()
1 -> backupViaWebDav()
}
},
onShareClick = { shareBackup() },
restoreOptions = configBackupOptions.toList(),
onRestoreOptionSelected = { which ->
when (which) {
0 -> restoreViaLocal()
1 -> restoreViaWebDav()
}
.show()
},
onWebDavSave = { config ->
MmkvManager.encodeWebDavConfig(config)
webDavConfigState.value = config
toastSuccess(R.string.toast_success)
},
onBackClick = { finish() }
)
}
binding.layoutShare.setOnClickListener {
private fun shareBackup() {
val ret = backupConfigurationToCache()
if (ret.first) {
startActivity(
@@ -63,9 +105,12 @@ class BackupActivity : HelperBaseActivity() {
.putExtra(
Intent.EXTRA_STREAM,
FileProvider.getUriForFile(
this, BuildConfig.APPLICATION_ID + ".cache", File(ret.second)
this,
BuildConfig.APPLICATION_ID + ".cache",
File(ret.second)
)
), getString(R.string.title_configuration_share)
),
getString(R.string.title_configuration_share)
)
)
} else {
@@ -73,23 +118,6 @@ class BackupActivity : HelperBaseActivity() {
}
}
binding.layoutRestore.setOnClickListener {
AlertDialog.Builder(this)
.setTitle(R.string.title_configuration_restore)
.setItems(config_backup_options) { dialog, which ->
when (which) {
0 -> restoreViaLocal()
1 -> restoreViaWebDav()
}
}
.show()
}
binding.layoutWebdavConfigSetting.setOnClickListener {
showWebDavSettingsDialog()
}
}
/**
* Backup configuration to cache directory
* Returns Pair<success, zipFilePath>
@@ -130,31 +158,6 @@ class BackupActivity : HelperBaseActivity() {
return count > 0
}
private fun showFileChooser() {
launchFileChooser { uri ->
if (uri == null) {
return@launchFileChooser
}
try {
val targetFile =
File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
contentResolver.openInputStream(uri).use { input ->
targetFile.outputStream().use { fileOut ->
input?.copyTo(fileOut)
}
}
if (restoreConfiguration(targetFile)) {
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error during file restore", e)
toastError(R.string.toast_failure)
}
}
}
private fun backupViaLocal() {
val dateFormatted = SimpleDateFormat(
"yyyy-MM-dd-HH-mm-ss",
@@ -188,7 +191,28 @@ class BackupActivity : HelperBaseActivity() {
}
private fun restoreViaLocal() {
showFileChooser()
launchFileChooser { uri ->
if (uri == null) {
return@launchFileChooser
}
try {
val targetFile =
File(this.cacheDir.absolutePath, "${System.currentTimeMillis()}.zip")
contentResolver.openInputStream(uri).use { input ->
targetFile.outputStream().use { fileOut ->
input?.copyTo(fileOut)
}
}
if (restoreConfiguration(targetFile)) {
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error during file restore", e)
toastError(R.string.toast_failure)
}
}
}
private fun backupViaWebDav() {
@@ -198,7 +222,7 @@ class BackupActivity : HelperBaseActivity() {
return
}
showLoading()
isLoadingState.value = true
lifecycleScope.launch(Dispatchers.IO) {
var tempFile: File? = null
@@ -235,7 +259,7 @@ class BackupActivity : HelperBaseActivity() {
} catch (_: Exception) {
}
withContext(Dispatchers.Main) {
hideLoading()
isLoadingState.value = false
}
}
}
@@ -248,7 +272,7 @@ class BackupActivity : HelperBaseActivity() {
return
}
showLoading()
isLoadingState.value = true
lifecycleScope.launch(Dispatchers.IO) {
var target: File? = null
@@ -280,35 +304,164 @@ class BackupActivity : HelperBaseActivity() {
} catch (_: Exception) {
}
withContext(Dispatchers.Main) {
hideLoading()
isLoadingState.value = false
}
}
}
}
}
private fun showWebDavSettingsDialog() {
val dialogBinding = DialogWebdavBinding.inflate(layoutInflater)
@Composable
fun BackupScreen(
isLoadingState: StateFlow<Boolean>,
webDavConfigState: StateFlow<WebDavConfig?>,
backupOptions: List<String>,
onBackupOptionSelected: (Int) -> Unit,
onShareClick: () -> Unit,
restoreOptions: List<String>,
onRestoreOptionSelected: (Int) -> Unit,
onWebDavSave: (WebDavConfig) -> Unit,
onBackClick: () -> Unit
) {
val isLoading by isLoadingState.collectAsState()
val currentWebDavConfig by webDavConfigState.collectAsState()
var showBackupDialog by remember { mutableStateOf(false) }
var showRestoreDialog by remember { mutableStateOf(false) }
var showWebDavDialog by remember { mutableStateOf(false) }
MmkvManager.decodeWebDavConfig()?.let { cfg ->
dialogBinding.etWebdavUrl.setText(cfg.baseUrl)
dialogBinding.etWebdavUser.setText(cfg.username ?: "")
dialogBinding.etWebdavPass.setText(cfg.password ?: "")
dialogBinding.etWebdavRemotePath.setText(cfg.remoteBasePath ?: "/")
val webDavSummary = if (currentWebDavConfig != null && currentWebDavConfig!!.baseUrl.isNotEmpty()) {
"${currentWebDavConfig!!.baseUrl} | ${currentWebDavConfig!!.username ?: ""}"
} else null
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.title_configuration_backup_restore),
onBackClick = onBackClick,
isLoading = isLoading
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
) {
SettingsMenuItem(
icon = painterResource(R.drawable.ic_backup_24dp),
title = stringResource(R.string.title_configuration_backup),
onClick = { showBackupDialog = true }
)
SettingsMenuItem(
icon = painterResource(R.drawable.ic_share_24dp),
title = stringResource(R.string.title_configuration_share),
onClick = onShareClick
)
SettingsMenuItem(
icon = painterResource(R.drawable.ic_restore_24dp),
title = stringResource(R.string.title_configuration_restore),
onClick = { showRestoreDialog = true }
)
Spacer(modifier = Modifier.height(16.dp))
SettingsMenuItem(
icon = painterResource(R.drawable.ic_settings_24dp),
title = stringResource(R.string.title_webdav_config_setting),
subtitle = webDavSummary,
onClick = { showWebDavDialog = true }
)
}
}
AlertDialog.Builder(this)
.setTitle(R.string.title_webdav_config_setting)
.setView(dialogBinding.root)
.setPositiveButton(R.string.menu_item_save_config) { _, _ ->
val url = dialogBinding.etWebdavUrl.text.toString().trim()
val user = dialogBinding.etWebdavUser.text.toString().trim().ifEmpty { null }
val pass = dialogBinding.etWebdavPass.text.toString()
val remotePath = dialogBinding.etWebdavRemotePath.text.toString().trim().ifEmpty { AppConfig.WEBDAV_BACKUP_DIR }
val cfg = WebDavConfig(baseUrl = url, username = user, password = pass, remoteBasePath = remotePath)
MmkvManager.encodeWebDavConfig(cfg)
toastSuccess(R.string.toast_success)
if (showBackupDialog) {
SelectListDialog(
title = stringResource(R.string.title_configuration_backup),
options = backupOptions,
onSelected = { index, _ ->
showBackupDialog = false
onBackupOptionSelected(index)
},
onDismiss = { showBackupDialog = false }
)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
if (showRestoreDialog) {
SelectListDialog(
title = stringResource(R.string.title_configuration_restore),
options = restoreOptions,
onSelected = { index, _ ->
showRestoreDialog = false
onRestoreOptionSelected(index)
},
onDismiss = { showRestoreDialog = false }
)
}
if (showWebDavDialog) {
WebDavInputDialog(
initialConfig = currentWebDavConfig,
onSave = {
showWebDavDialog = false
onWebDavSave(it)
},
onDismiss = { showWebDavDialog = false }
)
}
}
@Composable
private fun WebDavInputDialog(
initialConfig: WebDavConfig?,
onSave: (WebDavConfig) -> Unit,
onDismiss: () -> Unit
) {
var url by remember { mutableStateOf(initialConfig?.baseUrl ?: "") }
var username by remember { mutableStateOf(initialConfig?.username ?: "") }
var password by remember { mutableStateOf(initialConfig?.password ?: "") }
var remotePath by remember { mutableStateOf(initialConfig?.remoteBasePath ?: "/") }
val fields = listOf(
InputField(
label = stringResource(R.string.title_webdav_url),
value = url
),
InputField(
label = stringResource(R.string.title_webdav_user),
value = username
),
InputField(
label = stringResource(R.string.title_webdav_pass),
value = password,
visualTransformation = VisualTransformation.None
),
InputField(
label = stringResource(R.string.title_webdav_remote_path),
value = remotePath
)
)
InputDialog(
title = stringResource(R.string.title_webdav_config_setting),
fields = fields,
onFieldChange = { index, value ->
when (index) {
0 -> url = value
1 -> username = value
2 -> password = value
3 -> remotePath = value
}
},
confirmText = stringResource(R.string.menu_item_save_config),
dismissText = stringResource(android.R.string.cancel),
onConfirm = {
onSave(
WebDavConfig(
baseUrl = url.trim(),
username = username.trim().ifEmpty { null },
password = password,
remoteBasePath = remotePath.trim().ifEmpty { AppConfig.WEBDAV_BACKUP_DIR }
)
)
},
onDismiss = onDismiss
)
}
@@ -1,216 +0,0 @@
package com.v2ray.ang.ui
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup
import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.view.WindowCompat
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.appbar.MaterialToolbar
import com.google.android.material.progressindicator.LinearProgressIndicator
import com.v2ray.ang.R
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.CustomDividerItemDecoration
import com.v2ray.ang.util.MyContextWrapper
import com.v2ray.ang.util.Utils
/**
* BaseActivity provides common helpers and UI wiring used across the app's activities.
*
* Responsibilities:
* - Inflate a shared base layout that contains a toolbar and a content container.
* - Provide convenient overloads of `setContentViewWithToolbar` to attach child layouts or
* view-binding roots into the base container and initialize the toolbar.
* - Expose a global in-layout `ProgressBar` (cached) with `showLoading()` / `hideLoading()` helpers.
* - Provide a helper to add a custom divider to RecyclerViews.
* - Wrap base context according to user locale settings.
*/
abstract class BaseActivity : AppCompatActivity() {
// Progress indicator that sits at the bottom of the toolbar
private var progressBar: LinearProgressIndicator? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.setDisplayHomeAsUpEnabled(true)
if (!Utils.getDarkModeStatus(this)) {
WindowCompat.getInsetsController(window, window.decorView).apply {
isAppearanceLightStatusBars = true
}
}
}
/**
* Handle action bar item selections.
*
* Currently this handles the home/up button by delegating to the activity's
* onBackPressedDispatcher to provide consistent back navigation behavior.
*
* @param item the selected menu item
* @return true if the event was handled, otherwise delegates to the superclass
*/
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
android.R.id.home -> {
// Handles the home button press by delegating to the onBackPressedDispatcher.
// This ensures consistent back navigation behavior.
onBackPressedDispatcher.onBackPressed()
true
}
else -> super.onOptionsItemSelected(item)
}
/**
* Wrap the base context with the user's locale settings.
*
* This ensures resources are loaded using the configured locale.
*
* @param newBase the original base context to wrap
*/
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
}
/**
* Adds a custom divider drawable to the provided RecyclerView.
*
* This is a convenience helper that constructs a [CustomDividerItemDecoration]
* using the given drawable resource id and adds it to the RecyclerView.
*
* @param recyclerView the target RecyclerView
* @param context the context used to resolve resources (may be activity or application context)
* @param drawableResId the drawable resource id to use as the divider
* @param orientation one of [DividerItemDecoration.VERTICAL] or [DividerItemDecoration.HORIZONTAL]
*
* @throws IllegalArgumentException if the drawable resource cannot be found
*/
protected fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, context: Context?, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
// Get the drawable from resources
val drawable = ContextCompat.getDrawable(context!!, drawableResId)
requireNotNull(drawable) { "Drawable resource not found" }
// Create a DividerItemDecoration with the specified orientation
val dividerItemDecoration = CustomDividerItemDecoration(drawable, orientation)
// Add the divider to the RecyclerView
recyclerView.addItemDecoration(dividerItemDecoration)
}
/**
* Configure the toolbar instance using the default toolbar id if null is passed.
*
* This helper will set the toolbar as the action bar and configure the up button
* visibility plus optional title.
*
* @param toolbar the toolbar instance to configure (may be null, in which case the view
* with id R.id.toolbar in the activity content will be used)
* @param showHomeAsUp whether the home/up affordance should be shown (default true)
* @param title optional title to set on the activity
*/
protected fun setupToolbar(toolbar: Toolbar?, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
val tb = toolbar ?: findViewById<Toolbar?>(R.id.toolbar)
tb?.let {
setSupportActionBar(it)
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
title?.let { t -> this.title = t }
}
progressBar = findViewById(R.id.progress_bar)
}
/**
* Inflate the shared base layout, attach the child layout resource into the base
* content container, cache the in-layout ProgressBar and configure the toolbar.
*
* Typical usage in subclasses:
* setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = "Settings")
*
* @param layoutResId child layout resource to inflate into the base content container
* @param showHomeAsUp whether to show the up/home affordance on the toolbar (default true)
* @param title optional activity title to set on the toolbar
*/
protected fun setContentViewWithToolbar(layoutResId: Int, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
val base = LayoutInflater.from(this).inflate(R.layout.activity_base, null)
val container = base.findViewById<FrameLayout>(R.id.content_container)
LayoutInflater.from(this).inflate(layoutResId, container, true)
progressBar = base.findViewById(R.id.progress_bar)
super.setContentView(base)
setupToolbar(base, showHomeAsUp, title)
}
/**
* Inflate the shared base layout, attach the provided child view (commonly a view-binding root)
* into the base content container, cache the in-layout ProgressBar and configure the toolbar.
*
* Typical usage with view binding:
* setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "...")
*
* @param childView the already-inflated child view to add to the base content container
* @param showHomeAsUp whether to show the up/home affordance on the toolbar (default true)
* @param title optional activity title to set on the toolbar
*/
protected fun setContentViewWithToolbar(childView: View, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
val base = LayoutInflater.from(this).inflate(R.layout.activity_base, null)
val container = base.findViewById<FrameLayout>(R.id.content_container)
container.addView(childView, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
progressBar = base.findViewById(R.id.progress_bar)
super.setContentView(base)
setupToolbar(base, showHomeAsUp, title)
}
/**
* Internal helper that configures the MaterialToolbar found in the inflated base root.
*
* @param baseRoot the root view of the inflated base layout
* @param showHomeAsUp whether to show the up/home affordance
* @param title optional title to set on the support action bar
*/
private fun setupToolbar(baseRoot: View, showHomeAsUp: Boolean, title: CharSequence?) {
val toolbar = baseRoot.findViewById<MaterialToolbar>(R.id.toolbar)
toolbar?.let {
setSupportActionBar(it)
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
title?.let { t -> supportActionBar?.title = t }
}
}
/**
* Show the base layout's ProgressBar.
*
* This method is safe to call from background threads; the visibility change will
* be posted to the UI thread via [runOnUiThread]. If the base layout was not set yet
* (progressBar == null) the call is a no-op.
*/
protected fun showLoading() {
runOnUiThread {
progressBar?.visibility = View.VISIBLE
}
}
/**
* Hide the base layout's ProgressBar.
*
* Safe to call from background threads. No-op if the progress bar hasn't been cached.
*/
protected fun hideLoading() {
runOnUiThread {
progressBar?.visibility = View.GONE
}
}
/**
* Returns true when the base ProgressBar is currently visible.
*
* @return true if the progress bar exists and its visibility is VISIBLE
*/
protected fun isLoadingVisible(): Boolean {
return progressBar?.visibility == View.VISIBLE
}
}
@@ -0,0 +1,31 @@
package com.v2ray.ang.ui
import android.content.Context
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import com.v2ray.ang.compose.AppTheme
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.MyContextWrapper
abstract class BaseComponentActivity : ComponentActivity() {
override fun attachBaseContext(newBase: Context?) {
super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
AppTheme {
ScreenContent()
}
}
}
@Composable
protected abstract fun ScreenContent()
}
@@ -1,53 +0,0 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import androidx.viewbinding.ViewBinding
import com.v2ray.ang.helper.CustomDividerItemDecoration
abstract class BaseFragment<VB : ViewBinding> : Fragment() {
private var _binding: VB? = null
protected val binding: VB
get() = _binding!!
protected abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = inflateBinding(inflater, container)
return binding.root
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
/**
* Adds a custom divider to a RecyclerView.
*
* @param recyclerView The target RecyclerView to which the divider will be added.
* @param drawableResId The resource ID of the drawable to be used as the divider.
* @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
*/
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
// Get the drawable from resources
val drawable = ContextCompat.getDrawable(requireContext(), drawableResId)
requireNotNull(drawable) { "Drawable resource not found" }
// Create a DividerItemDecoration with the specified orientation
val dividerItemDecoration = CustomDividerItemDecoration(drawable, orientation)
// Add the divider to the RecyclerView
recyclerView.addItemDecoration(dividerItemDecoration)
}
}
@@ -1,13 +1,38 @@
package com.v2ray.ang.ui
import android.os.Bundle
import androidx.appcompat.app.AlertDialog
import androidx.lifecycle.lifecycleScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
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
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import com.v2ray.ang.AppConfig
import com.v2ray.ang.BuildConfig
import com.v2ray.ang.R
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.LocalAppSnackbar
import com.v2ray.ang.compose.SettingsMenuItem
import com.v2ray.ang.compose.SettingsSwitchItem
import com.v2ray.ang.compose.ToastType
import com.v2ray.ang.compose.VersionInfoBlock
import com.v2ray.ang.core.CoreNativeManager
import com.v2ray.ang.databinding.ActivityCheckUpdateBinding
import com.v2ray.ang.dto.CheckUpdateResult
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
@@ -18,62 +43,116 @@ import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.launch
class CheckUpdateActivity : BaseActivity() {
private val binding by lazy { ActivityCheckUpdateBinding.inflate(layoutInflater) }
class CheckUpdateActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.update_check_for_update))
binding.layoutCheckUpdate.setOnClickListener {
checkForUpdates(binding.checkPreRelease.isChecked)
}
binding.checkPreRelease.setOnCheckedChangeListener { _, isChecked ->
MmkvManager.encodeSettings(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, isChecked)
@Composable
override fun ScreenContent() {
CheckUpdateScreen(onBackClick = { finish() })
}
binding.checkPreRelease.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, false)
"v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
checkForUpdates(binding.checkPreRelease.isChecked)
@Composable
fun CheckUpdateScreen(onBackClick: () -> Unit) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbar = LocalAppSnackbar.current
var isLoading by remember { mutableStateOf(false) }
var checkPreRelease by remember {
mutableStateOf(MmkvManager.decodeSettingsBool(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, false))
}
var showUpdateDialog by remember { mutableStateOf(false) }
var updateResult by remember { mutableStateOf<CheckUpdateResult?>(null) }
private fun checkForUpdates(includePreRelease: Boolean) {
toast(R.string.update_checking_for_update)
showLoading()
val versionText = "v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})"
lifecycleScope.launch {
fun checkForUpdates(includePreRelease: Boolean) {
snackbar.show(context.getString(R.string.update_checking_for_update))
isLoading = true
scope.launch {
try {
val result = UpdateCheckerManager.checkForUpdate(includePreRelease)
if (result.hasUpdate) {
showUpdateDialog(result)
updateResult = result
showUpdateDialog = true
} else {
toastSuccess(R.string.update_already_latest_version)
snackbar.show(
message = context.getString(R.string.update_already_latest_version),
type = ToastType.SUCCESS
)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
toastError(e.message ?: getString(R.string.toast_failure))
snackbar.show(
message = e.message ?: context.getString(R.string.toast_failure),
type = ToastType.ERROR
)
} finally {
hideLoading()
isLoading = false
}
}
}
private fun showUpdateDialog(result: CheckUpdateResult) {
AlertDialog.Builder(this)
.setTitle(getString(R.string.update_new_version_found, result.latestVersion))
.setMessage(result.releaseNotes)
.setPositiveButton(R.string.update_now) { _, _ ->
result.downloadUrl?.let {
Utils.openUri(this, it)
LaunchedEffect(Unit) { checkForUpdates(checkPreRelease) }
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.update_check_for_update),
onBackClick = onBackClick,
isLoading = isLoading
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(rememberScrollState())
) {
SettingsSwitchItem(
icon = painterResource(R.drawable.ic_source_code_24dp),
title = stringResource(R.string.update_check_pre_release),
checked = checkPreRelease,
onCheckedChange = { checked ->
checkPreRelease = checked
MmkvManager.encodeSettings(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, checked)
}
)
SettingsMenuItem(
icon = painterResource(R.drawable.ic_check_update_24dp),
title = stringResource(R.string.update_check_for_update),
onClick = { checkForUpdates(checkPreRelease) }
)
VersionInfoBlock(versionText = versionText)
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
if (showUpdateDialog && updateResult != null) {
val result = updateResult!!
AlertDialog(
onDismissRequest = { showUpdateDialog = false },
title = { Text(stringResource(R.string.update_new_version_found, result.latestVersion ?: "")) },
text = { Text(result.releaseNotes ?: "") },
confirmButton = {
TextButton(onClick = {
showUpdateDialog = false
result.downloadUrl?.let { Utils.openUri(context, it) }
}) {
Text(stringResource(R.string.update_now))
}
},
dismissButton = {
TextButton(onClick = { showUpdateDialog = false }) {
Text(stringResource(android.R.string.cancel))
}
},
containerColor = MaterialTheme.colorScheme.surface
)
}
}
@@ -1,22 +0,0 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import androidx.fragment.app.FragmentActivity
import androidx.viewpager2.adapter.FragmentStateAdapter
import com.v2ray.ang.dto.GroupMapItem
/**
* Pager adapter for subscription groups.
*/
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
override fun getItemCount(): Int = groups.size
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
override fun getItemId(position: Int): Long = groups[position].id.hashCode().toLong()
override fun containsItem(itemId: Long): Boolean = groups.any { it.id.hashCode().toLong() == itemId }
@SuppressLint("NotifyDataSetChanged")
fun update(groups: List<GroupMapItem>) {
this.groups = groups
notifyDataSetChanged()
}
}
@@ -1,326 +0,0 @@
package com.v2ray.ang.ui
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.fragment.app.activityViewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.FragmentGroupServerBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.viewmodel.MainViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
SwipeRefreshLayout.OnRefreshListener {
private val ownerActivity: MainActivity
get() = requireActivity() as MainActivity
private val mainViewModel: MainViewModel by activityViewModels()
private lateinit var adapter: MainRecyclerAdapter
private var itemTouchHelper: ItemTouchHelper? = null
private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() }
private val share_method: Array<out String> by lazy {
ownerActivity.resources.getStringArray(R.array.share_method)
}
private val share_method_more: Array<out String> by lazy {
ownerActivity.resources.getStringArray(R.array.share_method_more)
}
private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (SettingsChangeManager.consumeRestartService() && mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
}
companion object {
private const val ARG_SUB_ID = "subscriptionId"
fun newInstance(subId: String) = GroupServerFragment().apply {
arguments = Bundle().apply { putString(ARG_SUB_ID, subId) }
}
}
override fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?) =
FragmentGroupServerBinding.inflate(inflater, container, false)
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = MainRecyclerAdapter(mainViewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true)
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
} else {
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 1)
}
addCustomDividerToRecyclerView(binding.recyclerView, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
binding.refreshLayout.isEnabled = false
// binding.refreshLayout.setOnRefreshListener(this)
// // Set the distance to trigger sync to 160dp
// binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
if (mainViewModel.subscriptionId != subId) {
return@observe
}
// LogUtil.d(TAG, "GroupServerFragment updateListAction subId=$subId")
adapter.setData(mainViewModel.serversCache, index)
}
// LogUtil.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
}
override fun onResume() {
super.onResume()
mainViewModel.subscriptionIdChanged(subId)
}
/**
* Shares server configuration
* Displays a dialog with sharing options and executes the selected action
* @param guid The server unique identifier
* @param profile The server configuration
* @param position The position in the list
* @param shareOptions The list of share options
* @param skip The number of options to skip
*/
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
AlertDialog.Builder(ownerActivity).setItems(shareOptions.toTypedArray()) { _, i ->
try {
when (i + skip) {
0 -> showQRCode(guid)
1 -> share2Clipboard(guid)
2 -> shareFullContent(guid)
3 -> editServer(guid, profile)
4 -> removeServer(guid, position)
else -> ownerActivity.toast("else")
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error when sharing server", e)
}
}.show()
}
/**
* Displays QR code for the server configuration
* @param guid The server unique identifier
*/
private fun showQRCode(guid: String) {
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
if (share_method.isNotEmpty()) {
ivBinding.ivQcode.contentDescription = share_method[0]
} else {
ivBinding.ivQcode.contentDescription = "QR Code"
}
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
}
/**
* Shares server configuration to clipboard
* @param guid The server unique identifier
*/
private fun share2Clipboard(guid: String) {
if (AngConfigManager.share2Clipboard(ownerActivity, guid) == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
ownerActivity.toastError(R.string.toast_failure)
}
}
/**
* Shares full server configuration content to clipboard
* @param guid The server unique identifier
*/
private fun shareFullContent(guid: String) {
lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
launch(Dispatchers.Main) {
if (result == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
ownerActivity.toastError(R.string.toast_failure)
}
}
}
}
/**
* Edits server configuration
* Opens appropriate editing interface based on configuration type
* @param guid The server unique identifier
* @param profile The server configuration
*/
private fun editServer(guid: String, profile: ProfileItem) {
val activityClass = when (profile.configType) {
EConfigType.CUSTOM -> ServerCustomConfigActivity::class.java
EConfigType.POLICYGROUP -> ServerGroupActivity::class.java
EConfigType.PROXYCHAIN -> ServerProxyChainActivity::class.java
else -> ServerActivity::class.java
}
val intent = Intent(ownerActivity, activityClass)
.putExtra("guid", guid)
.putExtra("isRunning", mainViewModel.isRunning.value)
.putExtra("createConfigType", profile.configType.value)
.putExtra("subscriptionId", subId)
launcher.launch(intent)
}
/**
* Removes server configuration
* Handles confirmation dialog and related checks
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServer(guid: String, position: Int) {
if (guid == MmkvManager.getSelectServer()) {
ownerActivity.toast(R.string.toast_action_not_allowed)
return
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
removeServerSub(guid, position)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
} else {
removeServerSub(guid, position)
}
}
/**
* Executes the actual server removal process
* @param guid The server unique identifier
* @param position The position in the list
*/
private fun removeServerSub(guid: String, position: Int) {
mainViewModel.removeServer(guid)
adapter.removeServerSub(guid, position)
ownerActivity.refreshGroupTabTitles()
}
/**
* Sets the selected server
* Updates UI and restarts service if needed
* @param guid The server unique identifier to select
*/
private fun setSelectServer(guid: String) {
val selected = MmkvManager.getSelectServer()
if (guid != selected) {
MmkvManager.setSelectServer(guid)
val fromPosition = mainViewModel.getPosition(selected.orEmpty())
val toPosition = mainViewModel.getPosition(guid)
adapter.setSelectServer(fromPosition, toPosition)
if (mainViewModel.isRunning.value == true) {
ownerActivity.restartV2Ray()
}
}
}
private inner class ActivityAdapterListener : MainAdapterListener {
override fun onEdit(guid: String, position: Int) {
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
}
override fun onRemove(guid: String, position: Int) {
removeServer(guid, position)
}
override fun onEdit(guid: String, position: Int, profile: ProfileItem) {
editServer(guid, profile)
}
override fun onSelectServer(guid: String) {
setSelectServer(guid)
}
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
val isCustom = profile.configType.isComplexType()
val (shareOptions, skip) = if (more) {
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
options to if (isCustom) 2 else 0
} else {
val options = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
options to if (isCustom) 2 else 0
}
shareServer(guid, profile, position, shareOptions, skip)
}
}
override fun onRefresh() {
ownerActivity.importConfigViaSub()
//binding.refreshLayout.isRefreshing = false
}
/**
* Scrolls to the currently selected server in the RecyclerView
*/
fun scrollToSelectedServer() {
val selectedGuid = MmkvManager.getSelectServer()
if (selectedGuid.isNullOrEmpty()) {
ownerActivity.toast(R.string.title_file_chooser)
return
}
// Find the position of the selected server
val serversCache = mainViewModel.serversCache
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
val recyclerView = binding.recyclerView
if (position >= 0) {
// Get the layout manager
val layoutManager = recyclerView.layoutManager as? GridLayoutManager
if (layoutManager != null) {
// Scroll to position with offset to center it on screen
// First scroll to position, then adjust to center
recyclerView.post {
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 3)
}
} else {
// Fallback to smooth scroll if layout manager is not GridLayoutManager
recyclerView.smoothScrollToPosition(position)
}
} else {
ownerActivity.toast(R.string.toast_server_not_found_in_group)
}
}
}
@@ -8,10 +8,10 @@ import com.v2ray.ang.helper.PermissionHelper
import com.v2ray.ang.helper.QRCodeScannerHelper
/**
* HelperBaseActivity extends BaseActivity and provides additional helpers for
* HelperBaseComponentActivity extends BaseComponentActivity and provides additional helpers for
* activities that need file chooser, permission requesting, or QR code scanning functionality.
*
* Activities that don't need these features should extend BaseActivity directly.
* Activities that don't need these features should extend BaseComponentActivity directly.
* Activities that need file selection, permissions, or QR code scanning should extend this class.
*
* Additional Responsibilities:
@@ -19,7 +19,8 @@ import com.v2ray.ang.helper.QRCodeScannerHelper
* - Provide permission request helpers with callbacks.
* - Provide QR code scanning helpers with camera permission handling.
*/
abstract class HelperBaseActivity : BaseActivity() {
abstract class HelperBaseComponentActivity : BaseComponentActivity() {
private lateinit var fileChooser: FileChooserHelper
private lateinit var permissionRequester: PermissionHelper
private lateinit var qrCodeScanner: QRCodeScannerHelper
@@ -80,9 +81,9 @@ abstract class HelperBaseActivity : BaseActivity() {
*
* @param onResult Callback invoked with the scan result string (null if cancelled or failed)
*/
protected fun launchQRCodeScanner(onResult: (String?) -> Unit) {
checkAndRequestPermission(PermissionType.CAMERA) {
protected fun launchQRCodeScanner(
onResult: (String?) -> Unit
) {
qrCodeScanner.launch(onResult)
}
}
}
@@ -1,21 +1,49 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.ClipData
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
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.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.FileProvider
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityLogcatBinding
import com.v2ray.ang.compose.AppDivider
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.LocalAppSnackbar
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.LogcatViewModel
import kotlinx.coroutines.Dispatchers
@@ -26,35 +54,25 @@ import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
class LogcatActivity : BaseComponentActivity() {
private val viewModel: LogcatViewModel by viewModels()
private lateinit var adapter: LogcatRecyclerAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_logcat))
adapter = LogcatRecyclerAdapter(viewModel, ::onLogLongClick)
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
binding.refreshLayout.setOnRefreshListener(this)
toast(getString(R.string.pull_down_to_refresh))
}
private fun onLogLongClick(log: String): Boolean {
Utils.setClipboard(this, log)
return true
@Composable
override fun ScreenContent() {
LogcatScreen(
viewModel = viewModel,
onBackClick = { finish() },
onShareLogcat = { shareLogcat() }
)
}
private fun shareLogcat() {
lifecycleScope.launch(Dispatchers.IO) {
val logText = viewModel.getAll().joinToString("\n")
val logText = viewModel.filteredLogs.value.joinToString("\n")
val result = try {
val shareDir = File(cacheDir, "shared_logs").apply {
@@ -100,70 +118,130 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_logcat, menu)
val searchItem = menu.findItem(R.id.search_view)
if (searchItem != null) {
val searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
viewModel.filter(newText)
refreshData()
return false
}
})
searchView.setOnCloseListener {
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun LogcatScreen(
viewModel: LogcatViewModel,
onBackClick: () -> Unit,
onShareLogcat: () -> Unit
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val logs by viewModel.filteredLogs.collectAsStateWithLifecycle()
val isRefreshing by viewModel.isRefreshing.collectAsStateWithLifecycle()
var searchQuery by remember { mutableStateOf("") }
var showSearch by remember { mutableStateOf(false) }
val snackbar = LocalAppSnackbar.current
LaunchedEffect(Unit) {
snackbar.show(context.getString(R.string.pull_down_to_refresh))
}
val listState = rememberLazyListState()
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.title_logcat),
onBackClick = onBackClick,
isSearchActive = showSearch,
searchQuery = searchQuery,
onSearchQueryChange = {
searchQuery = it
viewModel.filter(it)
},
onSearchClose = {
searchQuery = ""
viewModel.filter("")
refreshData()
false
showSearch = false
},
searchPlaceholder = stringResource(R.string.menu_item_search),
actions = {
if (!showSearch) {
IconButton(onClick = { showSearch = true }) {
Icon(
painterResource(R.drawable.ic_outline_filter_alt_24),
contentDescription = "filter"
)
}
}
IconButton(onClick = {
scope.launch(Dispatchers.IO) { viewModel.clearLogcat() }
}) {
Icon(
painterResource(R.drawable.ic_delete_24dp),
contentDescription = stringResource(R.string.logcat_clear)
)
}
IconButton(onClick = {
val all = viewModel.filteredLogs.value.joinToString("\n")
Utils.setClipboard(context, all)
context.toast(context.getString(R.string.toast_success))
}) {
Icon(
painterResource(R.drawable.ic_copy),
contentDescription = stringResource(R.string.logcat_copy)
)
}
IconButton(onClick = onShareLogcat) {
Icon(
painterResource(R.drawable.ic_share_24dp),
contentDescription = stringResource(R.string.logcat_share)
)
}
}
)
}
) { innerPadding ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
scope.launch(Dispatchers.IO) { viewModel.loadLogcat() }
},
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.verticalScrollbar(listState)
) {
itemsIndexed(items = logs, key = { index, _ -> index }) { _, log ->
LogcatItem(log = log, onLongClick = { Utils.setClipboard(context, log) })
AppDivider()
}
}
}
}
}
return super.onCreateOptionsMenu(menu)
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun LogcatItem(log: String, onLongClick: () -> Unit) {
val (tag, content) = if (log.isEmpty()) {
"" to ""
} else {
val parts = log.split("):", limit = 2)
val tagPart = parts.first().split("(", limit = 2).first().trim()
val contentPart = if (parts.size > 1) parts.last().trim() else ""
tagPart to contentPart
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.copy_all -> {
val all = viewModel.getAll().joinToString("\n")
Utils.setClipboard(this, all)
toastSuccess(R.string.toast_success)
true
}
R.id.share_all -> {
shareLogcat()
true
}
R.id.clear_all -> {
lifecycleScope.launch(Dispatchers.IO) {
viewModel.clearLogcat()
withContext(Dispatchers.Main) {
refreshData()
}
}
true
}
else -> super.onOptionsItemSelected(item)
}
override fun onRefresh() {
lifecycleScope.launch(Dispatchers.IO) {
viewModel.loadLogcat()
withContext(Dispatchers.Main) {
binding.refreshLayout.isRefreshing = false
refreshData()
Column(
modifier = Modifier
.fillMaxWidth()
.combinedClickable(onClick = {}, onLongClick = onLongClick)
.padding(8.dp)
) {
Text(text = tag, style = MaterialTheme.typography.bodySmall)
if (content.isNotEmpty()) {
Spacer(modifier = Modifier.height(8.dp))
Text(text = content, style = MaterialTheme.typography.bodySmall)
}
}
}
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
adapter.notifyDataSetChanged()
}
}
@@ -1,52 +0,0 @@
package com.v2ray.ang.ui
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.viewmodel.LogcatViewModel
class LogcatRecyclerAdapter(
private val viewModel: LogcatViewModel,
private val onLongClick: ((String) -> Boolean)? = null
) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
try {
val logs = viewModel.getAll()
val log = logs[position]
if (log.isEmpty()) {
holder.itemSubSettingBinding.logTag.text = ""
holder.itemSubSettingBinding.logContent.text = ""
} else {
val content = log.split("):", limit = 2)
holder.itemSubSettingBinding.logTag.text = content.first().split("(", limit = 2).first().trim()
holder.itemSubSettingBinding.logContent.text = if (content.count() > 1) content.last().trim() else ""
}
holder.itemView.setOnLongClickListener {
onLongClick?.invoke(log) ?: false
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error binding log view data", e)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
return MainViewHolder(
ItemRecyclerLogcatBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
class MainViewHolder(val itemSubSettingBinding: ItemRecyclerLogcatBinding) : RecyclerView.ViewHolder(itemSubSettingBinding.root)
}
File diff suppressed because it is too large Load Diff
@@ -1,236 +0,0 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.MainAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
import com.v2ray.ang.databinding.ItemRecyclerMainBinding
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.dto.entities.ServersCache
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.viewmodel.MainViewModel
import java.util.Collections
class MainRecyclerAdapter(
private val mainViewModel: MainViewModel,
private val adapterListener: MainAdapterListener?
) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
companion object {
private const val VIEW_TYPE_ITEM = 1
private const val VIEW_TYPE_FOOTER = 2
}
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
private var data: MutableList<ServersCache> = mutableListOf()
@SuppressLint("NotifyDataSetChanged")
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
data = newData?.toMutableList() ?: mutableListOf()
if (position >= 0 && position in data.indices) {
notifyItemChanged(position)
} else {
notifyDataSetChanged()
}
}
override fun getItemCount() = data.size + 1
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
if (holder is MainViewHolder) {
val context = holder.itemMainBinding.root.context
val guid = data[position].guid
val profile = data[position].profile
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
//Name address
holder.itemMainBinding.tvName.text = profile.remarks
holder.itemMainBinding.tvStatistics.text = getAddress(profile)
holder.itemMainBinding.tvType.text = getProtocolDescription(profile)
//TestResult
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
if ((aff?.testDelayMillis ?: 0L) < 0L) {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPingRed))
} else {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPing))
}
//layoutIndicator
if (guid == MmkvManager.getSelectServer()) {
holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorIndicator)
} else {
holder.itemMainBinding.layoutIndicator.setBackgroundResource(0)
}
//subscription remarks
val subRemarks = getSubscriptionRemarks(profile)
holder.itemMainBinding.tvSubscription.text = subRemarks
holder.itemMainBinding.layoutSubscription.visibility = if (subRemarks.isEmpty()) View.GONE else View.VISIBLE
//layout
if (doubleColumnDisplay) {
holder.itemMainBinding.layoutShare.visibility = View.GONE
holder.itemMainBinding.layoutEdit.visibility = View.GONE
holder.itemMainBinding.layoutRemove.visibility = View.GONE
holder.itemMainBinding.layoutMore.visibility = View.VISIBLE
holder.itemMainBinding.layoutMore.setOnClickListener {
adapterListener?.onShare(guid, profile, position, true)
}
} else {
holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
holder.itemMainBinding.layoutEdit.visibility = View.VISIBLE
holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE
holder.itemMainBinding.layoutMore.visibility = View.GONE
holder.itemMainBinding.layoutShare.setOnClickListener {
adapterListener?.onShare(guid, profile, position, false)
}
holder.itemMainBinding.layoutEdit.setOnClickListener {
adapterListener?.onEdit(guid, position, profile)
}
holder.itemMainBinding.layoutRemove.setOnClickListener {
adapterListener?.onRemove(guid, position)
}
}
holder.itemMainBinding.infoContainer.setOnClickListener {
adapterListener?.onSelectServer(guid)
}
}
}
/**
* Gets the server address information
* Hides part of IP or domain information for privacy protection
* @param profile The server configuration
* @return Formatted address string
*/
private fun getAddress(profile: ProfileItem): String {
return profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile)
}
/**
* Gets the subscription remarks information
* @param profile The server configuration
* @return Subscription remarks string, or empty string if none
*/
private fun getSubscriptionRemarks(profile: ProfileItem): String {
val subRemarks =
if (mainViewModel.subscriptionId.isEmpty())
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
else
null
return subRemarks?.toString() ?: ""
}
private fun getProtocolDescription(profile: ProfileItem): String {
if (profile.configType.isComplexType()) {
return profile.configType.name
}
val parts = mutableListOf<String>()
parts.add(profile.configType.name)
// Transport: hide tcp or blank
profile.network?.let { net ->
if (net.isNotBlank() && !net.equals("tcp", ignoreCase = true)) {
parts.add(net)
}
}
// Security: hide blank or tls
profile.security?.let { sec ->
if (sec.isNotBlank()) {
if (profile.insecure == true && sec.equals("tls", ignoreCase = true)) {
parts.add("$sec insecure") // TODO
} else {
parts.add(sec)
}
}
}
return parts.joinToString(" / ")
}
fun removeServerSub(guid: String, position: Int) {
val idx = data.indexOfFirst { it.guid == guid }
if (idx >= 0) {
data.removeAt(idx)
notifyItemRemoved(idx)
notifyItemRangeChanged(idx, data.size - idx)
}
}
fun setSelectServer(fromPosition: Int, toPosition: Int) {
notifyItemChanged(fromPosition)
notifyItemChanged(toPosition)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
return when (viewType) {
VIEW_TYPE_ITEM ->
MainViewHolder(ItemRecyclerMainBinding.inflate(LayoutInflater.from(parent.context), parent, false))
else ->
FooterViewHolder(ItemRecyclerFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false))
}
}
override fun getItemViewType(position: Int): Int {
return if (position == data.size) {
VIEW_TYPE_FOOTER
} else {
VIEW_TYPE_ITEM
}
}
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun onItemSelected() {
itemView.setBackgroundColor(Color.LTGRAY)
}
fun onItemClear() {
itemView.setBackgroundColor(0)
}
}
class MainViewHolder(val itemMainBinding: ItemRecyclerMainBinding) :
BaseViewHolder(itemMainBinding.root), ItemTouchHelperViewHolder
class FooterViewHolder(val itemFooterBinding: ItemRecyclerFooterBinding) :
BaseViewHolder(itemFooterBinding.root)
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
mainViewModel.swapServer(fromPosition, toPosition)
if (fromPosition < data.size && toPosition < data.size) {
Collections.swap(data, fromPosition, toPosition)
}
notifyItemMoved(fromPosition, toPosition)
return true
}
override fun onItemMoveCompleted() {
// do nothing
}
override fun onItemDismiss(position: Int) {
}
}
@@ -1,322 +1,279 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.os.Bundle
import android.text.TextUtils
import android.view.Menu
import android.view.MenuItem
import android.widget.Toast
import androidx.activity.viewModels
import androidx.appcompat.widget.SearchView
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import androidx.compose.foundation.layout.Arrangement
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
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
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityBypassListBinding
import com.v2ray.ang.compose.AppDivider
import com.v2ray.ang.compose.AppListItem
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.colorFabActive
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastInfo
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.extension.v2RayApplication
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
import es.dmoral.toasty.Toasty
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.Collator
class PerAppProxyActivity : BaseActivity() {
private val binding by lazy { ActivityBypassListBinding.inflate(layoutInflater) }
class PerAppProxyActivity : BaseComponentActivity() {
private var adapter: PerAppProxyAdapter? = null
private var appsAll: List<AppInfo>? = null
private val viewModel: PerAppProxyViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.per_app_proxy_settings))
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
initList()
binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked ->
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, isChecked)
}
binding.switchPerAppProxy.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false)
binding.switchBypassApps.setOnCheckedChangeListener { _, isChecked ->
MmkvManager.encodeSettings(AppConfig.PREF_BYPASS_APPS, isChecked)
}
binding.switchBypassApps.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS, false)
binding.layoutSwitchBypassAppsTips.setOnClickListener {
Toasty.info(this, R.string.summary_pref_per_app_proxy, Toast.LENGTH_LONG, true).show()
}
viewModel.loadApps(this)
}
private fun initList() {
showLoading()
@Composable
override fun ScreenContent() {
val apps by viewModel.displayedApps.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
val blacklist by viewModel.blacklist.collectAsStateWithLifecycle()
val perAppProxyEnabled by viewModel.perAppProxyEnabled.collectAsStateWithLifecycle()
val bypassApps by viewModel.bypassApps.collectAsStateWithLifecycle()
lifecycleScope.launch {
try {
val apps = withContext(Dispatchers.IO) {
val appsList = AppManagerUtil.loadNetworkAppList(this@PerAppProxyActivity)
val blacklistSet = viewModel.getAll()
if (blacklistSet.isNotEmpty()) {
appsList.forEach { app ->
app.isSelected = if (blacklistSet.contains(app.packageName)) 1 else 0
}
appsList.sortedWith { p1, p2 ->
when {
p1.isSelected > p2.isSelected -> -1
p1.isSelected < p2.isSelected -> 1
p1.isSystemApp > p2.isSystemApp -> 1
p1.isSystemApp < p2.isSystemApp -> -1
p1.appName.lowercase() > p2.appName.lowercase() -> 1
p1.appName.lowercase() < p2.appName.lowercase() -> -1
p1.packageName > p2.packageName -> 1
p1.packageName < p2.packageName -> -1
else -> 0
}
}
} else {
val collator = Collator.getInstance()
appsList.sortedWith(compareBy(collator) { it.appName })
}
}
appsAll = apps
adapter = PerAppProxyAdapter(apps, viewModel)
binding.recyclerView.adapter = adapter
} catch (e: Exception) {
LogUtil.e(ANG_PACKAGE, "Error loading apps", e)
} finally {
hideLoading()
}
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_bypass_list, menu)
val searchItem = menu.findItem(R.id.search_view)
if (searchItem != null) {
val searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean = false
override fun onQueryTextChange(newText: String?): Boolean {
filterProxyApp(newText.orEmpty())
return false
}
})
}
return super.onCreateOptionsMenu(menu)
}
@SuppressLint("NotifyDataSetChanged")
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.select_all -> {
selectAllApp()
allowPerAppProxy()
true
}
R.id.invert_selection -> {
invertSelection()
allowPerAppProxy()
true
}
R.id.select_proxy_app -> {
selectProxyAppAuto()
allowPerAppProxy()
true
}
R.id.import_proxy_app -> {
importProxyApp()
allowPerAppProxy()
true
}
R.id.export_proxy_app -> {
exportProxyApp()
true
}
else -> super.onOptionsItemSelected(item)
}
private fun selectAllApp() {
adapter?.let { adapter ->
val pkgNames = adapter.apps.map { it.packageName }
val allSelected = pkgNames.all { viewModel.contains(it) }
if (allSelected) {
viewModel.removeAll(pkgNames)
} else {
viewModel.addAll(pkgNames)
}
refreshData()
}
}
private fun invertSelection() {
adapter?.let { adapter ->
adapter.apps.forEach { app ->
viewModel.toggle(app.packageName)
}
refreshData()
}
}
private fun selectProxyAppAuto() {
toast(R.string.msg_downloading_content)
showLoading()
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
lifecycleScope.launch(Dispatchers.IO) {
var content = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000
)
)
if (content.isNullOrEmpty()) {
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
content = HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
) ?: ""
}
launch(Dispatchers.Main) {
//LogUtil.i(AppConfig.TAG, content)
selectProxyApp(content, true)
toastSuccess(R.string.toast_success)
hideLoading()
}
}
}
private fun importProxyApp() {
PerAppProxyScreen(
apps = apps,
isLoading = isLoading,
blacklist = blacklist,
perAppProxyEnabled = perAppProxyEnabled,
bypassApps = bypassApps,
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() },
onInvertSelection = { viewModel.invertSelection() },
onSelectProxyAuto = { viewModel.selectProxyAppAuto(this) },
onImportProxyApp = {
val content = Utils.getClipboard(applicationContext)
if (TextUtils.isEmpty(content)) return
selectProxyApp(content, false)
viewModel.importProxyApp(content, this)
},
onExportProxyApp = {
val export = viewModel.exportProxyApp()
Utils.setClipboard(applicationContext, export)
toastSuccess(R.string.toast_success)
}
private fun exportProxyApp() {
var lst = binding.switchBypassApps.isChecked.toString()
viewModel.getAll().forEach { pkg ->
lst = lst + System.lineSeparator() + pkg
)
}
Utils.setClipboard(applicationContext, lst)
toastSuccess(R.string.toast_success)
}
private fun allowPerAppProxy() {
binding.switchPerAppProxy.isChecked = true
SettingsChangeManager.makeRestartService()
}
@SuppressLint("NotifyDataSetChanged")
private fun selectProxyApp(content: String, force: Boolean): Boolean {
try {
val proxyApps = if (TextUtils.isEmpty(content)) {
Utils.readTextFromAssets(v2RayApplication, "proxy_package_name")
} else {
content
}
if (TextUtils.isEmpty(proxyApps)) return false
viewModel.clear()
if (binding.switchBypassApps.isChecked) {
adapter?.let { adapter ->
adapter.apps.forEach { app ->
val packageName = app.packageName
if (!inProxyApps(proxyApps, packageName, force)) {
viewModel.add(packageName)
}
}
refreshData()
}
} else {
adapter?.let { adapter ->
adapter.apps.forEach { app ->
val packageName = app.packageName
if (inProxyApps(proxyApps, packageName, force)) {
viewModel.add(packageName)
}
}
refreshData()
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
return false
}
return true
}
private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
println(packageName)
if (force) {
if (packageName == "com.google.android.webview") return false
if (packageName.startsWith("com.google")) return true
}
return proxyApps.indexOf(packageName) >= 0
}
private fun filterProxyApp(content: String): Boolean {
val apps = ArrayList<AppInfo>()
val key = content.uppercase()
if (key.isNotEmpty()) {
appsAll?.forEach {
if (it.appName.uppercase().indexOf(key) >= 0
|| it.packageName.uppercase().indexOf(key) >= 0
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PerAppProxyScreen(
apps: List<AppInfo>,
isLoading: Boolean,
blacklist: Set<String>,
perAppProxyEnabled: Boolean,
bypassApps: Boolean,
onBackClick: () -> Unit,
onPerAppProxyChanged: (Boolean) -> Unit,
onBypassAppsChanged: (Boolean) -> Unit,
onInfoClick: () -> Unit,
onToggleApp: (String) -> Unit,
onSearch: (String) -> Unit,
onSelectAll: () -> Unit,
onInvertSelection: () -> Unit,
onSelectProxyAuto: () -> Unit,
onImportProxyApp: () -> Unit,
onExportProxyApp: () -> Unit
) {
apps.add(it)
}
}
} else {
appsAll?.forEach {
apps.add(it)
}
var showSearch by rememberSaveable { mutableStateOf(false) }
var searchQuery by rememberSaveable { mutableStateOf("") }
var showMenu by remember { mutableStateOf(false) }
val listState = rememberLazyListState()
LaunchedEffect(searchQuery) {
onSearch(searchQuery)
}
adapter = PerAppProxyAdapter(apps, adapter?.viewModel ?: viewModel)
binding.recyclerView.adapter = adapter
refreshData()
return true
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.per_app_proxy_settings),
onBackClick = onBackClick,
isLoading = isLoading,
isSearchActive = showSearch,
searchQuery = searchQuery,
onSearchQueryChange = { query ->
searchQuery = query
},
onSearchClose = {
searchQuery = ""
showSearch = false
},
searchPlaceholder = stringResource(R.string.menu_item_search),
actions = {
if (!showSearch) {
IconButton(onClick = { showSearch = true }) {
Icon(
painterResource(R.drawable.ic_search_24dp),
contentDescription = stringResource(R.string.menu_item_search)
)
}
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
painterResource(R.drawable.ic_more_vert_24dp),
contentDescription = null
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
containerColor = MaterialTheme.colorScheme.surface
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_select_all)) },
onClick = { showMenu = false; onSelectAll() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_invert_selection)) },
onClick = { showMenu = false; onInvertSelection() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_select_proxy_app)) },
onClick = { showMenu = false; onSelectProxyAuto() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_import_proxy_app)) },
onClick = { showMenu = false; onImportProxyApp() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_export_proxy_app)) },
onClick = { showMenu = false; onExportProxyApp() }
)
}
}
}
)
}
) { innerPadding ->
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surface
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceEvenly
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(R.string.per_app_proxy_settings_enable),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = perAppProxyEnabled,
modifier = Modifier.scale(0.65f),
onCheckedChange = onPerAppProxyChanged,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
checkedTrackColor = colorFabActive
)
)
}
Spacer(modifier = Modifier.width(16.dp))
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Text(
text = stringResource(R.string.switch_bypass_apps_mode),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.width(8.dp))
Switch(
checked = bypassApps,
modifier = Modifier.scale(0.65f),
onCheckedChange = onBypassAppsChanged,
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
checkedTrackColor = colorFabActive
)
)
}
IconButton(onClick = onInfoClick) {
Icon(
painter = painterResource(R.drawable.ic_about_24dp),
contentDescription = stringResource(R.string.summary_pref_per_app_proxy),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
AppDivider()
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
adapter?.notifyDataSetChanged()
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.verticalScrollbar(listState)
) {
items(items = apps, key = { it.packageName }) { app ->
val checked = blacklist.contains(app.packageName)
AppListItem(
appName = app.appName,
packageName = app.packageName,
icon = app.appIcon,
checked = checked,
onCheckedChange = { onToggleApp(app.packageName) }
)
AppDivider()
}
}
}
}
}
@@ -1,77 +0,0 @@
package com.v2ray.ang.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
class PerAppProxyAdapter(
val apps: List<AppInfo>,
val viewModel: PerAppProxyViewModel
) : RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
companion object {
private const val VIEW_TYPE_HEADER = 0
private const val VIEW_TYPE_ITEM = 1
}
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
if (holder is AppViewHolder) {
val appInfo = apps[position - 1]
holder.bind(appInfo)
}
}
override fun getItemCount() = apps.size + 1
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
val ctx = parent.context
return when (viewType) {
VIEW_TYPE_HEADER -> {
val view = View(ctx)
view.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0
)
BaseViewHolder(view)
}
else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
}
}
override fun getItemViewType(position: Int) = if (position == 0) VIEW_TYPE_HEADER else VIEW_TYPE_ITEM
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
inner class AppViewHolder(private val itemBypassBinding: ItemRecyclerBypassListBinding) : BaseViewHolder(itemBypassBinding.root),
View.OnClickListener {
private lateinit var appInfo: AppInfo
fun bind(appInfo: AppInfo) {
this.appInfo = appInfo
itemBypassBinding.icon.setImageDrawable(appInfo.appIcon)
itemBypassBinding.name.text = if (appInfo.isSystemApp) {
String.format("** %s", appInfo.appName)
} else {
appInfo.appName
}
itemBypassBinding.packageName.text = appInfo.packageName
itemBypassBinding.checkBox.isChecked = viewModel.contains(appInfo.packageName)
itemView.setOnClickListener(this)
}
override fun onClick(v: View?) {
val packageName = appInfo.packageName
viewModel.toggle(packageName)
itemBypassBinding.checkBox.isChecked = viewModel.contains(packageName)
}
}
}
@@ -0,0 +1,46 @@
package com.v2ray.ang.ui
import android.app.Activity
import android.content.Intent
object ProfileEditorResult {
const val EXTRA_ACTION =
"com.v2ray.ang.extra.PROFILE_EDITOR_ACTION"
const val EXTRA_GUID =
"com.v2ray.ang.extra.PROFILE_EDITOR_GUID"
const val EXTRA_RESTART_SERVICE =
"com.v2ray.ang.extra.PROFILE_EDITOR_RESTART_SERVICE"
const val ACTION_SAVED = "saved"
const val ACTION_DELETED = "deleted"
fun Activity.finishSaved(
guid: String,
restartService: Boolean
) {
setResult(
Activity.RESULT_OK,
Intent().apply {
putExtra(EXTRA_ACTION, ACTION_SAVED)
putExtra(EXTRA_GUID, guid)
putExtra(EXTRA_RESTART_SERVICE, restartService)
}
)
finish()
}
fun Activity.finishDeleted(guid: String) {
setResult(
Activity.RESULT_OK,
Intent().apply {
putExtra(EXTRA_ACTION, ACTION_DELETED)
putExtra(EXTRA_GUID, guid)
putExtra(EXTRA_RESTART_SERVICE, false)
}
)
finish()
}
}
@@ -1,190 +1,297 @@
package com.v2ray.ang.ui
import android.app.Activity
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
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
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
import com.v2ray.ang.AppConfig.TAG_PROXY
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.ConfirmDialog
import com.v2ray.ang.compose.FormDropdownField
import com.v2ray.ang.compose.FormTextField
import com.v2ray.ang.compose.SettingsSwitchItem
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.extension.nullIfBlank
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.UUID
class RoutingEditActivity : BaseActivity() {
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
class RoutingEditActivity : BaseComponentActivity() {
private val position by lazy { intent.getIntExtra("position", -1) }
private val processPickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
val selectedPackages = AppPickerActivity.getSelectedPackages(result.data)
binding.etProcess.text = Utils.getEditable(selectedPackages.joinToString(","))
}
}
private var initial: RulesetItem? = null
private lateinit var outboundSuggestions: List<String>
private var canUseProcess: Boolean = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
setupOutboundTagInput()
setupProcessPicker()
val rulesetItem = SettingsManager.getRoutingRuleset(position)
if (rulesetItem != null) {
bindingServer(rulesetItem)
} else {
clearServer()
}
SettingsManager.canUseProcessRouting().let { canUse ->
binding.etProcess.isEnabled = canUse
binding.btnProcessPicker.isEnabled = canUse
}
}
private fun setupProcessPicker() {
binding.btnProcessPicker.setOnClickListener {
processPickerLauncher.launch(
AppPickerActivity.createIntent(
context = this,
selectedPackages = getSelectedProcessPackages(),
title = getString(R.string.routing_settings_process)
)
)
}
}
private fun getSelectedProcessPackages(): List<String> {
return binding.etProcess.text
.toString()
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
}
/**
* Sets up the AutoCompleteTextView for outbound tag:
* suggestions = built-in tags (proxy/direct/block) + all existing profile remarks.
* The dropdown button triggers showing the full list without typing.
*/
private fun setupOutboundTagInput() {
initial = SettingsManager.getRoutingRuleset(position)
val profileRemarks = SettingsManager.getProfileRemarks()
val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
binding.spOutboundTag.setAdapter(adapter)
// threshold=0 means show all suggestions even before typing; still need focus+request
binding.spOutboundTag.threshold = 0
// Dropdown arrow button shows the full suggestion list
binding.btnOutboundTagDropdown.setOnClickListener {
binding.spOutboundTag.requestFocus()
binding.spOutboundTag.showDropDown()
}
// Also show on field click when it already has focus
binding.spOutboundTag.setOnClickListener {
binding.spOutboundTag.showDropDown()
}
outboundSuggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
canUseProcess = SettingsManager.canUseProcessRouting()
}
private fun bindingServer(rulesetItem: RulesetItem): Boolean {
binding.etRemarks.text = Utils.getEditable(rulesetItem.remarks)
binding.chkLocked.isChecked = rulesetItem.locked == true
binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(","))
binding.etIp.text = Utils.getEditable(rulesetItem.ip?.joinToString(","))
binding.etProcess.text = Utils.getEditable(rulesetItem.process?.joinToString(","))
binding.etPort.text = Utils.getEditable(rulesetItem.port)
binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
// Set text directly; filter won't fire because we're not using setText(filter=true)
binding.spOutboundTag.setText(rulesetItem.outboundTag, false)
return true
}
private fun clearServer(): Boolean {
binding.etRemarks.text = null
binding.spOutboundTag.setText(BUILTIN_OUTBOUND_TAGS.first(), false)
return true
}
private fun saveServer(): Boolean {
val rulesetItem = SettingsManager.getRoutingRuleset(position) ?: RulesetItem()
rulesetItem.apply {
remarks = binding.etRemarks.text.toString()
locked = binding.chkLocked.isChecked
domain = binding.etDomain.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
ip = binding.etIp.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
process = binding.etProcess.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
protocol = binding.etProtocol.text.toString().nullIfBlank()?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() }
port = binding.etPort.text.toString().nullIfBlank()
network = binding.etNetwork.text.toString().nullIfBlank()
outboundTag = binding.spOutboundTag.text.toString().trim().ifEmpty { TAG_PROXY }
@Composable
override fun ScreenContent() {
RoutingEditScreen(
position = position,
initial = initial,
outboundSuggestions = outboundSuggestions,
canUseProcess = canUseProcess,
onBackClick = { finish() },
onSave = { saveServer(it) },
onDelete = { deleteServer() }
)
}
private fun saveServer(rulesetItem: RulesetItem): Boolean {
if (rulesetItem.remarks.isNullOrEmpty()) {
toast(R.string.sub_setting_remarks)
return false
}
if (position < 0 && rulesetItem.id.isEmpty()) {
rulesetItem.id = UUID.randomUUID().toString()
}
SettingsManager.saveRoutingRuleset(position, rulesetItem)
toastSuccess(R.string.toast_success)
finish()
return true
}
private fun deleteServer(): Boolean {
if (position >= 0) {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
SettingsManager.removeRoutingRuleset(position)
launch(Dispatchers.Main) {
finish()
withContext(Dispatchers.Main) { finish() }
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
// do nothing
}
.show()
}
return true
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
val delConfig = menu.findItem(R.id.del_config)
if (position < 0) {
delConfig?.isVisible = false
}
return super.onCreateOptionsMenu(menu)
@Composable
fun RoutingEditScreen(
position: Int,
initial: RulesetItem?,
outboundSuggestions: List<String>,
canUseProcess: Boolean,
onBackClick: () -> Unit,
onSave: (RulesetItem) -> Boolean,
onDelete: () -> Unit
) {
val context = LocalContext.current
val scrollState = rememberScrollState()
var remarks by rememberSaveable { mutableStateOf(initial?.remarks ?: "") }
var locked by rememberSaveable { mutableStateOf(initial?.locked == true) }
var domain by rememberSaveable { mutableStateOf(initial?.domain?.joinToString(",") ?: "") }
var ip by rememberSaveable { mutableStateOf(initial?.ip?.joinToString(",") ?: "") }
var processText by rememberSaveable { mutableStateOf(initial?.process?.joinToString(",") ?: "") }
var protocol by rememberSaveable { mutableStateOf(initial?.protocol?.joinToString(",") ?: "") }
var network by rememberSaveable { mutableStateOf(initial?.network ?: "") }
var port by rememberSaveable { mutableStateOf(initial?.port ?: "") }
var outboundTag by rememberSaveable {
mutableStateOf(initial?.outboundTag ?: BUILTIN_OUTBOUND_TAGS.first())
}
var showDeleteConfirm by rememberSaveable { mutableStateOf(false) }
val processPickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.StartActivityForResult()
) { result ->
if (result.resultCode == Activity.RESULT_OK) {
val selectedPackages = AppPickerActivity.getSelectedPackages(result.data)
processText = selectedPackages.joinToString(",")
}
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.del_config -> {
deleteServer()
true
fun buildRuleset(): RulesetItem {
val rulesetItem = SettingsManager.getRoutingRuleset(position) ?: RulesetItem()
rulesetItem.apply {
this.remarks = remarks
this.locked = locked
this.domain = domain.nullIfBlank()
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.takeIf { it.isNotEmpty() }
this.ip = ip.nullIfBlank()
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.takeIf { it.isNotEmpty() }
this.process = processText.nullIfBlank()
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.takeIf { it.isNotEmpty() }
this.protocol = protocol.nullIfBlank()
?.split(",")
?.map { it.trim() }
?.filter { it.isNotEmpty() }
?.takeIf { it.isNotEmpty() }
this.port = port.nullIfBlank()
this.network = network.nullIfBlank()
this.outboundTag = outboundTag.trim().ifEmpty { TAG_PROXY }
}
return rulesetItem
}
R.id.save_config -> {
saveServer()
true
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.routing_settings_rule_title),
onBackClick = onBackClick,
actions = {
if (position >= 0) {
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(
painterResource(R.drawable.ic_delete_24dp),
contentDescription = stringResource(R.string.menu_item_del_config)
)
}
}
IconButton(onClick = { onSave(buildRuleset()) }) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.menu_item_save_config)
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(scrollState)
.verticalScrollbar(scrollState)
.padding(vertical = 8.dp)
) {
FormTextField(
label = stringResource(R.string.sub_setting_remarks),
value = remarks,
onValueChange = { remarks = it }
)
SettingsSwitchItem(
title = stringResource(R.string.routing_settings_locked),
checked = locked,
onCheckedChange = { locked = it }
)
FormTextField(
label = stringResource(R.string.routing_settings_domain),
placeholder = stringResource(R.string.routing_settings_tips),
value = domain,
onValueChange = { domain = it }
)
FormTextField(
label = stringResource(R.string.routing_settings_ip),
placeholder = stringResource(R.string.routing_settings_tips),
value = ip,
onValueChange = { ip = it }
)
FormTextField(
label = stringResource(R.string.routing_settings_process),
placeholder = stringResource(R.string.routing_settings_tips),
value = processText,
onValueChange = { processText = it },
enabled = canUseProcess
)
if (canUseProcess) {
TextButton(
onClick = {
val current = processText
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.distinct()
processPickerLauncher.launch(
AppPickerActivity.createIntent(
context = context,
selectedPackages = current,
title = context.getString(R.string.routing_settings_process)
)
)
},
modifier = Modifier.padding(start = 16.dp)
) {
Icon(
painterResource(R.drawable.ic_per_apps_24dp),
contentDescription = null
)
Spacer(modifier = Modifier.width(8.dp))
Text(stringResource(R.string.routing_settings_process))
}
}
FormTextField(
label = stringResource(R.string.routing_settings_port),
value = port,
onValueChange = { port = it }
)
FormTextField(
label = stringResource(R.string.routing_settings_protocol),
placeholder = stringResource(R.string.routing_settings_protocol_tip),
value = protocol,
onValueChange = { protocol = it }
)
FormTextField(
label = stringResource(R.string.routing_settings_network),
placeholder = stringResource(R.string.routing_settings_network_tip),
value = network,
onValueChange = { network = it }
)
FormDropdownField(
label = stringResource(R.string.routing_settings_outbound_tag),
value = outboundTag,
options = outboundSuggestions,
onValueChange = { outboundTag = it },
editable = true
)
Spacer(modifier = Modifier.height(36.dp))
}
else -> super.onOptionsItemSelected(item)
if (showDeleteConfirm) {
ConfirmDialog(
message = stringResource(R.string.del_config_comfirm),
confirmText = stringResource(android.R.string.ok),
dismissText = stringResource(android.R.string.cancel),
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
}
}
}
@@ -1,140 +1,139 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
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
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
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
import com.v2ray.ang.compose.AppDivider
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.ReorderableListItem
import com.v2ray.ang.compose.SelectListDialog
import com.v2ray.ang.compose.colorConfigType
import com.v2ray.ang.compose.colorFabActive
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.JsonUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
class RoutingSettingActivity : HelperBaseActivity() {
private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
private val ownerActivity: RoutingSettingActivity
get() = this
class RoutingSettingActivity : HelperBaseComponentActivity() {
private val viewModel: RoutingSettingsViewModel by viewModels()
private lateinit var adapter: RoutingSettingRecyclerAdapter
private var mItemTouchHelper: ItemTouchHelper? = null
private val routing_domain_strategy: Array<out String> by lazy {
resources.getStringArray(R.array.routing_domain_strategy)
}
private val preset_rulesets: Array<out String> by lazy {
resources.getStringArray(R.array.preset_rulesets)
}
private val domainStrategyState = MutableStateFlow("")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title))
adapter = RoutingSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
binding.tvDomainStrategySummary.text = getDomainStrategy()
binding.layoutDomainStrategy.setOnClickListener {
setDomainStrategy()
domainStrategyState.value = getDomainStrategy()
}
@Composable
override fun ScreenContent() {
RoutingSettingScreen(
viewModel = viewModel,
domainStrategyState = domainStrategyState,
onBackClick = { finish() },
onAddRule = { startActivity(Intent(this, RoutingEditActivity::class.java)) },
onEditRule = { position ->
startActivity(Intent(this, RoutingEditActivity::class.java).putExtra("position", position))
},
onDomainStrategySelected = { value ->
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
domainStrategyState.value = value
},
onImportPredefined = { index -> importPredefined(index) },
onImportClipboard = { importFromClipboard() },
onImportQRcode = { importQRcode() },
onExportClipboard = { export2Clipboard() }
)
}
override fun onResume() {
super.onResume()
refreshData()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_routing_setting, menu)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.add_rule -> startActivity(Intent(this, RoutingEditActivity::class.java)).let { true }
R.id.import_predefined_rulesets -> importPredefined().let { true }
R.id.import_rulesets_from_clipboard -> importFromClipboard().let { true }
R.id.import_rulesets_from_qrcode -> importQRcode()
R.id.export_rulesets_to_clipboard -> export2Clipboard().let { true }
else -> super.onOptionsItemSelected(item)
viewModel.reload()
}
private fun getDomainStrategy(): String {
return MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: routing_domain_strategy.first()
val strategies = resources.getStringArray(R.array.routing_domain_strategy)
return MmkvManager.decodeSettingsString(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY) ?: strategies.first()
}
private fun setDomainStrategy() {
android.app.AlertDialog.Builder(this).setItems(routing_domain_strategy.asList().toTypedArray()) { _, i ->
try {
val value = routing_domain_strategy[i]
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
binding.tvDomainStrategySummary.text = value
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to set domain strategy", e)
}
}.show()
}
private fun importPredefined() {
AlertDialog.Builder(this).setItems(preset_rulesets.asList().toTypedArray()) { _, i ->
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
.setPositiveButton(android.R.string.ok) { _, _ ->
try {
private fun importPredefined(index: Int) {
lifecycleScope.launch(Dispatchers.IO) {
SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, i)
try {
SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, index)
launch(Dispatchers.Main) {
refreshData()
viewModel.reload()
toastSuccess(R.string.toast_success)
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to import predefined ruleset", e)
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do nothing
}
.show()
}.show()
}
private fun importFromClipboard() {
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
.setPositiveButton(android.R.string.ok) { _, _ ->
val clipboard = try {
Utils.getClipboard(this)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to get clipboard content", e)
toastError(R.string.toast_failure)
return@setPositiveButton
return
}
lifecycleScope.launch(Dispatchers.IO) {
val result = SettingsManager.resetRoutingRulesets(clipboard)
withContext(Dispatchers.Main) {
if (result) {
refreshData()
viewModel.reload()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
@@ -142,19 +141,23 @@ class RoutingSettingActivity : HelperBaseActivity() {
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do nothing
}
.show()
}
private fun importQRcode(): Boolean {
private fun importQRcode() {
launchQRCodeScanner { scanResult ->
if (scanResult != null) {
importRulesetsFromQRcode(scanResult)
lifecycleScope.launch(Dispatchers.IO) {
val result = SettingsManager.resetRoutingRulesets(scanResult)
withContext(Dispatchers.Main) {
if (result) {
viewModel.reload()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
}
}
}
}
return true
}
private fun export2Clipboard() {
@@ -166,52 +169,220 @@ class RoutingSettingActivity : HelperBaseActivity() {
toastSuccess(R.string.toast_success)
}
}
private fun importRulesetsFromQRcode(qrcode: String?): Boolean {
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
.setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
val result = SettingsManager.resetRoutingRulesets(qrcode)
withContext(Dispatchers.Main) {
if (result) {
refreshData()
toastSuccess(R.string.toast_success)
} else {
toastError(R.string.toast_failure)
}
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do nothing
}
.show()
return true
}
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
viewModel.reload()
adapter.notifyDataSetChanged()
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RoutingSettingScreen(
viewModel: RoutingSettingsViewModel,
domainStrategyState: MutableStateFlow<String>,
onBackClick: () -> Unit,
onAddRule: () -> Unit,
onEditRule: (Int) -> Unit,
onDomainStrategySelected: (String) -> Unit,
onImportPredefined: (Int) -> Unit,
onImportClipboard: () -> Unit,
onImportQRcode: () -> Unit,
onExportClipboard: () -> Unit
) {
val rulesets by viewModel.rulesetsFlow.collectAsStateWithLifecycle()
val domainStrategy by domainStrategyState.collectAsState()
var showMenu by remember { mutableStateOf(false) }
var showDomainDialog by remember { mutableStateOf(false) }
var showPresetDialog by remember { mutableStateOf(false) }
val domainStrategies = stringArrayResource(R.array.routing_domain_strategy).toList()
val presetRulesets = stringArrayResource(R.array.preset_rulesets).toList()
val lazyListState = rememberLazyListState()
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
viewModel.swap(from.index - 1, to.index - 1)
}
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
startActivity(
Intent(ownerActivity, RoutingEditActivity::class.java)
.putExtra("position", position)
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.routing_settings_title),
onBackClick = onBackClick,
actions = {
IconButton(onClick = onAddRule) {
Icon(
painterResource(R.drawable.ic_add_24dp),
contentDescription = stringResource(R.string.routing_settings_add_rule)
)
}
Box {
IconButton(onClick = { showMenu = true }) {
Icon(
painterResource(R.drawable.ic_more_vert_24dp),
contentDescription = null
)
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false },
containerColor = MaterialTheme.colorScheme.surface
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.routing_settings_import_predefined_rulesets)) },
onClick = { showMenu = false; showPresetDialog = true }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.routing_settings_import_rulesets_from_clipboard)) },
onClick = { showMenu = false; onImportClipboard() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.routing_settings_import_rulesets_from_qrcode)) },
onClick = { showMenu = false; onImportQRcode() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.routing_settings_export_rulesets_to_clipboard)) },
onClick = { showMenu = false; onExportClipboard() }
)
}
}
}
)
}
) { innerPadding ->
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScrollbar(lazyListState)
) {
item(key = "domain_strategy") {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { showDomainDialog = true }
.padding(horizontal = 16.dp, vertical = 12.dp)
) {
Text(
stringResource(R.string.routing_settings_domain_strategy),
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(4.dp))
Text(
domainStrategy,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
AppDivider(modifier = Modifier.padding(horizontal = 16.dp))
}
itemsIndexed(
items = rulesets,
key = { _, ruleset -> ruleset.id }
) { index, ruleset ->
ReorderableItem(reorderableState, key = ruleset.id) { isDragging ->
ReorderableListItem(
scope = this,
isDragging = isDragging
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = ruleset.remarks ?: "",
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (ruleset.locked == true) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
painter = painterResource(R.drawable.ic_lock_24dp),
contentDescription = "Locked",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
val domainIpInfo = (ruleset.domain ?: ruleset.ip ?: ruleset.process ?: ruleset.port)?.toString() ?: ""
if (domainIpInfo.isNotEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = domainIpInfo,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
if (!ruleset.outboundTag.isNullOrEmpty()) {
Spacer(modifier = Modifier.height(4.dp))
Text(
text = ruleset.outboundTag,
style = MaterialTheme.typography.labelMedium,
color = colorConfigType
)
}
}
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.padding(start = 8.dp)
) {
IconButton(onClick = { onEditRule(index) }) {
Icon(
painter = painterResource(R.drawable.ic_edit_24dp),
contentDescription = "Edit"
)
}
Spacer(modifier = Modifier.height(4.dp))
Switch(
checked = ruleset.enabled ?: false,
onCheckedChange = { checked ->
val updated = ruleset.copy(enabled = checked)
viewModel.update(index, updated)
},
modifier = Modifier.scale(0.7f),
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
checkedTrackColor = colorFabActive
)
)
}
}
}
AppDivider(modifier = Modifier.padding(horizontal = 14.dp))
}
}
}
}
if (showDomainDialog) {
SelectListDialog(
title = stringResource(R.string.routing_settings_domain_strategy),
options = domainStrategies,
selectedOption = domainStrategy,
showRadio = true,
onSelected = { _, value ->
onDomainStrategySelected(value)
showDomainDialog = false
},
onDismiss = { showDomainDialog = false }
)
}
override fun onRemove(guid: String, position: Int) {
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
refreshData()
}
if (showPresetDialog) {
SelectListDialog(
title = stringResource(R.string.routing_settings_import_predefined_rulesets),
options = presetRulesets,
onSelected = { index, _ ->
showPresetDialog = false
onImportPredefined(index)
},
onDismiss = { showPresetDialog = false }
)
}
}
@@ -1,80 +0,0 @@
package com.v2ray.ang.ui
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
class RoutingSettingRecyclerAdapter(
private val viewModel: RoutingSettingsViewModel,
private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(),
ItemTouchHelperAdapter {
override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
val rulesets = viewModel.getAll()
val ruleset = rulesets[position]
holder.itemRoutingSettingBinding.remarks.text = ruleset.remarks
holder.itemRoutingSettingBinding.domainIp.text = (ruleset.domain ?: ruleset.ip ?: ruleset.process ?: ruleset.port)?.toString()
holder.itemRoutingSettingBinding.outboundTag.text = ruleset.outboundTag
holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled
holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
adapterListener?.onEdit("", position)
}
holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
if (!it.isPressed) return@setOnCheckedChangeListener
ruleset.enabled = isChecked
viewModel.update(position, ruleset)
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
return MainViewHolder(
ItemRecyclerRoutingSettingBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
class MainViewHolder(val itemRoutingSettingBinding: ItemRecyclerRoutingSettingBinding) :
BaseViewHolder(itemRoutingSettingBinding.root), ItemTouchHelperViewHolder
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun onItemSelected() {
itemView.setBackgroundColor(Color.LTGRAY)
}
fun onItemClear() {
itemView.setBackgroundColor(0)
}
}
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
viewModel.swap(fromPosition, toPosition)
notifyItemMoved(fromPosition, toPosition)
return true
}
override fun onItemMoveCompleted() {
adapterListener?.onRefreshData()
}
override fun onItemDismiss(position: Int) {
}
}
@@ -2,18 +2,25 @@ package com.v2ray.ang.ui
import android.content.Intent
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.v2ray.ang.R
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.AngConfigManager
class ScScannerActivity : HelperBaseActivity() {
class ScScannerActivity : HelperBaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_none)
}
@Composable
override fun ScreenContent() {
LaunchedEffect(Unit) {
importQRcode()
}
}
private fun importQRcode() {
launchQRCodeScanner { scanResult ->
@@ -1,20 +1,24 @@
package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.v2ray.ang.core.CoreServiceManager
class ScStartActivity : BaseActivity() {
class ScStartActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
@Composable
override fun ScreenContent() {
LaunchedEffect(Unit) {
moveTaskToBack(true)
setContentView(R.layout.activity_none)
if (!CoreServiceManager.isRunning()) {
CoreServiceManager.startVServiceFromToggle(this)
CoreServiceManager.startVServiceFromToggle(this@ScStartActivity)
}
finish()
}
}
}
@@ -1,19 +1,24 @@
package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.v2ray.ang.core.CoreServiceManager
class ScStopActivity : BaseActivity() {
class ScStopActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
@Composable
override fun ScreenContent() {
LaunchedEffect(Unit) {
moveTaskToBack(true)
setContentView(R.layout.activity_none)
if (CoreServiceManager.isRunning()) {
CoreServiceManager.stopVService(this)
CoreServiceManager.stopVService(this@ScStopActivity)
}
finish()
}
}
}
@@ -1,21 +1,26 @@
package com.v2ray.ang.ui
import android.os.Bundle
import com.v2ray.ang.R
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import com.v2ray.ang.core.CoreServiceManager
class ScSwitchActivity : BaseActivity() {
class ScSwitchActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
}
@Composable
override fun ScreenContent() {
LaunchedEffect(Unit) {
moveTaskToBack(true)
setContentView(R.layout.activity_none)
if (CoreServiceManager.isRunning()) {
CoreServiceManager.stopVService(this)
CoreServiceManager.stopVService(this@ScSwitchActivity)
} else {
CoreServiceManager.startVServiceFromToggle(this)
CoreServiceManager.startVServiceFromToggle(this@ScSwitchActivity)
}
finish()
}
}
}
@@ -1,56 +1,119 @@
package com.v2ray.ang.ui
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.BitmapFactory
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import android.util.Size as TargetSize
import androidx.camera.compose.CameraXViewfinder
import androidx.camera.core.Camera
import androidx.camera.core.CameraControl
import androidx.camera.core.CameraInfo
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import androidx.camera.core.Preview
import androidx.camera.core.SurfaceRequest
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.resolutionselector.ResolutionStrategy
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.viewfinder.compose.MutableCoordinateTransformer
import androidx.camera.viewfinder.core.ImplementationMode
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
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.DisposableEffect
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import androidx.lifecycle.compose.LocalLifecycleOwner
import com.google.zxing.BarcodeFormat
import com.google.zxing.BinaryBitmap
import com.google.zxing.DecodeHintType
import com.google.zxing.MultiFormatReader
import com.google.zxing.PlanarYUVLuminanceSource
import com.google.zxing.common.HybridBinarizer
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityNoneBinding
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.enums.PermissionType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import io.github.g00fy2.quickie.QRResult
import io.github.g00fy2.quickie.ScanCustomCode
import io.github.g00fy2.quickie.config.BarcodeFormat
import io.github.g00fy2.quickie.config.ScannerConfig
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean
class ScannerActivity : HelperBaseActivity() {
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
private val qrReader = MultiFormatReader()
private val scanQrCode = registerForActivityResult(ScanCustomCode(), ::handleResult)
class ScannerActivity : HelperBaseComponentActivity() {
public override fun onCreate(savedInstanceState: Bundle?) {
private val uiState = mutableStateOf(ScannerUiState.IDLE)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.menu_item_import_config_qrcode))
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE)) {
launchScan()
val startImmediate = MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE, false)
if (ContextCompat.checkSelfPermission(
this,
PermissionType.CAMERA.getPermission()
) == PackageManager.PERMISSION_GRANTED && startImmediate
) {
uiState.value = ScannerUiState.ACTIVE
}
}
private fun launchScan() {
scanQrCode.launch(
ScannerConfig.build {
setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected
setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle button
setShowCloseButton(true) // show or hide (default) close button
setBarcodeFormats(listOf(BarcodeFormat.QR_CODE))
}
@Composable
override fun ScreenContent() {
ScannerScreen(
uiState = uiState.value,
onBackClick = { finish() },
onSelectPhoto = { showFileChooser() },
onStartScan = { startScan() },
onStopScan = { stopScan() },
onScanResult = { text -> finished(text) }
)
}
private fun handleResult(result: QRResult) {
if (result is QRResult.QRSuccess) {
finished(result.content.rawValue.orEmpty())
} else {
finish()
private fun startScan() {
checkAndRequestPermission(PermissionType.CAMERA) {
uiState.value = ScannerUiState.ACTIVE
}
}
private fun stopScan() {
uiState.value = ScannerUiState.IDLE
}
private fun finished(text: String) {
val intent = Intent()
intent.putExtra("SCAN_RESULT", text)
@@ -58,36 +121,13 @@ class ScannerActivity : HelperBaseActivity() {
finish()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_scanner, menu)
return true
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.scan_code -> {
launchScan()
true
}
R.id.select_photo -> {
showFileChooser()
true
}
else -> super.onOptionsItemSelected(item)
}
private fun showFileChooser() {
launchFileChooser("image/*") { uri ->
if (uri == null) {
return@launchFileChooser
}
if (uri == null) return@launchFileChooser
try {
val inputStream = contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
if (text.isNullOrEmpty()) {
toast(R.string.toast_decoding_failed)
@@ -101,3 +141,307 @@ class ScannerActivity : HelperBaseActivity() {
}
}
}
enum class ScannerUiState {
IDLE,
ACTIVE
}
@Composable
fun ScannerScreen(
uiState: ScannerUiState,
onBackClick: () -> Unit,
onSelectPhoto: () -> Unit,
onStartScan: () -> Unit,
onStopScan: () -> Unit,
onScanResult: (String) -> Unit
) {
val isScanning = uiState == ScannerUiState.ACTIVE
var cameraControl by remember { mutableStateOf<CameraControl?>(null) }
var hasTorch by remember { mutableStateOf(false) }
var torchEnabled by rememberSaveable { mutableStateOf(false) }
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.menu_item_import_config_qrcode),
onBackClick = onBackClick,
actions = {
IconButton(
onClick = {
if (isScanning) {
if (torchEnabled) {
torchEnabled = false
cameraControl?.enableTorch(false)
}
onStopScan()
} else {
onStartScan()
}
}
) {
Icon(
painterResource(
if (isScanning) R.drawable.ic_stop_24dp
else R.drawable.ic_scan_24dp
),
contentDescription = if (isScanning) "stop scan" else "start scan"
)
}
if (isScanning && hasTorch) {
IconButton(
onClick = {
torchEnabled = !torchEnabled
cameraControl?.enableTorch(torchEnabled)
}
) {
Icon(
painterResource(
if (torchEnabled) R.drawable.ic_flash_on_24dp
else R.drawable.ic_flash_off_24dp
),
contentDescription = "Torch"
)
}
}
IconButton(onClick = onSelectPhoto) {
Icon(
painterResource(R.drawable.ic_image_24dp),
contentDescription = "select image"
)
}
}
)
}
) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
) {
when (uiState) {
ScannerUiState.IDLE -> ScannerIdlePlaceholder(onStartClick = onStartScan)
ScannerUiState.ACTIVE -> {
CameraXPreview(
onScanResult = onScanResult,
onCameraReady = { control, info ->
cameraControl = control
hasTorch = info.hasFlashUnit()
if (torchEnabled && hasTorch) {
control.enableTorch(true)
}
}
)
ScannerOverlay()
}
}
}
}
}
@Composable
private fun ScannerIdlePlaceholder(onStartClick: () -> Unit) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.pointerInput(Unit) { detectTapGestures { onStartClick() } },
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Icon(
painter = painterResource(R.drawable.ic_scan_24dp),
contentDescription = "Start Scanner",
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
Text(
text = stringResource(R.string.menu_item_scan_qrcode),
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = stringResource(R.string.summary_scan_qrcode),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
fun ScannerOverlay() {
val scanBoxSize = 250.dp
val cornerLength = 24.dp
val cornerWidth = 3.dp
val cornerColor = Color(0xFF4CAF50)
Box(modifier = Modifier.fillMaxSize()) {
Canvas(modifier = Modifier.fillMaxSize()) {
val canvasWidth = size.width
val canvasHeight = size.height
val boxSizePx = scanBoxSize.toPx()
val left = (canvasWidth - boxSizePx) / 2f
val top = (canvasHeight - boxSizePx) / 2f
drawRect(color = Color(0x80000000))
drawRect(
color = Color.Transparent,
topLeft = Offset(left, top),
size = Size(boxSizePx, boxSizePx),
blendMode = BlendMode.Clear
)
}
Box(
modifier = Modifier
.size(scanBoxSize)
.align(Alignment.Center)
) {
listOf(
Alignment.TopStart,
Alignment.TopEnd,
Alignment.BottomStart,
Alignment.BottomEnd
).forEach { align ->
Box(
modifier = Modifier
.align(align)
.width(cornerLength)
.height(cornerWidth)
.background(cornerColor)
)
Box(
modifier = Modifier
.align(align)
.width(cornerWidth)
.height(cornerLength)
.background(cornerColor)
)
}
}
Text(
text = stringResource(R.string.menu_item_scan_qrcode),
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
modifier = Modifier
.align(Alignment.Center)
.offset(y = scanBoxSize / 2 + 24.dp)
)
}
}
@Composable
fun CameraXPreview(
onScanResult: (String) -> Unit,
onCameraReady: (CameraControl, CameraInfo) -> Unit
) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val foundResult = remember { AtomicBoolean(false) }
var surfaceRequest by remember { mutableStateOf<SurfaceRequest?>(null) }
DisposableEffect(lifecycleOwner) {
val analysisExecutor = Executors.newSingleThreadExecutor()
var cameraProvider: ProcessCameraProvider? = null
var camera: Camera? = null
val resolutionSelector = ResolutionSelector.Builder()
.setResolutionStrategy(
ResolutionStrategy(
TargetSize(1280, 720),
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER
)
)
.build()
val imageAnalysis = ImageAnalysis.Builder()
.setResolutionSelector(resolutionSelector)
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.build()
.apply {
setAnalyzer(analysisExecutor) { imageProxy ->
processImageProxy(imageProxy, foundResult, onScanResult)
}
}
val preview = Preview.Builder().build()
preview.setSurfaceProvider { request -> surfaceRequest = request }
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
cameraProviderFuture.addListener(
{
try {
cameraProvider = cameraProviderFuture.get()
cameraProvider?.unbindAll()
camera = cameraProvider?.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
preview,
imageAnalysis
)
camera?.let {
onCameraReady(it.cameraControl, it.cameraInfo)
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "CameraX bind failed", e)
}
},
ContextCompat.getMainExecutor(context)
)
onDispose {
cameraProvider?.unbindAll()
analysisExecutor.shutdownNow()
}
}
surfaceRequest?.let { request ->
val coordinateTransformer = remember { MutableCoordinateTransformer() }
CameraXViewfinder(
surfaceRequest = request,
implementationMode = ImplementationMode.EXTERNAL,
coordinateTransformer = coordinateTransformer,
modifier = Modifier.fillMaxSize()
)
}
}
private fun processImageProxy(
imageProxy: ImageProxy,
foundResult: AtomicBoolean,
onResult: (String) -> Unit
) {
if (foundResult.get()) {
imageProxy.close()
return
}
try {
val buffer = imageProxy.planes[0].buffer
val bytes = ByteArray(buffer.remaining())
buffer.get(bytes)
val width = imageProxy.width
val height = imageProxy.height
val source = PlanarYUVLuminanceSource(
bytes, width, height,
0, 0, width, height,
false
)
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
val hints = mapOf(
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
DecodeHintType.TRY_HARDER to true,
DecodeHintType.CHARACTER_SET to "UTF-8"
)
val result = qrReader.decode(binaryBitmap, hints)
val text = result.text
if (!text.isNullOrEmpty() && foundResult.compareAndSet(false, true)) {
onResult(text)
}
} catch (_: Exception) {
// do nothing
} finally {
imageProxy.close()
}
}
File diff suppressed because it is too large Load Diff
@@ -1,15 +1,66 @@
package com.v2ray.ang.ui
import android.app.Activity
import android.os.Bundle
import android.text.TextUtils
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import com.blacksquircle.ui.editorkit.utils.EditorTheme
import com.blacksquircle.ui.language.json.JsonLanguage
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
import androidx.compose.foundation.text.selection.TextSelectionColors
import androidx.compose.foundation.verticalScroll
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
import androidx.compose.runtime.LaunchedEffect
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.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.drawText
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.rememberTextMeasurer
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.ConfirmDialog
import com.v2ray.ang.compose.FormTextField
import com.v2ray.ang.compose.horizontalScrollbar
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
@@ -17,12 +68,10 @@ import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.fmt.CustomFmt
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.flow.collectLatest
class ServerCustomConfigActivity : BaseActivity() {
private val binding by lazy { ActivityServerCustomConfigBinding.inflate(layoutInflater) }
class ServerCustomConfigActivity : BaseComponentActivity() {
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
private val isRunning by lazy {
@@ -31,116 +80,406 @@ class ServerCustomConfigActivity : BaseActivity() {
&& editGuid == MmkvManager.getSelectServer()
}
private var initialRemarks: String = ""
private var initialContent: String = ""
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.CUSTOM.toString())
if (!Utils.getDarkModeStatus(this)) {
binding.editor.colorScheme = EditorTheme.INTELLIJ_LIGHT
}
binding.editor.language = JsonLanguage()
val config = MmkvManager.decodeServerConfig(editGuid)
if (config != null) {
bindingServer(config)
} else {
clearServer()
}
initialRemarks = config?.remarks ?: ""
initialContent = MmkvManager.decodeServerRaw(editGuid).orEmpty()
super.onCreate(savedInstanceState)
}
/**
* Binding selected server config
*/
private fun bindingServer(config: ProfileItem): Boolean {
binding.etRemarks.text = Utils.getEditable(config.remarks)
val raw = MmkvManager.decodeServerRaw(editGuid)
val configContent = raw.orEmpty()
binding.editor.setTextContent(Utils.getEditable(configContent))
return true
@Composable
override fun ScreenContent() {
ServerCustomConfigScreen(
editGuid = editGuid,
isRunning = isRunning,
initialRemarks = initialRemarks,
initialContent = initialContent,
onBackClick = { finish() },
onSave = { remarks, content -> saveServer(remarks, content) },
onDelete = { deleteServer() }
)
}
/**
* clear or init server config
*/
private fun clearServer(): Boolean {
binding.etRemarks.text = null
return true
}
/**
* save server config
*/
private fun saveServer(): Boolean {
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
private fun saveServer(
remarks: String,
content: String
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
val profileItem = try {
CustomFmt.parse(binding.editor.text.toString())
val parsedProfile = try {
CustomFmt.parse(content)
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to parse custom configuration", e)
toast("${getString(R.string.toast_malformed_josn)} ${e.cause?.message}")
LogUtil.e(
AppConfig.TAG,
"Failed to parse custom configuration",
e
)
toast(
"${getString(R.string.toast_malformed_josn)} " +
"${e.cause?.message.orEmpty()}"
)
return false
}
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.CUSTOM)
binding.etRemarks.text.let {
config.remarks = if (it.isNullOrEmpty()) profileItem?.remarks.orEmpty() else it.toString()
}
config.server = profileItem?.server
config.serverPort = profileItem?.serverPort
config.description = AngConfigManager.generateDescription(config)
val config =
MmkvManager.decodeServerConfig(editGuid)
?: ProfileItem.create(EConfigType.CUSTOM)
config.remarks =
remarks.ifEmpty { parsedProfile?.remarks.orEmpty() }
config.server = parsedProfile?.server
config.serverPort = parsedProfile?.serverPort
config.description =
AngConfigManager.generateDescription(config)
val savedGuid = MmkvManager.encodeServerConfig(
editGuid,
config
)
MmkvManager.encodeServerRaw(
savedGuid,
content
)
MmkvManager.encodeServerConfig(editGuid, config)
MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
if (isRunning) {
SettingsChangeManager.makeRestartService()
}
toastSuccess(R.string.toast_success)
finish()
ProfileEditorResult.run {
finishSaved(
guid = savedGuid,
restartService = isRunning
)
}
return true
}
/**
* save server config
*/
private fun deleteServer(): Boolean {
if (editGuid.isNotEmpty()) {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
if (editGuid.isEmpty()) {
return false
}
if (editGuid == MmkvManager.getSelectServer()) {
toast(R.string.toast_action_not_allowed)
return false
}
MmkvManager.removeServer(editGuid)
finish()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
// do nothing
}
.show()
ProfileEditorResult.run {
finishDeleted(editGuid)
}
return true
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
val delButton = menu.findItem(R.id.del_config)
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.del_config -> {
deleteServer()
true
private object EditorConstants {
val FONT_SIZE = 14.sp
val LINE_HEIGHT = 20.sp
val LINE_NUMBER_HORIZONTAL_PADDING = 8.dp
val SCROLLBAR_THICKNESS = 4.dp
val SCROLLBAR_PADDING = 2.dp
val SCROLL_PADDING = 60.dp
}
R.id.save_config -> {
saveServer()
true
@Composable
fun ServerCustomConfigScreen(
editGuid: String,
isRunning: Boolean,
initialRemarks: String,
initialContent: String,
onBackClick: () -> Unit,
onSave: (String, String) -> Boolean,
onDelete: () -> Unit
) {
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
val textFieldState = rememberTextFieldState(initialText = initialContent)
var showDeleteConfirm by remember { mutableStateOf(false) }
val showDelete = editGuid.isNotEmpty() && !isRunning
val verticalScroll = rememberScrollState()
val horizontalScroll = rememberScrollState()
val density = LocalDensity.current
var textLayoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
var hasInitializedCursor by rememberSaveable { mutableStateOf(false) }
LaunchedEffect(Unit) {
if (!hasInitializedCursor) {
textFieldState.edit {
selection = TextRange(0, 0)
}
hasInitializedCursor = true
}
}
else -> super.onOptionsItemSelected(item)
val textMeasurer = rememberTextMeasurer()
val lineCount = remember(textFieldState.text.toString()) {
textFieldState.text.toString().count { it == '\n' } + 1
}
val lineNumberStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = EditorConstants.FONT_SIZE,
lineHeight = EditorConstants.LINE_HEIGHT,
)
val lineNumberWidth: Dp = remember(lineCount, density) {
val sampleText = lineCount.toString()
val measured = textMeasurer.measure(
text = sampleText,
style = lineNumberStyle,
)
with(density) {
measured.size.width.toDp() +
EditorConstants.LINE_NUMBER_HORIZONTAL_PADDING * 2
}
}
LaunchedEffect(textFieldState, verticalScroll, horizontalScroll) {
snapshotFlow {
Triple(
textFieldState.selection,
verticalScroll.viewportSize,
horizontalScroll.viewportSize
)
}.collectLatest { (selection, _, _) ->
val layout = textLayoutResult ?: return@collectLatest
val cursor = selection.start
val textLen = layout.layoutInput.text.length
if (textLen == 0 || cursor < 0 || cursor > textLen) return@collectLatest
val line = layout.getLineForOffset(cursor)
val lineTop = layout.getLineTop(line)
val lineBottom = layout.getLineBottom(line)
val vh = verticalScroll.viewportSize.toFloat()
if (vh > 0f) {
val scrollY = verticalScroll.value.toFloat()
val pad = with(density) { EditorConstants.SCROLL_PADDING.toPx() }
val targetY = when {
lineBottom > scrollY + vh - pad ->
(lineBottom - vh + pad).toInt()
lineTop < scrollY + pad ->
(lineTop - pad).toInt()
else -> null
}
targetY?.let {
verticalScroll.animateScrollTo(
it.coerceIn(0, verticalScroll.maxValue)
)
}
}
val cursorX = layout.getHorizontalPosition(cursor, true)
val vw = horizontalScroll.viewportSize.toFloat()
if (vw > 0f) {
val scrollX = horizontalScroll.value.toFloat()
val pad = with(density) { EditorConstants.SCROLL_PADDING.toPx() }
val targetX = when {
cursorX < scrollX + pad ->
(cursorX - pad).toInt().coerceAtLeast(0)
cursorX > scrollX + vw - pad ->
(cursorX - vw + pad).toInt()
.coerceAtMost(horizontalScroll.maxValue)
else -> null
}
targetX?.let {
horizontalScroll.animateScrollTo(it)
}
}
}
}
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = EConfigType.CUSTOM.toString(),
onBackClick = onBackClick,
actions = {
if (showDelete) {
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(
painterResource(R.drawable.ic_delete_24dp),
contentDescription = stringResource(R.string.menu_item_del_config)
)
}
}
IconButton(onClick = { onSave(remarks, textFieldState.text.toString()) }) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.menu_item_save_config)
)
}
}
)
},
) { padding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(padding)
) {
FormTextField(
label = stringResource(R.string.server_lab_remarks),
value = remarks,
onValueChange = { remarks = it }
)
Box(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
) {
Row(
modifier = Modifier
.fillMaxSize()
.verticalScroll(verticalScroll)
) {
val lineNumberColor =
MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
val layoutForLineNumbers = textLayoutResult
if (layoutForLineNumbers != null && layoutForLineNumbers.lineCount > 0) {
val totalTextHeight = layoutForLineNumbers.size.height
Canvas(
modifier = Modifier
.width(lineNumberWidth)
.height(with(density) { totalTextHeight.toDp() })
) {
val lc = layoutForLineNumbers.lineCount
for (i in 0 until lc) {
val lineLabel = (i + 1).toString()
val measured = textMeasurer.measure(
text = lineLabel,
style = lineNumberStyle.copy(
color = lineNumberColor,
textAlign = TextAlign.End,
),
)
val lineTop = layoutForLineNumbers.getLineTop(i)
val lineBaseline = layoutForLineNumbers.getLineBaseline(i)
val measuredBaseline = measured.firstBaseline
val yOffset = lineBaseline - measuredBaseline
val xOffset = size.width -
EditorConstants.LINE_NUMBER_HORIZONTAL_PADDING.toPx() -
measured.size.width
drawText(
textLayoutResult = measured,
topLeft = Offset(
x = xOffset.coerceAtLeast(0f),
y = yOffset
)
)
}
}
} else {
Box(
modifier = Modifier
.width(lineNumberWidth)
.padding(end = EditorConstants.LINE_NUMBER_HORIZONTAL_PADDING),
contentAlignment = Alignment.TopEnd
) {
Text(
text = "1",
style = lineNumberStyle.copy(color = lineNumberColor),
)
}
}
CompositionLocalProvider(
LocalTextSelectionColors provides TextSelectionColors(
handleColor = MaterialTheme.colorScheme.secondary,
backgroundColor = MaterialTheme.colorScheme.secondary.copy(
alpha = 0.4f
)
)
) {
BasicTextField(
state = textFieldState,
modifier = Modifier
.weight(1f)
.horizontalScroll(horizontalScroll)
.padding(end = 24.dp)
.padding(bottom = 36.dp),
textStyle = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = EditorConstants.FONT_SIZE,
lineHeight = EditorConstants.LINE_HEIGHT,
color = MaterialTheme.colorScheme.onSurface
),
lineLimits = TextFieldLineLimits.MultiLine(),
cursorBrush = SolidColor(MaterialTheme.colorScheme.secondary),
onTextLayout = { resultProvider ->
textLayoutResult = resultProvider()
},
decorator = { innerTextField ->
Box {
if (textFieldState.text.isEmpty()) {
Text(
text = "{ }",
style = TextStyle(
fontFamily = FontFamily.Monospace,
fontSize = EditorConstants.FONT_SIZE,
lineHeight = EditorConstants.LINE_HEIGHT,
color = MaterialTheme.colorScheme.onSurface.copy(
alpha = 0.38f
)
)
)
}
innerTextField()
}
}
)
}
}
Box(
modifier = Modifier
.align(Alignment.CenterEnd)
.fillMaxHeight()
.width(
EditorConstants.SCROLLBAR_THICKNESS +
EditorConstants.SCROLLBAR_PADDING * 2
)
.verticalScrollbar(scrollState = verticalScroll)
)
Box(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(
EditorConstants.SCROLLBAR_THICKNESS +
EditorConstants.SCROLLBAR_PADDING * 2
)
.horizontalScrollbar(scrollState = horizontalScroll)
)
}
}
}
if (showDeleteConfirm) {
ConfirmDialog(
message = stringResource(R.string.del_config_comfirm),
confirmText = stringResource(android.R.string.ok),
dismissText = stringResource(android.R.string.cancel),
onConfirm = {
showDeleteConfirm = false
onDelete()
},
onDismiss = { showDeleteConfirm = false }
)
}
}
@@ -1,24 +1,37 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.layout.Column
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
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityServerGroupBinding
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.ConfirmDialog
import com.v2ray.ang.compose.FormDropdownField
import com.v2ray.ang.compose.FormTextField
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isNotNullEmpty
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.util.Utils
class ServerGroupActivity : BaseActivity() {
private val binding by lazy { ActivityServerGroupBinding.inflate(layoutInflater) }
class ServerGroupActivity : BaseComponentActivity() {
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
private val isRunning by lazy {
@@ -26,145 +39,214 @@ class ServerGroupActivity : BaseActivity() {
&& editGuid.isNotEmpty()
&& editGuid == MmkvManager.getSelectServer()
}
private val subscriptionId by lazy {
intent.getStringExtra("subscriptionId")
}
private val subscriptionId by lazy { intent.getStringExtra("subscriptionId") }
private val subIds = mutableListOf<String>()
private val subDisplay = mutableListOf<String>()
private lateinit var initialRemarks: String
private lateinit var initialFilter: String
private var initialType: Int = 0
private var initialSubIndex: Int = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.POLICYGROUP.toString())
val config = MmkvManager.decodeServerConfig(editGuid)
populateSubscriptionSpinner()
if (config != null) {
bindingServer(config)
} else {
clearServer()
}
initialRemarks = config?.remarks ?: ""
initialFilter = config?.policyGroupFilter ?: ""
initialType = config?.policyGroupType?.toIntOrNull() ?: 0
initialSubIndex = if (config != null) {
subIds.indexOf(config.policyGroupSubscriptionId ?: "").let { if (it >= 0) it else 0 }
} else if (subscriptionId.isNotNullEmpty()) {
subIds.indexOf(subscriptionId).let { if (it >= 0) it else 0 }
} else 0
}
/**
* Binding selected server config
*/
private fun bindingServer(config: ProfileItem): Boolean {
binding.etRemarks.text = Utils.getEditable(config.remarks)
binding.etPolicyGroupFilter.text = Utils.getEditable(config.policyGroupFilter)
val type = config.policyGroupType?.toInt() ?: 0
binding.spPolicyGroupType.setSelection(type)
val pos = subIds.indexOf(config.policyGroupSubscriptionId ?: "").let { if (it >= 0) it else 0 }
binding.spPolicyGroupSubId.setSelection(pos)
return true
@Composable
override fun ScreenContent() {
ServerGroupScreen(
editGuid = editGuid,
isRunning = isRunning,
subDisplay = subDisplay,
initialRemarks = initialRemarks,
initialFilter = initialFilter,
initialType = initialType,
initialSubIndex = initialSubIndex,
onBackClick = { finish() },
onSave = { remarks, filter, typeIdx, subIdx -> saveServer(remarks, filter, typeIdx, subIdx) },
onDelete = { deleteServer() }
)
}
/**
* clear or init server config
*/
private fun clearServer(): Boolean {
binding.etRemarks.text = null
binding.etPolicyGroupFilter.text = null
if (subscriptionId.isNotNullEmpty()) {
val pos = subIds.indexOf(subscriptionId).let { if (it >= 0) it else 0 }
binding.spPolicyGroupSubId.setSelection(pos)
}
return true
}
/**
* save server config
*/
private fun saveServer(): Boolean {
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
private fun saveServer(
remarks: String,
filter: String,
typeIdx: Int,
subIdx: Int
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.POLICYGROUP)
config.remarks = binding.etRemarks.text.toString().trim()
config.policyGroupFilter = binding.etPolicyGroupFilter.text.toString().trim()
val config =
MmkvManager.decodeServerConfig(editGuid)
?: ProfileItem.create(EConfigType.POLICYGROUP)
config.policyGroupType = binding.spPolicyGroupType.selectedItemPosition.toString()
config.remarks = remarks.trim()
config.policyGroupFilter = filter.trim()
config.policyGroupType = typeIdx.toString()
config.policyGroupSubscriptionId =
subIds.getOrNull(subIdx)
val selPos = binding.spPolicyGroupSubId.selectedItemPosition
config.policyGroupSubscriptionId = if (selPos >= 0 && selPos < subIds.size) subIds[selPos] else null
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
if (
config.subscriptionId.isEmpty() &&
!subscriptionId.isNullOrEmpty()
) {
config.subscriptionId = subscriptionId.orEmpty()
}
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
val typeDisplay =
stringArrayPolicyGroupType()
.getOrNull(typeIdx)
.orEmpty()
MmkvManager.encodeServerConfig(editGuid, config)
if (isRunning) {
SettingsChangeManager.makeRestartService()
config.description = buildString {
append(typeDisplay)
append(" - ")
append(subDisplay.getOrNull(subIdx).orEmpty())
append(" - ")
append(config.policyGroupFilter)
}
val savedGuid = MmkvManager.encodeServerConfig(
editGuid,
config
)
toastSuccess(R.string.toast_success)
finish()
ProfileEditorResult.run {
finishSaved(
guid = savedGuid,
restartService = isRunning
)
}
return true
}
/**
* save server config
*/
private fun deleteServer(): Boolean {
if (editGuid.isNotEmpty()) {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
if (editGuid.isEmpty()) {
return false
}
if (editGuid == MmkvManager.getSelectServer()) {
toast(R.string.toast_action_not_allowed)
return false
}
MmkvManager.removeServer(editGuid)
finish()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
// do nothing
}
.show()
ProfileEditorResult.run {
finishDeleted(editGuid)
}
return true
}
private fun populateSubscriptionSpinner() {
val subs = MmkvManager.decodeSubscriptions()
val displayList = mutableListOf(getString(R.string.filter_config_all)) //none
subIds.clear()
subIds.add("") // index 0 => All
subDisplay.clear()
subDisplay.add(getString(R.string.filter_config_all))
subIds.add("")
subs.forEach { sub ->
val name = when {
sub.subscription.remarks.isNotBlank() -> sub.subscription.remarks
else -> sub.guid
}
displayList.add(name)
subDisplay.add(name)
subIds.add(sub.guid)
}
val subAdapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, displayList)
subAdapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
binding.spPolicyGroupSubId.adapter = subAdapter
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
val delButton = menu.findItem(R.id.del_config)
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
return super.onCreateOptionsMenu(menu)
private fun stringArrayPolicyGroupType(): Array<String> =
resources.getStringArray(R.array.policy_group_type)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.del_config -> {
deleteServer()
true
@Composable
fun ServerGroupScreen(
editGuid: String,
isRunning: Boolean,
subDisplay: List<String>,
initialRemarks: String,
initialFilter: String,
initialType: Int,
initialSubIndex: Int,
onBackClick: () -> Unit,
onSave: (String, String, Int, Int) -> Boolean,
onDelete: () -> Unit
) {
val typeEntries = stringArrayResource(R.array.policy_group_type).toList()
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
var filter by rememberSaveable { mutableStateOf(initialFilter) }
var typeValue by rememberSaveable { mutableStateOf(typeEntries.getOrNull(initialType).orEmpty()) }
var subValue by rememberSaveable { mutableStateOf(subDisplay.getOrNull(initialSubIndex).orEmpty()) }
var showDeleteConfirm by remember { mutableStateOf(false) }
val showDelete = editGuid.isNotEmpty() && !isRunning
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = EConfigType.POLICYGROUP.toString(),
onBackClick = onBackClick,
actions = {
if (showDelete) {
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(painterResource(R.drawable.ic_delete_24dp), contentDescription = stringResource(R.string.menu_item_del_config))
}
}
IconButton(onClick = {
val typeIdx = typeEntries.indexOf(typeValue).coerceAtLeast(0)
val subIdx = subDisplay.indexOf(subValue).coerceAtLeast(0)
onSave(remarks, filter, typeIdx, subIdx)
}) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.menu_item_save_config))
}
}
)
}
) { innerPadding ->
Column(modifier = Modifier.fillMaxSize().padding(innerPadding).padding(vertical = 8.dp)) {
FormTextField(stringResource(R.string.server_lab_remarks), remarks, { remarks = it })
FormDropdownField(
label = stringResource(R.string.title_policy_group_type),
value = typeValue,
options = typeEntries,
onValueChange = { typeValue = it }
)
FormDropdownField(
label = stringResource(R.string.title_policy_group_subscription_id),
value = subValue,
options = subDisplay,
onValueChange = { subValue = it }
)
FormTextField(stringResource(R.string.title_policy_group_subscription_filter), filter, { filter = it })
}
}
R.id.save_config -> {
saveServer()
true
}
else -> super.onOptionsItemSelected(item)
if (showDeleteConfirm) {
ConfirmDialog(
message = stringResource(R.string.del_config_comfirm),
confirmText = stringResource(android.R.string.ok),
dismissText = stringResource(android.R.string.cancel),
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
}
}
@@ -1,108 +1,110 @@
package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.FloatingActionButton
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
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivityServerProxyChainBinding
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.ConfirmDialog
import com.v2ray.ang.compose.FormDropdownField
import com.v2ray.ang.compose.FormTextField
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.dto.entities.ProfileItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.isComplexType
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.Utils
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
class ServerProxyChainActivity : BaseComponentActivity() {
class ServerProxyChainActivity : BaseActivity() {
private val binding by lazy { ActivityServerProxyChainBinding.inflate(layoutInflater) }
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
private val isRunning by lazy {
intent.getBooleanExtra("isRunning", false)
&& editGuid.isNotEmpty()
&& editGuid == MmkvManager.getSelectServer()
}
private val subscriptionId by lazy {
intent.getStringExtra("subscriptionId")
}
private lateinit var memberAdapter: ServerProxyChainMemberAdapter
private val subscriptionId by lazy { intent.getStringExtra("subscriptionId") }
private var allRemarks: List<String> = emptyList()
private lateinit var allRemarks: List<String>
private lateinit var initialRemarks: String
private lateinit var initialMembers: List<String>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.PROXYCHAIN.toString())
loadAvailableRemarks()
setupRecycler()
binding.fabAddProxyChainMember.setOnClickListener {
addMemberRow()
}
val config = MmkvManager.decodeServerConfig(editGuid)
if (config != null) {
bindingServer(config)
} else {
clearServer()
}
}
private fun loadAvailableRemarks() {
allRemarks = SettingsManager.getProfileRemarks(
excludeConfigTypes = setOf(
EConfigType.CUSTOM,
EConfigType.POLICYGROUP,
EConfigType.PROXYCHAIN,
excludeConfigTypes = setOf(EConfigType.CUSTOM, EConfigType.POLICYGROUP, EConfigType.PROXYCHAIN)
)
val config = MmkvManager.decodeServerConfig(editGuid)
initialRemarks = config?.remarks ?: ""
initialMembers = config?.proxyChainProfiles?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: listOf("", "")
}
@Composable
override fun ScreenContent() {
ProxyChainScreen(
editGuid = editGuid,
isRunning = isRunning,
initialRemarks = initialRemarks,
initialMembers = initialMembers,
allRemarks = allRemarks,
onBackClick = { finish() },
onSave = { remarks, members -> saveServer(remarks, members) },
onDelete = { deleteServer() }
)
}
private fun setupRecycler() {
memberAdapter = ServerProxyChainMemberAdapter(
members = mutableListOf(),
suggestions = allRemarks,
adapterListener = ActivityAdapterListener()
)
binding.recyclerProxyChainMembers.layoutManager = LinearLayoutManager(this)
binding.recyclerProxyChainMembers.adapter = memberAdapter
ItemTouchHelper(SimpleItemTouchHelperCallback(memberAdapter)).attachToRecyclerView(binding.recyclerProxyChainMembers)
}
private fun bindingServer(config: ProfileItem): Boolean {
binding.etRemarks.text = Utils.getEditable(config.remarks)
val rows = parseChainMembers(config.proxyChainProfiles)
memberAdapter.replaceAll(rows)
if (rows.isEmpty()) {
memberAdapter.addRow()
}
return true
}
private fun clearServer(): Boolean {
binding.etRemarks.text = null
memberAdapter.replaceAll(listOf("", ""))
return true
}
private fun saveServer(): Boolean {
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
private fun saveServer(
remarks: String,
members: List<String>
): Boolean {
if (remarks.isBlank()) {
toast(R.string.server_lab_remarks)
return false
}
val chainMembers = memberAdapter.getMembers().map { it.trim() }.filter { it.isNotEmpty() }
if (chainMembers.size != memberAdapter.getMembers().size) {
val chainMembers = members
.map { it.trim() }
.filter { it.isNotEmpty() }
if (chainMembers.size != members.size) {
toast(R.string.server_proxy_chain_members_unselected)
return false
}
if (chainMembers.size < 2) {
toast(R.string.server_proxy_chain_members_insufficient)
return false
@@ -112,106 +114,204 @@ class ServerProxyChainActivity : BaseActivity() {
val profile = SettingsManager.getServerViaRemarks(member)
profile == null || profile.configType.isComplexType()
}
if (invalidMembers.isNotEmpty()) {
toast(getString(R.string.server_proxy_chain_members_invalid, invalidMembers.joinToString(", ")))
toast(
getString(
R.string.server_proxy_chain_members_invalid,
invalidMembers.joinToString(", ")
)
)
return false
}
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.PROXYCHAIN)
config.remarks = binding.etRemarks.text.toString().trim()
config.proxyChainProfiles = chainMembers.joinToString(",")
config.description = chainMembers.joinToString(" -> ")
val config =
MmkvManager.decodeServerConfig(editGuid)
?: ProfileItem.create(EConfigType.PROXYCHAIN)
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
config.remarks = remarks.trim()
config.proxyChainProfiles =
chainMembers.joinToString(",")
config.description =
chainMembers.joinToString(" -> ")
if (
config.subscriptionId.isEmpty() &&
!subscriptionId.isNullOrEmpty()
) {
config.subscriptionId = subscriptionId.orEmpty()
}
MmkvManager.encodeServerConfig(editGuid, config)
if (isRunning) {
SettingsChangeManager.makeRestartService()
}
val savedGuid = MmkvManager.encodeServerConfig(
editGuid,
config
)
toastSuccess(R.string.toast_success)
finish()
ProfileEditorResult.run {
finishSaved(
guid = savedGuid,
restartService = isRunning
)
}
return true
}
private fun deleteServer(): Boolean {
if (editGuid.isNotEmpty()) {
if (editGuid != MmkvManager.getSelectServer()) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
MmkvManager.removeServer(editGuid)
finish()
if (editGuid.isEmpty()) {
return false
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
// do nothing
}
.show()
} else {
MmkvManager.removeServer(editGuid)
finish()
}
} else {
if (editGuid == MmkvManager.getSelectServer()) {
toast(R.string.toast_action_not_allowed)
return false
}
MmkvManager.removeServer(editGuid)
ProfileEditorResult.run {
finishDeleted(editGuid)
}
return true
}
private fun addMemberRow() {
if (allRemarks.isEmpty()) {
toast(R.string.toast_none_data)
return
}
memberAdapter.addRow()
}
private fun parseChainMembers(raw: String?): List<String> {
return raw.orEmpty()
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
}
@Composable
fun ProxyChainScreen(
editGuid: String,
isRunning: Boolean,
initialRemarks: String,
initialMembers: List<String>,
allRemarks: List<String>,
onBackClick: () -> Unit,
onSave: (String, List<String>) -> Boolean,
onDelete: () -> Unit
) {
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
var members by rememberSaveable { mutableStateOf(initialMembers.toMutableList()) }
var showDeleteConfirm by remember { mutableStateOf(false) }
val showDelete = editGuid.isNotEmpty() && !isRunning
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
// Row selection is handled directly by AutoCompleteTextView in the adapter.
}
override fun onRemove(guid: String, position: Int) {
memberAdapter.removeRow(position)
}
override fun onShare(url: String) {
}
override fun onRefreshData() {
val lazyListState = rememberLazyListState()
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
val fromIdx = from.index - 1
val toIdx = to.index - 1
if (fromIdx in members.indices && toIdx in members.indices) {
members = members.toMutableList().apply { add(toIdx, removeAt(fromIdx)) }
}
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
val delButton = menu.findItem(R.id.del_config)
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
return super.onCreateOptionsMenu(menu)
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = EConfigType.PROXYCHAIN.toString(),
onBackClick = onBackClick,
actions = {
if (showDelete) {
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(painterResource(R.drawable.ic_delete_24dp), contentDescription = stringResource(R.string.menu_item_del_config))
}
}
IconButton(onClick = { onSave(remarks, members) }) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.menu_item_save_config))
}
}
)
},
floatingActionButton = {
FloatingActionButton(
onClick = { members = members.toMutableList().also { it.add("") } },
modifier = Modifier
.offset(y = -20.dp)
.navigationBarsPadding()
) {
Icon(painterResource(R.drawable.ic_add_24dp), contentDescription = "Add member")
}
}
) { innerPadding ->
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxSize()
.verticalScrollbar(lazyListState)
.padding(innerPadding),
contentPadding = PaddingValues(
top = 8.dp,
start = 16.dp,
end = 16.dp,
bottom = 36.dp
)
) {
item(key = "remarks_field") {
FormTextField(
label = stringResource(R.string.server_lab_remarks),
value = remarks,
onValueChange = { remarks = it }
)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.del_config -> {
deleteServer()
true
item {
Text(
text = stringResource(R.string.server_proxy_chain_members),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(start = 16.dp, top = 8.dp, bottom = 8.dp)
)
}
R.id.save_config -> {
saveServer()
true
itemsIndexed(items = members, key = { idx, _ -> "member_$idx" }) { index, member ->
ReorderableItem(reorderableState, key = "member_$index") { isDragging ->
val elevation by animateDpAsState(if (isDragging) 4.dp else 0.dp)
Surface(shadowElevation = elevation) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 4.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
"${index + 1}",
modifier = Modifier
.padding(start = 16.dp)
.width(10.dp)
)
FormDropdownField(
label = stringResource(R.string.server_lab_remarks),
placeholder = stringResource(R.string.server_proxy_chain_member_unselected),
value = member,
options = allRemarks,
onValueChange = { newVal ->
members = members.toMutableList().also { it[index] = newVal }
},
editable = true,
modifier = Modifier.weight(1f)
)
IconButton(onClick = {
members = members.toMutableList().also { it.removeAt(index) }
}) {
Icon(
painterResource(R.drawable.ic_delete_24dp),
contentDescription = "Remove"
)
}
}
}
}
}
else -> super.onOptionsItemSelected(item)
}
}
if (showDeleteConfirm) {
ConfirmDialog(
message = stringResource(R.string.del_config_comfirm),
confirmText = stringResource(android.R.string.ok),
dismissText = stringResource(android.R.string.cancel),
onConfirm = { showDeleteConfirm = false; onDelete() },
onDismiss = { showDeleteConfirm = false }
)
}
}
@@ -1,136 +0,0 @@
package com.v2ray.ang.ui
import android.graphics.Color
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerProxyChainMemberBinding
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import java.util.Collections
class ServerProxyChainMemberAdapter(
private val members: MutableList<String>,
private val suggestions: List<String>,
private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<ServerProxyChainMemberAdapter.MemberViewHolder>(), ItemTouchHelperAdapter {
override fun getItemCount(): Int = members.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MemberViewHolder {
return MemberViewHolder(
ItemRecyclerProxyChainMemberBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
override fun onBindViewHolder(holder: MemberViewHolder, position: Int) {
val adapterPos = holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: position
val value = members[position]
holder.binding.tvMemberIndex.text = (position + 1).toString()
val dropdownAdapter = ArrayAdapter(
holder.itemView.context,
android.R.layout.simple_dropdown_item_1line,
suggestions
)
holder.binding.spMemberRemark.setAdapter(dropdownAdapter)
holder.binding.spMemberRemark.threshold = 0
holder.binding.spMemberRemark.setText(value, false)
holder.binding.spMemberRemark.setOnItemClickListener { _, _, selectedIndex, _ ->
if (adapterPos in members.indices) {
members[adapterPos] = suggestions[selectedIndex].trim()
adapterListener?.onRefreshData()
}
}
holder.binding.spMemberRemark.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
if (hasFocus) return@OnFocusChangeListener
val text = holder.binding.spMemberRemark.text?.toString().orEmpty().trim()
if (adapterPos in members.indices && members[adapterPos] != text) {
members[adapterPos] = text
adapterListener?.onRefreshData()
}
}
holder.binding.spMemberRemark.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
holder.binding.btnMemberDropdown.setOnClickListener {
holder.binding.spMemberRemark.requestFocus()
holder.binding.spMemberRemark.showDropDown()
}
holder.itemView.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
holder.binding.layoutRemove.setOnClickListener {
val removePos = holder.bindingAdapterPosition
if (removePos != RecyclerView.NO_POSITION) {
adapterListener?.onRemove("", removePos)
}
}
}
fun addRow() {
members.add("")
notifyItemInserted(members.lastIndex)
adapterListener?.onRefreshData()
}
fun removeRow(position: Int) {
if (position < 0 || position >= members.size) return
members.removeAt(position)
notifyItemRemoved(position)
notifyItemRangeChanged(position, members.size - position)
adapterListener?.onRefreshData()
}
fun setRemark(position: Int, remark: String) {
if (position < 0 || position >= members.size) return
members[position] = remark
notifyItemChanged(position)
adapterListener?.onRefreshData()
}
fun replaceAll(newMembers: List<String>) {
members.clear()
members.addAll(newMembers)
notifyDataSetChanged()
adapterListener?.onRefreshData()
}
fun getMembers(): List<String> = members.toList()
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
if (fromPosition == toPosition) return true
Collections.swap(members, fromPosition, toPosition)
notifyItemMoved(fromPosition, toPosition)
notifyItemChanged(fromPosition)
notifyItemChanged(toPosition)
return true
}
override fun onItemMoveCompleted() {
adapterListener?.onRefreshData()
}
override fun onItemDismiss(position: Int) {
// Swipe-to-dismiss disabled for this adapter.
}
class MemberViewHolder(val binding: ItemRecyclerProxyChainMemberBinding) :
BaseViewHolder(binding.root), ItemTouchHelperViewHolder
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun onItemSelected() {
itemView.setBackgroundColor(Color.LTGRAY)
}
fun onItemClear() {
itemView.setBackgroundColor(Color.TRANSPARENT)
}
}
}
@@ -1,338 +1,577 @@
package com.v2ray.ang.ui
import android.content.Context
import android.os.Bundle
import android.view.View
import androidx.lifecycle.lifecycleScope
import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
import androidx.preference.PreferenceFragmentCompat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
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.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.ScaffoldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringArrayResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.VPN
import com.v2ray.ang.R
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.PreferenceGroupHeader
import com.v2ray.ang.compose.SettingsEditItem
import com.v2ray.ang.compose.SettingsListItem
import com.v2ray.ang.compose.SettingsMenuItem
import com.v2ray.ang.compose.SettingsSwitchItem
import com.v2ray.ang.compose.ThemeManager
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.MmkvPreferenceDataStore
import com.v2ray.ang.handler.MmkvManager.rememberMmkvBool
import com.v2ray.ang.handler.MmkvManager.rememberMmkvString
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.root.RootManager
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class SettingsActivity : BaseComponentActivity() {
class SettingsActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = getString(R.string.title_settings))
}
class SettingsFragment : PreferenceFragmentCompat() {
private val localDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_LOCAL_DNS_ENABLED) }
private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) }
private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) }
// private val localDnsPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_LOCAL_DNS_PORT) }
private val vpnDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_DNS) }
private val vpnBypassLan by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_BYPASS_LAN) }
private val vpnInterfaceAddress by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) }
private val vpnMtu by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_MTU) }
private val mux by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_MUX_ENABLED) }
private val muxConcurrency by lazy { findPreference<EditTextPreference>(AppConfig.PREF_MUX_CONCURRENCY) }
private val muxXudpConcurrency by lazy { findPreference<EditTextPreference>(AppConfig.PREF_MUX_XUDP_CONCURRENCY) }
private val muxXudpQuic by lazy { findPreference<ListPreference>(AppConfig.PREF_MUX_XUDP_QUIC) }
private val fragment by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FRAGMENT_ENABLED) }
private val fragmentPackets by lazy { findPreference<ListPreference>(AppConfig.PREF_FRAGMENT_PACKETS) }
private val fragmentLength by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_LENGTH) }
private val fragmentInterval by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_INTERVAL) }
private val fragmentMaxSplit by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_MAXSPLIT) }
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
private val enableRootMode by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ROOT_MODE_ENABLE) }
private val lanSharing by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ROOT_LAN_SHARING) }
private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
private val useHevTun by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_USE_HEV_TUNNEL) }
private val enableLocalProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ENABLE_LOCAL_PROXY) }
private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
private val dynamicSocksPort by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_DYNAMIC_SOCKS_PORT) }
private val socksUsername by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_USERNAME) }
private val socksPassword by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PASSWORD) }
private val socksEnableUdp by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_SOCKS_ENABLE_UDP) }
private val proxySharing by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_PROXY_SHARING) }
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
// Use MMKV as the storage backend for all Preferences
// This prevents inconsistencies between SharedPreferences and MMKV
preferenceManager.preferenceDataStore = MmkvPreferenceDataStore()
addPreferencesFromResource(R.xml.pref_settings)
initPreferenceSummaries()
localDns?.setOnPreferenceChangeListener { _, any ->
updateLocalDns(any as Boolean)
true
}
mux?.setOnPreferenceChangeListener { _, newValue ->
updateMux(newValue as Boolean)
true
}
muxConcurrency?.setOnPreferenceChangeListener { _, newValue ->
updateMuxConcurrency(newValue as String)
true
}
muxXudpConcurrency?.setOnPreferenceChangeListener { _, newValue ->
updateMuxXudpConcurrency(newValue as String)
true
}
fragment?.setOnPreferenceChangeListener { _, newValue ->
updateFragment(newValue as Boolean)
true
}
mode?.setOnPreferenceChangeListener { pref, newValue ->
val valueStr = newValue.toString()
(pref as? ListPreference)?.let { lp ->
val idx = lp.findIndexOfValue(valueStr)
lp.summary = if (idx >= 0) lp.entries[idx] else valueStr
}
updateMode(valueStr)
true
}
mode?.dialogLayoutResource = R.layout.preference_with_help_link
useHevTun?.setOnPreferenceChangeListener { _, newValue ->
updateHevTunSettings(newValue as Boolean)
true
}
enableLocalProxy?.setOnPreferenceChangeListener { _, newValue ->
updateEnableLocalProxy(newValue as Boolean)
true
}
dynamicSocksPort?.setOnPreferenceChangeListener { _, newValue ->
updateDynamicSocksPort(newValue as Boolean)
true
}
enableRootMode?.setOnPreferenceChangeListener { _, newValue ->
if (newValue == true && !RootManager.cachedRoot()) {
lifecycleScope.launch {
if (checkAndRequestRoot()) {
enableRootMode?.isChecked = true
}
}
false
} else {
true
@Composable
override fun ScreenContent() {
SettingsScreen(
onBackClick = { finish() },
onModeHelpClicked = { Utils.openUri(this, AppConfig.APP_WIKI_MODE) }
)
}
}
lanSharing?.setOnPreferenceChangeListener { _, newValue ->
if (newValue == true && !RootManager.cachedRoot()) {
lifecycleScope.launch {
if (checkAndRequestRoot()) {
lanSharing?.isChecked = true
}
}
false
} else {
true
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(
onBackClick: () -> Unit,
onModeHelpClicked: () -> Unit
) {
val scope = rememberCoroutineScope()
val context = LocalContext.current
val scrollState = rememberScrollState()
}
private fun initPreferenceSummaries() {
fun updateSummary(pref: androidx.preference.Preference) {
when (pref) {
is EditTextPreference -> {
if (pref.key == AppConfig.PREF_SOCKS_PASSWORD) {
pref.summary = if (pref.text.isNullOrEmpty()) "" else "******"
} else {
pref.summary = pref.text.orEmpty()
}
pref.setOnPreferenceChangeListener { p, newValue ->
if (p.key == AppConfig.PREF_SOCKS_PASSWORD) {
p.summary = if ((newValue as? String).isNullOrEmpty()) "" else "******"
} else {
p.summary = (newValue as? String).orEmpty()
}
true
}
}
is ListPreference -> {
pref.summary = pref.entry ?: ""
pref.setOnPreferenceChangeListener { p, newValue ->
val lp = p as ListPreference
val idx = lp.findIndexOfValue(newValue as? String)
lp.summary = (if (idx >= 0) lp.entries[idx] else newValue) as CharSequence?
true
}
}
is CheckBoxPreference, is androidx.preference.SwitchPreferenceCompat -> {
}
}
}
fun traverse(group: androidx.preference.PreferenceGroup) {
for (i in 0 until group.preferenceCount) {
when (val p = group.getPreference(i)) {
is androidx.preference.PreferenceGroup -> traverse(p)
else -> updateSummary(p)
}
}
}
preferenceScreen?.let { traverse(it) }
}
private suspend fun checkAndRequestRoot(): Boolean {
val hasRoot = RootManager.refresh()
if (!isAdded) return false
suspend fun checkAndRequestRoot(context: Context): Boolean {
val hasRoot = withContext(Dispatchers.IO) { RootManager.refresh() }
if (!hasRoot) {
context?.toastError(R.string.toast_root_required)
context.toastError(R.string.toast_root_required)
}
return hasRoot
}
override fun onStart() {
super.onStart()
updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true))
var localDns by rememberMmkvBool(AppConfig.PREF_LOCAL_DNS_ENABLED, false)
var fakeDns by rememberMmkvBool(AppConfig.PREF_FAKE_DNS_ENABLED, false)
var appendHttpProxy by rememberMmkvBool(AppConfig.PREF_APPEND_HTTP_PROXY, false)
var vpnDns by rememberMmkvString(AppConfig.PREF_VPN_DNS, "")
var vpnBypassLan by rememberMmkvString(AppConfig.PREF_VPN_BYPASS_LAN, "0")
var vpnInterfaceAddress by rememberMmkvString(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX, "0")
var vpnMtu by rememberMmkvString(AppConfig.PREF_VPN_MTU, "")
// Initialize mode-dependent UI states
updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN))
var mux by rememberMmkvBool(AppConfig.PREF_MUX_ENABLED, false)
var muxConcurrency by rememberMmkvString(AppConfig.PREF_MUX_CONCURRENCY, "8")
var muxXudpConcurrency by rememberMmkvString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
var muxXudpQuic by rememberMmkvString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
// Initialize local proxy state
updateEnableLocalProxy(MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true))
var fragment by rememberMmkvBool(AppConfig.PREF_FRAGMENT_ENABLED, false)
var fragmentPackets by rememberMmkvString(AppConfig.PREF_FRAGMENT_PACKETS, "tlshello")
var fragmentLength by rememberMmkvString(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
var fragmentInterval by rememberMmkvString(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
var fragmentMaxSplit by rememberMmkvString(AppConfig.PREF_FRAGMENT_MAXSPLIT, "10")
// Initialize mux-dependent UI states
updateMux(MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false))
var mode by rememberMmkvString(AppConfig.PREF_MODE, VPN)
var enableRootMode by rememberMmkvBool(AppConfig.PREF_ROOT_MODE_ENABLE, false)
var lanSharing by rememberMmkvBool(AppConfig.PREF_ROOT_LAN_SHARING, false)
// Initialize fragment-dependent UI states
updateFragment(MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false))
var hevTunLogLevel by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, "warning")
var hevTunRwTimeout by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, "")
var useHevTun by rememberMmkvBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
updateDynamicSocksPort(MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false))
}
var enableLocalProxy by rememberMmkvBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
var socksPort by rememberMmkvString(AppConfig.PREF_SOCKS_PORT, "")
var dynamicSocksPort by rememberMmkvBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
var socksUsername by rememberMmkvString(AppConfig.PREF_SOCKS_USERNAME, "")
var socksPassword by rememberMmkvString(AppConfig.PREF_SOCKS_PASSWORD, "")
var socksEnableUdp by rememberMmkvBool(AppConfig.PREF_SOCKS_ENABLE_UDP, false)
var proxySharing by rememberMmkvBool(AppConfig.PREF_PROXY_SHARING, false)
private fun updateMode(value: String?) {
val vpn = value == VPN
localDns?.isEnabled = vpn
fakeDns?.isEnabled = vpn
appendHttpProxy?.isEnabled = vpn
// localDnsPort?.isEnabled = vpn
vpnDns?.isEnabled = vpn
vpnBypassLan?.isEnabled = vpn
vpnInterfaceAddress?.isEnabled = vpn
vpnMtu?.isEnabled = vpn
useHevTun?.isEnabled = vpn
updateHevTunSettings(false)
if (vpn) {
updateLocalDns(
MmkvManager.decodeSettingsBool(
AppConfig.PREF_LOCAL_DNS_ENABLED,
false
)
)
updateHevTunSettings(
MmkvManager.decodeSettingsBool(
AppConfig.PREF_USE_HEV_TUNNEL,
false
)
var speedEnabled by rememberMmkvBool(AppConfig.PREF_SPEED_ENABLED, false)
var confirmRemove by rememberMmkvBool(AppConfig.PREF_CONFIRM_REMOVE, false)
var startScanImmediate by rememberMmkvBool(AppConfig.PREF_START_SCAN_IMMEDIATE, false)
var doubleColumnDisplay by rememberMmkvBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
var groupAllDisplay by rememberMmkvBool(AppConfig.PREF_GROUP_ALL_DISPLAY, false)
var language by rememberMmkvString(AppConfig.PREF_LANGUAGE, "auto")
var uiModeNight by rememberMmkvString(AppConfig.PREF_UI_MODE_NIGHT, "0")
var ipv6Enabled by rememberMmkvBool(AppConfig.PREF_IPV6_ENABLED, false)
var preferIpv6 by rememberMmkvBool(AppConfig.PREF_PREFER_IPV6, false)
var sniffingEnabled by rememberMmkvBool(AppConfig.PREF_SNIFFING_ENABLED, true)
var routeOnlyEnabled by rememberMmkvBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false)
var remoteDns by rememberMmkvString(AppConfig.PREF_REMOTE_DNS, "")
var domesticDns by rememberMmkvString(AppConfig.PREF_DOMESTIC_DNS, "")
var dnsHosts by rememberMmkvString(AppConfig.PREF_DNS_HOSTS, "")
var coreLogLevel by rememberMmkvString(AppConfig.PREF_LOGLEVEL, "warning")
var outboundResolveMethod by rememberMmkvString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "0")
var isBooted by rememberMmkvBool(AppConfig.PREF_IS_BOOTED, false)
var autoRemoveInvalidAfterTest by rememberMmkvBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)
var autoSortAfterTest by rememberMmkvBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)
var delayTestUrl by rememberMmkvString(AppConfig.PREF_DELAY_TEST_URL, "")
var realPingConcurrency by rememberMmkvString(AppConfig.PREF_REAL_PING_CONCURRENCY, "16")
var ipApiUrl by rememberMmkvString(AppConfig.PREF_IP_API_URL, "")
val isVpn = mode == VPN
val hevTunEnabled = isVpn && useHevTun
val localProxyForced = hevTunEnabled
val effectiveLocalProxy = enableLocalProxy || localProxyForced
val muxXudpConcurrencyInt = muxXudpConcurrency.toIntOrNull() ?: 8
val languageEntries = stringArrayResource(R.array.language_select).toList()
val languageValues = stringArrayResource(R.array.language_select_value).toList()
val uiModeNightEntries = stringArrayResource(R.array.ui_mode_night).toList()
val uiModeNightValues = stringArrayResource(R.array.ui_mode_night_value).toList()
val bypassLanEntries = stringArrayResource(R.array.vpn_bypass_lan).toList()
val bypassLanValues = stringArrayResource(R.array.vpn_bypass_lan_value).toList()
val interfaceAddrEntries = stringArrayResource(R.array.vpn_interface_address).toList()
val interfaceAddrValues = stringArrayResource(R.array.vpn_interface_address_value).toList()
val hevLogEntries = stringArrayResource(R.array.hev_tunnel_loglevel).toList()
val hevLogValues = stringArrayResource(R.array.hev_tunnel_loglevel).toList()
val coreLogLevelEntries = stringArrayResource(R.array.core_loglevel).toList()
val coreLogLevelValues = stringArrayResource(R.array.core_loglevel).toList()
val outboundResolveEntries = stringArrayResource(R.array.outbound_domain_resolve_method).toList()
val outboundResolveValues = stringArrayResource(R.array.outbound_domain_resolve_method_value).toList()
val xudpQuicEntries = stringArrayResource(R.array.mux_xudp_quic_entries).toList()
val xudpQuicValues = stringArrayResource(R.array.mux_xudp_quic_value).toList()
val fragmentPacketsEntries = stringArrayResource(R.array.fragment_packets).toList()
val fragmentPacketsValues = stringArrayResource(R.array.fragment_packets).toList()
val modeEntries = stringArrayResource(R.array.mode_entries).toList()
val modeValues = stringArrayResource(R.array.mode_value).toList()
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.title_settings),
onBackClick = onBackClick
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScrollbar(scrollState)
.verticalScroll(scrollState)
) {
PreferenceGroupHeader(title = stringResource(R.string.title_ui_settings))
SettingsSwitchItem(
title = stringResource(R.string.title_pref_speed_enabled),
summary = stringResource(R.string.summary_pref_speed_enabled),
checked = speedEnabled,
onCheckedChange = { speedEnabled = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_confirm_remove),
summary = stringResource(R.string.summary_pref_confirm_remove),
checked = confirmRemove,
onCheckedChange = { confirmRemove = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_start_scan_immediate),
summary = stringResource(R.string.summary_pref_start_scan_immediate),
checked = startScanImmediate,
onCheckedChange = { startScanImmediate = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_double_column_display),
summary = stringResource(R.string.summary_pref_double_column_display),
checked = doubleColumnDisplay,
onCheckedChange = {
doubleColumnDisplay = it
SettingsChangeManager.makeSetupGroupTab()
}
private fun updateLocalDns(enabled: Boolean) {
fakeDns?.isEnabled = enabled
// localDnsPort?.isEnabled = enabled
vpnDns?.isEnabled = !enabled
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_group_all_display),
summary = stringResource(R.string.summary_pref_group_all_display),
checked = groupAllDisplay,
onCheckedChange = {
groupAllDisplay = it
SettingsChangeManager.makeSetupGroupTab()
}
)
SettingsListItem(
title = stringResource(R.string.title_language),
entries = languageEntries,
values = languageValues,
selectedValue = language,
onSelected = { language = it }
)
SettingsListItem(
title = stringResource(R.string.title_pref_ui_mode_night),
entries = uiModeNightEntries,
values = uiModeNightValues,
selectedValue = uiModeNight,
onSelected = {
uiModeNight = it
ThemeManager.setThemeMode(it)
}
)
private fun updateMux(enabled: Boolean) {
muxConcurrency?.isEnabled = enabled
muxXudpConcurrency?.isEnabled = enabled
muxXudpQuic?.isEnabled = enabled
if (enabled) {
updateMuxConcurrency(MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_CONCURRENCY, "8"))
updateMuxXudpConcurrency(MmkvManager.decodeSettingsString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8"))
PreferenceGroupHeader(title = stringResource(R.string.title_vpn_settings))
SettingsSwitchItem(
title = stringResource(R.string.title_pref_ipv6_enabled),
summary = stringResource(R.string.summary_pref_ipv6_enabled),
checked = ipv6Enabled,
enabled = isVpn,
onCheckedChange = { ipv6Enabled = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_prefer_ipv6),
summary = stringResource(R.string.summary_pref_prefer_ipv6),
checked = preferIpv6,
enabled = isVpn && ipv6Enabled,
onCheckedChange = { preferIpv6 = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_local_dns_enabled),
summary = stringResource(R.string.summary_pref_local_dns_enabled),
checked = localDns,
enabled = isVpn,
onCheckedChange = { localDns = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_fake_dns_enabled),
summary = stringResource(R.string.summary_pref_fake_dns_enabled),
checked = fakeDns,
enabled = isVpn && localDns,
onCheckedChange = { fakeDns = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_vpn_dns),
value = vpnDns,
enabled = isVpn && !localDns,
onValueChanged = { vpnDns = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_append_http_proxy),
summary = stringResource(R.string.summary_pref_append_http_proxy),
checked = appendHttpProxy,
enabled = isVpn && effectiveLocalProxy,
onCheckedChange = { appendHttpProxy = it }
)
SettingsListItem(
title = stringResource(R.string.title_pref_vpn_bypass_lan),
entries = bypassLanEntries,
values = bypassLanValues,
selectedValue = vpnBypassLan,
enabled = isVpn,
onSelected = { vpnBypassLan = it }
)
SettingsListItem(
title = stringResource(R.string.title_pref_vpn_interface_address),
entries = interfaceAddrEntries,
values = interfaceAddrValues,
selectedValue = vpnInterfaceAddress,
enabled = isVpn,
onSelected = { vpnInterfaceAddress = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_vpn_mtu),
value = vpnMtu,
enabled = isVpn,
keyboardNumber = true,
onValueChanged = { vpnMtu = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_use_hev_tunnel),
summary = stringResource(R.string.summary_pref_use_hev_tunnel),
checked = useHevTun,
enabled = isVpn,
onCheckedChange = {
useHevTun = it
if (it && !enableLocalProxy) {
enableLocalProxy = true
}
}
)
SettingsListItem(
title = stringResource(R.string.title_pref_hev_tunnel_loglevel),
entries = hevLogEntries,
values = hevLogValues,
selectedValue = hevTunLogLevel,
enabled = hevTunEnabled,
onSelected = { hevTunLogLevel = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_hev_tunnel_rw_timeout),
value = hevTunRwTimeout,
enabled = hevTunEnabled,
keyboardNumber = true,
onValueChanged = { hevTunRwTimeout = it }
)
private fun updateMuxConcurrency(value: String?) {
val concurrency = value?.toIntOrNull() ?: 8
muxConcurrency?.summary = concurrency.toString()
PreferenceGroupHeader(title = stringResource(R.string.title_core_settings))
SettingsSwitchItem(
title = stringResource(R.string.title_pref_sniffing_enabled),
summary = stringResource(R.string.summary_pref_sniffing_enabled),
checked = sniffingEnabled,
onCheckedChange = { sniffingEnabled = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_route_only_enabled),
summary = stringResource(R.string.summary_pref_route_only_enabled),
checked = routeOnlyEnabled,
onCheckedChange = { routeOnlyEnabled = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_enable_local_proxy),
summary = stringResource(R.string.summary_pref_enable_local_proxy),
checked = enableLocalProxy,
enabled = !localProxyForced,
onCheckedChange = {
if (!localProxyForced) {
enableLocalProxy = it
if (!it && appendHttpProxy) {
appendHttpProxy = false
}
}
}
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_proxy_sharing_enabled),
summary = stringResource(R.string.summary_pref_proxy_sharing_enabled),
checked = proxySharing,
enabled = effectiveLocalProxy,
onCheckedChange = { proxySharing = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_dynamic_socks_port),
summary = stringResource(R.string.summary_pref_dynamic_socks_port),
checked = dynamicSocksPort,
enabled = effectiveLocalProxy,
onCheckedChange = { dynamicSocksPort = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_socks_port),
value = socksPort,
enabled = effectiveLocalProxy && !dynamicSocksPort,
keyboardNumber = true,
onValueChanged = { socksPort = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_socks_username),
value = socksUsername,
enabled = effectiveLocalProxy,
onValueChanged = { socksUsername = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_socks_password),
value = socksPassword,
enabled = effectiveLocalProxy,
isPassword = true,
onValueChanged = { socksPassword = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_socks_enable_udp),
summary = stringResource(R.string.summary_pref_socks_enable_udp),
checked = socksEnableUdp,
enabled = effectiveLocalProxy,
onCheckedChange = { socksEnableUdp = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_remote_dns),
value = remoteDns,
onValueChanged = { remoteDns = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_domestic_dns),
value = domesticDns,
onValueChanged = { domesticDns = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_dns_hosts),
value = dnsHosts,
onValueChanged = { dnsHosts = it }
)
SettingsListItem(
title = stringResource(R.string.title_core_loglevel),
entries = coreLogLevelEntries,
values = coreLogLevelValues,
selectedValue = coreLogLevel,
onSelected = { coreLogLevel = it }
)
SettingsListItem(
title = stringResource(R.string.title_outbound_domain_resolve_method),
entries = outboundResolveEntries,
values = outboundResolveValues,
selectedValue = outboundResolveMethod,
onSelected = { outboundResolveMethod = it }
)
PreferenceGroupHeader(title = stringResource(R.string.title_mux_settings))
SettingsSwitchItem(
title = stringResource(R.string.title_pref_mux_enabled),
summary = stringResource(R.string.summary_pref_mux_enabled),
checked = mux,
onCheckedChange = { mux = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_mux_concurency),
value = muxConcurrency,
enabled = mux,
keyboardNumber = true,
onValueChanged = { muxConcurrency = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_mux_xudp_concurency),
value = muxXudpConcurrency,
enabled = mux,
keyboardNumber = true,
onValueChanged = { muxXudpConcurrency = it }
)
SettingsListItem(
title = stringResource(R.string.title_pref_mux_xudp_quic),
entries = xudpQuicEntries,
values = xudpQuicValues,
selectedValue = muxXudpQuic,
enabled = mux && muxXudpConcurrencyInt >= 0,
onSelected = { muxXudpQuic = it }
)
private fun updateMuxXudpConcurrency(value: String?) {
if (value == null) {
muxXudpQuic?.isEnabled = true
PreferenceGroupHeader(title = stringResource(R.string.title_fragment_settings))
SettingsSwitchItem(
title = stringResource(R.string.title_pref_fragment_enabled),
checked = fragment,
onCheckedChange = { fragment = it }
)
SettingsListItem(
title = stringResource(R.string.title_pref_fragment_packets),
entries = fragmentPacketsEntries,
values = fragmentPacketsValues,
selectedValue = fragmentPackets,
enabled = fragment,
onSelected = { fragmentPackets = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_fragment_length),
value = fragmentLength,
enabled = fragment,
onValueChanged = { fragmentLength = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_fragment_interval),
value = fragmentInterval,
enabled = fragment,
onValueChanged = { fragmentInterval = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_fragment_maxsplit),
value = fragmentMaxSplit,
enabled = fragment,
keyboardNumber = true,
onValueChanged = { fragmentMaxSplit = it }
)
PreferenceGroupHeader(title = stringResource(R.string.title_advanced))
SettingsSwitchItem(
title = stringResource(R.string.title_pref_is_booted),
summary = stringResource(R.string.summary_pref_is_booted),
checked = isBooted,
onCheckedChange = { isBooted = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_auto_remove_invalid_after_test),
summary = stringResource(R.string.summary_pref_auto_remove_invalid_after_test),
checked = autoRemoveInvalidAfterTest,
onCheckedChange = { autoRemoveInvalidAfterTest = it }
)
SettingsSwitchItem(
title = stringResource(R.string.title_pref_auto_sort_after_test),
summary = stringResource(R.string.summary_pref_auto_sort_after_test),
checked = autoSortAfterTest,
onCheckedChange = { autoSortAfterTest = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_delay_test_url),
value = delayTestUrl,
onValueChanged = { delayTestUrl = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_real_ping_concurrency),
value = realPingConcurrency,
keyboardNumber = true,
onValueChanged = { realPingConcurrency = it }
)
SettingsEditItem(
title = stringResource(R.string.title_pref_ip_api_url),
value = ipApiUrl,
onValueChanged = { ipApiUrl = it }
)
PreferenceGroupHeader(title = stringResource(R.string.title_mode_settings))
SettingsListItem(
title = stringResource(R.string.title_mode),
entries = modeEntries,
values = modeValues,
selectedValue = mode,
onSelected = { mode = it }
)
SettingsMenuItem(
title = stringResource(R.string.title_mode_help),
onClick = onModeHelpClicked
)
SettingsSwitchItem(
title = stringResource(R.string.title_root_mode_enabled),
summary = stringResource(R.string.summary_root_mode_enabled),
checked = enableRootMode,
onCheckedChange = { newValue ->
if (newValue && !RootManager.cachedRoot()) {
scope.launch {
if (checkAndRequestRoot(context)) {
enableRootMode = true
}
}
} else {
val concurrency = value.toIntOrNull() ?: 8
muxXudpConcurrency?.summary = concurrency.toString()
muxXudpQuic?.isEnabled = concurrency >= 0
enableRootMode = newValue
}
}
private fun updateFragment(enabled: Boolean) {
fragmentPackets?.isEnabled = enabled
fragmentLength?.isEnabled = enabled
fragmentInterval?.isEnabled = enabled
fragmentMaxSplit?.isEnabled = enabled
)
SettingsSwitchItem(
title = stringResource(R.string.title_root_lan_sharing),
summary = stringResource(R.string.summary_root_lan_sharing),
checked = lanSharing,
onCheckedChange = { newValue ->
if (newValue && !RootManager.cachedRoot()) {
scope.launch {
if (checkAndRequestRoot(context)) {
lanSharing = true
}
private fun updateDynamicSocksPort(enabled: Boolean) {
socksPort?.isEnabled = (enableLocalProxy?.isChecked == true) && !enabled
}
private fun updateEnableLocalProxy(enabled: Boolean) {
val dynamic = MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
socksPort?.isEnabled = enabled && !dynamic
dynamicSocksPort?.isEnabled = enabled
socksUsername?.isEnabled = enabled
socksPassword?.isEnabled = enabled
socksEnableUdp?.isEnabled = enabled
proxySharing?.isEnabled = enabled
if (!enabled) {
if (appendHttpProxy?.isChecked == true) {
appendHttpProxy?.isChecked = false
MmkvManager.encodeSettings(AppConfig.PREF_APPEND_HTTP_PROXY, false)
}
appendHttpProxy?.isEnabled = false
} else {
val vpn = MmkvManager.decodeSettingsString(AppConfig.PREF_MODE) == VPN
appendHttpProxy?.isEnabled = vpn
lanSharing = newValue
}
}
)
private fun updateHevTunSettings(enabled: Boolean) {
hevTunLogLevel?.isEnabled = enabled
hevTunRwTimeout?.isEnabled = enabled
if (enabled) {
if (enableLocalProxy?.isChecked == false) {
enableLocalProxy?.isChecked = true
MmkvManager.encodeSettings(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
}
enableLocalProxy?.isEnabled = false
} else {
enableLocalProxy?.isEnabled = true
}
updateEnableLocalProxy(enableLocalProxy?.isChecked == true)
Spacer(modifier = Modifier.height(24.dp))
}
}
fun onModeHelpClicked(view: View) {
Utils.openUri(this, AppConfig.APP_WIKI_MODE)
}
}
@@ -2,16 +2,36 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
import android.view.Menu
import android.view.MenuItem
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.ImageButton
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
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.MaterialTheme
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
import androidx.compose.runtime.setValue
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivitySubEditBinding
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.ConfirmDialog
import com.v2ray.ang.compose.FormDropdownField
import com.v2ray.ang.compose.FormTextField
import com.v2ray.ang.compose.SettingsSwitchItem
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.enums.EConfigType
import com.v2ray.ang.extension.toast
@@ -24,127 +44,38 @@ import com.v2ray.ang.util.Utils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class SubEditActivity : BaseActivity() {
private val binding by lazy { ActivitySubEditBinding.inflate(layoutInflater) }
private var del_config: MenuItem? = null
private var save_config: MenuItem? = null
class SubEditActivity : BaseComponentActivity() {
private val editSubId by lazy { intent.getStringExtra("subId").orEmpty() }
private lateinit var suggestions: List<String>
private lateinit var subItem: SubscriptionItem
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
setupProfileRemarkInputs()
SettingsChangeManager.makeSetupGroupTab()
val subItem = MmkvManager.decodeSubscription(editSubId)
if (subItem != null) {
bindingServer(subItem)
} else {
clearServer()
}
}
/**
* binding selected server config
*/
private fun bindingServer(subItem: SubscriptionItem): Boolean {
binding.etRemarks.text = Utils.getEditable(subItem.remarks)
binding.etUrl.text = Utils.getEditable(subItem.url)
binding.etUserAgent.text = Utils.getEditable(subItem.userAgent)
binding.etFilter.text = Utils.getEditable(subItem.filter)
binding.chkEnable.isChecked = subItem.enabled
binding.autoUpdateCheck.isChecked = subItem.autoUpdate
binding.etUpdateInterval.text = Utils.getEditable(subItem.updateInterval.toString())
binding.allowInsecureUrl.isChecked = subItem.allowInsecureUrl
binding.etPreProfile.text = Utils.getEditable(subItem.prevProfile)
binding.etNextProfile.text = Utils.getEditable(subItem.nextProfile)
return true
}
/**
* clear or init server config
*/
private fun clearServer(): Boolean {
binding.etRemarks.text = null
binding.etUrl.text = null
binding.etFilter.text = null
binding.chkEnable.isChecked = true
binding.etUpdateInterval.text = null
binding.etPreProfile.text = null
binding.etNextProfile.text = null
return true
}
private fun setupProfileRemarkInputs() {
val suggestions = SettingsManager.getProfileRemarks(
suggestions = SettingsManager.getProfileRemarks(
excludeConfigTypes = setOf(
EConfigType.CUSTOM,
EConfigType.POLICYGROUP,
EConfigType.PROXYCHAIN,
)
)
setupProfileRemarkInput(binding.etPreProfile, binding.btnPreProfileDropdown, suggestions)
setupProfileRemarkInput(binding.etNextProfile, binding.btnNextProfileDropdown, suggestions)
subItem = MmkvManager.decodeSubscription(editSubId) ?: SubscriptionItem()
}
private fun setupProfileRemarkInput(
input: AutoCompleteTextView,
dropdownButton: ImageButton,
suggestions: List<String>
) {
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
input.setAdapter(adapter)
input.threshold = 0
dropdownButton.setOnClickListener {
input.requestFocus()
input.showDropDown()
}
input.setOnClickListener {
input.showDropDown()
}
@Composable
override fun ScreenContent() {
SubEditScreen(
editSubId = editSubId,
initial = subItem,
profileSuggestions = suggestions,
onBackClick = { finish() },
onSave = { saveServer(it) },
onDelete = { deleteServer() }
)
}
/**
* save server config
*/
private fun saveServer(): Boolean {
val subItem = MmkvManager.decodeSubscription(editSubId) ?: SubscriptionItem()
subItem.remarks = binding.etRemarks.text.toString()
subItem.url = binding.etUrl.text.toString()
subItem.userAgent = binding.etUserAgent.text.toString()
subItem.filter = binding.etFilter.text.toString()
subItem.enabled = binding.chkEnable.isChecked
subItem.autoUpdate = binding.autoUpdateCheck.isChecked
val intervalInput = binding.etUpdateInterval.text.toString().trim()
val intervalMinutes = intervalInput.toLongOrNull()
if (subItem.autoUpdate) {
// autoUpdate is enabled: interval must be valid
if (intervalMinutes == null) {
// field is empty, reset to default
subItem.updateInterval = SubscriptionItem().updateInterval
} else if (intervalMinutes < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
toast(R.string.toast_invalid_update_interval)
return false
} else {
subItem.updateInterval = intervalMinutes
}
} else {
// autoUpdate is disabled: save only if the value is valid, otherwise keep the existing value
if (intervalMinutes != null && intervalMinutes >= AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
subItem.updateInterval = intervalMinutes
}
}
subItem.prevProfile = binding.etPreProfile.text.toString()
subItem.nextProfile = binding.etNextProfile.text.toString()
subItem.allowInsecureUrl = binding.allowInsecureUrl.isChecked
private fun saveServer(subItem: SubscriptionItem): Boolean {
val intervalMinutes = subItem.updateInterval
if (TextUtils.isEmpty(subItem.remarks)) {
toast(R.string.sub_setting_remarks)
@@ -155,7 +86,6 @@ class SubEditActivity : BaseActivity() {
toast(R.string.toast_invalid_url)
return false
}
if (!Utils.isValidSubUrl(subItem.url)) {
toast(R.string.toast_insecure_url_protocol)
if (!subItem.allowInsecureUrl) {
@@ -166,62 +96,162 @@ class SubEditActivity : BaseActivity() {
MmkvManager.encodeSubscription(editSubId, subItem)
SubscriptionUpdater.syncOne(subId = editSubId)
SettingsChangeManager.makeSetupGroupTab()
toastSuccess(R.string.toast_success)
finish()
return true
}
/**
* save server config
*/
private fun deleteServer(): Boolean {
if (editSubId.isNotEmpty()) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
lifecycleScope.launch(Dispatchers.IO) {
SettingsManager.removeSubscriptionWithDefault(editSubId)
launch(Dispatchers.Main) {
finish()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
// do nothing
}
.show()
} else {
lifecycleScope.launch(Dispatchers.IO) {
SettingsManager.removeSubscriptionWithDefault(editSubId)
launch(Dispatchers.Main) {
finish()
}
}
SettingsChangeManager.makeSetupGroupTab()
launch(Dispatchers.Main) { finish() }
}
}
return true
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
del_config = menu.findItem(R.id.del_config)
save_config = menu.findItem(R.id.save_config)
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.del_config -> {
deleteServer()
true
@Composable
fun SubEditScreen(
editSubId: String,
initial: SubscriptionItem,
profileSuggestions: List<String>,
onBackClick: () -> Unit,
onSave: (SubscriptionItem) -> Boolean,
onDelete: () -> Unit
) {
val context = LocalContext.current
var remarks by rememberSaveable { mutableStateOf(initial.remarks.orEmpty()) }
var url by rememberSaveable { mutableStateOf(initial.url.orEmpty()) }
var userAgent by rememberSaveable { mutableStateOf(initial.userAgent.orEmpty()) }
var filter by rememberSaveable { mutableStateOf(initial.filter ?: "") }
var enabled by rememberSaveable { mutableStateOf(initial.enabled) }
var autoUpdate by rememberSaveable { mutableStateOf(initial.autoUpdate) }
var updateInterval by rememberSaveable { mutableStateOf(initial.updateInterval.toString()) }
var allowInsecureUrl by rememberSaveable { mutableStateOf(initial.allowInsecureUrl) }
var prevProfile by rememberSaveable { mutableStateOf(initial.prevProfile ?: "") }
var nextProfile by rememberSaveable { mutableStateOf(initial.nextProfile ?: "") }
var showDeleteConfirm by rememberSaveable { mutableStateOf(false) }
val confirmRemove = MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE, false)
val scrollState = rememberScrollState()
fun buildSubItem(): SubscriptionItem? {
val subItem = MmkvManager.decodeSubscription(editSubId) ?: SubscriptionItem()
subItem.remarks = remarks
subItem.url = url
subItem.userAgent = userAgent
subItem.filter = filter
subItem.enabled = enabled
subItem.autoUpdate = autoUpdate
val intervalInput = updateInterval.trim()
val intervalMinutes = intervalInput.toLongOrNull()
if (autoUpdate) {
if (intervalMinutes == null) {
subItem.updateInterval = SubscriptionItem().updateInterval
} else if (intervalMinutes < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
context.toast(context.getString(R.string.toast_invalid_update_interval))
return null
} else {
subItem.updateInterval = intervalMinutes
}
} else {
if (intervalMinutes != null && intervalMinutes >= AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
subItem.updateInterval = intervalMinutes
}
}
subItem.prevProfile = prevProfile
subItem.nextProfile = nextProfile
subItem.allowInsecureUrl = allowInsecureUrl
return subItem
}
R.id.save_config -> {
saveServer()
true
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.title_sub_setting),
onBackClick = onBackClick,
actions = {
if (editSubId.isNotEmpty()) {
IconButton(onClick = {
if (confirmRemove) showDeleteConfirm = true else onDelete()
}) {
Icon(painterResource(R.drawable.ic_delete_24dp), contentDescription = stringResource(R.string.menu_item_del_config))
}
}
IconButton(onClick = { buildSubItem()?.let { onSave(it) } }) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.menu_item_save_config))
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScroll(scrollState)
.verticalScrollbar(scrollState)
.padding(vertical = 8.dp)
.padding(bottom = 36.dp)
) {
FormTextField(stringResource(R.string.sub_setting_remarks), remarks, { remarks = it })
FormTextField(stringResource(R.string.sub_setting_url), url, { url = it })
FormTextField(stringResource(R.string.sub_setting_user_agent), userAgent, { userAgent = it })
FormTextField(stringResource(R.string.sub_setting_filter), filter, { filter = it })
SettingsSwitchItem(
title = stringResource(R.string.sub_setting_enable),
checked = enabled,
onCheckedChange = { enabled = it }
)
SettingsSwitchItem(
title = stringResource(R.string.sub_auto_update),
checked = autoUpdate,
onCheckedChange = { autoUpdate = it }
)
FormTextField(
stringResource(R.string.title_pref_auto_update_interval),
updateInterval, { updateInterval = it }, keyboardType = KeyboardType.Number
)
SettingsSwitchItem(
title = stringResource(R.string.sub_allow_insecure_url),
checked = allowInsecureUrl,
onCheckedChange = { allowInsecureUrl = it }
)
FormDropdownField(
label = stringResource(R.string.sub_setting_pre_profile),
placeholder = stringResource(R.string.sub_setting_pre_profile_tip),
value = prevProfile,
options = profileSuggestions,
onValueChange = { prevProfile = it },
editable = true
)
FormDropdownField(
label = stringResource(R.string.sub_setting_next_profile),
placeholder = stringResource(R.string.sub_setting_pre_profile_tip),
value = nextProfile,
options = profileSuggestions,
onValueChange = { nextProfile = it },
editable = true
)
}
}
else -> super.onOptionsItemSelected(item)
if (showDeleteConfirm) {
ConfirmDialog(
message = stringResource(R.string.del_config_comfirm),
confirmText = stringResource(android.R.string.ok),
dismissText = stringResource(android.R.string.cancel),
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
}
}
@@ -1,79 +1,106 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
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
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
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivitySubSettingBinding
import com.v2ray.ang.databinding.ItemQrcodeBinding
import com.v2ray.ang.compose.AppDivider
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.ConfirmDialog
import com.v2ray.ang.compose.SelectListDialog
import com.v2ray.ang.compose.QRCodeDialog
import com.v2ray.ang.compose.ReorderableListItem
import com.v2ray.ang.compose.colorFabActive
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.extension.toast
import com.v2ray.ang.handler.AngConfigManager
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.QRCodeDecoder
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import sh.calvin.reorderable.ReorderableItem
import sh.calvin.reorderable.rememberReorderableLazyListState
class SubSettingActivity : BaseActivity() {
private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) }
private val ownerActivity: SubSettingActivity
get() = this
class SubSettingActivity : BaseComponentActivity() {
private val viewModel: SubscriptionsViewModel by viewModels()
private lateinit var adapter: SubSettingRecyclerAdapter
private var mItemTouchHelper: ItemTouchHelper? = null
private val share_method: Array<out String> by lazy {
resources.getStringArray(R.array.share_sub_method)
}
private val isLoadingState = MutableStateFlow(false)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
}
adapter = SubSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
binding.recyclerView.adapter = adapter
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
@Composable
override fun ScreenContent() {
SubSettingScreen(
viewModel = viewModel,
isLoadingState = isLoadingState,
onBackClick = { finish() },
onAddClick = { startActivity(Intent(this, SubEditActivity::class.java)) },
onSubUpdate = { updateSubscriptions() },
onEditSub = { subId ->
startActivity(Intent(this, SubEditActivity::class.java).putExtra("subId", subId))
},
onRemoveSub = { subId -> removeSub(subId) },
onShareQRCode = { url -> QRCodeDecoder.createQRCode(url) },
onShareClipboard = { url ->
Utils.setClipboard(this, url)
toast(getString(R.string.toast_success))
},
shareSubMethodEntries = resources.getStringArray(R.array.share_sub_method).toList()
)
}
override fun onResume() {
super.onResume()
refreshData()
viewModel.reload()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_sub_setting, menu)
return super.onCreateOptionsMenu(menu)
private fun removeSub(subId: String) {
viewModel.remove(subId)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.add_config -> {
startActivity(Intent(this, SubEditActivity::class.java))
true
}
R.id.sub_update -> {
showLoading()
private fun updateSubscriptions() {
isLoadingState.value = true
lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.updateConfigViaSubAll()
delay(500L)
@@ -90,79 +117,198 @@ class SubSettingActivity : BaseActivity() {
)
)
}
hideLoading()
refreshData()
}
}
true
}
else -> super.onOptionsItemSelected(item)
}
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
isLoadingState.value = false
viewModel.reload()
adapter.notifyDataSetChanged()
}
}
}
}
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
startActivity(
Intent(ownerActivity, SubEditActivity::class.java)
.putExtra("subId", guid)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SubSettingScreen(
viewModel: SubscriptionsViewModel,
isLoadingState: MutableStateFlow<Boolean>,
onBackClick: () -> Unit,
onAddClick: () -> Unit,
onSubUpdate: () -> Unit,
onEditSub: (String) -> Unit,
onRemoveSub: (String) -> Unit,
onShareQRCode: (String) -> android.graphics.Bitmap?,
onShareClipboard: (String) -> Unit,
shareSubMethodEntries: List<String>
) {
val subscriptions by viewModel.subsFlow.collectAsStateWithLifecycle()
val isLoading by isLoadingState.collectAsState()
var removeTarget by remember { mutableStateOf<String?>(null) }
val confirmRemove = MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE, false)
var shareTarget by remember { mutableStateOf<Pair<String, String>?>(null) }
var showQRCodeBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
val lazyListState = rememberLazyListState()
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
viewModel.swap(from.index, to.index)
}
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.title_sub_setting),
onBackClick = onBackClick,
isLoading = isLoading,
actions = {
IconButton(onClick = onAddClick) {
Icon(painterResource(R.drawable.ic_add_24dp), contentDescription = stringResource(R.string.menu_item_add_config))
}
IconButton(onClick = onSubUpdate) {
Icon(painterResource(R.drawable.ic_restore_24dp), contentDescription = stringResource(R.string.title_sub_update))
}
}
)
}
) { innerPadding ->
LazyColumn(
state = lazyListState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScrollbar(lazyListState)
) {
itemsIndexed(
items = subscriptions,
key = { _, item -> item.guid }
) { _, subCache ->
ReorderableItem(reorderableState, key = subCache.guid) { isDragging ->
ReorderableListItem(
scope = this,
isDragging = isDragging
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 14.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = subCache.subscription.remarks,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
if (subCache.subscription.url.isNotEmpty()) {
Spacer(modifier = Modifier.height(2.dp))
Text(
text = subCache.subscription.url,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
Spacer(modifier = Modifier.height(2.dp))
Text(
text = Utils.formatTimestamp(subCache.subscription.lastUpdated),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
override fun onRemove(guid: String, position: Int) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
AlertDialog.Builder(ownerActivity)
.setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
viewModel.remove(guid)
refreshData()
Column(
horizontalAlignment = Alignment.End,
modifier = Modifier.padding(start = 8.dp)
) {
Row {
if (subCache.subscription.url.isNotEmpty()) {
IconButton(onClick = {
shareTarget = Pair(subCache.guid, subCache.subscription.url)
}) {
Icon(
painter = painterResource(R.drawable.ic_share_24dp),
contentDescription = "Share"
)
}
}
IconButton(onClick = { onEditSub(subCache.guid) }) {
Icon(
painter = painterResource(R.drawable.ic_edit_24dp),
contentDescription = "Edit"
)
}
IconButton(onClick = {
if (confirmRemove) removeTarget = subCache.guid
else onRemoveSub(subCache.guid)
}) {
Icon(
painter = painterResource(R.drawable.ic_delete_24dp),
contentDescription = "Delete"
)
}
}
Spacer(modifier = Modifier.height(4.dp))
Switch(
checked = subCache.subscription.enabled,
onCheckedChange = { checked ->
val updated = subCache.subscription.copy()
updated.enabled = checked
viewModel.update(subCache.guid, updated)
},
modifier = Modifier.scale(0.7f),
colors = SwitchDefaults.colors(
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
checkedTrackColor = colorFabActive
)
)
}
}
}
AppDivider(modifier = Modifier.padding(horizontal = 14.dp))
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
} else {
viewModel.remove(guid)
refreshData()
}
}
override fun onShare(url: String) {
AlertDialog.Builder(ownerActivity)
.setItems(share_method.asList().toTypedArray()) { _, i ->
try {
when (i) {
if (shareTarget != null) {
val (_, url) = shareTarget!!
SelectListDialog(
options = shareSubMethodEntries,
onSelected = { index, _ ->
shareTarget = null
when (index) {
0 -> {
val ivBinding =
ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
ivBinding.ivQcode.setImageBitmap(
QRCodeDecoder.createQRCode(
url
)
)
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
// QRCode
showQRCodeBitmap = onShareQRCode(url)
}
1 -> {
Utils.setClipboard(ownerActivity, url)
// Export to clipboard
onShareClipboard(url)
}
}
},
onDismiss = { shareTarget = null }
)
}
else -> ownerActivity.toast("else")
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Share subscription failed", e)
}
}.show()
// QR Code Dialog
if (showQRCodeBitmap != null) {
QRCodeDialog(
bitmap = showQRCodeBitmap,
onDismiss = { showQRCodeBitmap = null }
)
}
override fun onRefreshData() {
refreshData()
}
if (removeTarget != null) {
ConfirmDialog(
message = stringResource(R.string.del_config_comfirm),
confirmText = stringResource(android.R.string.ok),
dismissText = stringResource(android.R.string.cancel),
onConfirm = {
onRemoveSub(removeTarget!!)
removeTarget = null
},
onDismiss = { removeTarget = null }
)
}
}
@@ -1,98 +0,0 @@
package com.v2ray.ang.ui
import android.graphics.Color
import android.text.TextUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
import com.v2ray.ang.helper.ItemTouchHelperAdapter
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
class SubSettingRecyclerAdapter(
private val viewModel: SubscriptionsViewModel,
private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter {
override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
val subscriptions = viewModel.getAll()
val subId = subscriptions[position].guid
val subItem = subscriptions[position].subscription
holder.itemSubSettingBinding.tvName.text = subItem.remarks
holder.itemSubSettingBinding.tvUrl.text = subItem.url
holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled
holder.itemSubSettingBinding.tvLastUpdated.text = Utils.formatTimestamp(subItem.lastUpdated)
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
adapterListener?.onEdit(subId, position)
}
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
adapterListener?.onRemove(subId, position)
}
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
if (!it.isPressed) return@setOnCheckedChangeListener
subItem.enabled = isChecked
viewModel.update(subId, subItem)
}
if (TextUtils.isEmpty(subItem.url)) {
holder.itemSubSettingBinding.layoutUrl.visibility = View.GONE
holder.itemSubSettingBinding.layoutShare.visibility = View.INVISIBLE
holder.itemSubSettingBinding.chkEnable.visibility = View.INVISIBLE
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.INVISIBLE
} else {
holder.itemSubSettingBinding.layoutUrl.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutShare.visibility = View.VISIBLE
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutShare.setOnClickListener {
adapterListener?.onShare(subItem.url)
}
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
return MainViewHolder(
ItemRecyclerSubSettingBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
class MainViewHolder(val itemSubSettingBinding: ItemRecyclerSubSettingBinding) :
BaseViewHolder(itemSubSettingBinding.root), ItemTouchHelperViewHolder
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
fun onItemSelected() {
itemView.setBackgroundColor(Color.LTGRAY)
}
fun onItemClear() {
itemView.setBackgroundColor(0)
}
}
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
viewModel.swap(fromPosition, toPosition)
notifyItemMoved(fromPosition, toPosition)
return true
}
override fun onItemMoveCompleted() {
adapterListener?.onRefreshData()
}
override fun onItemDismiss(position: Int) {
}
}
@@ -3,30 +3,52 @@ package com.v2ray.ang.ui
import android.content.Intent
import android.os.Bundle
import android.text.TextUtils
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.ArrayAdapter
import android.widget.ListView
import androidx.activity.compose.setContent
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityTaskerBinding
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.SettingsSwitchItem
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.LogUtil
class TaskerActivity : BaseActivity() {
private val binding by lazy { ActivityTaskerBinding.inflate(layoutInflater) }
class TaskerActivity : BaseComponentActivity() {
private var listview: ListView? = null
private var lstData: ArrayList<String> = ArrayList()
private var lstGuid: ArrayList<String> = ArrayList()
private val switchState = mutableStateOf(false)
private val selectedPosition = mutableStateOf(-1)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "")
//add def value
lstData.add("Default")
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
@@ -36,16 +58,21 @@ class TaskerActivity : BaseActivity() {
lstGuid.add(key)
}
}
val adapter = ArrayAdapter(
this,
android.R.layout.simple_list_item_single_choice, lstData
)
listview = findViewById<View>(R.id.listview) as ListView
listview?.adapter = adapter
init()
}
@Composable
override fun ScreenContent() {
TaskerScreen(
items = lstData,
switchState = switchState,
selectedPosition = selectedPosition,
onBackClick = { finish() },
onSave = { confirmFinish() }
)
}
private fun init() {
try {
val bundle = intent?.getBundleExtra(AppConfig.TASKER_EXTRA_BUNDLE)
@@ -55,31 +82,30 @@ class TaskerActivity : BaseActivity() {
if (switch == null || TextUtils.isEmpty(guid)) {
return
} else {
binding.switchStartService.isChecked = switch
switchState.value = switch
val pos = lstGuid.indexOf(guid.toString())
if (pos >= 0) {
listview?.setItemChecked(pos, true)
selectedPosition.value = pos
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
}
}
private fun confirmFinish() {
val position = listview?.checkedItemPosition
if (position == null || position < 0) {
val position = selectedPosition.value
if (position < 0) {
return
}
val extraBundle = Bundle()
extraBundle.putBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, binding.switchStartService.isChecked)
extraBundle.putBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, switchState.value)
extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, lstGuid[position])
val intent = Intent()
val remarks = lstData[position]
val blurb = if (binding.switchStartService.isChecked) {
val blurb = if (switchState.value) {
"Start $remarks"
} else {
"Stop $remarks"
@@ -90,26 +116,60 @@ class TaskerActivity : BaseActivity() {
setResult(RESULT_OK, intent)
finish()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
val delConfig = menu.findItem(R.id.del_config)
delConfig?.isVisible = false
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.del_config -> {
true
@Composable
fun TaskerScreen(
items: List<String>,
switchState: MutableState<Boolean>,
selectedPosition: MutableState<Int>,
onBackClick: () -> Unit,
onSave: () -> Unit
) {
val listState = rememberLazyListState()
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = "",
onBackClick = onBackClick,
actions = {
IconButton(onClick = onSave) {
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.menu_item_save_config))
}
}
)
}
) { innerPadding ->
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
SettingsSwitchItem(
title = stringResource(R.string.tasker_start_service),
checked = switchState.value,
onCheckedChange = { switchState.value = it }
)
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.verticalScrollbar(listState)
) {
itemsIndexed(items) { index, remarks ->
Row(
modifier = Modifier
.fillMaxWidth()
.clickable { selectedPosition.value = index }
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedPosition.value == index,
onClick = { selectedPosition.value = index }
)
Spacer(modifier = Modifier.width(8.dp))
Text(text = remarks, style = MaterialTheme.typography.bodyLarge)
}
}
}
R.id.save_config -> {
confirmFinish()
true
}
else -> super.onOptionsItemSelected(item)
}
}
@@ -3,10 +3,10 @@ package com.v2ray.ang.ui
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.compose.runtime.Composable
import androidx.lifecycle.lifecycleScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityLogcatBinding
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.handler.AngConfigManager
@@ -16,13 +16,9 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.URLDecoder
class UrlSchemeActivity : BaseActivity() {
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
class UrlSchemeActivity : BaseComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
try {
intent.apply {
if (action == Intent.ACTION_SEND) {
@@ -59,6 +55,10 @@ class UrlSchemeActivity : BaseActivity() {
}
}
@Composable
override fun ScreenContent() {
}
private fun parseUri(uriString: String?, fragment: String?) {
if (uriString.isNullOrEmpty()) {
return
@@ -1,57 +1,120 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.app.AlertDialog
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.provider.OpenableColumns
import android.view.Menu
import android.view.MenuItem
import androidx.activity.viewModels
import androidx.compose.foundation.clickable
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.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
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
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ActivityUserAssetBinding
import com.v2ray.ang.compose.AppDivider
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.ConfirmDialog
import com.v2ray.ang.compose.SelectListDialog
import com.v2ray.ang.compose.verticalScrollbar
import com.v2ray.ang.dto.entities.AssetUrlCache
import com.v2ray.ang.dto.entities.AssetUrlItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastError
import com.v2ray.ang.extension.toastSuccess
import com.v2ray.ang.extension.toTrafficString
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import com.v2ray.ang.viewmodel.UserAssetViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.text.DateFormat
import java.util.Date
class UserAssetActivity : HelperBaseComponentActivity() {
class UserAssetActivity : HelperBaseActivity() {
private val binding by lazy { ActivityUserAssetBinding.inflate(layoutInflater) }
private val ownerActivity: UserAssetActivity
get() = this
private val viewModel: UserAssetViewModel by viewModels()
private lateinit var adapter: UserAssetAdapter
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)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting))
binding.recyclerView.setHasFixedSize(true)
binding.recyclerView.layoutManager = LinearLayoutManager(this)
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
adapter = UserAssetAdapter(viewModel, extDir, ActivityAdapterListener())
binding.recyclerView.adapter = adapter
binding.tvGeoFilesSourcesSummary.text = getGeoFilesSources()
binding.layoutGeoFilesSources.setOnClickListener {
setGeoFilesSources()
geoFilesSourceState.value = getGeoFilesSources()
}
@Composable
override fun ScreenContent() {
UserAssetScreen(
viewModel = viewModel,
extDir = extDir,
isLoadingState = isLoadingState,
geoFilesSourceState = geoFilesSourceState,
refreshTrigger = refreshTrigger,
geoFilesSourcesList = AppConfig.GEO_FILES_SOURCES.toList(),
onBackClick = { finish() },
onGeoSourceSelected = { value ->
MmkvManager.encodeSettings(AppConfig.PREF_GEO_FILES_SOURCES, value)
geoFilesSourceState.value = value
refreshData()
},
onAddFileClick = { showFileChooser() },
onAddUrlClick = { startActivity(Intent(this, UserAssetUrlActivity::class.java)) },
onAddQrcodeClick = { importAssetFromQRcode() },
onDownloadClick = { downloadGeoFiles() },
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()
}
}
)
}
override fun onResume() {
@@ -59,36 +122,10 @@ class UserAssetActivity : HelperBaseActivity() {
refreshData()
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_asset, menu)
return super.onCreateOptionsMenu(menu)
}
// Use when to streamline the option selection
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
R.id.add_file -> showFileChooser().let { true }
R.id.add_url -> startActivity(Intent(this, UserAssetUrlActivity::class.java)).let { true }
R.id.add_qrcode -> importAssetFromQRcode().let { true }
R.id.download_file -> downloadGeoFiles().let { true }
else -> super.onOptionsItemSelected(item)
}
private fun getGeoFilesSources(): String {
return MmkvManager.decodeSettingsString(AppConfig.PREF_GEO_FILES_SOURCES) ?: AppConfig.GEO_FILES_SOURCES.first()
}
private fun setGeoFilesSources() {
AlertDialog.Builder(this).setItems(AppConfig.GEO_FILES_SOURCES.toTypedArray()) { _, i ->
try {
val value = AppConfig.GEO_FILES_SOURCES[i]
MmkvManager.encodeSettings(AppConfig.PREF_GEO_FILES_SOURCES, value)
binding.tvGeoFilesSourcesSummary.text = value
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Failed to set geo files sources", e)
}
}.show()
}
private fun showFileChooser() {
launchFileChooser { uri ->
if (uri == null) {
@@ -149,14 +186,12 @@ class UserAssetActivity : HelperBaseActivity() {
return true
}
private fun importAsset(url: String?): Boolean {
try {
if (!Utils.isValidUrl(url)) {
toast(R.string.toast_invalid_url)
return false
}
// Send URL to UserAssetUrlActivity for Processing
startActivity(
Intent(this, UserAssetUrlActivity::class.java)
.putExtra(UserAssetUrlActivity.ASSET_URL_QRCODE, url)
@@ -170,7 +205,7 @@ class UserAssetActivity : HelperBaseActivity() {
private fun downloadGeoFiles() {
refreshData()
showLoading()
isLoadingState.value = true
toast(R.string.msg_downloading_content)
val proxyUsername = SettingsManager.getSocksUsername()
@@ -185,7 +220,7 @@ class UserAssetActivity : HelperBaseActivity() {
toast(getString(R.string.toast_failure))
}
refreshData()
hideLoading()
isLoadingState.value = false
}
}
}
@@ -202,40 +237,203 @@ class UserAssetActivity : HelperBaseActivity() {
@SuppressLint("NotifyDataSetChanged")
fun refreshData() {
viewModel.reload(getGeoFilesSources())
adapter.notifyDataSetChanged()
refreshTrigger.value++
}
}
private inner class ActivityAdapterListener : BaseAdapterListener {
override fun onEdit(guid: String, position: Int) {
startActivity(
Intent(ownerActivity, UserAssetUrlActivity::class.java)
.putExtra("assetId", guid)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun UserAssetScreen(
viewModel: UserAssetViewModel,
extDir: File,
isLoadingState: MutableStateFlow<Boolean>,
geoFilesSourceState: MutableStateFlow<String>,
refreshTrigger: MutableStateFlow<Int>,
geoFilesSourcesList: List<String>,
onBackClick: () -> Unit,
onGeoSourceSelected: (String) -> Unit,
onAddFileClick: () -> Unit,
onAddUrlClick: () -> Unit,
onAddQrcodeClick: () -> Unit,
onDownloadClick: () -> Unit,
onEditAsset: (String) -> Unit,
onRemoveAsset: (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 showGeoSourceDialog by remember { mutableStateOf(false) }
var deleteTargetGuid by remember { mutableStateOf<String?>(null) }
val listState = rememberLazyListState()
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.title_user_asset_setting),
onBackClick = onBackClick,
isLoading = isLoading,
actions = {
Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
IconButton(onClick = { showAddMenu = true }) {
Icon(painterResource(R.drawable.ic_add_24dp), contentDescription = stringResource(R.string.menu_item_add_asset))
}
DropdownMenu(
expanded = showAddMenu,
onDismissRequest = { showAddMenu = false },
containerColor = MaterialTheme.colorScheme.surface,
offset = DpOffset(x = 0.dp, y = 0.dp),
modifier = Modifier.wrapContentWidth(Alignment.End)
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_add_file)) },
onClick = { showAddMenu = false; onAddFileClick() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_add_url)) },
onClick = { showAddMenu = false; onAddUrlClick() }
)
DropdownMenuItem(
text = { Text(stringResource(R.string.menu_item_scan_qrcode)) },
onClick = { showAddMenu = false; onAddQrcodeClick() }
)
}
}
IconButton(onClick = onDownloadClick) {
Icon(painterResource(R.drawable.ic_cloud_download_24dp), contentDescription = stringResource(R.string.menu_item_download_file))
}
}
)
}
) { innerPadding ->
LazyColumn(
state = listState,
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.verticalScrollbar(listState)
) {
item(key = "geo_source_$trigger") {
Column(
modifier = Modifier
.fillMaxWidth()
.clickable { showGeoSourceDialog = true }
.padding(16.dp)
) {
Text(
text = stringResource(R.string.asset_geo_files_sources),
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(8.dp))
Text(
text = geoFilesSource,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2
)
}
}
item {
Text(
text = stringResource(R.string.title_user_asset_setting),
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(16.dp)
)
}
itemsIndexed(items = assets, key = { _, item -> "${item.guid}_$trigger" }) { _, item ->
UserAssetItem(
item = item,
extDir = extDir,
onEdit = { onEditAsset(item.guid) },
onDeleteClick = { deleteTargetGuid = item.guid }
)
AppDivider()
}
}
}
if (showGeoSourceDialog) {
SelectListDialog(
title = stringResource(R.string.asset_geo_files_sources),
options = geoFilesSourcesList,
selectedOption = geoFilesSource,
showRadio = true,
onSelected = { _, value ->
onGeoSourceSelected(value)
showGeoSourceDialog = false
},
onDismiss = { showGeoSourceDialog = false }
)
}
override fun onRemove(guid: String, position: Int) {
val asset = viewModel.getAsset(position)?.takeIf { it.guid == guid }
?: viewModel.getAssets().find { it.guid == guid }
?: return
val file = extDir.listFiles()?.find { it.name == asset.assetUrl.remarks }
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
file?.delete()
MmkvManager.removeAssetUrl(guid)
initAssets()
if (deleteTargetGuid != null) {
val guid = deleteTargetGuid!!
val assetName = assets.find { it.guid == guid }?.assetUrl?.remarks ?: ""
ConfirmDialog(
message = stringResource(R.string.del_config_comfirm) + "\n$assetName",
confirmText = stringResource(android.R.string.ok),
dismissText = stringResource(android.R.string.cancel),
onConfirm = { onRemoveAsset(guid) },
onDismiss = { deleteTargetGuid = null }
)
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
// do nothing
}
.show()
}
override fun onShare(url: String) {
@Composable
private fun UserAssetItem(
item: AssetUrlCache,
extDir: File,
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()))}"
} else {
stringResource(R.string.msg_file_not_found)
}
val showEditButton = item.assetUrl.locked != true && item.assetUrl.url != "file"
override fun onRefreshData() {
refreshData()
Row(
modifier = Modifier.fillMaxWidth().padding(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f).padding(8.dp)) {
Text(
text = item.assetUrl.remarks,
style = MaterialTheme.typography.bodyLarge,
maxLines = 2,
overflow = TextOverflow.Ellipsis
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = propertiesText,
style = MaterialTheme.typography.bodySmall,
maxLines = 1,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (showEditButton) {
IconButton(onClick = onEdit) {
Icon(
painter = painterResource(R.drawable.ic_edit_24dp),
contentDescription = stringResource(R.string.menu_item_edit_config),
modifier = Modifier.size(24.dp)
)
}
}
IconButton(onClick = onDeleteClick) {
Icon(
painter = painterResource(R.drawable.ic_delete_24dp),
contentDescription = stringResource(R.string.menu_item_del_config),
modifier = Modifier.size(24.dp)
)
}
}
}
@@ -1,71 +0,0 @@
package com.v2ray.ang.ui
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.RecyclerView
import com.v2ray.ang.R
import com.v2ray.ang.contracts.BaseAdapterListener
import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
import com.v2ray.ang.extension.toTrafficString
import com.v2ray.ang.viewmodel.UserAssetViewModel
import java.io.File
import java.text.DateFormat
import java.util.Date
class UserAssetAdapter(
private val viewModel: UserAssetViewModel,
private val extDir: File,
private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<UserAssetAdapter.UserAssetViewHolder>() {
override fun getItemCount() = viewModel.itemCount
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder {
return UserAssetViewHolder(
ItemRecyclerUserAssetBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
@SuppressLint("SetTextI18n")
override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) {
val item = viewModel.getAsset(position) ?: return
val file = extDir.listFiles()?.find { it.name == item.assetUrl.remarks }
holder.itemUserAssetBinding.assetName.text = item.assetUrl.remarks
if (file != null) {
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
holder.itemUserAssetBinding.assetProperties.text =
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}"
} else {
holder.itemUserAssetBinding.assetProperties.text =
holder.itemUserAssetBinding.root.context.getString(R.string.msg_file_not_found)
}
if (item.assetUrl.locked == true) {
holder.itemUserAssetBinding.layoutEdit.visibility = View.GONE
} else {
holder.itemUserAssetBinding.layoutEdit.visibility = if (item.assetUrl.url == "file") {
View.GONE
} else {
View.VISIBLE
}
}
holder.itemUserAssetBinding.layoutEdit.setOnClickListener {
adapterListener?.onEdit(item.guid, position)
}
holder.itemUserAssetBinding.layoutRemove.setOnClickListener {
adapterListener?.onRemove(item.guid, position)
}
}
class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) :
RecyclerView.ViewHolder(itemUserAssetBinding.root)
}
@@ -2,12 +2,29 @@ package com.v2ray.ang.ui
import android.os.Bundle
import android.text.TextUtils
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AlertDialog
import androidx.compose.foundation.layout.Column
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.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
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.v2ray.ang.AppConfig
import com.v2ray.ang.R
import com.v2ray.ang.databinding.ActivityUserAssetUrlBinding
import com.v2ray.ang.compose.AppTopBar
import com.v2ray.ang.compose.ConfirmDialog
import com.v2ray.ang.compose.FormTextField
import com.v2ray.ang.dto.entities.AssetUrlItem
import com.v2ray.ang.extension.toast
import com.v2ray.ang.extension.toastSuccess
@@ -16,65 +33,55 @@ import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import java.io.File
class UserAssetUrlActivity : BaseActivity() {
// Receive QRcode URL from UserAssetActivity
class UserAssetUrlActivity : BaseComponentActivity() {
companion object {
const val ASSET_URL_QRCODE = "ASSET_URL_QRCODE"
}
private val binding by lazy { ActivityUserAssetUrlBinding.inflate(layoutInflater) }
private var del_config: MenuItem? = null
private var save_config: MenuItem? = null
private val extDir by lazy { File(Utils.userAssetPath(this)) }
private val editAssetId by lazy { intent.getStringExtra("assetId").orEmpty() }
private lateinit var initialRemarks: String
private lateinit var initialUrl: String
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_add_url))
val assetItem = MmkvManager.decodeAsset(editAssetId)
val assetUrlQrcode = intent.getStringExtra(ASSET_URL_QRCODE)
val assetNameQrcode = File(assetUrlQrcode.toString()).name
when {
assetItem != null -> bindingAsset(assetItem)
assetItem != null -> {
initialRemarks = assetItem.remarks
initialUrl = assetItem.url
}
assetUrlQrcode != null -> {
binding.etRemarks.setText(assetNameQrcode)
binding.etUrl.setText(assetUrlQrcode)
initialRemarks = assetNameQrcode
initialUrl = assetUrlQrcode
}
else -> {
initialRemarks = ""
initialUrl = ""
}
else -> clearAsset()
}
}
/**
* bingding seleced asset config
*/
private fun bindingAsset(assetItem: AssetUrlItem): Boolean {
binding.etRemarks.text = Utils.getEditable(assetItem.remarks)
binding.etUrl.text = Utils.getEditable(assetItem.url)
return true
@Composable
override fun ScreenContent() {
UserAssetUrlScreen(
editAssetId = editAssetId,
initialRemarks = initialRemarks,
initialUrl = initialUrl,
onBackClick = { finish() },
onSave = { r, u -> saveServer(r, u) },
onDelete = { deleteServer() }
)
}
/**
* clear or init asset config
*/
private fun clearAsset(): Boolean {
binding.etRemarks.text = null
binding.etUrl.text = null
return true
}
/**
* save asset config
*/
private fun saveServer(): Boolean {
private fun saveServer(remarks: String, url: String): Boolean {
var assetItem = MmkvManager.decodeAsset(editAssetId)
var assetId = editAssetId
if (assetItem != null) {
// remove file associated with the asset
val file = extDir.resolve(assetItem.remarks)
if (file.exists()) {
try {
@@ -88,17 +95,14 @@ class UserAssetUrlActivity : BaseActivity() {
assetItem = AssetUrlItem()
}
assetItem.remarks = binding.etRemarks.text.toString()
assetItem.url = binding.etUrl.text.toString()
assetItem.remarks = remarks
assetItem.url = url
// check remarks unique
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
@@ -114,47 +118,79 @@ class UserAssetUrlActivity : BaseActivity() {
return true
}
/**
* save server config
*/
private fun deleteServer(): Boolean {
if (editAssetId.isNotEmpty()) {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
MmkvManager.removeAssetUrl(editAssetId)
finish()
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
// do nothing
}
.show()
}
return true
}
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_server, menu)
del_config = menu.findItem(R.id.del_config)
save_config = menu.findItem(R.id.save_config)
if (editAssetId.isEmpty()) {
del_config?.isVisible = false
}
return super.onCreateOptionsMenu(menu)
@Composable
fun UserAssetUrlScreen(
editAssetId: String,
initialRemarks: String,
initialUrl: String,
onBackClick: () -> Unit,
onSave: (String, String) -> Boolean,
onDelete: () -> Unit
) {
var remarks by remember { mutableStateOf(initialRemarks) }
var url by remember { mutableStateOf(initialUrl) }
var showDeleteConfirm by remember { mutableStateOf(false) }
Scaffold(
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
topBar = {
AppTopBar(
title = stringResource(R.string.title_user_asset_add_url),
onBackClick = onBackClick,
actions = {
if (editAssetId.isNotEmpty()) {
IconButton(onClick = { showDeleteConfirm = true }) {
Icon(
painterResource(R.drawable.ic_delete_24dp),
contentDescription = stringResource(R.string.menu_item_del_config)
)
}
}
IconButton(onClick = { onSave(remarks, url) }) {
Icon(
painterResource(R.drawable.ic_fab_check),
contentDescription = stringResource(R.string.menu_item_save_config)
)
}
}
)
}
) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding)
.padding(vertical = 8.dp)
) {
FormTextField(
label = stringResource(R.string.sub_setting_remarks),
value = remarks,
onValueChange = { remarks = it }
)
FormTextField(
label = stringResource(R.string.title_url),
value = url,
onValueChange = { url = it }
)
}
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.del_config -> {
deleteServer()
true
}
R.id.save_config -> {
saveServer()
true
}
else -> super.onOptionsItemSelected(item)
if (showDeleteConfirm) {
ConfirmDialog(
message = stringResource(R.string.del_config_comfirm),
confirmText = stringResource(android.R.string.ok),
dismissText = stringResource(android.R.string.cancel),
onConfirm = onDelete,
onDismiss = { showDeleteConfirm = false }
)
}
}
@@ -0,0 +1,124 @@
package com.v2ray.ang.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.util.AppManagerUtil
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.Collator
/**
* ViewModel for AppPicker screen.
*/
class AppPickerViewModel : ViewModel() {
private val _selectedPackages = MutableStateFlow<Set<String>>(emptySet())
val selectedPackages: StateFlow<Set<String>> = _selectedPackages.asStateFlow()
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
private val _displayedApps = MutableStateFlow<List<AppInfo>>(emptyList())
val displayedApps: StateFlow<List<AppInfo>> = _displayedApps.asStateFlow()
private var appsAll: List<AppInfo> = emptyList()
private var currentQuery: String = ""
private var SelectedSnapshot: Set<String> = emptySet()
fun initialize(initialSelected: Collection<String>) {
_selectedPackages.value = initialSelected.toSet()
}
fun loadApps(context: Context) {
viewModelScope.launch {
_isLoading.value = true
try {
SelectedSnapshot = _selectedPackages.value.toSet()
val apps = withContext(Dispatchers.IO) {
val list = AppManagerUtil.loadNetworkAppList(context)
val special = createSpecialItemUnidentified(context)
sortApps(list + special)
}
appsAll = apps
_displayedApps.value = applyFilter(currentQuery)
} catch (e: Exception) {
LogUtil.e("AppPickerViewModel", "Failed to load app list", e)
} finally {
_isLoading.value = false
}
}
}
fun filterApps(query: String) {
currentQuery = query
_displayedApps.value = applyFilter(query)
}
fun toggleApp(packageName: String) {
val current = _selectedPackages.value
_selectedPackages.value = if (current.contains(packageName)) {
current - packageName
} else {
current + packageName
}
}
fun selectAll() {
val displayedPackages = _displayedApps.value.map { it.packageName }.toSet()
_selectedPackages.value = _selectedPackages.value + displayedPackages
}
fun invertSelection() {
val current = _selectedPackages.value
val displayedPackages = _displayedApps.value.map { it.packageName }.toSet()
_selectedPackages.value = (current - displayedPackages) +
(displayedPackages - current)
}
fun getSelectedPackages(): List<String> = _selectedPackages.value.sorted()
private fun applyFilter(query: String): List<AppInfo> {
if (query.isBlank()) return appsAll
val key = query.uppercase()
return appsAll.filter {
it.appName.uppercase().contains(key) || it.packageName.uppercase().contains(key)
}
}
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
val collator = Collator.getInstance()
val snapshot = SelectedSnapshot
return apps.sortedWith { p1, p2 ->
val p1Selected = snapshot.contains(p1.packageName)
val p2Selected = snapshot.contains(p2.packageName)
when {
p1Selected && !p2Selected -> -1
!p1Selected && p2Selected -> 1
p1.isSystemApp && !p2.isSystemApp -> 1
!p1.isSystemApp && p2.isSystemApp -> -1
else -> collator.compare(p1.appName, p2.appName)
}
}
}
private fun createSpecialItemUnidentified(context: Context): AppInfo {
val icon = context.getDrawable(android.R.drawable.ic_menu_help)
?: context.getDrawable(android.R.drawable.sym_def_app_icon)
?: error("No fallback drawable available")
return AppInfo(
appName = context.getString(com.v2ray.ang.R.string.app_picker_unknown_app),
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
appIcon = icon,
isSystemApp = false,
isSelected = 0
)
}
}
@@ -1,19 +1,30 @@
package com.v2ray.ang.viewmodel
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.util.LogUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.IOException
class LogcatViewModel : ViewModel() {
private val logsetsAll: MutableList<String> = mutableListOf()
private var filteredLogs: List<String> = emptyList()
private var currentFilter: String = ""
fun getAll(): List<String> = filteredLogs
private val _filteredLogs = MutableStateFlow<List<String>>(emptyList())
val filteredLogs: StateFlow<List<String>> = _filteredLogs.asStateFlow()
private val _isRefreshing = MutableStateFlow(false)
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
fun loadLogcat() {
viewModelScope.launch(Dispatchers.IO) {
_isRefreshing.value = true
try {
val lst = LinkedHashSet<String>()
lst.add("logcat")
@@ -30,6 +41,9 @@ class LogcatViewModel : ViewModel() {
applyFilter()
} catch (e: IOException) {
LogUtil.e(AppConfig.TAG, "Failed to get logcat", e)
} finally {
_isRefreshing.value = false
}
}
}
@@ -42,7 +56,7 @@ class LogcatViewModel : ViewModel() {
process.waitFor()
logsetsAll.clear()
filteredLogs = emptyList()
_filteredLogs.value = emptyList()
} catch (e: IOException) {
LogUtil.e(AppConfig.TAG, "Failed to clear logcat", e)
}
@@ -54,7 +68,7 @@ class LogcatViewModel : ViewModel() {
}
private fun applyFilter() {
filteredLogs = if (currentFilter.isEmpty()) {
_filteredLogs.value = if (currentFilter.isEmpty()) {
logsetsAll.toList()
} else {
logsetsAll.filter { it.contains(currentFilter) }
File diff suppressed because it is too large Load Diff
@@ -1,37 +1,79 @@
package com.v2ray.ang.viewmodel
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.v2ray.ang.AppConfig
import com.v2ray.ang.AppConfig.ANG_PACKAGE
import com.v2ray.ang.dto.AppInfo
import com.v2ray.ang.dto.UrlContentRequest
import com.v2ray.ang.extension.v2RayApplication
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import com.v2ray.ang.util.AppManagerUtil
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.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.text.Collator
/**
* ViewModel for PerAppProxy screen.
* Holds all UI state and business logic.
*/
class PerAppProxyViewModel : ViewModel() {
private val blacklist: MutableSet<String> = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)?.let {
HashSet(it)
} ?: HashSet()
fun contains(packageName: String): Boolean = blacklist.contains(packageName)
// Blacklist (apps to be proxied or bypassed)
private val _blacklist = MutableStateFlow(loadBlacklist())
val blacklist: StateFlow<Set<String>> = _blacklist.asStateFlow()
fun getAll(): Set<String> = blacklist.toSet()
// UI states
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
fun add(packageName: String): Boolean {
val changed = blacklist.add(packageName)
if (changed) {
save()
private val _displayedApps = MutableStateFlow<List<AppInfo>>(emptyList())
val displayedApps: StateFlow<List<AppInfo>> = _displayedApps.asStateFlow()
private val _perAppProxyEnabled = MutableStateFlow(
MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false)
)
val perAppProxyEnabled: StateFlow<Boolean> = _perAppProxyEnabled.asStateFlow()
private val _bypassApps = MutableStateFlow(
MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS, false)
)
val bypassApps: StateFlow<Boolean> = _bypassApps.asStateFlow()
// Cached full list for filtering
private var appsAll: List<AppInfo>? = null
// Blacklist operations
fun contains(packageName: String): Boolean = _blacklist.value.contains(packageName)
fun add(packageName: String) {
val newSet = _blacklist.value + packageName
if (newSet != _blacklist.value) {
_blacklist.value = newSet
saveBlacklist()
}
return changed
}
fun remove(packageName: String): Boolean {
val changed = blacklist.remove(packageName)
if (changed) {
save()
fun remove(packageName: String) {
val newSet = _blacklist.value - packageName
if (newSet != _blacklist.value) {
_blacklist.value = newSet
saveBlacklist()
}
return changed
}
fun toggle(packageName: String) {
if (blacklist.contains(packageName)) {
if (_blacklist.value.contains(packageName)) {
remove(packageName)
} else {
add(packageName)
@@ -39,26 +81,202 @@ class PerAppProxyViewModel : ViewModel() {
}
fun addAll(packages: Collection<String>) {
if (blacklist.addAll(packages)) {
save()
val newSet = _blacklist.value + packages
if (newSet != _blacklist.value) {
_blacklist.value = newSet
saveBlacklist()
}
}
fun removeAll(packages: Collection<String>) {
if (blacklist.removeAll(packages.toSet())) {
save()
val newSet = _blacklist.value - packages.toSet()
if (newSet != _blacklist.value) {
_blacklist.value = newSet
saveBlacklist()
}
}
fun clear() {
if (blacklist.isNotEmpty()) {
blacklist.clear()
save()
if (_blacklist.value.isNotEmpty()) {
_blacklist.value = emptySet()
saveBlacklist()
}
}
private fun save() {
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, blacklist)
private fun loadBlacklist(): Set<String> {
return MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)?.toSet() ?: emptySet()
}
private fun saveBlacklist() {
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, _blacklist.value.toMutableSet())
SettingsChangeManager.makeRestartService()
}
// Per‑app proxy switch
fun setPerAppProxyEnabled(enabled: Boolean) {
if (_perAppProxyEnabled.value != enabled) {
_perAppProxyEnabled.value = enabled
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, enabled)
}
}
fun setBypassAppsEnabled(enabled: Boolean) {
if (_bypassApps.value != enabled) {
_bypassApps.value = enabled
MmkvManager.encodeSettings(AppConfig.PREF_BYPASS_APPS, enabled)
}
}
// Load and filter apps
fun loadApps(context: Context) {
viewModelScope.launch {
_isLoading.value = true
try {
val apps = withContext(Dispatchers.IO) {
val list = AppManagerUtil.loadNetworkAppList(context)
sortApps(list)
}
appsAll = apps
_displayedApps.value = apps
} catch (e: Exception) {
LogUtil.e(ANG_PACKAGE, "Error loading apps", e)
} finally {
_isLoading.value = false
}
}
}
fun filterApps(query: String) {
val key = query.uppercase()
_displayedApps.value = if (key.isNotEmpty()) {
appsAll?.filter {
it.appName.uppercase().contains(key) || it.packageName.uppercase().contains(key)
} ?: emptyList()
} else {
appsAll ?: emptyList()
}
}
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
val collator = Collator.getInstance()
return apps.sortedWith { p1, p2 ->
val s1 = contains(p1.packageName)
val s2 = contains(p2.packageName)
when {
s1 && !s2 -> -1
!s1 && s2 -> 1
p1.isSystemApp && !p2.isSystemApp -> 1
!p1.isSystemApp && p2.isSystemApp -> -1
else -> collator.compare(p1.appName, p2.appName)
}
}
}
// Bulk actions
fun selectAll() {
val pkgNames = _displayedApps.value.map { it.packageName }
val allSelected = pkgNames.all { contains(it) }
if (allSelected) removeAll(pkgNames) else addAll(pkgNames)
// Enable per‑app proxy after selection
enablePerAppProxyAndRestart()
}
fun invertSelection() {
_displayedApps.value.forEach { toggle(it.packageName) }
enablePerAppProxyAndRestart()
}
fun selectProxyAppAuto(context: Context) {
viewModelScope.launch {
_isLoading.value = true
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
var content = withContext(Dispatchers.IO) {
HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000
)
)
}
if (content.isNullOrEmpty()) {
val proxyUsername = SettingsManager.getSocksUsername()
val proxyPassword = SettingsManager.getSocksPassword()
val httpPort = SettingsManager.getHttpPort()
content = withContext(Dispatchers.IO) {
HttpUtil.getUrlContent(
UrlContentRequest(
url = url,
timeout = 5000,
httpPort = httpPort,
proxyUsername = proxyUsername,
proxyPassword = proxyPassword
)
)
} ?: ""
}
val success = selectProxyApp(content, true, context)
if (success) {
enablePerAppProxyAndRestart()
}
_isLoading.value = false
}
}
fun importProxyApp(content: String?, context: Context) {
if (content.isNullOrEmpty()) return
val success = selectProxyApp(content, false, context)
if (success) {
enablePerAppProxyAndRestart()
}
}
fun exportProxyApp(): String {
var result = _bypassApps.value.toString()
_blacklist.value.forEach { pkg ->
result = result + System.lineSeparator() + pkg
}
return result
}
private fun selectProxyApp(content: String, force: Boolean, context: Context): Boolean {
try {
val proxyApps = if (content.isEmpty()) {
Utils.readTextFromAssets(context, "proxy_package_name")
} else content
if (proxyApps.isNullOrEmpty()) return false
clear()
val apps = _displayedApps.value
if (_bypassApps.value) {
apps.forEach { app ->
if (!inProxyApps(proxyApps, app.packageName, force)) {
add(app.packageName)
}
}
} else {
apps.forEach { app ->
if (inProxyApps(proxyApps, app.packageName, force)) {
add(app.packageName)
}
}
}
} catch (e: Exception) {
LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
return false
}
return true
}
private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
if (force) {
if (packageName == "com.google.android.webview") return false
if (packageName.startsWith("com.google")) return true
}
return proxyApps.indexOf(packageName) >= 0
}
private fun enablePerAppProxyAndRestart() {
setPerAppProxyEnabled(true)
SettingsChangeManager.makeRestartService()
}
}
@@ -4,28 +4,48 @@ import androidx.lifecycle.ViewModel
import com.v2ray.ang.dto.entities.RulesetItem
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.util.UUID
class RoutingSettingsViewModel : ViewModel() {
private val rulesets: MutableList<RulesetItem> = mutableListOf()
private val _rulesetsFlow = MutableStateFlow<List<RulesetItem>>(emptyList())
val rulesetsFlow: StateFlow<List<RulesetItem>> = _rulesetsFlow.asStateFlow()
fun getAll(): List<RulesetItem> = rulesets.toList()
fun reload() {
val loaded = MmkvManager.decodeRoutingRulesets()?.toMutableList() ?: mutableListOf()
var needsSave = false
loaded.forEachIndexed { index, item ->
if (item.id.isEmpty()) {
item.id = UUID.randomUUID().toString()
SettingsManager.saveRoutingRuleset(index, item)
needsSave = true
}
}
rulesets.clear()
rulesets.addAll(MmkvManager.decodeRoutingRulesets() ?: mutableListOf())
rulesets.addAll(loaded)
_rulesetsFlow.value = rulesets.toList()
}
fun update(position: Int, item: RulesetItem) {
if (position in rulesets.indices) {
rulesets[position] = item
SettingsManager.saveRoutingRuleset(position, item)
_rulesetsFlow.value = rulesets.toList()
}
}
fun swap(fromPosition: Int, toPosition: Int) {
if (fromPosition in rulesets.indices && toPosition in rulesets.indices) {
SettingsManager.swapRoutingRuleset(fromPosition, toPosition)
val item = rulesets.removeAt(fromPosition)
rulesets.add(toPosition, item)
_rulesetsFlow.value = rulesets.toList()
}
}
}
@@ -6,16 +6,23 @@ import com.v2ray.ang.dto.entities.SubscriptionItem
import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.handler.SettingsChangeManager
import com.v2ray.ang.handler.SettingsManager
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
class SubscriptionsViewModel : ViewModel() {
private val subscriptions: MutableList<SubscriptionCache> =
MmkvManager.decodeSubscriptions().toMutableList()
private val _subsFlow = MutableStateFlow<List<SubscriptionCache>>(subscriptions.toList())
val subsFlow: StateFlow<List<SubscriptionCache>> = _subsFlow.asStateFlow()
fun getAll(): List<SubscriptionCache> = subscriptions.toList()
fun reload() {
subscriptions.clear()
subscriptions.addAll(MmkvManager.decodeSubscriptions())
_subsFlow.value = subscriptions.toList()
}
fun remove(subId: String): Boolean {
@@ -24,6 +31,7 @@ class SubscriptionsViewModel : ViewModel() {
SettingsManager.removeSubscriptionWithDefault(subId)
SettingsChangeManager.makeSetupGroupTab()
}
_subsFlow.value = subscriptions.toList()
return changed
}
@@ -33,6 +41,7 @@ class SubscriptionsViewModel : ViewModel() {
subscriptions[idx] = SubscriptionCache(subId, item)
MmkvManager.encodeSubscription(subId, item)
}
_subsFlow.value = subscriptions.toList()
}
fun swap(fromPosition: Int, toPosition: Int) {
@@ -41,7 +50,7 @@ class SubscriptionsViewModel : ViewModel() {
subscriptions.add(toPosition, item)
SettingsManager.swapSubscriptions(fromPosition, toPosition)
SettingsChangeManager.makeSetupGroupTab()
_subsFlow.value = subscriptions.toList()
}
}
}
@@ -10,12 +10,18 @@ import com.v2ray.ang.handler.MmkvManager
import com.v2ray.ang.util.HttpUtil
import com.v2ray.ang.util.LogUtil
import com.v2ray.ang.util.Utils
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File
class UserAssetViewModel : ViewModel() {
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()
val itemCount: Int
get() = assets.size
@@ -27,6 +33,7 @@ class UserAssetViewModel : ViewModel() {
val decoded = MmkvManager.decodeAssetUrls()
assets.clear()
assets.addAll(buildAssetList(decoded, geoFilesSource))
_assetsFlow.value = assets.toList()
}
private fun buildAssetList(
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="@dimen/highlight_alpha_material_colored" android:color="?android:attr/colorControlActivated" android:state_checked="true" android:state_enabled="true" />
<item android:color="?android:attr/colorControlHighlight" />
</selector>
@@ -1,13 +0,0 @@
<!-- res/drawable/custom_divider.xml -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:bottom="0dp"
android:left="16dp"
android:right="16dp"
android:top="0dp">
<shape>
<size android:height="1dp" />
<solid android:color="@color/divider_color_light" />
</shape>
</item>
</layer-list>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
</vector>
@@ -1,7 +0,0 @@
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="@color/divider_color_light" />
<size
android:width="48dp"
android:height="48dp" />
</shape>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3.27,3L2,4.27l5,5V13h3v9l3.58,-6.14L17.73,20 19,18.73 3.27,3zM17,10h-4l4,-8H7v2.18l8.46,8.46L17,10z"/>
</vector>
@@ -1,11 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorControlNormal"
android:pathData="M7,10l5,5 5,-5z" />
android:fillColor="@android:color/white"
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M3,18h18v-2H3v2zM3,13h18v-2H3v2zM3,6v2h18V6H3z"/>
</vector>
@@ -6,5 +6,5 @@
android:left="0dp"
android:right="0dp"
android:top="0dp" />
<solid android:color="@color/color_fab_active" />
<solid android:color="#f97910" />
</shape>
@@ -6,5 +6,5 @@
android:left="0dp"
android:right="0dp"
android:top="0dp" />
<solid android:color="@color/color_fab_inactive" />
<solid android:color="#9C9C9C" />
</shape>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
</vector>
@@ -1,7 +1,6 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?attr/colorControlNormal"
android:viewportWidth="960"
android:viewportHeight="960">
<path
@@ -1,171 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="top"
android:orientation="vertical">
<LinearLayout
android:id="@+id/layout_soure_ccode"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_source_code_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/title_source_code"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_oss_licenses"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/license_24px" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/title_oss_license"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_feedback"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_feedback_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/title_pref_feedback"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_tg_channel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_telegram_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/title_tg_channel"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_privacy_policy"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_privacy_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/title_privacy_policy"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp16">
<TextView
android:id="@+id/tv_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_about"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
<TextView
android:id="@+id/tv_app_id"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_about"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -1,10 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
@@ -1,146 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top"
android:orientation="vertical">
<LinearLayout
android:id="@+id/layout_backup"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_backup_24dp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="@dimen/padding_spacing_dp16">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_configuration_backup"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/layout_share"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_share_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/title_configuration_share"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_restore"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_restore_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/title_configuration_restore"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="top"
android:orientation="vertical"
android:paddingTop="@dimen/padding_spacing_dp16">
<LinearLayout
android:id="@+id/layout_webdav_config_setting"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_settings_24dp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="@dimen/padding_spacing_dp16">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_webdav_config_setting"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -1,37 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="0dp"
android:layout_height="?attr/actionBarSize"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:titleTextAppearance="@style/TextAppearance.AppCompat.Title" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintTop_toBottomOf="@id/toolbar"
android:indeterminate="true"
android:visibility="gone"
app:indicatorColor="@color/color_fab_active" />
<FrameLayout
android:id="@+id/content_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@id/toolbar"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintEnd_toEndOf="parent">
</FrameLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
@@ -1,98 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical"
tools:context=".ui.PerAppProxyActivity">
<LinearLayout
android:id="@+id/header_view"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp16">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/container_per_app_proxy"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_per_app_proxy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:text="@string/per_app_proxy_settings_enable"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
<LinearLayout
android:id="@+id/container_bypass_apps"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_bypass_apps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/switch_bypass_apps_mode"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
<LinearLayout
android:id="@+id/layout_switch_bypass_apps_tips"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:contentDescription="@string/switch_bypass_apps_mode"
android:focusable="true"
android:gravity="center"
android:padding="@dimen/padding_spacing_dp8">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_about_24dp" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" />
</LinearLayout>
@@ -1,83 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
<LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_source_code_24dp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/check_pre_release"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/update_check_pre_release"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_check_update"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_check_update_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/update_check_for_update"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<TextView
android:id="@+id/tv_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_about"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.LogcatActivity">
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/refresh_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</RelativeLayout>
@@ -1,130 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<androidx.appcompat.widget.Toolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
</com.google.android.material.appbar.AppBarLayout>
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.coordinatorlayout.widget.CoordinatorLayout
android:id="@+id/main_content"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progress_bar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="invisible"
app:indicatorColor="@color/color_fab_active" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabIndicatorFullWidth="false"
app:tabMode="scrollable"
app:tabTextAppearance="@style/TabLayoutTextStyle" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
android:layout_width="match_parent"
android:layout_height="0dp"
android:nextFocusRight="@+id/fab"
android:scrollbars="vertical"
android:layout_weight="1" />
<LinearLayout
android:id="@+id/layout_test"
android:layout_width="match_parent"
android:layout_height="@dimen/view_height_dp64"
android:clickable="true"
android:contentDescription="@string/connection_test_pending"
android:focusable="true"
android:nextFocusLeft="@+id/view_pager"
android:nextFocusRight="@+id/fab"
android:orientation="vertical">
<View
android:layout_width="wrap_content"
android:layout_height="1dp"
android:background="@color/divider_color_light" />
<TextView
android:id="@+id/tv_test_state"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:gravity="start|center"
android:maxLines="2"
android:minLines="1"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/connection_test_pending"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
</LinearLayout>
<FrameLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginBottom="-16dp"
android:layout_marginEnd="@dimen/padding_spacing_dp16">
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_marginBottom="@dimen/view_height_dp36"
android:clickable="true"
android:contentDescription="@string/tasker_start_service"
android:focusable="true"
android:nextFocusLeft="@+id/layout_test"
android:src="@drawable/ic_play_24dp"
app:tint="@color/colorWhite"
app:useCompatPadding="true"
app:layout_anchorGravity="bottom|right|end" />
</FrameLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>
</RelativeLayout>
</LinearLayout>
<com.google.android.material.navigation.NavigationView
android:id="@+id/nav_view"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
app:headerLayout="@layout/nav_header"
app:menu="@menu/menu_drawer">
</com.google.android.material.navigation.NavigationView>
</androidx.drawerlayout.widget.DrawerLayout>
@@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent" />
@@ -1,253 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.RoutingEditActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp16">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/sub_setting_remarks" />
<EditText
android:id="@+id/et_remarks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/chk_locked"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/routing_settings_locked"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/routing_settings_rule_title"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/routing_settings_domain" />
<EditText
android:id="@+id/et_domain"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/routing_settings_tips"
android:inputType="textMultiLine"
android:maxLines="10" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/routing_settings_ip" />
<EditText
android:id="@+id/et_ip"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/routing_settings_tips"
android:inputType="textMultiLine"
android:maxLines="10" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/routing_settings_process" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:layout_marginBottom="@dimen/padding_spacing_dp8">
<EditText
android:id="@+id/et_process"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/routing_settings_tips"
android:inputType="textMultiLine"
android:maxLines="10"
android:minHeight="48dp"
android:paddingStart="0dp"
android:paddingEnd="48dp" />
<ImageButton
android:id="@+id/btn_process_picker"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/routing_settings_process"
android:src="@drawable/ic_per_apps_24dp" />
</RelativeLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/routing_settings_port" />
<EditText
android:id="@+id/et_port"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/routing_settings_protocol" />
<EditText
android:id="@+id/et_protocol"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/routing_settings_protocol_tip"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/routing_settings_network" />
<EditText
android:id="@+id/et_network"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/routing_settings_network_tip"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/routing_settings_outbound_tag" />
<RelativeLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:layout_marginBottom="@dimen/padding_spacing_dp8">
<AutoCompleteTextView
android:id="@+id/sp_outbound_tag"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:completionThreshold="0"
android:hint="@string/routing_settings_outbound_tag_hint"
android:inputType="text"
android:imeOptions="actionDone"
android:paddingEnd="40dp" />
<ImageButton
android:id="@+id/btn_outbound_tag_dropdown"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/routing_settings_outbound_tag"
android:src="@drawable/ic_arrow_drop_down" />
</RelativeLayout>
</LinearLayout>
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:layout_marginBottom="@dimen/padding_spacing_dp16"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
@@ -1,67 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.RoutingSettingActivity">
<androidx.core.widget.NestedScrollView
android:id="@+id/main_content"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:id="@+id/layout_domain_strategy"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp16">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/routing_settings_domain_strategy"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
<TextView
android:id="@+id/tv_domain_strategy_summary"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/routing_settings_rule_title"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:nestedScrollingEnabled="false"
tools:listitem="@layout/item_recycler_routing_setting" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</RelativeLayout>
@@ -1,79 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.ServerCustomConfigActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp16">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_need_inbound"
android:textAppearance="@style/TextAppearance.AppCompat.Tooltip" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_server"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_remarks" />
<EditText
android:id="@+id/et_remarks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_content"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
<com.blacksquircle.ui.editorkit.widget.TextProcessor
android:id="@+id/editor"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="top|start" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -1,106 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.ServerCustomConfigActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp16">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_server"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_remarks" />
<EditText
android:id="@+id/et_remarks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_policy_group_type" />
<Spinner
android:id="@+id/sp_policy_group_type"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:layout_marginBottom="@dimen/padding_spacing_dp8"
android:entries="@array/policy_group_type" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_policy_group_subscription_id" />
<Spinner
android:id="@+id/sp_policy_group_sub_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:layout_marginBottom="@dimen/padding_spacing_dp8"
android:entries="@array/policy_group_type" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_policy_group_subscription_filter" />
<EditText
android:id="@+id/et_policy_group_filter"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</LinearLayout>
</LinearLayout>
</ScrollView>
@@ -1,140 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.ServerActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp16">
<include layout="@layout/layout_address_port" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_id3" />
<EditText
android:id="@+id/et_id"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_obfs_password" />
<EditText
android:id="@+id/et_obfs_password"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_port_hop" />
<EditText
android:id="@+id/et_port_hop"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_port_hop_interval" />
<EditText
android:id="@+id/et_port_hop_interval"
android:layout_width="match_parent"
android:layout_height="wrap_content" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_bandwidth_down" />
<EditText
android:id="@+id/et_bandwidth_down"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_bandwidth_up" />
<EditText
android:id="@+id/et_bandwidth_up"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:inputType="text" />
</LinearLayout>
<include layout="@layout/layout_tls_hysteria2" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp16"
android:layout_marginBottom="@dimen/padding_spacing_dp16"
android:orientation="vertical" />
</LinearLayout>
</ScrollView>
@@ -1,72 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
tools:context=".ui.ServerProxyChainActivity">
<androidx.core.widget.NestedScrollView
android:id="@+id/main_content"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_lab_remarks"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
<EditText
android:id="@+id/et_remarks"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="@dimen/padding_spacing_dp8"
android:hint="@string/server_lab_remarks"
android:inputType="text" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="@dimen/padding_spacing_dp16"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_proxy_chain_members"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recycler_proxy_chain_members"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:nestedScrollingEnabled="false"
tools:listitem="@layout/item_recycler_proxy_chain_member" />
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab_add_proxy_chain_member"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="end"
android:layout_margin="@dimen/padding_spacing_dp16"
android:contentDescription="@string/menu_item_add_config"
android:src="@drawable/ic_add_24dp" />
</LinearLayout>
</androidx.core.widget.NestedScrollView>
</RelativeLayout>

Some files were not shown because too many files have changed in this diff Show More