diff --git a/V2rayNG/app/build.gradle.kts b/V2rayNG/app/build.gradle.kts index 689c6fdd..b6726a28 100644 --- a/V2rayNG/app/build.gradle.kts +++ b/V2rayNG/app/build.gradle.kts @@ -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) diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/AngApplication.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/AngApplication.kt index 55fc2e26..58db77ef 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/AngApplication.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/AngApplication.kt @@ -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() } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Components.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Components.kt new file mode 100644 index 00000000..cd5ce5b1 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Components.kt @@ -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() + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Dialog.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Dialog.kt new file mode 100644 index 00000000..e1e09ed5 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Dialog.kt @@ -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, + 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, + 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 + ) +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/compose/FormFields.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/FormFields.kt new file mode 100644 index 00000000..ace22652 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/FormFields.kt @@ -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, + 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() + } + ) + } + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Scrollbar.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Scrollbar.kt new file mode 100644 index 00000000..5730b0b1 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Scrollbar.kt @@ -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( + 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( + 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( + 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( + 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) + ) + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/compose/SettingsItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/SettingsItem.kt new file mode 100644 index 00000000..143ea61c --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/SettingsItem.kt @@ -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, + values: List, + 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 + ) + } + ) +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/compose/SnackBar.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/SnackBar.kt new file mode 100644 index 00000000..be417442 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/SnackBar.kt @@ -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( + 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 { + 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() + ) + } + } + } + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Theme.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Theme.kt new file mode 100644 index 00000000..752964b9 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/compose/Theme.kt @@ -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 = _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) + } + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/BaseAdapterListener.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/BaseAdapterListener.kt deleted file mode 100644 index 43c5f76d..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/BaseAdapterListener.kt +++ /dev/null @@ -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() -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/MainAdapterListener.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/MainAdapterListener.kt deleted file mode 100644 index 182beb17..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/contracts/MainAdapterListener.kt +++ /dev/null @@ -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) - -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/LocateTarget.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/LocateTarget.kt new file mode 100644 index 00000000..ec3359a9 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/LocateTarget.kt @@ -0,0 +1,7 @@ +package com.v2ray.ang.dto + +data class LocateTarget( + val groupId: String, + val groupIndex: Int, + val itemPosition: Int +) \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ProfileItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ProfileItem.kt index 9a4a87d6..5c790d76 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ProfileItem.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ProfileItem.kt @@ -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 - ) - } -} \ No newline at end of file + /** + * 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 + ) +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/RulesetItem.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/RulesetItem.kt index 27f8666b..adf4b799 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/RulesetItem.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/RulesetItem.kt @@ -1,6 +1,7 @@ package com.v2ray.ang.dto.entities data class RulesetItem( + var id: String = "", var remarks: String? = "", var ip: List? = null, var domain: List? = null, diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ServersCache.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ServersCache.kt index dc971e94..05df6151 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ServersCache.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/dto/entities/ServersCache.kt @@ -2,5 +2,7 @@ package com.v2ray.ang.dto.entities data class ServersCache( val guid: String, - val profile: ProfileItem -) \ No newline at end of file + val profile: ProfileItem, + val testDelayMillis: Long = 0L, + val testDelayString: String = "", +) diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/extension/_Ext.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/extension/_Ext.kt index ba0ede48..263a2956 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/extension/_Ext.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/extension/_Ext.kt @@ -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 diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt index 958d5d0f..cf4cc7a1 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/MmkvManager.kt @@ -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 { + 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 { + 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 } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsChangeManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsChangeManager.kt index 5a748141..bad83bb8 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsChangeManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsChangeManager.kt @@ -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) } + + /** + * Atomically consumes the restart flag. + * @return true if a restart was requested, false otherwise. + */ + fun consumeRestartService(): Boolean = + restartService.compareAndSet(true, false) - // Read and clear the restartService flag - fun consumeRestartService(): Boolean { - val v = _restartService.value - _restartService.value = false - return v - } - - // 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) } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt index c5f08181..c419730c 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/handler/SettingsManager.kt @@ -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 diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/CustomDividerItemDecoration.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/CustomDividerItemDecoration.kt deleted file mode 100644 index 0f6d37c0..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/CustomDividerItemDecoration.kt +++ /dev/null @@ -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) - } - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/FileChooserHelper.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/FileChooserHelper.kt index 0818ceb5..9f06c326 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/FileChooserHelper.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/FileChooserHelper.kt @@ -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 = 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) @@ -85,4 +86,4 @@ class FileChooserHelper(private val activity: AppCompatActivity) { documentCreateCallback = null } } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.kt deleted file mode 100644 index 8707d16f..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperAdapter.kt +++ /dev/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.

- *

- * 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.

- *

