migrate to compose (#5866)
* add composeui dependencies and component * remove multidex * BaseComponentActivity and HelperBaseComponentActivity * migrate AppPicker/PerAppProxy Activity and use Snackbar * migrate AboutActivity to composeui * migrate BackupActivity to composeui * migrate CheckUpdateActivity to composeui * migrate Logcat to composeui * migrate Scanner to composeui and camerax * migrate sc start/stop/switch to compose * migrate TaskerActivity to composeui * migrate UrlSchemeActivity to composeui * migrate UserAsset to composeui * migrate ServerActivity to composeui * migrate ServerCustomConfigActivity to composeui * migrate ServerGroupActivity to composeui * migrate ServerProxyChainActivity to composeui * migrate SettingsActivity to composeui * migrate SubEdit/SubSetting Activity to composeui * migrate routingEdit/routingSetting Activity to composeui * migrate MainActivity to composeui * clode clean and chore change * Fix * add scrollbar * fix imePadding * fix FormDropdownField scrollbar and ime conflict * update servercostomconfigactivity with linenum and scrollbar * fix AppPickerActivity filter wrong toogle * fix App theme xml * fix scrollbar not work in SettingsActivity * final inset * fix servercustomconfigactivity when open not begin line * try optimize mainactivity performance * use launch intent and fix not refresh when second edit use equals * fix a ghost error * update string * update miss string * optimize mainactivity and mainviewmodel --------- Co-authored-by: 2dust <31833384+2dust@users.noreply.github.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.kotlin.compose)
|
||||
id("com.jaredsburrows.license")
|
||||
}
|
||||
|
||||
@@ -14,7 +15,6 @@ android {
|
||||
targetSdk = 37
|
||||
versionCode = 736
|
||||
versionName = "2.2.6"
|
||||
multiDexEnabled = true
|
||||
|
||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||
splits {
|
||||
@@ -124,8 +124,8 @@ android {
|
||||
}
|
||||
|
||||
buildFeatures {
|
||||
viewBinding = true
|
||||
buildConfig = true
|
||||
compose = true
|
||||
}
|
||||
|
||||
packaging {
|
||||
@@ -142,20 +142,19 @@ dependencies {
|
||||
|
||||
// AndroidX Core Libraries
|
||||
implementation(libs.androidx.core.ktx)
|
||||
implementation(libs.androidx.appcompat)
|
||||
implementation(libs.androidx.activity)
|
||||
implementation(libs.androidx.constraintlayout)
|
||||
implementation(libs.preference.ktx)
|
||||
implementation(libs.recyclerview)
|
||||
implementation(libs.androidx.swiperefreshlayout)
|
||||
implementation(libs.androidx.viewpager2)
|
||||
implementation(libs.androidx.fragment)
|
||||
|
||||
// UI Libraries
|
||||
implementation(libs.material)
|
||||
implementation(libs.toasty)
|
||||
implementation(libs.editorkit)
|
||||
implementation(libs.flexbox)
|
||||
// Compose Libraries
|
||||
implementation(platform(libs.androidx.compose.bom))
|
||||
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.androidx.compose.ui)
|
||||
implementation(libs.androidx.compose.foundation)
|
||||
implementation(libs.androidx.compose.material3)
|
||||
implementation(libs.androidx.compose.ui.tooling.preview)
|
||||
implementation(libs.lifecycle.runtime.compose)
|
||||
|
||||
debugImplementation(libs.androidx.compose.ui.tooling)
|
||||
|
||||
// Data and Storage Libraries
|
||||
implementation(libs.mmkv.static)
|
||||
@@ -166,25 +165,23 @@ dependencies {
|
||||
implementation(libs.kotlinx.coroutines.android)
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
// Language and Processing Libraries
|
||||
implementation(libs.language.base)
|
||||
implementation(libs.language.json)
|
||||
|
||||
// Intent and Utility Libraries
|
||||
implementation(libs.quickie.foss)
|
||||
implementation(libs.core)
|
||||
// QR Code: CameraX + ZXing
|
||||
implementation(libs.camerax.core)
|
||||
implementation(libs.camerax.camera2)
|
||||
implementation(libs.camerax.lifecycle)
|
||||
implementation(libs.camerax.compose)
|
||||
implementation(libs.core) // zxing core
|
||||
|
||||
// AndroidX Lifecycle and Architecture Components
|
||||
implementation(libs.lifecycle.viewmodel.ktx)
|
||||
implementation(libs.lifecycle.livedata.ktx)
|
||||
implementation(libs.lifecycle.runtime.ktx)
|
||||
|
||||
// Background Task Libraries
|
||||
implementation(libs.work.runtime.ktx)
|
||||
implementation(libs.work.multiprocess)
|
||||
|
||||
// Multidex Support
|
||||
implementation(libs.multidex)
|
||||
// Reorderable list
|
||||
implementation(libs.reorderable)
|
||||
|
||||
// Testing Libraries
|
||||
testImplementation(libs.junit)
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package com.v2ray.ang
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
import androidx.multidex.MultiDexApplication
|
||||
import androidx.work.Configuration
|
||||
import androidx.work.WorkManager
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.compose.ThemeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
|
||||
class AngApplication : MultiDexApplication() {
|
||||
class AngApplication : Application() {
|
||||
companion object {
|
||||
lateinit var application: AngApplication
|
||||
}
|
||||
@@ -39,10 +40,8 @@ class AngApplication : MultiDexApplication() {
|
||||
|
||||
// Ensure critical preference defaults are present in MMKV early
|
||||
SettingsManager.initApp(this)
|
||||
SettingsManager.setNightMode()
|
||||
|
||||
es.dmoral.toasty.Toasty.Config.getInstance()
|
||||
.setGravity(android.view.Gravity.BOTTOM, 0, 300)
|
||||
.apply()
|
||||
// Initialize theme state from MMKV
|
||||
ThemeManager.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.v2ray.ang.compose
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.drawable.BitmapDrawable
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.shrinkVertically
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CheckboxDefaults
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.LinearProgressIndicator
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.v2ray.ang.R
|
||||
import sh.calvin.reorderable.ReorderableCollectionItemScope
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppTopBar(
|
||||
title: String,
|
||||
onBackClick: () -> Unit,
|
||||
isLoading: Boolean = false,
|
||||
isSearchActive: Boolean = false,
|
||||
searchQuery: String = "",
|
||||
onSearchQueryChange: (String) -> Unit = {},
|
||||
onSearchClose: () -> Unit = {},
|
||||
searchPlaceholder: String? = null,
|
||||
navigationIcon: @Composable (() -> Unit)? = null,
|
||||
actions: @Composable RowScope.() -> Unit = {}
|
||||
) {
|
||||
Column {
|
||||
TopAppBar(
|
||||
title = {
|
||||
if (isSearchActive) {
|
||||
SearchInputField(
|
||||
query = searchQuery,
|
||||
onQueryChange = onSearchQueryChange,
|
||||
placeholder = searchPlaceholder
|
||||
)
|
||||
} else {
|
||||
Text(text = title)
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
if (navigationIcon != null) {
|
||||
navigationIcon()
|
||||
} else {
|
||||
IconButton(onClick = if (isSearchActive) onSearchClose else onBackClick) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_arrow_back_24dp),
|
||||
contentDescription = "Back"
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
actions = actions,
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
titleContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
navigationIconContentColor = MaterialTheme.colorScheme.onSurface,
|
||||
actionIconContentColor = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = isLoading,
|
||||
enter = expandVertically(),
|
||||
exit = shrinkVertically()
|
||||
) {
|
||||
LinearProgressIndicator(modifier = Modifier.fillMaxWidth(), color = colorFabActive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SearchInputField(
|
||||
query: String,
|
||||
onQueryChange: (String) -> Unit,
|
||||
placeholder: String?
|
||||
) {
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = onQueryChange,
|
||||
singleLine = true,
|
||||
textStyle = TextStyle(color = MaterialTheme.colorScheme.onSurface, fontSize = 16.sp),
|
||||
placeholder = { if (placeholder != null) Text(placeholder, style = TextStyle(color = MaterialTheme.colorScheme.onSurfaceVariant, fontSize = 16.sp)) },
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
focusedBorderColor = Color.Transparent,
|
||||
unfocusedBorderColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.secondary,
|
||||
selectionColors = TextSelectionColors(
|
||||
handleColor = MaterialTheme.colorScheme.secondary,
|
||||
backgroundColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.4f)
|
||||
)
|
||||
),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
|
||||
modifier = Modifier.weight(1f).focusRequester(focusRequester)
|
||||
)
|
||||
if (query.isNotEmpty()) {
|
||||
IconButton(onClick = { onQueryChange("") }) {
|
||||
Icon(painterResource(android.R.drawable.ic_menu_close_clear_cancel), "Clear")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppListItem(
|
||||
appName: String,
|
||||
packageName: String,
|
||||
icon: Any?,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onCheckedChange(!checked) }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (icon != null) {
|
||||
when (icon) {
|
||||
is Bitmap -> {
|
||||
Image(
|
||||
bitmap = icon.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
is Int -> {
|
||||
Image(
|
||||
painter = painterResource(id = icon),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
is Drawable -> {
|
||||
val bitmap = remember(icon) { createBitmapFromDrawable(icon) }
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(40.dp),
|
||||
contentScale = ContentScale.Fit
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Box(modifier = Modifier.size(40.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = appName,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = packageName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Checkbox(
|
||||
checked = checked,
|
||||
onCheckedChange = onCheckedChange,
|
||||
colors = CheckboxDefaults.colors(checkedColor = colorFabActive)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createBitmapFromDrawable(drawable: Drawable): Bitmap {
|
||||
if (drawable is BitmapDrawable) {
|
||||
return drawable.bitmap
|
||||
}
|
||||
val width = drawable.intrinsicWidth.takeIf { it > 0 } ?: 96
|
||||
val height = drawable.intrinsicHeight.takeIf { it > 0 } ?: 96
|
||||
val bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
|
||||
val canvas = Canvas(bitmap)
|
||||
drawable.setBounds(0, 0, canvas.width, canvas.height)
|
||||
drawable.draw(canvas)
|
||||
return bitmap
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppDivider(modifier: Modifier = Modifier) {
|
||||
val color = if (LocalDarkTheme.current) dividerColorDark else dividerColorLight
|
||||
HorizontalDivider(modifier = modifier.fillMaxWidth(), thickness = 1.dp, color = color)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VersionInfoBlock(
|
||||
versionText: String,
|
||||
appIdText: String? = null,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Text(text = versionText, style = MaterialTheme.typography.bodySmall)
|
||||
if (appIdText != null) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(text = appIdText, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun reorderableElevation(isDragging: Boolean) = animateDpAsState(
|
||||
targetValue = if (isDragging) 4.dp else 0.dp,
|
||||
label = "ReorderableElevation"
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun ReorderableListItem(
|
||||
scope: ReorderableCollectionItemScope,
|
||||
isDragging: Boolean,
|
||||
content: @Composable RowScope.() -> Unit
|
||||
) {
|
||||
val elevation by reorderableElevation(isDragging)
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
shadowElevation = elevation
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(with(scope) { Modifier.longPressDraggableHandle() }),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ReorderableGridItem(
|
||||
scope: ReorderableCollectionItemScope,
|
||||
isDragging: Boolean,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val elevation by reorderableElevation(isDragging)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.then(with(scope) { Modifier.longPressDraggableHandle() }),
|
||||
shadowElevation = elevation
|
||||
) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package com.v2ray.ang.compose
|
||||
|
||||
import android.graphics.Bitmap
|
||||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
|
||||
@Composable
|
||||
fun ConfirmDialog(
|
||||
title: String? = null,
|
||||
message: String,
|
||||
confirmText: String,
|
||||
dismissText: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = title?.let { { Text(it) } },
|
||||
text = { Text(message, style = MaterialTheme.typography.bodyMedium) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onConfirm(); onDismiss() }) { Text(confirmText) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(dismissText) }
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
}
|
||||
|
||||
data class InputField(
|
||||
val label: String,
|
||||
val value: String,
|
||||
val singleLine: Boolean = true,
|
||||
val visualTransformation: VisualTransformation = VisualTransformation.None
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun InputDialog(
|
||||
title: String,
|
||||
fields: List<InputField>,
|
||||
onFieldChange: (Int, String) -> Unit,
|
||||
confirmText: String,
|
||||
dismissText: String,
|
||||
onConfirm: () -> Unit,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(title) },
|
||||
text = {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
fields.forEachIndexed { index, field ->
|
||||
OutlinedTextField(
|
||||
value = field.value,
|
||||
onValueChange = { onFieldChange(index, it) },
|
||||
label = { Text(field.label) },
|
||||
singleLine = false,
|
||||
maxLines = 5,
|
||||
visualTransformation = field.visualTransformation,
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.secondary,
|
||||
selectionColors = TextSelectionColors(
|
||||
handleColor = MaterialTheme.colorScheme.secondary,
|
||||
backgroundColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.4f)
|
||||
)
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onConfirm) { Text(confirmText) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(dismissText) }
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun QRCodeDialog(
|
||||
bitmap: Bitmap?,
|
||||
onDismiss: () -> Unit
|
||||
) {
|
||||
if (bitmap == null) return
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
text = {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = "QR Code",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(1f)
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(android.R.string.ok)) }
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* When showRadio is true, displays RadioButton (single selection mode);
|
||||
* otherwise, plain clickable list mode.
|
||||
* The selectedOption parameter is used to highlight the selected item only when showRadio is true.
|
||||
*/
|
||||
@Composable
|
||||
fun SelectListDialog(
|
||||
title: String? = null,
|
||||
options: List<String>,
|
||||
selectedOption: String = "",
|
||||
onSelected: (Int, String) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
showRadio: Boolean = false
|
||||
) {
|
||||
val selectedIndex = if (showRadio) options.indexOf(selectedOption) else -1
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = title?.let { { Text(it) } },
|
||||
text = {
|
||||
LazyColumn {
|
||||
itemsIndexed(options) { index, option ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onSelected(index, option) }
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (showRadio) {
|
||||
RadioButton(
|
||||
selected = index == selectedIndex,
|
||||
onClick = { onSelected(index, option) }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
}
|
||||
Text(
|
||||
text = option,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = if (!showRadio)
|
||||
Modifier.fillMaxWidth().padding(vertical = 8.dp, horizontal = 4.dp)
|
||||
else Modifier
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package com.v2ray.ang.compose
|
||||
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.ExposedDropdownMenuAnchorType
|
||||
import androidx.compose.material3.ExposedDropdownMenuBox
|
||||
import androidx.compose.material3.ExposedDropdownMenuDefaults
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.OutlinedTextFieldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Composable
|
||||
fun FormTextField(
|
||||
label: String,
|
||||
value: String,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
keyboardType: KeyboardType = KeyboardType.Text,
|
||||
placeholder: String? = null,
|
||||
maxLines: Int = 5,
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = onValueChange,
|
||||
label = { Text(label) },
|
||||
placeholder = placeholder?.let { { Text(it) } },
|
||||
singleLine = false,
|
||||
maxLines = maxLines,
|
||||
enabled = enabled,
|
||||
keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.secondary,
|
||||
selectionColors = TextSelectionColors(
|
||||
handleColor = MaterialTheme.colorScheme.secondary,
|
||||
backgroundColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.4f)
|
||||
)
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun FormDropdownField(
|
||||
label: String,
|
||||
value: String,
|
||||
options: List<String>,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
editable: Boolean = false,
|
||||
enabled: Boolean = true,
|
||||
placeholder: String? = null,
|
||||
) {
|
||||
var expanded by rememberSaveable { mutableStateOf(false) }
|
||||
val menuScrollState = rememberScrollState()
|
||||
val focusManager = LocalFocusManager.current
|
||||
val keyboardController = LocalSoftwareKeyboardController.current
|
||||
|
||||
ExposedDropdownMenuBox(
|
||||
expanded = expanded,
|
||||
onExpandedChange = { newExpanded ->
|
||||
if (!enabled) return@ExposedDropdownMenuBox
|
||||
if (!editable && newExpanded) {
|
||||
keyboardController?.hide()
|
||||
}
|
||||
expanded = newExpanded
|
||||
},
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 4.dp)
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = value,
|
||||
onValueChange = { if (editable) onValueChange(it) },
|
||||
readOnly = !editable,
|
||||
enabled = enabled,
|
||||
label = { Text(label) },
|
||||
placeholder = { if (placeholder != null) Text(placeholder) },
|
||||
trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) },
|
||||
colors = OutlinedTextFieldDefaults.colors(
|
||||
focusedContainerColor = Color.Transparent,
|
||||
unfocusedContainerColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.secondary,
|
||||
selectionColors = TextSelectionColors(
|
||||
handleColor = MaterialTheme.colorScheme.secondary,
|
||||
backgroundColor = MaterialTheme.colorScheme.secondary.copy(alpha = 0.4f)
|
||||
)
|
||||
),
|
||||
modifier = Modifier
|
||||
.menuAnchor(
|
||||
type = if (editable) ExposedDropdownMenuAnchorType.PrimaryEditable
|
||||
else ExposedDropdownMenuAnchorType.PrimaryNotEditable
|
||||
)
|
||||
.fillMaxWidth()
|
||||
.onFocusChanged { focusState ->
|
||||
if (!editable && focusState.isFocused) {
|
||||
keyboardController?.hide()
|
||||
}
|
||||
}
|
||||
)
|
||||
ExposedDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
modifier = Modifier.verticalScrollbar(menuScrollState),
|
||||
scrollState = menuScrollState,
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
options.forEach { option ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(option) },
|
||||
onClick = {
|
||||
onValueChange(option)
|
||||
expanded = false
|
||||
focusManager.clearFocus()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
package com.v2ray.ang.compose
|
||||
|
||||
import androidx.compose.animation.core.Animatable
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.ScrollState
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.grid.LazyGridState
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.composed
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.CornerRadius
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
data class ScrollbarConfig(
|
||||
val thickness: Dp = 4.dp,
|
||||
val minThumbSize: Dp = 24.dp,
|
||||
val thumbColor: Color = Color.Unspecified,
|
||||
val trackColor: Color = Color.Transparent,
|
||||
val padding: Dp = 2.dp,
|
||||
val cornerRadius: Dp = 2.dp,
|
||||
val fadeOutDurationMs: Int = 1500,
|
||||
val fadeAnimDurationMs: Int = 300,
|
||||
)
|
||||
|
||||
fun Modifier.verticalScrollbar(
|
||||
scrollState: ScrollState,
|
||||
config: ScrollbarConfig = ScrollbarConfig(),
|
||||
): Modifier = composed {
|
||||
val actualThumbColor = if (config.thumbColor == Color.Unspecified) {
|
||||
MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
|
||||
} else {
|
||||
config.thumbColor
|
||||
}
|
||||
|
||||
val alpha = remember { Animatable(0f) }
|
||||
val scrollChanged = remember {
|
||||
MutableSharedFlow<Unit>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollState) {
|
||||
snapshotFlow { scrollState.value }
|
||||
.collect { scrollChanged.tryEmit(Unit) }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scrollChanged.collectLatest {
|
||||
alpha.snapTo(1f)
|
||||
delay(config.fadeOutDurationMs.toLong())
|
||||
alpha.animateTo(0f, animationSpec = tween(config.fadeAnimDurationMs))
|
||||
}
|
||||
}
|
||||
|
||||
drawWithContent {
|
||||
drawContent()
|
||||
|
||||
val maxScroll = scrollState.maxValue.toFloat()
|
||||
if (maxScroll <= 0f) return@drawWithContent
|
||||
if (alpha.value <= 0f) return@drawWithContent
|
||||
|
||||
val viewportHeight = size.height
|
||||
val contentHeight = viewportHeight + maxScroll
|
||||
val maxThumbHeight = viewportHeight * 0.5f
|
||||
val thumbHeight = (viewportHeight / contentHeight * viewportHeight)
|
||||
.coerceAtLeast(config.minThumbSize.toPx())
|
||||
.coerceAtMost(maxThumbHeight)
|
||||
val scrollFraction = scrollState.value / maxScroll
|
||||
val maxThumbOffset = viewportHeight - thumbHeight
|
||||
val thumbOffset = scrollFraction * maxThumbOffset
|
||||
|
||||
val thickness = config.thickness.toPx()
|
||||
val padding = config.padding.toPx()
|
||||
val cornerRadius = config.cornerRadius.toPx()
|
||||
val currentAlpha = alpha.value
|
||||
|
||||
if (config.trackColor != Color.Transparent) {
|
||||
drawRoundRect(
|
||||
color = config.trackColor.copy(alpha = config.trackColor.alpha * currentAlpha),
|
||||
topLeft = Offset(size.width - thickness - padding, 0f),
|
||||
size = Size(thickness, viewportHeight),
|
||||
cornerRadius = CornerRadius(cornerRadius)
|
||||
)
|
||||
}
|
||||
|
||||
drawRoundRect(
|
||||
color = actualThumbColor.copy(alpha = actualThumbColor.alpha * currentAlpha),
|
||||
topLeft = Offset(size.width - thickness - padding, thumbOffset),
|
||||
size = Size(thickness, thumbHeight),
|
||||
cornerRadius = CornerRadius(cornerRadius)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.horizontalScrollbar(
|
||||
scrollState: ScrollState,
|
||||
config: ScrollbarConfig = ScrollbarConfig(),
|
||||
): Modifier = composed {
|
||||
val actualThumbColor = if (config.thumbColor == Color.Unspecified) {
|
||||
MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
|
||||
} else {
|
||||
config.thumbColor
|
||||
}
|
||||
|
||||
val alpha = remember { Animatable(0f) }
|
||||
val scrollChanged = remember {
|
||||
MutableSharedFlow<Unit>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(scrollState) {
|
||||
snapshotFlow { scrollState.value }
|
||||
.collect { scrollChanged.tryEmit(Unit) }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scrollChanged.collectLatest {
|
||||
alpha.snapTo(1f)
|
||||
delay(config.fadeOutDurationMs.toLong())
|
||||
alpha.animateTo(0f, animationSpec = tween(config.fadeAnimDurationMs))
|
||||
}
|
||||
}
|
||||
|
||||
drawWithContent {
|
||||
drawContent()
|
||||
|
||||
val maxScroll = scrollState.maxValue.toFloat()
|
||||
if (maxScroll <= 0f) return@drawWithContent
|
||||
if (alpha.value <= 0f) return@drawWithContent
|
||||
|
||||
val viewportWidth = size.width
|
||||
val contentWidth = viewportWidth + maxScroll
|
||||
val maxThumbWidth = viewportWidth * 0.5f
|
||||
val thumbWidth = (viewportWidth / contentWidth * viewportWidth)
|
||||
.coerceAtLeast(config.minThumbSize.toPx())
|
||||
.coerceAtMost(maxThumbWidth)
|
||||
val scrollFraction = scrollState.value / maxScroll
|
||||
val maxThumbOffset = viewportWidth - thumbWidth
|
||||
val thumbOffset = scrollFraction * maxThumbOffset
|
||||
|
||||
val thickness = config.thickness.toPx()
|
||||
val padding = config.padding.toPx()
|
||||
val cornerRadius = config.cornerRadius.toPx()
|
||||
val currentAlpha = alpha.value
|
||||
|
||||
if (config.trackColor != Color.Transparent) {
|
||||
drawRoundRect(
|
||||
color = config.trackColor.copy(alpha = config.trackColor.alpha * currentAlpha),
|
||||
topLeft = Offset(0f, size.height - thickness - padding),
|
||||
size = Size(viewportWidth, thickness),
|
||||
cornerRadius = CornerRadius(cornerRadius)
|
||||
)
|
||||
}
|
||||
|
||||
drawRoundRect(
|
||||
color = actualThumbColor.copy(alpha = actualThumbColor.alpha * currentAlpha),
|
||||
topLeft = Offset(thumbOffset, size.height - thickness - padding),
|
||||
size = Size(thumbWidth, thickness),
|
||||
cornerRadius = CornerRadius(cornerRadius)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.verticalScrollbar(
|
||||
lazyListState: LazyListState,
|
||||
config: ScrollbarConfig = ScrollbarConfig(),
|
||||
): Modifier = composed {
|
||||
val actualThumbColor = if (config.thumbColor == Color.Unspecified) {
|
||||
MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
|
||||
} else {
|
||||
config.thumbColor
|
||||
}
|
||||
|
||||
val alpha = remember { Animatable(0f) }
|
||||
val scrollChanged = remember {
|
||||
MutableSharedFlow<Unit>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(lazyListState) {
|
||||
snapshotFlow {
|
||||
lazyListState.firstVisibleItemIndex to lazyListState.firstVisibleItemScrollOffset
|
||||
}.collect { scrollChanged.tryEmit(Unit) }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scrollChanged.collectLatest {
|
||||
alpha.snapTo(1f)
|
||||
delay(config.fadeOutDurationMs.toLong())
|
||||
alpha.animateTo(0f, animationSpec = tween(config.fadeAnimDurationMs))
|
||||
}
|
||||
}
|
||||
|
||||
drawWithContent {
|
||||
drawContent()
|
||||
|
||||
val layoutInfo = lazyListState.layoutInfo
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
if (totalItems == 0) return@drawWithContent
|
||||
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
if (visibleItems.isEmpty()) return@drawWithContent
|
||||
if (alpha.value <= 0f) return@drawWithContent
|
||||
|
||||
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
|
||||
val viewportStartOffset = layoutInfo.viewportStartOffset.toFloat()
|
||||
|
||||
val averageItemHeight = visibleItems.sumOf { it.size } / visibleItems.size.toFloat()
|
||||
val estimatedTotalHeight = averageItemHeight * totalItems
|
||||
|
||||
if (estimatedTotalHeight <= viewportHeight) return@drawWithContent
|
||||
|
||||
val maxThumbHeight = viewportHeight * 0.5f
|
||||
val thumbHeight = (viewportHeight / estimatedTotalHeight * viewportHeight)
|
||||
.coerceAtLeast(config.minThumbSize.toPx())
|
||||
.coerceAtMost(maxThumbHeight)
|
||||
|
||||
val firstItem = visibleItems.first()
|
||||
val scrolledPx = firstItem.index * averageItemHeight + (viewportStartOffset - firstItem.offset)
|
||||
val maxScroll = estimatedTotalHeight - viewportHeight
|
||||
val scrollFraction = (scrolledPx / maxScroll).coerceIn(0f, 1f)
|
||||
|
||||
val maxThumbOffset = viewportHeight - thumbHeight
|
||||
val thumbOffset = scrollFraction * maxThumbOffset
|
||||
|
||||
val thickness = config.thickness.toPx()
|
||||
val padding = config.padding.toPx()
|
||||
val cornerRadius = config.cornerRadius.toPx()
|
||||
val currentAlpha = alpha.value
|
||||
|
||||
if (config.trackColor != Color.Transparent) {
|
||||
drawRoundRect(
|
||||
color = config.trackColor.copy(alpha = config.trackColor.alpha * currentAlpha),
|
||||
topLeft = Offset(size.width - thickness - padding, 0f),
|
||||
size = Size(thickness, viewportHeight),
|
||||
cornerRadius = CornerRadius(cornerRadius)
|
||||
)
|
||||
}
|
||||
|
||||
drawRoundRect(
|
||||
color = actualThumbColor.copy(alpha = actualThumbColor.alpha * currentAlpha),
|
||||
topLeft = Offset(size.width - thickness - padding, thumbOffset),
|
||||
size = Size(thickness, thumbHeight),
|
||||
cornerRadius = CornerRadius(cornerRadius)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun Modifier.verticalScrollbar(
|
||||
lazyGridState: LazyGridState,
|
||||
config: ScrollbarConfig = ScrollbarConfig(),
|
||||
): Modifier = composed {
|
||||
val actualThumbColor = if (config.thumbColor == Color.Unspecified) {
|
||||
MaterialTheme.colorScheme.secondary.copy(alpha = 0.7f)
|
||||
} else {
|
||||
config.thumbColor
|
||||
}
|
||||
|
||||
val alpha = remember { Animatable(0f) }
|
||||
val scrollChanged = remember {
|
||||
MutableSharedFlow<Unit>(
|
||||
extraBufferCapacity = 1,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
}
|
||||
|
||||
LaunchedEffect(lazyGridState) {
|
||||
snapshotFlow {
|
||||
lazyGridState.firstVisibleItemIndex to lazyGridState.firstVisibleItemScrollOffset
|
||||
}.collect { scrollChanged.tryEmit(Unit) }
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
scrollChanged.collectLatest {
|
||||
alpha.snapTo(1f)
|
||||
delay(config.fadeOutDurationMs.toLong())
|
||||
alpha.animateTo(0f, animationSpec = tween(config.fadeAnimDurationMs))
|
||||
}
|
||||
}
|
||||
|
||||
drawWithContent {
|
||||
drawContent()
|
||||
|
||||
val layoutInfo = lazyGridState.layoutInfo
|
||||
val totalItems = layoutInfo.totalItemsCount
|
||||
if (totalItems == 0) return@drawWithContent
|
||||
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
if (visibleItems.isEmpty()) return@drawWithContent
|
||||
if (alpha.value <= 0f) return@drawWithContent
|
||||
|
||||
val viewportHeight = layoutInfo.viewportSize.height.toFloat()
|
||||
val viewportStartOffset = layoutInfo.viewportStartOffset.toFloat()
|
||||
|
||||
val rows = visibleItems.groupBy { it.row }
|
||||
val avgRowHeight = rows.values.map { row -> row.maxOf { it.size.height } }.average().toFloat()
|
||||
val columnsPerRow = rows.values.firstOrNull()?.size ?: 1
|
||||
val totalRows = (totalItems + columnsPerRow - 1) / columnsPerRow
|
||||
val estimatedTotalHeight = avgRowHeight * totalRows
|
||||
|
||||
if (estimatedTotalHeight <= viewportHeight) return@drawWithContent
|
||||
|
||||
val maxThumbHeight = viewportHeight * 0.5f
|
||||
val thumbHeight = (viewportHeight / estimatedTotalHeight * viewportHeight)
|
||||
.coerceAtLeast(config.minThumbSize.toPx())
|
||||
.coerceAtMost(maxThumbHeight)
|
||||
|
||||
val firstItem = visibleItems.first()
|
||||
val firstRow = firstItem.row
|
||||
val scrolledPx = firstRow * avgRowHeight + (viewportStartOffset - firstItem.offset.y)
|
||||
val maxScroll = estimatedTotalHeight - viewportHeight
|
||||
val scrollFraction = (scrolledPx / maxScroll).coerceIn(0f, 1f)
|
||||
|
||||
val maxThumbOffset = viewportHeight - thumbHeight
|
||||
val thumbOffset = scrollFraction * maxThumbOffset
|
||||
|
||||
val thickness = config.thickness.toPx()
|
||||
val padding = config.padding.toPx()
|
||||
val cornerRadius = config.cornerRadius.toPx()
|
||||
val currentAlpha = alpha.value
|
||||
|
||||
if (config.trackColor != Color.Transparent) {
|
||||
drawRoundRect(
|
||||
color = config.trackColor.copy(alpha = config.trackColor.alpha * currentAlpha),
|
||||
topLeft = Offset(size.width - thickness - padding, 0f),
|
||||
size = Size(thickness, viewportHeight),
|
||||
cornerRadius = CornerRadius(cornerRadius)
|
||||
)
|
||||
}
|
||||
|
||||
drawRoundRect(
|
||||
color = actualThumbColor.copy(alpha = actualThumbColor.alpha * currentAlpha),
|
||||
topLeft = Offset(size.width - thickness - padding, thumbOffset),
|
||||
size = Size(thickness, thumbHeight),
|
||||
cornerRadius = CornerRadius(cornerRadius)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
package com.v2ray.ang.compose
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.graphics.painter.Painter
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
|
||||
@Composable
|
||||
fun PreferenceGroupHeader(title: String, modifier: Modifier = Modifier) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = colorFabActive,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SettingsItemRow(
|
||||
icon: Painter?,
|
||||
title: String,
|
||||
description: String?,
|
||||
enabled: Boolean,
|
||||
onClick: (() -> Unit)?,
|
||||
modifier: Modifier = Modifier,
|
||||
trailing: @Composable (() -> Unit)? = null
|
||||
) {
|
||||
val contentColor = if (enabled) MaterialTheme.colorScheme.onSurface
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.then(if (onClick != null) Modifier.clickable(enabled = enabled, onClick = onClick) else Modifier)
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (icon != null) {
|
||||
Icon(
|
||||
painter = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(24.dp),
|
||||
tint = contentColor
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = contentColor
|
||||
)
|
||||
if (!description.isNullOrEmpty()) {
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
trailing?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsEditItem(
|
||||
icon: Painter? = null,
|
||||
title: String,
|
||||
value: String,
|
||||
onValueChanged: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
isPassword: Boolean = false,
|
||||
keyboardNumber: Boolean = false
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
val description = if (isPassword) {
|
||||
if (value.isEmpty()) null else "******"
|
||||
} else {
|
||||
value.ifEmpty { null }
|
||||
}
|
||||
|
||||
SettingsItemRow(
|
||||
icon = icon,
|
||||
title = title,
|
||||
description = description,
|
||||
enabled = enabled,
|
||||
onClick = if (enabled) { { showDialog = true } } else null,
|
||||
modifier = modifier
|
||||
)
|
||||
|
||||
if (showDialog) {
|
||||
var text by remember { mutableStateOf(value) }
|
||||
InputDialog(
|
||||
title = title,
|
||||
fields = listOf(
|
||||
InputField(
|
||||
label = title,
|
||||
value = text,
|
||||
visualTransformation = VisualTransformation.None
|
||||
)
|
||||
),
|
||||
onFieldChange = { _, v -> text = v },
|
||||
confirmText = stringResource(android.R.string.ok),
|
||||
dismissText = stringResource(android.R.string.cancel),
|
||||
onConfirm = { showDialog = false; onValueChanged(text) },
|
||||
onDismiss = { showDialog = false }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsListItem(
|
||||
icon: Painter? = null,
|
||||
title: String,
|
||||
entries: List<String>,
|
||||
values: List<String>,
|
||||
selectedValue: String,
|
||||
onSelected: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
var showDialog by remember { mutableStateOf(false) }
|
||||
val selectedIndex = values.indexOf(selectedValue).let { if (it >= 0) it else 0 }
|
||||
val summary = entries.getOrNull(selectedIndex).orEmpty()
|
||||
|
||||
SettingsItemRow(
|
||||
icon = icon,
|
||||
title = title,
|
||||
description = summary.ifEmpty { null },
|
||||
enabled = enabled,
|
||||
onClick = if (enabled) { { showDialog = true } } else null,
|
||||
modifier = modifier
|
||||
)
|
||||
|
||||
if (showDialog) {
|
||||
SelectListDialog(
|
||||
title = title,
|
||||
options = entries,
|
||||
selectedOption = summary,
|
||||
onSelected = { index, _ ->
|
||||
showDialog = false
|
||||
values.getOrNull(index)?.let(onSelected)
|
||||
},
|
||||
onDismiss = { showDialog = false },
|
||||
showRadio = true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsMenuItem(
|
||||
icon: Painter? = null,
|
||||
title: String,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
subtitle: String? = null
|
||||
) {
|
||||
SettingsItemRow(
|
||||
icon = icon,
|
||||
title = title,
|
||||
description = subtitle,
|
||||
enabled = true,
|
||||
onClick = onClick,
|
||||
modifier = modifier
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SettingsSwitchItem(
|
||||
icon: Painter? = null,
|
||||
title: String,
|
||||
summary: String? = null,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true
|
||||
) {
|
||||
SettingsItemRow(
|
||||
icon = icon,
|
||||
title = title,
|
||||
description = summary,
|
||||
enabled = enabled,
|
||||
onClick = if (enabled) { { onCheckedChange(!checked) } } else null,
|
||||
modifier = modifier,
|
||||
trailing = {
|
||||
Switch(
|
||||
checked = checked,
|
||||
onCheckedChange = if (enabled) onCheckedChange else null,
|
||||
modifier = Modifier.scale(0.8f),
|
||||
colors = SwitchDefaults.colors(
|
||||
checkedThumbColor = MaterialTheme.colorScheme.onSecondary,
|
||||
checkedTrackColor = MaterialTheme.colorScheme.secondary
|
||||
),
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
package com.v2ray.ang.compose
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.layout.wrapContentWidth
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.BufferOverflow
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
enum class ToastType {
|
||||
NORMAL, SUCCESS, ERROR, INFO
|
||||
}
|
||||
|
||||
data class AppSnackbarMessage(
|
||||
val message: CharSequence,
|
||||
val type: ToastType = ToastType.NORMAL,
|
||||
val long: Boolean = false,
|
||||
)
|
||||
|
||||
object AppSnackbarManager {
|
||||
private val activeHosts = AtomicInteger(0)
|
||||
|
||||
private val _messages = MutableSharedFlow<AppSnackbarMessage>(
|
||||
replay = 0,
|
||||
extraBufferCapacity = 32,
|
||||
onBufferOverflow = BufferOverflow.DROP_OLDEST
|
||||
)
|
||||
val messages = _messages.asSharedFlow()
|
||||
|
||||
fun registerHost() {
|
||||
activeHosts.incrementAndGet()
|
||||
}
|
||||
|
||||
fun unregisterHost() {
|
||||
val current = activeHosts.decrementAndGet()
|
||||
if (current < 0) activeHosts.set(0)
|
||||
}
|
||||
|
||||
fun hasActiveHost(): Boolean = activeHosts.get() > 0
|
||||
|
||||
fun show(
|
||||
message: CharSequence,
|
||||
type: ToastType = ToastType.NORMAL,
|
||||
long: Boolean = false,
|
||||
): Boolean {
|
||||
if (!hasActiveHost()) return false
|
||||
return _messages.tryEmit(
|
||||
AppSnackbarMessage(
|
||||
message = message,
|
||||
type = type,
|
||||
long = long
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
class AppSnackbarController(
|
||||
val hostState: SnackbarHostState,
|
||||
private val scope: CoroutineScope,
|
||||
) {
|
||||
fun show(
|
||||
message: CharSequence,
|
||||
type: ToastType = ToastType.NORMAL,
|
||||
long: Boolean = false,
|
||||
) {
|
||||
scope.launch {
|
||||
hostState.currentSnackbarData?.dismiss()
|
||||
hostState.showSnackbar(
|
||||
message = message.toString(),
|
||||
actionLabel = type.name,
|
||||
duration = if (long) SnackbarDuration.Long else SnackbarDuration.Short,
|
||||
withDismissAction = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val LocalAppSnackbar = staticCompositionLocalOf<AppSnackbarController> {
|
||||
error("AppSnackbarController not provided. Wrap your content in AppTheme.")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberAppSnackbarController(): AppSnackbarController {
|
||||
val hostState = remember { SnackbarHostState() }
|
||||
val scope = rememberCoroutineScope()
|
||||
return remember(hostState, scope) { AppSnackbarController(hostState, scope) }
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun AppSnackbarBridge(
|
||||
controller: AppSnackbarController
|
||||
) {
|
||||
DisposableEffect(Unit) {
|
||||
AppSnackbarManager.registerHost()
|
||||
onDispose { AppSnackbarManager.unregisterHost() }
|
||||
}
|
||||
|
||||
LaunchedEffect(controller) {
|
||||
AppSnackbarManager.messages.collect { event ->
|
||||
controller.show(
|
||||
message = event.message,
|
||||
type = event.type,
|
||||
long = event.long
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val ToastCornerRadius = 24.dp
|
||||
private val ToastHorizontalPad = 16.dp
|
||||
private val ToastVerticalPad = 12.dp
|
||||
private val ToastIconSize = 24.dp
|
||||
private val ToastIconSpacing = 10.dp
|
||||
private val ToastMaxLines = 8
|
||||
private val ToastMaxWidthFraction = 0.75f
|
||||
private val ToastBottomOffset = 100.dp
|
||||
|
||||
@Composable
|
||||
fun AppSnackbarHost(
|
||||
hostState: SnackbarHostState,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
val maxSnackbarWidth = maxWidth * ToastMaxWidthFraction
|
||||
|
||||
SnackbarHost(
|
||||
hostState = hostState,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
) { data ->
|
||||
val type = runCatching {
|
||||
ToastType.valueOf(data.visuals.actionLabel.orEmpty())
|
||||
}.getOrDefault(ToastType.NORMAL)
|
||||
|
||||
val isDark = LocalDarkTheme.current
|
||||
val bgColor = when (type) {
|
||||
ToastType.NORMAL -> if (isDark) toastNormalBgDark else toastNormalBgLight
|
||||
ToastType.SUCCESS -> toastSuccessBg
|
||||
ToastType.ERROR -> toastErrorBg
|
||||
ToastType.INFO -> toastInfoBg
|
||||
}
|
||||
|
||||
val iconText = when (type) {
|
||||
ToastType.SUCCESS -> "✓"
|
||||
ToastType.ERROR -> "✕"
|
||||
ToastType.INFO -> "ℹ"
|
||||
ToastType.NORMAL -> null
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(bottom = ToastBottomOffset),
|
||||
contentAlignment = Alignment.BottomCenter
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.wrapContentWidth()
|
||||
.widthIn(max = maxSnackbarWidth),
|
||||
shape = RoundedCornerShape(ToastCornerRadius),
|
||||
color = bgColor,
|
||||
shadowElevation = 0.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(
|
||||
horizontal = ToastHorizontalPad,
|
||||
vertical = ToastVerticalPad
|
||||
),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (iconText != null) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(ToastIconSize)
|
||||
.clip(CircleShape)
|
||||
.background(toastIconCircleBg),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = iconText,
|
||||
color = toastTextColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(ToastIconSpacing))
|
||||
}
|
||||
|
||||
Text(
|
||||
text = data.visuals.message,
|
||||
color = toastTextColor,
|
||||
fontSize = 14.sp,
|
||||
maxLines = ToastMaxLines,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.wrapContentWidth()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package com.v2ray.ang.compose
|
||||
|
||||
import android.app.Activity
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.material3.lightColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.SideEffect
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.compositionLocalOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalView
|
||||
import androidx.core.view.WindowCompat
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
private val LightColor = lightColorScheme(
|
||||
primary = Color(0xFF000000), // Black 黑色
|
||||
onPrimary = Color(0xFFFFFFFF), // White 白色
|
||||
primaryContainer = Color(0xFFE0E0E0), // Light Gray 浅灰色
|
||||
onPrimaryContainer = Color(0xFF000000), // Black 黑色
|
||||
secondary = Color(0xFFf97910), // Orange 橙色
|
||||
onSecondary = Color(0xFFFFFFFF), // White 白色
|
||||
secondaryContainer = Color(0xFFFFE8D6), // Pale Orange 淡橙色
|
||||
onSecondaryContainer = Color(0xFF2B1700), // Dark Brown 深棕色
|
||||
tertiary = Color(0xFF009966), // Green 绿色
|
||||
onTertiary = Color(0xFFFFFFFF), // White 白色
|
||||
tertiaryContainer = Color(0xFFA0F2D0), // Light Green 浅绿色
|
||||
onTertiaryContainer = Color(0xFF00201A), // Dark Teal 深蓝绿色
|
||||
error = Color(0xFFBA1A1A), // Red 红色
|
||||
errorContainer = Color(0xFFFFDAD6), // Light Red 浅红色
|
||||
onError = Color(0xFFFFFFFF), // White 白色
|
||||
onErrorContainer = Color(0xFF410002), // Dark Red 暗红色
|
||||
background = Color(0xFFFFFFFF), // White 白色
|
||||
onBackground = Color(0xFF1C1B1F), // Near Black 近黑色
|
||||
surface = Color(0xFFFFFFFF), // White 白色
|
||||
onSurface = Color(0xFF1C1B1F), // Near Black 近黑色
|
||||
surfaceVariant = Color(0xFFE7E0EC), // Light Purple Gray 浅紫灰色
|
||||
onSurfaceVariant = Color(0xFF49454F), // Dark Gray 深灰色
|
||||
outline = Color(0xFF79747E), // Medium Gray 中灰色
|
||||
outlineVariant = Color(0xFFCAC4D0), // Light Gray 浅灰色
|
||||
inverseSurface = Color(0xFF313033), // Dark Gray 深灰色
|
||||
inverseOnSurface = Color(0xFFF4EFF4), // Very Light Gray 极浅灰色
|
||||
inversePrimary = Color(0xFFC0C0C0), // Silver Gray 银灰色
|
||||
scrim = Color(0xFF000000), // Black 黑色
|
||||
surfaceTint = Color(0xFF000000), // Black 黑色
|
||||
surfaceContainerLowest = Color(0xFFFFFFFF), // White 白色
|
||||
surfaceContainerLow = Color(0xFFF7F7F7), // Very Light Gray 极浅灰色
|
||||
surfaceContainer = Color(0xFFF1F1F1), // Light Gray 浅灰色
|
||||
surfaceContainerHigh = Color(0xFFEBEBEB), // Light Gray 浅灰色
|
||||
surfaceContainerHighest = Color(0xFFE5E5E5), // Light Gray 浅灰色
|
||||
)
|
||||
|
||||
private val DarkColor = darkColorScheme(
|
||||
primary = Color(0xFFC0C0C0), // Silver Gray 银灰色
|
||||
onPrimary = Color(0xFF303030), // Dark Gray 深灰色
|
||||
primaryContainer = Color(0xFF474747), // Gray 灰色
|
||||
onPrimaryContainer = Color(0xFFE0E0E0), // Light Gray 浅灰色
|
||||
secondary = Color(0xFFf97910), // Orange 橙色
|
||||
onSecondary = Color(0xFF4E2600), // Dark Brown 深棕色
|
||||
secondaryContainer = Color(0xFF6F3800), // Brown 棕色
|
||||
onSecondaryContainer = Color(0xFFFFE8D6), // Pale Orange 淡橙色
|
||||
tertiary = Color(0xFF83D6B5), // Mint Green 薄荷绿
|
||||
onTertiary = Color(0xFF00382E), // Dark Teal 深蓝绿色
|
||||
tertiaryContainer = Color(0xFF005143), // Teal 蓝绿色
|
||||
onTertiaryContainer = Color(0xFFA0F2D0), // Light Green 浅绿色
|
||||
error = Color(0xFFFFB4AB), // Light Red 浅红色
|
||||
errorContainer = Color(0xFF93000A), // Dark Red 暗红色
|
||||
onError = Color(0xFF690005), // Deep Red 深红色
|
||||
onErrorContainer = Color(0xFFFFDAD6), // Light Red 浅红色
|
||||
background = Color(0xFF1C1B1F), // Near Black 近黑色
|
||||
onBackground = Color(0xFFE6E1E5), // Light Gray 浅灰色
|
||||
surface = Color(0xFF1C1B1F), // Near Black 近黑色
|
||||
onSurface = Color(0xFFE6E1E5), // Light Gray 浅灰色
|
||||
surfaceVariant = Color(0xFF49454F), // Dark Gray 深灰色
|
||||
onSurfaceVariant = Color(0xFFCAC4D0), // Light Gray 浅灰色
|
||||
outline = Color(0xFF938F99), // Grayish Purple 灰紫色
|
||||
outlineVariant = Color(0xFF49454F), // Dark Gray 深灰色
|
||||
inverseSurface = Color(0xFFE6E1E5), // Light Gray 浅灰色
|
||||
inverseOnSurface = Color(0xFF1C1B1F), // Near Black 近黑色
|
||||
inversePrimary = Color(0xFF000000), // Black 黑色
|
||||
scrim = Color(0xFF000000), // Black 黑色
|
||||
surfaceTint = Color(0xFFC0C0C0), // Silver Gray 银灰色
|
||||
surfaceContainerLowest = Color(0xFF0F0F12), // Near Black 近黑色
|
||||
surfaceContainerLow = Color(0xFF1A191D), // Dark Gray 深灰色
|
||||
surfaceContainer = Color(0xFF1E1D21), // Dark Gray 深灰色
|
||||
surfaceContainerHigh = Color(0xFF282729), // Dark Gray 深灰色
|
||||
surfaceContainerHighest = Color(0xFF333234), // Dark Gray 深灰色
|
||||
)
|
||||
|
||||
// Semantic Colors
|
||||
val colorPing = Color(0xFF009966) // Green 绿色
|
||||
val colorPingRed = Color(0xFFFF0099) // Pink Red 粉红色
|
||||
val colorConfigType = Color(0xFFf97910) // Orange 橙色
|
||||
val colorFabActive = Color(0xFFf97910) // Orange 橙色
|
||||
val colorFabInactiveLight = Color(0xFF9C9C9C) // Gray 灰色
|
||||
val colorFabInactiveDark = Color(0xFF646464) // Dark Gray 深灰色
|
||||
val dividerColorLight = Color(0xFFE0E0E0) // Light Gray 浅灰色
|
||||
val dividerColorDark = Color(0xFF424242) // Dark Gray 深灰色
|
||||
|
||||
// Toast Colors 70%
|
||||
val toastNormalBgLight = Color(0xB3353A3E) // Dark Gray 深灰色
|
||||
val toastNormalBgDark = Color(0xB34A4F54) // Darker Gray 暗灰色
|
||||
val toastSuccessBg = Color(0xB3388E3C) // Green 绿色
|
||||
val toastErrorBg = Color(0xB3D50000) // Red 红色
|
||||
val toastInfoBg = Color(0xB33F51B5) // Indigo Blue 靛蓝色
|
||||
val toastIconCircleBg = Color(0x33FFFFFF) // Semi-transparent White 半透明白色
|
||||
val toastTextColor = Color.White // White 白色
|
||||
|
||||
object ThemeManager {
|
||||
private val _themeMode = MutableStateFlow(
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0") ?: "0"
|
||||
)
|
||||
val themeMode: StateFlow<String> = _themeMode.asStateFlow()
|
||||
|
||||
fun setThemeMode(mode: String) {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_UI_MODE_NIGHT, mode)
|
||||
_themeMode.value = mode
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
_themeMode.value =
|
||||
MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0") ?: "0"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun resolveDarkTheme(): Boolean {
|
||||
val mode by ThemeManager.themeMode.collectAsState()
|
||||
return when (mode) {
|
||||
"1" -> false
|
||||
"2" -> true
|
||||
else -> isSystemInDarkTheme()
|
||||
}
|
||||
}
|
||||
|
||||
val LocalDarkTheme = compositionLocalOf { false }
|
||||
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
darkTheme: Boolean = resolveDarkTheme(),
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = if (darkTheme) DarkColor else LightColor
|
||||
val snackbarController = rememberAppSnackbarController()
|
||||
|
||||
val view = LocalView.current
|
||||
if (!view.isInEditMode) {
|
||||
SideEffect {
|
||||
val activity = view.context as? Activity ?: return@SideEffect
|
||||
val window = activity.window
|
||||
WindowCompat.getInsetsController(window, view).apply {
|
||||
isAppearanceLightStatusBars = !darkTheme
|
||||
isAppearanceLightNavigationBars = !darkTheme
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
CompositionLocalProvider(
|
||||
LocalDarkTheme provides darkTheme,
|
||||
LocalAppSnackbar provides snackbarController
|
||||
) {
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
content()
|
||||
|
||||
AppSnackbarBridge(controller = snackbarController)
|
||||
AppSnackbarHost(hostState = snackbarController.hostState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
/**
|
||||
* A common Adapter -> host callback interface that includes common actions: edit, remove and refresh.
|
||||
* Extend this interface or define more specific interfaces for different adapters as needed.
|
||||
*/
|
||||
interface BaseAdapterListener {
|
||||
/**
|
||||
* Request the host to edit the specified item.
|
||||
* @param guid Unique identifier (GUID) of the item
|
||||
* @param position Current position in the adapter (optional; host should validate it)
|
||||
*/
|
||||
fun onEdit(guid: String, position: Int)
|
||||
|
||||
/**
|
||||
* Request the host to remove the specified item. Position is provided for optional animation or validation.
|
||||
* @param guid Unique identifier (GUID) of the item
|
||||
* @param position Current position in the adapter (optional; host should validate it)
|
||||
*/
|
||||
fun onRemove(guid: String, position: Int)
|
||||
|
||||
/**
|
||||
* Request the host to share the specified URL.
|
||||
* @param url The URL to be shared
|
||||
*/
|
||||
fun onShare(url: String)
|
||||
|
||||
/**
|
||||
* Request the host to refresh data (for example, reload from the ViewModel or call notifyDataSetChanged).
|
||||
*/
|
||||
fun onRefreshData()
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package com.v2ray.ang.contracts
|
||||
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
|
||||
interface MainAdapterListener : BaseAdapterListener {
|
||||
|
||||
fun onEdit(guid: String, position: Int, profile: ProfileItem)
|
||||
|
||||
fun onSelectServer(guid: String)
|
||||
|
||||
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.v2ray.ang.dto
|
||||
|
||||
data class LocateTarget(
|
||||
val groupId: String,
|
||||
val groupIndex: Int,
|
||||
val itemPosition: Int
|
||||
)
|
||||
@@ -36,6 +36,7 @@ data class ProfileItem(
|
||||
var xhttpMode: String? = null,
|
||||
var xhttpExtra: String? = null,
|
||||
var finalMask: String? = null,
|
||||
|
||||
var security: String? = null,
|
||||
var sni: String? = null,
|
||||
var alpn: String? = null,
|
||||
@@ -69,63 +70,40 @@ data class ProfileItem(
|
||||
var proxyChainProfiles: String? = null,
|
||||
|
||||
var browserDialerMode: String? = null,
|
||||
) {
|
||||
|
||||
) {
|
||||
companion object {
|
||||
fun create(configType: EConfigType): ProfileItem {
|
||||
return ProfileItem(configType = configType)
|
||||
}
|
||||
fun create(configType: EConfigType): ProfileItem =
|
||||
ProfileItem(configType = configType)
|
||||
}
|
||||
|
||||
fun getServerAddressAndPort(): String {
|
||||
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
|
||||
return "${AppConfig.LOOPBACK}:${AppConfig.PORT_SOCKS}"
|
||||
}
|
||||
return Utils.getIpv6Address(server) + ":" + serverPort
|
||||
return "${Utils.getIpv6Address(server)}:$serverPort"
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (other == null) return false
|
||||
val obj = other as ProfileItem
|
||||
|
||||
return (this.server == obj.server
|
||||
&& this.serverPort == obj.serverPort
|
||||
&& this.password == obj.password
|
||||
&& this.method == obj.method
|
||||
&& this.flow == obj.flow
|
||||
&& this.username == obj.username
|
||||
|
||||
&& this.network == obj.network
|
||||
&& this.headerType == obj.headerType
|
||||
&& this.host == obj.host
|
||||
&& this.path == obj.path
|
||||
&& this.seed == obj.seed
|
||||
&& this.kcpMtu == obj.kcpMtu
|
||||
&& this.kcpTti == obj.kcpTti
|
||||
&& this.quicSecurity == obj.quicSecurity
|
||||
&& this.quicKey == obj.quicKey
|
||||
&& this.mode == obj.mode
|
||||
&& this.serviceName == obj.serviceName
|
||||
&& this.authority == obj.authority
|
||||
&& this.xhttpMode == obj.xhttpMode
|
||||
|
||||
&& this.security == obj.security
|
||||
&& this.sni == obj.sni
|
||||
&& this.alpn == obj.alpn
|
||||
&& this.fingerPrint == obj.fingerPrint
|
||||
&& this.publicKey == obj.publicKey
|
||||
&& this.shortId == obj.shortId
|
||||
|
||||
&& this.secretKey == obj.secretKey
|
||||
&& this.localAddress == obj.localAddress
|
||||
&& this.reserved == obj.reserved
|
||||
&& this.mtu == obj.mtu
|
||||
|
||||
&& this.obfsPassword == obj.obfsPassword
|
||||
&& this.portHopping == obj.portHopping
|
||||
&& this.portHoppingInterval == obj.portHoppingInterval
|
||||
&& this.pinnedCA256 == obj.pinnedCA256
|
||||
&& this.proxyChainProfiles == obj.proxyChainProfiles
|
||||
)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Dedicated identity for "remove duplicate configurations".
|
||||
*
|
||||
* Ignores metadata that does not affect connection:
|
||||
* - configVersion
|
||||
* - subscriptionId
|
||||
* - addedTime
|
||||
* - remarks
|
||||
* - description
|
||||
*
|
||||
* All other fields, including configType, are included in the comparison.
|
||||
*
|
||||
* Returns a copy; the caller must not modify it further.
|
||||
*/
|
||||
fun duplicateIdentity(): ProfileItem =
|
||||
copy(
|
||||
configVersion = 0,
|
||||
subscriptionId = "",
|
||||
addedTime = 0L,
|
||||
remarks = "",
|
||||
description = null
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.v2ray.ang.dto.entities
|
||||
|
||||
data class RulesetItem(
|
||||
var id: String = "",
|
||||
var remarks: String? = "",
|
||||
var ip: List<String>? = null,
|
||||
var domain: List<String>? = null,
|
||||
|
||||
@@ -2,5 +2,7 @@ package com.v2ray.ang.dto.entities
|
||||
|
||||
data class ServersCache(
|
||||
val guid: String,
|
||||
val profile: ProfileItem
|
||||
)
|
||||
val profile: ProfileItem,
|
||||
val testDelayMillis: Long = 0L,
|
||||
val testDelayString: String = "",
|
||||
)
|
||||
|
||||
@@ -6,10 +6,13 @@ import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.widget.Toast
|
||||
import com.v2ray.ang.AngApplication
|
||||
import com.v2ray.ang.compose.AppSnackbarManager
|
||||
import com.v2ray.ang.compose.ToastType
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import es.dmoral.toasty.Toasty
|
||||
import java.io.Serializable
|
||||
import java.net.URI
|
||||
import java.util.Locale
|
||||
@@ -17,13 +20,40 @@ import java.util.Locale
|
||||
val Context.v2RayApplication: AngApplication?
|
||||
get() = applicationContext as? AngApplication
|
||||
|
||||
private inline fun runOnMain(crossinline block: () -> Unit) {
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) {
|
||||
block()
|
||||
} else {
|
||||
Handler(Looper.getMainLooper()).post { block() }
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun Context.dispatchMessage(
|
||||
message: CharSequence,
|
||||
type: ToastType,
|
||||
long: Boolean = false,
|
||||
crossinline fallback: () -> Unit
|
||||
) {
|
||||
val handledBySnackbar = AppSnackbarManager.show(
|
||||
message = message,
|
||||
type = type,
|
||||
long = long
|
||||
)
|
||||
if (!handledBySnackbar) {
|
||||
runOnMain { fallback() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toast(message: Int) {
|
||||
Toasty.normal(this, message).show()
|
||||
val text = getString(message)
|
||||
dispatchMessage(text, ToastType.NORMAL) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -32,7 +62,9 @@ fun Context.toast(message: Int) {
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toast(message: CharSequence) {
|
||||
Toasty.normal(this, message).show()
|
||||
dispatchMessage(message, ToastType.NORMAL) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,7 +73,10 @@ fun Context.toast(message: CharSequence) {
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toastSuccess(message: Int) {
|
||||
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
|
||||
val text = getString(message)
|
||||
dispatchMessage(text, ToastType.SUCCESS) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,7 +85,9 @@ fun Context.toastSuccess(message: Int) {
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toastSuccess(message: CharSequence) {
|
||||
Toasty.success(this, message, Toast.LENGTH_SHORT, true).show()
|
||||
dispatchMessage(message, ToastType.SUCCESS) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +96,10 @@ fun Context.toastSuccess(message: CharSequence) {
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toastError(message: Int) {
|
||||
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
|
||||
val text = getString(message)
|
||||
dispatchMessage(text, ToastType.ERROR) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,7 +108,32 @@ fun Context.toastError(message: Int) {
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toastError(message: CharSequence) {
|
||||
Toasty.error(this, message, Toast.LENGTH_SHORT, true).show()
|
||||
dispatchMessage(message, ToastType.ERROR) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an info toast message with the given resource ID.
|
||||
*
|
||||
* @param message The resource ID of the message to show.
|
||||
*/
|
||||
fun Context.toastInfo(message: Int) {
|
||||
val text = getString(message)
|
||||
dispatchMessage(text, ToastType.INFO) {
|
||||
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows an info toast message with the given text.
|
||||
*
|
||||
* @param message The text of the message to show.
|
||||
*/
|
||||
fun Context.toastInfo(message: CharSequence) {
|
||||
dispatchMessage(message, ToastType.INFO) {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
const val THRESHOLD = 1000L
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import com.tencent.mmkv.MMKV
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
||||
@@ -14,6 +20,9 @@ import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.dto.entities.WebDavConfig
|
||||
import com.v2ray.ang.util.JsonUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.drop
|
||||
|
||||
object MmkvManager {
|
||||
|
||||
@@ -722,4 +731,56 @@ object MmkvManager {
|
||||
}
|
||||
|
||||
//endregion
|
||||
|
||||
//region Compose helpers for Settings
|
||||
|
||||
/**
|
||||
* MMKV-backed String state, auto-persists and notifies on change.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMmkvString(
|
||||
key: String,
|
||||
default: String = ""
|
||||
): MutableState<String> {
|
||||
val state = remember(key) {
|
||||
mutableStateOf(decodeSettingsString(key, default) ?: default)
|
||||
}
|
||||
|
||||
LaunchedEffect(key) {
|
||||
snapshotFlow { state.value }
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { value ->
|
||||
encodeSettings(key, value)
|
||||
SettingsChangeManager.notifySettingChanged(key)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* MMKV-backed Boolean state, auto-persists and notifies on change.
|
||||
*/
|
||||
@Composable
|
||||
fun rememberMmkvBool(
|
||||
key: String,
|
||||
default: Boolean = false
|
||||
): MutableState<Boolean> {
|
||||
val state = remember(key) {
|
||||
mutableStateOf(decodeSettingsBool(key, default))
|
||||
}
|
||||
|
||||
LaunchedEffect(key) {
|
||||
snapshotFlow { state.value }
|
||||
.drop(1)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { value ->
|
||||
encodeSettings(key, value)
|
||||
SettingsChangeManager.notifySettingChanged(key)
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
//endregion
|
||||
}
|
||||
|
||||
@@ -1,32 +1,60 @@
|
||||
package com.v2ray.ang.handler
|
||||
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import com.v2ray.ang.AppConfig
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Manages global flags for actions triggered by setting changes.
|
||||
* Uses AtomicBoolean for thread-safe consume operations.
|
||||
*/
|
||||
object SettingsChangeManager {
|
||||
private val _restartService = MutableStateFlow(false)
|
||||
private val _setupGroupTab = MutableStateFlow(false)
|
||||
|
||||
// Mark restartService as requiring a restart
|
||||
private val restartService = AtomicBoolean(false)
|
||||
private val setupGroupTab = AtomicBoolean(false)
|
||||
|
||||
// Keys that affect only UI behavior and do not require core service restart.
|
||||
private val uiOnlyKeys = setOf(
|
||||
AppConfig.PREF_CONFIRM_REMOVE,
|
||||
AppConfig.PREF_START_SCAN_IMMEDIATE,
|
||||
AppConfig.PREF_DOUBLE_COLUMN_DISPLAY,
|
||||
AppConfig.PREF_GROUP_ALL_DISPLAY,
|
||||
AppConfig.PREF_LANGUAGE,
|
||||
AppConfig.PREF_UI_MODE_NIGHT,
|
||||
AppConfig.PREF_IS_BOOTED,
|
||||
AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST,
|
||||
AppConfig.PREF_AUTO_SORT_AFTER_TEST,
|
||||
)
|
||||
|
||||
/**
|
||||
* Called when a setting value changes.
|
||||
* Triggers service restart if the key is not UI-only, and always refreshes UI tabs.
|
||||
*/
|
||||
fun notifySettingChanged(key: String) {
|
||||
if (key !in uiOnlyKeys) {
|
||||
makeRestartService()
|
||||
}
|
||||
makeSetupGroupTab()
|
||||
}
|
||||
|
||||
fun makeRestartService() {
|
||||
_restartService.value = true
|
||||
restartService.set(true)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import android.content.Context
|
||||
import android.content.res.AssetManager
|
||||
import android.os.Build
|
||||
import android.text.TextUtils
|
||||
import androidx.appcompat.app.AppCompatDelegate
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||
@@ -439,17 +438,6 @@ object SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set night mode.
|
||||
*/
|
||||
fun setNightMode() {
|
||||
when (MmkvManager.decodeSettingsString(AppConfig.PREF_UI_MODE_NIGHT, "0")) {
|
||||
"0" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM)
|
||||
"1" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO)
|
||||
"2" -> AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the currently selected VPN interface address configuration.
|
||||
* This method reads the user's preference for VPN interface addressing and returns
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.graphics.Canvas
|
||||
import android.graphics.Rect
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.view.View
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
|
||||
class CustomDividerItemDecoration(
|
||||
private val divider: Drawable,
|
||||
private val orientation: Int
|
||||
) : RecyclerView.ItemDecoration() {
|
||||
|
||||
override fun onDraw(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) {
|
||||
if (orientation == RecyclerView.VERTICAL) {
|
||||
drawVerticalDividers(canvas, parent)
|
||||
} else {
|
||||
drawHorizontalDividers(canvas, parent)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawVerticalDividers(canvas: Canvas, parent: RecyclerView) {
|
||||
val left = parent.paddingLeft
|
||||
val right = parent.width - parent.paddingRight
|
||||
|
||||
val childCount = parent.childCount
|
||||
for (i in 0 until childCount - 1) {
|
||||
val child = parent.getChildAt(i)
|
||||
val params = child.layoutParams as RecyclerView.LayoutParams
|
||||
|
||||
val top = child.bottom + params.bottomMargin
|
||||
val bottom = top + divider.intrinsicHeight
|
||||
|
||||
divider.setBounds(left, top, right, bottom)
|
||||
divider.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
private fun drawHorizontalDividers(canvas: Canvas, parent: RecyclerView) {
|
||||
val top = parent.paddingTop
|
||||
val bottom = parent.height - parent.paddingBottom
|
||||
|
||||
val childCount = parent.childCount
|
||||
for (i in 0 until childCount - 1) {
|
||||
val child = parent.getChildAt(i)
|
||||
val params = child.layoutParams as RecyclerView.LayoutParams
|
||||
|
||||
val left = child.right + params.rightMargin
|
||||
val right = left + divider.intrinsicWidth
|
||||
|
||||
divider.setBounds(left, top, right, bottom)
|
||||
divider.draw(canvas)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemOffsets(
|
||||
outRect: Rect,
|
||||
view: View,
|
||||
parent: RecyclerView,
|
||||
state: RecyclerView.State
|
||||
) {
|
||||
if (orientation == RecyclerView.VERTICAL) {
|
||||
outRect.set(0, 0, 0, divider.intrinsicHeight)
|
||||
} else {
|
||||
outRect.set(0, 0, divider.intrinsicWidth, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,12 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.extension.toast
|
||||
@@ -15,14 +16,14 @@ import com.v2ray.ang.util.LogUtil
|
||||
* Helper for choosing and creating files using Android Storage Access Framework.
|
||||
* Supports both file selection (ACTION_GET_CONTENT) and file creation (CreateDocument).
|
||||
*/
|
||||
class FileChooserHelper(private val activity: AppCompatActivity) {
|
||||
class FileChooserHelper(private val activity: ComponentActivity) {
|
||||
private var fileChooserCallback: ((Uri?) -> Unit)? = null
|
||||
private var documentCreateCallback: ((Uri?) -> Unit)? = null
|
||||
|
||||
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
val uri = result.data?.data
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK && uri != null) {
|
||||
if (result.resultCode == Activity.RESULT_OK && uri != null) {
|
||||
fileChooserCallback?.invoke(uri)
|
||||
} else {
|
||||
fileChooserCallback?.invoke(null)
|
||||
@@ -85,4 +86,4 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
|
||||
documentCreateCallback = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 Paul Burke
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
/**
|
||||
* Interface to listen for a move or dismissal event from a [ItemTouchHelper.Callback].
|
||||
*
|
||||
* @author Paul Burke (ipaulpro)
|
||||
*/
|
||||
interface ItemTouchHelperAdapter {
|
||||
/**
|
||||
* Called when an item has been dragged far enough to trigger a move. This is called every time
|
||||
* an item is shifted, and **not** at the end of a "drop" event.<br></br>
|
||||
* <br></br>
|
||||
* Implementations should call [RecyclerView.Adapter.notifyItemMoved] after
|
||||
* adjusting the underlying data to reflect this move.
|
||||
*
|
||||
* @param fromPosition The start position of the moved item.
|
||||
* @param toPosition Then resolved position of the moved item.
|
||||
* @return True if the item was moved to the new adapter position.
|
||||
* @see RecyclerView.getAdapterPositionFor
|
||||
* @see RecyclerView.ViewHolder.getAdapterPosition
|
||||
*/
|
||||
fun onItemMove(fromPosition: Int, toPosition: Int): Boolean
|
||||
|
||||
|
||||
fun onItemMoveCompleted()
|
||||
|
||||
/**
|
||||
* Called when an item has been dismissed by a swipe.<br></br>
|
||||
* <br></br>
|
||||
* Implementations should call [RecyclerView.Adapter.notifyItemRemoved] after
|
||||
* adjusting the underlying data to reflect this removal.
|
||||
*
|
||||
* @param position The position of the item dismissed.
|
||||
* @see RecyclerView.getAdapterPositionFor
|
||||
* @see RecyclerView.ViewHolder.getAdapterPosition
|
||||
*/
|
||||
fun onItemDismiss(position: Int)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 Paul Burke
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
|
||||
/**
|
||||
* Interface to notify an item ViewHolder of relevant callbacks from [ ].
|
||||
*
|
||||
* @author Paul Burke (ipaulpro)
|
||||
*/
|
||||
interface ItemTouchHelperViewHolder {
|
||||
/**
|
||||
* Called when the [ItemTouchHelper] first registers an item as being moved or swiped.
|
||||
* Implementations should update the item view to indicate it's active state.
|
||||
*/
|
||||
fun onItemSelected()
|
||||
|
||||
|
||||
/**
|
||||
* Called when the [ItemTouchHelper] has completed the move or swipe, and the active item
|
||||
* state should be cleared.
|
||||
*/
|
||||
fun onItemClear()
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import androidx.preference.PreferenceDataStore
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
/**
|
||||
* PreferenceDataStore implementation that bridges AndroidX Preference framework to MMKV storage.
|
||||
* This ensures that all Preference UI operations read/write directly from/to MMKV,
|
||||
* avoiding inconsistencies between SharedPreferences and MMKV.
|
||||
*/
|
||||
class MmkvPreferenceDataStore : PreferenceDataStore() {
|
||||
|
||||
override fun putString(key: String, value: String?) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getString(key: String, defaultValue: String?): String? {
|
||||
return MmkvManager.decodeSettingsString(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putInt(key: String, value: Int) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getInt(key: String, defaultValue: Int): Int {
|
||||
return MmkvManager.decodeSettingsInt(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putLong(key: String, value: Long) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getLong(key: String, defaultValue: Long): Long {
|
||||
return MmkvManager.decodeSettingsLong(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putFloat(key: String, value: Float) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getFloat(key: String, defaultValue: Float): Float {
|
||||
return MmkvManager.decodeSettingsFloat(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putBoolean(key: String, value: Boolean) {
|
||||
MmkvManager.encodeSettings(key, value)
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getBoolean(key: String, defaultValue: Boolean): Boolean {
|
||||
return MmkvManager.decodeSettingsBool(key, defaultValue)
|
||||
}
|
||||
|
||||
override fun putStringSet(key: String, values: MutableSet<String>?) {
|
||||
if (values == null) {
|
||||
MmkvManager.encodeSettings(key, null as String?)
|
||||
} else {
|
||||
MmkvManager.encodeSettings(key, values)
|
||||
}
|
||||
notifySettingChanged(key)
|
||||
}
|
||||
|
||||
override fun getStringSet(key: String, defaultValues: MutableSet<String>?): MutableSet<String>? {
|
||||
return MmkvManager.decodeSettingsStringSet(key) ?: defaultValues
|
||||
}
|
||||
|
||||
// Internal helper: notify other modules about setting changes
|
||||
private fun notifySettingChanged(key: String) {
|
||||
if (key == AppConfig.PREF_LOGLEVEL) {
|
||||
LogUtil.refreshLogLevel()
|
||||
}
|
||||
|
||||
// Call SettingsManager.setNightMode if UI mode changed
|
||||
if (key == AppConfig.PREF_UI_MODE_NIGHT) {
|
||||
SettingsManager.setNightMode()
|
||||
}
|
||||
// Notify listeners that require service restart or reinit
|
||||
SettingsChangeManager.makeRestartService()
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.enums.PermissionType
|
||||
@@ -12,7 +12,7 @@ import com.v2ray.ang.extension.toast
|
||||
/**
|
||||
* Helper for requesting permissions.
|
||||
*/
|
||||
class PermissionHelper(private val activity: AppCompatActivity) {
|
||||
class PermissionHelper(private val activity: ComponentActivity) {
|
||||
private var permissionCallback: ((Boolean) -> Unit)? = null
|
||||
|
||||
private val permissionLauncher: ActivityResultLauncher<String> =
|
||||
@@ -43,4 +43,4 @@ class PermissionHelper(private val activity: AppCompatActivity) {
|
||||
permissionLauncher.launch(permission)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import com.v2ray.ang.ui.ScannerActivity
|
||||
|
||||
/**
|
||||
@@ -12,12 +13,12 @@ import com.v2ray.ang.ui.ScannerActivity
|
||||
* This class encapsulates the logic for launching the QR code scanner activity
|
||||
* and handling the scan result.
|
||||
*/
|
||||
class QRCodeScannerHelper(private val activity: AppCompatActivity) {
|
||||
class QRCodeScannerHelper(private val activity: ComponentActivity) {
|
||||
private var scanCallback: ((String?) -> Unit)? = null
|
||||
|
||||
private val scanLauncher: ActivityResultLauncher<Intent> =
|
||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
||||
if (result.resultCode == Activity.RESULT_OK) {
|
||||
val scanResult = result.data?.getStringExtra("SCAN_RESULT")
|
||||
scanCallback?.invoke(scanResult)
|
||||
} else {
|
||||
|
||||
@@ -1,147 +0,0 @@
|
||||
/*
|
||||
* Copyright (C) 2015 Paul Burke
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package com.v2ray.ang.helper
|
||||
|
||||
import android.animation.ValueAnimator
|
||||
import android.graphics.Canvas
|
||||
import android.view.animation.DecelerateInterpolator
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.min
|
||||
import kotlin.math.sign
|
||||
|
||||
/**
|
||||
* An implementation of [ItemTouchHelper.Callback] that enables basic drag & drop and
|
||||
* swipe-to-dismiss. Drag events are automatically started by an item long-press.<br></br>
|
||||
*
|
||||
* Expects the `RecyclerView.Adapter` to listen for [ ] callbacks and the `RecyclerView.ViewHolder` to implement
|
||||
* [ItemTouchHelperViewHolder].
|
||||
*
|
||||
* @author Paul Burke (ipaulpro)
|
||||
*/
|
||||
class SimpleItemTouchHelperCallback(private val mAdapter: ItemTouchHelperAdapter, private val allowSwipe: Boolean = false) : ItemTouchHelper.Callback() {
|
||||
private var mReturnAnimator: ValueAnimator? = null
|
||||
|
||||
override fun isLongPressDragEnabled(): Boolean = true
|
||||
|
||||
override fun isItemViewSwipeEnabled(): Boolean = allowSwipe
|
||||
|
||||
override fun getMovementFlags(
|
||||
recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder
|
||||
): Int {
|
||||
val dragFlags: Int
|
||||
val swipeFlags: Int
|
||||
if (recyclerView.layoutManager is GridLayoutManager) {
|
||||
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN or ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT
|
||||
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
|
||||
} else {
|
||||
dragFlags = ItemTouchHelper.UP or ItemTouchHelper.DOWN
|
||||
swipeFlags = if (allowSwipe) ItemTouchHelper.START or ItemTouchHelper.END else 0
|
||||
}
|
||||
return makeMovementFlags(dragFlags, swipeFlags)
|
||||
}
|
||||
|
||||
override fun onMove(
|
||||
recyclerView: RecyclerView,
|
||||
source: RecyclerView.ViewHolder,
|
||||
target: RecyclerView.ViewHolder
|
||||
): Boolean {
|
||||
return if (source.itemViewType != target.itemViewType) {
|
||||
false
|
||||
} else {
|
||||
mAdapter.onItemMove(source.bindingAdapterPosition, target.bindingAdapterPosition)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) {
|
||||
// Do not delete; simply return item to original position
|
||||
returnViewToOriginalPosition(viewHolder)
|
||||
}
|
||||
|
||||
override fun onChildDraw(
|
||||
c: Canvas, recyclerView: RecyclerView,
|
||||
viewHolder: RecyclerView.ViewHolder,
|
||||
dX: Float, dY: Float, actionState: Int, isCurrentlyActive: Boolean
|
||||
) {
|
||||
if (actionState == ItemTouchHelper.ACTION_STATE_SWIPE) {
|
||||
val maxSwipeDistance = viewHolder.itemView.width * SWIPE_THRESHOLD
|
||||
val swipeAmount = abs(dX)
|
||||
val direction = sign(dX)
|
||||
|
||||
// Limit maximum swipe distance
|
||||
val translationX = min(swipeAmount, maxSwipeDistance) * direction
|
||||
val alpha = ALPHA_FULL - min(swipeAmount, maxSwipeDistance) / maxSwipeDistance
|
||||
|
||||
viewHolder.itemView.translationX = translationX
|
||||
viewHolder.itemView.alpha = alpha
|
||||
|
||||
if (swipeAmount >= maxSwipeDistance && isCurrentlyActive) {
|
||||
returnViewToOriginalPosition(viewHolder)
|
||||
}
|
||||
} else {
|
||||
super.onChildDraw(c, recyclerView, viewHolder, dX, dY, actionState, isCurrentlyActive)
|
||||
}
|
||||
}
|
||||
|
||||
private fun returnViewToOriginalPosition(viewHolder: RecyclerView.ViewHolder) {
|
||||
mReturnAnimator?.takeIf { it.isRunning }?.cancel()
|
||||
|
||||
mReturnAnimator = ValueAnimator.ofFloat(viewHolder.itemView.translationX, 0f).apply {
|
||||
addUpdateListener { animation ->
|
||||
val value = animation.animatedValue as Float
|
||||
viewHolder.itemView.translationX = value
|
||||
viewHolder.itemView.alpha = 1f - abs(value) / (viewHolder.itemView.width * SWIPE_THRESHOLD)
|
||||
}
|
||||
interpolator = DecelerateInterpolator()
|
||||
duration = ANIMATION_DURATION
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSelectedChanged(viewHolder: RecyclerView.ViewHolder?, actionState: Int) {
|
||||
if (actionState != ItemTouchHelper.ACTION_STATE_IDLE && viewHolder is ItemTouchHelperViewHolder) {
|
||||
viewHolder.onItemSelected()
|
||||
}
|
||||
super.onSelectedChanged(viewHolder, actionState)
|
||||
}
|
||||
|
||||
override fun clearView(recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder) {
|
||||
super.clearView(recyclerView, viewHolder)
|
||||
viewHolder.itemView.alpha = ALPHA_FULL
|
||||
if (viewHolder is ItemTouchHelperViewHolder) {
|
||||
viewHolder.onItemClear()
|
||||
}
|
||||
mAdapter.onItemMoveCompleted()
|
||||
}
|
||||
|
||||
override fun getSwipeThreshold(viewHolder: RecyclerView.ViewHolder): Float {
|
||||
return 1.1f // Set a value greater than 1 to prevent default swipe delete
|
||||
}
|
||||
|
||||
override fun getSwipeEscapeVelocity(defaultValue: Float): Float {
|
||||
return defaultValue * 10 // Increase swipe escape velocity to make swipe harder to trigger
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ALPHA_FULL = 1.0f
|
||||
private const val SWIPE_THRESHOLD = 0.25f
|
||||
private const val ANIMATION_DURATION: Long = 200
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,128 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.webkit.WebView
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.compose.VersionInfoBlock
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.databinding.ActivityAboutBinding
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class AboutActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityAboutBinding.inflate(layoutInflater) }
|
||||
class AboutActivity : BaseComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_about))
|
||||
}
|
||||
|
||||
binding.layoutSoureCcode.setOnClickListener {
|
||||
Utils.openUri(this, AppConfig.APP_URL)
|
||||
}
|
||||
@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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,25 +1,45 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import androidx.activity.viewModels
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityAppPickerBinding
|
||||
import com.v2ray.ang.compose.AppDivider
|
||||
import com.v2ray.ang.compose.AppListItem
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.verticalScrollbar
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.util.AppManagerUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.Collator
|
||||
import com.v2ray.ang.viewmodel.AppPickerViewModel
|
||||
|
||||
class AppPickerActivity : BaseComponentActivity() {
|
||||
|
||||
class AppPickerActivity : BaseActivity() {
|
||||
companion object {
|
||||
private const val EXTRA_SELECTED_PACKAGES = "selected_packages"
|
||||
private const val EXTRA_PICKER_TITLE = "picker_title"
|
||||
@@ -38,160 +58,141 @@ class AppPickerActivity : BaseActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
private val binding by lazy { ActivityAppPickerBinding.inflate(layoutInflater) }
|
||||
private val initialSelectedPackages by lazy {
|
||||
intent.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
|
||||
}
|
||||
private val selectedPackages = LinkedHashSet<String>()
|
||||
private var appsAll: List<AppInfo> = emptyList()
|
||||
private val adapter = AppSelectorAdapter(selectedPackages)
|
||||
private val viewModel: AppPickerViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = resolveScreenTitle())
|
||||
|
||||
selectedPackages.addAll(initialSelectedPackages)
|
||||
setupRecyclerView()
|
||||
loadApps()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_app_picker, menu)
|
||||
|
||||
val searchItem = menu.findItem(R.id.search_view)
|
||||
if (searchItem != null) {
|
||||
val searchView = searchItem.actionView as SearchView
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
filterApps(newText.orEmpty())
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.select_all -> {
|
||||
selectAllVisible()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.invert_selection -> {
|
||||
invertVisibleSelection()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
val initial = intent.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
|
||||
viewModel.initialize(initial)
|
||||
viewModel.loadApps(this)
|
||||
}
|
||||
|
||||
override fun finish() {
|
||||
setResult(
|
||||
RESULT_OK,
|
||||
Intent().apply {
|
||||
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, getSelectedPackages())
|
||||
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, ArrayList(viewModel.getSelectedPackages()))
|
||||
}
|
||||
)
|
||||
super.finish()
|
||||
}
|
||||
|
||||
private fun setupRecyclerView() {
|
||||
binding.recyclerView.adapter = adapter
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
||||
}
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
val apps by viewModel.displayedApps.collectAsStateWithLifecycle()
|
||||
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
||||
val selectedPackages by viewModel.selectedPackages.collectAsStateWithLifecycle()
|
||||
|
||||
@SuppressLint("UseCompatLoadingForDrawables")
|
||||
private fun createSpecialItemUnidentified(): AppInfo {
|
||||
val icon = requireNotNull(
|
||||
getDrawable(android.R.drawable.ic_menu_help)
|
||||
?: getDrawable(android.R.drawable.sym_def_app_icon)
|
||||
) { "No fallback drawable available" }
|
||||
return AppInfo(
|
||||
appName = getString(R.string.app_picker_unknown_app),
|
||||
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
|
||||
appIcon = icon,
|
||||
isSystemApp = false,
|
||||
isSelected = 0
|
||||
AppPickerScreen(
|
||||
title = resolveScreenTitle(),
|
||||
apps = apps,
|
||||
isLoading = isLoading,
|
||||
selectedPackages = selectedPackages,
|
||||
onBackClick = { finish() },
|
||||
onToggleApp = { viewModel.toggleApp(it) },
|
||||
onSearch = { viewModel.filterApps(it) },
|
||||
onSelectAll = { viewModel.selectAll() },
|
||||
onInvertSelection = { viewModel.invertSelection() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun loadApps() {
|
||||
showLoading()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val apps = withContext(Dispatchers.IO) {
|
||||
val appsList = AppManagerUtil.loadNetworkAppList(this@AppPickerActivity)
|
||||
val sortedApps = sortApps(appsList)
|
||||
listOf(createSpecialItemUnidentified()) + sortedApps
|
||||
}
|
||||
|
||||
appsAll = apps
|
||||
updateDisplayedApps(apps)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e("AppPickerActivity", "Failed to load app list", e)
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun filterApps(content: String) {
|
||||
val key = content.uppercase()
|
||||
val filteredApps = appsAll.filter { app ->
|
||||
key.isEmpty() || matchesSearch(app, key)
|
||||
}
|
||||
updateDisplayedApps(filteredApps)
|
||||
}
|
||||
|
||||
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
|
||||
val collator = Collator.getInstance()
|
||||
return apps.sortedWith { p1, p2 ->
|
||||
val p1Selected = selectedPackages.contains(p1.packageName)
|
||||
val p2Selected = selectedPackages.contains(p2.packageName)
|
||||
when {
|
||||
p1Selected && !p2Selected -> -1
|
||||
!p1Selected && p2Selected -> 1
|
||||
p1.isSystemApp && !p2.isSystemApp -> 1
|
||||
!p1.isSystemApp && p2.isSystemApp -> -1
|
||||
else -> collator.compare(p1.appName, p2.appName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun matchesSearch(app: AppInfo, keyword: String): Boolean {
|
||||
return app.appName.uppercase().contains(keyword) || app.packageName.uppercase().contains(keyword)
|
||||
}
|
||||
|
||||
private fun updateDisplayedApps(apps: List<AppInfo>) {
|
||||
adapter.submitList(apps)
|
||||
}
|
||||
|
||||
private fun selectAllVisible() {
|
||||
adapter.apps.forEach { app -> selectedPackages.add(app.packageName) }
|
||||
adapter.refreshSelection()
|
||||
}
|
||||
|
||||
private fun invertVisibleSelection() {
|
||||
adapter.apps.forEach { app ->
|
||||
if (selectedPackages.contains(app.packageName)) {
|
||||
selectedPackages.remove(app.packageName)
|
||||
} else {
|
||||
selectedPackages.add(app.packageName)
|
||||
}
|
||||
}
|
||||
adapter.refreshSelection()
|
||||
}
|
||||
|
||||
private fun getSelectedPackages(): ArrayList<String> {
|
||||
return ArrayList(selectedPackages.sorted())
|
||||
}
|
||||
|
||||
private fun resolveScreenTitle(): String {
|
||||
return intent.getStringExtra(EXTRA_PICKER_TITLE) ?: getString(R.string.per_app_proxy_settings)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AppPickerScreen(
|
||||
title: String,
|
||||
apps: List<AppInfo>,
|
||||
isLoading: Boolean,
|
||||
selectedPackages: Set<String>,
|
||||
onBackClick: () -> Unit,
|
||||
onToggleApp: (String) -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
onInvertSelection: () -> Unit
|
||||
) {
|
||||
var showSearch by rememberSaveable { mutableStateOf(false) }
|
||||
var searchQuery by rememberSaveable { mutableStateOf("") }
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(searchQuery) {
|
||||
onSearch(searchQuery)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = title,
|
||||
onBackClick = onBackClick,
|
||||
isLoading = isLoading,
|
||||
isSearchActive = showSearch,
|
||||
searchQuery = searchQuery,
|
||||
onSearchQueryChange = { query ->
|
||||
searchQuery = query
|
||||
},
|
||||
onSearchClose = {
|
||||
searchQuery = ""
|
||||
showSearch = false
|
||||
},
|
||||
searchPlaceholder = stringResource(R.string.menu_item_search),
|
||||
actions = {
|
||||
if (!showSearch) {
|
||||
IconButton(onClick = { showSearch = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_search_24dp),
|
||||
contentDescription = stringResource(R.string.menu_item_search)
|
||||
)
|
||||
}
|
||||
}
|
||||
Box {
|
||||
IconButton(onClick = { showMenu = true }) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_more_vert_24dp),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showMenu,
|
||||
onDismissRequest = { showMenu = false },
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_select_all)) },
|
||||
onClick = { showMenu = false; onSelectAll() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_invert_selection)) },
|
||||
onClick = { showMenu = false; onInvertSelection() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(listState)
|
||||
) {
|
||||
items(items = apps, key = { it.packageName }) { app ->
|
||||
val checked = selectedPackages.contains(app.packageName)
|
||||
AppListItem(
|
||||
appName = app.appName,
|
||||
packageName = app.packageName,
|
||||
icon = app.appIcon,
|
||||
checked = checked,
|
||||
onCheckedChange = { onToggleApp(app.packageName) }
|
||||
)
|
||||
AppDivider()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
|
||||
class AppSelectorAdapter(
|
||||
private val selectedPackages: MutableSet<String>
|
||||
) : RecyclerView.Adapter<AppSelectorAdapter.AppViewHolder>() {
|
||||
|
||||
var apps: List<AppInfo> = emptyList()
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
|
||||
val binding = ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return AppViewHolder(binding)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = apps.size
|
||||
|
||||
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
|
||||
holder.bind(apps[position])
|
||||
}
|
||||
|
||||
fun submitList(newApps: List<AppInfo>) {
|
||||
apps = newApps
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
fun refreshSelection() {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
inner class AppViewHolder(private val binding: ItemRecyclerBypassListBinding) : RecyclerView.ViewHolder(binding.root),
|
||||
View.OnClickListener {
|
||||
private lateinit var appInfo: AppInfo
|
||||
|
||||
fun bind(item: AppInfo) {
|
||||
appInfo = item
|
||||
binding.icon.setImageDrawable(item.appIcon)
|
||||
binding.name.text = if (item.isSystemApp) {
|
||||
String.format("** %s", item.appName)
|
||||
} else {
|
||||
item.appName
|
||||
}
|
||||
binding.packageName.text = item.packageName
|
||||
binding.checkBox.isChecked = selectedPackages.contains(item.packageName)
|
||||
itemView.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
val packageName = appInfo.packageName
|
||||
if (selectedPackages.contains(packageName)) {
|
||||
selectedPackages.remove(packageName)
|
||||
} else {
|
||||
selectedPackages.add(packageName)
|
||||
}
|
||||
binding.checkBox.isChecked = selectedPackages.contains(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,28 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.app.AlertDialog
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.tencent.mmkv.MMKV
|
||||
@@ -10,8 +30,11 @@ import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.WEBDAV_BACKUP_FILE_NAME
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityBackupBinding
|
||||
import com.v2ray.ang.databinding.DialogWebdavBinding
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.InputDialog
|
||||
import com.v2ray.ang.compose.InputField
|
||||
import com.v2ray.ang.compose.SelectListDialog
|
||||
import com.v2ray.ang.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.dto.entities.WebDavConfig
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
@@ -22,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<out String> by lazy {
|
||||
private val isLoadingState = MutableStateFlow(false)
|
||||
private val webDavConfigState = MutableStateFlow(MmkvManager.decodeWebDavConfig())
|
||||
|
||||
private val configBackupOptions: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.config_backup_options)
|
||||
}
|
||||
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore))
|
||||
}
|
||||
|
||||
binding.layoutBackup.setOnClickListener {
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.title_configuration_backup)
|
||||
.setItems(config_backup_options) { dialog, which ->
|
||||
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<Boolean>,
|
||||
webDavConfigState: StateFlow<WebDavConfig?>,
|
||||
backupOptions: List<String>,
|
||||
onBackupOptionSelected: (Int) -> Unit,
|
||||
onShareClick: () -> Unit,
|
||||
restoreOptions: List<String>,
|
||||
onRestoreOptionSelected: (Int) -> Unit,
|
||||
onWebDavSave: (WebDavConfig) -> Unit,
|
||||
onBackClick: () -> Unit
|
||||
) {
|
||||
val isLoading by isLoadingState.collectAsState()
|
||||
val currentWebDavConfig by webDavConfigState.collectAsState()
|
||||
var showBackupDialog by remember { mutableStateOf(false) }
|
||||
var showRestoreDialog by remember { mutableStateOf(false) }
|
||||
var showWebDavDialog by remember { mutableStateOf(false) }
|
||||
|
||||
MmkvManager.decodeWebDavConfig()?.let { cfg ->
|
||||
dialogBinding.etWebdavUrl.setText(cfg.baseUrl)
|
||||
dialogBinding.etWebdavUser.setText(cfg.username ?: "")
|
||||
dialogBinding.etWebdavPass.setText(cfg.password ?: "")
|
||||
dialogBinding.etWebdavRemotePath.setText(cfg.remoteBasePath ?: "/")
|
||||
val webDavSummary = if (currentWebDavConfig != null && currentWebDavConfig!!.baseUrl.isNotEmpty()) {
|
||||
"${currentWebDavConfig!!.baseUrl} | ${currentWebDavConfig!!.username ?: ""}"
|
||||
} else null
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_configuration_backup_restore),
|
||||
onBackClick = onBackClick,
|
||||
isLoading = isLoading
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
) {
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_backup_24dp),
|
||||
title = stringResource(R.string.title_configuration_backup),
|
||||
onClick = { showBackupDialog = true }
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_share_24dp),
|
||||
title = stringResource(R.string.title_configuration_share),
|
||||
onClick = onShareClick
|
||||
)
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_restore_24dp),
|
||||
title = stringResource(R.string.title_configuration_restore),
|
||||
onClick = { showRestoreDialog = true }
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
SettingsMenuItem(
|
||||
icon = painterResource(R.drawable.ic_settings_24dp),
|
||||
title = stringResource(R.string.title_webdav_config_setting),
|
||||
subtitle = webDavSummary,
|
||||
onClick = { showWebDavDialog = true }
|
||||
)
|
||||
}
|
||||
|
||||
AlertDialog.Builder(this)
|
||||
.setTitle(R.string.title_webdav_config_setting)
|
||||
.setView(dialogBinding.root)
|
||||
.setPositiveButton(R.string.menu_item_save_config) { _, _ ->
|
||||
val url = dialogBinding.etWebdavUrl.text.toString().trim()
|
||||
val user = dialogBinding.etWebdavUser.text.toString().trim().ifEmpty { null }
|
||||
val pass = dialogBinding.etWebdavPass.text.toString()
|
||||
val remotePath = dialogBinding.etWebdavRemotePath.text.toString().trim().ifEmpty { AppConfig.WEBDAV_BACKUP_DIR }
|
||||
val cfg = WebDavConfig(baseUrl = url, username = user, password = pass, remoteBasePath = remotePath)
|
||||
MmkvManager.encodeWebDavConfig(cfg)
|
||||
toastSuccess(R.string.toast_success)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.show()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.FrameLayout
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.appbar.MaterialToolbar
|
||||
import com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.helper.CustomDividerItemDecoration
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
|
||||
/**
|
||||
* BaseActivity provides common helpers and UI wiring used across the app's activities.
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Inflate a shared base layout that contains a toolbar and a content container.
|
||||
* - Provide convenient overloads of `setContentViewWithToolbar` to attach child layouts or
|
||||
* view-binding roots into the base container and initialize the toolbar.
|
||||
* - Expose a global in-layout `ProgressBar` (cached) with `showLoading()` / `hideLoading()` helpers.
|
||||
* - Provide a helper to add a custom divider to RecyclerViews.
|
||||
* - Wrap base context according to user locale settings.
|
||||
*/
|
||||
abstract class BaseActivity : AppCompatActivity() {
|
||||
// Progress indicator that sits at the bottom of the toolbar
|
||||
private var progressBar: LinearProgressIndicator? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(true)
|
||||
if (!Utils.getDarkModeStatus(this)) {
|
||||
WindowCompat.getInsetsController(window, window.decorView).apply {
|
||||
isAppearanceLightStatusBars = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle action bar item selections.
|
||||
*
|
||||
* Currently this handles the home/up button by delegating to the activity's
|
||||
* onBackPressedDispatcher to provide consistent back navigation behavior.
|
||||
*
|
||||
* @param item the selected menu item
|
||||
* @return true if the event was handled, otherwise delegates to the superclass
|
||||
*/
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
android.R.id.home -> {
|
||||
// Handles the home button press by delegating to the onBackPressedDispatcher.
|
||||
// This ensures consistent back navigation behavior.
|
||||
onBackPressedDispatcher.onBackPressed()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap the base context with the user's locale settings.
|
||||
*
|
||||
* This ensures resources are loaded using the configured locale.
|
||||
*
|
||||
* @param newBase the original base context to wrap
|
||||
*/
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom divider drawable to the provided RecyclerView.
|
||||
*
|
||||
* This is a convenience helper that constructs a [CustomDividerItemDecoration]
|
||||
* using the given drawable resource id and adds it to the RecyclerView.
|
||||
*
|
||||
* @param recyclerView the target RecyclerView
|
||||
* @param context the context used to resolve resources (may be activity or application context)
|
||||
* @param drawableResId the drawable resource id to use as the divider
|
||||
* @param orientation one of [DividerItemDecoration.VERTICAL] or [DividerItemDecoration.HORIZONTAL]
|
||||
*
|
||||
* @throws IllegalArgumentException if the drawable resource cannot be found
|
||||
*/
|
||||
protected fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, context: Context?, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
|
||||
// Get the drawable from resources
|
||||
val drawable = ContextCompat.getDrawable(context!!, drawableResId)
|
||||
requireNotNull(drawable) { "Drawable resource not found" }
|
||||
|
||||
// Create a DividerItemDecoration with the specified orientation
|
||||
val dividerItemDecoration = CustomDividerItemDecoration(drawable, orientation)
|
||||
|
||||
// Add the divider to the RecyclerView
|
||||
recyclerView.addItemDecoration(dividerItemDecoration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Configure the toolbar instance using the default toolbar id if null is passed.
|
||||
*
|
||||
* This helper will set the toolbar as the action bar and configure the up button
|
||||
* visibility plus optional title.
|
||||
*
|
||||
* @param toolbar the toolbar instance to configure (may be null, in which case the view
|
||||
* with id R.id.toolbar in the activity content will be used)
|
||||
* @param showHomeAsUp whether the home/up affordance should be shown (default true)
|
||||
* @param title optional title to set on the activity
|
||||
*/
|
||||
protected fun setupToolbar(toolbar: Toolbar?, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
|
||||
val tb = toolbar ?: findViewById<Toolbar?>(R.id.toolbar)
|
||||
tb?.let {
|
||||
setSupportActionBar(it)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
|
||||
title?.let { t -> this.title = t }
|
||||
}
|
||||
progressBar = findViewById(R.id.progress_bar)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflate the shared base layout, attach the child layout resource into the base
|
||||
* content container, cache the in-layout ProgressBar and configure the toolbar.
|
||||
*
|
||||
* Typical usage in subclasses:
|
||||
* setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = "Settings")
|
||||
*
|
||||
* @param layoutResId child layout resource to inflate into the base content container
|
||||
* @param showHomeAsUp whether to show the up/home affordance on the toolbar (default true)
|
||||
* @param title optional activity title to set on the toolbar
|
||||
*/
|
||||
protected fun setContentViewWithToolbar(layoutResId: Int, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
|
||||
val base = LayoutInflater.from(this).inflate(R.layout.activity_base, null)
|
||||
val container = base.findViewById<FrameLayout>(R.id.content_container)
|
||||
LayoutInflater.from(this).inflate(layoutResId, container, true)
|
||||
progressBar = base.findViewById(R.id.progress_bar)
|
||||
super.setContentView(base)
|
||||
setupToolbar(base, showHomeAsUp, title)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inflate the shared base layout, attach the provided child view (commonly a view-binding root)
|
||||
* into the base content container, cache the in-layout ProgressBar and configure the toolbar.
|
||||
*
|
||||
* Typical usage with view binding:
|
||||
* setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "...")
|
||||
*
|
||||
* @param childView the already-inflated child view to add to the base content container
|
||||
* @param showHomeAsUp whether to show the up/home affordance on the toolbar (default true)
|
||||
* @param title optional activity title to set on the toolbar
|
||||
*/
|
||||
protected fun setContentViewWithToolbar(childView: View, showHomeAsUp: Boolean = true, title: CharSequence? = null) {
|
||||
val base = LayoutInflater.from(this).inflate(R.layout.activity_base, null)
|
||||
val container = base.findViewById<FrameLayout>(R.id.content_container)
|
||||
container.addView(childView, ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT))
|
||||
progressBar = base.findViewById(R.id.progress_bar)
|
||||
super.setContentView(base)
|
||||
setupToolbar(base, showHomeAsUp, title)
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal helper that configures the MaterialToolbar found in the inflated base root.
|
||||
*
|
||||
* @param baseRoot the root view of the inflated base layout
|
||||
* @param showHomeAsUp whether to show the up/home affordance
|
||||
* @param title optional title to set on the support action bar
|
||||
*/
|
||||
private fun setupToolbar(baseRoot: View, showHomeAsUp: Boolean, title: CharSequence?) {
|
||||
val toolbar = baseRoot.findViewById<MaterialToolbar>(R.id.toolbar)
|
||||
toolbar?.let {
|
||||
setSupportActionBar(it)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
|
||||
title?.let { t -> supportActionBar?.title = t }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Show the base layout's ProgressBar.
|
||||
*
|
||||
* This method is safe to call from background threads; the visibility change will
|
||||
* be posted to the UI thread via [runOnUiThread]. If the base layout was not set yet
|
||||
* (progressBar == null) the call is a no-op.
|
||||
*/
|
||||
protected fun showLoading() {
|
||||
runOnUiThread {
|
||||
progressBar?.visibility = View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Hide the base layout's ProgressBar.
|
||||
*
|
||||
* Safe to call from background threads. No-op if the progress bar hasn't been cached.
|
||||
*/
|
||||
protected fun hideLoading() {
|
||||
runOnUiThread {
|
||||
progressBar?.visibility = View.GONE
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when the base ProgressBar is currently visible.
|
||||
*
|
||||
* @return true if the progress bar exists and its visibility is VISIBLE
|
||||
*/
|
||||
protected fun isLoadingVisible(): Boolean {
|
||||
return progressBar?.visibility == View.VISIBLE
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.v2ray.ang.compose.AppTheme
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.MyContextWrapper
|
||||
|
||||
abstract class BaseComponentActivity : ComponentActivity() {
|
||||
|
||||
override fun attachBaseContext(newBase: Context?) {
|
||||
super.attachBaseContext(MyContextWrapper.wrap(newBase ?: return, SettingsManager.getLocale()))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
setContent {
|
||||
AppTheme {
|
||||
ScreenContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
protected abstract fun ScreenContent()
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.recyclerview.widget.DividerItemDecoration
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import androidx.viewbinding.ViewBinding
|
||||
import com.v2ray.ang.helper.CustomDividerItemDecoration
|
||||
|
||||
abstract class BaseFragment<VB : ViewBinding> : Fragment() {
|
||||
private var _binding: VB? = null
|
||||
protected val binding: VB
|
||||
get() = _binding!!
|
||||
|
||||
protected abstract fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?): VB
|
||||
|
||||
override fun onCreateView(
|
||||
inflater: LayoutInflater,
|
||||
container: ViewGroup?,
|
||||
savedInstanceState: Bundle?
|
||||
): View {
|
||||
_binding = inflateBinding(inflater, container)
|
||||
return binding.root
|
||||
}
|
||||
|
||||
override fun onDestroyView() {
|
||||
super.onDestroyView()
|
||||
_binding = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a custom divider to a RecyclerView.
|
||||
*
|
||||
* @param recyclerView The target RecyclerView to which the divider will be added.
|
||||
* @param drawableResId The resource ID of the drawable to be used as the divider.
|
||||
* @param orientation The orientation of the divider (DividerItemDecoration.VERTICAL or DividerItemDecoration.HORIZONTAL).
|
||||
*/
|
||||
fun addCustomDividerToRecyclerView(recyclerView: RecyclerView, drawableResId: Int, orientation: Int = DividerItemDecoration.VERTICAL) {
|
||||
// Get the drawable from resources
|
||||
val drawable = ContextCompat.getDrawable(requireContext(), drawableResId)
|
||||
requireNotNull(drawable) { "Drawable resource not found" }
|
||||
|
||||
// Create a DividerItemDecoration with the specified orientation
|
||||
val dividerItemDecoration = CustomDividerItemDecoration(drawable, orientation)
|
||||
|
||||
// Add the divider to the RecyclerView
|
||||
recyclerView.addItemDecoration(dividerItemDecoration)
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,38 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.BuildConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.LocalAppSnackbar
|
||||
import com.v2ray.ang.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.compose.ToastType
|
||||
import com.v2ray.ang.compose.VersionInfoBlock
|
||||
import com.v2ray.ang.core.CoreNativeManager
|
||||
import com.v2ray.ang.databinding.ActivityCheckUpdateBinding
|
||||
import com.v2ray.ang.dto.CheckUpdateResult
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
@@ -18,62 +43,116 @@ import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class CheckUpdateActivity : BaseActivity() {
|
||||
|
||||
private val binding by lazy { ActivityCheckUpdateBinding.inflate(layoutInflater) }
|
||||
class CheckUpdateActivity : BaseComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.update_check_for_update))
|
||||
|
||||
binding.layoutCheckUpdate.setOnClickListener {
|
||||
checkForUpdates(binding.checkPreRelease.isChecked)
|
||||
}
|
||||
|
||||
binding.checkPreRelease.setOnCheckedChangeListener { _, isChecked ->
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, isChecked)
|
||||
}
|
||||
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<CheckUpdateResult?>(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showUpdateDialog && updateResult != null) {
|
||||
val result = updateResult!!
|
||||
AlertDialog(
|
||||
onDismissRequest = { showUpdateDialog = false },
|
||||
title = { Text(stringResource(R.string.update_new_version_found, result.latestVersion ?: "")) },
|
||||
text = { Text(result.releaseNotes ?: "") },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
showUpdateDialog = false
|
||||
result.downloadUrl?.let { Utils.openUri(context, it) }
|
||||
}) {
|
||||
Text(stringResource(R.string.update_now))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showUpdateDialog = false }) {
|
||||
Text(stringResource(android.R.string.cancel))
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.fragment.app.FragmentActivity
|
||||
import androidx.viewpager2.adapter.FragmentStateAdapter
|
||||
import com.v2ray.ang.dto.GroupMapItem
|
||||
|
||||
/**
|
||||
* Pager adapter for subscription groups.
|
||||
*/
|
||||
class GroupPagerAdapter(activity: FragmentActivity, var groups: List<GroupMapItem>) : FragmentStateAdapter(activity) {
|
||||
override fun getItemCount(): Int = groups.size
|
||||
override fun createFragment(position: Int) = GroupServerFragment.newInstance(groups[position].id)
|
||||
override fun getItemId(position: Int): Long = groups[position].id.hashCode().toLong()
|
||||
override fun containsItem(itemId: Long): Boolean = groups.any { it.id.hashCode().toLong() == itemId }
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun update(groups: List<GroupMapItem>) {
|
||||
this.groups = groups
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
@@ -1,326 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.recyclerview.widget.GridLayoutManager
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.MainAdapterListener
|
||||
import com.v2ray.ang.databinding.FragmentGroupServerBinding
|
||||
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.viewmodel.MainViewModel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
SwipeRefreshLayout.OnRefreshListener {
|
||||
private val ownerActivity: MainActivity
|
||||
get() = requireActivity() as MainActivity
|
||||
private val mainViewModel: MainViewModel by activityViewModels()
|
||||
private lateinit var adapter: MainRecyclerAdapter
|
||||
private var itemTouchHelper: ItemTouchHelper? = null
|
||||
private val subId: String by lazy { arguments?.getString(ARG_SUB_ID).orEmpty() }
|
||||
|
||||
private val share_method: Array<out String> by lazy {
|
||||
ownerActivity.resources.getStringArray(R.array.share_method)
|
||||
}
|
||||
private val share_method_more: Array<out String> by lazy {
|
||||
ownerActivity.resources.getStringArray(R.array.share_method_more)
|
||||
}
|
||||
private val launcher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
|
||||
if (SettingsChangeManager.consumeRestartService() && mainViewModel.isRunning.value == true) {
|
||||
ownerActivity.restartV2Ray()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ARG_SUB_ID = "subscriptionId"
|
||||
fun newInstance(subId: String) = GroupServerFragment().apply {
|
||||
arguments = Bundle().apply { putString(ARG_SUB_ID, subId) }
|
||||
}
|
||||
}
|
||||
|
||||
override fun inflateBinding(inflater: LayoutInflater, container: ViewGroup?) =
|
||||
FragmentGroupServerBinding.inflate(inflater, container, false)
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
|
||||
adapter = MainRecyclerAdapter(mainViewModel, ActivityAdapterListener())
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)) {
|
||||
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 2)
|
||||
} else {
|
||||
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 1)
|
||||
}
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, R.drawable.custom_divider)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
|
||||
itemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
||||
|
||||
binding.refreshLayout.isEnabled = false
|
||||
// binding.refreshLayout.setOnRefreshListener(this)
|
||||
// // Set the distance to trigger sync to 160dp
|
||||
// binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
|
||||
|
||||
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
|
||||
if (mainViewModel.subscriptionId != subId) {
|
||||
return@observe
|
||||
}
|
||||
// LogUtil.d(TAG, "GroupServerFragment updateListAction subId=$subId")
|
||||
adapter.setData(mainViewModel.serversCache, index)
|
||||
}
|
||||
|
||||
// LogUtil.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
mainViewModel.subscriptionIdChanged(subId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares server configuration
|
||||
* Displays a dialog with sharing options and executes the selected action
|
||||
* @param guid The server unique identifier
|
||||
* @param profile The server configuration
|
||||
* @param position The position in the list
|
||||
* @param shareOptions The list of share options
|
||||
* @param skip The number of options to skip
|
||||
*/
|
||||
private fun shareServer(guid: String, profile: ProfileItem, position: Int, shareOptions: List<String>, skip: Int) {
|
||||
AlertDialog.Builder(ownerActivity).setItems(shareOptions.toTypedArray()) { _, i ->
|
||||
try {
|
||||
when (i + skip) {
|
||||
0 -> showQRCode(guid)
|
||||
1 -> share2Clipboard(guid)
|
||||
2 -> shareFullContent(guid)
|
||||
3 -> editServer(guid, profile)
|
||||
4 -> removeServer(guid, position)
|
||||
else -> ownerActivity.toast("else")
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Error when sharing server", e)
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays QR code for the server configuration
|
||||
* @param guid The server unique identifier
|
||||
*/
|
||||
private fun showQRCode(guid: String) {
|
||||
val ivBinding = ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
|
||||
ivBinding.ivQcode.setImageBitmap(AngConfigManager.share2QRCode(guid))
|
||||
if (share_method.isNotEmpty()) {
|
||||
ivBinding.ivQcode.contentDescription = share_method[0]
|
||||
} else {
|
||||
ivBinding.ivQcode.contentDescription = "QR Code"
|
||||
}
|
||||
AlertDialog.Builder(ownerActivity).setView(ivBinding.root).show()
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares server configuration to clipboard
|
||||
* @param guid The server unique identifier
|
||||
*/
|
||||
private fun share2Clipboard(guid: String) {
|
||||
if (AngConfigManager.share2Clipboard(ownerActivity, guid) == 0) {
|
||||
ownerActivity.toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
ownerActivity.toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shares full server configuration content to clipboard
|
||||
* @param guid The server unique identifier
|
||||
*/
|
||||
private fun shareFullContent(guid: String) {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = AngConfigManager.shareFullContent2Clipboard(ownerActivity, guid)
|
||||
launch(Dispatchers.Main) {
|
||||
if (result == 0) {
|
||||
ownerActivity.toastSuccess(R.string.toast_success)
|
||||
} else {
|
||||
ownerActivity.toastError(R.string.toast_failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Edits server configuration
|
||||
* Opens appropriate editing interface based on configuration type
|
||||
* @param guid The server unique identifier
|
||||
* @param profile The server configuration
|
||||
*/
|
||||
private fun editServer(guid: String, profile: ProfileItem) {
|
||||
val activityClass = when (profile.configType) {
|
||||
EConfigType.CUSTOM -> ServerCustomConfigActivity::class.java
|
||||
EConfigType.POLICYGROUP -> ServerGroupActivity::class.java
|
||||
EConfigType.PROXYCHAIN -> ServerProxyChainActivity::class.java
|
||||
else -> ServerActivity::class.java
|
||||
}
|
||||
|
||||
val intent = Intent(ownerActivity, activityClass)
|
||||
.putExtra("guid", guid)
|
||||
.putExtra("isRunning", mainViewModel.isRunning.value)
|
||||
.putExtra("createConfigType", profile.configType.value)
|
||||
.putExtra("subscriptionId", subId)
|
||||
|
||||
launcher.launch(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes server configuration
|
||||
* Handles confirmation dialog and related checks
|
||||
* @param guid The server unique identifier
|
||||
* @param position The position in the list
|
||||
*/
|
||||
private fun removeServer(guid: String, position: Int) {
|
||||
if (guid == MmkvManager.getSelectServer()) {
|
||||
ownerActivity.toast(R.string.toast_action_not_allowed)
|
||||
return
|
||||
}
|
||||
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
||||
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
removeServerSub(guid, position)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
//do noting
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
removeServerSub(guid, position)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes the actual server removal process
|
||||
* @param guid The server unique identifier
|
||||
* @param position The position in the list
|
||||
*/
|
||||
private fun removeServerSub(guid: String, position: Int) {
|
||||
mainViewModel.removeServer(guid)
|
||||
adapter.removeServerSub(guid, position)
|
||||
ownerActivity.refreshGroupTabTitles()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the selected server
|
||||
* Updates UI and restarts service if needed
|
||||
* @param guid The server unique identifier to select
|
||||
*/
|
||||
private fun setSelectServer(guid: String) {
|
||||
val selected = MmkvManager.getSelectServer()
|
||||
if (guid != selected) {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
val fromPosition = mainViewModel.getPosition(selected.orEmpty())
|
||||
val toPosition = mainViewModel.getPosition(guid)
|
||||
adapter.setSelectServer(fromPosition, toPosition)
|
||||
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
ownerActivity.restartV2Ray()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private inner class ActivityAdapterListener : MainAdapterListener {
|
||||
override fun onEdit(guid: String, position: Int) {
|
||||
}
|
||||
|
||||
override fun onShare(url: String) {
|
||||
}
|
||||
|
||||
override fun onRefreshData() {
|
||||
}
|
||||
|
||||
override fun onRemove(guid: String, position: Int) {
|
||||
removeServer(guid, position)
|
||||
}
|
||||
|
||||
override fun onEdit(guid: String, position: Int, profile: ProfileItem) {
|
||||
editServer(guid, profile)
|
||||
}
|
||||
|
||||
override fun onSelectServer(guid: String) {
|
||||
setSelectServer(guid)
|
||||
}
|
||||
|
||||
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
|
||||
val isCustom = profile.configType.isComplexType()
|
||||
|
||||
val (shareOptions, skip) = if (more) {
|
||||
val options = if (isCustom) share_method_more.asList().takeLast(3) else share_method_more.asList()
|
||||
options to if (isCustom) 2 else 0
|
||||
} else {
|
||||
val options = if (isCustom) share_method.asList().takeLast(1) else share_method.asList()
|
||||
options to if (isCustom) 2 else 0
|
||||
}
|
||||
|
||||
shareServer(guid, profile, position, shareOptions, skip)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onRefresh() {
|
||||
ownerActivity.importConfigViaSub()
|
||||
//binding.refreshLayout.isRefreshing = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrolls to the currently selected server in the RecyclerView
|
||||
*/
|
||||
fun scrollToSelectedServer() {
|
||||
val selectedGuid = MmkvManager.getSelectServer()
|
||||
if (selectedGuid.isNullOrEmpty()) {
|
||||
ownerActivity.toast(R.string.title_file_chooser)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the position of the selected server
|
||||
val serversCache = mainViewModel.serversCache
|
||||
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
|
||||
val recyclerView = binding.recyclerView
|
||||
|
||||
if (position >= 0) {
|
||||
// Get the layout manager
|
||||
val layoutManager = recyclerView.layoutManager as? GridLayoutManager
|
||||
|
||||
if (layoutManager != null) {
|
||||
// Scroll to position with offset to center it on screen
|
||||
// First scroll to position, then adjust to center
|
||||
recyclerView.post {
|
||||
layoutManager.scrollToPositionWithOffset(position, recyclerView.height / 3)
|
||||
}
|
||||
} else {
|
||||
// Fallback to smooth scroll if layout manager is not GridLayoutManager
|
||||
recyclerView.smoothScrollToPosition(position)
|
||||
}
|
||||
} else {
|
||||
ownerActivity.toast(R.string.toast_server_not_found_in_group)
|
||||
}
|
||||
}
|
||||
}
|
||||
+8
-7
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.databinding.ItemRecyclerLogcatBinding
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.viewmodel.LogcatViewModel
|
||||
|
||||
class LogcatRecyclerAdapter(
|
||||
private val viewModel: LogcatViewModel,
|
||||
private val onLongClick: ((String) -> Boolean)? = null
|
||||
) : RecyclerView.Adapter<LogcatRecyclerAdapter.MainViewHolder>() {
|
||||
|
||||
override fun getItemCount() = viewModel.getAll().size
|
||||
|
||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||
try {
|
||||
val logs = viewModel.getAll()
|
||||
val log = logs[position]
|
||||
|
||||
if (log.isEmpty()) {
|
||||
holder.itemSubSettingBinding.logTag.text = ""
|
||||
holder.itemSubSettingBinding.logContent.text = ""
|
||||
} else {
|
||||
val content = log.split("):", limit = 2)
|
||||
holder.itemSubSettingBinding.logTag.text = content.first().split("(", limit = 2).first().trim()
|
||||
holder.itemSubSettingBinding.logContent.text = if (content.count() > 1) content.last().trim() else ""
|
||||
}
|
||||
|
||||
holder.itemView.setOnLongClickListener {
|
||||
onLongClick?.invoke(log) ?: false
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Error binding log view data", e)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
|
||||
return MainViewHolder(
|
||||
ItemRecyclerLogcatBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class MainViewHolder(val itemSubSettingBinding: ItemRecyclerLogcatBinding) : RecyclerView.ViewHolder(itemSubSettingBinding.root)
|
||||
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,236 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.MainAdapterListener
|
||||
import com.v2ray.ang.databinding.ItemRecyclerFooterBinding
|
||||
import com.v2ray.ang.databinding.ItemRecyclerMainBinding
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.dto.entities.ServersCache
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||
import com.v2ray.ang.viewmodel.MainViewModel
|
||||
import java.util.Collections
|
||||
|
||||
class MainRecyclerAdapter(
|
||||
private val mainViewModel: MainViewModel,
|
||||
private val adapterListener: MainAdapterListener?
|
||||
) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
|
||||
companion object {
|
||||
private const val VIEW_TYPE_ITEM = 1
|
||||
private const val VIEW_TYPE_FOOTER = 2
|
||||
}
|
||||
|
||||
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
|
||||
private var data: MutableList<ServersCache> = mutableListOf()
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
|
||||
data = newData?.toMutableList() ?: mutableListOf()
|
||||
|
||||
if (position >= 0 && position in data.indices) {
|
||||
notifyItemChanged(position)
|
||||
} else {
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = data.size + 1
|
||||
|
||||
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
|
||||
if (holder is MainViewHolder) {
|
||||
val context = holder.itemMainBinding.root.context
|
||||
val guid = data[position].guid
|
||||
val profile = data[position].profile
|
||||
|
||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
//Name address
|
||||
holder.itemMainBinding.tvName.text = profile.remarks
|
||||
holder.itemMainBinding.tvStatistics.text = getAddress(profile)
|
||||
holder.itemMainBinding.tvType.text = getProtocolDescription(profile)
|
||||
|
||||
//TestResult
|
||||
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
|
||||
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
|
||||
if ((aff?.testDelayMillis ?: 0L) < 0L) {
|
||||
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPingRed))
|
||||
} else {
|
||||
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPing))
|
||||
}
|
||||
|
||||
//layoutIndicator
|
||||
if (guid == MmkvManager.getSelectServer()) {
|
||||
holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorIndicator)
|
||||
} else {
|
||||
holder.itemMainBinding.layoutIndicator.setBackgroundResource(0)
|
||||
}
|
||||
|
||||
//subscription remarks
|
||||
val subRemarks = getSubscriptionRemarks(profile)
|
||||
holder.itemMainBinding.tvSubscription.text = subRemarks
|
||||
holder.itemMainBinding.layoutSubscription.visibility = if (subRemarks.isEmpty()) View.GONE else View.VISIBLE
|
||||
|
||||
//layout
|
||||
if (doubleColumnDisplay) {
|
||||
holder.itemMainBinding.layoutShare.visibility = View.GONE
|
||||
holder.itemMainBinding.layoutEdit.visibility = View.GONE
|
||||
holder.itemMainBinding.layoutRemove.visibility = View.GONE
|
||||
holder.itemMainBinding.layoutMore.visibility = View.VISIBLE
|
||||
|
||||
holder.itemMainBinding.layoutMore.setOnClickListener {
|
||||
adapterListener?.onShare(guid, profile, position, true)
|
||||
}
|
||||
} else {
|
||||
holder.itemMainBinding.layoutShare.visibility = View.VISIBLE
|
||||
holder.itemMainBinding.layoutEdit.visibility = View.VISIBLE
|
||||
holder.itemMainBinding.layoutRemove.visibility = View.VISIBLE
|
||||
holder.itemMainBinding.layoutMore.visibility = View.GONE
|
||||
|
||||
holder.itemMainBinding.layoutShare.setOnClickListener {
|
||||
adapterListener?.onShare(guid, profile, position, false)
|
||||
}
|
||||
|
||||
holder.itemMainBinding.layoutEdit.setOnClickListener {
|
||||
adapterListener?.onEdit(guid, position, profile)
|
||||
}
|
||||
holder.itemMainBinding.layoutRemove.setOnClickListener {
|
||||
adapterListener?.onRemove(guid, position)
|
||||
}
|
||||
}
|
||||
|
||||
holder.itemMainBinding.infoContainer.setOnClickListener {
|
||||
adapterListener?.onSelectServer(guid)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the server address information
|
||||
* Hides part of IP or domain information for privacy protection
|
||||
* @param profile The server configuration
|
||||
* @return Formatted address string
|
||||
*/
|
||||
private fun getAddress(profile: ProfileItem): String {
|
||||
return profile.description.nullIfBlank() ?: AngConfigManager.generateDescription(profile)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the subscription remarks information
|
||||
* @param profile The server configuration
|
||||
* @return Subscription remarks string, or empty string if none
|
||||
*/
|
||||
private fun getSubscriptionRemarks(profile: ProfileItem): String {
|
||||
val subRemarks =
|
||||
if (mainViewModel.subscriptionId.isEmpty())
|
||||
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
|
||||
else
|
||||
null
|
||||
return subRemarks?.toString() ?: ""
|
||||
}
|
||||
|
||||
private fun getProtocolDescription(profile: ProfileItem): String {
|
||||
if (profile.configType.isComplexType()) {
|
||||
return profile.configType.name
|
||||
}
|
||||
|
||||
val parts = mutableListOf<String>()
|
||||
parts.add(profile.configType.name)
|
||||
|
||||
// Transport: hide tcp or blank
|
||||
profile.network?.let { net ->
|
||||
if (net.isNotBlank() && !net.equals("tcp", ignoreCase = true)) {
|
||||
parts.add(net)
|
||||
}
|
||||
}
|
||||
|
||||
// Security: hide blank or tls
|
||||
profile.security?.let { sec ->
|
||||
if (sec.isNotBlank()) {
|
||||
if (profile.insecure == true && sec.equals("tls", ignoreCase = true)) {
|
||||
parts.add("$sec insecure") // TODO
|
||||
} else {
|
||||
parts.add(sec)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parts.joinToString(" / ")
|
||||
}
|
||||
|
||||
fun removeServerSub(guid: String, position: Int) {
|
||||
val idx = data.indexOfFirst { it.guid == guid }
|
||||
if (idx >= 0) {
|
||||
data.removeAt(idx)
|
||||
notifyItemRemoved(idx)
|
||||
notifyItemRangeChanged(idx, data.size - idx)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectServer(fromPosition: Int, toPosition: Int) {
|
||||
notifyItemChanged(fromPosition)
|
||||
notifyItemChanged(toPosition)
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
|
||||
return when (viewType) {
|
||||
VIEW_TYPE_ITEM ->
|
||||
MainViewHolder(ItemRecyclerMainBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
|
||||
else ->
|
||||
FooterViewHolder(ItemRecyclerFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int): Int {
|
||||
return if (position == data.size) {
|
||||
VIEW_TYPE_FOOTER
|
||||
} else {
|
||||
VIEW_TYPE_ITEM
|
||||
}
|
||||
}
|
||||
|
||||
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
fun onItemSelected() {
|
||||
itemView.setBackgroundColor(Color.LTGRAY)
|
||||
}
|
||||
|
||||
fun onItemClear() {
|
||||
itemView.setBackgroundColor(0)
|
||||
}
|
||||
}
|
||||
|
||||
class MainViewHolder(val itemMainBinding: ItemRecyclerMainBinding) :
|
||||
BaseViewHolder(itemMainBinding.root), ItemTouchHelperViewHolder
|
||||
|
||||
class FooterViewHolder(val itemFooterBinding: ItemRecyclerFooterBinding) :
|
||||
BaseViewHolder(itemFooterBinding.root)
|
||||
|
||||
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
|
||||
mainViewModel.swapServer(fromPosition, toPosition)
|
||||
if (fromPosition < data.size && toPosition < data.size) {
|
||||
Collections.swap(data, fromPosition, toPosition)
|
||||
}
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onItemMoveCompleted() {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
override fun onItemDismiss(position: Int) {
|
||||
}
|
||||
}
|
||||
@@ -1,322 +1,279 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.Toast
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.widget.SearchView
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.DropdownMenu
|
||||
import androidx.compose.material3.DropdownMenuItem
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.SwitchDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityBypassListBinding
|
||||
import com.v2ray.ang.compose.AppDivider
|
||||
import com.v2ray.ang.compose.AppListItem
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.colorFabActive
|
||||
import com.v2ray.ang.compose.verticalScrollbar
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastInfo
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.extension.v2RayApplication
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.AppManagerUtil
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
||||
import es.dmoral.toasty.Toasty
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.Collator
|
||||
|
||||
class PerAppProxyActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityBypassListBinding.inflate(layoutInflater) }
|
||||
class PerAppProxyActivity : BaseComponentActivity() {
|
||||
|
||||
private var adapter: PerAppProxyAdapter? = null
|
||||
private var appsAll: List<AppInfo>? = null
|
||||
private val viewModel: PerAppProxyViewModel by viewModels()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.per_app_proxy_settings))
|
||||
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
||||
|
||||
initList()
|
||||
|
||||
binding.switchPerAppProxy.setOnCheckedChangeListener { _, isChecked ->
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, isChecked)
|
||||
}
|
||||
binding.switchPerAppProxy.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false)
|
||||
|
||||
binding.switchBypassApps.setOnCheckedChangeListener { _, isChecked ->
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_BYPASS_APPS, isChecked)
|
||||
}
|
||||
binding.switchBypassApps.isChecked = MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS, false)
|
||||
|
||||
binding.layoutSwitchBypassAppsTips.setOnClickListener {
|
||||
Toasty.info(this, R.string.summary_pref_per_app_proxy, Toast.LENGTH_LONG, true).show()
|
||||
}
|
||||
viewModel.loadApps(this)
|
||||
}
|
||||
|
||||
private fun initList() {
|
||||
showLoading()
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
val apps by viewModel.displayedApps.collectAsStateWithLifecycle()
|
||||
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
||||
val blacklist by viewModel.blacklist.collectAsStateWithLifecycle()
|
||||
val perAppProxyEnabled by viewModel.perAppProxyEnabled.collectAsStateWithLifecycle()
|
||||
val bypassApps by viewModel.bypassApps.collectAsStateWithLifecycle()
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
val apps = withContext(Dispatchers.IO) {
|
||||
val appsList = AppManagerUtil.loadNetworkAppList(this@PerAppProxyActivity)
|
||||
|
||||
val blacklistSet = viewModel.getAll()
|
||||
if (blacklistSet.isNotEmpty()) {
|
||||
appsList.forEach { app ->
|
||||
app.isSelected = if (blacklistSet.contains(app.packageName)) 1 else 0
|
||||
}
|
||||
appsList.sortedWith { p1, p2 ->
|
||||
when {
|
||||
p1.isSelected > p2.isSelected -> -1
|
||||
p1.isSelected < p2.isSelected -> 1
|
||||
p1.isSystemApp > p2.isSystemApp -> 1
|
||||
p1.isSystemApp < p2.isSystemApp -> -1
|
||||
p1.appName.lowercase() > p2.appName.lowercase() -> 1
|
||||
p1.appName.lowercase() < p2.appName.lowercase() -> -1
|
||||
p1.packageName > p2.packageName -> 1
|
||||
p1.packageName < p2.packageName -> -1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val collator = Collator.getInstance()
|
||||
appsList.sortedWith(compareBy(collator) { it.appName })
|
||||
}
|
||||
}
|
||||
|
||||
appsAll = apps
|
||||
adapter = PerAppProxyAdapter(apps, viewModel)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(ANG_PACKAGE, "Error loading apps", e)
|
||||
} finally {
|
||||
hideLoading()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_bypass_list, menu)
|
||||
|
||||
val searchItem = menu.findItem(R.id.search_view)
|
||||
if (searchItem != null) {
|
||||
val searchView = searchItem.actionView as SearchView
|
||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
||||
|
||||
override fun onQueryTextChange(newText: String?): Boolean {
|
||||
filterProxyApp(newText.orEmpty())
|
||||
return false
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
|
||||
@SuppressLint("NotifyDataSetChanged")
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.select_all -> {
|
||||
selectAllApp()
|
||||
allowPerAppProxy()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.invert_selection -> {
|
||||
invertSelection()
|
||||
allowPerAppProxy()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.select_proxy_app -> {
|
||||
selectProxyAppAuto()
|
||||
allowPerAppProxy()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.import_proxy_app -> {
|
||||
importProxyApp()
|
||||
allowPerAppProxy()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.export_proxy_app -> {
|
||||
exportProxyApp()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
private fun selectAllApp() {
|
||||
adapter?.let { adapter ->
|
||||
val pkgNames = adapter.apps.map { it.packageName }
|
||||
val allSelected = pkgNames.all { viewModel.contains(it) }
|
||||
|
||||
if (allSelected) {
|
||||
viewModel.removeAll(pkgNames)
|
||||
} else {
|
||||
viewModel.addAll(pkgNames)
|
||||
}
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun invertSelection() {
|
||||
adapter?.let { adapter ->
|
||||
adapter.apps.forEach { app ->
|
||||
viewModel.toggle(app.packageName)
|
||||
}
|
||||
refreshData()
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectProxyAppAuto() {
|
||||
toast(R.string.msg_downloading_content)
|
||||
showLoading()
|
||||
|
||||
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
var content = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000
|
||||
)
|
||||
)
|
||||
if (content.isNullOrEmpty()) {
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
content = HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
) ?: ""
|
||||
}
|
||||
launch(Dispatchers.Main) {
|
||||
//LogUtil.i(AppConfig.TAG, content)
|
||||
selectProxyApp(content, true)
|
||||
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<AppInfo>,
|
||||
isLoading: Boolean,
|
||||
blacklist: Set<String>,
|
||||
perAppProxyEnabled: Boolean,
|
||||
bypassApps: Boolean,
|
||||
onBackClick: () -> Unit,
|
||||
onPerAppProxyChanged: (Boolean) -> Unit,
|
||||
onBypassAppsChanged: (Boolean) -> Unit,
|
||||
onInfoClick: () -> Unit,
|
||||
onToggleApp: (String) -> Unit,
|
||||
onSearch: (String) -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
onInvertSelection: () -> Unit,
|
||||
onSelectProxyAuto: () -> Unit,
|
||||
onImportProxyApp: () -> Unit,
|
||||
onExportProxyApp: () -> Unit
|
||||
) {
|
||||
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<AppInfo>()
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,77 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.databinding.ItemRecyclerBypassListBinding
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
||||
|
||||
class PerAppProxyAdapter(
|
||||
val apps: List<AppInfo>,
|
||||
val viewModel: PerAppProxyViewModel
|
||||
) : RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
|
||||
|
||||
companion object {
|
||||
private const val VIEW_TYPE_HEADER = 0
|
||||
private const val VIEW_TYPE_ITEM = 1
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
|
||||
if (holder is AppViewHolder) {
|
||||
val appInfo = apps[position - 1]
|
||||
holder.bind(appInfo)
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemCount() = apps.size + 1
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
|
||||
val ctx = parent.context
|
||||
|
||||
return when (viewType) {
|
||||
VIEW_TYPE_HEADER -> {
|
||||
val view = View(ctx)
|
||||
view.layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
0
|
||||
)
|
||||
BaseViewHolder(view)
|
||||
}
|
||||
|
||||
else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
|
||||
}
|
||||
}
|
||||
|
||||
override fun getItemViewType(position: Int) = if (position == 0) VIEW_TYPE_HEADER else VIEW_TYPE_ITEM
|
||||
|
||||
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
|
||||
|
||||
inner class AppViewHolder(private val itemBypassBinding: ItemRecyclerBypassListBinding) : BaseViewHolder(itemBypassBinding.root),
|
||||
View.OnClickListener {
|
||||
private lateinit var appInfo: AppInfo
|
||||
|
||||
fun bind(appInfo: AppInfo) {
|
||||
this.appInfo = appInfo
|
||||
|
||||
itemBypassBinding.icon.setImageDrawable(appInfo.appIcon)
|
||||
itemBypassBinding.name.text = if (appInfo.isSystemApp) {
|
||||
String.format("** %s", appInfo.appName)
|
||||
} else {
|
||||
appInfo.appName
|
||||
}
|
||||
|
||||
itemBypassBinding.packageName.text = appInfo.packageName
|
||||
itemBypassBinding.checkBox.isChecked = viewModel.contains(appInfo.packageName)
|
||||
|
||||
itemView.setOnClickListener(this)
|
||||
}
|
||||
|
||||
override fun onClick(v: View?) {
|
||||
val packageName = appInfo.packageName
|
||||
viewModel.toggle(packageName)
|
||||
itemBypassBinding.checkBox.isChecked = viewModel.contains(packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Intent
|
||||
|
||||
object ProfileEditorResult {
|
||||
|
||||
const val EXTRA_ACTION =
|
||||
"com.v2ray.ang.extra.PROFILE_EDITOR_ACTION"
|
||||
|
||||
const val EXTRA_GUID =
|
||||
"com.v2ray.ang.extra.PROFILE_EDITOR_GUID"
|
||||
|
||||
const val EXTRA_RESTART_SERVICE =
|
||||
"com.v2ray.ang.extra.PROFILE_EDITOR_RESTART_SERVICE"
|
||||
|
||||
const val ACTION_SAVED = "saved"
|
||||
const val ACTION_DELETED = "deleted"
|
||||
|
||||
fun Activity.finishSaved(
|
||||
guid: String,
|
||||
restartService: Boolean
|
||||
) {
|
||||
setResult(
|
||||
Activity.RESULT_OK,
|
||||
Intent().apply {
|
||||
putExtra(EXTRA_ACTION, ACTION_SAVED)
|
||||
putExtra(EXTRA_GUID, guid)
|
||||
putExtra(EXTRA_RESTART_SERVICE, restartService)
|
||||
}
|
||||
)
|
||||
finish()
|
||||
}
|
||||
|
||||
fun Activity.finishDeleted(guid: String) {
|
||||
setResult(
|
||||
Activity.RESULT_OK,
|
||||
Intent().apply {
|
||||
putExtra(EXTRA_ACTION, ACTION_DELETED)
|
||||
putExtra(EXTRA_GUID, guid)
|
||||
putExtra(EXTRA_RESTART_SERVICE, false)
|
||||
}
|
||||
)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -1,190 +1,297 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
|
||||
import com.v2ray.ang.AppConfig.TAG_PROXY
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityRoutingEditBinding
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.ConfirmDialog
|
||||
import com.v2ray.ang.compose.FormDropdownField
|
||||
import com.v2ray.ang.compose.FormTextField
|
||||
import com.v2ray.ang.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.compose.verticalScrollbar
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.extension.nullIfBlank
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.UUID
|
||||
|
||||
class RoutingEditActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
|
||||
class RoutingEditActivity : BaseComponentActivity() {
|
||||
private val position by lazy { intent.getIntExtra("position", -1) }
|
||||
private val processPickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||
if (result.resultCode == RESULT_OK) {
|
||||
val selectedPackages = AppPickerActivity.getSelectedPackages(result.data)
|
||||
binding.etProcess.text = Utils.getEditable(selectedPackages.joinToString(","))
|
||||
}
|
||||
}
|
||||
|
||||
private var initial: RulesetItem? = null
|
||||
private lateinit var outboundSuggestions: List<String>
|
||||
private var canUseProcess: Boolean = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
|
||||
|
||||
setupOutboundTagInput()
|
||||
setupProcessPicker()
|
||||
|
||||
val rulesetItem = SettingsManager.getRoutingRuleset(position)
|
||||
if (rulesetItem != null) {
|
||||
bindingServer(rulesetItem)
|
||||
} else {
|
||||
clearServer()
|
||||
}
|
||||
|
||||
SettingsManager.canUseProcessRouting().let { canUse ->
|
||||
binding.etProcess.isEnabled = canUse
|
||||
binding.btnProcessPicker.isEnabled = canUse
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupProcessPicker() {
|
||||
binding.btnProcessPicker.setOnClickListener {
|
||||
processPickerLauncher.launch(
|
||||
AppPickerActivity.createIntent(
|
||||
context = this,
|
||||
selectedPackages = getSelectedProcessPackages(),
|
||||
title = getString(R.string.routing_settings_process)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSelectedProcessPackages(): List<String> {
|
||||
return binding.etProcess.text
|
||||
.toString()
|
||||
.split(",")
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the AutoCompleteTextView for outbound tag:
|
||||
* suggestions = built-in tags (proxy/direct/block) + all existing profile remarks.
|
||||
* The dropdown button triggers showing the full list without typing.
|
||||
*/
|
||||
private fun setupOutboundTagInput() {
|
||||
initial = SettingsManager.getRoutingRuleset(position)
|
||||
val profileRemarks = SettingsManager.getProfileRemarks()
|
||||
|
||||
val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
|
||||
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
|
||||
binding.spOutboundTag.setAdapter(adapter)
|
||||
// threshold=0 means show all suggestions even before typing; still need focus+request
|
||||
binding.spOutboundTag.threshold = 0
|
||||
|
||||
// Dropdown arrow button shows the full suggestion list
|
||||
binding.btnOutboundTagDropdown.setOnClickListener {
|
||||
binding.spOutboundTag.requestFocus()
|
||||
binding.spOutboundTag.showDropDown()
|
||||
}
|
||||
// Also show on field click when it already has focus
|
||||
binding.spOutboundTag.setOnClickListener {
|
||||
binding.spOutboundTag.showDropDown()
|
||||
}
|
||||
outboundSuggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
|
||||
canUseProcess = SettingsManager.canUseProcessRouting()
|
||||
}
|
||||
|
||||
private fun bindingServer(rulesetItem: RulesetItem): Boolean {
|
||||
binding.etRemarks.text = Utils.getEditable(rulesetItem.remarks)
|
||||
binding.chkLocked.isChecked = rulesetItem.locked == true
|
||||
binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(","))
|
||||
binding.etIp.text = Utils.getEditable(rulesetItem.ip?.joinToString(","))
|
||||
binding.etProcess.text = Utils.getEditable(rulesetItem.process?.joinToString(","))
|
||||
binding.etPort.text = Utils.getEditable(rulesetItem.port)
|
||||
binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
|
||||
binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
|
||||
// Set text directly; filter won't fire because we're not using setText(filter=true)
|
||||
binding.spOutboundTag.setText(rulesetItem.outboundTag, false)
|
||||
return true
|
||||
@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<String>,
|
||||
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)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<out String> by lazy {
|
||||
resources.getStringArray(R.array.routing_domain_strategy)
|
||||
}
|
||||
private val preset_rulesets: Array<out String> by lazy {
|
||||
resources.getStringArray(R.array.preset_rulesets)
|
||||
}
|
||||
private val domainStrategyState = MutableStateFlow("")
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title))
|
||||
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<String>,
|
||||
onBackClick: () -> Unit,
|
||||
onAddRule: () -> Unit,
|
||||
onEditRule: (Int) -> Unit,
|
||||
onDomainStrategySelected: (String) -> Unit,
|
||||
onImportPredefined: (Int) -> Unit,
|
||||
onImportClipboard: () -> Unit,
|
||||
onImportQRcode: () -> Unit,
|
||||
onExportClipboard: () -> Unit
|
||||
) {
|
||||
val rulesets by viewModel.rulesetsFlow.collectAsStateWithLifecycle()
|
||||
val domainStrategy by domainStrategyState.collectAsState()
|
||||
var showMenu by remember { mutableStateOf(false) }
|
||||
var showDomainDialog by remember { mutableStateOf(false) }
|
||||
var showPresetDialog by remember { mutableStateOf(false) }
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ItemRecyclerRoutingSettingBinding
|
||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
|
||||
|
||||
class RoutingSettingRecyclerAdapter(
|
||||
private val viewModel: RoutingSettingsViewModel,
|
||||
private val adapterListener: BaseAdapterListener?
|
||||
) : RecyclerView.Adapter<RoutingSettingRecyclerAdapter.MainViewHolder>(),
|
||||
ItemTouchHelperAdapter {
|
||||
|
||||
override fun getItemCount() = viewModel.getAll().size
|
||||
|
||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||
val rulesets = viewModel.getAll()
|
||||
val ruleset = rulesets[position]
|
||||
|
||||
holder.itemRoutingSettingBinding.remarks.text = ruleset.remarks
|
||||
holder.itemRoutingSettingBinding.domainIp.text = (ruleset.domain ?: ruleset.ip ?: ruleset.process ?: ruleset.port)?.toString()
|
||||
holder.itemRoutingSettingBinding.outboundTag.text = ruleset.outboundTag
|
||||
holder.itemRoutingSettingBinding.chkEnable.isChecked = ruleset.enabled
|
||||
holder.itemRoutingSettingBinding.imgLocked.isVisible = ruleset.locked == true
|
||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
holder.itemRoutingSettingBinding.layoutEdit.setOnClickListener {
|
||||
adapterListener?.onEdit("", position)
|
||||
}
|
||||
|
||||
holder.itemRoutingSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
|
||||
if (!it.isPressed) return@setOnCheckedChangeListener
|
||||
ruleset.enabled = isChecked
|
||||
viewModel.update(position, ruleset)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
|
||||
return MainViewHolder(
|
||||
ItemRecyclerRoutingSettingBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class MainViewHolder(val itemRoutingSettingBinding: ItemRecyclerRoutingSettingBinding) :
|
||||
BaseViewHolder(itemRoutingSettingBinding.root), ItemTouchHelperViewHolder
|
||||
|
||||
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
fun onItemSelected() {
|
||||
itemView.setBackgroundColor(Color.LTGRAY)
|
||||
}
|
||||
|
||||
fun onItemClear() {
|
||||
itemView.setBackgroundColor(0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
|
||||
viewModel.swap(fromPosition, toPosition)
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onItemMoveCompleted() {
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
override fun onItemDismiss(position: Int) {
|
||||
}
|
||||
}
|
||||
@@ -2,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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum class ScannerUiState {
|
||||
IDLE,
|
||||
ACTIVE
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScannerScreen(
|
||||
uiState: ScannerUiState,
|
||||
onBackClick: () -> Unit,
|
||||
onSelectPhoto: () -> Unit,
|
||||
onStartScan: () -> Unit,
|
||||
onStopScan: () -> Unit,
|
||||
onScanResult: (String) -> Unit
|
||||
) {
|
||||
val isScanning = uiState == ScannerUiState.ACTIVE
|
||||
var cameraControl by remember { mutableStateOf<CameraControl?>(null) }
|
||||
var hasTorch by remember { mutableStateOf(false) }
|
||||
var torchEnabled by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.menu_item_import_config_qrcode),
|
||||
onBackClick = onBackClick,
|
||||
actions = {
|
||||
IconButton(
|
||||
onClick = {
|
||||
if (isScanning) {
|
||||
if (torchEnabled) {
|
||||
torchEnabled = false
|
||||
cameraControl?.enableTorch(false)
|
||||
}
|
||||
onStopScan()
|
||||
} else {
|
||||
onStartScan()
|
||||
}
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painterResource(
|
||||
if (isScanning) R.drawable.ic_stop_24dp
|
||||
else R.drawable.ic_scan_24dp
|
||||
),
|
||||
contentDescription = if (isScanning) "stop scan" else "start scan"
|
||||
)
|
||||
}
|
||||
if (isScanning && hasTorch) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
torchEnabled = !torchEnabled
|
||||
cameraControl?.enableTorch(torchEnabled)
|
||||
}
|
||||
) {
|
||||
Icon(
|
||||
painterResource(
|
||||
if (torchEnabled) R.drawable.ic_flash_on_24dp
|
||||
else R.drawable.ic_flash_off_24dp
|
||||
),
|
||||
contentDescription = "Torch"
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onSelectPhoto) {
|
||||
Icon(
|
||||
painterResource(R.drawable.ic_image_24dp),
|
||||
contentDescription = "select image"
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
) {
|
||||
when (uiState) {
|
||||
ScannerUiState.IDLE -> ScannerIdlePlaceholder(onStartClick = onStartScan)
|
||||
ScannerUiState.ACTIVE -> {
|
||||
CameraXPreview(
|
||||
onScanResult = onScanResult,
|
||||
onCameraReady = { control, info ->
|
||||
cameraControl = control
|
||||
hasTorch = info.hasFlashUnit()
|
||||
if (torchEnabled && hasTorch) {
|
||||
control.enableTorch(true)
|
||||
}
|
||||
}
|
||||
)
|
||||
ScannerOverlay()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ScannerIdlePlaceholder(onStartClick: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.pointerInput(Unit) { detectTapGestures { onStartClick() } },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.ic_scan_24dp),
|
||||
contentDescription = "Start Scanner",
|
||||
modifier = Modifier.size(80.dp),
|
||||
tint = MaterialTheme.colorScheme.primary
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.menu_item_scan_qrcode),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
color = MaterialTheme.colorScheme.onSurface
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = stringResource(R.string.summary_scan_qrcode),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ScannerOverlay() {
|
||||
val scanBoxSize = 250.dp
|
||||
val cornerLength = 24.dp
|
||||
val cornerWidth = 3.dp
|
||||
val cornerColor = Color(0xFF4CAF50)
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
val canvasWidth = size.width
|
||||
val canvasHeight = size.height
|
||||
val boxSizePx = scanBoxSize.toPx()
|
||||
val left = (canvasWidth - boxSizePx) / 2f
|
||||
val top = (canvasHeight - boxSizePx) / 2f
|
||||
drawRect(color = Color(0x80000000))
|
||||
drawRect(
|
||||
color = Color.Transparent,
|
||||
topLeft = Offset(left, top),
|
||||
size = Size(boxSizePx, boxSizePx),
|
||||
blendMode = BlendMode.Clear
|
||||
)
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(scanBoxSize)
|
||||
.align(Alignment.Center)
|
||||
) {
|
||||
listOf(
|
||||
Alignment.TopStart,
|
||||
Alignment.TopEnd,
|
||||
Alignment.BottomStart,
|
||||
Alignment.BottomEnd
|
||||
).forEach { align ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(align)
|
||||
.width(cornerLength)
|
||||
.height(cornerWidth)
|
||||
.background(cornerColor)
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(align)
|
||||
.width(cornerWidth)
|
||||
.height(cornerLength)
|
||||
.background(cornerColor)
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.menu_item_scan_qrcode),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.offset(y = scanBoxSize / 2 + 24.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CameraXPreview(
|
||||
onScanResult: (String) -> Unit,
|
||||
onCameraReady: (CameraControl, CameraInfo) -> Unit
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val foundResult = remember { AtomicBoolean(false) }
|
||||
var surfaceRequest by remember { mutableStateOf<SurfaceRequest?>(null) }
|
||||
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val analysisExecutor = Executors.newSingleThreadExecutor()
|
||||
var cameraProvider: ProcessCameraProvider? = null
|
||||
var camera: Camera? = null
|
||||
|
||||
val resolutionSelector = ResolutionSelector.Builder()
|
||||
.setResolutionStrategy(
|
||||
ResolutionStrategy(
|
||||
TargetSize(1280, 720),
|
||||
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER
|
||||
)
|
||||
)
|
||||
.build()
|
||||
|
||||
val imageAnalysis = ImageAnalysis.Builder()
|
||||
.setResolutionSelector(resolutionSelector)
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.build()
|
||||
.apply {
|
||||
setAnalyzer(analysisExecutor) { imageProxy ->
|
||||
processImageProxy(imageProxy, foundResult, onScanResult)
|
||||
}
|
||||
}
|
||||
|
||||
val preview = Preview.Builder().build()
|
||||
preview.setSurfaceProvider { request -> surfaceRequest = request }
|
||||
|
||||
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
||||
cameraProviderFuture.addListener(
|
||||
{
|
||||
try {
|
||||
cameraProvider = cameraProviderFuture.get()
|
||||
cameraProvider?.unbindAll()
|
||||
camera = cameraProvider?.bindToLifecycle(
|
||||
lifecycleOwner,
|
||||
CameraSelector.DEFAULT_BACK_CAMERA,
|
||||
preview,
|
||||
imageAnalysis
|
||||
)
|
||||
camera?.let {
|
||||
onCameraReady(it.cameraControl, it.cameraInfo)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "CameraX bind failed", e)
|
||||
}
|
||||
},
|
||||
ContextCompat.getMainExecutor(context)
|
||||
)
|
||||
|
||||
onDispose {
|
||||
cameraProvider?.unbindAll()
|
||||
analysisExecutor.shutdownNow()
|
||||
}
|
||||
}
|
||||
|
||||
surfaceRequest?.let { request ->
|
||||
val coordinateTransformer = remember { MutableCoordinateTransformer() }
|
||||
CameraXViewfinder(
|
||||
surfaceRequest = request,
|
||||
implementationMode = ImplementationMode.EXTERNAL,
|
||||
coordinateTransformer = coordinateTransformer,
|
||||
modifier = Modifier.fillMaxSize()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun processImageProxy(
|
||||
imageProxy: ImageProxy,
|
||||
foundResult: AtomicBoolean,
|
||||
onResult: (String) -> Unit
|
||||
) {
|
||||
if (foundResult.get()) {
|
||||
imageProxy.close()
|
||||
return
|
||||
}
|
||||
try {
|
||||
val buffer = imageProxy.planes[0].buffer
|
||||
val bytes = ByteArray(buffer.remaining())
|
||||
buffer.get(bytes)
|
||||
val width = imageProxy.width
|
||||
val height = imageProxy.height
|
||||
val source = PlanarYUVLuminanceSource(
|
||||
bytes, width, height,
|
||||
0, 0, width, height,
|
||||
false
|
||||
)
|
||||
val binaryBitmap = BinaryBitmap(HybridBinarizer(source))
|
||||
val hints = mapOf(
|
||||
DecodeHintType.POSSIBLE_FORMATS to listOf(BarcodeFormat.QR_CODE),
|
||||
DecodeHintType.TRY_HARDER to true,
|
||||
DecodeHintType.CHARACTER_SET to "UTF-8"
|
||||
)
|
||||
val result = qrReader.decode(binaryBitmap, hints)
|
||||
val text = result.text
|
||||
if (!text.isNullOrEmpty() && foundResult.compareAndSet(false, true)) {
|
||||
onResult(text)
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
// do nothing
|
||||
} finally {
|
||||
imageProxy.close()
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,66 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import com.blacksquircle.ui.editorkit.utils.EditorTheme
|
||||
import com.blacksquircle.ui.language.json.JsonLanguage
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.text.selection.LocalTextSelectionColors
|
||||
import androidx.compose.foundation.text.selection.TextSelectionColors
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.TextLayoutResult
|
||||
import androidx.compose.ui.text.TextRange
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.drawText
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityServerCustomConfigBinding
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.ConfirmDialog
|
||||
import com.v2ray.ang.compose.FormTextField
|
||||
import com.v2ray.ang.compose.horizontalScrollbar
|
||||
import com.v2ray.ang.compose.verticalScrollbar
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.toast
|
||||
@@ -17,12 +68,10 @@ import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.fmt.CustomFmt
|
||||
import com.v2ray.ang.handler.AngConfigManager
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
|
||||
class ServerCustomConfigActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityServerCustomConfigBinding.inflate(layoutInflater) }
|
||||
class ServerCustomConfigActivity : BaseComponentActivity() {
|
||||
|
||||
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
||||
private val isRunning by lazy {
|
||||
@@ -31,116 +80,406 @@ class ServerCustomConfigActivity : BaseActivity() {
|
||||
&& editGuid == MmkvManager.getSelectServer()
|
||||
}
|
||||
|
||||
private var initialRemarks: String = ""
|
||||
private var initialContent: String = ""
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.CUSTOM.toString())
|
||||
|
||||
if (!Utils.getDarkModeStatus(this)) {
|
||||
binding.editor.colorScheme = EditorTheme.INTELLIJ_LIGHT
|
||||
}
|
||||
binding.editor.language = JsonLanguage()
|
||||
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||
if (config != null) {
|
||||
bindingServer(config)
|
||||
} else {
|
||||
clearServer()
|
||||
}
|
||||
initialRemarks = config?.remarks ?: ""
|
||||
initialContent = MmkvManager.decodeServerRaw(editGuid).orEmpty()
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
/**
|
||||
* Binding selected server config
|
||||
*/
|
||||
private fun bindingServer(config: ProfileItem): Boolean {
|
||||
binding.etRemarks.text = Utils.getEditable(config.remarks)
|
||||
val raw = MmkvManager.decodeServerRaw(editGuid)
|
||||
val configContent = raw.orEmpty()
|
||||
|
||||
binding.editor.setTextContent(Utils.getEditable(configContent))
|
||||
return true
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
ServerCustomConfigScreen(
|
||||
editGuid = editGuid,
|
||||
isRunning = isRunning,
|
||||
initialRemarks = initialRemarks,
|
||||
initialContent = initialContent,
|
||||
onBackClick = { finish() },
|
||||
onSave = { remarks, content -> saveServer(remarks, content) },
|
||||
onDelete = { deleteServer() }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* clear or init server config
|
||||
*/
|
||||
private fun clearServer(): Boolean {
|
||||
binding.etRemarks.text = null
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* save server config
|
||||
*/
|
||||
private fun saveServer(): Boolean {
|
||||
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
|
||||
private fun saveServer(
|
||||
remarks: String,
|
||||
content: String
|
||||
): Boolean {
|
||||
if (remarks.isBlank()) {
|
||||
toast(R.string.server_lab_remarks)
|
||||
return false
|
||||
}
|
||||
|
||||
val profileItem = try {
|
||||
CustomFmt.parse(binding.editor.text.toString())
|
||||
val parsedProfile = try {
|
||||
CustomFmt.parse(content)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom configuration", e)
|
||||
toast("${getString(R.string.toast_malformed_josn)} ${e.cause?.message}")
|
||||
LogUtil.e(
|
||||
AppConfig.TAG,
|
||||
"Failed to parse custom configuration",
|
||||
e
|
||||
)
|
||||
toast(
|
||||
"${getString(R.string.toast_malformed_josn)} " +
|
||||
"${e.cause?.message.orEmpty()}"
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.CUSTOM)
|
||||
binding.etRemarks.text.let {
|
||||
config.remarks = if (it.isNullOrEmpty()) profileItem?.remarks.orEmpty() else it.toString()
|
||||
}
|
||||
config.server = profileItem?.server
|
||||
config.serverPort = profileItem?.serverPort
|
||||
config.description = AngConfigManager.generateDescription(config)
|
||||
val config =
|
||||
MmkvManager.decodeServerConfig(editGuid)
|
||||
?: ProfileItem.create(EConfigType.CUSTOM)
|
||||
|
||||
config.remarks =
|
||||
remarks.ifEmpty { parsedProfile?.remarks.orEmpty() }
|
||||
|
||||
config.server = parsedProfile?.server
|
||||
config.serverPort = parsedProfile?.serverPort
|
||||
config.description =
|
||||
AngConfigManager.generateDescription(config)
|
||||
|
||||
val savedGuid = MmkvManager.encodeServerConfig(
|
||||
editGuid,
|
||||
config
|
||||
)
|
||||
|
||||
MmkvManager.encodeServerRaw(
|
||||
savedGuid,
|
||||
content
|
||||
)
|
||||
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
MmkvManager.encodeServerRaw(editGuid, binding.editor.text.toString())
|
||||
if (isRunning) {
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
|
||||
ProfileEditorResult.run {
|
||||
finishSaved(
|
||||
guid = savedGuid,
|
||||
restartService = isRunning
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* save server config
|
||||
*/
|
||||
private fun deleteServer(): Boolean {
|
||||
if (editGuid.isNotEmpty()) {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
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<TextLayoutResult?>(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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,37 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityServerGroupBinding
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.ConfirmDialog
|
||||
import com.v2ray.ang.compose.FormDropdownField
|
||||
import com.v2ray.ang.compose.FormTextField
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isNotNullEmpty
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
|
||||
class ServerGroupActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityServerGroupBinding.inflate(layoutInflater) }
|
||||
class ServerGroupActivity : BaseComponentActivity() {
|
||||
|
||||
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
||||
private val isRunning by lazy {
|
||||
@@ -26,145 +39,214 @@ class ServerGroupActivity : BaseActivity() {
|
||||
&& editGuid.isNotEmpty()
|
||||
&& editGuid == MmkvManager.getSelectServer()
|
||||
}
|
||||
private val subscriptionId by lazy {
|
||||
intent.getStringExtra("subscriptionId")
|
||||
}
|
||||
private val subscriptionId by lazy { intent.getStringExtra("subscriptionId") }
|
||||
private val subIds = mutableListOf<String>()
|
||||
private val subDisplay = mutableListOf<String>()
|
||||
|
||||
private lateinit var initialRemarks: String
|
||||
private lateinit var initialFilter: String
|
||||
private var initialType: Int = 0
|
||||
private var initialSubIndex: Int = 0
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.POLICYGROUP.toString())
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||
populateSubscriptionSpinner()
|
||||
|
||||
if (config != null) {
|
||||
bindingServer(config)
|
||||
} else {
|
||||
clearServer()
|
||||
}
|
||||
initialRemarks = config?.remarks ?: ""
|
||||
initialFilter = config?.policyGroupFilter ?: ""
|
||||
initialType = config?.policyGroupType?.toIntOrNull() ?: 0
|
||||
initialSubIndex = if (config != null) {
|
||||
subIds.indexOf(config.policyGroupSubscriptionId ?: "").let { if (it >= 0) it else 0 }
|
||||
} else if (subscriptionId.isNotNullEmpty()) {
|
||||
subIds.indexOf(subscriptionId).let { if (it >= 0) it else 0 }
|
||||
} else 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Binding selected server config
|
||||
*/
|
||||
private fun bindingServer(config: ProfileItem): Boolean {
|
||||
binding.etRemarks.text = Utils.getEditable(config.remarks)
|
||||
binding.etPolicyGroupFilter.text = Utils.getEditable(config.policyGroupFilter)
|
||||
|
||||
val type = config.policyGroupType?.toInt() ?: 0
|
||||
binding.spPolicyGroupType.setSelection(type)
|
||||
|
||||
val pos = subIds.indexOf(config.policyGroupSubscriptionId ?: "").let { if (it >= 0) it else 0 }
|
||||
binding.spPolicyGroupSubId.setSelection(pos)
|
||||
|
||||
return true
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
ServerGroupScreen(
|
||||
editGuid = editGuid,
|
||||
isRunning = isRunning,
|
||||
subDisplay = subDisplay,
|
||||
initialRemarks = initialRemarks,
|
||||
initialFilter = initialFilter,
|
||||
initialType = initialType,
|
||||
initialSubIndex = initialSubIndex,
|
||||
onBackClick = { finish() },
|
||||
onSave = { remarks, filter, typeIdx, subIdx -> saveServer(remarks, filter, typeIdx, subIdx) },
|
||||
onDelete = { deleteServer() }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* clear or init server config
|
||||
*/
|
||||
private fun clearServer(): Boolean {
|
||||
binding.etRemarks.text = null
|
||||
binding.etPolicyGroupFilter.text = null
|
||||
|
||||
if (subscriptionId.isNotNullEmpty()) {
|
||||
val pos = subIds.indexOf(subscriptionId).let { if (it >= 0) it else 0 }
|
||||
binding.spPolicyGroupSubId.setSelection(pos)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* save server config
|
||||
*/
|
||||
private fun saveServer(): Boolean {
|
||||
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
|
||||
private fun saveServer(
|
||||
remarks: String,
|
||||
filter: String,
|
||||
typeIdx: Int,
|
||||
subIdx: Int
|
||||
): Boolean {
|
||||
if (remarks.isBlank()) {
|
||||
toast(R.string.server_lab_remarks)
|
||||
return false
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.POLICYGROUP)
|
||||
config.remarks = binding.etRemarks.text.toString().trim()
|
||||
config.policyGroupFilter = binding.etPolicyGroupFilter.text.toString().trim()
|
||||
val config =
|
||||
MmkvManager.decodeServerConfig(editGuid)
|
||||
?: ProfileItem.create(EConfigType.POLICYGROUP)
|
||||
|
||||
config.policyGroupType = binding.spPolicyGroupType.selectedItemPosition.toString()
|
||||
config.remarks = remarks.trim()
|
||||
config.policyGroupFilter = filter.trim()
|
||||
config.policyGroupType = typeIdx.toString()
|
||||
config.policyGroupSubscriptionId =
|
||||
subIds.getOrNull(subIdx)
|
||||
|
||||
val selPos = binding.spPolicyGroupSubId.selectedItemPosition
|
||||
config.policyGroupSubscriptionId = if (selPos >= 0 && selPos < subIds.size) subIds[selPos] else null
|
||||
|
||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||
if (
|
||||
config.subscriptionId.isEmpty() &&
|
||||
!subscriptionId.isNullOrEmpty()
|
||||
) {
|
||||
config.subscriptionId = subscriptionId.orEmpty()
|
||||
}
|
||||
|
||||
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
|
||||
val typeDisplay =
|
||||
stringArrayPolicyGroupType()
|
||||
.getOrNull(typeIdx)
|
||||
.orEmpty()
|
||||
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
if (isRunning) {
|
||||
SettingsChangeManager.makeRestartService()
|
||||
config.description = buildString {
|
||||
append(typeDisplay)
|
||||
append(" - ")
|
||||
append(subDisplay.getOrNull(subIdx).orEmpty())
|
||||
append(" - ")
|
||||
append(config.policyGroupFilter)
|
||||
}
|
||||
|
||||
val savedGuid = MmkvManager.encodeServerConfig(
|
||||
editGuid,
|
||||
config
|
||||
)
|
||||
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
|
||||
ProfileEditorResult.run {
|
||||
finishSaved(
|
||||
guid = savedGuid,
|
||||
restartService = isRunning
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* save server config
|
||||
*/
|
||||
private fun deleteServer(): Boolean {
|
||||
if (editGuid.isNotEmpty()) {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
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<String> =
|
||||
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<String>,
|
||||
initialRemarks: String,
|
||||
initialFilter: String,
|
||||
initialType: Int,
|
||||
initialSubIndex: Int,
|
||||
onBackClick: () -> Unit,
|
||||
onSave: (String, String, Int, Int) -> Boolean,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
val typeEntries = stringArrayResource(R.array.policy_group_type).toList()
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,108 +1,110 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.recyclerview.widget.ItemTouchHelper
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import com.v2ray.ang.AppConfig
|
||||
import androidx.compose.animation.core.animateDpAsState
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.FloatingActionButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ActivityServerProxyChainBinding
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.ConfirmDialog
|
||||
import com.v2ray.ang.compose.FormDropdownField
|
||||
import com.v2ray.ang.compose.FormTextField
|
||||
import com.v2ray.ang.compose.verticalScrollbar
|
||||
import com.v2ray.ang.dto.entities.ProfileItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.isComplexType
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
||||
import com.v2ray.ang.util.Utils
|
||||
import sh.calvin.reorderable.ReorderableItem
|
||||
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||
|
||||
class ServerProxyChainActivity : BaseComponentActivity() {
|
||||
|
||||
class ServerProxyChainActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityServerProxyChainBinding.inflate(layoutInflater) }
|
||||
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
||||
private val isRunning by lazy {
|
||||
intent.getBooleanExtra("isRunning", false)
|
||||
&& editGuid.isNotEmpty()
|
||||
&& editGuid == MmkvManager.getSelectServer()
|
||||
}
|
||||
private val subscriptionId by lazy {
|
||||
intent.getStringExtra("subscriptionId")
|
||||
}
|
||||
private lateinit var memberAdapter: ServerProxyChainMemberAdapter
|
||||
private val subscriptionId by lazy { intent.getStringExtra("subscriptionId") }
|
||||
|
||||
private var allRemarks: List<String> = emptyList()
|
||||
private lateinit var allRemarks: List<String>
|
||||
private lateinit var initialRemarks: String
|
||||
private lateinit var initialMembers: List<String>
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.PROXYCHAIN.toString())
|
||||
|
||||
loadAvailableRemarks()
|
||||
setupRecycler()
|
||||
binding.fabAddProxyChainMember.setOnClickListener {
|
||||
addMemberRow()
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||
if (config != null) {
|
||||
bindingServer(config)
|
||||
} else {
|
||||
clearServer()
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadAvailableRemarks() {
|
||||
allRemarks = SettingsManager.getProfileRemarks(
|
||||
excludeConfigTypes = setOf(
|
||||
EConfigType.CUSTOM,
|
||||
EConfigType.POLICYGROUP,
|
||||
EConfigType.PROXYCHAIN,
|
||||
)
|
||||
excludeConfigTypes = setOf(EConfigType.CUSTOM, EConfigType.POLICYGROUP, EConfigType.PROXYCHAIN)
|
||||
)
|
||||
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||
initialRemarks = config?.remarks ?: ""
|
||||
initialMembers = config?.proxyChainProfiles?.split(",")?.map { it.trim() }?.filter { it.isNotEmpty() } ?: listOf("", "")
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
ProxyChainScreen(
|
||||
editGuid = editGuid,
|
||||
isRunning = isRunning,
|
||||
initialRemarks = initialRemarks,
|
||||
initialMembers = initialMembers,
|
||||
allRemarks = allRemarks,
|
||||
onBackClick = { finish() },
|
||||
onSave = { remarks, members -> saveServer(remarks, members) },
|
||||
onDelete = { deleteServer() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun setupRecycler() {
|
||||
memberAdapter = ServerProxyChainMemberAdapter(
|
||||
members = mutableListOf(),
|
||||
suggestions = allRemarks,
|
||||
adapterListener = ActivityAdapterListener()
|
||||
)
|
||||
binding.recyclerProxyChainMembers.layoutManager = LinearLayoutManager(this)
|
||||
binding.recyclerProxyChainMembers.adapter = memberAdapter
|
||||
ItemTouchHelper(SimpleItemTouchHelperCallback(memberAdapter)).attachToRecyclerView(binding.recyclerProxyChainMembers)
|
||||
}
|
||||
|
||||
private fun bindingServer(config: ProfileItem): Boolean {
|
||||
binding.etRemarks.text = Utils.getEditable(config.remarks)
|
||||
val rows = parseChainMembers(config.proxyChainProfiles)
|
||||
memberAdapter.replaceAll(rows)
|
||||
if (rows.isEmpty()) {
|
||||
memberAdapter.addRow()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun clearServer(): Boolean {
|
||||
binding.etRemarks.text = null
|
||||
memberAdapter.replaceAll(listOf("", ""))
|
||||
return true
|
||||
}
|
||||
|
||||
private fun saveServer(): Boolean {
|
||||
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
|
||||
private fun saveServer(
|
||||
remarks: String,
|
||||
members: List<String>
|
||||
): Boolean {
|
||||
if (remarks.isBlank()) {
|
||||
toast(R.string.server_lab_remarks)
|
||||
return false
|
||||
}
|
||||
|
||||
val chainMembers = memberAdapter.getMembers().map { it.trim() }.filter { it.isNotEmpty() }
|
||||
if (chainMembers.size != memberAdapter.getMembers().size) {
|
||||
val chainMembers = members
|
||||
.map { it.trim() }
|
||||
.filter { it.isNotEmpty() }
|
||||
|
||||
if (chainMembers.size != members.size) {
|
||||
toast(R.string.server_proxy_chain_members_unselected)
|
||||
return false
|
||||
}
|
||||
|
||||
if (chainMembers.size < 2) {
|
||||
toast(R.string.server_proxy_chain_members_insufficient)
|
||||
return false
|
||||
@@ -112,106 +114,204 @@ class ServerProxyChainActivity : BaseActivity() {
|
||||
val profile = SettingsManager.getServerViaRemarks(member)
|
||||
profile == null || profile.configType.isComplexType()
|
||||
}
|
||||
|
||||
if (invalidMembers.isNotEmpty()) {
|
||||
toast(getString(R.string.server_proxy_chain_members_invalid, invalidMembers.joinToString(", ")))
|
||||
toast(
|
||||
getString(
|
||||
R.string.server_proxy_chain_members_invalid,
|
||||
invalidMembers.joinToString(", ")
|
||||
)
|
||||
)
|
||||
return false
|
||||
}
|
||||
|
||||
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.PROXYCHAIN)
|
||||
config.remarks = binding.etRemarks.text.toString().trim()
|
||||
config.proxyChainProfiles = chainMembers.joinToString(",")
|
||||
config.description = chainMembers.joinToString(" -> ")
|
||||
val config =
|
||||
MmkvManager.decodeServerConfig(editGuid)
|
||||
?: ProfileItem.create(EConfigType.PROXYCHAIN)
|
||||
|
||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
||||
config.remarks = remarks.trim()
|
||||
config.proxyChainProfiles =
|
||||
chainMembers.joinToString(",")
|
||||
|
||||
config.description =
|
||||
chainMembers.joinToString(" -> ")
|
||||
|
||||
if (
|
||||
config.subscriptionId.isEmpty() &&
|
||||
!subscriptionId.isNullOrEmpty()
|
||||
) {
|
||||
config.subscriptionId = subscriptionId.orEmpty()
|
||||
}
|
||||
|
||||
MmkvManager.encodeServerConfig(editGuid, config)
|
||||
if (isRunning) {
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
val savedGuid = MmkvManager.encodeServerConfig(
|
||||
editGuid,
|
||||
config
|
||||
)
|
||||
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
|
||||
ProfileEditorResult.run {
|
||||
finishSaved(
|
||||
guid = savedGuid,
|
||||
restartService = isRunning
|
||||
)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun deleteServer(): Boolean {
|
||||
if (editGuid.isNotEmpty()) {
|
||||
if (editGuid != MmkvManager.getSelectServer()) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
MmkvManager.removeServer(editGuid)
|
||||
finish()
|
||||
}
|
||||
.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<String> {
|
||||
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<String>,
|
||||
allRemarks: List<String>,
|
||||
onBackClick: () -> Unit,
|
||||
onSave: (String, List<String>) -> Boolean,
|
||||
onDelete: () -> Unit
|
||||
) {
|
||||
var remarks by rememberSaveable { mutableStateOf(initialRemarks) }
|
||||
var members by rememberSaveable { mutableStateOf(initialMembers.toMutableList()) }
|
||||
var showDeleteConfirm by remember { mutableStateOf(false) }
|
||||
val showDelete = editGuid.isNotEmpty() && !isRunning
|
||||
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,136 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.graphics.Color
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ItemRecyclerProxyChainMemberBinding
|
||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||
import java.util.Collections
|
||||
|
||||
class ServerProxyChainMemberAdapter(
|
||||
private val members: MutableList<String>,
|
||||
private val suggestions: List<String>,
|
||||
private val adapterListener: BaseAdapterListener?
|
||||
) : RecyclerView.Adapter<ServerProxyChainMemberAdapter.MemberViewHolder>(), ItemTouchHelperAdapter {
|
||||
|
||||
override fun getItemCount(): Int = members.size
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MemberViewHolder {
|
||||
return MemberViewHolder(
|
||||
ItemRecyclerProxyChainMemberBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
override fun onBindViewHolder(holder: MemberViewHolder, position: Int) {
|
||||
val adapterPos = holder.bindingAdapterPosition.takeIf { it != RecyclerView.NO_POSITION } ?: position
|
||||
val value = members[position]
|
||||
holder.binding.tvMemberIndex.text = (position + 1).toString()
|
||||
|
||||
val dropdownAdapter = ArrayAdapter(
|
||||
holder.itemView.context,
|
||||
android.R.layout.simple_dropdown_item_1line,
|
||||
suggestions
|
||||
)
|
||||
holder.binding.spMemberRemark.setAdapter(dropdownAdapter)
|
||||
holder.binding.spMemberRemark.threshold = 0
|
||||
holder.binding.spMemberRemark.setText(value, false)
|
||||
|
||||
holder.binding.spMemberRemark.setOnItemClickListener { _, _, selectedIndex, _ ->
|
||||
if (adapterPos in members.indices) {
|
||||
members[adapterPos] = suggestions[selectedIndex].trim()
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
}
|
||||
holder.binding.spMemberRemark.onFocusChangeListener = View.OnFocusChangeListener { _, hasFocus ->
|
||||
if (hasFocus) return@OnFocusChangeListener
|
||||
val text = holder.binding.spMemberRemark.text?.toString().orEmpty().trim()
|
||||
if (adapterPos in members.indices && members[adapterPos] != text) {
|
||||
members[adapterPos] = text
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
}
|
||||
holder.binding.spMemberRemark.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
|
||||
holder.binding.btnMemberDropdown.setOnClickListener {
|
||||
holder.binding.spMemberRemark.requestFocus()
|
||||
holder.binding.spMemberRemark.showDropDown()
|
||||
}
|
||||
holder.itemView.setOnClickListener { holder.binding.spMemberRemark.showDropDown() }
|
||||
|
||||
holder.binding.layoutRemove.setOnClickListener {
|
||||
val removePos = holder.bindingAdapterPosition
|
||||
if (removePos != RecyclerView.NO_POSITION) {
|
||||
adapterListener?.onRemove("", removePos)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addRow() {
|
||||
members.add("")
|
||||
notifyItemInserted(members.lastIndex)
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
fun removeRow(position: Int) {
|
||||
if (position < 0 || position >= members.size) return
|
||||
members.removeAt(position)
|
||||
notifyItemRemoved(position)
|
||||
notifyItemRangeChanged(position, members.size - position)
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
fun setRemark(position: Int, remark: String) {
|
||||
if (position < 0 || position >= members.size) return
|
||||
members[position] = remark
|
||||
notifyItemChanged(position)
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
fun replaceAll(newMembers: List<String>) {
|
||||
members.clear()
|
||||
members.addAll(newMembers)
|
||||
notifyDataSetChanged()
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
fun getMembers(): List<String> = members.toList()
|
||||
|
||||
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
|
||||
if (fromPosition == toPosition) return true
|
||||
Collections.swap(members, fromPosition, toPosition)
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
notifyItemChanged(fromPosition)
|
||||
notifyItemChanged(toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onItemMoveCompleted() {
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
override fun onItemDismiss(position: Int) {
|
||||
// Swipe-to-dismiss disabled for this adapter.
|
||||
}
|
||||
|
||||
class MemberViewHolder(val binding: ItemRecyclerProxyChainMemberBinding) :
|
||||
BaseViewHolder(binding.root), ItemTouchHelperViewHolder
|
||||
|
||||
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
fun onItemSelected() {
|
||||
itemView.setBackgroundColor(Color.LTGRAY)
|
||||
}
|
||||
|
||||
fun onItemClear() {
|
||||
itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,338 +1,577 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.CheckBoxPreference
|
||||
import androidx.preference.EditTextPreference
|
||||
import androidx.preference.ListPreference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringArrayResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.VPN
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.PreferenceGroupHeader
|
||||
import com.v2ray.ang.compose.SettingsEditItem
|
||||
import com.v2ray.ang.compose.SettingsListItem
|
||||
import com.v2ray.ang.compose.SettingsMenuItem
|
||||
import com.v2ray.ang.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.compose.ThemeManager
|
||||
import com.v2ray.ang.compose.verticalScrollbar
|
||||
import com.v2ray.ang.extension.toastError
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.helper.MmkvPreferenceDataStore
|
||||
import com.v2ray.ang.handler.MmkvManager.rememberMmkvBool
|
||||
import com.v2ray.ang.handler.MmkvManager.rememberMmkvString
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.root.RootManager
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class SettingsActivity : BaseComponentActivity() {
|
||||
|
||||
class SettingsActivity : BaseActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentViewWithToolbar(R.layout.activity_settings, showHomeAsUp = true, title = getString(R.string.title_settings))
|
||||
}
|
||||
|
||||
class SettingsFragment : PreferenceFragmentCompat() {
|
||||
@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<CheckBoxPreference>(AppConfig.PREF_LOCAL_DNS_ENABLED) }
|
||||
private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) }
|
||||
private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) }
|
||||
|
||||
// private val localDnsPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_LOCAL_DNS_PORT) }
|
||||
private val vpnDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_DNS) }
|
||||
private val vpnBypassLan by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_BYPASS_LAN) }
|
||||
private val vpnInterfaceAddress by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX) }
|
||||
private val vpnMtu by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_MTU) }
|
||||
|
||||
private val mux by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_MUX_ENABLED) }
|
||||
private val muxConcurrency by lazy { findPreference<EditTextPreference>(AppConfig.PREF_MUX_CONCURRENCY) }
|
||||
private val muxXudpConcurrency by lazy { findPreference<EditTextPreference>(AppConfig.PREF_MUX_XUDP_CONCURRENCY) }
|
||||
private val muxXudpQuic by lazy { findPreference<ListPreference>(AppConfig.PREF_MUX_XUDP_QUIC) }
|
||||
|
||||
private val fragment by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FRAGMENT_ENABLED) }
|
||||
private val fragmentPackets by lazy { findPreference<ListPreference>(AppConfig.PREF_FRAGMENT_PACKETS) }
|
||||
private val fragmentLength by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_LENGTH) }
|
||||
private val fragmentInterval by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_INTERVAL) }
|
||||
private val fragmentMaxSplit by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_MAXSPLIT) }
|
||||
|
||||
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
|
||||
private val enableRootMode by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ROOT_MODE_ENABLE) }
|
||||
private val lanSharing by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ROOT_LAN_SHARING) }
|
||||
|
||||
private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
|
||||
private val hevTunRwTimeout by lazy { findPreference<EditTextPreference>(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT) }
|
||||
private val useHevTun by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_USE_HEV_TUNNEL) }
|
||||
|
||||
private val enableLocalProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ENABLE_LOCAL_PROXY) }
|
||||
private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
|
||||
private val dynamicSocksPort by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_DYNAMIC_SOCKS_PORT) }
|
||||
private val socksUsername by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_USERNAME) }
|
||||
private val socksPassword by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PASSWORD) }
|
||||
private val socksEnableUdp by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_SOCKS_ENABLE_UDP) }
|
||||
private val proxySharing by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_PROXY_SHARING) }
|
||||
|
||||
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
|
||||
// Use MMKV as the storage backend for all Preferences
|
||||
// This prevents inconsistencies between SharedPreferences and MMKV
|
||||
preferenceManager.preferenceDataStore = MmkvPreferenceDataStore()
|
||||
|
||||
addPreferencesFromResource(R.xml.pref_settings)
|
||||
|
||||
initPreferenceSummaries()
|
||||
|
||||
localDns?.setOnPreferenceChangeListener { _, any ->
|
||||
updateLocalDns(any as Boolean)
|
||||
true
|
||||
}
|
||||
|
||||
mux?.setOnPreferenceChangeListener { _, newValue ->
|
||||
updateMux(newValue as Boolean)
|
||||
true
|
||||
}
|
||||
muxConcurrency?.setOnPreferenceChangeListener { _, newValue ->
|
||||
updateMuxConcurrency(newValue as String)
|
||||
true
|
||||
}
|
||||
muxXudpConcurrency?.setOnPreferenceChangeListener { _, newValue ->
|
||||
updateMuxXudpConcurrency(newValue as String)
|
||||
true
|
||||
}
|
||||
|
||||
fragment?.setOnPreferenceChangeListener { _, newValue ->
|
||||
updateFragment(newValue as Boolean)
|
||||
true
|
||||
}
|
||||
|
||||
mode?.setOnPreferenceChangeListener { pref, newValue ->
|
||||
val valueStr = newValue.toString()
|
||||
(pref as? ListPreference)?.let { lp ->
|
||||
val idx = lp.findIndexOfValue(valueStr)
|
||||
lp.summary = if (idx >= 0) lp.entries[idx] else valueStr
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,16 +2,36 @@ package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.AutoCompleteTextView
|
||||
import android.widget.ImageButton
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivitySubEditBinding
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.ConfirmDialog
|
||||
import com.v2ray.ang.compose.FormDropdownField
|
||||
import com.v2ray.ang.compose.FormTextField
|
||||
import com.v2ray.ang.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.compose.verticalScrollbar
|
||||
import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.enums.EConfigType
|
||||
import com.v2ray.ang.extension.toast
|
||||
@@ -24,127 +44,38 @@ import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SubEditActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivitySubEditBinding.inflate(layoutInflater) }
|
||||
|
||||
private var del_config: MenuItem? = null
|
||||
private var save_config: MenuItem? = null
|
||||
|
||||
class SubEditActivity : BaseComponentActivity() {
|
||||
private val editSubId by lazy { intent.getStringExtra("subId").orEmpty() }
|
||||
private lateinit var suggestions: List<String>
|
||||
private lateinit var subItem: SubscriptionItem
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
|
||||
|
||||
setupProfileRemarkInputs()
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
val subItem = MmkvManager.decodeSubscription(editSubId)
|
||||
if (subItem != null) {
|
||||
bindingServer(subItem)
|
||||
} else {
|
||||
clearServer()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* binding selected server config
|
||||
*/
|
||||
private fun bindingServer(subItem: SubscriptionItem): Boolean {
|
||||
binding.etRemarks.text = Utils.getEditable(subItem.remarks)
|
||||
binding.etUrl.text = Utils.getEditable(subItem.url)
|
||||
binding.etUserAgent.text = Utils.getEditable(subItem.userAgent)
|
||||
binding.etFilter.text = Utils.getEditable(subItem.filter)
|
||||
binding.chkEnable.isChecked = subItem.enabled
|
||||
binding.autoUpdateCheck.isChecked = subItem.autoUpdate
|
||||
binding.etUpdateInterval.text = Utils.getEditable(subItem.updateInterval.toString())
|
||||
binding.allowInsecureUrl.isChecked = subItem.allowInsecureUrl
|
||||
binding.etPreProfile.text = Utils.getEditable(subItem.prevProfile)
|
||||
binding.etNextProfile.text = Utils.getEditable(subItem.nextProfile)
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* clear or init server config
|
||||
*/
|
||||
private fun clearServer(): Boolean {
|
||||
binding.etRemarks.text = null
|
||||
binding.etUrl.text = null
|
||||
binding.etFilter.text = null
|
||||
binding.chkEnable.isChecked = true
|
||||
binding.etUpdateInterval.text = null
|
||||
binding.etPreProfile.text = null
|
||||
binding.etNextProfile.text = null
|
||||
return true
|
||||
}
|
||||
|
||||
private fun setupProfileRemarkInputs() {
|
||||
val suggestions = SettingsManager.getProfileRemarks(
|
||||
suggestions = SettingsManager.getProfileRemarks(
|
||||
excludeConfigTypes = setOf(
|
||||
EConfigType.CUSTOM,
|
||||
EConfigType.POLICYGROUP,
|
||||
EConfigType.PROXYCHAIN,
|
||||
)
|
||||
)
|
||||
|
||||
setupProfileRemarkInput(binding.etPreProfile, binding.btnPreProfileDropdown, suggestions)
|
||||
setupProfileRemarkInput(binding.etNextProfile, binding.btnNextProfileDropdown, suggestions)
|
||||
subItem = MmkvManager.decodeSubscription(editSubId) ?: SubscriptionItem()
|
||||
}
|
||||
|
||||
private fun setupProfileRemarkInput(
|
||||
input: AutoCompleteTextView,
|
||||
dropdownButton: ImageButton,
|
||||
suggestions: List<String>
|
||||
) {
|
||||
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
|
||||
input.setAdapter(adapter)
|
||||
input.threshold = 0
|
||||
|
||||
dropdownButton.setOnClickListener {
|
||||
input.requestFocus()
|
||||
input.showDropDown()
|
||||
}
|
||||
input.setOnClickListener {
|
||||
input.showDropDown()
|
||||
}
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
SubEditScreen(
|
||||
editSubId = editSubId,
|
||||
initial = subItem,
|
||||
profileSuggestions = suggestions,
|
||||
onBackClick = { finish() },
|
||||
onSave = { saveServer(it) },
|
||||
onDelete = { deleteServer() }
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* save server config
|
||||
*/
|
||||
private fun saveServer(): Boolean {
|
||||
val subItem = MmkvManager.decodeSubscription(editSubId) ?: SubscriptionItem()
|
||||
|
||||
subItem.remarks = binding.etRemarks.text.toString()
|
||||
subItem.url = binding.etUrl.text.toString()
|
||||
subItem.userAgent = binding.etUserAgent.text.toString()
|
||||
subItem.filter = binding.etFilter.text.toString()
|
||||
subItem.enabled = binding.chkEnable.isChecked
|
||||
subItem.autoUpdate = binding.autoUpdateCheck.isChecked
|
||||
|
||||
val intervalInput = binding.etUpdateInterval.text.toString().trim()
|
||||
val intervalMinutes = intervalInput.toLongOrNull()
|
||||
if (subItem.autoUpdate) {
|
||||
// autoUpdate is enabled: interval must be valid
|
||||
if (intervalMinutes == null) {
|
||||
// field is empty, reset to default
|
||||
subItem.updateInterval = SubscriptionItem().updateInterval
|
||||
} else if (intervalMinutes < AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
|
||||
toast(R.string.toast_invalid_update_interval)
|
||||
return false
|
||||
} else {
|
||||
subItem.updateInterval = intervalMinutes
|
||||
}
|
||||
} else {
|
||||
// autoUpdate is disabled: save only if the value is valid, otherwise keep the existing value
|
||||
if (intervalMinutes != null && intervalMinutes >= AppConfig.SUBSCRIPTION_MIN_INTERVAL_MINUTES) {
|
||||
subItem.updateInterval = intervalMinutes
|
||||
}
|
||||
}
|
||||
|
||||
subItem.prevProfile = binding.etPreProfile.text.toString()
|
||||
subItem.nextProfile = binding.etNextProfile.text.toString()
|
||||
subItem.allowInsecureUrl = binding.allowInsecureUrl.isChecked
|
||||
private fun saveServer(subItem: SubscriptionItem): Boolean {
|
||||
val intervalMinutes = subItem.updateInterval
|
||||
|
||||
if (TextUtils.isEmpty(subItem.remarks)) {
|
||||
toast(R.string.sub_setting_remarks)
|
||||
@@ -155,7 +86,6 @@ class SubEditActivity : BaseActivity() {
|
||||
toast(R.string.toast_invalid_url)
|
||||
return false
|
||||
}
|
||||
|
||||
if (!Utils.isValidSubUrl(subItem.url)) {
|
||||
toast(R.string.toast_insecure_url_protocol)
|
||||
if (!subItem.allowInsecureUrl) {
|
||||
@@ -166,62 +96,162 @@ class SubEditActivity : BaseActivity() {
|
||||
|
||||
MmkvManager.encodeSubscription(editSubId, subItem)
|
||||
SubscriptionUpdater.syncOne(subId = editSubId)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
toastSuccess(R.string.toast_success)
|
||||
finish()
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* save server config
|
||||
*/
|
||||
private fun deleteServer(): Boolean {
|
||||
if (editSubId.isNotEmpty()) {
|
||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
SettingsManager.removeSubscriptionWithDefault(editSubId)
|
||||
launch(Dispatchers.Main) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
||||
// do nothing
|
||||
}
|
||||
.show()
|
||||
} else {
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
SettingsManager.removeSubscriptionWithDefault(editSubId)
|
||||
launch(Dispatchers.Main) {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
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<String>,
|
||||
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 }
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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<out String> by lazy {
|
||||
resources.getStringArray(R.array.share_sub_method)
|
||||
}
|
||||
private val isLoadingState = MutableStateFlow(false)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
|
||||
}
|
||||
|
||||
adapter = SubSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
|
||||
|
||||
binding.recyclerView.setHasFixedSize(true)
|
||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
|
||||
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
SubSettingScreen(
|
||||
viewModel = viewModel,
|
||||
isLoadingState = isLoadingState,
|
||||
onBackClick = { finish() },
|
||||
onAddClick = { startActivity(Intent(this, SubEditActivity::class.java)) },
|
||||
onSubUpdate = { updateSubscriptions() },
|
||||
onEditSub = { subId ->
|
||||
startActivity(Intent(this, SubEditActivity::class.java).putExtra("subId", subId))
|
||||
},
|
||||
onRemoveSub = { subId -> removeSub(subId) },
|
||||
onShareQRCode = { url -> QRCodeDecoder.createQRCode(url) },
|
||||
onShareClipboard = { url ->
|
||||
Utils.setClipboard(this, url)
|
||||
toast(getString(R.string.toast_success))
|
||||
},
|
||||
shareSubMethodEntries = resources.getStringArray(R.array.share_sub_method).toList()
|
||||
)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
refreshData()
|
||||
}
|
||||
|
||||
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<Boolean>,
|
||||
onBackClick: () -> Unit,
|
||||
onAddClick: () -> Unit,
|
||||
onSubUpdate: () -> Unit,
|
||||
onEditSub: (String) -> Unit,
|
||||
onRemoveSub: (String) -> Unit,
|
||||
onShareQRCode: (String) -> android.graphics.Bitmap?,
|
||||
onShareClipboard: (String) -> Unit,
|
||||
shareSubMethodEntries: List<String>
|
||||
) {
|
||||
val subscriptions by viewModel.subsFlow.collectAsStateWithLifecycle()
|
||||
val isLoading by isLoadingState.collectAsState()
|
||||
var removeTarget by remember { mutableStateOf<String?>(null) }
|
||||
val confirmRemove = MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE, false)
|
||||
|
||||
var shareTarget by remember { mutableStateOf<Pair<String, String>?>(null) }
|
||||
var showQRCodeBitmap by remember { mutableStateOf<android.graphics.Bitmap?>(null) }
|
||||
|
||||
val lazyListState = rememberLazyListState()
|
||||
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
|
||||
viewModel.swap(from.index, to.index)
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_sub_setting),
|
||||
onBackClick = onBackClick,
|
||||
isLoading = isLoading,
|
||||
actions = {
|
||||
IconButton(onClick = onAddClick) {
|
||||
Icon(painterResource(R.drawable.ic_add_24dp), contentDescription = stringResource(R.string.menu_item_add_config))
|
||||
}
|
||||
IconButton(onClick = onSubUpdate) {
|
||||
Icon(painterResource(R.drawable.ic_restore_24dp), contentDescription = stringResource(R.string.title_sub_update))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
LazyColumn(
|
||||
state = lazyListState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScrollbar(lazyListState)
|
||||
) {
|
||||
itemsIndexed(
|
||||
items = subscriptions,
|
||||
key = { _, item -> item.guid }
|
||||
) { _, subCache ->
|
||||
ReorderableItem(reorderableState, key = subCache.guid) { isDragging ->
|
||||
ReorderableListItem(
|
||||
scope = this,
|
||||
isDragging = isDragging
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = subCache.subscription.remarks,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
if (subCache.subscription.url.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subCache.subscription.url,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = Utils.formatTimestamp(subCache.subscription.lastUpdated),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.graphics.Color
|
||||
import android.text.TextUtils
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ItemRecyclerSubSettingBinding
|
||||
import com.v2ray.ang.helper.ItemTouchHelperAdapter
|
||||
import com.v2ray.ang.helper.ItemTouchHelperViewHolder
|
||||
import com.v2ray.ang.util.Utils
|
||||
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
||||
|
||||
class SubSettingRecyclerAdapter(
|
||||
private val viewModel: SubscriptionsViewModel,
|
||||
private val adapterListener: BaseAdapterListener?
|
||||
) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter {
|
||||
|
||||
override fun getItemCount() = viewModel.getAll().size
|
||||
|
||||
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
|
||||
val subscriptions = viewModel.getAll()
|
||||
val subId = subscriptions[position].guid
|
||||
val subItem = subscriptions[position].subscription
|
||||
holder.itemSubSettingBinding.tvName.text = subItem.remarks
|
||||
holder.itemSubSettingBinding.tvUrl.text = subItem.url
|
||||
holder.itemSubSettingBinding.chkEnable.isChecked = subItem.enabled
|
||||
holder.itemSubSettingBinding.tvLastUpdated.text = Utils.formatTimestamp(subItem.lastUpdated)
|
||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
|
||||
adapterListener?.onEdit(subId, position)
|
||||
}
|
||||
|
||||
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
|
||||
adapterListener?.onRemove(subId, position)
|
||||
}
|
||||
|
||||
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
|
||||
if (!it.isPressed) return@setOnCheckedChangeListener
|
||||
subItem.enabled = isChecked
|
||||
viewModel.update(subId, subItem)
|
||||
}
|
||||
|
||||
if (TextUtils.isEmpty(subItem.url)) {
|
||||
holder.itemSubSettingBinding.layoutUrl.visibility = View.GONE
|
||||
holder.itemSubSettingBinding.layoutShare.visibility = View.INVISIBLE
|
||||
holder.itemSubSettingBinding.chkEnable.visibility = View.INVISIBLE
|
||||
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.INVISIBLE
|
||||
} else {
|
||||
holder.itemSubSettingBinding.layoutUrl.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.layoutShare.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE
|
||||
holder.itemSubSettingBinding.layoutShare.setOnClickListener {
|
||||
adapterListener?.onShare(subItem.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MainViewHolder {
|
||||
return MainViewHolder(
|
||||
ItemRecyclerSubSettingBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
class MainViewHolder(val itemSubSettingBinding: ItemRecyclerSubSettingBinding) :
|
||||
BaseViewHolder(itemSubSettingBinding.root), ItemTouchHelperViewHolder
|
||||
|
||||
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
|
||||
fun onItemSelected() {
|
||||
itemView.setBackgroundColor(Color.LTGRAY)
|
||||
}
|
||||
|
||||
fun onItemClear() {
|
||||
itemView.setBackgroundColor(0)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
|
||||
viewModel.swap(fromPosition, toPosition)
|
||||
notifyItemMoved(fromPosition, toPosition)
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onItemMoveCompleted() {
|
||||
adapterListener?.onRefreshData()
|
||||
}
|
||||
|
||||
override fun onItemDismiss(position: Int) {
|
||||
}
|
||||
}
|
||||
@@ -3,30 +3,52 @@ package com.v2ray.ang.ui
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.ListView
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.itemsIndexed
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.RadioButton
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityTaskerBinding
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.SettingsSwitchItem
|
||||
import com.v2ray.ang.compose.verticalScrollbar
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
|
||||
class TaskerActivity : BaseActivity() {
|
||||
private val binding by lazy { ActivityTaskerBinding.inflate(layoutInflater) }
|
||||
class TaskerActivity : BaseComponentActivity() {
|
||||
|
||||
private var listview: ListView? = null
|
||||
private var lstData: ArrayList<String> = ArrayList()
|
||||
private var lstGuid: ArrayList<String> = ArrayList()
|
||||
|
||||
private val switchState = mutableStateOf(false)
|
||||
private val selectedPosition = mutableStateOf(-1)
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "")
|
||||
|
||||
//add def value
|
||||
lstData.add("Default")
|
||||
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
|
||||
|
||||
@@ -36,16 +58,21 @@ class TaskerActivity : BaseActivity() {
|
||||
lstGuid.add(key)
|
||||
}
|
||||
}
|
||||
val adapter = ArrayAdapter(
|
||||
this,
|
||||
android.R.layout.simple_list_item_single_choice, lstData
|
||||
)
|
||||
listview = findViewById<View>(R.id.listview) as ListView
|
||||
listview?.adapter = adapter
|
||||
|
||||
init()
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun ScreenContent() {
|
||||
TaskerScreen(
|
||||
items = lstData,
|
||||
switchState = switchState,
|
||||
selectedPosition = selectedPosition,
|
||||
onBackClick = { finish() },
|
||||
onSave = { confirmFinish() }
|
||||
)
|
||||
}
|
||||
|
||||
private fun init() {
|
||||
try {
|
||||
val bundle = intent?.getBundleExtra(AppConfig.TASKER_EXTRA_BUNDLE)
|
||||
@@ -55,31 +82,30 @@ class TaskerActivity : BaseActivity() {
|
||||
if (switch == null || TextUtils.isEmpty(guid)) {
|
||||
return
|
||||
} else {
|
||||
binding.switchStartService.isChecked = switch
|
||||
switchState.value = switch
|
||||
val pos = lstGuid.indexOf(guid.toString())
|
||||
if (pos >= 0) {
|
||||
listview?.setItemChecked(pos, true)
|
||||
selectedPosition.value = pos
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
private fun confirmFinish() {
|
||||
val position = listview?.checkedItemPosition
|
||||
if (position == null || position < 0) {
|
||||
val position = selectedPosition.value
|
||||
if (position < 0) {
|
||||
return
|
||||
}
|
||||
|
||||
val extraBundle = Bundle()
|
||||
extraBundle.putBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, binding.switchStartService.isChecked)
|
||||
extraBundle.putBoolean(AppConfig.TASKER_EXTRA_BUNDLE_SWITCH, switchState.value)
|
||||
extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, lstGuid[position])
|
||||
val intent = Intent()
|
||||
|
||||
val remarks = lstData[position]
|
||||
val blurb = if (binding.switchStartService.isChecked) {
|
||||
val blurb = if (switchState.value) {
|
||||
"Start $remarks"
|
||||
} else {
|
||||
"Stop $remarks"
|
||||
@@ -90,26 +116,60 @@ class TaskerActivity : BaseActivity() {
|
||||
setResult(RESULT_OK, intent)
|
||||
finish()
|
||||
}
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.action_server, menu)
|
||||
val delConfig = menu.findItem(R.id.del_config)
|
||||
delConfig?.isVisible = false
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.del_config -> {
|
||||
true
|
||||
}
|
||||
|
||||
R.id.save_config -> {
|
||||
confirmFinish()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun TaskerScreen(
|
||||
items: List<String>,
|
||||
switchState: MutableState<Boolean>,
|
||||
selectedPosition: MutableState<Int>,
|
||||
onBackClick: () -> Unit,
|
||||
onSave: () -> Unit
|
||||
) {
|
||||
val listState = rememberLazyListState()
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = "",
|
||||
onBackClick = onBackClick,
|
||||
actions = {
|
||||
IconButton(onClick = onSave) {
|
||||
Icon(painterResource(R.drawable.ic_fab_check), contentDescription = stringResource(R.string.menu_item_save_config))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||
SettingsSwitchItem(
|
||||
title = stringResource(R.string.tasker_start_service),
|
||||
checked = switchState.value,
|
||||
onCheckedChange = { switchState.value = it }
|
||||
)
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScrollbar(listState)
|
||||
) {
|
||||
itemsIndexed(items) { index, remarks ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { selectedPosition.value = index }
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
RadioButton(
|
||||
selected = selectedPosition.value == index,
|
||||
onClick = { selectedPosition.value = index }
|
||||
)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(text = remarks, style = MaterialTheme.typography.bodyLarge)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Boolean>,
|
||||
geoFilesSourceState: MutableStateFlow<String>,
|
||||
refreshTrigger: MutableStateFlow<Int>,
|
||||
geoFilesSourcesList: List<String>,
|
||||
onBackClick: () -> Unit,
|
||||
onGeoSourceSelected: (String) -> Unit,
|
||||
onAddFileClick: () -> Unit,
|
||||
onAddUrlClick: () -> Unit,
|
||||
onAddQrcodeClick: () -> Unit,
|
||||
onDownloadClick: () -> Unit,
|
||||
onEditAsset: (String) -> Unit,
|
||||
onRemoveAsset: (String) -> Unit
|
||||
) {
|
||||
val isLoading by isLoadingState.collectAsState()
|
||||
val geoFilesSource by geoFilesSourceState.collectAsState()
|
||||
val assets by viewModel.assetsFlow.collectAsStateWithLifecycle()
|
||||
val trigger by refreshTrigger.collectAsState()
|
||||
|
||||
var showAddMenu by remember { mutableStateOf(false) }
|
||||
var showGeoSourceDialog by remember { mutableStateOf(false) }
|
||||
var deleteTargetGuid by remember { mutableStateOf<String?>(null) }
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
Scaffold(
|
||||
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||
topBar = {
|
||||
AppTopBar(
|
||||
title = stringResource(R.string.title_user_asset_setting),
|
||||
onBackClick = onBackClick,
|
||||
isLoading = isLoading,
|
||||
actions = {
|
||||
Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
|
||||
IconButton(onClick = { showAddMenu = true }) {
|
||||
Icon(painterResource(R.drawable.ic_add_24dp), contentDescription = stringResource(R.string.menu_item_add_asset))
|
||||
}
|
||||
DropdownMenu(
|
||||
expanded = showAddMenu,
|
||||
onDismissRequest = { showAddMenu = false },
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
offset = DpOffset(x = 0.dp, y = 0.dp),
|
||||
modifier = Modifier.wrapContentWidth(Alignment.End)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_add_file)) },
|
||||
onClick = { showAddMenu = false; onAddFileClick() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_add_url)) },
|
||||
onClick = { showAddMenu = false; onAddUrlClick() }
|
||||
)
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.menu_item_scan_qrcode)) },
|
||||
onClick = { showAddMenu = false; onAddQrcodeClick() }
|
||||
)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDownloadClick) {
|
||||
Icon(painterResource(R.drawable.ic_cloud_download_24dp), contentDescription = stringResource(R.string.menu_item_download_file))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
package com.v2ray.ang.ui
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
||||
import com.v2ray.ang.databinding.ItemRecyclerUserAssetBinding
|
||||
import com.v2ray.ang.extension.toTrafficString
|
||||
import com.v2ray.ang.viewmodel.UserAssetViewModel
|
||||
import java.io.File
|
||||
import java.text.DateFormat
|
||||
import java.util.Date
|
||||
|
||||
class UserAssetAdapter(
|
||||
private val viewModel: UserAssetViewModel,
|
||||
private val extDir: File,
|
||||
private val adapterListener: BaseAdapterListener?
|
||||
) : RecyclerView.Adapter<UserAssetAdapter.UserAssetViewHolder>() {
|
||||
|
||||
override fun getItemCount() = viewModel.itemCount
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): UserAssetViewHolder {
|
||||
return UserAssetViewHolder(
|
||||
ItemRecyclerUserAssetBinding.inflate(
|
||||
LayoutInflater.from(parent.context),
|
||||
parent,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
override fun onBindViewHolder(holder: UserAssetViewHolder, position: Int) {
|
||||
val item = viewModel.getAsset(position) ?: return
|
||||
val file = extDir.listFiles()?.find { it.name == item.assetUrl.remarks }
|
||||
|
||||
holder.itemUserAssetBinding.assetName.text = item.assetUrl.remarks
|
||||
|
||||
if (file != null) {
|
||||
val dateFormat = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.MEDIUM)
|
||||
holder.itemUserAssetBinding.assetProperties.text =
|
||||
"${file.length().toTrafficString()} • ${dateFormat.format(Date(file.lastModified()))}"
|
||||
} else {
|
||||
holder.itemUserAssetBinding.assetProperties.text =
|
||||
holder.itemUserAssetBinding.root.context.getString(R.string.msg_file_not_found)
|
||||
}
|
||||
|
||||
if (item.assetUrl.locked == true) {
|
||||
holder.itemUserAssetBinding.layoutEdit.visibility = View.GONE
|
||||
} else {
|
||||
holder.itemUserAssetBinding.layoutEdit.visibility = if (item.assetUrl.url == "file") {
|
||||
View.GONE
|
||||
} else {
|
||||
View.VISIBLE
|
||||
}
|
||||
}
|
||||
|
||||
holder.itemUserAssetBinding.layoutEdit.setOnClickListener {
|
||||
adapterListener?.onEdit(item.guid, position)
|
||||
}
|
||||
holder.itemUserAssetBinding.layoutRemove.setOnClickListener {
|
||||
adapterListener?.onRemove(item.guid, position)
|
||||
}
|
||||
}
|
||||
|
||||
class UserAssetViewHolder(val itemUserAssetBinding: ItemRecyclerUserAssetBinding) :
|
||||
RecyclerView.ViewHolder(itemUserAssetBinding.root)
|
||||
}
|
||||
@@ -2,12 +2,29 @@ package com.v2ray.ang.ui
|
||||
|
||||
import android.os.Bundle
|
||||
import android.text.TextUtils
|
||||
import android.view.Menu
|
||||
import android.view.MenuItem
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.ScaffoldDefaults
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.R
|
||||
import com.v2ray.ang.databinding.ActivityUserAssetUrlBinding
|
||||
import com.v2ray.ang.compose.AppTopBar
|
||||
import com.v2ray.ang.compose.ConfirmDialog
|
||||
import com.v2ray.ang.compose.FormTextField
|
||||
import com.v2ray.ang.dto.entities.AssetUrlItem
|
||||
import com.v2ray.ang.extension.toast
|
||||
import com.v2ray.ang.extension.toastSuccess
|
||||
@@ -16,65 +33,55 @@ import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import java.io.File
|
||||
|
||||
class UserAssetUrlActivity : BaseActivity() {
|
||||
// Receive QRcode URL from UserAssetActivity
|
||||
class UserAssetUrlActivity : BaseComponentActivity() {
|
||||
companion object {
|
||||
const val ASSET_URL_QRCODE = "ASSET_URL_QRCODE"
|
||||
}
|
||||
|
||||
private val binding by lazy { ActivityUserAssetUrlBinding.inflate(layoutInflater) }
|
||||
|
||||
private var del_config: MenuItem? = null
|
||||
private var save_config: MenuItem? = null
|
||||
|
||||
private val extDir by lazy { File(Utils.userAssetPath(this)) }
|
||||
private val editAssetId by lazy { intent.getStringExtra("assetId").orEmpty() }
|
||||
private lateinit var initialRemarks: String
|
||||
private lateinit var initialUrl: String
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
//setContentView(binding.root)
|
||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_add_url))
|
||||
|
||||
val assetItem = MmkvManager.decodeAsset(editAssetId)
|
||||
val assetUrlQrcode = intent.getStringExtra(ASSET_URL_QRCODE)
|
||||
val assetNameQrcode = File(assetUrlQrcode.toString()).name
|
||||
when {
|
||||
assetItem != null -> bindingAsset(assetItem)
|
||||
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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
package com.v2ray.ang.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.util.AppManagerUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.Collator
|
||||
|
||||
/**
|
||||
* ViewModel for AppPicker screen.
|
||||
*/
|
||||
class AppPickerViewModel : ViewModel() {
|
||||
|
||||
private val _selectedPackages = MutableStateFlow<Set<String>>(emptySet())
|
||||
val selectedPackages: StateFlow<Set<String>> = _selectedPackages.asStateFlow()
|
||||
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
private val _displayedApps = MutableStateFlow<List<AppInfo>>(emptyList())
|
||||
val displayedApps: StateFlow<List<AppInfo>> = _displayedApps.asStateFlow()
|
||||
|
||||
private var appsAll: List<AppInfo> = emptyList()
|
||||
private var currentQuery: String = ""
|
||||
private var SelectedSnapshot: Set<String> = emptySet()
|
||||
|
||||
fun initialize(initialSelected: Collection<String>) {
|
||||
_selectedPackages.value = initialSelected.toSet()
|
||||
}
|
||||
|
||||
fun loadApps(context: Context) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
SelectedSnapshot = _selectedPackages.value.toSet()
|
||||
val apps = withContext(Dispatchers.IO) {
|
||||
val list = AppManagerUtil.loadNetworkAppList(context)
|
||||
val special = createSpecialItemUnidentified(context)
|
||||
sortApps(list + special)
|
||||
}
|
||||
appsAll = apps
|
||||
_displayedApps.value = applyFilter(currentQuery)
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e("AppPickerViewModel", "Failed to load app list", e)
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun filterApps(query: String) {
|
||||
currentQuery = query
|
||||
_displayedApps.value = applyFilter(query)
|
||||
}
|
||||
|
||||
fun toggleApp(packageName: String) {
|
||||
val current = _selectedPackages.value
|
||||
_selectedPackages.value = if (current.contains(packageName)) {
|
||||
current - packageName
|
||||
} else {
|
||||
current + packageName
|
||||
}
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
val displayedPackages = _displayedApps.value.map { it.packageName }.toSet()
|
||||
_selectedPackages.value = _selectedPackages.value + displayedPackages
|
||||
}
|
||||
|
||||
fun invertSelection() {
|
||||
val current = _selectedPackages.value
|
||||
val displayedPackages = _displayedApps.value.map { it.packageName }.toSet()
|
||||
_selectedPackages.value = (current - displayedPackages) +
|
||||
(displayedPackages - current)
|
||||
}
|
||||
|
||||
fun getSelectedPackages(): List<String> = _selectedPackages.value.sorted()
|
||||
|
||||
private fun applyFilter(query: String): List<AppInfo> {
|
||||
if (query.isBlank()) return appsAll
|
||||
val key = query.uppercase()
|
||||
return appsAll.filter {
|
||||
it.appName.uppercase().contains(key) || it.packageName.uppercase().contains(key)
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
|
||||
val collator = Collator.getInstance()
|
||||
val snapshot = SelectedSnapshot
|
||||
return apps.sortedWith { p1, p2 ->
|
||||
val p1Selected = snapshot.contains(p1.packageName)
|
||||
val p2Selected = snapshot.contains(p2.packageName)
|
||||
when {
|
||||
p1Selected && !p2Selected -> -1
|
||||
!p1Selected && p2Selected -> 1
|
||||
p1.isSystemApp && !p2.isSystemApp -> 1
|
||||
!p1.isSystemApp && p2.isSystemApp -> -1
|
||||
else -> collator.compare(p1.appName, p2.appName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun createSpecialItemUnidentified(context: Context): AppInfo {
|
||||
val icon = context.getDrawable(android.R.drawable.ic_menu_help)
|
||||
?: context.getDrawable(android.R.drawable.sym_def_app_icon)
|
||||
?: error("No fallback drawable available")
|
||||
return AppInfo(
|
||||
appName = context.getString(com.v2ray.ang.R.string.app_picker_unknown_app),
|
||||
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
|
||||
appIcon = icon,
|
||||
isSystemApp = false,
|
||||
isSelected = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,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<String> = mutableListOf()
|
||||
private var filteredLogs: List<String> = emptyList()
|
||||
private var currentFilter: String = ""
|
||||
|
||||
fun getAll(): List<String> = filteredLogs
|
||||
private val _filteredLogs = MutableStateFlow<List<String>>(emptyList())
|
||||
val filteredLogs: StateFlow<List<String>> = _filteredLogs.asStateFlow()
|
||||
|
||||
private val _isRefreshing = MutableStateFlow(false)
|
||||
val isRefreshing: StateFlow<Boolean> = _isRefreshing.asStateFlow()
|
||||
|
||||
fun loadLogcat() {
|
||||
try {
|
||||
val lst = LinkedHashSet<String>()
|
||||
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<String>()
|
||||
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) }
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,79 @@
|
||||
package com.v2ray.ang.viewmodel
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.v2ray.ang.AppConfig
|
||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||
import com.v2ray.ang.dto.AppInfo
|
||||
import com.v2ray.ang.dto.UrlContentRequest
|
||||
import com.v2ray.ang.extension.v2RayApplication
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import com.v2ray.ang.util.AppManagerUtil
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.text.Collator
|
||||
|
||||
/**
|
||||
* ViewModel for PerAppProxy screen.
|
||||
* Holds all UI state and business logic.
|
||||
*/
|
||||
class PerAppProxyViewModel : ViewModel() {
|
||||
private val blacklist: MutableSet<String> = MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)?.let {
|
||||
HashSet(it)
|
||||
} ?: HashSet()
|
||||
|
||||
fun contains(packageName: String): Boolean = blacklist.contains(packageName)
|
||||
// Blacklist (apps to be proxied or bypassed)
|
||||
private val _blacklist = MutableStateFlow(loadBlacklist())
|
||||
val blacklist: StateFlow<Set<String>> = _blacklist.asStateFlow()
|
||||
|
||||
fun getAll(): Set<String> = blacklist.toSet()
|
||||
// UI states
|
||||
private val _isLoading = MutableStateFlow(false)
|
||||
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
|
||||
|
||||
fun add(packageName: String): Boolean {
|
||||
val changed = blacklist.add(packageName)
|
||||
if (changed) {
|
||||
save()
|
||||
private val _displayedApps = MutableStateFlow<List<AppInfo>>(emptyList())
|
||||
val displayedApps: StateFlow<List<AppInfo>> = _displayedApps.asStateFlow()
|
||||
|
||||
private val _perAppProxyEnabled = MutableStateFlow(
|
||||
MmkvManager.decodeSettingsBool(AppConfig.PREF_PER_APP_PROXY, false)
|
||||
)
|
||||
val perAppProxyEnabled: StateFlow<Boolean> = _perAppProxyEnabled.asStateFlow()
|
||||
|
||||
private val _bypassApps = MutableStateFlow(
|
||||
MmkvManager.decodeSettingsBool(AppConfig.PREF_BYPASS_APPS, false)
|
||||
)
|
||||
val bypassApps: StateFlow<Boolean> = _bypassApps.asStateFlow()
|
||||
|
||||
// Cached full list for filtering
|
||||
private var appsAll: List<AppInfo>? = null
|
||||
|
||||
// Blacklist operations
|
||||
fun contains(packageName: String): Boolean = _blacklist.value.contains(packageName)
|
||||
|
||||
fun add(packageName: String) {
|
||||
val newSet = _blacklist.value + packageName
|
||||
if (newSet != _blacklist.value) {
|
||||
_blacklist.value = newSet
|
||||
saveBlacklist()
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
fun remove(packageName: String): Boolean {
|
||||
val changed = blacklist.remove(packageName)
|
||||
if (changed) {
|
||||
save()
|
||||
fun remove(packageName: String) {
|
||||
val newSet = _blacklist.value - packageName
|
||||
if (newSet != _blacklist.value) {
|
||||
_blacklist.value = newSet
|
||||
saveBlacklist()
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
fun toggle(packageName: String) {
|
||||
if (blacklist.contains(packageName)) {
|
||||
if (_blacklist.value.contains(packageName)) {
|
||||
remove(packageName)
|
||||
} else {
|
||||
add(packageName)
|
||||
@@ -39,26 +81,202 @@ class PerAppProxyViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun addAll(packages: Collection<String>) {
|
||||
if (blacklist.addAll(packages)) {
|
||||
save()
|
||||
val newSet = _blacklist.value + packages
|
||||
if (newSet != _blacklist.value) {
|
||||
_blacklist.value = newSet
|
||||
saveBlacklist()
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAll(packages: Collection<String>) {
|
||||
if (blacklist.removeAll(packages.toSet())) {
|
||||
save()
|
||||
val newSet = _blacklist.value - packages.toSet()
|
||||
if (newSet != _blacklist.value) {
|
||||
_blacklist.value = newSet
|
||||
saveBlacklist()
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
if (blacklist.isNotEmpty()) {
|
||||
blacklist.clear()
|
||||
save()
|
||||
if (_blacklist.value.isNotEmpty()) {
|
||||
_blacklist.value = emptySet()
|
||||
saveBlacklist()
|
||||
}
|
||||
}
|
||||
|
||||
private fun save() {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, blacklist)
|
||||
private fun loadBlacklist(): Set<String> {
|
||||
return MmkvManager.decodeSettingsStringSet(AppConfig.PREF_PER_APP_PROXY_SET)?.toSet() ?: emptySet()
|
||||
}
|
||||
|
||||
private fun saveBlacklist() {
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, _blacklist.value.toMutableSet())
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
}
|
||||
|
||||
// Per‑app proxy switch
|
||||
fun setPerAppProxyEnabled(enabled: Boolean) {
|
||||
if (_perAppProxyEnabled.value != enabled) {
|
||||
_perAppProxyEnabled.value = enabled
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
fun setBypassAppsEnabled(enabled: Boolean) {
|
||||
if (_bypassApps.value != enabled) {
|
||||
_bypassApps.value = enabled
|
||||
MmkvManager.encodeSettings(AppConfig.PREF_BYPASS_APPS, enabled)
|
||||
}
|
||||
}
|
||||
|
||||
// Load and filter apps
|
||||
fun loadApps(context: Context) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
try {
|
||||
val apps = withContext(Dispatchers.IO) {
|
||||
val list = AppManagerUtil.loadNetworkAppList(context)
|
||||
sortApps(list)
|
||||
}
|
||||
appsAll = apps
|
||||
_displayedApps.value = apps
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(ANG_PACKAGE, "Error loading apps", e)
|
||||
} finally {
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun filterApps(query: String) {
|
||||
val key = query.uppercase()
|
||||
_displayedApps.value = if (key.isNotEmpty()) {
|
||||
appsAll?.filter {
|
||||
it.appName.uppercase().contains(key) || it.packageName.uppercase().contains(key)
|
||||
} ?: emptyList()
|
||||
} else {
|
||||
appsAll ?: emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortApps(apps: List<AppInfo>): List<AppInfo> {
|
||||
val collator = Collator.getInstance()
|
||||
return apps.sortedWith { p1, p2 ->
|
||||
val s1 = contains(p1.packageName)
|
||||
val s2 = contains(p2.packageName)
|
||||
when {
|
||||
s1 && !s2 -> -1
|
||||
!s1 && s2 -> 1
|
||||
p1.isSystemApp && !p2.isSystemApp -> 1
|
||||
!p1.isSystemApp && p2.isSystemApp -> -1
|
||||
else -> collator.compare(p1.appName, p2.appName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bulk actions
|
||||
fun selectAll() {
|
||||
val pkgNames = _displayedApps.value.map { it.packageName }
|
||||
val allSelected = pkgNames.all { contains(it) }
|
||||
if (allSelected) removeAll(pkgNames) else addAll(pkgNames)
|
||||
// Enable per‑app proxy after selection
|
||||
enablePerAppProxyAndRestart()
|
||||
}
|
||||
|
||||
fun invertSelection() {
|
||||
_displayedApps.value.forEach { toggle(it.packageName) }
|
||||
enablePerAppProxyAndRestart()
|
||||
}
|
||||
|
||||
fun selectProxyAppAuto(context: Context) {
|
||||
viewModelScope.launch {
|
||||
_isLoading.value = true
|
||||
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
|
||||
var content = withContext(Dispatchers.IO) {
|
||||
HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000
|
||||
)
|
||||
)
|
||||
}
|
||||
if (content.isNullOrEmpty()) {
|
||||
val proxyUsername = SettingsManager.getSocksUsername()
|
||||
val proxyPassword = SettingsManager.getSocksPassword()
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
content = withContext(Dispatchers.IO) {
|
||||
HttpUtil.getUrlContent(
|
||||
UrlContentRequest(
|
||||
url = url,
|
||||
timeout = 5000,
|
||||
httpPort = httpPort,
|
||||
proxyUsername = proxyUsername,
|
||||
proxyPassword = proxyPassword
|
||||
)
|
||||
)
|
||||
} ?: ""
|
||||
}
|
||||
val success = selectProxyApp(content, true, context)
|
||||
if (success) {
|
||||
enablePerAppProxyAndRestart()
|
||||
}
|
||||
_isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
fun importProxyApp(content: String?, context: Context) {
|
||||
if (content.isNullOrEmpty()) return
|
||||
val success = selectProxyApp(content, false, context)
|
||||
if (success) {
|
||||
enablePerAppProxyAndRestart()
|
||||
}
|
||||
}
|
||||
|
||||
fun exportProxyApp(): String {
|
||||
var result = _bypassApps.value.toString()
|
||||
_blacklist.value.forEach { pkg ->
|
||||
result = result + System.lineSeparator() + pkg
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private fun selectProxyApp(content: String, force: Boolean, context: Context): Boolean {
|
||||
try {
|
||||
val proxyApps = if (content.isEmpty()) {
|
||||
Utils.readTextFromAssets(context, "proxy_package_name")
|
||||
} else content
|
||||
if (proxyApps.isNullOrEmpty()) return false
|
||||
|
||||
clear()
|
||||
val apps = _displayedApps.value
|
||||
if (_bypassApps.value) {
|
||||
apps.forEach { app ->
|
||||
if (!inProxyApps(proxyApps, app.packageName, force)) {
|
||||
add(app.packageName)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
apps.forEach { app ->
|
||||
if (inProxyApps(proxyApps, app.packageName, force)) {
|
||||
add(app.packageName)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LogUtil.e(AppConfig.TAG, "Error selecting proxy app", e)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
|
||||
if (force) {
|
||||
if (packageName == "com.google.android.webview") return false
|
||||
if (packageName.startsWith("com.google")) return true
|
||||
}
|
||||
return proxyApps.indexOf(packageName) >= 0
|
||||
}
|
||||
|
||||
private fun enablePerAppProxyAndRestart() {
|
||||
setPerAppProxyEnabled(true)
|
||||
SettingsChangeManager.makeRestartService()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,28 +4,48 @@ import androidx.lifecycle.ViewModel
|
||||
import com.v2ray.ang.dto.entities.RulesetItem
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.util.UUID
|
||||
|
||||
class RoutingSettingsViewModel : ViewModel() {
|
||||
private val rulesets: MutableList<RulesetItem> = mutableListOf()
|
||||
|
||||
private val _rulesetsFlow = MutableStateFlow<List<RulesetItem>>(emptyList())
|
||||
val rulesetsFlow: StateFlow<List<RulesetItem>> = _rulesetsFlow.asStateFlow()
|
||||
|
||||
fun getAll(): List<RulesetItem> = rulesets.toList()
|
||||
|
||||
fun reload() {
|
||||
val loaded = MmkvManager.decodeRoutingRulesets()?.toMutableList() ?: mutableListOf()
|
||||
var needsSave = false
|
||||
loaded.forEachIndexed { index, item ->
|
||||
if (item.id.isEmpty()) {
|
||||
item.id = UUID.randomUUID().toString()
|
||||
SettingsManager.saveRoutingRuleset(index, item)
|
||||
needsSave = true
|
||||
}
|
||||
}
|
||||
rulesets.clear()
|
||||
rulesets.addAll(MmkvManager.decodeRoutingRulesets() ?: mutableListOf())
|
||||
rulesets.addAll(loaded)
|
||||
_rulesetsFlow.value = rulesets.toList()
|
||||
}
|
||||
|
||||
fun update(position: Int, item: RulesetItem) {
|
||||
if (position in rulesets.indices) {
|
||||
rulesets[position] = item
|
||||
SettingsManager.saveRoutingRuleset(position, item)
|
||||
_rulesetsFlow.value = rulesets.toList()
|
||||
}
|
||||
}
|
||||
|
||||
fun swap(fromPosition: Int, toPosition: Int) {
|
||||
if (fromPosition in rulesets.indices && toPosition in rulesets.indices) {
|
||||
SettingsManager.swapRoutingRuleset(fromPosition, toPosition)
|
||||
val item = rulesets.removeAt(fromPosition)
|
||||
rulesets.add(toPosition, item)
|
||||
_rulesetsFlow.value = rulesets.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,16 +6,23 @@ import com.v2ray.ang.dto.entities.SubscriptionItem
|
||||
import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.handler.SettingsChangeManager
|
||||
import com.v2ray.ang.handler.SettingsManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
|
||||
class SubscriptionsViewModel : ViewModel() {
|
||||
private val subscriptions: MutableList<SubscriptionCache> =
|
||||
MmkvManager.decodeSubscriptions().toMutableList()
|
||||
|
||||
private val _subsFlow = MutableStateFlow<List<SubscriptionCache>>(subscriptions.toList())
|
||||
val subsFlow: StateFlow<List<SubscriptionCache>> = _subsFlow.asStateFlow()
|
||||
|
||||
fun getAll(): List<SubscriptionCache> = subscriptions.toList()
|
||||
|
||||
fun reload() {
|
||||
subscriptions.clear()
|
||||
subscriptions.addAll(MmkvManager.decodeSubscriptions())
|
||||
_subsFlow.value = subscriptions.toList()
|
||||
}
|
||||
|
||||
fun remove(subId: String): Boolean {
|
||||
@@ -24,6 +31,7 @@ class SubscriptionsViewModel : ViewModel() {
|
||||
SettingsManager.removeSubscriptionWithDefault(subId)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
}
|
||||
_subsFlow.value = subscriptions.toList()
|
||||
return changed
|
||||
}
|
||||
|
||||
@@ -33,6 +41,7 @@ class SubscriptionsViewModel : ViewModel() {
|
||||
subscriptions[idx] = SubscriptionCache(subId, item)
|
||||
MmkvManager.encodeSubscription(subId, item)
|
||||
}
|
||||
_subsFlow.value = subscriptions.toList()
|
||||
}
|
||||
|
||||
fun swap(fromPosition: Int, toPosition: Int) {
|
||||
@@ -41,7 +50,7 @@ class SubscriptionsViewModel : ViewModel() {
|
||||
subscriptions.add(toPosition, item)
|
||||
SettingsManager.swapSubscriptions(fromPosition, toPosition)
|
||||
SettingsChangeManager.makeSetupGroupTab()
|
||||
_subsFlow.value = subscriptions.toList()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,12 +10,18 @@ import com.v2ray.ang.handler.MmkvManager
|
||||
import com.v2ray.ang.util.HttpUtil
|
||||
import com.v2ray.ang.util.LogUtil
|
||||
import com.v2ray.ang.util.Utils
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import java.io.File
|
||||
|
||||
class UserAssetViewModel : ViewModel() {
|
||||
private val assets = mutableListOf<AssetUrlCache>()
|
||||
private val builtInGeoFiles = listOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
||||
|
||||
private val _assetsFlow = MutableStateFlow<List<AssetUrlCache>>(emptyList())
|
||||
val assetsFlow: StateFlow<List<AssetUrlCache>> = _assetsFlow.asStateFlow()
|
||||
|
||||
val itemCount: Int
|
||||
get() = assets.size
|
||||
|
||||
@@ -27,6 +33,7 @@ class UserAssetViewModel : ViewModel() {
|
||||
val decoded = MmkvManager.decodeAssetUrls()
|
||||
assets.clear()
|
||||
assets.addAll(buildAssetList(decoded, geoFilesSource))
|
||||
_assetsFlow.value = assets.toList()
|
||||
}
|
||||
|
||||
private fun buildAssetList(
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:alpha="@dimen/highlight_alpha_material_colored" android:color="?android:attr/colorControlActivated" android:state_checked="true" android:state_enabled="true" />
|
||||
<item android:color="?android:attr/colorControlHighlight" />
|
||||
</selector>
|
||||
@@ -1,13 +0,0 @@
|
||||
<!-- res/drawable/custom_divider.xml -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:bottom="0dp"
|
||||
android:left="16dp"
|
||||
android:right="16dp"
|
||||
android:top="0dp">
|
||||
<shape>
|
||||
<size android:height="1dp" />
|
||||
<solid android:color="@color/divider_color_light" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z"/>
|
||||
</vector>
|
||||
@@ -1,7 +0,0 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/divider_color_light" />
|
||||
<size
|
||||
android:width="48dp"
|
||||
android:height="48dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3.27,3L2,4.27l5,5V13h3v9l3.58,-6.14L17.73,20 19,18.73 3.27,3zM17,10h-4l4,-8H7v2.18l8.46,8.46L17,10z"/>
|
||||
</vector>
|
||||
+2
-4
@@ -1,11 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="?attr/colorControlNormal"
|
||||
android:pathData="M7,10l5,5 5,-5z" />
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z"/>
|
||||
</vector>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M3,18h18v-2H3v2zM3,13h18v-2H3v2zM3,6v2h18V6H3z"/>
|
||||
</vector>
|
||||
@@ -6,5 +6,5 @@
|
||||
android:left="0dp"
|
||||
android:right="0dp"
|
||||
android:top="0dp" />
|
||||
<solid android:color="@color/color_fab_active" />
|
||||
<solid android:color="#f97910" />
|
||||
</shape>
|
||||
@@ -6,5 +6,5 @@
|
||||
android:left="0dp"
|
||||
android:right="0dp"
|
||||
android:top="0dp" />
|
||||
<solid android:color="@color/color_fab_inactive" />
|
||||
<solid android:color="#9C9C9C" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/>
|
||||
</vector>
|
||||
@@ -1,7 +1,6 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960">
|
||||
<path
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="top"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_soure_ccode"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_source_code_24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/title_source_code"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_oss_licenses"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/license_24px" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/title_oss_license"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_feedback"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_feedback_24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/title_pref_feedback"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_tg_channel"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_telegram_24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/title_tg_channel"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_privacy_policy"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_privacy_24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/title_privacy_policy"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_version"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title_about"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_app_id"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title_about"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.recyclerview.widget.RecyclerView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
|
||||
|
||||
|
||||
@@ -1,146 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="top"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_backup"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_backup_24dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title_configuration_backup"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_share"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_share_24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/title_configuration_share"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_restore"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_restore_24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/title_configuration_restore"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="top"
|
||||
android:orientation="vertical"
|
||||
android:paddingTop="@dimen/padding_spacing_dp16">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_webdav_config_setting"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_settings_24dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title_webdav_config_setting"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:titleTextAppearance="@style/TextAppearance.AppCompat.Title" />
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progress_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
app:indicatorColor="@color/color_fab_active" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/content_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
@@ -1,98 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
android:orientation="vertical"
|
||||
tools:context=".ui.PerAppProxyActivity">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/header_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_per_app_proxy"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/switch_per_app_proxy"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="2"
|
||||
android:text="@string/per_app_proxy_settings_enable"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
app:theme="@style/BrandedSwitch" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/container_bypass_apps"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:layout_weight="1"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/switch_bypass_apps"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/switch_bypass_apps_mode"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
app:theme="@style/BrandedSwitch" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_switch_bypass_apps_tips"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/switch_bypass_apps_mode"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:padding="@dimen/padding_spacing_dp8">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_about_24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" />
|
||||
|
||||
</LinearLayout>
|
||||
@@ -1,83 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_source_code_24dp" />
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/check_pre_release"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="1"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/update_check_pre_release"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
app:theme="@style/BrandedSwitch" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_check_update"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_check_update_24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/update_check_for_update"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_version"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title_about"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".ui.LogcatActivity">
|
||||
|
||||
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
||||
android:id="@+id/refresh_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scrollbars="vertical"
|
||||
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
|
||||
|
||||
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
|
||||
|
||||
</RelativeLayout>
|
||||
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.drawerlayout.widget.DrawerLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:id="@+id/drawer_layout"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<androidx.appcompat.widget.Toolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<androidx.coordinatorlayout.widget.CoordinatorLayout
|
||||
android:id="@+id/main_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progress_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
android:visibility="invisible"
|
||||
app:indicatorColor="@color/color_fab_active" />
|
||||
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/tab_group"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:tabIndicatorFullWidth="false"
|
||||
app:tabMode="scrollable"
|
||||
app:tabTextAppearance="@style/TabLayoutTextStyle" />
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/view_pager"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:nextFocusRight="@+id/fab"
|
||||
android:scrollbars="vertical"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_test"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/view_height_dp64"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/connection_test_pending"
|
||||
android:focusable="true"
|
||||
android:nextFocusLeft="@+id/view_pager"
|
||||
android:nextFocusRight="@+id/fab"
|
||||
android:orientation="vertical">
|
||||
|
||||
<View
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/divider_color_light" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_test_state"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="start|center"
|
||||
android:maxLines="2"
|
||||
android:minLines="1"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/connection_test_pending"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginBottom="-16dp"
|
||||
android:layout_marginEnd="@dimen/padding_spacing_dp16">
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_marginBottom="@dimen/view_height_dp36"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/tasker_start_service"
|
||||
android:focusable="true"
|
||||
android:nextFocusLeft="@+id/layout_test"
|
||||
android:src="@drawable/ic_play_24dp"
|
||||
app:tint="@color/colorWhite"
|
||||
app:useCompatPadding="true"
|
||||
app:layout_anchorGravity="bottom|right|end" />
|
||||
|
||||
</FrameLayout>
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</RelativeLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<com.google.android.material.navigation.NavigationView
|
||||
android:id="@+id/nav_view"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
app:headerLayout="@layout/nav_header"
|
||||
app:menu="@menu/menu_drawer">
|
||||
|
||||
</com.google.android.material.navigation.NavigationView>
|
||||
|
||||
</androidx.drawerlayout.widget.DrawerLayout>
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent" />
|
||||
@@ -1,253 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".ui.RoutingEditActivity">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/sub_setting_remarks" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_remarks"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/chk_locked"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/routing_settings_locked"
|
||||
app:theme="@style/BrandedSwitch" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/routing_settings_rule_title"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/routing_settings_domain" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_domain"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/routing_settings_tips"
|
||||
android:inputType="textMultiLine"
|
||||
android:maxLines="10" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/routing_settings_ip" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_ip"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/routing_settings_tips"
|
||||
android:inputType="textMultiLine"
|
||||
android:maxLines="10" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/routing_settings_process" />
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:layout_marginBottom="@dimen/padding_spacing_dp8">
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_process"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/routing_settings_tips"
|
||||
android:inputType="textMultiLine"
|
||||
android:maxLines="10"
|
||||
android:minHeight="48dp"
|
||||
android:paddingStart="0dp"
|
||||
android:paddingEnd="48dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_process_picker"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/routing_settings_process"
|
||||
android:src="@drawable/ic_per_apps_24dp" />
|
||||
|
||||
</RelativeLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/routing_settings_port" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_port"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/routing_settings_protocol" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_protocol"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/routing_settings_protocol_tip"
|
||||
android:inputType="text" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/routing_settings_network" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_network"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:hint="@string/routing_settings_network_tip"
|
||||
android:inputType="text" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/routing_settings_outbound_tag" />
|
||||
|
||||
<RelativeLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:layout_marginBottom="@dimen/padding_spacing_dp8">
|
||||
|
||||
<AutoCompleteTextView
|
||||
android:id="@+id/sp_outbound_tag"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:completionThreshold="0"
|
||||
android:hint="@string/routing_settings_outbound_tag_hint"
|
||||
android:inputType="text"
|
||||
android:imeOptions="actionDone"
|
||||
android:paddingEnd="40dp" />
|
||||
|
||||
<ImageButton
|
||||
android:id="@+id/btn_outbound_tag_dropdown"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_centerVertical="true"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/routing_settings_outbound_tag"
|
||||
android:src="@drawable/ic_arrow_drop_down" />
|
||||
|
||||
</RelativeLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:layout_marginBottom="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -1,67 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".ui.RoutingSettingActivity">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/main_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_domain_strategy"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/routing_settings_domain_strategy"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_domain_strategy_summary"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:maxLines="1"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/routing_settings_rule_title"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_view"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:nestedScrollingEnabled="false"
|
||||
tools:listitem="@layout/item_recycler_routing_setting" />
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</RelativeLayout>
|
||||
@@ -1,79 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".ui.ServerCustomConfigActivity">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_need_inbound"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Tooltip" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title_server"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_remarks" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_remarks"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_content"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
|
||||
<com.blacksquircle.ui.editorkit.widget.TextProcessor
|
||||
android:id="@+id/editor"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="top|start" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -1,106 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".ui.ServerCustomConfigActivity">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title_server"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_remarks" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_remarks"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title_policy_group_type" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/sp_policy_group_type"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:layout_marginBottom="@dimen/padding_spacing_dp8"
|
||||
android:entries="@array/policy_group_type" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title_policy_group_subscription_id" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/sp_policy_group_sub_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:layout_marginBottom="@dimen/padding_spacing_dp8"
|
||||
android:entries="@array/policy_group_type" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/title_policy_group_subscription_filter" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_policy_group_filter"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -1,140 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".ui.ServerActivity">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
|
||||
<include layout="@layout/layout_address_port" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_id3" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_id"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_obfs_password" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_obfs_password"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_port_hop" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_port_hop"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_port_hop_interval" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_port_hop_interval"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_bandwidth_down" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_bandwidth_down"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_bandwidth_up" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_bandwidth_up"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:inputType="text" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<include layout="@layout/layout_tls_hysteria2" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp16"
|
||||
android:layout_marginBottom="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical" />
|
||||
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
@@ -1,72 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true"
|
||||
tools:context=".ui.ServerProxyChainActivity">
|
||||
|
||||
<androidx.core.widget.NestedScrollView
|
||||
android:id="@+id/main_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_lab_remarks"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/et_remarks"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:hint="@string/server_lab_remarks"
|
||||
android:inputType="text" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="@dimen/padding_spacing_dp16"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/server_proxy_chain_members"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<androidx.recyclerview.widget.RecyclerView
|
||||
android:id="@+id/recycler_proxy_chain_members"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:nestedScrollingEnabled="false"
|
||||
tools:listitem="@layout/item_recycler_proxy_chain_member" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fab_add_proxy_chain_member"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="end"
|
||||
android:layout_margin="@dimen/padding_spacing_dp16"
|
||||
android:contentDescription="@string/menu_item_add_config"
|
||||
android:src="@drawable/ic_add_24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
</androidx.core.widget.NestedScrollView>
|
||||
</RelativeLayout>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user