- * 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) -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.kt deleted file mode 100644 index 75655b0c..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/ItemTouchHelperViewHolder.kt +++ /dev/null @@ -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() -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/MmkvPreferenceDataStore.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/MmkvPreferenceDataStore.kt deleted file mode 100644 index 859dea5f..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/MmkvPreferenceDataStore.kt +++ /dev/null @@ -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?) { - if (values == null) { - MmkvManager.encodeSettings(key, null as String?) - } else { - MmkvManager.encodeSettings(key, values) - } - notifySettingChanged(key) - } - - override fun getStringSet(key: String, defaultValues: MutableSet?): MutableSet? { - 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() - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/PermissionHelper.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/PermissionHelper.kt index 4c29126a..c7b05bc4 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/PermissionHelper.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/PermissionHelper.kt @@ -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 = @@ -43,4 +43,4 @@ class PermissionHelper(private val activity: AppCompatActivity) { permissionLauncher.launch(permission) } } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/QRCodeScannerHelper.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/QRCodeScannerHelper.kt index 5eea4f6e..5c06f4a3 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/QRCodeScannerHelper.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/QRCodeScannerHelper.kt @@ -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 = 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 { diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.kt deleted file mode 100644 index 2160660b..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/helper/SimpleItemTouchHelperCallback.kt +++ /dev/null @@ -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.

- * - * 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 - } -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt index 302e42cf..0efc6ba2 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AboutActivity.kt @@ -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) - } + @Composable + override fun ScreenContent() { + AboutScreen(onBackClick = { finish() }) + } +} - binding.layoutFeedback.setOnClickListener { - Utils.openUri(this, AppConfig.APP_ISSUES_URL) - } +@Composable +fun AboutScreen(onBackClick: () -> Unit) { + val context = LocalContext.current + var showOssDialog by remember { mutableStateOf(false) } - 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() - } + val versionText = "v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})" + val appIdText = BuildConfig.APPLICATION_ID - binding.layoutTgChannel.setOnClickListener { - Utils.openUri(this, AppConfig.TG_CHANNEL_URL) + Scaffold( + contentWindowInsets = ScaffoldDefaults.contentWindowInsets, + topBar = { + AppTopBar( + title = stringResource(R.string.title_about), + onBackClick = onBackClick + ) } - - 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 + ) { 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 + ) } } -} \ No newline at end of file + + 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") + } + }, + 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) + ) + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppPickerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppPickerActivity.kt index b808e608..c44f20e6 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppPickerActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppPickerActivity.kt @@ -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() - private var appsAll: List = 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): List { - 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) { - 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 { - 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, + isLoading: Boolean, + selectedPackages: Set, + 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() + } + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppSelectorAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppSelectorAdapter.kt deleted file mode 100644 index 719664a4..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/AppSelectorAdapter.kt +++ /dev/null @@ -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 -) : RecyclerView.Adapter() { - - var apps: List = 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) { - 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) - } - } -} - diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BackupActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BackupActivity.kt index 84deb969..591b9f5e 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BackupActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BackupActivity.kt @@ -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,71 +45,76 @@ 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 by lazy { + private val isLoadingState = MutableStateFlow(false) + private val webDavConfigState = MutableStateFlow(MmkvManager.decodeWebDavConfig()) + + private val configBackupOptions: Array 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 -> - when (which) { - 0 -> backupViaLocal() - 1 -> backupViaWebDav() - } + @Composable + override fun ScreenContent() { + BackupScreen( + isLoadingState = isLoadingState, + webDavConfigState = webDavConfigState, + backupOptions = configBackupOptions.toList(), + onBackupOptionSelected = { which -> + when (which) { + 0 -> backupViaLocal() + 1 -> backupViaWebDav() } - .show() - } + }, + onShareClick = { shareBackup() }, + restoreOptions = configBackupOptions.toList(), + onRestoreOptionSelected = { which -> + when (which) { + 0 -> restoreViaLocal() + 1 -> restoreViaWebDav() + } + }, + onWebDavSave = { config -> + MmkvManager.encodeWebDavConfig(config) + webDavConfigState.value = config + toastSuccess(R.string.toast_success) + }, + onBackClick = { finish() } + ) + } - binding.layoutShare.setOnClickListener { - val ret = backupConfigurationToCache() - if (ret.first) { - startActivity( - Intent.createChooser( - Intent(Intent.ACTION_SEND).setType("application/zip") - .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - .putExtra( - Intent.EXTRA_STREAM, - FileProvider.getUriForFile( - this, BuildConfig.APPLICATION_ID + ".cache", File(ret.second) - ) - ), getString(R.string.title_configuration_share) - ) + private fun shareBackup() { + val ret = backupConfigurationToCache() + if (ret.first) { + startActivity( + Intent.createChooser( + Intent(Intent.ACTION_SEND).setType("application/zip") + .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + .putExtra( + Intent.EXTRA_STREAM, + FileProvider.getUriForFile( + this, + BuildConfig.APPLICATION_ID + ".cache", + File(ret.second) + ) + ), + getString(R.string.title_configuration_share) ) - } else { - toastError(R.string.toast_failure) - } - } - - 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() + ) + } else { + toastError(R.string.toast_failure) } } @@ -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, + webDavConfigState: StateFlow, + backupOptions: List, + onBackupOptionSelected: (Int) -> Unit, + onShareClick: () -> Unit, + restoreOptions: List, + 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) - } - .setNegativeButton(android.R.string.cancel, null) - .show() } -} \ No newline at end of file + + if (showBackupDialog) { + SelectListDialog( + title = stringResource(R.string.title_configuration_backup), + options = backupOptions, + onSelected = { index, _ -> + showBackupDialog = false + onBackupOptionSelected(index) + }, + onDismiss = { showBackupDialog = false } + ) + } + 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 + ) +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseActivity.kt deleted file mode 100644 index cf975694..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseActivity.kt +++ /dev/null @@ -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(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(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(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(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 - } -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseComponentActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseComponentActivity.kt new file mode 100644 index 00000000..dba8fe20 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseComponentActivity.kt @@ -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() +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseFragment.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseFragment.kt deleted file mode 100644 index 4d18416e..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/BaseFragment.kt +++ /dev/null @@ -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 : 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) - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt index 23883296..ab9bb3e1 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/CheckUpdateActivity.kt @@ -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) - } - 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) } - private fun checkForUpdates(includePreRelease: Boolean) { - toast(R.string.update_checking_for_update) - showLoading() + @Composable + override fun ScreenContent() { + CheckUpdateScreen(onBackClick = { finish() }) + } +} - lifecycleScope.launch { +@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(null) } + + val versionText = "v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})" + + 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) } - } - .setNegativeButton(android.R.string.cancel, null) - .show() + ) + SettingsMenuItem( + icon = painterResource(R.drawable.ic_check_update_24dp), + title = stringResource(R.string.update_check_for_update), + onClick = { checkForUpdates(checkPreRelease) } + ) + VersionInfoBlock(versionText = versionText) + } } -} \ No newline at end of file + + 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 + ) + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/GroupPagerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/GroupPagerAdapter.kt deleted file mode 100644 index 9c8df108..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/GroupPagerAdapter.kt +++ /dev/null @@ -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) : 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) { - this.groups = groups - notifyDataSetChanged() - } -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/GroupServerFragment.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/GroupServerFragment.kt deleted file mode 100644 index 5a3c9ea4..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/GroupServerFragment.kt +++ /dev/null @@ -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(), - 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 by lazy { - ownerActivity.resources.getStringArray(R.array.share_method) - } - private val share_method_more: Array 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, 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) - } - } -} \ No newline at end of file diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/HelperBaseActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/HelperBaseComponentActivity.kt similarity index 87% rename from V2rayNG/app/src/main/java/com/v2ray/ang/ui/HelperBaseActivity.kt rename to V2rayNG/app/src/main/java/com/v2ray/ang/ui/HelperBaseComponentActivity.kt index c8078450..885eadc5 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/HelperBaseActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/HelperBaseComponentActivity.kt @@ -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) { - qrCodeScanner.launch(onResult) - } + protected fun launchQRCodeScanner( + onResult: (String?) -> Unit + ) { + qrCodeScanner.launch(onResult) } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatActivity.kt index 12b7f46a..53e3a50f 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatActivity.kt @@ -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) +@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() - 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 + var searchQuery by remember { mutableStateOf("") } + var showSearch by remember { mutableStateOf(false) } - override fun onQueryTextChange(newText: String?): Boolean { - viewModel.filter(newText) - refreshData() - return false - } - }) - searchView.setOnCloseListener { - viewModel.filter("") - refreshData() - false - } - } - - return super.onCreateOptionsMenu(menu) + val snackbar = LocalAppSnackbar.current + LaunchedEffect(Unit) { + snackbar.show(context.getString(R.string.pull_down_to_refresh)) } + val listState = rememberLazyListState() - 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 + 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("") + 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) + ) + } + } + ) } - - R.id.share_all -> { - shareLogcat() - true - } - - R.id.clear_all -> { - lifecycleScope.launch(Dispatchers.IO) { - viewModel.clearLogcat() - withContext(Dispatchers.Main) { - refreshData() + ) { 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() } } - true - } - - else -> super.onOptionsItemSelected(item) - } - - override fun onRefresh() { - lifecycleScope.launch(Dispatchers.IO) { - viewModel.loadLogcat() - withContext(Dispatchers.Main) { - binding.refreshLayout.isRefreshing = false - refreshData() - } } } +} - @SuppressLint("NotifyDataSetChanged") - fun refreshData() { - adapter.notifyDataSetChanged() +@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 } -} \ No newline at end of file + + 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) + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt deleted file mode 100644 index b77e870a..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/LogcatRecyclerAdapter.kt +++ /dev/null @@ -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() { - - 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) - -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt index 90a42e4c..7136ac01 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainActivity.kt @@ -1,34 +1,144 @@ package com.v2ray.ang.ui +import android.app.Activity import android.content.Intent -import android.content.res.ColorStateList -import android.net.Uri +import android.graphics.Bitmap import android.net.VpnService import android.os.Build import android.os.Bundle import android.view.KeyEvent -import android.view.Menu -import android.view.MenuItem -import androidx.activity.OnBackPressedCallback +import android.view.WindowManager import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels -import androidx.appcompat.app.ActionBarDrawerToggle -import androidx.appcompat.app.AlertDialog -import androidx.appcompat.widget.SearchView -import androidx.core.content.ContextCompat -import androidx.core.view.GravityCompat -import androidx.core.view.isVisible +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.IntrinsicSize +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.heightIn +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.navigationBarsPadding +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.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.itemsIndexed +import androidx.compose.foundation.lazy.grid.rememberLazyGridState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PagerDefaults +import androidx.compose.foundation.pager.PagerState +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.DrawerValue +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalDrawerSheet +import androidx.compose.material3.ModalNavigationDrawer +import androidx.compose.material3.PrimaryScrollableTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Surface +import androidx.compose.material3.Tab +import androidx.compose.material3.TabRowDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.rememberDrawerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.painter.Painter +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineBreak +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.lifecycleScope -import com.google.android.material.navigation.NavigationView -import com.google.android.material.tabs.TabLayoutMediator import com.v2ray.ang.AppConfig import com.v2ray.ang.R +import com.v2ray.ang.compose.AppDivider +import com.v2ray.ang.compose.AppTheme +import com.v2ray.ang.compose.AppTopBar +import com.v2ray.ang.compose.ConfirmDialog +import com.v2ray.ang.compose.SelectListDialog +import com.v2ray.ang.compose.LocalDarkTheme +import com.v2ray.ang.compose.QRCodeDialog +import com.v2ray.ang.compose.ReorderableGridItem +import com.v2ray.ang.compose.ReorderableListItem +import com.v2ray.ang.compose.colorConfigType +import com.v2ray.ang.compose.colorFabActive +import com.v2ray.ang.compose.colorFabInactiveDark +import com.v2ray.ang.compose.colorFabInactiveLight +import com.v2ray.ang.compose.colorPing +import com.v2ray.ang.compose.colorPingRed +import com.v2ray.ang.compose.verticalScrollbar import com.v2ray.ang.core.CoreServiceManager -import com.v2ray.ang.databinding.ActivityMainBinding +import com.v2ray.ang.dto.GroupMapItem +import com.v2ray.ang.dto.entities.ProfileItem +import com.v2ray.ang.dto.entities.ServersCache import com.v2ray.ang.enums.EConfigType import com.v2ray.ang.enums.PermissionType +import com.v2ray.ang.extension.isComplexType +import com.v2ray.ang.extension.nullIfBlank 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 @@ -39,625 +149,412 @@ import com.v2ray.ang.util.Utils import com.v2ray.ang.viewmodel.MainViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import sh.calvin.reorderable.ReorderableItem +import sh.calvin.reorderable.rememberReorderableLazyGridState +import sh.calvin.reorderable.rememberReorderableLazyListState +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Job +import kotlinx.coroutines.yield +import kotlin.math.abs -class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelectedListener { - private val binding by lazy { - ActivityMainBinding.inflate(layoutInflater) - } +class MainActivity : HelperBaseComponentActivity() { + private val mainViewModel: MainViewModel by viewModels() - val mainViewModel: MainViewModel by viewModels() - private lateinit var groupPagerAdapter: GroupPagerAdapter - private var tabMediator: TabLayoutMediator? = null - - private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (it.resultCode == RESULT_OK) { - startV2Ray() + private val requestVpnPermission = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (it.resultCode == RESULT_OK) startV2Ray() } - } - private val requestActivityLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { - if (SettingsChangeManager.consumeRestartService() && mainViewModel.isRunning.value == true) { - restartV2Ray() - } - if (SettingsChangeManager.consumeSetupGroupTab()) { - setupGroupTab() - } - } + // Launcher for profile editor activities (ServerActivity, ServerCustomConfigActivity, etc.) + private val profileEditorLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode != Activity.RESULT_OK) return@registerForActivityResult + + val data = result.data ?: return@registerForActivityResult + val action = data.getStringExtra(ProfileEditorResult.EXTRA_ACTION) + ?: return@registerForActivityResult + + if (action != ProfileEditorResult.ACTION_SAVED && + action != ProfileEditorResult.ACTION_DELETED + ) { + return@registerForActivityResult + } + + val restartService = data.getBooleanExtra( + ProfileEditorResult.EXTRA_RESTART_SERVICE, + false + ) + + mainViewModel.setupGroupTab(forceRefresh = true) + + if (restartService && mainViewModel.uiState.value.isRunning) { + restartV2Ray() + } + } + + // Launcher for settings, subscription, routing, etc. (non-editor sever pages) + private val settingsActivityLauncher = + registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { + val restartService = SettingsChangeManager.consumeRestartService() + val refreshGroups = SettingsChangeManager.consumeSetupGroupTab() + + mainViewModel.refreshUiSettings() + + if (refreshGroups) { + mainViewModel.setupGroupTab(forceRefresh = true) + } + + if (restartService && mainViewModel.uiState.value.isRunning) { + restartV2Ray() + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(binding.root) - setupToolbar(binding.toolbar, false, getString(R.string.title_server)) + mainViewModel.initialize() - // setup viewpager and tablayout - groupPagerAdapter = GroupPagerAdapter(this, emptyList()) - binding.viewPager.adapter = groupPagerAdapter - binding.viewPager.isUserInputEnabled = true - - // setup navigation drawer - setupNavigationDrawer() - - binding.fab.setOnClickListener { handleFabAction() } - binding.layoutTest.setOnClickListener { handleLayoutTestClick() } - - setupGroupTab() - setupViewModel() - SubscriptionUpdater.sync() - mainViewModel.reloadServerList() - - checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) { - } + checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {} } - private fun setupNavigationDrawer() { - val toggle = ActionBarDrawerToggle( - this, - binding.drawerLayout, - binding.toolbar, - R.string.navigation_drawer_open, - R.string.navigation_drawer_close + @Composable + override fun ScreenContent() { + MainScreen( + mainViewModel = mainViewModel, + onFabClick = ::handleFabAction, + onTestClick = ::handleLayoutTestClick, + onNavigate = ::navigateTo, + onImportManually = ::importManually, + onImportQRcode = ::importQRcode, + onImportClipboard = ::importClipboard, + onImportLocal = ::importConfigLocal, + onSubUpdate = ::importConfigViaSub, + onExportAll = ::exportAll, + onRealPingAll = mainViewModel::testAllRealPing, + onRestartService = ::restartV2Ray, + onDelAllConfig = ::delAllConfig, + onDelDuplicateConfig = ::delDuplicateConfig, + onDelInvalidConfig = ::delInvalidConfig, + onSortByTestResults = ::sortByTestResults, + onEditServer = ::editServer, + onRemoveServer = ::removeServer, + onSelectServer = ::setSelectServer, + onShareQRCode = ::getShareQRCodeBitmap, + onShareClipboard = ::shareToClipboard, + onShareFullContent = ::shareFullContentAsync, + onSubscriptionIdChanged = mainViewModel::subscriptionIdChanged, + onLocateSelectedServer = mainViewModel::triggerLocateSelectedServer, + shareMethodEntries = resources.getStringArray(R.array.share_method).toList(), + shareMethodMoreEntries = resources.getStringArray(R.array.share_method_more).toList() ) - binding.drawerLayout.addDrawerListener(toggle) - toggle.syncState() - binding.navView.setNavigationItemSelectedListener(this) - - onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - if (binding.drawerLayout.isDrawerOpen(GravityCompat.START)) { - binding.drawerLayout.closeDrawer(GravityCompat.START) - } else { - isEnabled = false - onBackPressedDispatcher.onBackPressed() - isEnabled = true - } - } - }) } - private fun setupViewModel() { - mainViewModel.updateTestResultAction.observe(this) { setTestState(it) } - mainViewModel.isRunning.observe(this) { isRunning -> - applyRunningState(false, isRunning) + fun getShareQRCodeBitmap(guid: String): Bitmap? = AngConfigManager.share2QRCode(guid) + fun shareToClipboard(guid: String): Boolean = + AngConfigManager.share2Clipboard(this, guid) == 0 + + fun shareFullContentAsync(guid: String) { + lifecycleScope.launch(Dispatchers.IO) { + val result = AngConfigManager.shareFullContent2Clipboard(this@MainActivity, guid) + withContext(Dispatchers.Main) { + if (result == 0) toastSuccess(R.string.toast_success) + else toastError(R.string.toast_failure) + } } - mainViewModel.startListenBroadcast() - mainViewModel.initAssets(assets) } - private fun setupGroupTab() { - val groups = mainViewModel.getSubscriptions(this) - groupPagerAdapter.update(groups) - - tabMediator?.detach() - tabMediator = TabLayoutMediator(binding.tabGroup, binding.viewPager) { tab, position -> - groupPagerAdapter.groups.getOrNull(position)?.let { - tab.text = it.remarks - tab.tag = it.id - } - }.also { it.attach() } - - val targetIndex = groups.indexOfFirst { it.id == mainViewModel.subscriptionId }.takeIf { it >= 0 } ?: (groups.size - 1) - binding.viewPager.setCurrentItem(targetIndex, false) - - binding.tabGroup.isVisible = groups.size > 1 - refreshGroupTabTitles(true) - } - - fun refreshGroupTabTitles(refreshAll: Boolean = false) { - val groupsToRefresh = if (refreshAll || mainViewModel.subscriptionId.isEmpty()) { - groupPagerAdapter.groups - } else { - groupPagerAdapter.groups.filter { it.id == mainViewModel.subscriptionId } - } - - groupsToRefresh.forEach { group -> - if (group.id.isEmpty()) { - return@forEach - } - val tabIndex = groupPagerAdapter.groups.indexOfFirst { it.id == group.id } - if (tabIndex >= 0) { - val count = MmkvManager.decodeServerList(group.id).size - binding.tabGroup.getTabAt(tabIndex)?.text = "${group.remarks} ($count)" + private fun navigateTo(destination: String) { + val intent = when (destination) { + "sub_setting" -> Intent(this, SubSettingActivity::class.java) + "per_app_proxy" -> Intent(this, PerAppProxyActivity::class.java) + "routing_setting" -> Intent(this, RoutingSettingActivity::class.java) + "user_asset" -> Intent(this, UserAssetActivity::class.java) + "settings" -> Intent(this, SettingsActivity::class.java) + "logcat" -> Intent(this, LogcatActivity::class.java) + "check_update" -> Intent(this, CheckUpdateActivity::class.java) + "backup_restore" -> Intent(this, BackupActivity::class.java) + "about" -> Intent(this, AboutActivity::class.java) + "promotion" -> { + Utils.openUri( + this, + "${Utils.decode(AppConfig.APP_PROMOTION_URL)}?t=${System.currentTimeMillis()}" + ) + return } + else -> return } + settingsActivityLauncher.launch(intent) } private fun handleFabAction() { - applyRunningState(isLoading = true, isRunning = false) - - if (mainViewModel.isRunning.value == true) { + if (mainViewModel.uiState.value.isRunning) { CoreServiceManager.stopVService(this) } else if (SettingsManager.isVpnMode()) { val intent = VpnService.prepare(this) - if (intent == null) { - startV2Ray() - } else { - requestVpnPermission.launch(intent) - } + if (intent == null) startV2Ray() else requestVpnPermission.launch(intent) } else { startV2Ray() } } private fun handleLayoutTestClick() { - if (mainViewModel.isRunning.value == true) { - setTestState(getString(R.string.connection_test_testing)) - mainViewModel.testCurrentServerRealPing() - } else { - // service not running: keep existing no-op (could show a message if desired) - } + if (mainViewModel.uiState.value.isRunning) mainViewModel.testCurrentServerRealPing() } private fun startV2Ray() { if (MmkvManager.getSelectServer().isNullOrEmpty()) { - toast(R.string.title_file_chooser) - return + toast(R.string.title_file_chooser); return } - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN && MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING)) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN && + MmkvManager.decodeSettingsBool(AppConfig.PREF_PROXY_SHARING) + ) { checkAndRequestPermission(PermissionType.ACCESS_LOCAL_NETWORK) {} } - CoreServiceManager.startVService(this) } - fun restartV2Ray() { - if (mainViewModel.isRunning.value == true) { - CoreServiceManager.stopVService(this) - } - lifecycleScope.launch { - delay(500) - startV2Ray() - } - } - - private fun setTestState(content: String?) { - binding.tvTestState.text = content - } - - private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) { - if (isLoading) { - binding.fab.setImageResource(R.drawable.ic_fab_check) - return - } - - if (isRunning) { - binding.fab.setImageResource(R.drawable.ic_stop_24dp) - binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active)) - binding.fab.contentDescription = getString(R.string.action_stop_service) - setTestState(getString(R.string.connection_connected)) - binding.layoutTest.isFocusable = true - } else { - binding.fab.setImageResource(R.drawable.ic_play_24dp) - binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive)) - binding.fab.contentDescription = getString(R.string.tasker_start_service) - setTestState(getString(R.string.connection_not_connected)) - binding.layoutTest.isFocusable = false - } - } - - override fun onResume() { - super.onResume() - } - - override fun onPause() { - super.onPause() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.menu_main, 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 { - mainViewModel.filterConfig(newText.orEmpty()) - return false - } - }) - - searchView.setOnCloseListener { - mainViewModel.filterConfig("") - false - } - } - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - R.id.import_qrcode -> { - importQRcode() - true - } - - R.id.import_clipboard -> { - importClipboard() - true - } - - R.id.import_local -> { - importConfigLocal() - true - } - - R.id.import_manually_policy_group -> { - importManually(EConfigType.POLICYGROUP.value) - true - } - - R.id.import_manually_proxy_chain -> { - importManually(EConfigType.PROXYCHAIN.value) - true - } - - R.id.import_manually_vmess -> { - importManually(EConfigType.VMESS.value) - true - } - - R.id.import_manually_vless -> { - importManually(EConfigType.VLESS.value) - true - } - - R.id.import_manually_ss -> { - importManually(EConfigType.SHADOWSOCKS.value) - true - } - - R.id.import_manually_socks -> { - importManually(EConfigType.SOCKS.value) - true - } - - R.id.import_manually_http -> { - importManually(EConfigType.HTTP.value) - true - } - - R.id.import_manually_trojan -> { - importManually(EConfigType.TROJAN.value) - true - } - - R.id.import_manually_wireguard -> { - importManually(EConfigType.WIREGUARD.value) - true - } - - R.id.import_manually_hysteria2 -> { - importManually(EConfigType.HYSTERIA2.value) - true - } - - R.id.export_all -> { - exportAll() - true - } - - R.id.real_ping_all -> { - toast(getString(R.string.connection_test_testing_count, mainViewModel.serversCache.count())) - mainViewModel.testAllRealPing() - true - } - - R.id.service_restart -> { - restartV2Ray() - true - } - - R.id.del_all_config -> { - delAllConfig() - true - } - - R.id.del_duplicate_config -> { - delDuplicateConfig() - true - } - - R.id.del_invalid_config -> { - delInvalidConfig() - true - } - - R.id.sort_by_test_results -> { - sortByTestResults() - true - } - - R.id.sub_update -> { - importConfigViaSub() - true - } - - R.id.locate_selected_config -> { - locateSelectedServer() - true - } - - else -> super.onOptionsItemSelected(item) + private fun restartV2Ray() { + if (mainViewModel.uiState.value.isRunning) CoreServiceManager.stopVService(this) + lifecycleScope.launch { delay(500); startV2Ray() } } private fun importManually(createConfigType: Int) { - if (createConfigType == EConfigType.POLICYGROUP.value) { - startActivity( - Intent() - .putExtra("subscriptionId", mainViewModel.subscriptionId) - .setClass(this, ServerGroupActivity::class.java) - ) - } else if (createConfigType == EConfigType.PROXYCHAIN.value) { - startActivity( - Intent() - .putExtra("subscriptionId", mainViewModel.subscriptionId) - .setClass(this, ServerProxyChainActivity::class.java) - ) - } else { - startActivity( - Intent() - .putExtra("createConfigType", createConfigType) - .putExtra("subscriptionId", mainViewModel.subscriptionId) - .setClass(this, ServerActivity::class.java) - ) - } - } - - /** - * import config from qrcode - */ - private fun importQRcode(): Boolean { - launchQRCodeScanner { scanResult -> - if (scanResult != null) { - importBatchConfig(scanResult) + val intent = when (createConfigType) { + EConfigType.POLICYGROUP.value -> Intent(this, ServerGroupActivity::class.java) + EConfigType.PROXYCHAIN.value -> Intent(this, ServerProxyChainActivity::class.java) + else -> Intent(this, ServerActivity::class.java).apply { + putExtra("createConfigType", createConfigType) } + }.apply { + putExtra("subscriptionId", mainViewModel.subscriptionId) } - return true + profileEditorLauncher.launch(intent) } - /** - * import config from clipboard - */ - private fun importClipboard() - : Boolean { + private fun importQRcode() { + launchQRCodeScanner { scanResult -> + if (scanResult != null) importBatchConfig(scanResult) + } + } + + private fun importClipboard() { try { - val clipboard = Utils.getClipboard(this) - importBatchConfig(clipboard) + importBatchConfig(Utils.getClipboard(this)) } catch (e: Exception) { LogUtil.e(AppConfig.TAG, "Failed to import config from clipboard", e) - return false } - return true } private fun importBatchConfig(server: String?) { - showLoading() - - lifecycleScope.launch(Dispatchers.IO) { + mainViewModel.setLoading(true) + lifecycleScope.launch { try { - val (count, countSub) = AngConfigManager.importBatchConfig(server, mainViewModel.subscriptionId, true) - delay(500L) - withContext(Dispatchers.Main) { - when { - count > 0 -> { - toast(getString(R.string.title_import_config_count, count)) - mainViewModel.reloadServerList() - refreshGroupTabTitles() - } - - countSub > 0 -> setupGroupTab() - else -> toastError(R.string.toast_failure) - } - hideLoading() - } - } catch (e: Exception) { - withContext(Dispatchers.Main) { - toastError(R.string.toast_failure) - hideLoading() - } - LogUtil.e(AppConfig.TAG, "Failed to import batch config", e) - } - } - } - - /** - * import config from local config file - */ - private fun importConfigLocal(): Boolean { - try { - showFileChooser() - } catch (e: Exception) { - LogUtil.e(AppConfig.TAG, "Failed to import config from local file", e) - return false - } - return true - } - - - /** - * import config from sub - */ - fun importConfigViaSub(): Boolean { - showLoading() - - lifecycleScope.launch(Dispatchers.IO) { - val result = mainViewModel.updateConfigViaSubAll() - delay(500L) - launch(Dispatchers.Main) { - if (result.successCount + result.failureCount + result.skipCount == 0) { - toast(R.string.title_update_subscription_no_subscription) - } else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) { - toast(getString(R.string.title_update_config_count, result.configCount)) - } else { - toast( - getString( - R.string.title_update_subscription_result, - result.configCount, result.successCount, result.failureCount, result.skipCount - ) + val (count, countSub) = withContext(Dispatchers.IO) { + AngConfigManager.importBatchConfig( + server, + mainViewModel.subscriptionId, + true ) } - if (result.configCount > 0) { - mainViewModel.reloadServerList() - refreshGroupTabTitles() + when { + count > 0 -> { + toast(getString(R.string.title_import_config_count, count)) + mainViewModel.setupGroupTab(forceRefresh = true) + } + countSub > 0 -> mainViewModel.setupGroupTab(forceRefresh = true) + else -> toastError(R.string.toast_failure) } - hideLoading() + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + LogUtil.e(AppConfig.TAG, "Failed to import batch config", e) + toastError(R.string.toast_failure) + } finally { + mainViewModel.setLoading(false) + } + } + } + + private fun importConfigLocal() { + launchFileChooser { uri -> + if (uri == null) return@launchFileChooser + try { + contentResolver.openInputStream(uri) + .use { input -> importBatchConfig(input?.bufferedReader()?.readText()) } + } catch (e: Exception) { + LogUtil.e(AppConfig.TAG, "Failed to read content from URI", e) + } + } + } + + private fun importConfigViaSub() { + mainViewModel.setLoading(true) + lifecycleScope.launch { + try { + val result = withContext(Dispatchers.IO) { + mainViewModel.updateConfigViaSubAll() + } + when { + result.successCount + result.failureCount + result.skipCount == 0 -> + toast(R.string.title_update_subscription_no_subscription) + result.successCount > 0 && result.failureCount + result.skipCount == 0 -> + toast(getString(R.string.title_update_config_count, result.configCount)) + else -> + toast( + getString( + R.string.title_update_subscription_result, + result.configCount, + result.successCount, + result.failureCount, + result.skipCount + ) + ) + } + if (result.configCount > 0) { + mainViewModel.setupGroupTab(forceRefresh = true) + mainViewModel.refreshSelectedGuid() + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + LogUtil.e(AppConfig.TAG, "Subscription update failed", e) + toastError(R.string.toast_failure) + } finally { + mainViewModel.setLoading(false) } } - return true } private fun exportAll() { - showLoading() - lifecycleScope.launch(Dispatchers.IO) { - val ret = mainViewModel.exportAllServer() - launch(Dispatchers.Main) { - if (ret > 0) - toast(getString(R.string.title_export_config_count, ret)) - else - toastError(R.string.toast_failure) - hideLoading() + mainViewModel.setLoading(true) + lifecycleScope.launch { + try { + val ret = withContext(Dispatchers.IO) { + mainViewModel.exportAllServer() + } + if (ret > 0) toast(getString(R.string.title_export_config_count, ret)) + else toastError(R.string.toast_failure) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + LogUtil.e(AppConfig.TAG, "Export failed", e) + toastError(R.string.toast_failure) + } finally { + mainViewModel.setLoading(false) } } } private fun delAllConfig() { - AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm) - .setPositiveButton(android.R.string.ok) { _, _ -> - showLoading() - lifecycleScope.launch(Dispatchers.IO) { - val ret = mainViewModel.removeAllServer() - launch(Dispatchers.Main) { - mainViewModel.reloadServerList() - refreshGroupTabTitles() - toast(getString(R.string.title_del_config_count, ret)) - hideLoading() - } + mainViewModel.setLoading(true) + lifecycleScope.launch { + try { + val ret = withContext(Dispatchers.IO) { + mainViewModel.removeAllServer() } + mainViewModel.setupGroupTab(forceRefresh = true) + toast(getString(R.string.title_del_config_count, ret)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + LogUtil.e(AppConfig.TAG, "Delete all failed", e) + toastError(R.string.toast_failure) + } finally { + mainViewModel.setLoading(false) } - .setNegativeButton(android.R.string.cancel) { _, _ -> - //do noting - } - .show() + } } private fun delDuplicateConfig() { - AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm) - .setPositiveButton(android.R.string.ok) { _, _ -> - showLoading() - lifecycleScope.launch(Dispatchers.IO) { - val ret = mainViewModel.removeDuplicateServer() - launch(Dispatchers.Main) { - mainViewModel.reloadServerList() - refreshGroupTabTitles() - toast(getString(R.string.title_del_duplicate_config_count, ret)) - hideLoading() - } + mainViewModel.setLoading(true) + lifecycleScope.launch { + try { + val ret = withContext(Dispatchers.IO) { + mainViewModel.removeDuplicateServer() } + mainViewModel.setupGroupTab(forceRefresh = true) + toast(getString(R.string.title_del_duplicate_config_count, ret)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + LogUtil.e(AppConfig.TAG, "Delete duplicate failed", e) + toastError(R.string.toast_failure) + } finally { + mainViewModel.setLoading(false) } - .setNegativeButton(android.R.string.cancel) { _, _ -> - //do noting - } - .show() + } } private fun delInvalidConfig() { - AlertDialog.Builder(this).setMessage(R.string.del_invalid_config_comfirm) - .setPositiveButton(android.R.string.ok) { _, _ -> - showLoading() - lifecycleScope.launch(Dispatchers.IO) { - val ret = mainViewModel.removeInvalidServer() - launch(Dispatchers.Main) { - mainViewModel.reloadServerList() - refreshGroupTabTitles() - toast(getString(R.string.title_del_config_count, ret)) - hideLoading() - } + mainViewModel.setLoading(true) + lifecycleScope.launch { + try { + val ret = withContext(Dispatchers.IO) { + mainViewModel.removeInvalidServer() } + mainViewModel.setupGroupTab(forceRefresh = true) + toast(getString(R.string.title_del_config_count, ret)) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + LogUtil.e(AppConfig.TAG, "Delete invalid failed", e) + toastError(R.string.toast_failure) + } finally { + mainViewModel.setLoading(false) } - .setNegativeButton(android.R.string.cancel) { _, _ -> - //do noting - } - .show() + } } private fun sortByTestResults() { - showLoading() - lifecycleScope.launch(Dispatchers.IO) { - mainViewModel.sortByTestResults() - launch(Dispatchers.Main) { - mainViewModel.reloadServerList() - hideLoading() + mainViewModel.setLoading(true) + lifecycleScope.launch { + try { + withContext(Dispatchers.IO) { + mainViewModel.sortByTestResults() + } + mainViewModel.setupGroupTab(forceRefresh = true) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (e: Exception) { + LogUtil.e(AppConfig.TAG, "Sort by test results failed", e) + toastError(R.string.toast_failure) + } finally { + mainViewModel.setLoading(false) } } } - /** - * show file chooser - */ - private fun showFileChooser() { - launchFileChooser { uri -> - if (uri == null) { - return@launchFileChooser - } - - readContentFromUri(uri) + 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(this, activityClass).apply { + putExtra("guid", guid) + putExtra("isRunning", mainViewModel.uiState.value.isRunning) + putExtra("createConfigType", profile.configType.value) + putExtra("subscriptionId", mainViewModel.subscriptionId) + } + profileEditorLauncher.launch(intent) } - /** - * read content from uri - */ - private fun readContentFromUri(uri: Uri) { - try { - contentResolver.openInputStream(uri).use { input -> - importBatchConfig(input?.bufferedReader()?.readText()) - } - } catch (e: Exception) { - LogUtil.e(AppConfig.TAG, "Failed to read content from URI", e) + private fun removeServer(guid: String) { + if (guid == MmkvManager.getSelectServer()) { + toast(R.string.toast_action_not_allowed); return } + mainViewModel.removeServerAndRefresh(guid) } - /** - * Locates and scrolls to the currently selected server. - * If the selected server is in a different group, automatically switches to that group first. - */ - private fun locateSelectedServer() { - val targetSubscriptionId = mainViewModel.findSubscriptionIdBySelect() - if (targetSubscriptionId.isNullOrEmpty()) { - toast(R.string.title_file_chooser) - return - } - - val targetGroupIndex = groupPagerAdapter.groups.indexOfFirst { it.id == targetSubscriptionId } - if (targetGroupIndex < 0) { - toast(R.string.toast_server_not_found_in_group) - return - } - - // Switch to target group if needed, then scroll to the server - if (binding.viewPager.currentItem != targetGroupIndex) { - binding.viewPager.setCurrentItem(targetGroupIndex, true) - binding.viewPager.postDelayed({ scrollToSelectedServer(targetGroupIndex) }, 1000) - } else { - scrollToSelectedServer(targetGroupIndex) - } - } - - /** - * Scrolls to the selected server in the specified fragment. - * @param groupIndex The index of the group/fragment to scroll in - */ - private fun scrollToSelectedServer(groupIndex: Int) { - val itemId = groupPagerAdapter.getItemId(groupIndex) - val fragment = supportFragmentManager.findFragmentByTag("f$itemId") as? GroupServerFragment - - if (fragment?.isAdded == true && fragment.view != null) { - fragment.scrollToSelectedServer() - } else { - toast(R.string.toast_fragment_not_available) + private fun setSelectServer(guid: String) { + val selected = MmkvManager.getSelectServer() + if (guid != selected) { + mainViewModel.updateSelectedGuid(guid) + if (mainViewModel.uiState.value.isRunning) restartV2Ray() } } @@ -668,29 +565,1098 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect } return super.onKeyDown(keyCode, event) } +} +@Composable +private fun MainDialogs( + showDelAllConfirm: Boolean, + onDismissDelAll: () -> Unit, + onConfirmDelAll: () -> Unit, + showDelDuplicateConfirm: Boolean, + onDismissDelDuplicate: () -> Unit, + onConfirmDelDuplicate: () -> Unit, + showDelInvalidConfirm: Boolean, + onDismissDelInvalid: () -> Unit, + onConfirmDelInvalid: () -> Unit, + showRemoveConfirm: String?, + onDismissRemove: () -> Unit, + onConfirmRemove: (String) -> Unit, +) { + if (showDelAllConfirm) { + ConfirmDialog( + message = stringResource(R.string.del_config_comfirm), + confirmText = stringResource(android.R.string.ok), + dismissText = stringResource(android.R.string.cancel), + onConfirm = onConfirmDelAll, + onDismiss = onDismissDelAll + ) + } + if (showDelDuplicateConfirm) { + ConfirmDialog( + message = stringResource(R.string.del_config_comfirm), + confirmText = stringResource(android.R.string.ok), + dismissText = stringResource(android.R.string.cancel), + onConfirm = onConfirmDelDuplicate, + onDismiss = onDismissDelDuplicate + ) + } + if (showDelInvalidConfirm) { + ConfirmDialog( + message = stringResource(R.string.del_invalid_config_comfirm), + confirmText = stringResource(android.R.string.ok), + dismissText = stringResource(android.R.string.cancel), + onConfirm = onConfirmDelInvalid, + onDismiss = onDismissDelInvalid + ) + } + if (showRemoveConfirm != null) { + val guid = showRemoveConfirm + ConfirmDialog( + message = stringResource(R.string.del_config_comfirm), + confirmText = stringResource(android.R.string.ok), + dismissText = stringResource(android.R.string.cancel), + onConfirm = { onConfirmRemove(guid) }, + onDismiss = onDismissRemove + ) + } +} - override fun onNavigationItemSelected(item: MenuItem): Boolean { - // Handle navigation view item clicks here. - when (item.itemId) { - R.id.sub_setting -> requestActivityLauncher.launch(Intent(this, SubSettingActivity::class.java)) - R.id.per_app_proxy_settings -> requestActivityLauncher.launch(Intent(this, PerAppProxyActivity::class.java)) - R.id.routing_setting -> requestActivityLauncher.launch(Intent(this, RoutingSettingActivity::class.java)) - R.id.user_asset_setting -> requestActivityLauncher.launch(Intent(this, UserAssetActivity::class.java)) - R.id.settings -> requestActivityLauncher.launch(Intent(this, SettingsActivity::class.java)) - R.id.promotion -> Utils.openUri(this, "${Utils.decode(AppConfig.APP_PROMOTION_URL)}?t=${System.currentTimeMillis()}") - R.id.logcat -> startActivity(Intent(this, LogcatActivity::class.java)) - R.id.check_for_update -> startActivity(Intent(this, CheckUpdateActivity::class.java)) - R.id.backup_restore -> requestActivityLauncher.launch(Intent(this, BackupActivity::class.java)) - R.id.about -> startActivity(Intent(this, AboutActivity::class.java)) +@Composable +private fun MainBottomBar( + displayText: String, + isRunning: Boolean, + isDarkTheme: Boolean, + onTestClick: () -> Unit, + onFabClick: () -> Unit +) { + Box(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.fillMaxWidth()) { + AppDivider() + Surface( + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.navigationBars) + .height(64.dp) + .clickable(onClick = onTestClick), + color = MaterialTheme.colorScheme.surface, + tonalElevation = 0.dp + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text(text = displayText, style = MaterialTheme.typography.bodyMedium) + } + } + } + FloatingActionButton( + onClick = onFabClick, + modifier = Modifier + .align(Alignment.TopEnd) + .padding(end = 24.dp) + .offset(y = (-28).dp) + .navigationBarsPadding(), + containerColor = if (isRunning) colorFabActive + else if (isDarkTheme) colorFabInactiveDark + else colorFabInactiveLight + ) { + Icon( + painter = if (isRunning) painterResource(R.drawable.ic_stop_24dp) + else painterResource(R.drawable.ic_play_24dp), + contentDescription = if (isRunning) "Stop" else "Start", + tint = Color.White, + modifier = Modifier.size(24.dp) + ) + } + } +} + +private suspend fun PagerState.navigateToPageOptimized( + targetPage: Int, + animateAdjacentPage: Boolean = true +) { + if (pageCount <= 0) return + + val target = targetPage.coerceIn(0, pageCount - 1) + val current = settledPage.coerceIn(0, pageCount - 1) + + if (target == current) return + + val distance = abs(target - current) + + when { + distance == 1 && animateAdjacentPage -> animateScrollToPage(target) + animateAdjacentPage -> { + val adjacent = if (target > current) target - 1 else target + 1 + scrollToPage(adjacent) + yield() + animateScrollToPage(target) + } + else -> scrollToPage(target) + } +} + +@Composable +private fun GroupTabBar( + groups: List, + selectedTabIndex: Int, + mainViewModel: MainViewModel, + onTabClick: (Int) -> Unit, + modifier: Modifier = Modifier +) { + PrimaryScrollableTabRow( + selectedTabIndex = selectedTabIndex.coerceIn(0, groups.lastIndex), + modifier = modifier.fillMaxWidth(), + containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.95f), + contentColor = MaterialTheme.colorScheme.onSurface, + edgePadding = 16.dp, + minTabWidth = 56.dp, + indicator = { + TabRowDefaults.PrimaryIndicator( + modifier = Modifier + .tabIndicatorOffset( + selectedTabIndex = selectedTabIndex.coerceIn(0, groups.lastIndex), + matchContentSize = true + ) + .clip(RoundedCornerShape(3.dp)), + width = Dp.Unspecified, + color = colorFabActive + ) + }, + divider = {} + ) { + groups.forEachIndexed { index, group -> + GroupTabItem( + group = group, + selected = index == selectedTabIndex, + serverFlowProvider = { + mainViewModel.serversForGroup(group.id) + }, + onClick = { onTabClick(index) } + ) + } + } +} + +@Composable +private fun GroupTabItem( + group: GroupMapItem, + selected: Boolean, + serverFlowProvider: () -> StateFlow>, + onClick: () -> Unit +) { + val serverFlow = remember(group.id) { + serverFlowProvider() + } + val servers by serverFlow.collectAsStateWithLifecycle() + + Tab( + selected = selected, + onClick = onClick, + text = { + val text = if (group.id.isEmpty()) { + group.remarks + } else { + "${group.remarks} (${servers.size})" + } + Text( + text = text, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Ellipsis + ) + } + ) +} + +@Composable +private fun GroupPagerPage( + groupId: String, + mainViewModel: MainViewModel, + selectedGuid: String?, + doubleColumnDisplay: Boolean, + confirmRemove: Boolean, + searchQuery: String, + lazyListStates: MutableMap, + lazyGridStates: MutableMap, + onSelectServer: (String) -> Unit, + onEditServer: (String, ProfileItem) -> Unit, + onShareServer: (String, ProfileItem) -> Unit, + onMoreServer: (String, ProfileItem) -> Unit, + onRemoveServer: (String) -> Unit, + contentPadding: PaddingValues +) { + val serverFlow = remember(groupId) { + mainViewModel.serversForGroup(groupId) + } + val servers by serverFlow.collectAsStateWithLifecycle() + + val canReorder = groupId.isNotEmpty() && searchQuery.isEmpty() + + ServerListPage( + servers = servers, + selectedGuid = selectedGuid, + canReorder = canReorder, + doubleColumnDisplay = doubleColumnDisplay, + subscriptionId = groupId, + confirmRemove = confirmRemove, + groupId = groupId, + lazyListStates = lazyListStates, + lazyGridStates = lazyGridStates, + onSelectServer = onSelectServer, + onEditServer = onEditServer, + onShareServer = onShareServer, + onMoreServer = onMoreServer, + onRemoveServer = onRemoveServer, + onSwapServer = mainViewModel::swapServer, + contentPadding = contentPadding + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MainScreen( + mainViewModel: MainViewModel, + onFabClick: () -> Unit, + onTestClick: () -> Unit, + onNavigate: (String) -> Unit, + onImportManually: (Int) -> Unit, + onImportQRcode: () -> Unit, + onImportClipboard: () -> Unit, + onImportLocal: () -> Unit, + onSubUpdate: () -> Unit, + onExportAll: () -> Unit, + onRealPingAll: () -> Unit, + onRestartService: () -> Unit, + onDelAllConfig: () -> Unit, + onDelDuplicateConfig: () -> Unit, + onDelInvalidConfig: () -> Unit, + onSortByTestResults: () -> Unit, + onEditServer: (String, ProfileItem) -> Unit, + onRemoveServer: (String) -> Unit, + onSelectServer: (String) -> Unit, + onShareQRCode: (String) -> Bitmap?, + onShareClipboard: (String) -> Boolean, + onShareFullContent: (String) -> Unit, + onSubscriptionIdChanged: (String) -> Unit, + onLocateSelectedServer: () -> Unit, + shareMethodEntries: List, + shareMethodMoreEntries: List +) { + val context = LocalContext.current + val uiState by mainViewModel.uiState.collectAsStateWithLifecycle() + val groups = uiState.groups + val isLoading = uiState.isLoading + val isRunning = uiState.isRunning + val displayText = uiState.statusText + val selectedGuid = uiState.selectedGuid + val doubleColumnDisplay = uiState.doubleColumnDisplay + val confirmRemove = uiState.confirmRemove + + val isDarkTheme = LocalDarkTheme.current + val drawerState = rememberDrawerState(DrawerValue.Closed) + val scope = rememberCoroutineScope() + var showSearch by remember { mutableStateOf(false) } + var searchQuery by remember { mutableStateOf("") } + var showMenu by remember { mutableStateOf(false) } + var showImportMenu by remember { mutableStateOf(false) } + var showDelAllConfirm by remember { mutableStateOf(false) } + var showDelDuplicateConfirm by remember { mutableStateOf(false) } + var showDelInvalidConfirm by remember { mutableStateOf(false) } + var showRemoveConfirm by remember { mutableStateOf(null) } + + var shareTarget by remember { mutableStateOf?>(null) } + var showQRCodeBitmap by remember { mutableStateOf(null) } + + val pagerState = rememberPagerState( + initialPage = 0, + pageCount = { groups.size.coerceAtLeast(1) } + ) + + val lazyListStates = remember { mutableStateMapOf() } + val lazyGridStates = remember { mutableStateMapOf() } + + val drawerScrollState = rememberScrollState() + val importMenuScrollState = rememberScrollState() + val moreMenuScrollState = rememberScrollState() + + val statusBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + val navBarHeight = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() + val maxMenuHeight = LocalConfiguration.current.screenHeightDp.dp - statusBarHeight - navBarHeight - 20.dp + + var locateInProgress by remember { mutableStateOf(false) } + + LaunchedEffect(groups) { + val validGroupIds = groups.map { it.id }.toSet() + lazyListStates.keys.retainAll(validGroupIds) + lazyGridStates.keys.retainAll(validGroupIds) + } + + val latestDoubleColumnDisplay by rememberUpdatedState(doubleColumnDisplay) + + LaunchedEffect(groups, uiState.selectedGroupId) { + if (groups.isEmpty()) return@LaunchedEffect + val selectedIndex = groups.indexOfFirst { it.id == uiState.selectedGroupId } + .takeIf { it >= 0 } ?: 0 + if (!pagerState.isScrollInProgress && pagerState.settledPage != selectedIndex) { + pagerState.scrollToPage(selectedIndex) + } + } + + val latestGroups by rememberUpdatedState(groups) + val latestLocateInProgress by rememberUpdatedState(locateInProgress) + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage } + .distinctUntilChanged() + .collect { page -> + val currentGroups = latestGroups + if (!latestLocateInProgress && page in currentGroups.indices) { + onSubscriptionIdChanged(currentGroups[page].id) + } + } + } + + LaunchedEffect(mainViewModel, pagerState) { + mainViewModel.locateEvent.collect { target -> + if (target.groupIndex !in 0 until pagerState.pageCount) return@collect + + locateInProgress = true + try { + if (pagerState.settledPage != target.groupIndex) { + pagerState.navigateToPageOptimized( + targetPage = target.groupIndex, + animateAdjacentPage = false + ) + } + onSubscriptionIdChanged(target.groupId) + + repeat(10) { + val ready = if (latestDoubleColumnDisplay) { + lazyGridStates[target.groupId] != null + } else { + lazyListStates[target.groupId] != null + } + if (ready) return@repeat + delay(16L) + } + + if (latestDoubleColumnDisplay) { + lazyGridStates[target.groupId]?.let { gridState -> + gridState.scrollToItem( + index = target.itemPosition, + scrollOffset = -gridState.layoutInfo.viewportSize.height / 3 + ) + } + } else { + lazyListStates[target.groupId]?.let { listState -> + listState.scrollToItem( + index = target.itemPosition, + scrollOffset = -listState.layoutInfo.viewportSize.height / 3 + ) + } + } + } finally { + delay(32L) + locateInProgress = false + } + } + } + + MainDialogs( + showDelAllConfirm = showDelAllConfirm, + onDismissDelAll = { showDelAllConfirm = false }, + onConfirmDelAll = { showDelAllConfirm = false; onDelAllConfig() }, + showDelDuplicateConfirm = showDelDuplicateConfirm, + onDismissDelDuplicate = { showDelDuplicateConfirm = false }, + onConfirmDelDuplicate = { showDelDuplicateConfirm = false; onDelDuplicateConfig() }, + showDelInvalidConfirm = showDelInvalidConfirm, + onDismissDelInvalid = { showDelInvalidConfirm = false }, + onConfirmDelInvalid = { showDelInvalidConfirm = false; onDelInvalidConfig() }, + showRemoveConfirm = showRemoveConfirm, + onDismissRemove = { showRemoveConfirm = null }, + onConfirmRemove = { guid -> showRemoveConfirm = null; onRemoveServer(guid) } + ) + + if (shareTarget != null) { + val (guid, profile, more) = shareTarget!! + val isCustom = profile.configType.isComplexType() + val (shareOptions, skip) = if (more) { + val options = if (isCustom) shareMethodMoreEntries.takeLast(3) else shareMethodMoreEntries + options to if (isCustom) 2 else 0 + } else { + val options = if (isCustom) shareMethodEntries.takeLast(1) else shareMethodEntries + options to if (isCustom) 2 else 0 + } + SelectListDialog( + options = shareOptions, + onSelected = { index, _ -> + shareTarget = null + when (index + skip) { + 0 -> showQRCodeBitmap = onShareQRCode(guid) + 1 -> onShareClipboard(guid) + 2 -> onShareFullContent(guid) + 3 -> onEditServer(guid, profile) + 4 -> onRemoveServer(guid) + } + }, + onDismiss = { shareTarget = null } + ) + } + if (showQRCodeBitmap != null) { + QRCodeDialog(bitmap = showQRCodeBitmap, onDismiss = { showQRCodeBitmap = null }) + } + + ModalNavigationDrawer( + drawerState = drawerState, + drawerContent = { + ModalDrawerSheet( + modifier = Modifier + .fillMaxWidth(0.75f) + .navigationBarsPadding(), + drawerContainerColor = MaterialTheme.colorScheme.surface + ) { + Column( + modifier = Modifier + .verticalScroll(drawerScrollState) + .verticalScrollbar(drawerScrollState) + .padding(bottom = 80.dp) + ) { + Surface(modifier = Modifier + .fillMaxWidth() + .height(160.dp)) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Text( + text = stringResource(R.string.app_name), + style = MaterialTheme.typography.headlineLarge.copy( + fontFamily = FontFamily(Font(R.font.montserrat_thin)), + fontWeight = FontWeight.Thin + ), + textAlign = TextAlign.Center + ) + } + } + listOf( + Triple( + R.drawable.ic_subscriptions_24dp, + R.string.title_sub_setting, + "sub_setting" + ), + Triple( + R.drawable.ic_per_apps_24dp, + R.string.per_app_proxy_settings, + "per_app_proxy" + ), + Triple( + R.drawable.ic_routing_24dp, + R.string.routing_settings_title, + "routing_setting" + ), + Triple( + R.drawable.ic_file_24dp, + R.string.title_user_asset_setting, + "user_asset" + ), + Triple(R.drawable.ic_settings_24dp, R.string.title_settings, "settings"), + ).forEach { (iconRes, labelRes, route) -> + DrawerMenuItem( + icon = painterResource(iconRes), + label = stringResource(labelRes), + onClick = { scope.launch { drawerState.close() }; onNavigate(route) } + ) + } + AppDivider(modifier = Modifier.padding(vertical = 4.dp)) + listOf( + Triple( + R.drawable.ic_promotion_24dp, + R.string.title_pref_promotion, + "promotion" + ), + Triple(R.drawable.ic_logcat_24dp, R.string.title_logcat, "logcat"), + Triple( + R.drawable.ic_check_update_24dp, + R.string.update_check_for_update, + "check_update" + ), + Triple( + R.drawable.ic_restore_24dp, + R.string.title_configuration_backup_restore, + "backup_restore" + ), + Triple(R.drawable.ic_about_24dp, R.string.title_about, "about"), + ).forEach { (iconRes, labelRes, route) -> + DrawerMenuItem( + icon = painterResource(iconRes), + label = stringResource(labelRes), + onClick = { scope.launch { drawerState.close() }; onNavigate(route) } + ) + } + } + } + } + ) { + Scaffold( + contentWindowInsets = ScaffoldDefaults.contentWindowInsets, + topBar = { + AppTopBar( + title = stringResource(R.string.title_server), + onBackClick = {}, + isLoading = isLoading, + isSearchActive = showSearch, + searchQuery = searchQuery, + onSearchQueryChange = { query -> + searchQuery = query + mainViewModel.filterConfig(query) + }, + onSearchClose = { + searchQuery = "" + mainViewModel.filterConfig("") + showSearch = false + }, + searchPlaceholder = stringResource(R.string.menu_item_search), + navigationIcon = { + if (showSearch) { + IconButton(onClick = { + searchQuery = "" + mainViewModel.filterConfig("") + showSearch = false + }) { + Icon( + painterResource(R.drawable.ic_arrow_back_24dp), + contentDescription = "Back" + ) + } + } else { + IconButton(onClick = { scope.launch { drawerState.open() } }) { + Icon( + painterResource(R.drawable.ic_menu_24dp), + contentDescription = "Menu" + ) + } + } + }, + actions = { + if (!showSearch) { + IconButton(onClick = { showSearch = true }) { + Icon( + painterResource(R.drawable.ic_outline_filter_alt_24), + contentDescription = "filter" + ) + } + } + Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) { + IconButton(onClick = { showImportMenu = true }) { + Icon( + painterResource(R.drawable.ic_add_24dp), + contentDescription = "Add" + ) + } + DropdownMenu( + expanded = showImportMenu, + onDismissRequest = { showImportMenu = false }, + scrollState = importMenuScrollState, + containerColor = MaterialTheme.colorScheme.surface, + modifier = Modifier + .heightIn(max = maxMenuHeight) + .verticalScrollbar(importMenuScrollState) + ) { + listOf( + R.string.menu_item_import_config_qrcode to { + showImportMenu = false; onImportQRcode() + }, + R.string.menu_item_import_config_clipboard to { + showImportMenu = false; onImportClipboard() + }, + R.string.menu_item_import_config_local to { + showImportMenu = false; onImportLocal() + }, + R.string.menu_item_import_config_policy_group to { + showImportMenu = false; onImportManually(EConfigType.POLICYGROUP.value) + }, + R.string.menu_item_import_config_proxy_chain to { + showImportMenu = false; onImportManually(EConfigType.PROXYCHAIN.value) + }, + R.string.menu_item_import_config_manually_vmess to { + showImportMenu = false; onImportManually(EConfigType.VMESS.value) + }, + R.string.menu_item_import_config_manually_vless to { + showImportMenu = false; onImportManually(EConfigType.VLESS.value) + }, + R.string.menu_item_import_config_manually_ss to { + showImportMenu = false; onImportManually(EConfigType.SHADOWSOCKS.value) + }, + R.string.menu_item_import_config_manually_socks to { + showImportMenu = false; onImportManually(EConfigType.SOCKS.value) + }, + R.string.menu_item_import_config_manually_http to { + showImportMenu = false; onImportManually(EConfigType.HTTP.value) + }, + R.string.menu_item_import_config_manually_trojan to { + showImportMenu = false; onImportManually(EConfigType.TROJAN.value) + }, + R.string.menu_item_import_config_manually_wireguard to { + showImportMenu = false; onImportManually(EConfigType.WIREGUARD.value) + }, + R.string.menu_item_import_config_manually_hysteria2 to { + showImportMenu = false; onImportManually(EConfigType.HYSTERIA2.value) + }, + ).forEach { (stringRes, action) -> + DropdownMenuItem( + text = { Text(stringResource(stringRes)) }, + onClick = action + ) + } + } + } + Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) { + IconButton(onClick = { showMenu = true }) { + Icon( + painterResource(R.drawable.ic_more_vert_24dp), + contentDescription = null + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false }, + scrollState = moreMenuScrollState, + containerColor = MaterialTheme.colorScheme.surface, + modifier = Modifier + .heightIn(max = maxMenuHeight) + .verticalScrollbar(moreMenuScrollState) + ) { + listOf( + R.string.title_service_restart to { + showMenu = false; onRestartService() + }, + R.string.title_del_all_config to { + showMenu = false; showDelAllConfirm = true + }, + R.string.title_del_duplicate_config to { + showMenu = false; showDelDuplicateConfirm = true + }, + R.string.title_del_invalid_config to { + showMenu = false; showDelInvalidConfirm = true + }, + R.string.title_export_all to { + showMenu = false; onExportAll() + }, + R.string.title_real_ping_all_server to { + showMenu = false; onRealPingAll() + }, + R.string.title_locate_selected_config to { + showMenu = false; onLocateSelectedServer() + }, + R.string.title_sort_by_test_results to { + showMenu = false; onSortByTestResults() + }, + R.string.title_sub_update to { + showMenu = false; onSubUpdate() + }, + ).forEach { (stringRes, action) -> + DropdownMenuItem( + text = { Text(stringResource(stringRes)) }, + onClick = action + ) + } + } + } + } + ) + }, + bottomBar = { + MainBottomBar( + displayText = displayText, + isRunning = isRunning, + isDarkTheme = isDarkTheme, + onTestClick = onTestClick, + onFabClick = onFabClick + ) + }, + floatingActionButton = {}, + ) { innerPadding -> + val layoutDirection = LocalLayoutDirection.current + + if (groups.isNotEmpty()) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding) + ) { + if (groups.size > 1) { + GroupTabBar( + groups = groups, + selectedTabIndex = pagerState.currentPage.coerceIn(0, groups.lastIndex), + mainViewModel = mainViewModel, + onTabClick = { targetIndex -> + scope.launch { + pagerState.navigateToPageOptimized( + targetPage = targetIndex, + animateAdjacentPage = true + ) + } + } + ) + } + + HorizontalPager( + state = pagerState, + modifier = Modifier.fillMaxSize(), + userScrollEnabled = true, + beyondViewportPageCount = 1, + key = { page -> groups.getOrNull(page)?.id ?: "group-page-$page" } + ) { page -> + val group = groups.getOrNull(page) ?: return@HorizontalPager + + GroupPagerPage( + groupId = group.id, + mainViewModel = mainViewModel, + selectedGuid = selectedGuid, + doubleColumnDisplay = doubleColumnDisplay, + confirmRemove = confirmRemove, + searchQuery = searchQuery, + lazyListStates = lazyListStates, + lazyGridStates = lazyGridStates, + onSelectServer = onSelectServer, + onEditServer = onEditServer, + onShareServer = { guid, profile -> + shareTarget = Triple(guid, profile, false) + }, + onMoreServer = { guid, profile -> + shareTarget = Triple(guid, profile, true) + }, + onRemoveServer = { guid -> + if (confirmRemove) showRemoveConfirm = guid + else onRemoveServer(guid) + }, + contentPadding = PaddingValues( + start = 0.dp, + top = 0.dp, + end = 0.dp, + bottom = 80.dp + ) + ) + } + } + } + } + } +} + +@Composable +private fun ServerListPage( + servers: List, + selectedGuid: String?, + canReorder: Boolean, + doubleColumnDisplay: Boolean, + subscriptionId: String, + confirmRemove: Boolean, + groupId: String, + lazyListStates: MutableMap, + lazyGridStates: MutableMap, + onSelectServer: (String) -> Unit, + onEditServer: (String, ProfileItem) -> Unit, + onShareServer: (String, ProfileItem) -> Unit, + onMoreServer: (String, ProfileItem) -> Unit, + onRemoveServer: (String) -> Unit, + onSwapServer: (Int, Int) -> Unit, + contentPadding: PaddingValues +) { + if (doubleColumnDisplay) { + val gridState = remember(groupId) { + lazyGridStates.getOrPut(groupId) { LazyGridState() } + } + val reorderableGridState = if (canReorder) { + rememberReorderableLazyGridState(gridState) { from, to -> + onSwapServer(from.index, to.index) + } + } else null + + LazyVerticalGrid( + columns = GridCells.Fixed(2), + state = gridState, + modifier = Modifier + .fillMaxSize() + .verticalScrollbar(gridState), + contentPadding = contentPadding + ) { + itemsIndexed(items = servers, key = { _, item -> item.guid }) { _, serverCache -> + val content: @Composable () -> Unit = { + ServerItemColumn( + serverCache = serverCache, + selectedGuid = selectedGuid, + subscriptionId = subscriptionId, + doubleColumnDisplay = true, + onSelectServer = onSelectServer, + onEditServer = onEditServer, + onShareServer = onShareServer, + onMoreServer = onMoreServer, + onRemoveServer = onRemoveServer + ) + } + if (canReorder && reorderableGridState != null) { + ReorderableItem( + reorderableGridState, + key = serverCache.guid + ) { isDragging -> + ReorderableGridItem( + scope = this, + isDragging = isDragging + ) { content() } + } + } else { + content() + } + } + } + } else { + val listState = remember(groupId) { + lazyListStates.getOrPut(groupId) { LazyListState() } + } + val reorderableState = if (canReorder) { + rememberReorderableLazyListState(listState) { from, to -> + onSwapServer(from.index, to.index) + } + } else null + + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .verticalScrollbar(listState), + contentPadding = contentPadding + ) { + itemsIndexed(items = servers, key = { _, item -> item.guid }) { _, serverCache -> + if (canReorder && reorderableState != null) { + ReorderableItem( + reorderableState, + key = serverCache.guid + ) { isDragging -> + ReorderableListItem( + scope = this, + isDragging = isDragging + ) { + ServerItemRow( + serverCache = serverCache, + selectedGuid = selectedGuid, + subscriptionId = subscriptionId, + onSelectServer = onSelectServer, + onEditServer = onEditServer, + onShareServer = onShareServer, + onMoreServer = onMoreServer, + onRemoveServer = onRemoveServer + ) + } + AppDivider(modifier = Modifier.padding(horizontal = 12.dp)) + } + } else { + ServerItemRow( + serverCache = serverCache, + selectedGuid = selectedGuid, + subscriptionId = subscriptionId, + onSelectServer = onSelectServer, + onEditServer = onEditServer, + onShareServer = onShareServer, + onMoreServer = onMoreServer, + onRemoveServer = onRemoveServer + ) + AppDivider(modifier = Modifier.padding(horizontal = 12.dp)) + } + } + } + } +} + +@Composable +private fun ServerItemRow( + serverCache: ServersCache, + selectedGuid: String?, + subscriptionId: String, + onSelectServer: (String) -> Unit, + onEditServer: (String, ProfileItem) -> Unit, + onShareServer: (String, ProfileItem) -> Unit, + onMoreServer: (String, ProfileItem) -> Unit, + onRemoveServer: (String) -> Unit +) { + val profile = serverCache.profile + val subRemarks = if (subscriptionId.isEmpty()) { + MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull() + ?.toString() ?: "" + } else "" + + ServerListItem( + remarks = profile.remarks, + statistics = profile.description.nullIfBlank() + ?: AngConfigManager.generateDescription(profile), + typeDescription = getProtocolDescription(profile), + testResult = serverCache.testDelayString, + testDelayMillis = serverCache.testDelayMillis, + isSelected = serverCache.guid == selectedGuid, + subscriptionRemarks = subRemarks, + doubleColumnDisplay = false, + onClick = { onSelectServer(serverCache.guid) }, + onShare = { onShareServer(serverCache.guid, profile) }, + onEdit = { onEditServer(serverCache.guid, profile) }, + onRemove = { onRemoveServer(serverCache.guid) }, + onMore = { onMoreServer(serverCache.guid, profile) } + ) +} + +@Composable +private fun ServerItemColumn( + serverCache: ServersCache, + selectedGuid: String?, + subscriptionId: String, + doubleColumnDisplay: Boolean, + onSelectServer: (String) -> Unit, + onEditServer: (String, ProfileItem) -> Unit, + onShareServer: (String, ProfileItem) -> Unit, + onMoreServer: (String, ProfileItem) -> Unit, + onRemoveServer: (String) -> Unit +) { + val profile = serverCache.profile + val subRemarks = if (subscriptionId.isEmpty()) { + MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()?.toString() ?: "" + } else "" + + Column { + ServerListItem( + remarks = profile.remarks, + statistics = profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile), + typeDescription = getProtocolDescription(profile), + testResult = serverCache.testDelayString, + testDelayMillis = serverCache.testDelayMillis, + isSelected = serverCache.guid == selectedGuid, + subscriptionRemarks = subRemarks, + doubleColumnDisplay = doubleColumnDisplay, + onClick = { onSelectServer(serverCache.guid) }, + onEdit = { onEditServer(serverCache.guid, profile) }, + onShare = { onShareServer(serverCache.guid, profile) }, + onRemove = { onRemoveServer(serverCache.guid) }, + onMore = { onMoreServer(serverCache.guid, profile) } + ) + AppDivider(modifier = Modifier.padding(horizontal = 12.dp)) + } +} + +@Composable +fun ServerListItem( + remarks: String, + statistics: String, + typeDescription: String, + testResult: String, + testDelayMillis: Long, + isSelected: Boolean, + subscriptionRemarks: String, + doubleColumnDisplay: Boolean, + onClick: () -> Unit, + onEdit: () -> Unit, + onShare: () -> Unit, + onRemove: () -> Unit, + onMore: () -> Unit, + modifier: Modifier = Modifier, + dragModifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth().height(IntrinsicSize.Min).clickable(onClick = onClick).then(dragModifier) + ) { + Box(Modifier.width(10.dp).fillMaxHeight()) { + if (isSelected) { + Row { + Spacer(Modifier.width(6.dp)) + Box(Modifier.width(4.dp).fillMaxHeight().padding(vertical = 10.dp).background(colorFabActive)) + } + } } - binding.drawerLayout.closeDrawer(GravityCompat.START) - return true + Column(Modifier.weight(1f).padding(start = 8.dp, end = 12.dp, top = 8.dp, bottom = 8.dp)) { + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + Text(remarks, Modifier.weight(1f), style = MaterialTheme.typography.bodyLarge.copy(lineBreak = LineBreak.Paragraph), maxLines = 2, overflow = TextOverflow.Ellipsis) + if (doubleColumnDisplay) { + IconButton(onClick = onMore, Modifier.size(36.dp)) { + Icon(painterResource(R.drawable.ic_more_vert_24dp), null, Modifier.size(24.dp)) + } + } else { + IconButton(onClick = onShare, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_share_24dp), null, Modifier.size(24.dp)) } + IconButton(onClick = onEdit, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_edit_24dp), null, Modifier.size(24.dp)) } + IconButton(onClick = onRemove, Modifier.size(36.dp)) { Icon(painterResource(R.drawable.ic_delete_24dp), null, Modifier.size(24.dp)) } + } + } + Spacer(modifier = Modifier.height(6.dp)) + Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) { + if (subscriptionRemarks.isNotBlank()) { + Box(Modifier.size(24.dp).clip(CircleShape).background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), Alignment.Center) { + Text(subscriptionRemarks.take(1).uppercase(), fontSize = 11.sp, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary) + } + } + Text(statistics, Modifier.weight(1f), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + Spacer(modifier = Modifier.height(6.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(typeDescription, style = MaterialTheme.typography.bodySmall, color = colorConfigType, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(testResult, style = MaterialTheme.typography.bodySmall, color = if (testDelayMillis < 0L) colorPingRed else colorPing, maxLines = 1, overflow = TextOverflow.Ellipsis) + } + } } +} - override fun onDestroy() { - tabMediator?.detach() - super.onDestroy() +@Composable +fun DrawerMenuItem( + icon: Painter, + label: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + selected: Boolean = false +) { + Row( + modifier = modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clickable(onClick = onClick) + .background( + if (selected) MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f) + else Color.Transparent + ) + .padding(horizontal = 16.dp, vertical = 0.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + painter = icon, + contentDescription = null, + modifier = Modifier.size(24.dp), + tint = MaterialTheme.colorScheme.onSurface + ) + Spacer(modifier = Modifier.width(12.dp)) + Text( + text = label, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurface + ) } -} \ No newline at end of file +} + +private fun getProtocolDescription(profile: ProfileItem): String { + if (profile.configType.isComplexType()) return profile.configType.name + val parts = mutableListOf(profile.configType.name) + profile.network?.let { net -> + if (net.isNotBlank() && !net.equals("tcp", ignoreCase = true)) parts.add(net) + } + profile.security?.let { sec -> + if (sec.isNotBlank()) { + if (profile.insecure == true && sec.equals("tls", ignoreCase = true)) { + parts.add("$sec insecure") + } else { + parts.add(sec) + } + } + } + return parts.joinToString(" / ") +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt deleted file mode 100644 index a2d150da..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/MainRecyclerAdapter.kt +++ /dev/null @@ -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(), 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 = mutableListOf() - - @SuppressLint("NotifyDataSetChanged") - fun setData(newData: MutableList?, 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() - 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) { - } -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt index 9a8e2206..e8beec92 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyActivity.kt @@ -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? = 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) + 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) + viewModel.importProxyApp(content, this) + }, + onExportProxyApp = { + val export = viewModel.exportProxyApp() + Utils.setClipboard(applicationContext, export) toastSuccess(R.string.toast_success) - hideLoading() } - } - } - - private fun importProxyApp() { - val content = Utils.getClipboard(applicationContext) - if (TextUtils.isEmpty(content)) return - selectProxyApp(content, false) - toastSuccess(R.string.toast_success) + ) } +} - private fun exportProxyApp() { - var lst = binding.switchBypassApps.isChecked.toString() +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PerAppProxyScreen( + apps: List, + isLoading: Boolean, + blacklist: Set, + 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 +) { + var showSearch by rememberSaveable { mutableStateOf(false) } + var searchQuery by rememberSaveable { mutableStateOf("") } + var showMenu by remember { mutableStateOf(false) } + val listState = rememberLazyListState() - viewModel.getAll().forEach { pkg -> - lst = lst + System.lineSeparator() + pkg - } - Utils.setClipboard(applicationContext, lst) - toastSuccess(R.string.toast_success) + LaunchedEffect(searchQuery) { + onSearch(searchQuery) } - - 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) + 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) + ) } } - refreshData() - } - } else { - adapter?.let { adapter -> - adapter.apps.forEach { app -> - val packageName = app.packageName - if (inProxyApps(proxyApps, packageName, force)) { - viewModel.add(packageName) + 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() } + ) } } - 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() - - val key = content.uppercase() - if (key.isNotEmpty()) { - appsAll?.forEach { - if (it.appName.uppercase().indexOf(key) >= 0 - || it.packageName.uppercase().indexOf(key) >= 0 + ) { 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 ) { - apps.add(it) + 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 + ) + } } } - } else { - appsAll?.forEach { - apps.add(it) + AppDivider() + + 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() + } } } - - adapter = PerAppProxyAdapter(apps, adapter?.viewModel ?: viewModel) - binding.recyclerView.adapter = adapter - refreshData() - return true - } - - @SuppressLint("NotifyDataSetChanged") - fun refreshData() { - adapter?.notifyDataSetChanged() } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt deleted file mode 100644 index e534845f..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/PerAppProxyAdapter.kt +++ /dev/null @@ -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, - val viewModel: PerAppProxyViewModel -) : RecyclerView.Adapter() { - - 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) - } - } -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ProfileEditorResult.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ProfileEditorResult.kt new file mode 100644 index 00000000..a63c3f3a --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ProfileEditorResult.kt @@ -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() + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt index e5bb6b1d..9f4683f1 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingEditActivity.kt @@ -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 + 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 { - 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 + @Composable + override fun ScreenContent() { + RoutingEditScreen( + position = position, + initial = initial, + outboundSuggestions = outboundSuggestions, + canUseProcess = canUseProcess, + onBackClick = { finish() }, + onSave = { saveServer(it) }, + onDelete = { deleteServer() } + ) } - 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 } - } - + 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() - } - } - } - .setNegativeButton(android.R.string.cancel) { _, _ -> - // do nothing - } - .show() + lifecycleScope.launch(Dispatchers.IO) { + SettingsManager.removeRoutingRuleset(position) + withContext(Dispatchers.Main) { finish() } + } } return true } +} - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.action_server, menu) - val delConfig = menu.findItem(R.id.del_config) +@Composable +fun RoutingEditScreen( + position: Int, + initial: RulesetItem?, + outboundSuggestions: List, + canUseProcess: Boolean, + onBackClick: () -> Unit, + onSave: (RulesetItem) -> Boolean, + onDelete: () -> Unit +) { + val context = LocalContext.current + val scrollState = rememberScrollState() - if (position < 0) { - delConfig?.isVisible = false + 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(",") } + } - return super.onCreateOptionsMenu(menu) + 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 } - override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - R.id.del_config -> { - deleteServer() - 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)) + } - R.id.save_config -> { - saveServer() - true + 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 } + ) } - - else -> super.onOptionsItemSelected(item) } - } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt index 335aa6c5..aab0b827 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingActivity.kt @@ -1,140 +1,155 @@ 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 by lazy { - resources.getStringArray(R.array.routing_domain_strategy) - } - private val preset_rulesets: Array 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)) + domainStrategyState.value = getDomainStrategy() + } - 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() - } + @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 -> + private fun importPredefined(index: Int) { + lifecycleScope.launch(Dispatchers.IO) { try { - val value = routing_domain_strategy[i] - MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value) - binding.tvDomainStrategySummary.text = value + SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, index) + launch(Dispatchers.Main) { + viewModel.reload() + toastSuccess(R.string.toast_success) + } } catch (e: Exception) { - LogUtil.e(AppConfig.TAG, "Failed to set domain strategy", e) + LogUtil.e(AppConfig.TAG, "Failed to import predefined ruleset", 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 { - lifecycleScope.launch(Dispatchers.IO) { - SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, i) - launch(Dispatchers.Main) { - refreshData() - 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) + val clipboard = try { + Utils.getClipboard(this) + } catch (e: Exception) { + toastError(R.string.toast_failure) + return + } + lifecycleScope.launch(Dispatchers.IO) { + val result = SettingsManager.resetRoutingRulesets(clipboard) + withContext(Dispatchers.Main) { + if (result) { + viewModel.reload() + toastSuccess(R.string.toast_success) + } else { toastError(R.string.toast_failure) - return@setPositiveButton } + } + } + } + + private fun importQRcode() { + launchQRCodeScanner { scanResult -> + if (scanResult != null) { lifecycleScope.launch(Dispatchers.IO) { - val result = SettingsManager.resetRoutingRulesets(clipboard) + val result = SettingsManager.resetRoutingRulesets(scanResult) withContext(Dispatchers.Main) { if (result) { - refreshData() + viewModel.reload() toastSuccess(R.string.toast_success) } else { toastError(R.string.toast_failure) @@ -142,19 +157,7 @@ class RoutingSettingActivity : HelperBaseActivity() { } } } - .setNegativeButton(android.R.string.cancel) { _, _ -> - //do nothing - } - .show() - } - - private fun importQRcode(): Boolean { - launchQRCodeScanner { scanResult -> - if (scanResult != null) { - importRulesetsFromQRcode(scanResult) - } } - return true } private fun export2Clipboard() { @@ -166,52 +169,220 @@ class RoutingSettingActivity : HelperBaseActivity() { toastSuccess(R.string.toast_success) } } +} +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RoutingSettingScreen( + viewModel: RoutingSettingsViewModel, + domainStrategyState: MutableStateFlow, + 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) } - 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) + 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) + } + + 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() } + ) } } } - } - .setNegativeButton(android.R.string.cancel) { _, _ -> - //do nothing - } - .show() - return true - } - - @SuppressLint("NotifyDataSetChanged") - fun refreshData() { - viewModel.reload() - adapter.notifyDataSetChanged() - } - - private inner class ActivityAdapterListener : BaseAdapterListener { - override fun onEdit(guid: String, position: Int) { - startActivity( - Intent(ownerActivity, RoutingEditActivity::class.java) - .putExtra("position", position) ) } + ) { 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)) + } - override fun onRemove(guid: String, position: Int) { - } + 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 + ) + } + } - override fun onShare(url: String) { - } - - override fun onRefreshData() { - refreshData() + 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)) + } + } } } -} \ No newline at end of file + + 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 } + ) + } + + if (showPresetDialog) { + SelectListDialog( + title = stringResource(R.string.routing_settings_import_predefined_rulesets), + options = presetRulesets, + onSelected = { index, _ -> + showPresetDialog = false + onImportPredefined(index) + }, + onDismiss = { showPresetDialog = false } + ) + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt deleted file mode 100644 index 3f5a68f1..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/RoutingSettingRecyclerAdapter.kt +++ /dev/null @@ -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(), - 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) { - } -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScScannerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScScannerActivity.kt index 5bd1e4b4..96e3f150 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScScannerActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScScannerActivity.kt @@ -2,17 +2,24 @@ 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) - importQRcode() + } + + @Composable + override fun ScreenContent() { + LaunchedEffect(Unit) { + importQRcode() + } } private fun importQRcode() { @@ -31,4 +38,4 @@ class ScScannerActivity : HelperBaseActivity() { finish() } } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScStartActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScStartActivity.kt index 02a69ef9..106e010d 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScStartActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScStartActivity.kt @@ -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) - moveTaskToBack(true) + } - setContentView(R.layout.activity_none) - - if (!CoreServiceManager.isRunning()) { - CoreServiceManager.startVServiceFromToggle(this) + @Composable + override fun ScreenContent() { + LaunchedEffect(Unit) { + moveTaskToBack(true) + if (!CoreServiceManager.isRunning()) { + CoreServiceManager.startVServiceFromToggle(this@ScStartActivity) + } + finish() } - finish() } } - diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScStopActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScStopActivity.kt index 2fdb692d..eade1d42 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScStopActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScStopActivity.kt @@ -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) - moveTaskToBack(true) + } - setContentView(R.layout.activity_none) - - if (CoreServiceManager.isRunning()) { - CoreServiceManager.stopVService(this) + @Composable + override fun ScreenContent() { + LaunchedEffect(Unit) { + moveTaskToBack(true) + if (CoreServiceManager.isRunning()) { + CoreServiceManager.stopVService(this@ScStopActivity) + } + finish() } - finish() } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScSwitchActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScSwitchActivity.kt index 43d88288..fca1d6ee 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScSwitchActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScSwitchActivity.kt @@ -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) - moveTaskToBack(true) + } - setContentView(R.layout.activity_none) - - if (CoreServiceManager.isRunning()) { - CoreServiceManager.stopVService(this) - } else { - CoreServiceManager.startVServiceFromToggle(this) + @Composable + override fun ScreenContent() { + LaunchedEffect(Unit) { + moveTaskToBack(true) + if (CoreServiceManager.isRunning()) { + CoreServiceManager.stopVService(this@ScSwitchActivity) + } else { + CoreServiceManager.startVServiceFromToggle(this@ScSwitchActivity) + } + finish() } - finish() } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt index c603d88c..e00b95e4 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ScannerActivity.kt @@ -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) @@ -100,4 +140,308 @@ class ScannerActivity : HelperBaseActivity() { } } } -} \ No newline at end of file +} + +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(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(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() + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt index a8dd8672..7123ad53 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerActivity.kt @@ -1,19 +1,31 @@ package com.v2ray.ang.ui import android.os.Bundle -import android.text.TextUtils -import android.view.Menu -import android.view.MenuItem -import android.view.View -import android.widget.AdapterView -import android.widget.ArrayAdapter -import android.widget.Button -import android.widget.EditText -import android.widget.LinearLayout -import android.widget.Spinner -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -import androidx.lifecycle.lifecycleScope +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.ScaffoldDefaults +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +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.stringArrayResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp import com.v2ray.ang.AppConfig import com.v2ray.ang.AppConfig.DEFAULT_PORT import com.v2ray.ang.AppConfig.REALITY @@ -21,24 +33,26 @@ import com.v2ray.ang.AppConfig.TLS import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_ADDRESS_V4 import com.v2ray.ang.AppConfig.WIREGUARD_LOCAL_MTU import com.v2ray.ang.R +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.enums.NetworkType -import com.v2ray.ang.extension.isNotNullEmpty import com.v2ray.ang.extension.nullIfBlank import com.v2ray.ang.extension.toast import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.CertificateFingerprintManager import com.v2ray.ang.handler.MmkvManager -import com.v2ray.ang.handler.SettingsChangeManager import com.v2ray.ang.util.JsonUtil -import com.v2ray.ang.util.Utils import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class ServerActivity : BaseActivity() { +class ServerActivity : BaseComponentActivity() { private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() } private val isRunning by lazy { @@ -50,721 +64,433 @@ class ServerActivity : BaseActivity() { EConfigType.fromInt(intent.getIntExtra("createConfigType", EConfigType.VMESS.value)) ?: EConfigType.VMESS } - private val subscriptionId by lazy { - intent.getStringExtra("subscriptionId") - } - - private val securitys: Array by lazy { - resources.getStringArray(R.array.securitys) - } - private val shadowsocksSecuritys: Array by lazy { - resources.getStringArray(R.array.ss_securitys) - } - private val flows: Array by lazy { - resources.getStringArray(R.array.flows) - } - private val networks: Array by lazy { - resources.getStringArray(R.array.networks) - } - private val tcpTypes: Array by lazy { - resources.getStringArray(R.array.header_type_tcp) - } - private val kcpAndQuicTypes: Array by lazy { - resources.getStringArray(R.array.header_type_kcp_and_quic) - } - private val grpcModes: Array by lazy { - resources.getStringArray(R.array.mode_type_grpc) - } - private val streamSecuritys: Array by lazy { - resources.getStringArray(R.array.streamsecurityxs) - } - private val allowinsecures: Array by lazy { - resources.getStringArray(R.array.allowinsecures) - } - private val uTlsItems: Array by lazy { - resources.getStringArray(R.array.streamsecurity_utls) - } - private val alpns: Array by lazy { - resources.getStringArray(R.array.streamsecurity_alpn) - } - private val xhttpMode: Array by lazy { - resources.getStringArray(R.array.xhttp_mode) - } - private val browserDialerModes: Array by lazy { - resources.getStringArray(R.array.browser_dialer_mode) - } - - - // Kotlin synthetics was used, but since it is removed in 1.8. We switch to old manual approach. - // We don't use AndroidViewBinding because, it is better to share similar logics for different - // protocols. Use findViewById manually ensures the xml are de-coupled with the activity logic. - private val et_remarks: EditText by lazy { findViewById(R.id.et_remarks) } - private val et_address: EditText by lazy { findViewById(R.id.et_address) } - private val et_port: EditText by lazy { findViewById(R.id.et_port) } - private val et_id: EditText by lazy { findViewById(R.id.et_id) } - private val et_security: EditText? by lazy { findViewById(R.id.et_security) } - private val sp_flow: Spinner? by lazy { findViewById(R.id.sp_flow) } - private val sp_security: Spinner? by lazy { findViewById(R.id.sp_security) } - private val sp_stream_security: Spinner? by lazy { findViewById(R.id.sp_stream_security) } - private val sp_allow_insecure: Spinner? by lazy { findViewById(R.id.sp_allow_insecure) } - private val container_allow_insecure: LinearLayout? by lazy { findViewById(R.id.lay_allow_insecure) } - private val et_sni: EditText? by lazy { findViewById(R.id.et_sni) } - private val container_sni: LinearLayout? by lazy { findViewById(R.id.lay_sni) } - private val sp_stream_fingerprint: Spinner? by lazy { findViewById(R.id.sp_stream_fingerprint) } //uTLS - private val container_fingerprint: LinearLayout? by lazy { findViewById(R.id.lay_stream_fingerprint) } - private val sp_network: Spinner? by lazy { findViewById(R.id.sp_network) } - private val sp_header_type: Spinner? by lazy { findViewById(R.id.sp_header_type) } - private val sp_header_type_title: TextView? by lazy { findViewById(R.id.sp_header_type_title) } - private val tv_request_host: TextView? by lazy { findViewById(R.id.tv_request_host) } - private val et_request_host: EditText? by lazy { findViewById(R.id.et_request_host) } - private val tv_path: TextView? by lazy { findViewById(R.id.tv_path) } - private val et_path: EditText? by lazy { findViewById(R.id.et_path) } - private val sp_stream_alpn: Spinner? by lazy { findViewById(R.id.sp_stream_alpn) } //uTLS - private val container_alpn: LinearLayout? by lazy { findViewById(R.id.lay_stream_alpn) } - private val et_public_key: EditText? by lazy { findViewById(R.id.et_public_key) } - private val et_preshared_key: EditText? by lazy { findViewById(R.id.et_preshared_key) } - private val container_public_key: LinearLayout? by lazy { findViewById(R.id.lay_public_key) } - private val et_short_id: EditText? by lazy { findViewById(R.id.et_short_id) } - private val container_short_id: LinearLayout? by lazy { findViewById(R.id.lay_short_id) } - private val et_spider_x: EditText? by lazy { findViewById(R.id.et_spider_x) } - private val container_spider_x: LinearLayout? by lazy { findViewById(R.id.lay_spider_x) } - private val et_mldsa65_verify: EditText? by lazy { findViewById(R.id.et_mldsa65_verify) } - private val container_mldsa65_verify: LinearLayout? by lazy { findViewById(R.id.lay_mldsa65_verify) } - private val et_reserved1: EditText? by lazy { findViewById(R.id.et_reserved1) } - private val et_local_address: EditText? by lazy { findViewById(R.id.et_local_address) } - private val et_local_mtu: EditText? by lazy { findViewById(R.id.et_local_mtu) } - private val et_obfs_password: EditText? by lazy { findViewById(R.id.et_obfs_password) } - private val et_port_hop: EditText? by lazy { findViewById(R.id.et_port_hop) } - private val et_port_hop_interval: EditText? by lazy { findViewById(R.id.et_port_hop_interval) } - private val et_bandwidth_down: EditText? by lazy { findViewById(R.id.et_bandwidth_down) } - private val et_bandwidth_up: EditText? by lazy { findViewById(R.id.et_bandwidth_up) } - private val et_kcp_mtu: EditText? by lazy { findViewById(R.id.et_kcp_mtu) } - private val et_kcp_tti: EditText? by lazy { findViewById(R.id.et_kcp_tti) } - private val layout_kcp: LinearLayout? by lazy { findViewById(R.id.layout_kcp) } - private val et_extra: EditText? by lazy { findViewById(R.id.et_extra) } - private val et_fm: EditText? by lazy { findViewById(R.id.et_fm) } - private val layout_extra: LinearLayout? by lazy { findViewById(R.id.layout_extra) } - private val et_ech_config_list: EditText? by lazy { findViewById(R.id.et_ech_config_list) } - private val container_ech_config_list: LinearLayout? by lazy { findViewById(R.id.lay_ech_config_list) } - private val et_verify_peer_cert_by_name: EditText? by lazy { findViewById(R.id.et_verify_peer_cert_by_name) } - private val container_verify_peer_cert_by_name: LinearLayout? by lazy { findViewById(R.id.lay_verify_peer_cert_by_name) } - private val et_pinned_ca256: EditText? by lazy { findViewById(R.id.et_pinned_ca256) } - private val btn_pinned_ca256_action: Button? by lazy { findViewById(R.id.btn_pinned_ca256_action) } - private val container_pinned_ca256: LinearLayout? by lazy { findViewById(R.id.lay_pinned_ca256) } - private val layout_browser_dialer: LinearLayout? by lazy { findViewById(R.id.layout_browser_dialer) } - private val sp_browser_dialer_mode: Spinner? by lazy { findViewById(R.id.sp_browser_dialer_mode) } + private val subscriptionId by lazy { intent.getStringExtra("subscriptionId") } + private lateinit var initialConfig: ProfileItem override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - - val config = MmkvManager.decodeServerConfig(editGuid) - - val layoutId = when (config?.configType ?: createConfigType) { - EConfigType.VMESS -> R.layout.activity_server_vmess - EConfigType.SHADOWSOCKS -> R.layout.activity_server_shadowsocks - EConfigType.SOCKS, EConfigType.HTTP -> R.layout.activity_server_socks - EConfigType.VLESS -> R.layout.activity_server_vless - EConfigType.TROJAN -> R.layout.activity_server_trojan - EConfigType.WIREGUARD -> R.layout.activity_server_wireguard - EConfigType.HYSTERIA2 -> R.layout.activity_server_hysteria2 - else -> null - } ?: return - setContentViewWithToolbar(layoutId, showHomeAsUp = true, title = (config?.configType ?: createConfigType).toString()) - - sp_network?.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected( - parent: AdapterView<*>?, - view: View?, - position: Int, - id: Long, - ) { - val types = transportTypes(networks[position]) - sp_header_type?.isEnabled = types.size > 1 - val adapter = - ArrayAdapter(this@ServerActivity, android.R.layout.simple_spinner_item, types) - adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) - sp_header_type?.adapter = adapter - sp_header_type_title?.text = - when (networks[position]) { - NetworkType.GRPC.type -> getString(R.string.server_lab_mode_type) - NetworkType.XHTTP.type -> getString(R.string.server_lab_xhttp_mode) - else -> getString(R.string.server_lab_head_type) - }.orEmpty() - sp_header_type?.setSelection( - Utils.arrayFind( - types, - when (networks[position]) { - NetworkType.GRPC.type -> config?.mode - NetworkType.XHTTP.type -> config?.xhttpMode - else -> config?.headerType - }.orEmpty() - ) - ) - - et_request_host?.text = Utils.getEditable( - when (networks[position]) { - //"quic" -> config?.quicSecurity - NetworkType.GRPC.type -> config?.authority - else -> config?.host - }.orEmpty() - ) - et_path?.text = Utils.getEditable( - when (networks[position]) { - NetworkType.KCP.type -> config?.seed - //"quic" -> config?.quicKey - NetworkType.GRPC.type -> config?.serviceName - else -> config?.path - }.orEmpty() - ) - - tv_request_host?.text = Utils.getEditable( - getString( - when (networks[position]) { - NetworkType.TCP.type -> R.string.server_lab_request_host_http - NetworkType.WS.type -> R.string.server_lab_request_host_ws - NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_request_host_httpupgrade - NetworkType.XHTTP.type -> R.string.server_lab_request_host_xhttp - NetworkType.H2.type -> R.string.server_lab_request_host_h2 - //"quic" -> R.string.server_lab_request_host_quic - NetworkType.GRPC.type -> R.string.server_lab_request_host_grpc - else -> R.string.server_lab_request_host - } - ) - ) - - tv_path?.text = Utils.getEditable( - getString( - when (networks[position]) { - NetworkType.KCP.type -> R.string.server_lab_path_kcp - NetworkType.WS.type -> R.string.server_lab_path_ws - NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_path_httpupgrade - NetworkType.XHTTP.type -> R.string.server_lab_path_xhttp - NetworkType.H2.type -> R.string.server_lab_path_h2 - //"quic" -> R.string.server_lab_path_quic - NetworkType.GRPC.type -> R.string.server_lab_path_grpc - else -> R.string.server_lab_path - } - ) - ) - et_extra?.text = Utils.getEditable( - when (networks[position]) { - NetworkType.XHTTP.type -> config?.xhttpExtra - else -> null - }.orEmpty() - ) - et_fm?.text = Utils.getEditable(config?.finalMask) - - layout_kcp?.visibility = - when (networks[position]) { - NetworkType.KCP.type -> View.VISIBLE - else -> View.GONE - } - et_kcp_mtu?.text = Utils.getEditable(config?.kcpMtu?.toString().orEmpty()) - et_kcp_tti?.text = Utils.getEditable(config?.kcpTti?.toString().orEmpty()) - - layout_extra?.visibility = - when (networks[position]) { - NetworkType.XHTTP.type -> View.VISIBLE - else -> View.GONE - } - - layout_browser_dialer?.visibility = - when (networks[position]) { - NetworkType.WS.type -> View.VISIBLE - NetworkType.XHTTP.type -> View.VISIBLE - else -> View.GONE - } - } - - override fun onNothingSelected(parent: AdapterView<*>?) { - // do nothing - } - } - sp_stream_security?.onItemSelectedListener = object : AdapterView.OnItemSelectedListener { - override fun onItemSelected( - parent: AdapterView<*>?, - view: View?, - position: Int, - id: Long, - ) { - val isBlank = streamSecuritys[position].isBlank() - val isTLS = streamSecuritys[position] == TLS - - when { - // Case 1: Null or blank - isBlank -> { - listOf( - container_sni, - container_fingerprint, - container_alpn, - container_allow_insecure, - container_public_key, - container_short_id, - container_spider_x, - container_mldsa65_verify, - container_ech_config_list, - container_verify_peer_cert_by_name, - container_pinned_ca256 - ).forEach { it?.visibility = View.GONE } - } - - // Case 2: TLS value - isTLS -> { - listOf( - container_sni, - container_fingerprint, - container_alpn, - container_allow_insecure, - container_ech_config_list, - container_verify_peer_cert_by_name, - container_pinned_ca256 - ).forEach { it?.visibility = View.VISIBLE } - listOf( - container_public_key, - container_short_id, - container_spider_x, - container_mldsa65_verify - ).forEach { it?.visibility = View.GONE } - } - - // Case 3: Other reality values - else -> { - listOf( - container_sni, - container_fingerprint - ).forEach { it?.visibility = View.VISIBLE } - listOf( - container_alpn, - container_allow_insecure, - container_ech_config_list, - container_verify_peer_cert_by_name, - container_pinned_ca256 - ).forEach { it?.visibility = View.GONE } - listOf( - container_public_key, - container_short_id, - container_spider_x, - container_mldsa65_verify - ).forEach { it?.visibility = View.VISIBLE } - } - } - } - - override fun onNothingSelected(p0: AdapterView<*>?) { - // do nothing - } - } - btn_pinned_ca256_action?.setOnClickListener { - fetchPinnedCA256ForCurrentConfig() - } - if (config != null) { - bindingServer(config) - } else { - clearServer() - } + val existingConfig = MmkvManager.decodeServerConfig(editGuid) + initialConfig = existingConfig ?: ProfileItem.create(createConfigType) } - /** - * binding selected server config - */ - private fun bindingServer(config: ProfileItem): Boolean { - - et_remarks.text = Utils.getEditable(config.remarks) - et_address.text = Utils.getEditable(config.server.orEmpty()) - et_port.text = Utils.getEditable(config.serverPort ?: DEFAULT_PORT.toString()) - et_id.text = Utils.getEditable(config.password.orEmpty()) - - if (config.configType == EConfigType.SOCKS || config.configType == EConfigType.HTTP) { - et_security?.text = Utils.getEditable(config.username.orEmpty()) - } else if (config.configType == EConfigType.VLESS) { - et_security?.text = Utils.getEditable(config.method.orEmpty()) - val flow = Utils.arrayFind(flows, config.flow.orEmpty()) - if (flow >= 0) { - sp_flow?.setSelection(flow) - } - } else if (config.configType == EConfigType.WIREGUARD) { - et_id.text = Utils.getEditable(config.secretKey.orEmpty()) - et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty()) - et_preshared_key?.visibility = View.VISIBLE - et_preshared_key?.text = Utils.getEditable(config.preSharedKey.orEmpty()) - et_reserved1?.text = Utils.getEditable(config.reserved ?: "0,0,0") - et_local_address?.text = Utils.getEditable( - config.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4 - ) - et_local_mtu?.text = Utils.getEditable(config.mtu?.toString() ?: WIREGUARD_LOCAL_MTU) - } else if (config.configType == EConfigType.HYSTERIA2) { - et_obfs_password?.text = Utils.getEditable(config.obfsPassword) - et_port_hop?.text = Utils.getEditable(config.portHopping) - et_port_hop_interval?.text = Utils.getEditable(config.portHoppingInterval) - et_bandwidth_down?.text = Utils.getEditable(config.bandwidthDown) - et_bandwidth_up?.text = Utils.getEditable(config.bandwidthUp) - } - val securityEncryptions = - if (config.configType == EConfigType.SHADOWSOCKS) shadowsocksSecuritys else securitys - val security = Utils.arrayFind(securityEncryptions, config.method.orEmpty()) - if (security >= 0) { - sp_security?.setSelection(security) - } - - val streamSecurity = Utils.arrayFind(streamSecuritys, config.security.orEmpty()) - if (streamSecurity >= 0) { - sp_stream_security?.setSelection(streamSecurity) - et_sni?.text = Utils.getEditable(config.sni) - config.fingerPrint?.let { it -> - val utlsIndex = Utils.arrayFind(uTlsItems, it) - utlsIndex.let { sp_stream_fingerprint?.setSelection(if (it >= 0) it else 0) } - } - config.alpn?.let { it -> - val alpnIndex = Utils.arrayFind(alpns, it) - alpnIndex.let { sp_stream_alpn?.setSelection(if (it >= 0) it else 0) } - } - if (config.security == TLS) { - val allowinsecure = Utils.arrayFind(allowinsecures, config.insecure.toString()) - if (allowinsecure >= 0) { - sp_allow_insecure?.setSelection(allowinsecure) - } - et_ech_config_list?.text = Utils.getEditable(config.echConfigList) - et_verify_peer_cert_by_name?.text = Utils.getEditable(config.verifyPeerCertByName) - et_pinned_ca256?.text = Utils.getEditable(config.pinnedCA256) - } else if (config.security == REALITY) { - et_public_key?.text = Utils.getEditable(config.publicKey.orEmpty()) - et_short_id?.text = Utils.getEditable(config.shortId.orEmpty()) - et_spider_x?.text = Utils.getEditable(config.spiderX.orEmpty()) - et_mldsa65_verify?.text = Utils.getEditable(config.mldsa65Verify.orEmpty()) - } - } - - val network = Utils.arrayFind(networks, config.network.orEmpty()) - if (network >= 0) { - sp_network?.setSelection(network) - } - - val browserDialerMode = Utils.arrayFind(browserDialerModes, config.browserDialerMode.orEmpty()) - if (browserDialerMode >= 0) { - sp_browser_dialer_mode?.setSelection(browserDialerMode) - } - - return true + @Composable + override fun ScreenContent() { + ServerScreen( + guid = editGuid, + configType = initialConfig.configType, + initialConfig = initialConfig, + isRunning = isRunning, + onBackClick = { finish() }, + onSave = { saveServer(it) }, + onDelete = { deleteServer(editGuid, isRunning) } + ) } - /** - * clear or init server config - */ - private fun clearServer(): Boolean { - et_remarks.text = null - et_address.text = null - et_port.text = Utils.getEditable(DEFAULT_PORT.toString()) - et_id.text = null - sp_security?.setSelection(0) - sp_network?.setSelection(0) - - sp_header_type?.setSelection(0) - et_request_host?.text = null - et_path?.text = null - sp_stream_security?.setSelection(0) - sp_allow_insecure?.setSelection(0) - et_sni?.text = null - - //et_security.text = null - sp_flow?.setSelection(0) - et_public_key?.text = null - et_reserved1?.text = Utils.getEditable("0,0,0") - et_local_address?.text = - Utils.getEditable(WIREGUARD_LOCAL_ADDRESS_V4) - et_local_mtu?.text = Utils.getEditable(WIREGUARD_LOCAL_MTU) - sp_browser_dialer_mode?.setSelection(0) - return true - } - - /** - * save server config - */ - private fun saveServer(): Boolean { - if (TextUtils.isEmpty(et_remarks.text.toString())) { + private fun saveServer(config: ProfileItem): Boolean { + if (config.remarks.isBlank()) { toast(R.string.server_lab_remarks) return false } - if (TextUtils.isEmpty(et_address.text.toString())) { + + if (config.server.isNullOrBlank()) { toast(R.string.server_lab_address) return false } - if (createConfigType != EConfigType.HYSTERIA2) { - if (Utils.parseInt(et_port.text.toString()) <= 0) { - toast(R.string.server_lab_port) - return false - } - } - val config = - MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(createConfigType) - if (config.configType != EConfigType.SOCKS - && config.configType != EConfigType.HTTP - && TextUtils.isEmpty(et_id.text.toString()) + + if ( + config.configType != EConfigType.HYSTERIA2 && + (config.serverPort?.toIntOrNull() ?: 0) <= 0 ) { - if (config.configType == EConfigType.TROJAN - || config.configType == EConfigType.SHADOWSOCKS - || config.configType == EConfigType.HYSTERIA2 - ) { - toast(R.string.server_lab_id3) - } else { - toast(R.string.server_lab_id) - } + toast(R.string.server_lab_port) return false } - sp_stream_security?.let { - if (config.configType == EConfigType.TROJAN && TextUtils.isEmpty(streamSecuritys[it.selectedItemPosition])) { - toast(R.string.server_lab_stream_security) - return false - } - } - if (et_extra?.text?.toString().isNotNullEmpty()) { - if (JsonUtil.parseString(et_extra?.text?.toString()) == null) { - toast(R.string.server_lab_xhttp_extra) - return false + + if ( + config.configType != EConfigType.SOCKS && + config.configType != EConfigType.HTTP && + config.password.isNullOrBlank() + ) { + val message = when (config.configType) { + EConfigType.TROJAN, + EConfigType.SHADOWSOCKS, + EConfigType.HYSTERIA2 -> R.string.server_lab_id3 + + else -> R.string.server_lab_id } + toast(message) + return false } - if (et_fm?.text?.toString().isNotNullEmpty()) { - if (JsonUtil.parseString(et_fm?.text?.toString()) == null) { - toast(R.string.server_lab_final_mask) - return false - } + if ( + config.configType == EConfigType.TROJAN && + config.security.isNullOrBlank() + ) { + toast(R.string.server_lab_stream_security) + return false } - saveCommon(config) - saveStreamSettings(config) - saveTls(config) + if ( + !config.xhttpExtra.isNullOrBlank() && + JsonUtil.parseString(config.xhttpExtra) == null + ) { + toast(R.string.server_lab_xhttp_extra) + return false + } - config.description = AngConfigManager.generateDescription(config) + if ( + !config.finalMask.isNullOrBlank() && + JsonUtil.parseString(config.finalMask) == null + ) { + toast(R.string.server_lab_final_mask) + return false + } - if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) { + config.description = + AngConfigManager.generateDescription(config) + + if ( + config.subscriptionId.isEmpty() && + !subscriptionId.isNullOrEmpty() + ) { config.subscriptionId = subscriptionId.orEmpty() } - //LogUtil.i(AppConfig.TAG, JsonUtil.toJsonPretty(config) ?: "") - 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 saveCommon(config: ProfileItem) { - config.remarks = et_remarks.text.toString().trim() - config.server = et_address.text.toString().trim() - config.serverPort = et_port.text.toString().trim() - config.password = et_id.text.toString().trim() - - if (config.configType == EConfigType.VMESS) { - config.method = securitys[sp_security?.selectedItemPosition ?: 0] - } else if (config.configType == EConfigType.VLESS) { - config.method = et_security?.text.toString().trim() - config.flow = flows[sp_flow?.selectedItemPosition ?: 0] - } else if (config.configType == EConfigType.SHADOWSOCKS) { - config.method = shadowsocksSecuritys[sp_security?.selectedItemPosition ?: 0] - } else if (config.configType == EConfigType.SOCKS || config.configType == EConfigType.HTTP) { - if (!TextUtils.isEmpty(et_security?.text) || !TextUtils.isEmpty(et_id.text)) { - config.username = et_security?.text.toString().trim() - } - } else if (config.configType == EConfigType.TROJAN) { - } else if (config.configType == EConfigType.WIREGUARD) { - config.secretKey = et_id.text.toString().trim() - config.publicKey = et_public_key?.text.toString().trim() - config.preSharedKey = et_preshared_key?.text.toString().trim() - config.reserved = et_reserved1?.text.toString().trim() - config.localAddress = et_local_address?.text.toString().trim() - config.mtu = Utils.parseInt(et_local_mtu?.text.toString()) - } else if (config.configType == EConfigType.HYSTERIA2) { - config.obfsPassword = et_obfs_password?.text?.toString() - config.portHopping = et_port_hop?.text?.toString() - config.portHoppingInterval = et_port_hop_interval?.text?.toString()?.trim() - config.bandwidthDown = et_bandwidth_down?.text?.toString() - config.bandwidthUp = et_bandwidth_up?.text?.toString() - } - } - - - private fun saveStreamSettings(profileItem: ProfileItem) { - val network = sp_network?.selectedItemPosition ?: return - val type = sp_header_type?.selectedItemPosition ?: return - val requestHost = et_request_host?.text?.toString()?.trim() ?: return - val path = et_path?.text?.toString()?.trim() ?: return - - profileItem.network = networks[network] - profileItem.headerType = transportTypes(networks[network])[type] - profileItem.host = requestHost - profileItem.path = path - profileItem.seed = path - profileItem.quicSecurity = requestHost - profileItem.quicKey = path - profileItem.mode = transportTypes(networks[network])[type] - profileItem.serviceName = path - profileItem.authority = requestHost - profileItem.xhttpMode = transportTypes(networks[network])[type] - profileItem.xhttpExtra = et_extra?.text?.toString()?.trim().nullIfBlank() - profileItem.finalMask = et_fm?.text?.toString()?.trim()?.nullIfBlank() - profileItem.kcpMtu = et_kcp_mtu?.text?.toString()?.toIntOrNull() - profileItem.kcpTti = et_kcp_tti?.text?.toString()?.toIntOrNull() - if (networks[network] == NetworkType.WS.type || networks[network] == NetworkType.XHTTP.type) { - val browserDialerMode = browserDialerModes[sp_browser_dialer_mode?.selectedItemPosition ?: 0] - if (browserDialerMode != browserDialerModes[0]) { - profileItem.browserDialerMode = browserDialerMode - } else { - profileItem.browserDialerMode = null - } - } else { - profileItem.browserDialerMode = null - } - } - - private fun saveTls(config: ProfileItem) { - val streamSecurity = sp_stream_security?.selectedItemPosition ?: return - val sniField = et_sni?.text?.toString()?.trim() - val allowInsecureField = sp_allow_insecure?.selectedItemPosition - val utlsIndex = sp_stream_fingerprint?.selectedItemPosition ?: 0 - val alpnIndex = sp_stream_alpn?.selectedItemPosition ?: 0 - val publicKey = et_public_key?.text?.toString() - val shortId = et_short_id?.text?.toString() - val spiderX = et_spider_x?.text?.toString() - val mldsa65Verify = et_mldsa65_verify?.text?.toString() - val echConfigList = et_ech_config_list?.text?.toString() - val verifyPeerCertByName = et_verify_peer_cert_by_name?.text?.toString() - val pinnedCA256 = et_pinned_ca256?.text?.toString() - - val allowInsecure = - if (allowInsecureField == null || allowinsecures[allowInsecureField].isBlank()) { - false - } else { - allowinsecures[allowInsecureField].toBoolean() - } - - config.security = streamSecuritys[streamSecurity] - config.insecure = allowInsecure - config.sni = sniField - config.fingerPrint = uTlsItems[utlsIndex] - config.alpn = alpns[alpnIndex] - config.publicKey = publicKey - config.shortId = shortId - config.spiderX = spiderX - config.mldsa65Verify = mldsa65Verify - config.echConfigList = echConfigList - config.verifyPeerCertByName = verifyPeerCertByName - config.pinnedCA256 = pinnedCA256 - } - - private fun fetchPinnedCA256ForCurrentConfig() { - val config = buildCurrentProfileForCertificateFetch() ?: return - - lifecycleScope.launch { - btn_pinned_ca256_action?.isEnabled = false - try { - val sha256 = withContext(Dispatchers.IO) { - CertificateFingerprintManager.fetchForManualFill(config) - } - if (sha256.isNullOrBlank()) { - toast(R.string.toast_fetch_cert_sha256_failed) - } else { - et_pinned_ca256?.text = Utils.getEditable(sha256) - toastSuccess(R.string.toast_fetch_cert_sha256_success) - } - } finally { - btn_pinned_ca256_action?.isEnabled = true - } - } - } - - private fun buildCurrentProfileForCertificateFetch(): ProfileItem? { - if (TextUtils.isEmpty(et_address.text.toString())) { - toast(R.string.server_lab_address) - return null + private fun deleteServer( + guid: String, + isRunning: Boolean + ) { + if ( + guid.isEmpty() || + guid == MmkvManager.getSelectServer() + ) { + toast(R.string.toast_action_not_allowed) + return } - val configType = MmkvManager.decodeServerConfig(editGuid)?.configType ?: createConfigType - if (configType != EConfigType.HYSTERIA2 && Utils.parseInt(et_port.text.toString()) <= 0) { - toast(R.string.server_lab_port) - return null + MmkvManager.removeServer(guid) + + ProfileEditorResult.run { + finishDeleted(guid) } + } +} + +@Composable +fun ServerScreen( + guid: String, + configType: EConfigType, + initialConfig: ProfileItem, + isRunning: Boolean, + onBackClick: () -> Unit, + onSave: (ProfileItem) -> Boolean, + onDelete: () -> Unit +) { + val context = LocalContext.current - val config = ProfileItem.create(configType) - saveCommon(config) - saveStreamSettings(config) - saveTls(config) + val securityOptions = stringArrayResource(R.array.securitys).toList() + val ssSecurityOptions = stringArrayResource(R.array.ss_securitys).toList() + val flowOptions = stringArrayResource(R.array.flows).toList() + val networkOptions = stringArrayResource(R.array.networks).toList() + val tcpHeaderOptions = stringArrayResource(R.array.header_type_tcp).toList() + val kcpHeaderOptions = stringArrayResource(R.array.header_type_kcp_and_quic).toList() + val grpcModeOptions = stringArrayResource(R.array.mode_type_grpc).toList() + val xhttpModeOptions = stringArrayResource(R.array.xhttp_mode).toList() + val streamSecurityOptions = stringArrayResource(R.array.streamsecurityxs).toList() + val allowInsecureOptions = stringArrayResource(R.array.allowinsecures).toList() + val uTlsOptions = stringArrayResource(R.array.streamsecurity_utls).toList() + val alpnOptions = stringArrayResource(R.array.streamsecurity_alpn).toList() + val browserDialerOptions = stringArrayResource(R.array.browser_dialer_mode_value).toList() - return config + var remarks by rememberSaveable { mutableStateOf(initialConfig.remarks) } + var address by rememberSaveable { mutableStateOf(initialConfig.server ?: "") } + var port by rememberSaveable { mutableStateOf(initialConfig.serverPort ?: DEFAULT_PORT.toString()) } + var password by rememberSaveable { mutableStateOf(initialConfig.password ?: "") } + var method by rememberSaveable { mutableStateOf(initialConfig.method ?: "") } + var flow by rememberSaveable { mutableStateOf(initialConfig.flow ?: "") } + var encryption by rememberSaveable { mutableStateOf(initialConfig.method ?: "") } + var username by rememberSaveable { mutableStateOf(initialConfig.username ?: "") } + var secretKey by rememberSaveable { mutableStateOf(initialConfig.secretKey ?: "") } + var publicKey by rememberSaveable { mutableStateOf(initialConfig.publicKey ?: "") } + var preSharedKey by rememberSaveable { mutableStateOf(initialConfig.preSharedKey ?: "") } + var reserved by rememberSaveable { mutableStateOf(initialConfig.reserved ?: "0,0,0") } + var localAddress by rememberSaveable { mutableStateOf(initialConfig.localAddress ?: WIREGUARD_LOCAL_ADDRESS_V4) } + var mtu by rememberSaveable { mutableStateOf(initialConfig.mtu?.toString() ?: WIREGUARD_LOCAL_MTU) } + var obfsPassword by rememberSaveable { mutableStateOf(initialConfig.obfsPassword ?: "") } + var portHopping by rememberSaveable { mutableStateOf(initialConfig.portHopping ?: "") } + var portHoppingInterval by rememberSaveable { mutableStateOf(initialConfig.portHoppingInterval ?: "") } + var bandwidthDown by rememberSaveable { mutableStateOf(initialConfig.bandwidthDown ?: "") } + var bandwidthUp by rememberSaveable { mutableStateOf(initialConfig.bandwidthUp ?: "") } + var network by rememberSaveable { mutableStateOf(initialConfig.network ?: NetworkType.TCP.type) } + var headerType by rememberSaveable { mutableStateOf(initialConfig.headerType ?: "none") } + var host by rememberSaveable { mutableStateOf(initialConfig.host ?: "") } + var path by rememberSaveable { mutableStateOf(initialConfig.path ?: "") } + var seed by rememberSaveable { mutableStateOf(initialConfig.seed ?: "") } + var quicSecurity by rememberSaveable { mutableStateOf(initialConfig.quicSecurity ?: "") } + var quicKey by rememberSaveable { mutableStateOf(initialConfig.quicKey ?: "") } + var mode by rememberSaveable { mutableStateOf(initialConfig.mode ?: "") } + var serviceName by rememberSaveable { mutableStateOf(initialConfig.serviceName ?: "") } + var authority by rememberSaveable { mutableStateOf(initialConfig.authority ?: "") } + var xhttpMode by rememberSaveable { mutableStateOf(initialConfig.xhttpMode ?: "") } + var xhttpExtra by rememberSaveable { mutableStateOf(initialConfig.xhttpExtra ?: "") } + var finalMask by rememberSaveable { mutableStateOf(initialConfig.finalMask ?: "") } + var kcpMtu by rememberSaveable { mutableStateOf(initialConfig.kcpMtu?.toString() ?: "") } + var kcpTti by rememberSaveable { mutableStateOf(initialConfig.kcpTti?.toString() ?: "") } + var browserDialerMode by rememberSaveable { mutableStateOf(initialConfig.browserDialerMode ?: browserDialerOptions.firstOrNull() ?: "Disable") } + var streamSecurity by rememberSaveable { mutableStateOf(initialConfig.security ?: "") } + var sni by rememberSaveable { mutableStateOf(initialConfig.sni ?: "") } + var allowInsecureStr by rememberSaveable { + mutableStateOf( + when { + initialConfig.insecure == true -> "true" + initialConfig.insecure == false -> "false" + else -> "" + } + ) } + var fingerPrint by rememberSaveable { mutableStateOf(initialConfig.fingerPrint ?: "") } + var alpn by rememberSaveable { mutableStateOf(initialConfig.alpn ?: "") } + var publicKeyReality by rememberSaveable { mutableStateOf(initialConfig.publicKey ?: "") } + var shortId by rememberSaveable { mutableStateOf(initialConfig.shortId ?: "") } + var spiderX by rememberSaveable { mutableStateOf(initialConfig.spiderX ?: "") } + var mldsa65Verify by rememberSaveable { mutableStateOf(initialConfig.mldsa65Verify ?: "") } + var echConfigList by rememberSaveable { mutableStateOf(initialConfig.echConfigList ?: "") } + var verifyPeerCertByName by rememberSaveable { mutableStateOf(initialConfig.verifyPeerCertByName ?: "") } + var pinnedCA256 by rememberSaveable { mutableStateOf(initialConfig.pinnedCA256 ?: "") } + + val isVmess = configType == EConfigType.VMESS + val isVless = configType == EConfigType.VLESS + val isShadowsocks = configType == EConfigType.SHADOWSOCKS + val isSocksOrHttp = configType == EConfigType.SOCKS || configType == EConfigType.HTTP + val isTrojan = configType == EConfigType.TROJAN + val isWireguard = configType == EConfigType.WIREGUARD + val isHysteria2 = configType == EConfigType.HYSTERIA2 + + var showDeleteDialog by rememberSaveable { mutableStateOf(false) } + var isFetchingCert by rememberSaveable { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val listState = rememberLazyListState() - private fun transportTypes(network: String?): Array { - return when (network) { - NetworkType.TCP.type -> { - tcpTypes + Scaffold( + contentWindowInsets = ScaffoldDefaults.contentWindowInsets, + topBar = { + AppTopBar( + title = configType.toString(), + onBackClick = onBackClick, + actions = { + if (guid.isNotEmpty() && !isRunning) { + IconButton(onClick = { showDeleteDialog = true }) { + Icon(painterResource(R.drawable.ic_delete_24dp), stringResource(R.string.menu_item_del_config)) + } + } + IconButton(onClick = { + val updated = initialConfig.copy( + remarks = remarks, server = address, serverPort = port, password = password, + method = when { isVmess || isShadowsocks -> method; isVless -> encryption; else -> null }, + flow = if (isVless) flow else null, + username = if (isSocksOrHttp) username else null, + secretKey = if (isWireguard) secretKey else null, + publicKey = when { isWireguard -> publicKey; streamSecurity == REALITY -> publicKeyReality; else -> null }, + preSharedKey = if (isWireguard) preSharedKey else null, + reserved = if (isWireguard) reserved else null, + localAddress = if (isWireguard) localAddress else null, + mtu = if (isWireguard) mtu.toIntOrNull() else null, + obfsPassword = if (isHysteria2) obfsPassword else null, + portHopping = if (isHysteria2) portHopping else null, + portHoppingInterval = if (isHysteria2) portHoppingInterval else null, + bandwidthDown = if (isHysteria2) bandwidthDown else null, + bandwidthUp = if (isHysteria2) bandwidthUp else null, + network = network, headerType = headerType, host = host, path = path, seed = seed, + quicSecurity = quicSecurity, quicKey = quicKey, mode = mode, serviceName = serviceName, + authority = authority, xhttpMode = xhttpMode, xhttpExtra = xhttpExtra.nullIfBlank(), + finalMask = finalMask.nullIfBlank(), kcpMtu = kcpMtu.toIntOrNull(), kcpTti = kcpTti.toIntOrNull(), + browserDialerMode = if (network in listOf(NetworkType.WS.type, NetworkType.XHTTP.type)) browserDialerMode.nullIfBlank() else null, + security = streamSecurity, sni = sni, insecure = allowInsecureStr == "true", + fingerPrint = fingerPrint, alpn = alpn, shortId = shortId, spiderX = spiderX, + mldsa65Verify = mldsa65Verify, echConfigList = echConfigList, + verifyPeerCertByName = verifyPeerCertByName, pinnedCA256 = pinnedCA256 + ) + onSave(updated) + }) { + Icon(painterResource(R.drawable.ic_fab_check), stringResource(R.string.menu_item_save_config)) + } + } + ) + } + ) { padding -> + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScrollbar(listState), + contentPadding = PaddingValues(bottom = 36.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + item { FormTextField(stringResource(R.string.server_lab_remarks), remarks, { remarks = it }) } + item { FormTextField(stringResource(R.string.server_lab_address), address, { address = it }) } + if (configType != EConfigType.HYSTERIA2) item { FormTextField(stringResource(R.string.server_lab_port), port, { port = it }, keyboardType = KeyboardType.Number) } + when { + isVmess || isTrojan || isShadowsocks || isHysteria2 -> item { + FormTextField(stringResource(when { isTrojan||isShadowsocks||isHysteria2 -> R.string.server_lab_id3 else -> R.string.server_lab_id }), password, { password = it }) + } + isVless -> { + item { FormTextField(stringResource(R.string.server_lab_id), password, { password = it }) } + item { FormTextField(stringResource(R.string.server_lab_encryption), encryption, { encryption = it }) } + item { FormDropdownField(stringResource(R.string.server_lab_flow), flow, flowOptions, { flow = it }) } + } + isSocksOrHttp -> { + item { FormTextField(stringResource(R.string.server_lab_security4), username, { username = it }) } + item { FormTextField(stringResource(R.string.server_lab_id4), password, { password = it }) } + } + } + if (isVmess) item { FormDropdownField(stringResource(R.string.server_lab_security), method, securityOptions, { method = it }) } + if (isShadowsocks) item { FormDropdownField(stringResource(R.string.server_lab_security), method, ssSecurityOptions, { method = it }) } + if (isWireguard) { + item { FormTextField(stringResource(R.string.server_lab_secret_key), secretKey, { secretKey = it }) } + item { FormTextField(stringResource(R.string.server_lab_public_key), publicKey, { publicKey = it }) } + item { FormTextField(stringResource(R.string.server_lab_preshared_key), preSharedKey, { preSharedKey = it }) } + item { FormTextField(stringResource(R.string.server_lab_reserved), reserved, { reserved = it }) } + item { FormTextField(stringResource(R.string.server_lab_local_address), localAddress, { localAddress = it }) } + item { FormTextField(stringResource(R.string.server_lab_local_mtu), mtu, { mtu = it }, keyboardType = KeyboardType.Number) } + } + if (isHysteria2) { + item { FormTextField(stringResource(R.string.server_obfs_password), obfsPassword, { obfsPassword = it }) } + item { FormTextField(stringResource(R.string.server_lab_port_hop), portHopping, { portHopping = it }) } + item { FormTextField(stringResource(R.string.server_lab_port_hop_interval), portHoppingInterval, { portHoppingInterval = it }) } + item { FormTextField(stringResource(R.string.server_lab_bandwidth_down), bandwidthDown, { bandwidthDown = it }) } + item { FormTextField(stringResource(R.string.server_lab_bandwidth_up), bandwidthUp, { bandwidthUp = it }) } } - - NetworkType.KCP.type -> { - kcpAndQuicTypes + item { FormDropdownField(stringResource(R.string.server_lab_network), network, networkOptions, { network = it }) } + val headerOptions = when (network) { + NetworkType.TCP.type -> tcpHeaderOptions + NetworkType.KCP.type -> kcpHeaderOptions + NetworkType.GRPC.type -> grpcModeOptions + NetworkType.XHTTP.type -> xhttpModeOptions + else -> listOf("---") + } + if (headerOptions.size > 1) { + item { + FormDropdownField( + stringResource(when (network) { NetworkType.GRPC.type -> R.string.server_lab_mode_type; NetworkType.XHTTP.type -> R.string.server_lab_xhttp_mode; else -> R.string.server_lab_head_type }), + headerType, headerOptions, { headerType = it } + ) + } } - - NetworkType.GRPC.type -> { - grpcModes + item { FormTextField(stringResource(when (network) { + NetworkType.TCP.type, NetworkType.HTTP_UPGRADE.type, NetworkType.XHTTP.type, NetworkType.H2.type -> R.string.server_lab_request_host_http + NetworkType.WS.type -> R.string.server_lab_request_host_ws + NetworkType.GRPC.type -> R.string.server_lab_request_host_grpc + else -> R.string.server_lab_request_host6 + }), host, { host = it }) } + item { FormTextField(stringResource(when (network) { + NetworkType.KCP.type -> R.string.server_lab_path_kcp + NetworkType.WS.type -> R.string.server_lab_path_ws + NetworkType.HTTP_UPGRADE.type -> R.string.server_lab_path_httpupgrade + NetworkType.XHTTP.type -> R.string.server_lab_path_xhttp + NetworkType.H2.type -> R.string.server_lab_path_h2 + NetworkType.GRPC.type -> R.string.server_lab_path_grpc + else -> R.string.server_lab_path + }), path, { path = it }) } + if (network == NetworkType.XHTTP.type) { + item { FormTextField(stringResource(R.string.server_lab_xhttp_extra), xhttpExtra, { xhttpExtra = it }) } + item { FormTextField(stringResource(R.string.server_lab_final_mask), finalMask, { finalMask = it }) } } - - NetworkType.XHTTP.type -> { - xhttpMode + if (network == NetworkType.KCP.type) { + item { FormTextField(stringResource(R.string.server_lab_kcp_mtu), kcpMtu, { kcpMtu = it }, keyboardType = KeyboardType.Number) } + item { FormTextField(stringResource(R.string.server_lab_kcp_tti), kcpTti, { kcpTti = it }, keyboardType = KeyboardType.Number) } } - - else -> { - arrayOf("---") + if (network == NetworkType.WS.type || network == NetworkType.XHTTP.type) { + item { FormDropdownField(stringResource(R.string.server_lab_browser_dialer), browserDialerMode, browserDialerOptions, { browserDialerMode = it }) } } - } - } - - /** - * delete server config - */ - 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() - } - .setNegativeButton(android.R.string.cancel) { _, _ -> - // do nothing - } - .show() - } else { - MmkvManager.removeServer(editGuid) - finish() + item { FormDropdownField(stringResource(R.string.server_lab_stream_security), streamSecurity, streamSecurityOptions, { streamSecurity = it }) } + if (streamSecurity.isNotBlank()) { + item { FormTextField(stringResource(R.string.server_lab_sni), sni, { sni = it }) } + item { FormDropdownField(stringResource(R.string.server_lab_stream_fingerprint), fingerPrint, uTlsOptions, { fingerPrint = it }) } + if (streamSecurity == TLS) { + item { FormDropdownField(stringResource(R.string.server_lab_allow_insecure), allowInsecureStr, allowInsecureOptions, { allowInsecureStr = it }) } + item { FormDropdownField(stringResource(R.string.server_lab_stream_alpn), alpn, alpnOptions, { alpn = it }) } + item { FormTextField(stringResource(R.string.server_lab_ech_config_list), echConfigList, { echConfigList = it }) } + item { FormTextField(stringResource(R.string.server_lab_verify_peer_cert_by_name), verifyPeerCertByName, { verifyPeerCertByName = it }) } + item { FormTextField(stringResource(R.string.server_lab_pinned_ca256), pinnedCA256, { pinnedCA256 = it }) } + item { + Button( + onClick = { + if (address.isBlank()) { context.toast(R.string.server_lab_address); return@Button } + if (configType != EConfigType.HYSTERIA2 && (port.toIntOrNull() ?: 0) <= 0) { context.toast(R.string.server_lab_port); return@Button } + val temp = initialConfig.copy( + remarks = remarks, server = address, serverPort = port, password = password, + method = when { isVmess||isShadowsocks -> method; isVless -> encryption; else -> null }, + flow = if (isVless) flow else null, username = if (isSocksOrHttp) username else null, + secretKey = if (isWireguard) secretKey else null, + publicKey = when { isWireguard -> publicKey; streamSecurity == REALITY -> publicKeyReality; else -> null }, + preSharedKey = if (isWireguard) preSharedKey else null, + reserved = if (isWireguard) reserved else null, + localAddress = if (isWireguard) localAddress else null, + mtu = if (isWireguard) mtu.toIntOrNull() else null, + obfsPassword = if (isHysteria2) obfsPassword else null, + portHopping = if (isHysteria2) portHopping else null, + portHoppingInterval = if (isHysteria2) portHoppingInterval else null, + bandwidthDown = if (isHysteria2) bandwidthDown else null, + bandwidthUp = if (isHysteria2) bandwidthUp else null, + network = network, headerType = headerType, host = host, path = path, seed = seed, + quicSecurity = quicSecurity, quicKey = quicKey, mode = mode, serviceName = serviceName, + authority = authority, xhttpMode = xhttpMode, xhttpExtra = xhttpExtra.nullIfBlank(), + finalMask = finalMask.nullIfBlank(), kcpMtu = kcpMtu.toIntOrNull(), kcpTti = kcpTti.toIntOrNull(), + browserDialerMode = if (network in listOf(NetworkType.WS.type, NetworkType.XHTTP.type)) browserDialerMode.nullIfBlank() else null, + security = streamSecurity, sni = sni, insecure = allowInsecureStr == "true", + fingerPrint = fingerPrint, alpn = alpn, shortId = shortId, spiderX = spiderX, + mldsa65Verify = mldsa65Verify, echConfigList = echConfigList, + verifyPeerCertByName = verifyPeerCertByName, pinnedCA256 = pinnedCA256 + ) + scope.launch { + isFetchingCert = true + try { + val sha256 = withContext(Dispatchers.IO) { CertificateFingerprintManager.fetchForManualFill(temp) } + if (sha256.isNullOrBlank()) context.toast(R.string.toast_fetch_cert_sha256_failed) else { + pinnedCA256 = sha256 + context.toastSuccess(R.string.toast_fetch_cert_sha256_success) + } + } finally { isFetchingCert = false } + } + }, + enabled = !isFetchingCert, + modifier = Modifier.padding(start = 16.dp) + ) { Text(stringResource(R.string.pinned_ca256_action_fetch)) } + } + } else if (streamSecurity == REALITY) { + item { FormTextField(stringResource(R.string.server_lab_public_key), publicKeyReality, { publicKeyReality = it }) } + item { FormTextField(stringResource(R.string.server_lab_short_id), shortId, { shortId = it }) } + item { FormTextField(stringResource(R.string.server_lab_spider_x), spiderX, { spiderX = it }) } + item { FormTextField(stringResource(R.string.server_lab_mldsa65_verify), mldsa65Verify, { mldsa65Verify = it }) } } - } else { - toast(R.string.toast_action_not_allowed) } } - 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 - } - - R.id.save_config -> { - saveServer() - true - } - - else -> super.onOptionsItemSelected(item) + if (showDeleteDialog) { + ConfirmDialog( + message = stringResource(R.string.del_config_comfirm), + confirmText = stringResource(android.R.string.ok), + dismissText = stringResource(android.R.string.cancel), + onConfirm = { showDeleteDialog = false; onDelete() }, + onDismiss = { showDeleteDialog = false } + ) } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt index 7ae22913..589d0a14 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerCustomConfigActivity.kt @@ -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) { _, _ -> - MmkvManager.removeServer(editGuid) - finish() - } - .setNegativeButton(android.R.string.cancel) { _, _ -> - // do nothing - } - .show() + if (editGuid.isEmpty()) { + return false } + + if (editGuid == MmkvManager.getSelectServer()) { + toast(R.string.toast_action_not_allowed) + return false + } + + MmkvManager.removeServer(editGuid) + + ProfileEditorResult.run { + finishDeleted(editGuid) + } + return true } +} - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.action_server, menu) +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 +} - val delButton = menu.findItem(R.id.del_config) - delButton?.isVisible = editGuid.isNotEmpty() && !isRunning +@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 - return super.onCreateOptionsMenu(menu) + val verticalScroll = rememberScrollState() + val horizontalScroll = rememberScrollState() + val density = LocalDensity.current + + var textLayoutResult by remember { mutableStateOf(null) } + var hasInitializedCursor by rememberSaveable { mutableStateOf(false) } + + LaunchedEffect(Unit) { + if (!hasInitializedCursor) { + textFieldState.edit { + selection = TextRange(0, 0) + } + hasInitializedCursor = true + } + } + + 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) - override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - R.id.del_config -> { - deleteServer() - true + 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 } + ) - R.id.save_config -> { - saveServer() - true + 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) + ) + } } + } - 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 } + ) } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerGroupActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerGroupActivity.kt index defdac9f..6880b314 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerGroupActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerGroupActivity.kt @@ -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() + private val subDisplay = mutableListOf() + + 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) { _, _ -> - MmkvManager.removeServer(editGuid) - finish() - } - .setNegativeButton(android.R.string.cancel) { _, _ -> - // do nothing - } - .show() + if (editGuid.isEmpty()) { + return false } + + if (editGuid == MmkvManager.getSelectServer()) { + toast(R.string.toast_action_not_allowed) + return false + } + + MmkvManager.removeServer(editGuid) + + 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) + private fun stringArrayPolicyGroupType(): Array = + resources.getStringArray(R.array.policy_group_type) +} - val delButton = menu.findItem(R.id.del_config) - delButton?.isVisible = editGuid.isNotEmpty() && !isRunning +@Composable +fun ServerGroupScreen( + editGuid: String, + isRunning: Boolean, + subDisplay: List, + 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() - return super.onCreateOptionsMenu(menu) + 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 }) + } } - 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 } + ) } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerProxyChainActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerProxyChainActivity.kt index f6264e71..95fe6b9e 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerProxyChainActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerProxyChainActivity.kt @@ -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 = emptyList() + private lateinit var allRemarks: List + private lateinit var initialRemarks: String + private lateinit var initialMembers: List 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 + ): 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() - } - .setNegativeButton(android.R.string.cancel) { _, _ -> - // do nothing - } - .show() - } else { - MmkvManager.removeServer(editGuid) - finish() - } - } else { - toast(R.string.toast_action_not_allowed) - } + if (editGuid.isEmpty()) { + return false } + + 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 { - return raw.orEmpty() - .split(",") - .map { it.trim() } - .filter { it.isNotEmpty() } - } - - 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() { - } - } - - 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 - } - - R.id.save_config -> { - saveServer() - true - } - - else -> super.onOptionsItemSelected(item) - } } +@Composable +fun ProxyChainScreen( + editGuid: String, + isRunning: Boolean, + initialRemarks: String, + initialMembers: List, + allRemarks: List, + onBackClick: () -> Unit, + onSave: (String, List) -> 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 + 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)) } + } + } + + 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 } + ) + } + + 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) + ) + } + + 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" + ) + } + } + } + } + } + } + } + + 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 } + ) + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerProxyChainMemberAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerProxyChainMemberAdapter.kt deleted file mode 100644 index ebce88b1..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/ServerProxyChainMemberAdapter.kt +++ /dev/null @@ -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, - private val suggestions: List, - private val adapterListener: BaseAdapterListener? -) : RecyclerView.Adapter(), 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) { - members.clear() - members.addAll(newMembers) - notifyDataSetChanged() - adapterListener?.onRefreshData() - } - - fun getMembers(): List = 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) - } - } -} - diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt index bc13fce8..4768f258 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SettingsActivity.kt @@ -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() { + @Composable + override fun ScreenContent() { + SettingsScreen( + onBackClick = { finish() }, + onModeHelpClicked = { Utils.openUri(this, AppConfig.APP_WIKI_MODE) } + ) + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onBackClick: () -> Unit, + onModeHelpClicked: () -> Unit +) { + val scope = rememberCoroutineScope() + val context = LocalContext.current + val scrollState = rememberScrollState() + + suspend fun checkAndRequestRoot(context: Context): Boolean { + val hasRoot = withContext(Dispatchers.IO) { RootManager.refresh() } + if (!hasRoot) { + context.toastError(R.string.toast_root_required) + } + return hasRoot + } - private val localDns by lazy { findPreference(AppConfig.PREF_LOCAL_DNS_ENABLED) } - private val fakeDns by lazy { findPreference(AppConfig.PREF_FAKE_DNS_ENABLED) } - private val appendHttpProxy by lazy { findPreference(AppConfig.PREF_APPEND_HTTP_PROXY) } - - // private val localDnsPort by lazy { findPreference(AppConfig.PREF_LOCAL_DNS_PORT) } - private val vpnDns by lazy { findPreference(AppConfig.PREF_VPN_DNS) } - private val vpnBypassLan by lazy { findPreference(AppConfig.PREF_VPN_BYPASS_LAN) } - private val vpnInterfaceAddress by lazy { findPreference(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) } - private val vpnMtu by lazy { findPreference(AppConfig.PREF_VPN_MTU) } - - private val mux by lazy { findPreference(AppConfig.PREF_MUX_ENABLED) } - private val muxConcurrency by lazy { findPreference(AppConfig.PREF_MUX_CONCURRENCY) } - private val muxXudpConcurrency by lazy { findPreference(AppConfig.PREF_MUX_XUDP_CONCURRENCY) } - private val muxXudpQuic by lazy { findPreference(AppConfig.PREF_MUX_XUDP_QUIC) } - - private val fragment by lazy { findPreference(AppConfig.PREF_FRAGMENT_ENABLED) } - private val fragmentPackets by lazy { findPreference(AppConfig.PREF_FRAGMENT_PACKETS) } - private val fragmentLength by lazy { findPreference(AppConfig.PREF_FRAGMENT_LENGTH) } - private val fragmentInterval by lazy { findPreference(AppConfig.PREF_FRAGMENT_INTERVAL) } - private val fragmentMaxSplit by lazy { findPreference(AppConfig.PREF_FRAGMENT_MAXSPLIT) } - - private val mode by lazy { findPreference(AppConfig.PREF_MODE) } - private val enableRootMode by lazy { findPreference(AppConfig.PREF_ROOT_MODE_ENABLE) } - private val lanSharing by lazy { findPreference(AppConfig.PREF_ROOT_LAN_SHARING) } - - private val hevTunLogLevel by lazy { findPreference(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) } - private val hevTunRwTimeout by lazy { findPreference(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) } - private val useHevTun by lazy { findPreference(AppConfig.PREF_USE_HEV_TUNNEL) } - - private val enableLocalProxy by lazy { findPreference(AppConfig.PREF_ENABLE_LOCAL_PROXY) } - private val socksPort by lazy { findPreference(AppConfig.PREF_SOCKS_PORT) } - private val dynamicSocksPort by lazy { findPreference(AppConfig.PREF_DYNAMIC_SOCKS_PORT) } - private val socksUsername by lazy { findPreference(AppConfig.PREF_SOCKS_USERNAME) } - private val socksPassword by lazy { findPreference(AppConfig.PREF_SOCKS_PASSWORD) } - private val socksEnableUdp by lazy { findPreference(AppConfig.PREF_SOCKS_ENABLE_UDP) } - private val proxySharing by lazy { findPreference(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 + 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, "") + + 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") + + 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") + + 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) + + 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) + + 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) + + 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() + } + ) + 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) } - 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 - } + ) + + 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 } - false - } else { - true } - } - - lanSharing?.setOnPreferenceChangeListener { _, newValue -> - if (newValue == true && !RootManager.cachedRoot()) { - lifecycleScope.launch { - if (checkAndRequestRoot()) { - lanSharing?.isChecked = 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 } + ) + + 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 } } - false - } else { - true } - } - - } - - 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() + ) + 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 } + ) + + 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 } - true } + } else { + enableRootMode = newValue } - - 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 + } + ) + 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 + } } - } - - 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) + } else { + lanSharing = newValue } - } - } - - preferenceScreen?.let { traverse(it) } - } - - private suspend fun checkAndRequestRoot(): Boolean { - val hasRoot = RootManager.refresh() - if (!isAdded) return false - if (!hasRoot) { - context?.toastError(R.string.toast_root_required) - } - return hasRoot - } - - override fun onStart() { - super.onStart() - updateHevTunSettings(MmkvManager.decodeSettingsBool(AppConfig.PREF_USE_HEV_TUNNEL, true)) - - // Initialize mode-dependent UI states - updateMode(MmkvManager.decodeSettingsString(AppConfig.PREF_MODE, VPN)) - - // Initialize local proxy state - updateEnableLocalProxy(MmkvManager.decodeSettingsBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)) - - // Initialize mux-dependent UI states - updateMux(MmkvManager.decodeSettingsBool(AppConfig.PREF_MUX_ENABLED, false)) - - // Initialize fragment-dependent UI states - updateFragment(MmkvManager.decodeSettingsBool(AppConfig.PREF_FRAGMENT_ENABLED, false)) - - updateDynamicSocksPort(MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, 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 - ) - ) - } - } - - private fun updateLocalDns(enabled: Boolean) { - fakeDns?.isEnabled = enabled -// localDnsPort?.isEnabled = enabled - vpnDns?.isEnabled = !enabled - } - - 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")) - } - } - - private fun updateMuxConcurrency(value: String?) { - val concurrency = value?.toIntOrNull() ?: 8 - muxConcurrency?.summary = concurrency.toString() - } - - - private fun updateMuxXudpConcurrency(value: String?) { - if (value == null) { - muxXudpQuic?.isEnabled = true - } else { - val concurrency = value.toIntOrNull() ?: 8 - muxXudpConcurrency?.summary = concurrency.toString() - muxXudpQuic?.isEnabled = concurrency >= 0 - } - } - - private fun updateFragment(enabled: Boolean) { - fragmentPackets?.isEnabled = enabled - fragmentLength?.isEnabled = enabled - fragmentInterval?.isEnabled = enabled - fragmentMaxSplit?.isEnabled = enabled - } - - 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 - } - } + ) - 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) } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubEditActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubEditActivity.kt index 2272f088..93fa9df7 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubEditActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubEditActivity.kt @@ -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 + 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 - ) { - 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() - } - } + lifecycleScope.launch(Dispatchers.IO) { + SettingsManager.removeSubscriptionWithDefault(editSubId) + 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) +@Composable +fun SubEditScreen( + editSubId: String, + initial: SubscriptionItem, + profileSuggestions: List, + onBackClick: () -> Unit, + onSave: (SubscriptionItem) -> Boolean, + onDelete: () -> Unit +) { + val context = LocalContext.current - return super.onCreateOptionsMenu(menu) + 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 } - override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - R.id.del_config -> { - deleteServer() - 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 } + ) - R.id.save_config -> { - saveServer() - true + 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 } + ) } - } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt index 6e04500e..8d4e2667 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingActivity.kt @@ -1,168 +1,314 @@ 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 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() - } - - override fun onCreateOptionsMenu(menu: Menu): Boolean { - menuInflater.inflate(R.menu.action_sub_setting, menu) - return super.onCreateOptionsMenu(menu) - } - - override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) { - R.id.add_config -> { - startActivity(Intent(this, SubEditActivity::class.java)) - true - } - - R.id.sub_update -> { - showLoading() - - lifecycleScope.launch(Dispatchers.IO) { - val result = AngConfigManager.updateConfigViaSubAll() - delay(500L) - launch(Dispatchers.Main) { - if (result.successCount + result.failureCount + result.skipCount == 0) { - toast(R.string.title_update_subscription_no_subscription) - } else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) { - toast(getString(R.string.title_update_config_count, result.configCount)) - } else { - toast( - getString( - R.string.title_update_subscription_result, - result.configCount, result.successCount, result.failureCount, result.skipCount - ) - ) - } - hideLoading() - refreshData() - } - } - - true - } - - else -> super.onOptionsItemSelected(item) - - } - - @SuppressLint("NotifyDataSetChanged") - fun refreshData() { 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) - ) - } + private fun removeSub(subId: String) { + viewModel.remove(subId) + } - 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() - } - .setNegativeButton(android.R.string.cancel, null) - .show() - } else { - viewModel.remove(guid) - refreshData() + private fun updateSubscriptions() { + isLoadingState.value = true + lifecycleScope.launch(Dispatchers.IO) { + val result = AngConfigManager.updateConfigViaSubAll() + delay(500L) + launch(Dispatchers.Main) { + if (result.successCount + result.failureCount + result.skipCount == 0) { + toast(R.string.title_update_subscription_no_subscription) + } else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) { + toast(getString(R.string.title_update_config_count, result.configCount)) + } else { + toast( + getString( + R.string.title_update_subscription_result, + result.configCount, result.successCount, result.failureCount, result.skipCount + ) + ) + } + isLoadingState.value = false + viewModel.reload() } } + } +} - override fun onShare(url: String) { - AlertDialog.Builder(ownerActivity) - .setItems(share_method.asList().toTypedArray()) { _, i -> - try { - when (i) { - 0 -> { - val ivBinding = - ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity)) - ivBinding.ivQcode.setImageBitmap( - QRCodeDecoder.createQRCode( - url +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SubSettingScreen( + viewModel: SubscriptionsViewModel, + isLoadingState: MutableStateFlow, + onBackClick: () -> Unit, + onAddClick: () -> Unit, + onSubUpdate: () -> Unit, + onEditSub: (String) -> Unit, + onRemoveSub: (String) -> Unit, + onShareQRCode: (String) -> android.graphics.Bitmap?, + onShareClipboard: (String) -> Unit, + shareSubMethodEntries: List +) { + val subscriptions by viewModel.subsFlow.collectAsStateWithLifecycle() + val isLoading by isLoadingState.collectAsState() + var removeTarget by remember { mutableStateOf(null) } + val confirmRemove = MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE, false) + var shareTarget by remember { mutableStateOf?>(null) } + var showQRCodeBitmap by remember { mutableStateOf(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 ) - AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show() } - 1 -> { - Utils.setClipboard(ownerActivity, url) + 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 + ) + ) } - - else -> ownerActivity.toast("else") } - } catch (e: Exception) { - LogUtil.e(AppConfig.TAG, "Share subscription failed", e) } - }.show() + AppDivider(modifier = Modifier.padding(horizontal = 14.dp)) + } + } } + } - override fun onRefreshData() { - refreshData() - } + if (shareTarget != null) { + val (_, url) = shareTarget!! + SelectListDialog( + options = shareSubMethodEntries, + onSelected = { index, _ -> + shareTarget = null + when (index) { + 0 -> { + // QRCode + showQRCodeBitmap = onShareQRCode(url) + } + 1 -> { + // Export to clipboard + onShareClipboard(url) + } + } + }, + onDismiss = { shareTarget = null } + ) + } + + // QR Code Dialog + if (showQRCodeBitmap != null) { + QRCodeDialog( + bitmap = showQRCodeBitmap, + onDismiss = { showQRCodeBitmap = null } + ) + } + + 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 } + ) } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt deleted file mode 100644 index 0c482499..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/SubSettingRecyclerAdapter.kt +++ /dev/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(), 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) { - } -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt index 1918c1cf..33a68b68 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/TaskerActivity.kt @@ -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 = ArrayList() private var lstGuid: ArrayList = 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(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 - } - - R.id.save_config -> { - confirmFinish() - true - } - - else -> super.onOptionsItemSelected(item) - } - } +@Composable +fun TaskerScreen( + items: List, + switchState: MutableState, + selectedPosition: MutableState, + 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) + } + } + } + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt index d8358b92..593859d0 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UrlSchemeActivity.kt @@ -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 @@ -84,4 +84,4 @@ class UrlSchemeActivity : BaseActivity() { } } } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt index 59a0e212..089de061 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetActivity.kt @@ -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)) + geoFilesSourceState.value = getGeoFilesSources() + } - 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() - } + @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, + geoFilesSourceState: MutableStateFlow, + refreshTrigger: MutableStateFlow, + geoFilesSourcesList: List, + 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(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)) + } + } ) } - - 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() + ) { 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 + ) } - .setNegativeButton(android.R.string.cancel) { _, _ -> - // do nothing - } - .show() - } - - override fun onShare(url: String) { - } - - override fun onRefreshData() { - refreshData() + } + 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() + } } } -} \ No newline at end of file + + 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 } + ) + } + + 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 } + ) + } +} + +@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" + + 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) + ) + } + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetAdapter.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetAdapter.kt deleted file mode 100644 index a7fbe452..00000000 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetAdapter.kt +++ /dev/null @@ -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() { - - 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) -} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt index 4049aa26..78366c91 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/ui/UserAssetUrlActivity.kt @@ -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) - assetUrlQrcode != null -> { - binding.etRemarks.setText(assetNameQrcode) - binding.etUrl.setText(assetUrlQrcode) - } - else -> clearAsset() + when { + assetItem != null -> { + initialRemarks = assetItem.remarks + initialUrl = assetItem.url + } + assetUrlQrcode != null -> { + initialRemarks = assetNameQrcode + initialUrl = assetUrlQrcode + } + else -> { + initialRemarks = "" + initialUrl = "" + } } } - /** - * 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() + MmkvManager.removeAssetUrl(editAssetId) + 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) +@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) } - if (editAssetId.isEmpty()) { - del_config?.isVisible = 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 } + ) } - - return super.onCreateOptionsMenu(menu) } - 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 } + ) } -} \ No newline at end of file +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/AppPickerViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/AppPickerViewModel.kt new file mode 100644 index 00000000..eb36f731 --- /dev/null +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/AppPickerViewModel.kt @@ -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>(emptySet()) + val selectedPackages: StateFlow> = _selectedPackages.asStateFlow() + + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() + + private val _displayedApps = MutableStateFlow>(emptyList()) + val displayedApps: StateFlow> = _displayedApps.asStateFlow() + + private var appsAll: List = emptyList() + private var currentQuery: String = "" + private var SelectedSnapshot: Set = emptySet() + + fun initialize(initialSelected: Collection) { + _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 = _selectedPackages.value.sorted() + + private fun applyFilter(query: String): List { + 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): List { + 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 + ) + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/LogcatViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/LogcatViewModel.kt index c8ee8342..5983c69e 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/LogcatViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/LogcatViewModel.kt @@ -1,35 +1,49 @@ 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 = mutableListOf() - private var filteredLogs: List = emptyList() private var currentFilter: String = "" - fun getAll(): List = filteredLogs + private val _filteredLogs = MutableStateFlow>(emptyList()) + val filteredLogs: StateFlow> = _filteredLogs.asStateFlow() + + private val _isRefreshing = MutableStateFlow(false) + val isRefreshing: StateFlow = _isRefreshing.asStateFlow() fun loadLogcat() { - try { - val lst = LinkedHashSet() - lst.add("logcat") - lst.add("-d") - lst.add("-v") - lst.add("time") - lst.add("-s") - lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err") - val process = Runtime.getRuntime().exec(lst.toTypedArray()) - val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed() + viewModelScope.launch(Dispatchers.IO) { + _isRefreshing.value = true + try { + val lst = LinkedHashSet() + lst.add("logcat") + lst.add("-d") + lst.add("-v") + lst.add("time") + lst.add("-s") + lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err") + val process = Runtime.getRuntime().exec(lst.toTypedArray()) + val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed() - logsetsAll.clear() - logsetsAll.addAll(allText) - applyFilter() - } catch (e: IOException) { - LogUtil.e(AppConfig.TAG, "Failed to get logcat", e) + logsetsAll.clear() + logsetsAll.addAll(allText) + 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) } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt index 0eff1f0f..7a66c137 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/MainViewModel.kt @@ -5,19 +5,20 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.res.AssetManager import androidx.core.content.ContextCompat import androidx.lifecycle.AndroidViewModel -import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope import com.v2ray.ang.AngApplication import com.v2ray.ang.AppConfig import com.v2ray.ang.R import com.v2ray.ang.dto.GroupMapItem +import com.v2ray.ang.dto.LocateTarget import com.v2ray.ang.dto.SubscriptionUpdateResult import com.v2ray.ang.dto.TestServiceMessage +import com.v2ray.ang.dto.entities.ProfileItem import com.v2ray.ang.dto.entities.ServersCache import com.v2ray.ang.dto.entities.SubscriptionCache +import com.v2ray.ang.dto.entities.SubscriptionItem import com.v2ray.ang.extension.isComplexType import com.v2ray.ang.extension.matchesPattern import com.v2ray.ang.extension.toastError @@ -25,452 +26,1163 @@ import com.v2ray.ang.extension.toastSuccess import com.v2ray.ang.handler.AngConfigManager import com.v2ray.ang.handler.MmkvManager import com.v2ray.ang.handler.SettingsManager +import com.v2ray.ang.handler.SubscriptionUpdater import com.v2ray.ang.util.LogUtil import com.v2ray.ang.util.MessageUtil import com.v2ray.ang.util.Utils -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import java.util.Collections +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger import java.util.regex.PatternSyntaxException +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock -class MainViewModel(application: Application) : AndroidViewModel(application) { - private var serverList = mutableListOf() // MmkvManager.decodeServerList() - var subscriptionId: String = MmkvManager.decodeSettingsString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty() - var keywordFilter = "" - val serversCache = mutableListOf() - val isRunning by lazy { MutableLiveData() } - val updateListAction by lazy { MutableLiveData() } - val updateTestResultAction by lazy { MutableLiveData() } +data class MainUiState( + val groups: List = emptyList(), + val selectedGroupId: String = "", + val selectedGuid: String? = null, + val isRunning: Boolean = false, + val isLoading: Boolean = false, + val isTesting: Boolean = false, + val statusText: String = "", + val confirmRemove: Boolean = false, + val doubleColumnDisplay: Boolean = false +) - /** - * Refer to the official documentation for [registerReceiver](https://developer.android.com/reference/androidx/core/content/ContextCompat#registerReceiver(android.content.Context,android.content.BroadcastReceiver,android.content.IntentFilter,int): - * `registerReceiver(Context, BroadcastReceiver, IntentFilter, int)`. - */ - fun startListenBroadcast() { - isRunning.value = false - val mFilter = IntentFilter(AppConfig.BROADCAST_ACTION_ACTIVITY) - ContextCompat.registerReceiver(getApplication(), mMsgReceiver, mFilter, Utils.receiverFlags()) - MessageUtil.sendMsg2Service(getApplication(), AppConfig.MSG_REGISTER_CLIENT, "") +class MainViewModel( + application: Application +) : AndroidViewModel(application) { + + private val app: AngApplication + get() = getApplication() + + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO + private val defaultDispatcher: CoroutineDispatcher = Dispatchers.Default + + private val preloadDispatcher: CoroutineDispatcher = + Dispatchers.IO.limitedParallelism(1) + + private val disconnectedText: String + get() = app.getString(R.string.connection_not_connected) + + private val connectedText: String + get() = app.getString(R.string.connection_connected) + + private val _uiState = MutableStateFlow( + MainUiState( + selectedGroupId = MmkvManager.decodeSettingsString( + AppConfig.CACHE_SUBSCRIPTION_ID, + "" + ).orEmpty(), + selectedGuid = MmkvManager.getSelectServer(), + statusText = app.getString(R.string.connection_not_connected), + confirmRemove = MmkvManager.decodeSettingsBool( + AppConfig.PREF_CONFIRM_REMOVE, + false + ), + doubleColumnDisplay = MmkvManager.decodeSettingsBool( + AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, + false + ) + ) + ) + val uiState: StateFlow = _uiState.asStateFlow() + + val subscriptionId: String + get() = _uiState.value.selectedGroupId + + @Volatile + var keywordFilter: String = "" + private set + + private val _locateEvent = MutableSharedFlow( + extraBufferCapacity = 1 + ) + val locateEvent: SharedFlow = _locateEvent.asSharedFlow() + + private val cacheMutex = Mutex() + private val groupDataCache = mutableMapOf>() + + private val groupPageFlows = + ConcurrentHashMap>>() + + private val groupLoadMutexes = + ConcurrentHashMap() + + private val loadingCount = AtomicInteger(0) + private val initialPageReady = CompletableDeferred() + + private var setupGroupJob: Job? = null + private var preloadJob: Job? = null + private var filterJob: Job? = null + private var reloadJob: Job? = null + private var selectedGroupLoadJob: Job? = null + + private var receiverRegistered = false + private var initialized = false + + @Volatile + private var testingGroupId: String? = null + + fun serversForGroup(groupId: String): StateFlow> = + groupPageFlows + .computeIfAbsent(groupId) { + MutableStateFlow(emptyList()) + } + .asStateFlow() + + private fun mutableServersForGroup( + groupId: String + ): MutableStateFlow> = + groupPageFlows.computeIfAbsent(groupId) { + MutableStateFlow(emptyList()) + } + + private fun currentServers(): List = + mutableServersForGroup(subscriptionId).value + + fun initialize() { + if (initialized) return + initialized = true + + startListenBroadcast() + setupGroupTab() + + viewModelScope.launch(preloadDispatcher) { + try { + initialPageReady.await() + delay(32L) + + SettingsManager.initAssets(app, app.assets) + SubscriptionUpdater.sync(app) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + LogUtil.e( + AppConfig.TAG, + "Main background initialization failed", + error + ) + } + } } - /** - * Called when the ViewModel is cleared. - */ - override fun onCleared() { - getApplication().unregisterReceiver(mMsgReceiver) - LogUtil.i(AppConfig.TAG, "Main ViewModel is cleared") - super.onCleared() + fun refreshUiSettings() { + _uiState.update { + it.copy( + confirmRemove = MmkvManager.decodeSettingsBool( + AppConfig.PREF_CONFIRM_REMOVE, + false + ), + doubleColumnDisplay = MmkvManager.decodeSettingsBool( + AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, + false + ) + ) + } } - /** - * Reloads the server list based on current subscription filter. - */ - fun reloadServerList() { - serverList = if (subscriptionId.isEmpty()) { + fun setLoading(loading: Boolean) { + if (loading) { + val count = loadingCount.incrementAndGet() + if (count == 1) { + _uiState.update { it.copy(isLoading = true) } + } + } else { + val count = loadingCount.updateAndGet { + if (it > 0) it - 1 else 0 + } + if (count == 0) { + _uiState.update { it.copy(isLoading = false) } + } + } + } + + private fun getServerGuidList(groupId: String): List = + if (groupId.isEmpty()) { MmkvManager.decodeAllServerList() } else { - MmkvManager.decodeServerList(subscriptionId) + MmkvManager.decodeServerList(groupId) } - updateCache() - updateListAction.value = -1 - } + private suspend fun buildServersCache( + guids: List + ): List = + guids.mapNotNull { guid -> + currentCoroutineContext().ensureActive() - /** - * Removes a server by its GUID. - * @param guid The GUID of the server to remove. - */ - fun removeServer(guid: String) { - serverList.remove(guid) - MmkvManager.removeServer(guid) - val index = getPosition(guid) - if (index >= 0) { - serversCache.removeAt(index) + val profile = MmkvManager.decodeServerConfig(guid) + ?: return@mapNotNull null + + val affiliation = + MmkvManager.decodeServerAffiliationInfo(guid) + + ServersCache( + guid = guid, + profile = profile.copy(), + testDelayMillis = + affiliation?.testDelayMillis ?: 0L, + testDelayString = + affiliation?.getTestDelayString().orEmpty() + ) + } + + private suspend fun loadGroup( + groupId: String, + forceRefresh: Boolean = false + ): List { + val loadMutex = groupLoadMutexes.computeIfAbsent(groupId) { + Mutex() + } + + return loadMutex.withLock { + if (!forceRefresh) { + cacheMutex.withLock { + groupDataCache[groupId]?.let { + return@withLock it + } + } + } + + val servers = buildServersCache( + getServerGuidList(groupId) + ) + + currentCoroutineContext().ensureActive() + + cacheMutex.withLock { + groupDataCache[groupId] = servers + } + + servers } } - /** - * Swaps the positions of two servers. - * @param fromPosition The initial position of the server. - * @param toPosition The target position of the server. - */ - fun swapServer(fromPosition: Int, toPosition: Int) { - if (subscriptionId.isEmpty()) { + private fun applyKeywordFilter( + servers: List + ): List { + val keyword = keywordFilter.trim() + if (keyword.isEmpty()) return servers + + val regex = try { + Regex(keyword, RegexOption.IGNORE_CASE) + } catch (_: PatternSyntaxException) { + return servers + } + + return servers.filter { cache -> + val profile = cache.profile + + profile.remarks.matchesPattern(regex, keyword) || + profile.description.orEmpty() + .matchesPattern(regex, keyword) || + profile.server.orEmpty() + .matchesPattern(regex, keyword) || + profile.configType.name + .matchesPattern(regex, keyword) + } + } + + private fun updateGroupUi( + groupId: String, + servers: List + ) { + mutableServersForGroup(groupId).value = + applyKeywordFilter(servers) + } + + private fun getSubscriptionsInternal(): + List { + val result = mutableListOf() + + if ( + MmkvManager.decodeSettingsBool( + AppConfig.PREF_GROUP_ALL_DISPLAY + ) + ) { + result += SubscriptionCache( + guid = "", + subscription = SubscriptionItem().apply { + remarks = app.getString( + R.string.filter_config_all + ) + } + ) + } + + result += MmkvManager.decodeSubscriptions() + return result + } + + fun getSubscriptions(): List = + getSubscriptionsInternal() + + private fun resolveSelectedGroup( + groups: List + ): String { + val current = subscriptionId + + val resolved = when { + groups.isEmpty() -> "" + groups.any { it.id == current } -> current + else -> groups.first().id + } + + if (resolved != current) { + MmkvManager.encodeSettings( + AppConfig.CACHE_SUBSCRIPTION_ID, + resolved + ) + } + + return resolved + } + + private fun radialPreloadOrder( + groups: List, + selectedIndex: Int + ): List { + if (groups.isEmpty()) return emptyList() + + val result = ArrayList( + (groups.size - 1).coerceAtLeast(0) + ) + + for (distance in 1 until groups.size) { + val right = selectedIndex + distance + val left = selectedIndex - distance + + if (right in groups.indices) { + result += groups[right].id + } + if (left in groups.indices) { + result += groups[left].id + } + } + + return result + } + + fun setupGroupTab( + forceRefresh: Boolean = false + ): Job { + setupGroupJob?.cancel() + preloadJob?.cancel() + selectedGroupLoadJob?.cancel() + + return viewModelScope.launch(ioDispatcher) { + try { + if (forceRefresh) { + cacheMutex.withLock { + groupDataCache.clear() + } + } + + val groups = getSubscriptionsInternal().map { + GroupMapItem( + id = it.guid, + remarks = it.subscription.remarks + ) + } + + val selectedGroup = + resolveSelectedGroup(groups) + + val validIds = + groups.mapTo(HashSet()) { it.id } + + groupPageFlows.keys.removeAll { it !in validIds } + groupLoadMutexes.keys.removeAll { it !in validIds } + + _uiState.update { + it.copy( + groups = groups, + selectedGroupId = selectedGroup, + selectedGuid = + MmkvManager.getSelectServer() + ) + } + groups.forEach { mutableServersForGroup(it.id) } + + if (groups.isEmpty()) { + cacheMutex.withLock { + groupDataCache.clear() + } + return@launch + } + + val selectedServers = loadGroup( + groupId = selectedGroup, + forceRefresh = forceRefresh + ) + updateGroupUi( + selectedGroup, + selectedServers + ) + + if (!initialPageReady.isCompleted) { + initialPageReady.complete(Unit) + } + + val selectedIndex = groups.indexOfFirst { + it.id == selectedGroup + }.coerceAtLeast(0) + + val preloadOrder = radialPreloadOrder( + groups, + selectedIndex + ) + + preloadJob = viewModelScope.launch( + preloadDispatcher + ) { + preloadOrder.forEach { groupId -> + ensureActive() + + delay(32L) + + val servers = loadGroup( + groupId = groupId, + forceRefresh = forceRefresh + ) + updateGroupUi(groupId, servers) + } + } + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + LogUtil.e( + AppConfig.TAG, + "Failed to set up group tabs", + error + ) + } finally { + if (!initialPageReady.isCompleted) { + initialPageReady.complete(Unit) + } + } + }.also { + setupGroupJob = it + } + } + + fun subscriptionIdChanged(id: String) { + if (_uiState.value.groups.none { it.id == id }) { + return + } + mutableServersForGroup(id) + + if (subscriptionId != id) { + MmkvManager.encodeSettings( + AppConfig.CACHE_SUBSCRIPTION_ID, + id + ) + _uiState.update { + it.copy(selectedGroupId = id) + } + } + + selectedGroupLoadJob?.cancel() + selectedGroupLoadJob = viewModelScope.launch( + ioDispatcher + ) { + try { + updateGroupUi( + id, + loadGroup(id) + ) + } catch (cancelled: CancellationException) { + throw cancelled + } catch (error: Exception) { + LogUtil.e( + AppConfig.TAG, + "Failed to load selected group: $id", + error + ) + } + } + } + + fun reloadServerList() { + val groupId = subscriptionId + + selectedGroupLoadJob?.cancel() + selectedGroupLoadJob = viewModelScope.launch( + ioDispatcher + ) { + updateGroupUi( + groupId, + loadGroup( + groupId, + forceRefresh = true + ) + ) + } + } + + fun reloadAllGroups(groupIds: List) { + reloadJob?.cancel() + + reloadJob = viewModelScope.launch( + preloadDispatcher + ) { + val selected = subscriptionId + + val order = buildList { + if (selected in groupIds) add(selected) + addAll(groupIds.filter { it != selected }) + } + + order.forEachIndexed { index, groupId -> + ensureActive() + + if (index > 0) { + delay(32L) + } + + updateGroupUi( + groupId, + loadGroup( + groupId, + forceRefresh = true + ) + ) + } + } + } + + fun filterConfig(keyword: String) { + if (keyword == keywordFilter) return + + keywordFilter = keyword + filterJob?.cancel() + + filterJob = viewModelScope.launch( + defaultDispatcher + ) { + delay(300L) + + val snapshot = cacheMutex.withLock { + groupDataCache.toMap() + } + + ensureActive() + + snapshot.forEach { (groupId, servers) -> + ensureActive() + updateGroupUi(groupId, servers) + } + } + } + + fun updateSelectedGuid(guid: String) { + MmkvManager.setSelectServer(guid) + _uiState.update { + it.copy(selectedGuid = guid) + } + } + + fun refreshSelectedGuid() { + _uiState.update { + it.copy( + selectedGuid = + MmkvManager.getSelectServer() + ) + } + } + + fun removeServerAndRefresh(guid: String) { + viewModelScope.launch(ioDispatcher) { + MmkvManager.removeServer(guid) + + cacheMutex.withLock { + groupDataCache.clear() + } + + setupGroupTab( + forceRefresh = true + ).join() + } + } + + fun swapServer( + fromPosition: Int, + toPosition: Int + ) { + val groupId = subscriptionId + if (groupId.isEmpty()) return + + val servers = currentServers().toMutableList() + + if ( + fromPosition !in servers.indices || + toPosition !in servers.indices + ) { return } - Collections.swap(serverList, fromPosition, toPosition) - Collections.swap(serversCache, fromPosition, toPosition) - - MmkvManager.encodeServerList(serverList, subscriptionId) - } - - /** - * Updates the cache of servers. - */ - @Synchronized - fun updateCache() { - serversCache.clear() - val kw = keywordFilter.trim() - val searchRegex = try { - if (kw.isNotEmpty()) Regex(kw, setOf(RegexOption.IGNORE_CASE)) else null - } catch (e: PatternSyntaxException) { - null // Fallback to literal search if regex is invalid - } - for (guid in serverList) { - val profile = MmkvManager.decodeServerConfig(guid) ?: continue - if (kw.isEmpty()) { - serversCache.add(ServersCache(guid, profile)) - continue - } - - val remarks = profile.remarks - val description = profile.description.orEmpty() - val server = profile.server.orEmpty() - val protocol = profile.configType.name - if (remarks.matchesPattern(searchRegex, kw) - || description.matchesPattern(searchRegex, kw) - || server.matchesPattern(searchRegex, kw) - || protocol.matchesPattern(searchRegex, kw) - ) { - serversCache.add(ServersCache(guid, profile)) - } - } - } - - /** - * Updates the configuration via subscription for all servers. - * @return Detailed result of the subscription update operation. - */ - fun updateConfigViaSubAll(): SubscriptionUpdateResult { - if (subscriptionId.isEmpty()) { - return AngConfigManager.updateConfigViaSubAll() - } else { - val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return SubscriptionUpdateResult() - return AngConfigManager.updateConfigViaSub(SubscriptionCache(subscriptionId, subItem)) - } - } - - /** - * Exports all servers. - * @return The number of exported servers. - */ - fun exportAllServer(): Int { - val serverListCopy = - if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) { - serverList - } else { - serversCache.map { it.guid }.toList() - } - - val ret = AngConfigManager.shareNonCustomConfigsToClipboard( - getApplication(), - serverListCopy + Collections.swap( + servers, + fromPosition, + toPosition ) - return ret + + val guids = servers.mapTo( + ArrayList(servers.size) + ) { it.guid } + + mutableServersForGroup(groupId).value = servers + + viewModelScope.launch(ioDispatcher) { + MmkvManager.encodeServerList( + guids, + groupId + ) + + cacheMutex.withLock { + groupDataCache[groupId] = servers + } + } + } + + fun cancelAllPing() { + MessageUtil.sendMsg2TestService( + app, + TestServiceMessage( + key = AppConfig.MSG_MEASURE_CONFIG_CANCEL + ) + ) + + testingGroupId = null + + _uiState.update { + it.copy( + isTesting = false, + statusText = if (it.isRunning) { + connectedText + } else { + disconnectedText + } + ) + } } - /** - * Tests the real ping for all servers. - */ fun testAllRealPing() { MessageUtil.sendMsg2TestService( - getApplication(), - TestServiceMessage(key = AppConfig.MSG_MEASURE_CONFIG_CANCEL) + app, + TestServiceMessage( + key = AppConfig.MSG_MEASURE_CONFIG_CANCEL + ) ) - MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList()) - updateListAction.value = -1 - viewModelScope.launch(Dispatchers.Default) { - if (serversCache.isEmpty()) { - return@launch + val groupId = subscriptionId + val servers = currentServers() + + MmkvManager.clearAllTestDelayResults( + servers.map { it.guid } + ) + + if (servers.isEmpty()) { + _uiState.update { + it.copy(isTesting = false) } - MessageUtil.sendMsg2TestService( - getApplication(), - TestServiceMessage( - key = AppConfig.MSG_MEASURE_CONFIG_START, - subscriptionId = subscriptionId, - serverGuids = if (keywordFilter.isNotEmpty()) serversCache.map { it.guid } else emptyList() - ) - ) - } - } - - /** - * Tests the real ping for the current server. - */ - fun testCurrentServerRealPing() { - MessageUtil.sendMsg2Service(getApplication(), AppConfig.MSG_MEASURE_DELAY, "") - } - - /** - * Changes the subscription ID. - * @param id The new subscription ID. - */ - fun subscriptionIdChanged(id: String) { - if (subscriptionId != id) { - subscriptionId = id - MmkvManager.encodeSettings(AppConfig.CACHE_SUBSCRIPTION_ID, subscriptionId) - } - reloadServerList() - } - - /** - * Gets the subscriptions. - * @param context The context. - * @return A pair of lists containing the subscription IDs and remarks. - */ - fun getSubscriptions(context: Context): List { - val subscriptions = MmkvManager.decodeSubscriptions() - if (subscriptionId.isNotEmpty() - && !subscriptions.map { it.guid }.contains(subscriptionId) - ) { - subscriptionIdChanged("") - } - - val groups = mutableListOf() - if (MmkvManager.decodeSettingsBool(AppConfig.PREF_GROUP_ALL_DISPLAY)) { - groups.add( - GroupMapItem( - id = "", - remarks = context.getString(R.string.filter_config_all) - ) - ) - } - subscriptions.forEach { sub -> - groups.add( - GroupMapItem( - id = sub.guid, - remarks = sub.subscription.remarks - ) - ) - } - return groups - } - - /** - * Gets the position of a server by its GUID. - * @param guid The GUID of the server. - * @return The position of the server. - */ - fun getPosition(guid: String): Int { - serversCache.forEachIndexed { index, it -> - if (it.guid == guid) - return index - } - return -1 - } - - /** - * Removes duplicate servers. - * Excludes servers with complex types (Custom, PolicyGroup, or ProxyChain) from duplicate comparison. - * @return The number of removed servers. - */ - fun removeDuplicateServer(): Int { - val serversCacheCopy = serversCache.toList().toMutableList() - val deleteServer = mutableListOf() - - serversCacheCopy.forEachIndexed { index, sc -> - val profile = sc.profile - // Skip if this profile has a complex config type - if (profile.configType.isComplexType()) { - return@forEachIndexed - } - - serversCacheCopy.forEachIndexed { index2, sc2 -> - if (index2 > index) { - val profile2 = sc2.profile - // Skip if the second profile has a complex config type - if (profile2.configType.isComplexType()) { - return@forEachIndexed - } - - if (profile == profile2 && !deleteServer.contains(sc2.guid)) { - deleteServer.add(sc2.guid) - } - } - } - } - for (it in deleteServer) { - MmkvManager.removeServer(it) - } - - return deleteServer.count() - } - - /** - * Removes all servers. - * @return The number of removed servers. - */ - fun removeAllServer(): Int { - val count = - if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) { - MmkvManager.removeAllServer() - } else { - val serversCopy = serversCache.toList() - for (item in serversCopy) { - MmkvManager.removeServer(item.guid) - } - serversCache.toList().count() - } - return count - } - - /** - * Removes invalid servers. - * @return The number of removed servers. - */ - fun removeInvalidServer(): Int { - var count = 0 - if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) { - count += MmkvManager.removeInvalidServer("") - } else { - val serversCopy = serversCache.toList() - for (item in serversCopy) { - count += MmkvManager.removeInvalidServer(item.guid) - } - } - return count - } - - /** - * Sorts servers by their test results. - */ - fun sortByTestResults() { - if (subscriptionId.isEmpty()) { - MmkvManager.decodeSubsList().forEach { guid -> - sortByTestResultsForSub(guid) - } - } else { - sortByTestResultsForSub(subscriptionId) - } - } - - /** - * Sorts servers by their test results for a specific subscription. - * @param subId The subscription ID to sort servers for. - */ - private fun sortByTestResultsForSub(subId: String) { - data class ServerDelay(var guid: String, var testDelayMillis: Long) - - val serverDelays = mutableListOf() - val serverListToSort = MmkvManager.decodeServerList(subId) - - serverListToSort.forEach { key -> - val delay = MmkvManager.decodeServerAffiliationInfo(key)?.testDelayMillis ?: 0L - serverDelays.add(ServerDelay(key, if (delay <= 0L) 999999 else delay)) - } - serverDelays.sortBy { it.testDelayMillis } - - val sortedServerList = serverDelays.map { it.guid }.toMutableList() - - // Save the sorted list for this subscription - MmkvManager.encodeServerList(sortedServerList, subId) - } - - - /** - * Initializes assets. - * @param assets The asset manager. - */ - fun initAssets(assets: AssetManager) { - viewModelScope.launch(Dispatchers.Default) { - SettingsManager.initAssets(getApplication(), assets) - } - } - - /** - * Filters the configuration by a keyword. - * @param keyword The keyword to filter by. - */ - fun filterConfig(keyword: String) { - if (keyword == keywordFilter) { return } - keywordFilter = keyword - reloadServerList() - } - fun findSubscriptionIdBySelect(): String? { - // Get the selected server GUID - val selectedGuid = MmkvManager.getSelectServer() - if (selectedGuid.isNullOrEmpty()) { - return null + testingGroupId = groupId + + _uiState.update { + it.copy( + isTesting = true, + statusText = app.getString( + R.string.connection_test_testing + ) + ) } - val config = MmkvManager.decodeServerConfig(selectedGuid) - return config?.subscriptionId + viewModelScope.launch(ioDispatcher) { + cacheMutex.withLock { + groupDataCache.remove(groupId) + } + + MessageUtil.sendMsg2TestService( + app, + TestServiceMessage( + key = + AppConfig.MSG_MEASURE_CONFIG_START, + subscriptionId = groupId, + serverGuids = + if (keywordFilter.isNotEmpty()) { + servers.map { it.guid } + } else { + emptyList() + } + ) + ) + } + } + + fun testCurrentServerRealPing() { + _uiState.update { + it.copy( + statusText = app.getString( + R.string.connection_test_testing + ) + ) + } + + MessageUtil.sendMsg2Service( + app, + AppConfig.MSG_MEASURE_DELAY, + "" + ) } fun onTestsFinished() { - viewModelScope.launch(Dispatchers.Default) { - if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST)) { - removeInvalidServer() + viewModelScope.launch(ioDispatcher) { + if ( + MmkvManager.decodeSettingsBool( + AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST + ) + ) { + removeInvalidServerInternal() } - if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST)) { - sortByTestResults() + if ( + MmkvManager.decodeSettingsBool( + AppConfig.PREF_AUTO_SORT_AFTER_TEST + ) + ) { + sortByTestResultsInternal() } - withContext(Dispatchers.Main) { - reloadServerList() + cacheMutex.withLock { + groupDataCache.clear() } + + testingGroupId = null + + _uiState.update { + it.copy( + isTesting = false, + statusText = if (it.isRunning) { + connectedText + } else { + disconnectedText + } + ) + } + + reloadAllGroups( + _uiState.value.groups.map { it.id } + ) } } - private val mMsgReceiver = object : BroadcastReceiver() { - override fun onReceive(ctx: Context?, intent: Intent?) { - when (intent?.getIntExtra("key", 0)) { - AppConfig.MSG_STATE_RUNNING -> { - isRunning.value = true - } + fun updateConfigViaSubAll(): + SubscriptionUpdateResult = + if (subscriptionId.isEmpty()) { + AngConfigManager.updateConfigViaSubAll() + } else { + val item = MmkvManager.decodeSubscription( + subscriptionId + ) ?: return SubscriptionUpdateResult() - AppConfig.MSG_STATE_NOT_RUNNING -> { - isRunning.value = false - } + AngConfigManager.updateConfigViaSub( + SubscriptionCache( + subscriptionId, + item + ) + ) + } - AppConfig.MSG_STATE_START_SUCCESS -> { - getApplication().toastSuccess(R.string.toast_services_success) - isRunning.value = true - } + fun exportAllServer(): Int { + val list = + if ( + subscriptionId.isEmpty() && + keywordFilter.isEmpty() + ) { + MmkvManager.decodeAllServerList() + } else { + currentServers().map { it.guid } + } - AppConfig.MSG_STATE_START_FAILURE -> { - val errorMessage = intent.getStringExtra("content") - if (!errorMessage.isNullOrBlank()) { - getApplication().toastError(errorMessage) + return AngConfigManager + .shareNonCustomConfigsToClipboard( + app, + list + ) + } + + fun removeDuplicateServer(): Int { + val seen = HashSet() + val duplicates = ArrayList() + + currentServers().forEach { server -> + val profile = server.profile + + if (!profile.configType.isComplexType()) { + val identity = + profile.duplicateIdentity() + + if (!seen.add(identity)) { + duplicates += server.guid + } + } + } + + duplicates.forEach( + MmkvManager::removeServer + ) + + return duplicates.size + } + + fun removeAllServer(): Int { + val count = + if ( + subscriptionId.isEmpty() && + keywordFilter.isEmpty() + ) { + MmkvManager.removeAllServer() + } else { + val guids = + currentServers().map { it.guid } + + guids.forEach( + MmkvManager::removeServer + ) + + guids.size + } + + viewModelScope.launch(ioDispatcher) { + cacheMutex.withLock { + groupDataCache.clear() + } + } + + return count + } + + private fun removeInvalidServerInternal(): Int = + if ( + subscriptionId.isEmpty() && + keywordFilter.isEmpty() + ) { + MmkvManager.removeInvalidServer("") + } else { + currentServers().sumOf { + MmkvManager.removeInvalidServer( + it.guid + ) + } + } + + fun removeInvalidServer(): Int { + val count = removeInvalidServerInternal() + + viewModelScope.launch(ioDispatcher) { + cacheMutex.withLock { + groupDataCache.clear() + } + + setupGroupTab( + forceRefresh = true + ) + } + + return count + } + + private fun sortByTestResultsForSub( + subId: String + ) { + val sorted = + MmkvManager.decodeServerList(subId) + .map { guid -> + val delay = + MmkvManager + .decodeServerAffiliationInfo(guid) + ?.testDelayMillis + ?: 0L + + guid to if (delay <= 0L) { + Long.MAX_VALUE } else { - getApplication().toastError(R.string.toast_services_failure) + delay } - isRunning.value = false } + .sortedBy { it.second } + .mapTo(ArrayList()) { it.first } - AppConfig.MSG_STATE_STOP_SUCCESS -> { - isRunning.value = false - } + MmkvManager.encodeServerList( + sorted, + subId + ) + } - AppConfig.MSG_MEASURE_DELAY_SUCCESS -> { - updateTestResultAction.value = intent.getStringExtra("content") - } + private fun sortByTestResultsInternal() { + if (subscriptionId.isEmpty()) { + MmkvManager.decodeSubsList() + .forEach( + ::sortByTestResultsForSub + ) + } else { + sortByTestResultsForSub( + subscriptionId + ) + } + } - AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> { - val content = intent.getStringExtra("content") - updateListAction.value = getPosition(content ?: "") - } + fun sortByTestResults() { + sortByTestResultsInternal() - AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> { - val content = intent.getStringExtra("content") - updateTestResultAction.value = - getApplication().getString(R.string.connection_runing_task_left, content) - } + viewModelScope.launch(ioDispatcher) { + cacheMutex.withLock { + groupDataCache.clear() + } - AppConfig.MSG_MEASURE_CONFIG_FINISH -> { - val content = intent.getStringExtra("content") - if (content == "0") { - onTestsFinished() + setupGroupTab( + forceRefresh = true + ) + } + } + + fun triggerLocateSelectedServer() { + val selected = + MmkvManager.getSelectServer() ?: return + + val profile = + MmkvManager.decodeServerConfig(selected) + ?: return + + val groupId = profile.subscriptionId + + val groupIndex = + _uiState.value.groups + .indexOfFirst { it.id == groupId } + .takeIf { it >= 0 } + ?: return + + viewModelScope.launch(ioDispatcher) { + val position = + loadGroup(groupId) + .indexOfFirst { + it.guid == selected + } + .takeIf { it >= 0 } + ?: return@launch + + _locateEvent.emit( + LocateTarget( + groupId = groupId, + groupIndex = groupIndex, + itemPosition = position + ) + ) + } + } + + fun getPosition(guid: String): Int = + currentServers() + .indexOfFirst { it.guid == guid } + + fun startListenBroadcast() { + if (receiverRegistered) return + + ContextCompat.registerReceiver( + app, + messageReceiver, + IntentFilter( + AppConfig.BROADCAST_ACTION_ACTIVITY + ), + Utils.receiverFlags() + ) + + receiverRegistered = true + + MessageUtil.sendMsg2Service( + app, + AppConfig.MSG_REGISTER_CLIENT, + "" + ) + } + + private fun updateRunningState( + running: Boolean, + clearTestingText: Boolean = true + ) { + _uiState.update { state -> + state.copy( + isRunning = running, + statusText = + if ( + !clearTestingText && + state.isTesting + ) { + state.statusText + } else if (running) { + connectedText + } else { + disconnectedText + } + ) + } + } + + private val messageReceiver = + object : BroadcastReceiver() { + override fun onReceive( + context: Context?, + intent: Intent? + ) { + when ( + intent?.getIntExtra("key", 0) + ) { + AppConfig.MSG_STATE_RUNNING -> { + updateRunningState( + running = true, + clearTestingText = false + ) + } + + AppConfig.MSG_STATE_NOT_RUNNING -> { + updateRunningState( + running = false, + clearTestingText = false + ) + } + + AppConfig.MSG_STATE_START_SUCCESS -> { + app.toastSuccess( + R.string.toast_services_success + ) + updateRunningState(true) + } + + AppConfig.MSG_STATE_START_FAILURE -> { + val error = + intent.getStringExtra("content") + .orEmpty() + + if (error.isNotBlank()) { + app.toastError(error) + } else { + app.toastError( + R.string.toast_services_failure + ) + } + + updateRunningState(false) + } + + AppConfig.MSG_STATE_STOP_SUCCESS -> { + updateRunningState(false) + } + + AppConfig.MSG_MEASURE_DELAY_SUCCESS -> { + _uiState.update { + it.copy( + statusText = + intent.getStringExtra( + "content" + ).orEmpty() + ) + } + } + + AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> { + val groupId = + testingGroupId + ?: subscriptionId + + viewModelScope.launch( + ioDispatcher + ) { + cacheMutex.withLock { + groupDataCache.remove( + groupId + ) + } + + updateGroupUi( + groupId, + loadGroup( + groupId, + forceRefresh = true + ) + ) + } + } + + AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> { + val progress = app.getString( + R.string.connection_runing_task_left, + intent.getStringExtra( + "content" + ) + ) + + _uiState.update { + it.copy( + statusText = progress + ) + } + } + + AppConfig.MSG_MEASURE_CONFIG_FINISH -> { + if ( + intent.getStringExtra( + "content" + ) == "0" + ) { + onTestsFinished() + } } } } } + + override fun onCleared() { + setupGroupJob?.cancel() + preloadJob?.cancel() + selectedGroupLoadJob?.cancel() + reloadJob?.cancel() + filterJob?.cancel() + + if (receiverRegistered) { + runCatching { + app.unregisterReceiver( + messageReceiver + ) + }.onFailure { + LogUtil.e( + AppConfig.TAG, + "Failed to unregister MainViewModel receiver", + it + ) + } + + receiverRegistered = false + } + + super.onCleared() } } diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/PerAppProxyViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/PerAppProxyViewModel.kt index 83b63b8c..7336f997 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/PerAppProxyViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/PerAppProxyViewModel.kt @@ -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 = 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> = _blacklist.asStateFlow() - fun getAll(): Set = blacklist.toSet() + // UI states + private val _isLoading = MutableStateFlow(false) + val isLoading: StateFlow = _isLoading.asStateFlow() - fun add(packageName: String): Boolean { - val changed = blacklist.add(packageName) - if (changed) { - save() + private val _displayedApps = MutableStateFlow>(emptyList()) + val displayedApps: StateFlow> = _displayedApps.asStateFlow() + + private val _perAppProxyEnabled = MutableStateFlow( + MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false) + ) + val perAppProxyEnabled: StateFlow = _perAppProxyEnabled.asStateFlow() + + private val _bypassApps = MutableStateFlow( + MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS, false) + ) + val bypassApps: StateFlow = _bypassApps.asStateFlow() + + // Cached full list for filtering + private var appsAll: List? = 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) { - if (blacklist.addAll(packages)) { - save() + val newSet = _blacklist.value + packages + if (newSet != _blacklist.value) { + _blacklist.value = newSet + saveBlacklist() } } fun removeAll(packages: Collection) { - 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 { + 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() } -} \ No newline at end of file + + // 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): List { + 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() + } +} diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/RoutingSettingsViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/RoutingSettingsViewModel.kt index fe816413..c514428f 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/RoutingSettingsViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/RoutingSettingsViewModel.kt @@ -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 = mutableListOf() + private val _rulesetsFlow = MutableStateFlow>(emptyList()) + val rulesetsFlow: StateFlow> = _rulesetsFlow.asStateFlow() + fun getAll(): List = 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() } } } - diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/SubscriptionsViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/SubscriptionsViewModel.kt index 53d29f92..74090709 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/SubscriptionsViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/SubscriptionsViewModel.kt @@ -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 = MmkvManager.decodeSubscriptions().toMutableList() + private val _subsFlow = MutableStateFlow>(subscriptions.toList()) + val subsFlow: StateFlow> = _subsFlow.asStateFlow() + fun getAll(): List = 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() } } } - diff --git a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/UserAssetViewModel.kt b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/UserAssetViewModel.kt index d926eac4..f5940cef 100644 --- a/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/UserAssetViewModel.kt +++ b/V2rayNG/app/src/main/java/com/v2ray/ang/viewmodel/UserAssetViewModel.kt @@ -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() private val builtInGeoFiles = listOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT) + private val _assetsFlow = MutableStateFlow>(emptyList()) + val assetsFlow: StateFlow> = _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( diff --git a/V2rayNG/app/src/main/res/color/color_highlight_material.xml b/V2rayNG/app/src/main/res/color/color_highlight_material.xml deleted file mode 100644 index 5029d3e9..00000000 --- a/V2rayNG/app/src/main/res/color/color_highlight_material.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/drawable/custom_divider.xml b/V2rayNG/app/src/main/res/drawable/custom_divider.xml deleted file mode 100644 index 94f03147..00000000 --- a/V2rayNG/app/src/main/res/drawable/custom_divider.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/drawable/ic_arrow_back_24dp.xml b/V2rayNG/app/src/main/res/drawable/ic_arrow_back_24dp.xml new file mode 100644 index 00000000..ff242f41 --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable/ic_arrow_back_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/V2rayNG/app/src/main/res/drawable/ic_circle.xml b/V2rayNG/app/src/main/res/drawable/ic_circle.xml deleted file mode 100644 index 13bb8e65..00000000 --- a/V2rayNG/app/src/main/res/drawable/ic_circle.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/drawable/ic_flash_off_24dp.xml b/V2rayNG/app/src/main/res/drawable/ic_flash_off_24dp.xml new file mode 100644 index 00000000..3e2ced0b --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable/ic_flash_off_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/V2rayNG/app/src/main/res/drawable/ic_arrow_drop_down.xml b/V2rayNG/app/src/main/res/drawable/ic_flash_on_24dp.xml similarity index 59% rename from V2rayNG/app/src/main/res/drawable/ic_arrow_drop_down.xml rename to V2rayNG/app/src/main/res/drawable/ic_flash_on_24dp.xml index aba3c44d..d8bc67dc 100644 --- a/V2rayNG/app/src/main/res/drawable/ic_arrow_drop_down.xml +++ b/V2rayNG/app/src/main/res/drawable/ic_flash_on_24dp.xml @@ -1,11 +1,9 @@ - + android:fillColor="@android:color/white" + android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z"/> - diff --git a/V2rayNG/app/src/main/res/drawable/ic_menu_24dp.xml b/V2rayNG/app/src/main/res/drawable/ic_menu_24dp.xml new file mode 100644 index 00000000..a093ec4e --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable/ic_menu_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/V2rayNG/app/src/main/res/drawable/ic_rounded_corner_active.xml b/V2rayNG/app/src/main/res/drawable/ic_rounded_corner_active.xml index 3dcd95f7..4253ef4d 100644 --- a/V2rayNG/app/src/main/res/drawable/ic_rounded_corner_active.xml +++ b/V2rayNG/app/src/main/res/drawable/ic_rounded_corner_active.xml @@ -6,5 +6,5 @@ android:left="0dp" android:right="0dp" android:top="0dp" /> - + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/drawable/ic_rounded_corner_inactive.xml b/V2rayNG/app/src/main/res/drawable/ic_rounded_corner_inactive.xml index 96a98158..cae9311f 100644 --- a/V2rayNG/app/src/main/res/drawable/ic_rounded_corner_inactive.xml +++ b/V2rayNG/app/src/main/res/drawable/ic_rounded_corner_inactive.xml @@ -6,5 +6,5 @@ android:left="0dp" android:right="0dp" android:top="0dp" /> - + \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/drawable/ic_search_24dp.xml b/V2rayNG/app/src/main/res/drawable/ic_search_24dp.xml new file mode 100644 index 00000000..8ef92f73 --- /dev/null +++ b/V2rayNG/app/src/main/res/drawable/ic_search_24dp.xml @@ -0,0 +1,9 @@ + + + diff --git a/V2rayNG/app/src/main/res/drawable/license_24px.xml b/V2rayNG/app/src/main/res/drawable/license_24px.xml index 5b63fd29..9574b943 100644 --- a/V2rayNG/app/src/main/res/drawable/license_24px.xml +++ b/V2rayNG/app/src/main/res/drawable/license_24px.xml @@ -1,7 +1,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_app_picker.xml b/V2rayNG/app/src/main/res/layout/activity_app_picker.xml deleted file mode 100644 index 55444199..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_app_picker.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_backup.xml b/V2rayNG/app/src/main/res/layout/activity_backup.xml deleted file mode 100644 index f892f0c2..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_backup.xml +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_base.xml b/V2rayNG/app/src/main/res/layout/activity_base.xml deleted file mode 100644 index 57d6e311..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_base.xml +++ /dev/null @@ -1,37 +0,0 @@ - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_bypass_list.xml b/V2rayNG/app/src/main/res/layout/activity_bypass_list.xml deleted file mode 100644 index c9aa4d97..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_bypass_list.xml +++ /dev/null @@ -1,98 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_check_update.xml b/V2rayNG/app/src/main/res/layout/activity_check_update.xml deleted file mode 100644 index 35142720..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_check_update.xml +++ /dev/null @@ -1,83 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_logcat.xml b/V2rayNG/app/src/main/res/layout/activity_logcat.xml deleted file mode 100644 index f0cbffd5..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_logcat.xml +++ /dev/null @@ -1,26 +0,0 @@ - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_main.xml b/V2rayNG/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index 0c08851f..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,130 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_none.xml b/V2rayNG/app/src/main/res/layout/activity_none.xml deleted file mode 100644 index 3abff60e..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_none.xml +++ /dev/null @@ -1,4 +0,0 @@ - - diff --git a/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml b/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml deleted file mode 100644 index 9db3ec8e..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_routing_edit.xml +++ /dev/null @@ -1,253 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_routing_setting.xml b/V2rayNG/app/src/main/res/layout/activity_routing_setting.xml deleted file mode 100644 index d79b4c84..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_routing_setting.xml +++ /dev/null @@ -1,67 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_server_custom_config.xml b/V2rayNG/app/src/main/res/layout/activity_server_custom_config.xml deleted file mode 100644 index b3802749..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_server_custom_config.xml +++ /dev/null @@ -1,79 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_server_group.xml b/V2rayNG/app/src/main/res/layout/activity_server_group.xml deleted file mode 100644 index 74e861bb..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_server_group.xml +++ /dev/null @@ -1,106 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_server_hysteria2.xml b/V2rayNG/app/src/main/res/layout/activity_server_hysteria2.xml deleted file mode 100644 index 10e5b951..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_server_hysteria2.xml +++ /dev/null @@ -1,140 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_server_proxy_chain.xml b/V2rayNG/app/src/main/res/layout/activity_server_proxy_chain.xml deleted file mode 100644 index 227d9965..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_server_proxy_chain.xml +++ /dev/null @@ -1,72 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_server_shadowsocks.xml b/V2rayNG/app/src/main/res/layout/activity_server_shadowsocks.xml deleted file mode 100644 index 0d9e958f..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_server_shadowsocks.xml +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_server_socks.xml b/V2rayNG/app/src/main/res/layout/activity_server_socks.xml deleted file mode 100644 index 90c1b883..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_server_socks.xml +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_server_trojan.xml b/V2rayNG/app/src/main/res/layout/activity_server_trojan.xml deleted file mode 100644 index 2159ec43..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_server_trojan.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_server_vless.xml b/V2rayNG/app/src/main/res/layout/activity_server_vless.xml deleted file mode 100644 index 7d29efd2..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_server_vless.xml +++ /dev/null @@ -1,88 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_server_vmess.xml b/V2rayNG/app/src/main/res/layout/activity_server_vmess.xml deleted file mode 100644 index 4e77cbdf..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_server_vmess.xml +++ /dev/null @@ -1,68 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/activity_server_wireguard.xml b/V2rayNG/app/src/main/res/layout/activity_server_wireguard.xml deleted file mode 100644 index 7ecd7ce1..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_server_wireguard.xml +++ /dev/null @@ -1,146 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_settings.xml b/V2rayNG/app/src/main/res/layout/activity_settings.xml deleted file mode 100644 index 618bc8c0..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_settings.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_sub_edit.xml b/V2rayNG/app/src/main/res/layout/activity_sub_edit.xml deleted file mode 100644 index 242d5eda..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_sub_edit.xml +++ /dev/null @@ -1,292 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_sub_setting.xml b/V2rayNG/app/src/main/res/layout/activity_sub_setting.xml deleted file mode 100644 index b3841f25..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_sub_setting.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_tasker.xml b/V2rayNG/app/src/main/res/layout/activity_tasker.xml deleted file mode 100644 index 56a1276d..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_tasker.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_user_asset.xml b/V2rayNG/app/src/main/res/layout/activity_user_asset.xml deleted file mode 100644 index 2e619c35..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_user_asset.xml +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/activity_user_asset_url.xml b/V2rayNG/app/src/main/res/layout/activity_user_asset_url.xml deleted file mode 100644 index e1b55173..00000000 --- a/V2rayNG/app/src/main/res/layout/activity_user_asset_url.xml +++ /dev/null @@ -1,86 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/dialog_config_filter.xml b/V2rayNG/app/src/main/res/layout/dialog_config_filter.xml deleted file mode 100644 index 0bbc9003..00000000 --- a/V2rayNG/app/src/main/res/layout/dialog_config_filter.xml +++ /dev/null @@ -1,46 +0,0 @@ - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/dialog_webdav.xml b/V2rayNG/app/src/main/res/layout/dialog_webdav.xml deleted file mode 100644 index f244702b..00000000 --- a/V2rayNG/app/src/main/res/layout/dialog_webdav.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/fragment_group_server.xml b/V2rayNG/app/src/main/res/layout/fragment_group_server.xml deleted file mode 100644 index 121e995c..00000000 --- a/V2rayNG/app/src/main/res/layout/fragment_group_server.xml +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/item_qrcode.xml b/V2rayNG/app/src/main/res/layout/item_qrcode.xml deleted file mode 100644 index 7f66acee..00000000 --- a/V2rayNG/app/src/main/res/layout/item_qrcode.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_bypass_list.xml b/V2rayNG/app/src/main/res/layout/item_recycler_bypass_list.xml deleted file mode 100644 index 889cb3c6..00000000 --- a/V2rayNG/app/src/main/res/layout/item_recycler_bypass_list.xml +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_footer.xml b/V2rayNG/app/src/main/res/layout/item_recycler_footer.xml deleted file mode 100644 index a37eb886..00000000 --- a/V2rayNG/app/src/main/res/layout/item_recycler_footer.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_logcat.xml b/V2rayNG/app/src/main/res/layout/item_recycler_logcat.xml deleted file mode 100644 index 73d4e23d..00000000 --- a/V2rayNG/app/src/main/res/layout/item_recycler_logcat.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_main.xml b/V2rayNG/app/src/main/res/layout/item_recycler_main.xml deleted file mode 100644 index b0760742..00000000 --- a/V2rayNG/app/src/main/res/layout/item_recycler_main.xml +++ /dev/null @@ -1,223 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_proxy_chain_member.xml b/V2rayNG/app/src/main/res/layout/item_recycler_proxy_chain_member.xml deleted file mode 100644 index 9627de80..00000000 --- a/V2rayNG/app/src/main/res/layout/item_recycler_proxy_chain_member.xml +++ /dev/null @@ -1,84 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_routing_setting.xml b/V2rayNG/app/src/main/res/layout/item_recycler_routing_setting.xml deleted file mode 100644 index cb5e18bd..00000000 --- a/V2rayNG/app/src/main/res/layout/item_recycler_routing_setting.xml +++ /dev/null @@ -1,113 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_sub_setting.xml b/V2rayNG/app/src/main/res/layout/item_recycler_sub_setting.xml deleted file mode 100644 index 9323157c..00000000 --- a/V2rayNG/app/src/main/res/layout/item_recycler_sub_setting.xml +++ /dev/null @@ -1,181 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/item_recycler_user_asset.xml b/V2rayNG/app/src/main/res/layout/item_recycler_user_asset.xml deleted file mode 100644 index b820a3d0..00000000 --- a/V2rayNG/app/src/main/res/layout/item_recycler_user_asset.xml +++ /dev/null @@ -1,90 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/V2rayNG/app/src/main/res/layout/layout_address_port.xml b/V2rayNG/app/src/main/res/layout/layout_address_port.xml deleted file mode 100644 index 1c0111ca..00000000 --- a/V2rayNG/app/src/main/res/layout/layout_address_port.xml +++ /dev/null @@ -1,75 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/V2rayNG/app/src/main/res/layout/layout_tls.xml b/V2rayNG/app/src/main/res/layout/layout_tls.xml deleted file mode 100644 index 76f23645..00000000 --- a/V2rayNG/app/src/main/res/layout/layout_tls.xml +++ /dev/null @@ -1,273 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -