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 {
|
plugins {
|
||||||
alias(libs.plugins.android.application)
|
alias(libs.plugins.android.application)
|
||||||
alias(libs.plugins.kotlin.android)
|
alias(libs.plugins.kotlin.android)
|
||||||
|
alias(libs.plugins.kotlin.compose)
|
||||||
id("com.jaredsburrows.license")
|
id("com.jaredsburrows.license")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,7 +15,6 @@ android {
|
|||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 736
|
versionCode = 736
|
||||||
versionName = "2.2.6"
|
versionName = "2.2.6"
|
||||||
multiDexEnabled = true
|
|
||||||
|
|
||||||
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
|
||||||
splits {
|
splits {
|
||||||
@@ -124,8 +124,8 @@ android {
|
|||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
viewBinding = true
|
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
|
compose = true
|
||||||
}
|
}
|
||||||
|
|
||||||
packaging {
|
packaging {
|
||||||
@@ -142,20 +142,19 @@ dependencies {
|
|||||||
|
|
||||||
// AndroidX Core Libraries
|
// AndroidX Core Libraries
|
||||||
implementation(libs.androidx.core.ktx)
|
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
|
// Compose Libraries
|
||||||
implementation(libs.material)
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.toasty)
|
androidTestImplementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.editorkit)
|
|
||||||
implementation(libs.flexbox)
|
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
|
// Data and Storage Libraries
|
||||||
implementation(libs.mmkv.static)
|
implementation(libs.mmkv.static)
|
||||||
@@ -166,25 +165,23 @@ dependencies {
|
|||||||
implementation(libs.kotlinx.coroutines.android)
|
implementation(libs.kotlinx.coroutines.android)
|
||||||
implementation(libs.kotlinx.coroutines.core)
|
implementation(libs.kotlinx.coroutines.core)
|
||||||
|
|
||||||
// Language and Processing Libraries
|
// QR Code: CameraX + ZXing
|
||||||
implementation(libs.language.base)
|
implementation(libs.camerax.core)
|
||||||
implementation(libs.language.json)
|
implementation(libs.camerax.camera2)
|
||||||
|
implementation(libs.camerax.lifecycle)
|
||||||
// Intent and Utility Libraries
|
implementation(libs.camerax.compose)
|
||||||
implementation(libs.quickie.foss)
|
implementation(libs.core) // zxing core
|
||||||
implementation(libs.core)
|
|
||||||
|
|
||||||
// AndroidX Lifecycle and Architecture Components
|
// AndroidX Lifecycle and Architecture Components
|
||||||
implementation(libs.lifecycle.viewmodel.ktx)
|
implementation(libs.lifecycle.viewmodel.ktx)
|
||||||
implementation(libs.lifecycle.livedata.ktx)
|
|
||||||
implementation(libs.lifecycle.runtime.ktx)
|
implementation(libs.lifecycle.runtime.ktx)
|
||||||
|
|
||||||
// Background Task Libraries
|
// Background Task Libraries
|
||||||
implementation(libs.work.runtime.ktx)
|
implementation(libs.work.runtime.ktx)
|
||||||
implementation(libs.work.multiprocess)
|
implementation(libs.work.multiprocess)
|
||||||
|
|
||||||
// Multidex Support
|
// Reorderable list
|
||||||
implementation(libs.multidex)
|
implementation(libs.reorderable)
|
||||||
|
|
||||||
// Testing Libraries
|
// Testing Libraries
|
||||||
testImplementation(libs.junit)
|
testImplementation(libs.junit)
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
package com.v2ray.ang
|
package com.v2ray.ang
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.multidex.MultiDexApplication
|
|
||||||
import androidx.work.Configuration
|
import androidx.work.Configuration
|
||||||
import androidx.work.WorkManager
|
import androidx.work.WorkManager
|
||||||
import com.tencent.mmkv.MMKV
|
import com.tencent.mmkv.MMKV
|
||||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||||
|
import com.v2ray.ang.compose.ThemeManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
|
||||||
class AngApplication : MultiDexApplication() {
|
class AngApplication : Application() {
|
||||||
companion object {
|
companion object {
|
||||||
lateinit var application: AngApplication
|
lateinit var application: AngApplication
|
||||||
}
|
}
|
||||||
@@ -39,10 +40,8 @@ class AngApplication : MultiDexApplication() {
|
|||||||
|
|
||||||
// Ensure critical preference defaults are present in MMKV early
|
// Ensure critical preference defaults are present in MMKV early
|
||||||
SettingsManager.initApp(this)
|
SettingsManager.initApp(this)
|
||||||
SettingsManager.setNightMode()
|
|
||||||
|
|
||||||
es.dmoral.toasty.Toasty.Config.getInstance()
|
// Initialize theme state from MMKV
|
||||||
.setGravity(android.view.Gravity.BOTTOM, 0, 300)
|
ThemeManager.refresh()
|
||||||
.apply()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 xhttpMode: String? = null,
|
||||||
var xhttpExtra: String? = null,
|
var xhttpExtra: String? = null,
|
||||||
var finalMask: String? = null,
|
var finalMask: String? = null,
|
||||||
|
|
||||||
var security: String? = null,
|
var security: String? = null,
|
||||||
var sni: String? = null,
|
var sni: String? = null,
|
||||||
var alpn: String? = null,
|
var alpn: String? = null,
|
||||||
@@ -69,63 +70,40 @@ data class ProfileItem(
|
|||||||
var proxyChainProfiles: String? = null,
|
var proxyChainProfiles: String? = null,
|
||||||
|
|
||||||
var browserDialerMode: String? = null,
|
var browserDialerMode: String? = null,
|
||||||
|
) {
|
||||||
|
|
||||||
) {
|
|
||||||
companion object {
|
companion object {
|
||||||
fun create(configType: EConfigType): ProfileItem {
|
fun create(configType: EConfigType): ProfileItem =
|
||||||
return ProfileItem(configType = configType)
|
ProfileItem(configType = configType)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getServerAddressAndPort(): String {
|
fun getServerAddressAndPort(): String {
|
||||||
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
|
if (server.isNullOrEmpty() && configType == EConfigType.CUSTOM) {
|
||||||
return "${AppConfig.LOOPBACK}:${AppConfig.PORT_SOCKS}"
|
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
|
* Dedicated identity for "remove duplicate configurations".
|
||||||
val obj = other as ProfileItem
|
*
|
||||||
|
* Ignores metadata that does not affect connection:
|
||||||
return (this.server == obj.server
|
* - configVersion
|
||||||
&& this.serverPort == obj.serverPort
|
* - subscriptionId
|
||||||
&& this.password == obj.password
|
* - addedTime
|
||||||
&& this.method == obj.method
|
* - remarks
|
||||||
&& this.flow == obj.flow
|
* - description
|
||||||
&& this.username == obj.username
|
*
|
||||||
|
* All other fields, including configType, are included in the comparison.
|
||||||
&& this.network == obj.network
|
*
|
||||||
&& this.headerType == obj.headerType
|
* Returns a copy; the caller must not modify it further.
|
||||||
&& this.host == obj.host
|
*/
|
||||||
&& this.path == obj.path
|
fun duplicateIdentity(): ProfileItem =
|
||||||
&& this.seed == obj.seed
|
copy(
|
||||||
&& this.kcpMtu == obj.kcpMtu
|
configVersion = 0,
|
||||||
&& this.kcpTti == obj.kcpTti
|
subscriptionId = "",
|
||||||
&& this.quicSecurity == obj.quicSecurity
|
addedTime = 0L,
|
||||||
&& this.quicKey == obj.quicKey
|
remarks = "",
|
||||||
&& this.mode == obj.mode
|
description = null
|
||||||
&& 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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.v2ray.ang.dto.entities
|
package com.v2ray.ang.dto.entities
|
||||||
|
|
||||||
data class RulesetItem(
|
data class RulesetItem(
|
||||||
|
var id: String = "",
|
||||||
var remarks: String? = "",
|
var remarks: String? = "",
|
||||||
var ip: List<String>? = null,
|
var ip: List<String>? = null,
|
||||||
var domain: List<String>? = null,
|
var domain: List<String>? = null,
|
||||||
|
|||||||
@@ -2,5 +2,7 @@ package com.v2ray.ang.dto.entities
|
|||||||
|
|
||||||
data class ServersCache(
|
data class ServersCache(
|
||||||
val guid: String,
|
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.content.IntentFilter
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
import android.widget.Toast
|
import android.widget.Toast
|
||||||
import com.v2ray.ang.AngApplication
|
import com.v2ray.ang.AngApplication
|
||||||
|
import com.v2ray.ang.compose.AppSnackbarManager
|
||||||
|
import com.v2ray.ang.compose.ToastType
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import es.dmoral.toasty.Toasty
|
|
||||||
import java.io.Serializable
|
import java.io.Serializable
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
@@ -17,13 +20,40 @@ import java.util.Locale
|
|||||||
val Context.v2RayApplication: AngApplication?
|
val Context.v2RayApplication: AngApplication?
|
||||||
get() = applicationContext as? 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.
|
* Shows a toast message with the given resource ID.
|
||||||
*
|
*
|
||||||
* @param message The resource ID of the message to show.
|
* @param message The resource ID of the message to show.
|
||||||
*/
|
*/
|
||||||
fun Context.toast(message: Int) {
|
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.
|
* @param message The text of the message to show.
|
||||||
*/
|
*/
|
||||||
fun Context.toast(message: CharSequence) {
|
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.
|
* @param message The resource ID of the message to show.
|
||||||
*/
|
*/
|
||||||
fun Context.toastSuccess(message: Int) {
|
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.
|
* @param message The text of the message to show.
|
||||||
*/
|
*/
|
||||||
fun Context.toastSuccess(message: CharSequence) {
|
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.
|
* @param message The resource ID of the message to show.
|
||||||
*/
|
*/
|
||||||
fun Context.toastError(message: Int) {
|
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.
|
* @param message The text of the message to show.
|
||||||
*/
|
*/
|
||||||
fun Context.toastError(message: CharSequence) {
|
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
|
const val THRESHOLD = 1000L
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
package com.v2ray.ang.handler
|
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.tencent.mmkv.MMKV
|
||||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
||||||
import com.v2ray.ang.AppConfig.PREF_IS_BOOTED
|
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.dto.entities.WebDavConfig
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.drop
|
||||||
|
|
||||||
object MmkvManager {
|
object MmkvManager {
|
||||||
|
|
||||||
@@ -722,4 +731,56 @@ object MmkvManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
//endregion
|
//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
|
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 {
|
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() {
|
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() {
|
fun makeSetupGroupTab() {
|
||||||
_setupGroupTab.value = true
|
setupGroupTab.set(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read and clear the reinitGroupTab flag
|
/**
|
||||||
fun consumeSetupGroupTab(): Boolean {
|
* Atomically consumes the setup-group-tab flag.
|
||||||
val v = _setupGroupTab.value
|
* @return true if UI refresh was requested, false otherwise.
|
||||||
_setupGroupTab.value = false
|
*/
|
||||||
return v
|
fun consumeSetupGroupTab(): Boolean =
|
||||||
}
|
setupGroupTab.compareAndSet(true, false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import android.content.Context
|
|||||||
import android.content.res.AssetManager
|
import android.content.res.AssetManager
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import androidx.appcompat.app.AppCompatDelegate
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||||
import com.v2ray.ang.AppConfig.DEFAULT_SUBSCRIPTION_ID
|
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.
|
* Retrieves the currently selected VPN interface address configuration.
|
||||||
* This method reads the user's preference for VPN interface addressing and returns
|
* 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
|
package com.v2ray.ang.helper
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.ActivityNotFoundException
|
import android.content.ActivityNotFoundException
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.extension.toast
|
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.
|
* Helper for choosing and creating files using Android Storage Access Framework.
|
||||||
* Supports both file selection (ACTION_GET_CONTENT) and file creation (CreateDocument).
|
* 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 fileChooserCallback: ((Uri?) -> Unit)? = null
|
||||||
private var documentCreateCallback: ((Uri?) -> Unit)? = null
|
private var documentCreateCallback: ((Uri?) -> Unit)? = null
|
||||||
|
|
||||||
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
|
private val fileChooserLauncher: ActivityResultLauncher<Intent> =
|
||||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
val uri = result.data?.data
|
val uri = result.data?.data
|
||||||
if (result.resultCode == AppCompatActivity.RESULT_OK && uri != null) {
|
if (result.resultCode == Activity.RESULT_OK && uri != null) {
|
||||||
fileChooserCallback?.invoke(uri)
|
fileChooserCallback?.invoke(uri)
|
||||||
} else {
|
} else {
|
||||||
fileChooserCallback?.invoke(null)
|
fileChooserCallback?.invoke(null)
|
||||||
@@ -85,4 +86,4 @@ class FileChooserHelper(private val activity: AppCompatActivity) {
|
|||||||
documentCreateCallback = null
|
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
|
package com.v2ray.ang.helper
|
||||||
|
|
||||||
import android.content.pm.PackageManager
|
import android.content.pm.PackageManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.enums.PermissionType
|
import com.v2ray.ang.enums.PermissionType
|
||||||
@@ -12,7 +12,7 @@ import com.v2ray.ang.extension.toast
|
|||||||
/**
|
/**
|
||||||
* Helper for requesting permissions.
|
* Helper for requesting permissions.
|
||||||
*/
|
*/
|
||||||
class PermissionHelper(private val activity: AppCompatActivity) {
|
class PermissionHelper(private val activity: ComponentActivity) {
|
||||||
private var permissionCallback: ((Boolean) -> Unit)? = null
|
private var permissionCallback: ((Boolean) -> Unit)? = null
|
||||||
|
|
||||||
private val permissionLauncher: ActivityResultLauncher<String> =
|
private val permissionLauncher: ActivityResultLauncher<String> =
|
||||||
@@ -43,4 +43,4 @@ class PermissionHelper(private val activity: AppCompatActivity) {
|
|||||||
permissionLauncher.launch(permission)
|
permissionLauncher.launch(permission)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package com.v2ray.ang.helper
|
package com.v2ray.ang.helper
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.result.ActivityResultLauncher
|
import androidx.activity.result.ActivityResultLauncher
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.appcompat.app.AppCompatActivity
|
|
||||||
import com.v2ray.ang.ui.ScannerActivity
|
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
|
* This class encapsulates the logic for launching the QR code scanner activity
|
||||||
* and handling the scan result.
|
* and handling the scan result.
|
||||||
*/
|
*/
|
||||||
class QRCodeScannerHelper(private val activity: AppCompatActivity) {
|
class QRCodeScannerHelper(private val activity: ComponentActivity) {
|
||||||
private var scanCallback: ((String?) -> Unit)? = null
|
private var scanCallback: ((String?) -> Unit)? = null
|
||||||
|
|
||||||
private val scanLauncher: ActivityResultLauncher<Intent> =
|
private val scanLauncher: ActivityResultLauncher<Intent> =
|
||||||
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
activity.registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
||||||
if (result.resultCode == AppCompatActivity.RESULT_OK) {
|
if (result.resultCode == Activity.RESULT_OK) {
|
||||||
val scanResult = result.data?.getStringExtra("SCAN_RESULT")
|
val scanResult = result.data?.getStringExtra("SCAN_RESULT")
|
||||||
scanCallback?.invoke(scanResult)
|
scanCallback?.invoke(scanResult)
|
||||||
} else {
|
} 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
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
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.AppConfig
|
||||||
import com.v2ray.ang.BuildConfig
|
import com.v2ray.ang.BuildConfig
|
||||||
import com.v2ray.ang.R
|
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.core.CoreNativeManager
|
||||||
import com.v2ray.ang.databinding.ActivityAboutBinding
|
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
|
||||||
class AboutActivity : BaseActivity() {
|
class AboutActivity : BaseComponentActivity() {
|
||||||
private val binding by lazy { ActivityAboutBinding.inflate(layoutInflater) }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
}
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_about))
|
|
||||||
|
|
||||||
binding.layoutSoureCcode.setOnClickListener {
|
@Composable
|
||||||
Utils.openUri(this, AppConfig.APP_URL)
|
override fun ScreenContent() {
|
||||||
}
|
AboutScreen(onBackClick = { finish() })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
binding.layoutFeedback.setOnClickListener {
|
@Composable
|
||||||
Utils.openUri(this, AppConfig.APP_ISSUES_URL)
|
fun AboutScreen(onBackClick: () -> Unit) {
|
||||||
}
|
val context = LocalContext.current
|
||||||
|
var showOssDialog by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
binding.layoutOssLicenses.setOnClickListener {
|
val versionText = "v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})"
|
||||||
val webView = android.webkit.WebView(this)
|
val appIdText = BuildConfig.APPLICATION_ID
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
binding.layoutTgChannel.setOnClickListener {
|
Scaffold(
|
||||||
Utils.openUri(this, AppConfig.TG_CHANNEL_URL)
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||||
|
topBar = {
|
||||||
|
AppTopBar(
|
||||||
|
title = stringResource(R.string.title_about),
|
||||||
|
onBackClick = onBackClick
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
) { innerPadding ->
|
||||||
binding.layoutPrivacyPolicy.setOnClickListener {
|
Column(
|
||||||
Utils.openUri(this, AppConfig.APP_PRIVACY_POLICY)
|
modifier = Modifier
|
||||||
}
|
.fillMaxSize()
|
||||||
|
.padding(innerPadding)
|
||||||
"v${BuildConfig.VERSION_NAME} (${CoreNativeManager.getLibVersion()})".also {
|
.verticalScroll(rememberScrollState())
|
||||||
binding.tvVersion.text = it
|
) {
|
||||||
}
|
SettingsMenuItem(
|
||||||
BuildConfig.APPLICATION_ID.also {
|
icon = painterResource(R.drawable.ic_source_code_24dp),
|
||||||
binding.tvAppId.text = it
|
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
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import androidx.activity.viewModels
|
||||||
import android.view.MenuItem
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.compose.foundation.layout.padding
|
||||||
import com.v2ray.ang.AppConfig
|
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.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.dto.AppInfo
|
||||||
import com.v2ray.ang.util.AppManagerUtil
|
import com.v2ray.ang.viewmodel.AppPickerViewModel
|
||||||
import com.v2ray.ang.util.LogUtil
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
class AppPickerActivity : BaseComponentActivity() {
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import java.text.Collator
|
|
||||||
|
|
||||||
class AppPickerActivity : BaseActivity() {
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val EXTRA_SELECTED_PACKAGES = "selected_packages"
|
private const val EXTRA_SELECTED_PACKAGES = "selected_packages"
|
||||||
private const val EXTRA_PICKER_TITLE = "picker_title"
|
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 viewModel: AppPickerViewModel by viewModels()
|
||||||
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)
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = resolveScreenTitle())
|
val initial = intent.getStringArrayListExtra(EXTRA_SELECTED_PACKAGES).orEmpty()
|
||||||
|
viewModel.initialize(initial)
|
||||||
selectedPackages.addAll(initialSelectedPackages)
|
viewModel.loadApps(this)
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun finish() {
|
override fun finish() {
|
||||||
setResult(
|
setResult(
|
||||||
RESULT_OK,
|
RESULT_OK,
|
||||||
Intent().apply {
|
Intent().apply {
|
||||||
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, getSelectedPackages())
|
putStringArrayListExtra(EXTRA_SELECTED_PACKAGES, ArrayList(viewModel.getSelectedPackages()))
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
super.finish()
|
super.finish()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupRecyclerView() {
|
@Composable
|
||||||
binding.recyclerView.adapter = adapter
|
override fun ScreenContent() {
|
||||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
val apps by viewModel.displayedApps.collectAsStateWithLifecycle()
|
||||||
}
|
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()
|
||||||
|
val selectedPackages by viewModel.selectedPackages.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@SuppressLint("UseCompatLoadingForDrawables")
|
AppPickerScreen(
|
||||||
private fun createSpecialItemUnidentified(): AppInfo {
|
title = resolveScreenTitle(),
|
||||||
val icon = requireNotNull(
|
apps = apps,
|
||||||
getDrawable(android.R.drawable.ic_menu_help)
|
isLoading = isLoading,
|
||||||
?: getDrawable(android.R.drawable.sym_def_app_icon)
|
selectedPackages = selectedPackages,
|
||||||
) { "No fallback drawable available" }
|
onBackClick = { finish() },
|
||||||
return AppInfo(
|
onToggleApp = { viewModel.toggleApp(it) },
|
||||||
appName = getString(R.string.app_picker_unknown_app),
|
onSearch = { viewModel.filterApps(it) },
|
||||||
packageName = AppConfig.UNIDENTIFIED_PACKAGE,
|
onSelectAll = { viewModel.selectAll() },
|
||||||
appIcon = icon,
|
onInvertSelection = { viewModel.invertSelection() }
|
||||||
isSystemApp = false,
|
|
||||||
isSelected = 0
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
private fun resolveScreenTitle(): String {
|
||||||
return intent.getStringExtra(EXTRA_PICKER_TITLE) ?: getString(R.string.per_app_proxy_settings)
|
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
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
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.core.content.FileProvider
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.tencent.mmkv.MMKV
|
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.AppConfig.WEBDAV_BACKUP_FILE_NAME
|
||||||
import com.v2ray.ang.BuildConfig
|
import com.v2ray.ang.BuildConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ActivityBackupBinding
|
import com.v2ray.ang.compose.AppTopBar
|
||||||
import com.v2ray.ang.databinding.DialogWebdavBinding
|
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.dto.entities.WebDavConfig
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
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.LogUtil
|
||||||
import com.v2ray.ang.util.ZipUtil
|
import com.v2ray.ang.util.ZipUtil
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.text.SimpleDateFormat
|
import java.text.SimpleDateFormat
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class BackupActivity : HelperBaseActivity() {
|
class BackupActivity : HelperBaseComponentActivity() {
|
||||||
private val binding by lazy { ActivityBackupBinding.inflate(layoutInflater) }
|
|
||||||
|
|
||||||
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)
|
resources.getStringArray(R.array.config_backup_options)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
}
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_configuration_backup_restore))
|
|
||||||
|
|
||||||
binding.layoutBackup.setOnClickListener {
|
@Composable
|
||||||
AlertDialog.Builder(this)
|
override fun ScreenContent() {
|
||||||
.setTitle(R.string.title_configuration_backup)
|
BackupScreen(
|
||||||
.setItems(config_backup_options) { dialog, which ->
|
isLoadingState = isLoadingState,
|
||||||
when (which) {
|
webDavConfigState = webDavConfigState,
|
||||||
0 -> backupViaLocal()
|
backupOptions = configBackupOptions.toList(),
|
||||||
1 -> backupViaWebDav()
|
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 {
|
private fun shareBackup() {
|
||||||
val ret = backupConfigurationToCache()
|
val ret = backupConfigurationToCache()
|
||||||
if (ret.first) {
|
if (ret.first) {
|
||||||
startActivity(
|
startActivity(
|
||||||
Intent.createChooser(
|
Intent.createChooser(
|
||||||
Intent(Intent.ACTION_SEND).setType("application/zip")
|
Intent(Intent.ACTION_SEND).setType("application/zip")
|
||||||
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||||
.putExtra(
|
.putExtra(
|
||||||
Intent.EXTRA_STREAM,
|
Intent.EXTRA_STREAM,
|
||||||
FileProvider.getUriForFile(
|
FileProvider.getUriForFile(
|
||||||
this, BuildConfig.APPLICATION_ID + ".cache", File(ret.second)
|
this,
|
||||||
)
|
BuildConfig.APPLICATION_ID + ".cache",
|
||||||
), getString(R.string.title_configuration_share)
|
File(ret.second)
|
||||||
)
|
)
|
||||||
|
),
|
||||||
|
getString(R.string.title_configuration_share)
|
||||||
)
|
)
|
||||||
} else {
|
)
|
||||||
toastError(R.string.toast_failure)
|
} 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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,31 +158,6 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
return count > 0
|
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() {
|
private fun backupViaLocal() {
|
||||||
val dateFormatted = SimpleDateFormat(
|
val dateFormatted = SimpleDateFormat(
|
||||||
"yyyy-MM-dd-HH-mm-ss",
|
"yyyy-MM-dd-HH-mm-ss",
|
||||||
@@ -188,7 +191,28 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun restoreViaLocal() {
|
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() {
|
private fun backupViaWebDav() {
|
||||||
@@ -198,7 +222,7 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showLoading()
|
isLoadingState.value = true
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
var tempFile: File? = null
|
var tempFile: File? = null
|
||||||
@@ -235,7 +259,7 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
hideLoading()
|
isLoadingState.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,7 +272,7 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
showLoading()
|
isLoadingState.value = true
|
||||||
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
var target: File? = null
|
var target: File? = null
|
||||||
@@ -280,35 +304,164 @@ class BackupActivity : HelperBaseActivity() {
|
|||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
hideLoading()
|
isLoadingState.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun showWebDavSettingsDialog() {
|
@Composable
|
||||||
val dialogBinding = DialogWebdavBinding.inflate(layoutInflater)
|
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 ->
|
val webDavSummary = if (currentWebDavConfig != null && currentWebDavConfig!!.baseUrl.isNotEmpty()) {
|
||||||
dialogBinding.etWebdavUrl.setText(cfg.baseUrl)
|
"${currentWebDavConfig!!.baseUrl} | ${currentWebDavConfig!!.username ?: ""}"
|
||||||
dialogBinding.etWebdavUser.setText(cfg.username ?: "")
|
} else null
|
||||||
dialogBinding.etWebdavPass.setText(cfg.password ?: "")
|
|
||||||
dialogBinding.etWebdavRemotePath.setText(cfg.remoteBasePath ?: "/")
|
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
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.lifecycle.lifecycleScope
|
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.AppConfig
|
||||||
import com.v2ray.ang.BuildConfig
|
import com.v2ray.ang.BuildConfig
|
||||||
import com.v2ray.ang.R
|
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.core.CoreNativeManager
|
||||||
import com.v2ray.ang.databinding.ActivityCheckUpdateBinding
|
|
||||||
import com.v2ray.ang.dto.CheckUpdateResult
|
import com.v2ray.ang.dto.CheckUpdateResult
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
@@ -18,62 +43,116 @@ import com.v2ray.ang.util.LogUtil
|
|||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class CheckUpdateActivity : BaseActivity() {
|
class CheckUpdateActivity : BaseComponentActivity() {
|
||||||
|
|
||||||
private val binding by lazy { ActivityCheckUpdateBinding.inflate(layoutInflater) }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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) {
|
@Composable
|
||||||
toast(R.string.update_checking_for_update)
|
override fun ScreenContent() {
|
||||||
showLoading()
|
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 {
|
try {
|
||||||
val result = UpdateCheckerManager.checkForUpdate(includePreRelease)
|
val result = UpdateCheckerManager.checkForUpdate(includePreRelease)
|
||||||
if (result.hasUpdate) {
|
if (result.hasUpdate) {
|
||||||
showUpdateDialog(result)
|
updateResult = result
|
||||||
|
showUpdateDialog = true
|
||||||
} else {
|
} 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) {
|
} catch (e: Exception) {
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to check for updates: ${e.message}")
|
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 {
|
} finally {
|
||||||
hideLoading()
|
isLoading = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun showUpdateDialog(result: CheckUpdateResult) {
|
LaunchedEffect(Unit) { checkForUpdates(checkPreRelease) }
|
||||||
AlertDialog.Builder(this)
|
|
||||||
.setTitle(getString(R.string.update_new_version_found, result.latestVersion))
|
Scaffold(
|
||||||
.setMessage(result.releaseNotes)
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||||
.setPositiveButton(R.string.update_now) { _, _ ->
|
topBar = {
|
||||||
result.downloadUrl?.let {
|
AppTopBar(
|
||||||
Utils.openUri(this, it)
|
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)
|
SettingsMenuItem(
|
||||||
.show()
|
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
|
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 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.
|
* Activities that need file selection, permissions, or QR code scanning should extend this class.
|
||||||
*
|
*
|
||||||
* Additional Responsibilities:
|
* Additional Responsibilities:
|
||||||
@@ -19,7 +19,8 @@ import com.v2ray.ang.helper.QRCodeScannerHelper
|
|||||||
* - Provide permission request helpers with callbacks.
|
* - Provide permission request helpers with callbacks.
|
||||||
* - Provide QR code scanning helpers with camera permission handling.
|
* - 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 fileChooser: FileChooserHelper
|
||||||
private lateinit var permissionRequester: PermissionHelper
|
private lateinit var permissionRequester: PermissionHelper
|
||||||
private lateinit var qrCodeScanner: QRCodeScannerHelper
|
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)
|
* @param onResult Callback invoked with the scan result string (null if cancelled or failed)
|
||||||
*/
|
*/
|
||||||
protected fun launchQRCodeScanner(onResult: (String?) -> Unit) {
|
protected fun launchQRCodeScanner(
|
||||||
checkAndRequestPermission(PermissionType.CAMERA) {
|
onResult: (String?) -> Unit
|
||||||
qrCodeScanner.launch(onResult)
|
) {
|
||||||
}
|
qrCodeScanner.launch(onResult)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,21 +1,49 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.ClipData
|
import android.content.ClipData
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import androidx.activity.viewModels
|
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.core.content.FileProvider
|
||||||
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
|
|
||||||
import com.v2ray.ang.R
|
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.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.LogcatViewModel
|
import com.v2ray.ang.viewmodel.LogcatViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -26,35 +54,25 @@ import java.text.SimpleDateFormat
|
|||||||
import java.util.Date
|
import java.util.Date
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
class LogcatActivity : BaseComponentActivity() {
|
||||||
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
|
|
||||||
private val viewModel: LogcatViewModel by viewModels()
|
private val viewModel: LogcatViewModel by viewModels()
|
||||||
private lateinit var adapter: LogcatRecyclerAdapter
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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 {
|
@Composable
|
||||||
Utils.setClipboard(this, log)
|
override fun ScreenContent() {
|
||||||
return true
|
LogcatScreen(
|
||||||
|
viewModel = viewModel,
|
||||||
|
onBackClick = { finish() },
|
||||||
|
onShareLogcat = { shareLogcat() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun shareLogcat() {
|
private fun shareLogcat() {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val logText = viewModel.getAll().joinToString("\n")
|
val logText = viewModel.filteredLogs.value.joinToString("\n")
|
||||||
|
|
||||||
val result = try {
|
val result = try {
|
||||||
val shareDir = File(cacheDir, "shared_logs").apply {
|
val shareDir = File(cacheDir, "shared_logs").apply {
|
||||||
@@ -100,70 +118,130 @@ class LogcatActivity : BaseActivity(), SwipeRefreshLayout.OnRefreshListener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||||
menuInflater.inflate(R.menu.menu_logcat, menu)
|
@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)
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
if (searchItem != null) {
|
var showSearch by remember { mutableStateOf(false) }
|
||||||
val searchView = searchItem.actionView as SearchView
|
|
||||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
|
||||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
|
||||||
|
|
||||||
override fun onQueryTextChange(newText: String?): Boolean {
|
val snackbar = LocalAppSnackbar.current
|
||||||
viewModel.filter(newText)
|
LaunchedEffect(Unit) {
|
||||||
refreshData()
|
snackbar.show(context.getString(R.string.pull_down_to_refresh))
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
searchView.setOnCloseListener {
|
|
||||||
viewModel.filter("")
|
|
||||||
refreshData()
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.onCreateOptionsMenu(menu)
|
|
||||||
}
|
}
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
Scaffold(
|
||||||
R.id.copy_all -> {
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||||
val all = viewModel.getAll().joinToString("\n")
|
topBar = {
|
||||||
Utils.setClipboard(this, all)
|
AppTopBar(
|
||||||
toastSuccess(R.string.toast_success)
|
title = stringResource(R.string.title_logcat),
|
||||||
true
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
) { innerPadding ->
|
||||||
R.id.share_all -> {
|
PullToRefreshBox(
|
||||||
shareLogcat()
|
isRefreshing = isRefreshing,
|
||||||
true
|
onRefresh = {
|
||||||
}
|
scope.launch(Dispatchers.IO) { viewModel.loadLogcat() }
|
||||||
|
},
|
||||||
R.id.clear_all -> {
|
modifier = Modifier
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
.fillMaxSize()
|
||||||
viewModel.clearLogcat()
|
.padding(innerPadding)
|
||||||
withContext(Dispatchers.Main) {
|
) {
|
||||||
refreshData()
|
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")
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
fun refreshData() {
|
@Composable
|
||||||
adapter.notifyDataSetChanged()
|
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
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Bundle
|
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.activity.viewModels
|
||||||
import androidx.appcompat.widget.SearchView
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.compose.foundation.layout.Box
|
||||||
import com.v2ray.ang.AppConfig
|
import androidx.compose.foundation.layout.Column
|
||||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
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.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.AppInfo
|
||||||
import com.v2ray.ang.dto.UrlContentRequest
|
import com.v2ray.ang.extension.toastInfo
|
||||||
import com.v2ray.ang.extension.toast
|
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
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.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.PerAppProxyViewModel
|
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() {
|
class PerAppProxyActivity : BaseComponentActivity() {
|
||||||
private val binding by lazy { ActivityBypassListBinding.inflate(layoutInflater) }
|
|
||||||
|
|
||||||
private var adapter: PerAppProxyAdapter? = null
|
|
||||||
private var appsAll: List<AppInfo>? = null
|
|
||||||
private val viewModel: PerAppProxyViewModel by viewModels()
|
private val viewModel: PerAppProxyViewModel by viewModels()
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
viewModel.loadApps(this)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun initList() {
|
@Composable
|
||||||
showLoading()
|
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 {
|
PerAppProxyScreen(
|
||||||
try {
|
apps = apps,
|
||||||
val apps = withContext(Dispatchers.IO) {
|
isLoading = isLoading,
|
||||||
val appsList = AppManagerUtil.loadNetworkAppList(this@PerAppProxyActivity)
|
blacklist = blacklist,
|
||||||
|
perAppProxyEnabled = perAppProxyEnabled,
|
||||||
val blacklistSet = viewModel.getAll()
|
bypassApps = bypassApps,
|
||||||
if (blacklistSet.isNotEmpty()) {
|
onBackClick = { finish() },
|
||||||
appsList.forEach { app ->
|
onPerAppProxyChanged = { viewModel.setPerAppProxyEnabled(it) },
|
||||||
app.isSelected = if (blacklistSet.contains(app.packageName)) 1 else 0
|
onBypassAppsChanged = { viewModel.setBypassAppsEnabled(it) },
|
||||||
}
|
onInfoClick = {
|
||||||
appsList.sortedWith { p1, p2 ->
|
toastInfo(R.string.summary_pref_per_app_proxy)
|
||||||
when {
|
},
|
||||||
p1.isSelected > p2.isSelected -> -1
|
onToggleApp = { viewModel.toggle(it) },
|
||||||
p1.isSelected < p2.isSelected -> 1
|
onSearch = { viewModel.filterApps(it) },
|
||||||
p1.isSystemApp > p2.isSystemApp -> 1
|
onSelectAll = { viewModel.selectAll() },
|
||||||
p1.isSystemApp < p2.isSystemApp -> -1
|
onInvertSelection = { viewModel.invertSelection() },
|
||||||
p1.appName.lowercase() > p2.appName.lowercase() -> 1
|
onSelectProxyAuto = { viewModel.selectProxyAppAuto(this) },
|
||||||
p1.appName.lowercase() < p2.appName.lowercase() -> -1
|
onImportProxyApp = {
|
||||||
p1.packageName > p2.packageName -> 1
|
val content = Utils.getClipboard(applicationContext)
|
||||||
p1.packageName < p2.packageName -> -1
|
viewModel.importProxyApp(content, this)
|
||||||
else -> 0
|
},
|
||||||
}
|
onExportProxyApp = {
|
||||||
}
|
val export = viewModel.exportProxyApp()
|
||||||
} else {
|
Utils.setClipboard(applicationContext, export)
|
||||||
val collator = Collator.getInstance()
|
|
||||||
appsList.sortedWith(compareBy(collator) { it.appName })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
appsAll = apps
|
|
||||||
adapter = PerAppProxyAdapter(apps, viewModel)
|
|
||||||
binding.recyclerView.adapter = adapter
|
|
||||||
|
|
||||||
} catch (e: Exception) {
|
|
||||||
LogUtil.e(ANG_PACKAGE, "Error loading apps", e)
|
|
||||||
} finally {
|
|
||||||
hideLoading()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
|
||||||
menuInflater.inflate(R.menu.menu_bypass_list, menu)
|
|
||||||
|
|
||||||
val searchItem = menu.findItem(R.id.search_view)
|
|
||||||
if (searchItem != null) {
|
|
||||||
val searchView = searchItem.actionView as SearchView
|
|
||||||
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
|
|
||||||
override fun onQueryTextSubmit(query: String?): Boolean = false
|
|
||||||
|
|
||||||
override fun onQueryTextChange(newText: String?): Boolean {
|
|
||||||
filterProxyApp(newText.orEmpty())
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
return super.onCreateOptionsMenu(menu)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
|
|
||||||
R.id.select_all -> {
|
|
||||||
selectAllApp()
|
|
||||||
allowPerAppProxy()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.invert_selection -> {
|
|
||||||
invertSelection()
|
|
||||||
allowPerAppProxy()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.select_proxy_app -> {
|
|
||||||
selectProxyAppAuto()
|
|
||||||
allowPerAppProxy()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.import_proxy_app -> {
|
|
||||||
importProxyApp()
|
|
||||||
allowPerAppProxy()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
R.id.export_proxy_app -> {
|
|
||||||
exportProxyApp()
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun selectAllApp() {
|
|
||||||
adapter?.let { adapter ->
|
|
||||||
val pkgNames = adapter.apps.map { it.packageName }
|
|
||||||
val allSelected = pkgNames.all { viewModel.contains(it) }
|
|
||||||
|
|
||||||
if (allSelected) {
|
|
||||||
viewModel.removeAll(pkgNames)
|
|
||||||
} else {
|
|
||||||
viewModel.addAll(pkgNames)
|
|
||||||
}
|
|
||||||
refreshData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun invertSelection() {
|
|
||||||
adapter?.let { adapter ->
|
|
||||||
adapter.apps.forEach { app ->
|
|
||||||
viewModel.toggle(app.packageName)
|
|
||||||
}
|
|
||||||
refreshData()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun selectProxyAppAuto() {
|
|
||||||
toast(R.string.msg_downloading_content)
|
|
||||||
showLoading()
|
|
||||||
|
|
||||||
val url = AppConfig.ANDROID_PACKAGE_NAME_LIST_URL
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
|
||||||
var content = HttpUtil.getUrlContent(
|
|
||||||
UrlContentRequest(
|
|
||||||
url = url,
|
|
||||||
timeout = 5000
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if (content.isNullOrEmpty()) {
|
|
||||||
val proxyUsername = SettingsManager.getSocksUsername()
|
|
||||||
val proxyPassword = SettingsManager.getSocksPassword()
|
|
||||||
val httpPort = SettingsManager.getHttpPort()
|
|
||||||
content = HttpUtil.getUrlContent(
|
|
||||||
UrlContentRequest(
|
|
||||||
url = url,
|
|
||||||
timeout = 5000,
|
|
||||||
httpPort = httpPort,
|
|
||||||
proxyUsername = proxyUsername,
|
|
||||||
proxyPassword = proxyPassword
|
|
||||||
)
|
|
||||||
) ?: ""
|
|
||||||
}
|
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
//LogUtil.i(AppConfig.TAG, content)
|
|
||||||
selectProxyApp(content, true)
|
|
||||||
toastSuccess(R.string.toast_success)
|
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() {
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
var lst = binding.switchBypassApps.isChecked.toString()
|
@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 ->
|
LaunchedEffect(searchQuery) {
|
||||||
lst = lst + System.lineSeparator() + pkg
|
onSearch(searchQuery)
|
||||||
}
|
|
||||||
Utils.setClipboard(applicationContext, lst)
|
|
||||||
toastSuccess(R.string.toast_success)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun allowPerAppProxy() {
|
|
||||||
binding.switchPerAppProxy.isChecked = true
|
|
||||||
SettingsChangeManager.makeRestartService()
|
|
||||||
}
|
|
||||||
|
|
||||||
@SuppressLint("NotifyDataSetChanged")
|
|
||||||
private fun selectProxyApp(content: String, force: Boolean): Boolean {
|
|
||||||
try {
|
|
||||||
val proxyApps = if (TextUtils.isEmpty(content)) {
|
|
||||||
Utils.readTextFromAssets(v2RayApplication, "proxy_package_name")
|
|
||||||
} else {
|
|
||||||
content
|
|
||||||
}
|
|
||||||
if (TextUtils.isEmpty(proxyApps)) return false
|
|
||||||
|
|
||||||
viewModel.clear()
|
|
||||||
|
|
||||||
if (binding.switchBypassApps.isChecked) {
|
Scaffold(
|
||||||
adapter?.let { adapter ->
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||||
adapter.apps.forEach { app ->
|
topBar = {
|
||||||
val packageName = app.packageName
|
AppTopBar(
|
||||||
if (!inProxyApps(proxyApps, packageName, force)) {
|
title = stringResource(R.string.per_app_proxy_settings),
|
||||||
viewModel.add(packageName)
|
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()
|
Box {
|
||||||
}
|
IconButton(onClick = { showMenu = true }) {
|
||||||
} else {
|
Icon(
|
||||||
adapter?.let { adapter ->
|
painterResource(R.drawable.ic_more_vert_24dp),
|
||||||
adapter.apps.forEach { app ->
|
contentDescription = null
|
||||||
val packageName = app.packageName
|
)
|
||||||
if (inProxyApps(proxyApps, packageName, force)) {
|
}
|
||||||
viewModel.add(packageName)
|
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
|
) { innerPadding ->
|
||||||
}
|
Column(modifier = Modifier.fillMaxSize().padding(innerPadding)) {
|
||||||
|
Surface(
|
||||||
private fun inProxyApps(proxyApps: String, packageName: String, force: Boolean): Boolean {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
println(packageName)
|
color = MaterialTheme.colorScheme.surface
|
||||||
if (force) {
|
) {
|
||||||
if (packageName == "com.google.android.webview") return false
|
Row(
|
||||||
if (packageName.startsWith("com.google")) return true
|
modifier = Modifier
|
||||||
}
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
return proxyApps.indexOf(packageName) >= 0
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
}
|
horizontalArrangement = Arrangement.SpaceEvenly
|
||||||
|
|
||||||
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
|
|
||||||
) {
|
) {
|
||||||
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 {
|
AppDivider()
|
||||||
appsAll?.forEach {
|
|
||||||
apps.add(it)
|
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
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||||
import android.view.MenuItem
|
|
||||||
import android.widget.ArrayAdapter
|
|
||||||
import androidx.activity.result.contract.ActivityResultContracts
|
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 androidx.lifecycle.lifecycleScope
|
||||||
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
|
import com.v2ray.ang.AppConfig.BUILTIN_OUTBOUND_TAGS
|
||||||
import com.v2ray.ang.AppConfig.TAG_PROXY
|
import com.v2ray.ang.AppConfig.TAG_PROXY
|
||||||
import com.v2ray.ang.R
|
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.dto.entities.RulesetItem
|
||||||
import com.v2ray.ang.extension.nullIfBlank
|
import com.v2ray.ang.extension.nullIfBlank
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.util.Utils
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
class RoutingEditActivity : BaseActivity() {
|
class RoutingEditActivity : BaseComponentActivity() {
|
||||||
private val binding by lazy { ActivityRoutingEditBinding.inflate(layoutInflater) }
|
|
||||||
private val position by lazy { intent.getIntExtra("position", -1) }
|
private val position by lazy { intent.getIntExtra("position", -1) }
|
||||||
private val processPickerLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
|
|
||||||
if (result.resultCode == RESULT_OK) {
|
private var initial: RulesetItem? = null
|
||||||
val selectedPackages = AppPickerActivity.getSelectedPackages(result.data)
|
private lateinit var outboundSuggestions: List<String>
|
||||||
binding.etProcess.text = Utils.getEditable(selectedPackages.joinToString(","))
|
private var canUseProcess: Boolean = false
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_rule_title))
|
initial = SettingsManager.getRoutingRuleset(position)
|
||||||
|
|
||||||
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() {
|
|
||||||
val profileRemarks = SettingsManager.getProfileRemarks()
|
val profileRemarks = SettingsManager.getProfileRemarks()
|
||||||
|
outboundSuggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
|
||||||
val suggestions = (BUILTIN_OUTBOUND_TAGS.toList() + profileRemarks).distinct()
|
canUseProcess = SettingsManager.canUseProcessRouting()
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun bindingServer(rulesetItem: RulesetItem): Boolean {
|
@Composable
|
||||||
binding.etRemarks.text = Utils.getEditable(rulesetItem.remarks)
|
override fun ScreenContent() {
|
||||||
binding.chkLocked.isChecked = rulesetItem.locked == true
|
RoutingEditScreen(
|
||||||
binding.etDomain.text = Utils.getEditable(rulesetItem.domain?.joinToString(","))
|
position = position,
|
||||||
binding.etIp.text = Utils.getEditable(rulesetItem.ip?.joinToString(","))
|
initial = initial,
|
||||||
binding.etProcess.text = Utils.getEditable(rulesetItem.process?.joinToString(","))
|
outboundSuggestions = outboundSuggestions,
|
||||||
binding.etPort.text = Utils.getEditable(rulesetItem.port)
|
canUseProcess = canUseProcess,
|
||||||
binding.etProtocol.text = Utils.getEditable(rulesetItem.protocol?.joinToString(","))
|
onBackClick = { finish() },
|
||||||
binding.etNetwork.text = Utils.getEditable(rulesetItem.network)
|
onSave = { saveServer(it) },
|
||||||
// Set text directly; filter won't fire because we're not using setText(filter=true)
|
onDelete = { deleteServer() }
|
||||||
binding.spOutboundTag.setText(rulesetItem.outboundTag, false)
|
)
|
||||||
return true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearServer(): Boolean {
|
private fun saveServer(rulesetItem: RulesetItem): 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 }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (rulesetItem.remarks.isNullOrEmpty()) {
|
if (rulesetItem.remarks.isNullOrEmpty()) {
|
||||||
toast(R.string.sub_setting_remarks)
|
toast(R.string.sub_setting_remarks)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if (position < 0 && rulesetItem.id.isEmpty()) {
|
||||||
|
rulesetItem.id = UUID.randomUUID().toString()
|
||||||
|
}
|
||||||
SettingsManager.saveRoutingRuleset(position, rulesetItem)
|
SettingsManager.saveRoutingRuleset(position, rulesetItem)
|
||||||
toastSuccess(R.string.toast_success)
|
toastSuccess(R.string.toast_success)
|
||||||
finish()
|
finish()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun deleteServer(): Boolean {
|
private fun deleteServer(): Boolean {
|
||||||
if (position >= 0) {
|
if (position >= 0) {
|
||||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
SettingsManager.removeRoutingRuleset(position)
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
withContext(Dispatchers.Main) { finish() }
|
||||||
SettingsManager.removeRoutingRuleset(position)
|
}
|
||||||
launch(Dispatchers.Main) {
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
@Composable
|
||||||
menuInflater.inflate(R.menu.action_server, menu)
|
fun RoutingEditScreen(
|
||||||
val delConfig = menu.findItem(R.id.del_config)
|
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) {
|
var remarks by rememberSaveable { mutableStateOf(initial?.remarks ?: "") }
|
||||||
delConfig?.isVisible = false
|
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) {
|
Scaffold(
|
||||||
R.id.del_config -> {
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||||
deleteServer()
|
topBar = {
|
||||||
true
|
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 -> {
|
if (showDeleteConfirm) {
|
||||||
saveServer()
|
ConfirmDialog(
|
||||||
true
|
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
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import androidx.activity.viewModels
|
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.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
import com.v2ray.ang.compose.AppDivider
|
||||||
import com.v2ray.ang.databinding.ActivityRoutingSettingBinding
|
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.toastError
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
|
||||||
import com.v2ray.ang.util.JsonUtil
|
import com.v2ray.ang.util.JsonUtil
|
||||||
import com.v2ray.ang.util.LogUtil
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
|
import com.v2ray.ang.viewmodel.RoutingSettingsViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
|
|
||||||
class RoutingSettingActivity : HelperBaseActivity() {
|
class RoutingSettingActivity : HelperBaseComponentActivity() {
|
||||||
private val binding by lazy { ActivityRoutingSettingBinding.inflate(layoutInflater) }
|
|
||||||
private val ownerActivity: RoutingSettingActivity
|
|
||||||
get() = this
|
|
||||||
private val viewModel: RoutingSettingsViewModel by viewModels()
|
private val viewModel: RoutingSettingsViewModel by viewModels()
|
||||||
private lateinit var adapter: RoutingSettingRecyclerAdapter
|
private val domainStrategyState = MutableStateFlow("")
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
domainStrategyState.value = getDomainStrategy()
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.routing_settings_title))
|
}
|
||||||
|
|
||||||
adapter = RoutingSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
|
@Composable
|
||||||
|
override fun ScreenContent() {
|
||||||
binding.recyclerView.setHasFixedSize(true)
|
RoutingSettingScreen(
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
viewModel = viewModel,
|
||||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
domainStrategyState = domainStrategyState,
|
||||||
binding.recyclerView.adapter = adapter
|
onBackClick = { finish() },
|
||||||
|
onAddRule = { startActivity(Intent(this, RoutingEditActivity::class.java)) },
|
||||||
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
|
onEditRule = { position ->
|
||||||
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
startActivity(Intent(this, RoutingEditActivity::class.java).putExtra("position", position))
|
||||||
|
},
|
||||||
binding.tvDomainStrategySummary.text = getDomainStrategy()
|
onDomainStrategySelected = { value ->
|
||||||
binding.layoutDomainStrategy.setOnClickListener {
|
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
|
||||||
setDomainStrategy()
|
domainStrategyState.value = value
|
||||||
}
|
},
|
||||||
|
onImportPredefined = { index -> importPredefined(index) },
|
||||||
|
onImportClipboard = { importFromClipboard() },
|
||||||
|
onImportQRcode = { importQRcode() },
|
||||||
|
onExportClipboard = { export2Clipboard() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onResume() {
|
override fun onResume() {
|
||||||
super.onResume()
|
super.onResume()
|
||||||
refreshData()
|
viewModel.reload()
|
||||||
}
|
|
||||||
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun getDomainStrategy(): String {
|
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() {
|
private fun importPredefined(index: Int) {
|
||||||
android.app.AlertDialog.Builder(this).setItems(routing_domain_strategy.asList().toTypedArray()) { _, i ->
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
val value = routing_domain_strategy[i]
|
SettingsManager.resetRoutingRulesetsFromPresets(this@RoutingSettingActivity, index)
|
||||||
MmkvManager.encodeSettings(AppConfig.PREF_ROUTING_DOMAIN_STRATEGY, value)
|
launch(Dispatchers.Main) {
|
||||||
binding.tvDomainStrategySummary.text = value
|
viewModel.reload()
|
||||||
|
toastSuccess(R.string.toast_success)
|
||||||
|
}
|
||||||
} catch (e: Exception) {
|
} 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() {
|
private fun importFromClipboard() {
|
||||||
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
|
val clipboard = try {
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
Utils.getClipboard(this)
|
||||||
val clipboard = try {
|
} catch (e: Exception) {
|
||||||
Utils.getClipboard(this)
|
toastError(R.string.toast_failure)
|
||||||
} catch (e: Exception) {
|
return
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to get clipboard content", e)
|
}
|
||||||
|
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)
|
toastError(R.string.toast_failure)
|
||||||
return@setPositiveButton
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun importQRcode() {
|
||||||
|
launchQRCodeScanner { scanResult ->
|
||||||
|
if (scanResult != null) {
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
val result = SettingsManager.resetRoutingRulesets(clipboard)
|
val result = SettingsManager.resetRoutingRulesets(scanResult)
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
if (result) {
|
if (result) {
|
||||||
refreshData()
|
viewModel.reload()
|
||||||
toastSuccess(R.string.toast_success)
|
toastSuccess(R.string.toast_success)
|
||||||
} else {
|
} else {
|
||||||
toastError(R.string.toast_failure)
|
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() {
|
private fun export2Clipboard() {
|
||||||
@@ -166,52 +169,220 @@ class RoutingSettingActivity : HelperBaseActivity() {
|
|||||||
toastSuccess(R.string.toast_success)
|
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 {
|
val domainStrategies = stringArrayResource(R.array.routing_domain_strategy).toList()
|
||||||
AlertDialog.Builder(this).setMessage(R.string.routing_settings_import_rulesets_tip)
|
val presetRulesets = stringArrayResource(R.array.preset_rulesets).toList()
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
val lazyListState = rememberLazyListState()
|
||||||
val result = SettingsManager.resetRoutingRulesets(qrcode)
|
val reorderableState = rememberReorderableLazyListState(lazyListState) { from, to ->
|
||||||
withContext(Dispatchers.Main) {
|
viewModel.swap(from.index - 1, to.index - 1)
|
||||||
if (result) {
|
}
|
||||||
refreshData()
|
|
||||||
toastSuccess(R.string.toast_success)
|
Scaffold(
|
||||||
} else {
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||||
toastError(R.string.toast_failure)
|
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) {
|
Column(
|
||||||
}
|
horizontalAlignment = Alignment.End,
|
||||||
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
override fun onRefreshData() {
|
) {
|
||||||
refreshData()
|
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.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
|
|
||||||
class ScScannerActivity : HelperBaseActivity() {
|
class ScScannerActivity : HelperBaseComponentActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentView(R.layout.activity_none)
|
}
|
||||||
importQRcode()
|
|
||||||
|
@Composable
|
||||||
|
override fun ScreenContent() {
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
importQRcode()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun importQRcode() {
|
private fun importQRcode() {
|
||||||
@@ -31,4 +38,4 @@ class ScScannerActivity : HelperBaseActivity() {
|
|||||||
finish()
|
finish()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,24 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
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
|
import com.v2ray.ang.core.CoreServiceManager
|
||||||
|
|
||||||
class ScStartActivity : BaseActivity() {
|
class ScStartActivity : BaseComponentActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
moveTaskToBack(true)
|
}
|
||||||
|
|
||||||
setContentView(R.layout.activity_none)
|
@Composable
|
||||||
|
override fun ScreenContent() {
|
||||||
if (!CoreServiceManager.isRunning()) {
|
LaunchedEffect(Unit) {
|
||||||
CoreServiceManager.startVServiceFromToggle(this)
|
moveTaskToBack(true)
|
||||||
|
if (!CoreServiceManager.isRunning()) {
|
||||||
|
CoreServiceManager.startVServiceFromToggle(this@ScStartActivity)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
}
|
}
|
||||||
finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,19 +1,24 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
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
|
import com.v2ray.ang.core.CoreServiceManager
|
||||||
|
|
||||||
class ScStopActivity : BaseActivity() {
|
class ScStopActivity : BaseComponentActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
moveTaskToBack(true)
|
}
|
||||||
|
|
||||||
setContentView(R.layout.activity_none)
|
@Composable
|
||||||
|
override fun ScreenContent() {
|
||||||
if (CoreServiceManager.isRunning()) {
|
LaunchedEffect(Unit) {
|
||||||
CoreServiceManager.stopVService(this)
|
moveTaskToBack(true)
|
||||||
|
if (CoreServiceManager.isRunning()) {
|
||||||
|
CoreServiceManager.stopVService(this@ScStopActivity)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
}
|
}
|
||||||
finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,26 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
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
|
import com.v2ray.ang.core.CoreServiceManager
|
||||||
|
|
||||||
class ScSwitchActivity : BaseActivity() {
|
class ScSwitchActivity : BaseComponentActivity() {
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
moveTaskToBack(true)
|
}
|
||||||
|
|
||||||
setContentView(R.layout.activity_none)
|
@Composable
|
||||||
|
override fun ScreenContent() {
|
||||||
if (CoreServiceManager.isRunning()) {
|
LaunchedEffect(Unit) {
|
||||||
CoreServiceManager.stopVService(this)
|
moveTaskToBack(true)
|
||||||
} else {
|
if (CoreServiceManager.isRunning()) {
|
||||||
CoreServiceManager.startVServiceFromToggle(this)
|
CoreServiceManager.stopVService(this@ScSwitchActivity)
|
||||||
|
} else {
|
||||||
|
CoreServiceManager.startVServiceFromToggle(this@ScSwitchActivity)
|
||||||
|
}
|
||||||
|
finish()
|
||||||
}
|
}
|
||||||
finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +1,119 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.BitmapFactory
|
import android.graphics.BitmapFactory
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.Menu
|
import android.util.Size as TargetSize
|
||||||
import android.view.MenuItem
|
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.AppConfig
|
||||||
import com.v2ray.ang.R
|
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.extension.toast
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.util.LogUtil
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.QRCodeDecoder
|
import com.v2ray.ang.util.QRCodeDecoder
|
||||||
import io.github.g00fy2.quickie.QRResult
|
import java.util.concurrent.Executors
|
||||||
import io.github.g00fy2.quickie.ScanCustomCode
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
import io.github.g00fy2.quickie.config.BarcodeFormat
|
|
||||||
import io.github.g00fy2.quickie.config.ScannerConfig
|
|
||||||
|
|
||||||
class ScannerActivity : HelperBaseActivity() {
|
private val qrReader = MultiFormatReader()
|
||||||
private val binding by lazy { ActivityNoneBinding.inflate(layoutInflater) }
|
|
||||||
|
|
||||||
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)
|
super.onCreate(savedInstanceState)
|
||||||
|
val startImmediate = MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE, false)
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.menu_item_import_config_qrcode))
|
if (ContextCompat.checkSelfPermission(
|
||||||
|
this,
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_START_SCAN_IMMEDIATE)) {
|
PermissionType.CAMERA.getPermission()
|
||||||
launchScan()
|
) == PackageManager.PERMISSION_GRANTED && startImmediate
|
||||||
|
) {
|
||||||
|
uiState.value = ScannerUiState.ACTIVE
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun launchScan() {
|
@Composable
|
||||||
scanQrCode.launch(
|
override fun ScreenContent() {
|
||||||
ScannerConfig.build {
|
ScannerScreen(
|
||||||
setHapticSuccessFeedback(true) // enable (default) or disable haptic feedback when a barcode was detected
|
uiState = uiState.value,
|
||||||
setShowTorchToggle(true) // show or hide (default) torch/flashlight toggle button
|
onBackClick = { finish() },
|
||||||
setShowCloseButton(true) // show or hide (default) close button
|
onSelectPhoto = { showFileChooser() },
|
||||||
setBarcodeFormats(listOf(BarcodeFormat.QR_CODE))
|
onStartScan = { startScan() },
|
||||||
}
|
onStopScan = { stopScan() },
|
||||||
|
onScanResult = { text -> finished(text) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleResult(result: QRResult) {
|
private fun startScan() {
|
||||||
if (result is QRResult.QRSuccess) {
|
checkAndRequestPermission(PermissionType.CAMERA) {
|
||||||
finished(result.content.rawValue.orEmpty())
|
uiState.value = ScannerUiState.ACTIVE
|
||||||
} else {
|
|
||||||
finish()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun stopScan() {
|
||||||
|
uiState.value = ScannerUiState.IDLE
|
||||||
|
}
|
||||||
|
|
||||||
private fun finished(text: String) {
|
private fun finished(text: String) {
|
||||||
val intent = Intent()
|
val intent = Intent()
|
||||||
intent.putExtra("SCAN_RESULT", text)
|
intent.putExtra("SCAN_RESULT", text)
|
||||||
@@ -58,36 +121,13 @@ class ScannerActivity : HelperBaseActivity() {
|
|||||||
finish()
|
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() {
|
private fun showFileChooser() {
|
||||||
launchFileChooser("image/*") { uri ->
|
launchFileChooser("image/*") { uri ->
|
||||||
if (uri == null) {
|
if (uri == null) return@launchFileChooser
|
||||||
return@launchFileChooser
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
val inputStream = contentResolver.openInputStream(uri)
|
val inputStream = contentResolver.openInputStream(uri)
|
||||||
val bitmap = BitmapFactory.decodeStream(inputStream)
|
val bitmap = BitmapFactory.decodeStream(inputStream)
|
||||||
inputStream?.close()
|
inputStream?.close()
|
||||||
|
|
||||||
val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
|
val text = QRCodeDecoder.syncDecodeQRCode(bitmap)
|
||||||
if (text.isNullOrEmpty()) {
|
if (text.isNullOrEmpty()) {
|
||||||
toast(R.string.toast_decoding_failed)
|
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
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.TextUtils
|
import androidx.compose.foundation.Canvas
|
||||||
import android.view.Menu
|
import androidx.compose.foundation.horizontalScroll
|
||||||
import android.view.MenuItem
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.compose.foundation.layout.Column
|
||||||
import com.blacksquircle.ui.editorkit.utils.EditorTheme
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import com.blacksquircle.ui.language.json.JsonLanguage
|
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.AppConfig
|
||||||
import com.v2ray.ang.R
|
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.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.toast
|
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.fmt.CustomFmt
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
|
||||||
import com.v2ray.ang.util.LogUtil
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
|
||||||
class ServerCustomConfigActivity : BaseActivity() {
|
class ServerCustomConfigActivity : BaseComponentActivity() {
|
||||||
private val binding by lazy { ActivityServerCustomConfigBinding.inflate(layoutInflater) }
|
|
||||||
|
|
||||||
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
||||||
private val isRunning by lazy {
|
private val isRunning by lazy {
|
||||||
@@ -31,116 +80,406 @@ class ServerCustomConfigActivity : BaseActivity() {
|
|||||||
&& editGuid == MmkvManager.getSelectServer()
|
&& editGuid == MmkvManager.getSelectServer()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var initialRemarks: String = ""
|
||||||
|
private var initialContent: String = ""
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
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)
|
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||||
if (config != null) {
|
initialRemarks = config?.remarks ?: ""
|
||||||
bindingServer(config)
|
initialContent = MmkvManager.decodeServerRaw(editGuid).orEmpty()
|
||||||
} else {
|
super.onCreate(savedInstanceState)
|
||||||
clearServer()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Composable
|
||||||
* Binding selected server config
|
override fun ScreenContent() {
|
||||||
*/
|
ServerCustomConfigScreen(
|
||||||
private fun bindingServer(config: ProfileItem): Boolean {
|
editGuid = editGuid,
|
||||||
binding.etRemarks.text = Utils.getEditable(config.remarks)
|
isRunning = isRunning,
|
||||||
val raw = MmkvManager.decodeServerRaw(editGuid)
|
initialRemarks = initialRemarks,
|
||||||
val configContent = raw.orEmpty()
|
initialContent = initialContent,
|
||||||
|
onBackClick = { finish() },
|
||||||
binding.editor.setTextContent(Utils.getEditable(configContent))
|
onSave = { remarks, content -> saveServer(remarks, content) },
|
||||||
return true
|
onDelete = { deleteServer() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun saveServer(
|
||||||
* clear or init server config
|
remarks: String,
|
||||||
*/
|
content: String
|
||||||
private fun clearServer(): Boolean {
|
): Boolean {
|
||||||
binding.etRemarks.text = null
|
if (remarks.isBlank()) {
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* save server config
|
|
||||||
*/
|
|
||||||
private fun saveServer(): Boolean {
|
|
||||||
if (TextUtils.isEmpty(binding.etRemarks.text.toString())) {
|
|
||||||
toast(R.string.server_lab_remarks)
|
toast(R.string.server_lab_remarks)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val profileItem = try {
|
val parsedProfile = try {
|
||||||
CustomFmt.parse(binding.editor.text.toString())
|
CustomFmt.parse(content)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to parse custom configuration", e)
|
LogUtil.e(
|
||||||
toast("${getString(R.string.toast_malformed_josn)} ${e.cause?.message}")
|
AppConfig.TAG,
|
||||||
|
"Failed to parse custom configuration",
|
||||||
|
e
|
||||||
|
)
|
||||||
|
toast(
|
||||||
|
"${getString(R.string.toast_malformed_josn)} " +
|
||||||
|
"${e.cause?.message.orEmpty()}"
|
||||||
|
)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.CUSTOM)
|
val config =
|
||||||
binding.etRemarks.text.let {
|
MmkvManager.decodeServerConfig(editGuid)
|
||||||
config.remarks = if (it.isNullOrEmpty()) profileItem?.remarks.orEmpty() else it.toString()
|
?: ProfileItem.create(EConfigType.CUSTOM)
|
||||||
}
|
|
||||||
config.server = profileItem?.server
|
config.remarks =
|
||||||
config.serverPort = profileItem?.serverPort
|
remarks.ifEmpty { parsedProfile?.remarks.orEmpty() }
|
||||||
config.description = AngConfigManager.generateDescription(config)
|
|
||||||
|
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)
|
toastSuccess(R.string.toast_success)
|
||||||
finish()
|
|
||||||
|
ProfileEditorResult.run {
|
||||||
|
finishSaved(
|
||||||
|
guid = savedGuid,
|
||||||
|
restartService = isRunning
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* save server config
|
|
||||||
*/
|
|
||||||
private fun deleteServer(): Boolean {
|
private fun deleteServer(): Boolean {
|
||||||
if (editGuid.isNotEmpty()) {
|
if (editGuid.isEmpty()) {
|
||||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
return false
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
MmkvManager.removeServer(editGuid)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editGuid == MmkvManager.getSelectServer()) {
|
||||||
|
toast(R.string.toast_action_not_allowed)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
MmkvManager.removeServer(editGuid)
|
||||||
|
|
||||||
|
ProfileEditorResult.run {
|
||||||
|
finishDeleted(editGuid)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
private object EditorConstants {
|
||||||
menuInflater.inflate(R.menu.action_server, menu)
|
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)
|
@Composable
|
||||||
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
|
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) {
|
val vh = verticalScroll.viewportSize.toFloat()
|
||||||
R.id.del_config -> {
|
if (vh > 0f) {
|
||||||
deleteServer()
|
val scrollY = verticalScroll.value.toFloat()
|
||||||
true
|
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 -> {
|
Box(
|
||||||
saveServer()
|
modifier = Modifier
|
||||||
true
|
.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
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.TextUtils
|
import androidx.compose.foundation.layout.Column
|
||||||
import android.view.Menu
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import android.view.MenuItem
|
import androidx.compose.foundation.layout.padding
|
||||||
import android.widget.ArrayAdapter
|
import androidx.compose.material3.Icon
|
||||||
import androidx.appcompat.app.AlertDialog
|
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.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.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.isNotNullEmpty
|
import com.v2ray.ang.extension.isNotNullEmpty
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
|
||||||
import com.v2ray.ang.util.Utils
|
|
||||||
|
|
||||||
class ServerGroupActivity : BaseActivity() {
|
class ServerGroupActivity : BaseComponentActivity() {
|
||||||
private val binding by lazy { ActivityServerGroupBinding.inflate(layoutInflater) }
|
|
||||||
|
|
||||||
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
private val editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
||||||
private val isRunning by lazy {
|
private val isRunning by lazy {
|
||||||
@@ -26,145 +39,214 @@ class ServerGroupActivity : BaseActivity() {
|
|||||||
&& editGuid.isNotEmpty()
|
&& editGuid.isNotEmpty()
|
||||||
&& editGuid == MmkvManager.getSelectServer()
|
&& editGuid == MmkvManager.getSelectServer()
|
||||||
}
|
}
|
||||||
private val subscriptionId by lazy {
|
private val subscriptionId by lazy { intent.getStringExtra("subscriptionId") }
|
||||||
intent.getStringExtra("subscriptionId")
|
|
||||||
}
|
|
||||||
private val subIds = mutableListOf<String>()
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = EConfigType.POLICYGROUP.toString())
|
|
||||||
|
|
||||||
val config = MmkvManager.decodeServerConfig(editGuid)
|
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||||
populateSubscriptionSpinner()
|
populateSubscriptionSpinner()
|
||||||
|
|
||||||
if (config != null) {
|
initialRemarks = config?.remarks ?: ""
|
||||||
bindingServer(config)
|
initialFilter = config?.policyGroupFilter ?: ""
|
||||||
} else {
|
initialType = config?.policyGroupType?.toIntOrNull() ?: 0
|
||||||
clearServer()
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Composable
|
||||||
* Binding selected server config
|
override fun ScreenContent() {
|
||||||
*/
|
ServerGroupScreen(
|
||||||
private fun bindingServer(config: ProfileItem): Boolean {
|
editGuid = editGuid,
|
||||||
binding.etRemarks.text = Utils.getEditable(config.remarks)
|
isRunning = isRunning,
|
||||||
binding.etPolicyGroupFilter.text = Utils.getEditable(config.policyGroupFilter)
|
subDisplay = subDisplay,
|
||||||
|
initialRemarks = initialRemarks,
|
||||||
val type = config.policyGroupType?.toInt() ?: 0
|
initialFilter = initialFilter,
|
||||||
binding.spPolicyGroupType.setSelection(type)
|
initialType = initialType,
|
||||||
|
initialSubIndex = initialSubIndex,
|
||||||
val pos = subIds.indexOf(config.policyGroupSubscriptionId ?: "").let { if (it >= 0) it else 0 }
|
onBackClick = { finish() },
|
||||||
binding.spPolicyGroupSubId.setSelection(pos)
|
onSave = { remarks, filter, typeIdx, subIdx -> saveServer(remarks, filter, typeIdx, subIdx) },
|
||||||
|
onDelete = { deleteServer() }
|
||||||
return true
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun saveServer(
|
||||||
* clear or init server config
|
remarks: String,
|
||||||
*/
|
filter: String,
|
||||||
private fun clearServer(): Boolean {
|
typeIdx: Int,
|
||||||
binding.etRemarks.text = null
|
subIdx: Int
|
||||||
binding.etPolicyGroupFilter.text = null
|
): Boolean {
|
||||||
|
if (remarks.isBlank()) {
|
||||||
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())) {
|
|
||||||
toast(R.string.server_lab_remarks)
|
toast(R.string.server_lab_remarks)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.POLICYGROUP)
|
val config =
|
||||||
config.remarks = binding.etRemarks.text.toString().trim()
|
MmkvManager.decodeServerConfig(editGuid)
|
||||||
config.policyGroupFilter = binding.etPolicyGroupFilter.text.toString().trim()
|
?: 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
|
if (
|
||||||
config.policyGroupSubscriptionId = if (selPos >= 0 && selPos < subIds.size) subIds[selPos] else null
|
config.subscriptionId.isEmpty() &&
|
||||||
|
!subscriptionId.isNullOrEmpty()
|
||||||
if (config.subscriptionId.isEmpty() && !subscriptionId.isNullOrEmpty()) {
|
) {
|
||||||
config.subscriptionId = subscriptionId.orEmpty()
|
config.subscriptionId = subscriptionId.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
config.description = "${binding.spPolicyGroupType.selectedItem} - ${binding.spPolicyGroupSubId.selectedItem} - ${config.policyGroupFilter}"
|
val typeDisplay =
|
||||||
|
stringArrayPolicyGroupType()
|
||||||
|
.getOrNull(typeIdx)
|
||||||
|
.orEmpty()
|
||||||
|
|
||||||
MmkvManager.encodeServerConfig(editGuid, config)
|
config.description = buildString {
|
||||||
if (isRunning) {
|
append(typeDisplay)
|
||||||
SettingsChangeManager.makeRestartService()
|
append(" - ")
|
||||||
|
append(subDisplay.getOrNull(subIdx).orEmpty())
|
||||||
|
append(" - ")
|
||||||
|
append(config.policyGroupFilter)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val savedGuid = MmkvManager.encodeServerConfig(
|
||||||
|
editGuid,
|
||||||
|
config
|
||||||
|
)
|
||||||
|
|
||||||
toastSuccess(R.string.toast_success)
|
toastSuccess(R.string.toast_success)
|
||||||
finish()
|
|
||||||
|
ProfileEditorResult.run {
|
||||||
|
finishSaved(
|
||||||
|
guid = savedGuid,
|
||||||
|
restartService = isRunning
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* save server config
|
|
||||||
*/
|
|
||||||
private fun deleteServer(): Boolean {
|
private fun deleteServer(): Boolean {
|
||||||
if (editGuid.isNotEmpty()) {
|
if (editGuid.isEmpty()) {
|
||||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
return false
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
|
||||||
MmkvManager.removeServer(editGuid)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (editGuid == MmkvManager.getSelectServer()) {
|
||||||
|
toast(R.string.toast_action_not_allowed)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
MmkvManager.removeServer(editGuid)
|
||||||
|
|
||||||
|
ProfileEditorResult.run {
|
||||||
|
finishDeleted(editGuid)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun populateSubscriptionSpinner() {
|
private fun populateSubscriptionSpinner() {
|
||||||
val subs = MmkvManager.decodeSubscriptions()
|
val subs = MmkvManager.decodeSubscriptions()
|
||||||
val displayList = mutableListOf(getString(R.string.filter_config_all)) //none
|
|
||||||
subIds.clear()
|
subIds.clear()
|
||||||
subIds.add("") // index 0 => All
|
subDisplay.clear()
|
||||||
|
subDisplay.add(getString(R.string.filter_config_all))
|
||||||
|
subIds.add("")
|
||||||
subs.forEach { sub ->
|
subs.forEach { sub ->
|
||||||
val name = when {
|
val name = when {
|
||||||
sub.subscription.remarks.isNotBlank() -> sub.subscription.remarks
|
sub.subscription.remarks.isNotBlank() -> sub.subscription.remarks
|
||||||
else -> sub.guid
|
else -> sub.guid
|
||||||
}
|
}
|
||||||
displayList.add(name)
|
subDisplay.add(name)
|
||||||
subIds.add(sub.guid)
|
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 {
|
private fun stringArrayPolicyGroupType(): Array<String> =
|
||||||
menuInflater.inflate(R.menu.action_server, menu)
|
resources.getStringArray(R.array.policy_group_type)
|
||||||
|
}
|
||||||
|
|
||||||
val delButton = menu.findItem(R.id.del_config)
|
@Composable
|
||||||
delButton?.isVisible = editGuid.isNotEmpty() && !isRunning
|
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) {
|
if (showDeleteConfirm) {
|
||||||
R.id.del_config -> {
|
ConfirmDialog(
|
||||||
deleteServer()
|
message = stringResource(R.string.del_config_comfirm),
|
||||||
true
|
confirmText = stringResource(android.R.string.ok),
|
||||||
}
|
dismissText = stringResource(android.R.string.cancel),
|
||||||
|
onConfirm = onDelete,
|
||||||
R.id.save_config -> {
|
onDismiss = { showDeleteConfirm = false }
|
||||||
saveServer()
|
)
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,108 +1,110 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.TextUtils
|
import androidx.compose.animation.core.animateDpAsState
|
||||||
import android.view.Menu
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import android.view.MenuItem
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.appcompat.app.AlertDialog
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import com.v2ray.ang.AppConfig
|
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.R
|
||||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
import com.v2ray.ang.compose.AppTopBar
|
||||||
import com.v2ray.ang.databinding.ActivityServerProxyChainBinding
|
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.dto.entities.ProfileItem
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.isComplexType
|
import com.v2ray.ang.extension.isComplexType
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.helper.SimpleItemTouchHelperCallback
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
import com.v2ray.ang.util.Utils
|
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 editGuid by lazy { intent.getStringExtra("guid").orEmpty() }
|
||||||
private val isRunning by lazy {
|
private val isRunning by lazy {
|
||||||
intent.getBooleanExtra("isRunning", false)
|
intent.getBooleanExtra("isRunning", false)
|
||||||
&& editGuid.isNotEmpty()
|
&& editGuid.isNotEmpty()
|
||||||
&& editGuid == MmkvManager.getSelectServer()
|
&& editGuid == MmkvManager.getSelectServer()
|
||||||
}
|
}
|
||||||
private val subscriptionId by lazy {
|
private val subscriptionId by lazy { intent.getStringExtra("subscriptionId") }
|
||||||
intent.getStringExtra("subscriptionId")
|
|
||||||
}
|
|
||||||
private lateinit var memberAdapter: ServerProxyChainMemberAdapter
|
|
||||||
|
|
||||||
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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(
|
allRemarks = SettingsManager.getProfileRemarks(
|
||||||
excludeConfigTypes = setOf(
|
excludeConfigTypes = setOf(EConfigType.CUSTOM, EConfigType.POLICYGROUP, EConfigType.PROXYCHAIN)
|
||||||
EConfigType.CUSTOM,
|
)
|
||||||
EConfigType.POLICYGROUP,
|
val config = MmkvManager.decodeServerConfig(editGuid)
|
||||||
EConfigType.PROXYCHAIN,
|
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() {
|
private fun saveServer(
|
||||||
memberAdapter = ServerProxyChainMemberAdapter(
|
remarks: String,
|
||||||
members = mutableListOf(),
|
members: List<String>
|
||||||
suggestions = allRemarks,
|
): Boolean {
|
||||||
adapterListener = ActivityAdapterListener()
|
if (remarks.isBlank()) {
|
||||||
)
|
|
||||||
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())) {
|
|
||||||
toast(R.string.server_lab_remarks)
|
toast(R.string.server_lab_remarks)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val chainMembers = memberAdapter.getMembers().map { it.trim() }.filter { it.isNotEmpty() }
|
val chainMembers = members
|
||||||
if (chainMembers.size != memberAdapter.getMembers().size) {
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() }
|
||||||
|
|
||||||
|
if (chainMembers.size != members.size) {
|
||||||
toast(R.string.server_proxy_chain_members_unselected)
|
toast(R.string.server_proxy_chain_members_unselected)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chainMembers.size < 2) {
|
if (chainMembers.size < 2) {
|
||||||
toast(R.string.server_proxy_chain_members_insufficient)
|
toast(R.string.server_proxy_chain_members_insufficient)
|
||||||
return false
|
return false
|
||||||
@@ -112,106 +114,204 @@ class ServerProxyChainActivity : BaseActivity() {
|
|||||||
val profile = SettingsManager.getServerViaRemarks(member)
|
val profile = SettingsManager.getServerViaRemarks(member)
|
||||||
profile == null || profile.configType.isComplexType()
|
profile == null || profile.configType.isComplexType()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (invalidMembers.isNotEmpty()) {
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
val config = MmkvManager.decodeServerConfig(editGuid) ?: ProfileItem.create(EConfigType.PROXYCHAIN)
|
val config =
|
||||||
config.remarks = binding.etRemarks.text.toString().trim()
|
MmkvManager.decodeServerConfig(editGuid)
|
||||||
config.proxyChainProfiles = chainMembers.joinToString(",")
|
?: ProfileItem.create(EConfigType.PROXYCHAIN)
|
||||||
config.description = chainMembers.joinToString(" -> ")
|
|
||||||
|
|
||||||
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()
|
config.subscriptionId = subscriptionId.orEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
MmkvManager.encodeServerConfig(editGuid, config)
|
val savedGuid = MmkvManager.encodeServerConfig(
|
||||||
if (isRunning) {
|
editGuid,
|
||||||
SettingsChangeManager.makeRestartService()
|
config
|
||||||
}
|
)
|
||||||
|
|
||||||
toastSuccess(R.string.toast_success)
|
toastSuccess(R.string.toast_success)
|
||||||
finish()
|
|
||||||
|
ProfileEditorResult.run {
|
||||||
|
finishSaved(
|
||||||
|
guid = savedGuid,
|
||||||
|
restartService = isRunning
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun deleteServer(): Boolean {
|
private fun deleteServer(): Boolean {
|
||||||
if (editGuid.isNotEmpty()) {
|
if (editGuid.isEmpty()) {
|
||||||
if (editGuid != MmkvManager.getSelectServer()) {
|
return false
|
||||||
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 == MmkvManager.getSelectServer()) {
|
||||||
|
toast(R.string.toast_action_not_allowed)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
MmkvManager.removeServer(editGuid)
|
||||||
|
|
||||||
|
ProfileEditorResult.run {
|
||||||
|
finishDeleted(editGuid)
|
||||||
|
}
|
||||||
|
|
||||||
return true
|
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
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.View
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.preference.CheckBoxPreference
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.preference.EditTextPreference
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.preference.ListPreference
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.preference.PreferenceFragmentCompat
|
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
|
||||||
import com.v2ray.ang.AppConfig.VPN
|
import com.v2ray.ang.AppConfig.VPN
|
||||||
import com.v2ray.ang.R
|
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.extension.toastError
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager.rememberMmkvBool
|
||||||
import com.v2ray.ang.helper.MmkvPreferenceDataStore
|
import com.v2ray.ang.handler.MmkvManager.rememberMmkvString
|
||||||
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
import com.v2ray.ang.root.RootManager
|
import com.v2ray.ang.root.RootManager
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
class SettingsActivity : BaseComponentActivity() {
|
||||||
|
|
||||||
class SettingsActivity : BaseActivity() {
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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) }
|
var localDns by rememberMmkvBool(AppConfig.PREF_LOCAL_DNS_ENABLED, false)
|
||||||
private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) }
|
var fakeDns by rememberMmkvBool(AppConfig.PREF_FAKE_DNS_ENABLED, false)
|
||||||
private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) }
|
var appendHttpProxy by rememberMmkvBool(AppConfig.PREF_APPEND_HTTP_PROXY, false)
|
||||||
|
var vpnDns by rememberMmkvString(AppConfig.PREF_VPN_DNS, "")
|
||||||
// private val localDnsPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_LOCAL_DNS_PORT) }
|
var vpnBypassLan by rememberMmkvString(AppConfig.PREF_VPN_BYPASS_LAN, "0")
|
||||||
private val vpnDns by lazy { findPreference<EditTextPreference>(AppConfig.PREF_VPN_DNS) }
|
var vpnInterfaceAddress by rememberMmkvString(AppConfig.PREF_VPN_INTERFACE_ADDRESS_CONFIG_INDEX, "0")
|
||||||
private val vpnBypassLan by lazy { findPreference<ListPreference>(AppConfig.PREF_VPN_BYPASS_LAN) }
|
var vpnMtu by rememberMmkvString(AppConfig.PREF_VPN_MTU, "")
|
||||||
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) }
|
var mux by rememberMmkvBool(AppConfig.PREF_MUX_ENABLED, false)
|
||||||
|
var muxConcurrency by rememberMmkvString(AppConfig.PREF_MUX_CONCURRENCY, "8")
|
||||||
private val mux by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_MUX_ENABLED) }
|
var muxXudpConcurrency by rememberMmkvString(AppConfig.PREF_MUX_XUDP_CONCURRENCY, "8")
|
||||||
private val muxConcurrency by lazy { findPreference<EditTextPreference>(AppConfig.PREF_MUX_CONCURRENCY) }
|
var muxXudpQuic by rememberMmkvString(AppConfig.PREF_MUX_XUDP_QUIC, "reject")
|
||||||
private val muxXudpConcurrency by lazy { findPreference<EditTextPreference>(AppConfig.PREF_MUX_XUDP_CONCURRENCY) }
|
|
||||||
private val muxXudpQuic by lazy { findPreference<ListPreference>(AppConfig.PREF_MUX_XUDP_QUIC) }
|
var fragment by rememberMmkvBool(AppConfig.PREF_FRAGMENT_ENABLED, false)
|
||||||
|
var fragmentPackets by rememberMmkvString(AppConfig.PREF_FRAGMENT_PACKETS, "tlshello")
|
||||||
private val fragment by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FRAGMENT_ENABLED) }
|
var fragmentLength by rememberMmkvString(AppConfig.PREF_FRAGMENT_LENGTH, "50-100")
|
||||||
private val fragmentPackets by lazy { findPreference<ListPreference>(AppConfig.PREF_FRAGMENT_PACKETS) }
|
var fragmentInterval by rememberMmkvString(AppConfig.PREF_FRAGMENT_INTERVAL, "10-20")
|
||||||
private val fragmentLength by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_LENGTH) }
|
var fragmentMaxSplit by rememberMmkvString(AppConfig.PREF_FRAGMENT_MAXSPLIT, "10")
|
||||||
private val fragmentInterval by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_INTERVAL) }
|
|
||||||
private val fragmentMaxSplit by lazy { findPreference<EditTextPreference>(AppConfig.PREF_FRAGMENT_MAXSPLIT) }
|
var mode by rememberMmkvString(AppConfig.PREF_MODE, VPN)
|
||||||
|
var enableRootMode by rememberMmkvBool(AppConfig.PREF_ROOT_MODE_ENABLE, false)
|
||||||
private val mode by lazy { findPreference<ListPreference>(AppConfig.PREF_MODE) }
|
var lanSharing by rememberMmkvBool(AppConfig.PREF_ROOT_LAN_SHARING, false)
|
||||||
private val enableRootMode by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ROOT_MODE_ENABLE) }
|
|
||||||
private val lanSharing by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ROOT_LAN_SHARING) }
|
var hevTunLogLevel by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL, "warning")
|
||||||
|
var hevTunRwTimeout by rememberMmkvString(AppConfig.PREF_HEV_TUNNEL_RW_TIMEOUT, "")
|
||||||
private val hevTunLogLevel by lazy { findPreference<ListPreference>(AppConfig.PREF_HEV_TUNNEL_LOGLEVEL) }
|
var useHevTun by rememberMmkvBool(AppConfig.PREF_USE_HEV_TUNNEL, true)
|
||||||
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) }
|
var enableLocalProxy by rememberMmkvBool(AppConfig.PREF_ENABLE_LOCAL_PROXY, true)
|
||||||
|
var socksPort by rememberMmkvString(AppConfig.PREF_SOCKS_PORT, "")
|
||||||
private val enableLocalProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_ENABLE_LOCAL_PROXY) }
|
var dynamicSocksPort by rememberMmkvBool(AppConfig.PREF_DYNAMIC_SOCKS_PORT, false)
|
||||||
private val socksPort by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PORT) }
|
var socksUsername by rememberMmkvString(AppConfig.PREF_SOCKS_USERNAME, "")
|
||||||
private val dynamicSocksPort by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_DYNAMIC_SOCKS_PORT) }
|
var socksPassword by rememberMmkvString(AppConfig.PREF_SOCKS_PASSWORD, "")
|
||||||
private val socksUsername by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_USERNAME) }
|
var socksEnableUdp by rememberMmkvBool(AppConfig.PREF_SOCKS_ENABLE_UDP, false)
|
||||||
private val socksPassword by lazy { findPreference<EditTextPreference>(AppConfig.PREF_SOCKS_PASSWORD) }
|
var proxySharing by rememberMmkvBool(AppConfig.PREF_PROXY_SHARING, false)
|
||||||
private val socksEnableUdp by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_SOCKS_ENABLE_UDP) }
|
|
||||||
private val proxySharing by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_PROXY_SHARING) }
|
var speedEnabled by rememberMmkvBool(AppConfig.PREF_SPEED_ENABLED, false)
|
||||||
|
var confirmRemove by rememberMmkvBool(AppConfig.PREF_CONFIRM_REMOVE, false)
|
||||||
override fun onCreatePreferences(bundle: Bundle?, s: String?) {
|
var startScanImmediate by rememberMmkvBool(AppConfig.PREF_START_SCAN_IMMEDIATE, false)
|
||||||
// Use MMKV as the storage backend for all Preferences
|
var doubleColumnDisplay by rememberMmkvBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
|
||||||
// This prevents inconsistencies between SharedPreferences and MMKV
|
var groupAllDisplay by rememberMmkvBool(AppConfig.PREF_GROUP_ALL_DISPLAY, false)
|
||||||
preferenceManager.preferenceDataStore = MmkvPreferenceDataStore()
|
var language by rememberMmkvString(AppConfig.PREF_LANGUAGE, "auto")
|
||||||
|
var uiModeNight by rememberMmkvString(AppConfig.PREF_UI_MODE_NIGHT, "0")
|
||||||
addPreferencesFromResource(R.xml.pref_settings)
|
|
||||||
|
var ipv6Enabled by rememberMmkvBool(AppConfig.PREF_IPV6_ENABLED, false)
|
||||||
initPreferenceSummaries()
|
var preferIpv6 by rememberMmkvBool(AppConfig.PREF_PREFER_IPV6, false)
|
||||||
|
var sniffingEnabled by rememberMmkvBool(AppConfig.PREF_SNIFFING_ENABLED, true)
|
||||||
localDns?.setOnPreferenceChangeListener { _, any ->
|
var routeOnlyEnabled by rememberMmkvBool(AppConfig.PREF_ROUTE_ONLY_ENABLED, false)
|
||||||
updateLocalDns(any as Boolean)
|
var remoteDns by rememberMmkvString(AppConfig.PREF_REMOTE_DNS, "")
|
||||||
true
|
var domesticDns by rememberMmkvString(AppConfig.PREF_DOMESTIC_DNS, "")
|
||||||
}
|
var dnsHosts by rememberMmkvString(AppConfig.PREF_DNS_HOSTS, "")
|
||||||
|
var coreLogLevel by rememberMmkvString(AppConfig.PREF_LOGLEVEL, "warning")
|
||||||
mux?.setOnPreferenceChangeListener { _, newValue ->
|
var outboundResolveMethod by rememberMmkvString(AppConfig.PREF_OUTBOUND_DOMAIN_RESOLVE_METHOD, "0")
|
||||||
updateMux(newValue as Boolean)
|
|
||||||
true
|
var isBooted by rememberMmkvBool(AppConfig.PREF_IS_BOOTED, false)
|
||||||
}
|
var autoRemoveInvalidAfterTest by rememberMmkvBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST, false)
|
||||||
muxConcurrency?.setOnPreferenceChangeListener { _, newValue ->
|
var autoSortAfterTest by rememberMmkvBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, false)
|
||||||
updateMuxConcurrency(newValue as String)
|
var delayTestUrl by rememberMmkvString(AppConfig.PREF_DELAY_TEST_URL, "")
|
||||||
true
|
var realPingConcurrency by rememberMmkvString(AppConfig.PREF_REAL_PING_CONCURRENCY, "16")
|
||||||
}
|
var ipApiUrl by rememberMmkvString(AppConfig.PREF_IP_API_URL, "")
|
||||||
muxXudpConcurrency?.setOnPreferenceChangeListener { _, newValue ->
|
|
||||||
updateMuxXudpConcurrency(newValue as String)
|
val isVpn = mode == VPN
|
||||||
true
|
val hevTunEnabled = isVpn && useHevTun
|
||||||
}
|
val localProxyForced = hevTunEnabled
|
||||||
|
val effectiveLocalProxy = enableLocalProxy || localProxyForced
|
||||||
fragment?.setOnPreferenceChangeListener { _, newValue ->
|
val muxXudpConcurrencyInt = muxXudpConcurrency.toIntOrNull() ?: 8
|
||||||
updateFragment(newValue as Boolean)
|
|
||||||
true
|
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()
|
||||||
mode?.setOnPreferenceChangeListener { pref, newValue ->
|
val uiModeNightValues = stringArrayResource(R.array.ui_mode_night_value).toList()
|
||||||
val valueStr = newValue.toString()
|
val bypassLanEntries = stringArrayResource(R.array.vpn_bypass_lan).toList()
|
||||||
(pref as? ListPreference)?.let { lp ->
|
val bypassLanValues = stringArrayResource(R.array.vpn_bypass_lan_value).toList()
|
||||||
val idx = lp.findIndexOfValue(valueStr)
|
val interfaceAddrEntries = stringArrayResource(R.array.vpn_interface_address).toList()
|
||||||
lp.summary = if (idx >= 0) lp.entries[idx] else valueStr
|
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
|
|
||||||
}
|
PreferenceGroupHeader(title = stringResource(R.string.title_vpn_settings))
|
||||||
|
SettingsSwitchItem(
|
||||||
mode?.dialogLayoutResource = R.layout.preference_with_help_link
|
title = stringResource(R.string.title_pref_ipv6_enabled),
|
||||||
|
summary = stringResource(R.string.summary_pref_ipv6_enabled),
|
||||||
useHevTun?.setOnPreferenceChangeListener { _, newValue ->
|
checked = ipv6Enabled,
|
||||||
updateHevTunSettings(newValue as Boolean)
|
enabled = isVpn,
|
||||||
true
|
onCheckedChange = { ipv6Enabled = it }
|
||||||
}
|
)
|
||||||
|
SettingsSwitchItem(
|
||||||
enableLocalProxy?.setOnPreferenceChangeListener { _, newValue ->
|
title = stringResource(R.string.title_pref_prefer_ipv6),
|
||||||
updateEnableLocalProxy(newValue as Boolean)
|
summary = stringResource(R.string.summary_pref_prefer_ipv6),
|
||||||
true
|
checked = preferIpv6,
|
||||||
}
|
enabled = isVpn && ipv6Enabled,
|
||||||
|
onCheckedChange = { preferIpv6 = it }
|
||||||
dynamicSocksPort?.setOnPreferenceChangeListener { _, newValue ->
|
)
|
||||||
updateDynamicSocksPort(newValue as Boolean)
|
SettingsSwitchItem(
|
||||||
true
|
title = stringResource(R.string.title_pref_local_dns_enabled),
|
||||||
}
|
summary = stringResource(R.string.summary_pref_local_dns_enabled),
|
||||||
|
checked = localDns,
|
||||||
enableRootMode?.setOnPreferenceChangeListener { _, newValue ->
|
enabled = isVpn,
|
||||||
if (newValue == true && !RootManager.cachedRoot()) {
|
onCheckedChange = { localDns = it }
|
||||||
lifecycleScope.launch {
|
)
|
||||||
if (checkAndRequestRoot()) {
|
SettingsSwitchItem(
|
||||||
enableRootMode?.isChecked = true
|
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
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
SettingsListItem(
|
||||||
lanSharing?.setOnPreferenceChangeListener { _, newValue ->
|
title = stringResource(R.string.title_pref_hev_tunnel_loglevel),
|
||||||
if (newValue == true && !RootManager.cachedRoot()) {
|
entries = hevLogEntries,
|
||||||
lifecycleScope.launch {
|
values = hevLogValues,
|
||||||
if (checkAndRequestRoot()) {
|
selectedValue = hevTunLogLevel,
|
||||||
lanSharing?.isChecked = true
|
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
|
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
|
SettingsSwitchItem(
|
||||||
}
|
title = stringResource(R.string.title_pref_proxy_sharing_enabled),
|
||||||
|
summary = stringResource(R.string.summary_pref_proxy_sharing_enabled),
|
||||||
private fun initPreferenceSummaries() {
|
checked = proxySharing,
|
||||||
fun updateSummary(pref: androidx.preference.Preference) {
|
enabled = effectiveLocalProxy,
|
||||||
when (pref) {
|
onCheckedChange = { proxySharing = it }
|
||||||
is EditTextPreference -> {
|
)
|
||||||
if (pref.key == AppConfig.PREF_SOCKS_PASSWORD) {
|
SettingsSwitchItem(
|
||||||
pref.summary = if (pref.text.isNullOrEmpty()) "" else "******"
|
title = stringResource(R.string.title_pref_dynamic_socks_port),
|
||||||
} else {
|
summary = stringResource(R.string.summary_pref_dynamic_socks_port),
|
||||||
pref.summary = pref.text.orEmpty()
|
checked = dynamicSocksPort,
|
||||||
}
|
enabled = effectiveLocalProxy,
|
||||||
pref.setOnPreferenceChangeListener { p, newValue ->
|
onCheckedChange = { dynamicSocksPort = it }
|
||||||
if (p.key == AppConfig.PREF_SOCKS_PASSWORD) {
|
)
|
||||||
p.summary = if ((newValue as? String).isNullOrEmpty()) "" else "******"
|
SettingsEditItem(
|
||||||
} else {
|
title = stringResource(R.string.title_pref_socks_port),
|
||||||
p.summary = (newValue as? String).orEmpty()
|
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 ?: ""
|
SettingsSwitchItem(
|
||||||
pref.setOnPreferenceChangeListener { p, newValue ->
|
title = stringResource(R.string.title_root_lan_sharing),
|
||||||
val lp = p as ListPreference
|
summary = stringResource(R.string.summary_root_lan_sharing),
|
||||||
val idx = lp.findIndexOfValue(newValue as? String)
|
checked = lanSharing,
|
||||||
lp.summary = (if (idx >= 0) lp.entries[idx] else newValue) as CharSequence?
|
onCheckedChange = { newValue ->
|
||||||
true
|
if (newValue && !RootManager.cachedRoot()) {
|
||||||
|
scope.launch {
|
||||||
|
if (checkAndRequestRoot(context)) {
|
||||||
|
lanSharing = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
|
lanSharing = newValue
|
||||||
is CheckBoxPreference, is androidx.preference.SwitchPreferenceCompat -> {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun traverse(group: androidx.preference.PreferenceGroup) {
|
|
||||||
for (i in 0 until group.preferenceCount) {
|
|
||||||
when (val p = group.getPreference(i)) {
|
|
||||||
is androidx.preference.PreferenceGroup -> traverse(p)
|
|
||||||
else -> updateSummary(p)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
preferenceScreen?.let { traverse(it) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun checkAndRequestRoot(): Boolean {
|
|
||||||
val hasRoot = RootManager.refresh()
|
|
||||||
if (!isAdded) return false
|
|
||||||
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) {
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
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.os.Bundle
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.view.Menu
|
import androidx.compose.foundation.layout.Column
|
||||||
import android.view.MenuItem
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import android.widget.ArrayAdapter
|
import androidx.compose.foundation.layout.padding
|
||||||
import android.widget.AutoCompleteTextView
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import android.widget.ImageButton
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.appcompat.app.AlertDialog
|
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 androidx.lifecycle.lifecycleScope
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
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.dto.entities.SubscriptionItem
|
||||||
import com.v2ray.ang.enums.EConfigType
|
import com.v2ray.ang.enums.EConfigType
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
@@ -24,127 +44,38 @@ import com.v2ray.ang.util.Utils
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class SubEditActivity : BaseActivity() {
|
class SubEditActivity : BaseComponentActivity() {
|
||||||
private val binding by lazy { ActivitySubEditBinding.inflate(layoutInflater) }
|
|
||||||
|
|
||||||
private var del_config: MenuItem? = null
|
|
||||||
private var save_config: MenuItem? = null
|
|
||||||
|
|
||||||
private val editSubId by lazy { intent.getStringExtra("subId").orEmpty() }
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
|
|
||||||
|
|
||||||
setupProfileRemarkInputs()
|
suggestions = SettingsManager.getProfileRemarks(
|
||||||
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(
|
|
||||||
excludeConfigTypes = setOf(
|
excludeConfigTypes = setOf(
|
||||||
EConfigType.CUSTOM,
|
EConfigType.CUSTOM,
|
||||||
EConfigType.POLICYGROUP,
|
EConfigType.POLICYGROUP,
|
||||||
EConfigType.PROXYCHAIN,
|
EConfigType.PROXYCHAIN,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
subItem = MmkvManager.decodeSubscription(editSubId) ?: SubscriptionItem()
|
||||||
setupProfileRemarkInput(binding.etPreProfile, binding.btnPreProfileDropdown, suggestions)
|
|
||||||
setupProfileRemarkInput(binding.etNextProfile, binding.btnNextProfileDropdown, suggestions)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun setupProfileRemarkInput(
|
@Composable
|
||||||
input: AutoCompleteTextView,
|
override fun ScreenContent() {
|
||||||
dropdownButton: ImageButton,
|
SubEditScreen(
|
||||||
suggestions: List<String>
|
editSubId = editSubId,
|
||||||
) {
|
initial = subItem,
|
||||||
val adapter = ArrayAdapter(this, android.R.layout.simple_dropdown_item_1line, suggestions)
|
profileSuggestions = suggestions,
|
||||||
input.setAdapter(adapter)
|
onBackClick = { finish() },
|
||||||
input.threshold = 0
|
onSave = { saveServer(it) },
|
||||||
|
onDelete = { deleteServer() }
|
||||||
dropdownButton.setOnClickListener {
|
)
|
||||||
input.requestFocus()
|
|
||||||
input.showDropDown()
|
|
||||||
}
|
|
||||||
input.setOnClickListener {
|
|
||||||
input.showDropDown()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun saveServer(subItem: SubscriptionItem): Boolean {
|
||||||
* save server config
|
val intervalMinutes = subItem.updateInterval
|
||||||
*/
|
|
||||||
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
|
|
||||||
|
|
||||||
if (TextUtils.isEmpty(subItem.remarks)) {
|
if (TextUtils.isEmpty(subItem.remarks)) {
|
||||||
toast(R.string.sub_setting_remarks)
|
toast(R.string.sub_setting_remarks)
|
||||||
@@ -155,7 +86,6 @@ class SubEditActivity : BaseActivity() {
|
|||||||
toast(R.string.toast_invalid_url)
|
toast(R.string.toast_invalid_url)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!Utils.isValidSubUrl(subItem.url)) {
|
if (!Utils.isValidSubUrl(subItem.url)) {
|
||||||
toast(R.string.toast_insecure_url_protocol)
|
toast(R.string.toast_insecure_url_protocol)
|
||||||
if (!subItem.allowInsecureUrl) {
|
if (!subItem.allowInsecureUrl) {
|
||||||
@@ -166,62 +96,162 @@ class SubEditActivity : BaseActivity() {
|
|||||||
|
|
||||||
MmkvManager.encodeSubscription(editSubId, subItem)
|
MmkvManager.encodeSubscription(editSubId, subItem)
|
||||||
SubscriptionUpdater.syncOne(subId = editSubId)
|
SubscriptionUpdater.syncOne(subId = editSubId)
|
||||||
|
SettingsChangeManager.makeSetupGroupTab()
|
||||||
toastSuccess(R.string.toast_success)
|
toastSuccess(R.string.toast_success)
|
||||||
finish()
|
finish()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* save server config
|
|
||||||
*/
|
|
||||||
private fun deleteServer(): Boolean {
|
private fun deleteServer(): Boolean {
|
||||||
if (editSubId.isNotEmpty()) {
|
if (editSubId.isNotEmpty()) {
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
SettingsManager.removeSubscriptionWithDefault(editSubId)
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
SettingsChangeManager.makeSetupGroupTab()
|
||||||
lifecycleScope.launch(Dispatchers.IO) {
|
launch(Dispatchers.Main) { finish() }
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
@Composable
|
||||||
menuInflater.inflate(R.menu.action_server, menu)
|
fun SubEditScreen(
|
||||||
del_config = menu.findItem(R.id.del_config)
|
editSubId: String,
|
||||||
save_config = menu.findItem(R.id.save_config)
|
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) {
|
Scaffold(
|
||||||
R.id.del_config -> {
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||||
deleteServer()
|
topBar = {
|
||||||
true
|
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 -> {
|
FormTextField(
|
||||||
saveServer()
|
stringResource(R.string.title_pref_auto_update_interval),
|
||||||
true
|
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
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.LayoutInflater
|
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import androidx.activity.viewModels
|
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.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.ItemTouchHelper
|
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
import com.v2ray.ang.compose.AppDivider
|
||||||
import com.v2ray.ang.databinding.ActivitySubSettingBinding
|
import com.v2ray.ang.compose.AppTopBar
|
||||||
import com.v2ray.ang.databinding.ItemQrcodeBinding
|
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.extension.toast
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
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.QRCodeDecoder
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
import com.v2ray.ang.viewmodel.SubscriptionsViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import sh.calvin.reorderable.ReorderableItem
|
||||||
|
import sh.calvin.reorderable.rememberReorderableLazyListState
|
||||||
|
|
||||||
class SubSettingActivity : BaseActivity() {
|
class SubSettingActivity : BaseComponentActivity() {
|
||||||
private val binding by lazy { ActivitySubSettingBinding.inflate(layoutInflater) }
|
|
||||||
private val ownerActivity: SubSettingActivity
|
|
||||||
get() = this
|
|
||||||
private val viewModel: SubscriptionsViewModel by viewModels()
|
private val viewModel: SubscriptionsViewModel by viewModels()
|
||||||
private lateinit var adapter: SubSettingRecyclerAdapter
|
private val isLoadingState = MutableStateFlow(false)
|
||||||
private var mItemTouchHelper: ItemTouchHelper? = null
|
|
||||||
private val share_method: Array<out String> by lazy {
|
|
||||||
resources.getStringArray(R.array.share_sub_method)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
}
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
|
|
||||||
|
|
||||||
adapter = SubSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
|
@Composable
|
||||||
|
override fun ScreenContent() {
|
||||||
binding.recyclerView.setHasFixedSize(true)
|
SubSettingScreen(
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
viewModel = viewModel,
|
||||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
isLoadingState = isLoadingState,
|
||||||
binding.recyclerView.adapter = adapter
|
onBackClick = { finish() },
|
||||||
|
onAddClick = { startActivity(Intent(this, SubEditActivity::class.java)) },
|
||||||
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
|
onSubUpdate = { updateSubscriptions() },
|
||||||
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
|
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() {
|
override fun onResume() {
|
||||||
super.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()
|
viewModel.reload()
|
||||||
adapter.notifyDataSetChanged()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private inner class ActivityAdapterListener : BaseAdapterListener {
|
private fun removeSub(subId: String) {
|
||||||
override fun onEdit(guid: String, position: Int) {
|
viewModel.remove(subId)
|
||||||
startActivity(
|
}
|
||||||
Intent(ownerActivity, SubEditActivity::class.java)
|
|
||||||
.putExtra("subId", guid)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onRemove(guid: String, position: Int) {
|
private fun updateSubscriptions() {
|
||||||
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_CONFIRM_REMOVE)) {
|
isLoadingState.value = true
|
||||||
AlertDialog.Builder(ownerActivity)
|
lifecycleScope.launch(Dispatchers.IO) {
|
||||||
.setMessage(R.string.del_config_comfirm)
|
val result = AngConfigManager.updateConfigViaSubAll()
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
delay(500L)
|
||||||
viewModel.remove(guid)
|
launch(Dispatchers.Main) {
|
||||||
refreshData()
|
if (result.successCount + result.failureCount + result.skipCount == 0) {
|
||||||
}
|
toast(R.string.title_update_subscription_no_subscription)
|
||||||
.setNegativeButton(android.R.string.cancel, null)
|
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
|
||||||
.show()
|
toast(getString(R.string.title_update_config_count, result.configCount))
|
||||||
} else {
|
} else {
|
||||||
viewModel.remove(guid)
|
toast(
|
||||||
refreshData()
|
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) {
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
AlertDialog.Builder(ownerActivity)
|
@Composable
|
||||||
.setItems(share_method.asList().toTypedArray()) { _, i ->
|
fun SubSettingScreen(
|
||||||
try {
|
viewModel: SubscriptionsViewModel,
|
||||||
when (i) {
|
isLoadingState: MutableStateFlow<Boolean>,
|
||||||
0 -> {
|
onBackClick: () -> Unit,
|
||||||
val ivBinding =
|
onAddClick: () -> Unit,
|
||||||
ItemQrcodeBinding.inflate(LayoutInflater.from(ownerActivity))
|
onSubUpdate: () -> Unit,
|
||||||
ivBinding.ivQcode.setImageBitmap(
|
onEditSub: (String) -> Unit,
|
||||||
QRCodeDecoder.createQRCode(
|
onRemoveSub: (String) -> Unit,
|
||||||
url
|
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 -> {
|
Column(
|
||||||
Utils.setClipboard(ownerActivity, url)
|
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() {
|
if (shareTarget != null) {
|
||||||
refreshData()
|
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.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.view.Menu
|
import androidx.activity.compose.setContent
|
||||||
import android.view.MenuItem
|
import androidx.compose.foundation.clickable
|
||||||
import android.view.View
|
import androidx.compose.foundation.layout.Column
|
||||||
import android.widget.ArrayAdapter
|
import androidx.compose.foundation.layout.Row
|
||||||
import android.widget.ListView
|
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.AppConfig
|
||||||
import com.v2ray.ang.R
|
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.handler.MmkvManager
|
||||||
import com.v2ray.ang.util.LogUtil
|
import com.v2ray.ang.util.LogUtil
|
||||||
|
|
||||||
class TaskerActivity : BaseActivity() {
|
class TaskerActivity : BaseComponentActivity() {
|
||||||
private val binding by lazy { ActivityTaskerBinding.inflate(layoutInflater) }
|
|
||||||
|
|
||||||
private var listview: ListView? = null
|
|
||||||
private var lstData: ArrayList<String> = ArrayList()
|
private var lstData: ArrayList<String> = ArrayList()
|
||||||
private var lstGuid: ArrayList<String> = ArrayList()
|
private var lstGuid: ArrayList<String> = ArrayList()
|
||||||
|
|
||||||
|
private val switchState = mutableStateOf(false)
|
||||||
|
private val selectedPosition = mutableStateOf(-1)
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
//setContentView(binding.root)
|
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = "")
|
|
||||||
|
|
||||||
//add def value
|
|
||||||
lstData.add("Default")
|
lstData.add("Default")
|
||||||
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
|
lstGuid.add(AppConfig.TASKER_DEFAULT_GUID)
|
||||||
|
|
||||||
@@ -36,16 +58,21 @@ class TaskerActivity : BaseActivity() {
|
|||||||
lstGuid.add(key)
|
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()
|
init()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun ScreenContent() {
|
||||||
|
TaskerScreen(
|
||||||
|
items = lstData,
|
||||||
|
switchState = switchState,
|
||||||
|
selectedPosition = selectedPosition,
|
||||||
|
onBackClick = { finish() },
|
||||||
|
onSave = { confirmFinish() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private fun init() {
|
private fun init() {
|
||||||
try {
|
try {
|
||||||
val bundle = intent?.getBundleExtra(AppConfig.TASKER_EXTRA_BUNDLE)
|
val bundle = intent?.getBundleExtra(AppConfig.TASKER_EXTRA_BUNDLE)
|
||||||
@@ -55,31 +82,30 @@ class TaskerActivity : BaseActivity() {
|
|||||||
if (switch == null || TextUtils.isEmpty(guid)) {
|
if (switch == null || TextUtils.isEmpty(guid)) {
|
||||||
return
|
return
|
||||||
} else {
|
} else {
|
||||||
binding.switchStartService.isChecked = switch
|
switchState.value = switch
|
||||||
val pos = lstGuid.indexOf(guid.toString())
|
val pos = lstGuid.indexOf(guid.toString())
|
||||||
if (pos >= 0) {
|
if (pos >= 0) {
|
||||||
listview?.setItemChecked(pos, true)
|
selectedPosition.value = pos
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
|
LogUtil.e(AppConfig.TAG, "Failed to initialize Tasker settings", e)
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun confirmFinish() {
|
private fun confirmFinish() {
|
||||||
val position = listview?.checkedItemPosition
|
val position = selectedPosition.value
|
||||||
if (position == null || position < 0) {
|
if (position < 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val extraBundle = Bundle()
|
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])
|
extraBundle.putString(AppConfig.TASKER_EXTRA_BUNDLE_GUID, lstGuid[position])
|
||||||
val intent = Intent()
|
val intent = Intent()
|
||||||
|
|
||||||
val remarks = lstData[position]
|
val remarks = lstData[position]
|
||||||
val blurb = if (binding.switchStartService.isChecked) {
|
val blurb = if (switchState.value) {
|
||||||
"Start $remarks"
|
"Start $remarks"
|
||||||
} else {
|
} else {
|
||||||
"Stop $remarks"
|
"Stop $remarks"
|
||||||
@@ -90,26 +116,60 @@ class TaskerActivity : BaseActivity() {
|
|||||||
setResult(RESULT_OK, intent)
|
setResult(RESULT_OK, intent)
|
||||||
finish()
|
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.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.lifecycle.lifecycleScope
|
import androidx.lifecycle.lifecycleScope
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.databinding.ActivityLogcatBinding
|
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
import com.v2ray.ang.handler.AngConfigManager
|
import com.v2ray.ang.handler.AngConfigManager
|
||||||
@@ -16,13 +16,9 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
|
|
||||||
class UrlSchemeActivity : BaseActivity() {
|
class UrlSchemeActivity : BaseComponentActivity() {
|
||||||
private val binding by lazy { ActivityLogcatBinding.inflate(layoutInflater) }
|
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
|
||||||
setContentView(binding.root)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
intent.apply {
|
intent.apply {
|
||||||
if (action == Intent.ACTION_SEND) {
|
if (action == Intent.ACTION_SEND) {
|
||||||
@@ -59,6 +55,10 @@ class UrlSchemeActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
override fun ScreenContent() {
|
||||||
|
}
|
||||||
|
|
||||||
private fun parseUri(uriString: String?, fragment: String?) {
|
private fun parseUri(uriString: String?, fragment: String?) {
|
||||||
if (uriString.isNullOrEmpty()) {
|
if (uriString.isNullOrEmpty()) {
|
||||||
return
|
return
|
||||||
@@ -84,4 +84,4 @@ class UrlSchemeActivity : BaseActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,57 +1,120 @@
|
|||||||
package com.v2ray.ang.ui
|
package com.v2ray.ang.ui
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.AlertDialog
|
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.provider.OpenableColumns
|
import android.provider.OpenableColumns
|
||||||
import android.view.Menu
|
|
||||||
import android.view.MenuItem
|
|
||||||
import androidx.activity.viewModels
|
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.lifecycle.lifecycleScope
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.R
|
import com.v2ray.ang.R
|
||||||
import com.v2ray.ang.contracts.BaseAdapterListener
|
import com.v2ray.ang.compose.AppDivider
|
||||||
import com.v2ray.ang.databinding.ActivityUserAssetBinding
|
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.dto.entities.AssetUrlItem
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastError
|
import com.v2ray.ang.extension.toastError
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
|
import com.v2ray.ang.extension.toTrafficString
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
import com.v2ray.ang.util.LogUtil
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import com.v2ray.ang.viewmodel.UserAssetViewModel
|
import com.v2ray.ang.viewmodel.UserAssetViewModel
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.io.File
|
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 val viewModel: UserAssetViewModel by viewModels()
|
||||||
private lateinit var adapter: UserAssetAdapter
|
|
||||||
|
|
||||||
val extDir by lazy { File(Utils.userAssetPath(this)) }
|
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?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_user_asset_setting))
|
geoFilesSourceState.value = getGeoFilesSources()
|
||||||
|
}
|
||||||
|
|
||||||
binding.recyclerView.setHasFixedSize(true)
|
@Composable
|
||||||
binding.recyclerView.layoutManager = LinearLayoutManager(this)
|
override fun ScreenContent() {
|
||||||
addCustomDividerToRecyclerView(binding.recyclerView, this, R.drawable.custom_divider)
|
UserAssetScreen(
|
||||||
adapter = UserAssetAdapter(viewModel, extDir, ActivityAdapterListener())
|
viewModel = viewModel,
|
||||||
binding.recyclerView.adapter = adapter
|
extDir = extDir,
|
||||||
|
isLoadingState = isLoadingState,
|
||||||
binding.tvGeoFilesSourcesSummary.text = getGeoFilesSources()
|
geoFilesSourceState = geoFilesSourceState,
|
||||||
binding.layoutGeoFilesSources.setOnClickListener {
|
refreshTrigger = refreshTrigger,
|
||||||
setGeoFilesSources()
|
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() {
|
override fun onResume() {
|
||||||
@@ -59,36 +122,10 @@ class UserAssetActivity : HelperBaseActivity() {
|
|||||||
refreshData()
|
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 {
|
private fun getGeoFilesSources(): String {
|
||||||
return MmkvManager.decodeSettingsString(AppConfig.PREF_GEO_FILES_SOURCES) ?: AppConfig.GEO_FILES_SOURCES.first()
|
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() {
|
private fun showFileChooser() {
|
||||||
launchFileChooser { uri ->
|
launchFileChooser { uri ->
|
||||||
if (uri == null) {
|
if (uri == null) {
|
||||||
@@ -149,14 +186,12 @@ class UserAssetActivity : HelperBaseActivity() {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun importAsset(url: String?): Boolean {
|
private fun importAsset(url: String?): Boolean {
|
||||||
try {
|
try {
|
||||||
if (!Utils.isValidUrl(url)) {
|
if (!Utils.isValidUrl(url)) {
|
||||||
toast(R.string.toast_invalid_url)
|
toast(R.string.toast_invalid_url)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
// Send URL to UserAssetUrlActivity for Processing
|
|
||||||
startActivity(
|
startActivity(
|
||||||
Intent(this, UserAssetUrlActivity::class.java)
|
Intent(this, UserAssetUrlActivity::class.java)
|
||||||
.putExtra(UserAssetUrlActivity.ASSET_URL_QRCODE, url)
|
.putExtra(UserAssetUrlActivity.ASSET_URL_QRCODE, url)
|
||||||
@@ -170,7 +205,7 @@ class UserAssetActivity : HelperBaseActivity() {
|
|||||||
|
|
||||||
private fun downloadGeoFiles() {
|
private fun downloadGeoFiles() {
|
||||||
refreshData()
|
refreshData()
|
||||||
showLoading()
|
isLoadingState.value = true
|
||||||
toast(R.string.msg_downloading_content)
|
toast(R.string.msg_downloading_content)
|
||||||
|
|
||||||
val proxyUsername = SettingsManager.getSocksUsername()
|
val proxyUsername = SettingsManager.getSocksUsername()
|
||||||
@@ -185,7 +220,7 @@ class UserAssetActivity : HelperBaseActivity() {
|
|||||||
toast(getString(R.string.toast_failure))
|
toast(getString(R.string.toast_failure))
|
||||||
}
|
}
|
||||||
refreshData()
|
refreshData()
|
||||||
hideLoading()
|
isLoadingState.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -202,40 +237,203 @@ class UserAssetActivity : HelperBaseActivity() {
|
|||||||
@SuppressLint("NotifyDataSetChanged")
|
@SuppressLint("NotifyDataSetChanged")
|
||||||
fun refreshData() {
|
fun refreshData() {
|
||||||
viewModel.reload(getGeoFilesSources())
|
viewModel.reload(getGeoFilesSources())
|
||||||
adapter.notifyDataSetChanged()
|
refreshTrigger.value++
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private inner class ActivityAdapterListener : BaseAdapterListener {
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
override fun onEdit(guid: String, position: Int) {
|
@Composable
|
||||||
startActivity(
|
fun UserAssetScreen(
|
||||||
Intent(ownerActivity, UserAssetUrlActivity::class.java)
|
viewModel: UserAssetViewModel,
|
||||||
.putExtra("assetId", guid)
|
extDir: File,
|
||||||
|
isLoadingState: MutableStateFlow<Boolean>,
|
||||||
|
geoFilesSourceState: MutableStateFlow<String>,
|
||||||
|
refreshTrigger: MutableStateFlow<Int>,
|
||||||
|
geoFilesSourcesList: List<String>,
|
||||||
|
onBackClick: () -> Unit,
|
||||||
|
onGeoSourceSelected: (String) -> Unit,
|
||||||
|
onAddFileClick: () -> Unit,
|
||||||
|
onAddUrlClick: () -> Unit,
|
||||||
|
onAddQrcodeClick: () -> Unit,
|
||||||
|
onDownloadClick: () -> Unit,
|
||||||
|
onEditAsset: (String) -> Unit,
|
||||||
|
onRemoveAsset: (String) -> Unit
|
||||||
|
) {
|
||||||
|
val isLoading by isLoadingState.collectAsState()
|
||||||
|
val geoFilesSource by geoFilesSourceState.collectAsState()
|
||||||
|
val assets by viewModel.assetsFlow.collectAsStateWithLifecycle()
|
||||||
|
val trigger by refreshTrigger.collectAsState()
|
||||||
|
|
||||||
|
var showAddMenu by remember { mutableStateOf(false) }
|
||||||
|
var showGeoSourceDialog by remember { mutableStateOf(false) }
|
||||||
|
var deleteTargetGuid by remember { mutableStateOf<String?>(null) }
|
||||||
|
val listState = rememberLazyListState()
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
contentWindowInsets = ScaffoldDefaults.contentWindowInsets,
|
||||||
|
topBar = {
|
||||||
|
AppTopBar(
|
||||||
|
title = stringResource(R.string.title_user_asset_setting),
|
||||||
|
onBackClick = onBackClick,
|
||||||
|
isLoading = isLoading,
|
||||||
|
actions = {
|
||||||
|
Box(modifier = Modifier.wrapContentSize(Alignment.TopEnd)) {
|
||||||
|
IconButton(onClick = { showAddMenu = true }) {
|
||||||
|
Icon(painterResource(R.drawable.ic_add_24dp), contentDescription = stringResource(R.string.menu_item_add_asset))
|
||||||
|
}
|
||||||
|
DropdownMenu(
|
||||||
|
expanded = showAddMenu,
|
||||||
|
onDismissRequest = { showAddMenu = false },
|
||||||
|
containerColor = MaterialTheme.colorScheme.surface,
|
||||||
|
offset = DpOffset(x = 0.dp, y = 0.dp),
|
||||||
|
modifier = Modifier.wrapContentWidth(Alignment.End)
|
||||||
|
) {
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_item_add_file)) },
|
||||||
|
onClick = { showAddMenu = false; onAddFileClick() }
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_item_add_url)) },
|
||||||
|
onClick = { showAddMenu = false; onAddUrlClick() }
|
||||||
|
)
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = { Text(stringResource(R.string.menu_item_scan_qrcode)) },
|
||||||
|
onClick = { showAddMenu = false; onAddQrcodeClick() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDownloadClick) {
|
||||||
|
Icon(painterResource(R.drawable.ic_cloud_download_24dp), contentDescription = stringResource(R.string.menu_item_download_file))
|
||||||
|
}
|
||||||
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
) { innerPadding ->
|
||||||
override fun onRemove(guid: String, position: Int) {
|
LazyColumn(
|
||||||
val asset = viewModel.getAsset(position)?.takeIf { it.guid == guid }
|
state = listState,
|
||||||
?: viewModel.getAssets().find { it.guid == guid }
|
modifier = Modifier
|
||||||
?: return
|
.fillMaxSize()
|
||||||
val file = extDir.listFiles()?.find { it.name == asset.assetUrl.remarks }
|
.padding(innerPadding)
|
||||||
|
.verticalScrollbar(listState)
|
||||||
AlertDialog.Builder(ownerActivity).setMessage(R.string.del_config_comfirm)
|
) {
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
item(key = "geo_source_$trigger") {
|
||||||
file?.delete()
|
Column(
|
||||||
MmkvManager.removeAssetUrl(guid)
|
modifier = Modifier
|
||||||
initAssets()
|
.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
|
item {
|
||||||
}
|
Text(
|
||||||
.show()
|
text = stringResource(R.string.title_user_asset_setting),
|
||||||
}
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
modifier = Modifier.padding(16.dp)
|
||||||
override fun onShare(url: String) {
|
)
|
||||||
}
|
}
|
||||||
|
itemsIndexed(items = assets, key = { _, item -> "${item.guid}_$trigger" }) { _, item ->
|
||||||
override fun onRefreshData() {
|
UserAssetItem(
|
||||||
refreshData()
|
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.os.Bundle
|
||||||
import android.text.TextUtils
|
import android.text.TextUtils
|
||||||
import android.view.Menu
|
import androidx.compose.foundation.layout.Column
|
||||||
import android.view.MenuItem
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.appcompat.app.AlertDialog
|
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.AppConfig
|
||||||
import com.v2ray.ang.R
|
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.dto.entities.AssetUrlItem
|
||||||
import com.v2ray.ang.extension.toast
|
import com.v2ray.ang.extension.toast
|
||||||
import com.v2ray.ang.extension.toastSuccess
|
import com.v2ray.ang.extension.toastSuccess
|
||||||
@@ -16,65 +33,55 @@ import com.v2ray.ang.util.LogUtil
|
|||||||
import com.v2ray.ang.util.Utils
|
import com.v2ray.ang.util.Utils
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
class UserAssetUrlActivity : BaseActivity() {
|
class UserAssetUrlActivity : BaseComponentActivity() {
|
||||||
// Receive QRcode URL from UserAssetActivity
|
|
||||||
companion object {
|
companion object {
|
||||||
const val ASSET_URL_QRCODE = "ASSET_URL_QRCODE"
|
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 extDir by lazy { File(Utils.userAssetPath(this)) }
|
||||||
private val editAssetId by lazy { intent.getStringExtra("assetId").orEmpty() }
|
private val editAssetId by lazy { intent.getStringExtra("assetId").orEmpty() }
|
||||||
|
private lateinit var initialRemarks: String
|
||||||
|
private lateinit var initialUrl: String
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
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 assetItem = MmkvManager.decodeAsset(editAssetId)
|
||||||
val assetUrlQrcode = intent.getStringExtra(ASSET_URL_QRCODE)
|
val assetUrlQrcode = intent.getStringExtra(ASSET_URL_QRCODE)
|
||||||
val assetNameQrcode = File(assetUrlQrcode.toString()).name
|
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 = ""
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
@Composable
|
||||||
* bingding seleced asset config
|
override fun ScreenContent() {
|
||||||
*/
|
UserAssetUrlScreen(
|
||||||
private fun bindingAsset(assetItem: AssetUrlItem): Boolean {
|
editAssetId = editAssetId,
|
||||||
binding.etRemarks.text = Utils.getEditable(assetItem.remarks)
|
initialRemarks = initialRemarks,
|
||||||
binding.etUrl.text = Utils.getEditable(assetItem.url)
|
initialUrl = initialUrl,
|
||||||
return true
|
onBackClick = { finish() },
|
||||||
|
onSave = { r, u -> saveServer(r, u) },
|
||||||
|
onDelete = { deleteServer() }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
private fun saveServer(remarks: String, url: String): Boolean {
|
||||||
* 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 {
|
|
||||||
var assetItem = MmkvManager.decodeAsset(editAssetId)
|
var assetItem = MmkvManager.decodeAsset(editAssetId)
|
||||||
var assetId = editAssetId
|
var assetId = editAssetId
|
||||||
if (assetItem != null) {
|
if (assetItem != null) {
|
||||||
// remove file associated with the asset
|
|
||||||
val file = extDir.resolve(assetItem.remarks)
|
val file = extDir.resolve(assetItem.remarks)
|
||||||
if (file.exists()) {
|
if (file.exists()) {
|
||||||
try {
|
try {
|
||||||
@@ -88,17 +95,14 @@ class UserAssetUrlActivity : BaseActivity() {
|
|||||||
assetItem = AssetUrlItem()
|
assetItem = AssetUrlItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
assetItem.remarks = binding.etRemarks.text.toString()
|
assetItem.remarks = remarks
|
||||||
assetItem.url = binding.etUrl.text.toString()
|
assetItem.url = url
|
||||||
|
|
||||||
// check remarks unique
|
|
||||||
val assetList = MmkvManager.decodeAssetUrls()
|
val assetList = MmkvManager.decodeAssetUrls()
|
||||||
if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != assetId }) {
|
if (assetList.any { it.assetUrl.remarks == assetItem.remarks && it.guid != assetId }) {
|
||||||
toast(R.string.msg_remark_is_duplicate)
|
toast(R.string.msg_remark_is_duplicate)
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
if (TextUtils.isEmpty(assetItem.remarks)) {
|
if (TextUtils.isEmpty(assetItem.remarks)) {
|
||||||
toast(R.string.sub_setting_remarks)
|
toast(R.string.sub_setting_remarks)
|
||||||
return false
|
return false
|
||||||
@@ -114,47 +118,79 @@ class UserAssetUrlActivity : BaseActivity() {
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* save server config
|
|
||||||
*/
|
|
||||||
private fun deleteServer(): Boolean {
|
private fun deleteServer(): Boolean {
|
||||||
if (editAssetId.isNotEmpty()) {
|
if (editAssetId.isNotEmpty()) {
|
||||||
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
|
MmkvManager.removeAssetUrl(editAssetId)
|
||||||
.setPositiveButton(android.R.string.ok) { _, _ ->
|
finish()
|
||||||
MmkvManager.removeAssetUrl(editAssetId)
|
|
||||||
finish()
|
|
||||||
}
|
|
||||||
.setNegativeButton(android.R.string.cancel) { _, _ ->
|
|
||||||
// do nothing
|
|
||||||
}
|
|
||||||
.show()
|
|
||||||
}
|
}
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
@Composable
|
||||||
menuInflater.inflate(R.menu.action_server, menu)
|
fun UserAssetUrlScreen(
|
||||||
del_config = menu.findItem(R.id.del_config)
|
editAssetId: String,
|
||||||
save_config = menu.findItem(R.id.save_config)
|
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()) {
|
Scaffold(
|
||||||
del_config?.isVisible = false
|
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) {
|
if (showDeleteConfirm) {
|
||||||
R.id.del_config -> {
|
ConfirmDialog(
|
||||||
deleteServer()
|
message = stringResource(R.string.del_config_comfirm),
|
||||||
true
|
confirmText = stringResource(android.R.string.ok),
|
||||||
}
|
dismissText = stringResource(android.R.string.cancel),
|
||||||
|
onConfirm = onDelete,
|
||||||
R.id.save_config -> {
|
onDismiss = { showDeleteConfirm = false }
|
||||||
saveServer()
|
)
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> super.onOptionsItemSelected(item)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
package com.v2ray.ang.viewmodel
|
||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.v2ray.ang.AppConfig
|
import com.v2ray.ang.AppConfig
|
||||||
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
import com.v2ray.ang.AppConfig.ANG_PACKAGE
|
||||||
import com.v2ray.ang.util.LogUtil
|
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
|
import java.io.IOException
|
||||||
|
|
||||||
class LogcatViewModel : ViewModel() {
|
class LogcatViewModel : ViewModel() {
|
||||||
private val logsetsAll: MutableList<String> = mutableListOf()
|
private val logsetsAll: MutableList<String> = mutableListOf()
|
||||||
private var filteredLogs: List<String> = emptyList()
|
|
||||||
private var currentFilter: String = ""
|
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() {
|
fun loadLogcat() {
|
||||||
try {
|
viewModelScope.launch(Dispatchers.IO) {
|
||||||
val lst = LinkedHashSet<String>()
|
_isRefreshing.value = true
|
||||||
lst.add("logcat")
|
try {
|
||||||
lst.add("-d")
|
val lst = LinkedHashSet<String>()
|
||||||
lst.add("-v")
|
lst.add("logcat")
|
||||||
lst.add("time")
|
lst.add("-d")
|
||||||
lst.add("-s")
|
lst.add("-v")
|
||||||
lst.add("GoLog,${ANG_PACKAGE},AndroidRuntime,System.err")
|
lst.add("time")
|
||||||
val process = Runtime.getRuntime().exec(lst.toTypedArray())
|
lst.add("-s")
|
||||||
val allText = process.inputStream.bufferedReader().use { it.readLines() }.reversed()
|
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.clear()
|
||||||
logsetsAll.addAll(allText)
|
logsetsAll.addAll(allText)
|
||||||
applyFilter()
|
applyFilter()
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to get logcat", e)
|
LogUtil.e(AppConfig.TAG, "Failed to get logcat", e)
|
||||||
|
} finally {
|
||||||
|
_isRefreshing.value = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +56,7 @@ class LogcatViewModel : ViewModel() {
|
|||||||
process.waitFor()
|
process.waitFor()
|
||||||
|
|
||||||
logsetsAll.clear()
|
logsetsAll.clear()
|
||||||
filteredLogs = emptyList()
|
_filteredLogs.value = emptyList()
|
||||||
} catch (e: IOException) {
|
} catch (e: IOException) {
|
||||||
LogUtil.e(AppConfig.TAG, "Failed to clear logcat", e)
|
LogUtil.e(AppConfig.TAG, "Failed to clear logcat", e)
|
||||||
}
|
}
|
||||||
@@ -54,7 +68,7 @@ class LogcatViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun applyFilter() {
|
private fun applyFilter() {
|
||||||
filteredLogs = if (currentFilter.isEmpty()) {
|
_filteredLogs.value = if (currentFilter.isEmpty()) {
|
||||||
logsetsAll.toList()
|
logsetsAll.toList()
|
||||||
} else {
|
} else {
|
||||||
logsetsAll.filter { it.contains(currentFilter) }
|
logsetsAll.filter { it.contains(currentFilter) }
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,37 +1,79 @@
|
|||||||
package com.v2ray.ang.viewmodel
|
package com.v2ray.ang.viewmodel
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.v2ray.ang.AppConfig
|
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.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
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() {
|
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 {
|
private val _displayedApps = MutableStateFlow<List<AppInfo>>(emptyList())
|
||||||
val changed = blacklist.add(packageName)
|
val displayedApps: StateFlow<List<AppInfo>> = _displayedApps.asStateFlow()
|
||||||
if (changed) {
|
|
||||||
save()
|
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 {
|
fun remove(packageName: String) {
|
||||||
val changed = blacklist.remove(packageName)
|
val newSet = _blacklist.value - packageName
|
||||||
if (changed) {
|
if (newSet != _blacklist.value) {
|
||||||
save()
|
_blacklist.value = newSet
|
||||||
|
saveBlacklist()
|
||||||
}
|
}
|
||||||
return changed
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun toggle(packageName: String) {
|
fun toggle(packageName: String) {
|
||||||
if (blacklist.contains(packageName)) {
|
if (_blacklist.value.contains(packageName)) {
|
||||||
remove(packageName)
|
remove(packageName)
|
||||||
} else {
|
} else {
|
||||||
add(packageName)
|
add(packageName)
|
||||||
@@ -39,26 +81,202 @@ class PerAppProxyViewModel : ViewModel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun addAll(packages: Collection<String>) {
|
fun addAll(packages: Collection<String>) {
|
||||||
if (blacklist.addAll(packages)) {
|
val newSet = _blacklist.value + packages
|
||||||
save()
|
if (newSet != _blacklist.value) {
|
||||||
|
_blacklist.value = newSet
|
||||||
|
saveBlacklist()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun removeAll(packages: Collection<String>) {
|
fun removeAll(packages: Collection<String>) {
|
||||||
if (blacklist.removeAll(packages.toSet())) {
|
val newSet = _blacklist.value - packages.toSet()
|
||||||
save()
|
if (newSet != _blacklist.value) {
|
||||||
|
_blacklist.value = newSet
|
||||||
|
saveBlacklist()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
if (blacklist.isNotEmpty()) {
|
if (_blacklist.value.isNotEmpty()) {
|
||||||
blacklist.clear()
|
_blacklist.value = emptySet()
|
||||||
save()
|
saveBlacklist()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun save() {
|
private fun loadBlacklist(): Set<String> {
|
||||||
MmkvManager.encodeSettings(AppConfig.PREF_PER_APP_PROXY_SET, blacklist)
|
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()
|
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.dto.entities.RulesetItem
|
||||||
import com.v2ray.ang.handler.MmkvManager
|
import com.v2ray.ang.handler.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
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() {
|
class RoutingSettingsViewModel : ViewModel() {
|
||||||
private val rulesets: MutableList<RulesetItem> = mutableListOf()
|
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 getAll(): List<RulesetItem> = rulesets.toList()
|
||||||
|
|
||||||
fun reload() {
|
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.clear()
|
||||||
rulesets.addAll(MmkvManager.decodeRoutingRulesets() ?: mutableListOf())
|
rulesets.addAll(loaded)
|
||||||
|
_rulesetsFlow.value = rulesets.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun update(position: Int, item: RulesetItem) {
|
fun update(position: Int, item: RulesetItem) {
|
||||||
if (position in rulesets.indices) {
|
if (position in rulesets.indices) {
|
||||||
rulesets[position] = item
|
rulesets[position] = item
|
||||||
SettingsManager.saveRoutingRuleset(position, item)
|
SettingsManager.saveRoutingRuleset(position, item)
|
||||||
|
_rulesetsFlow.value = rulesets.toList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun swap(fromPosition: Int, toPosition: Int) {
|
fun swap(fromPosition: Int, toPosition: Int) {
|
||||||
if (fromPosition in rulesets.indices && toPosition in rulesets.indices) {
|
if (fromPosition in rulesets.indices && toPosition in rulesets.indices) {
|
||||||
SettingsManager.swapRoutingRuleset(fromPosition, toPosition)
|
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.MmkvManager
|
||||||
import com.v2ray.ang.handler.SettingsChangeManager
|
import com.v2ray.ang.handler.SettingsChangeManager
|
||||||
import com.v2ray.ang.handler.SettingsManager
|
import com.v2ray.ang.handler.SettingsManager
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
|
||||||
class SubscriptionsViewModel : ViewModel() {
|
class SubscriptionsViewModel : ViewModel() {
|
||||||
private val subscriptions: MutableList<SubscriptionCache> =
|
private val subscriptions: MutableList<SubscriptionCache> =
|
||||||
MmkvManager.decodeSubscriptions().toMutableList()
|
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 getAll(): List<SubscriptionCache> = subscriptions.toList()
|
||||||
|
|
||||||
fun reload() {
|
fun reload() {
|
||||||
subscriptions.clear()
|
subscriptions.clear()
|
||||||
subscriptions.addAll(MmkvManager.decodeSubscriptions())
|
subscriptions.addAll(MmkvManager.decodeSubscriptions())
|
||||||
|
_subsFlow.value = subscriptions.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(subId: String): Boolean {
|
fun remove(subId: String): Boolean {
|
||||||
@@ -24,6 +31,7 @@ class SubscriptionsViewModel : ViewModel() {
|
|||||||
SettingsManager.removeSubscriptionWithDefault(subId)
|
SettingsManager.removeSubscriptionWithDefault(subId)
|
||||||
SettingsChangeManager.makeSetupGroupTab()
|
SettingsChangeManager.makeSetupGroupTab()
|
||||||
}
|
}
|
||||||
|
_subsFlow.value = subscriptions.toList()
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -33,6 +41,7 @@ class SubscriptionsViewModel : ViewModel() {
|
|||||||
subscriptions[idx] = SubscriptionCache(subId, item)
|
subscriptions[idx] = SubscriptionCache(subId, item)
|
||||||
MmkvManager.encodeSubscription(subId, item)
|
MmkvManager.encodeSubscription(subId, item)
|
||||||
}
|
}
|
||||||
|
_subsFlow.value = subscriptions.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
fun swap(fromPosition: Int, toPosition: Int) {
|
fun swap(fromPosition: Int, toPosition: Int) {
|
||||||
@@ -41,7 +50,7 @@ class SubscriptionsViewModel : ViewModel() {
|
|||||||
subscriptions.add(toPosition, item)
|
subscriptions.add(toPosition, item)
|
||||||
SettingsManager.swapSubscriptions(fromPosition, toPosition)
|
SettingsManager.swapSubscriptions(fromPosition, toPosition)
|
||||||
SettingsChangeManager.makeSetupGroupTab()
|
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.HttpUtil
|
||||||
import com.v2ray.ang.util.LogUtil
|
import com.v2ray.ang.util.LogUtil
|
||||||
import com.v2ray.ang.util.Utils
|
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
|
import java.io.File
|
||||||
|
|
||||||
class UserAssetViewModel : ViewModel() {
|
class UserAssetViewModel : ViewModel() {
|
||||||
private val assets = mutableListOf<AssetUrlCache>()
|
private val assets = mutableListOf<AssetUrlCache>()
|
||||||
private val builtInGeoFiles = listOf(AppConfig.GEOSITE_DAT, AppConfig.GEOIP_DAT, AppConfig.GEOIP_ONLY_CN_PRIVATE_DAT)
|
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
|
val itemCount: Int
|
||||||
get() = assets.size
|
get() = assets.size
|
||||||
|
|
||||||
@@ -27,6 +33,7 @@ class UserAssetViewModel : ViewModel() {
|
|||||||
val decoded = MmkvManager.decodeAssetUrls()
|
val decoded = MmkvManager.decodeAssetUrls()
|
||||||
assets.clear()
|
assets.clear()
|
||||||
assets.addAll(buildAssetList(decoded, geoFilesSource))
|
assets.addAll(buildAssetList(decoded, geoFilesSource))
|
||||||
|
_assetsFlow.value = assets.toList()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun buildAssetList(
|
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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:viewportWidth="24"
|
android:viewportWidth="24"
|
||||||
android:viewportHeight="24">
|
android:viewportHeight="24">
|
||||||
<path
|
<path
|
||||||
android:fillColor="?attr/colorControlNormal"
|
android:fillColor="@android:color/white"
|
||||||
android:pathData="M7,10l5,5 5,-5z" />
|
android:pathData="M7,2v11h3v9l7,-12h-4l4,-8z"/>
|
||||||
</vector>
|
</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:left="0dp"
|
||||||
android:right="0dp"
|
android:right="0dp"
|
||||||
android:top="0dp" />
|
android:top="0dp" />
|
||||||
<solid android:color="@color/color_fab_active" />
|
<solid android:color="#f97910" />
|
||||||
</shape>
|
</shape>
|
||||||
@@ -6,5 +6,5 @@
|
|||||||
android:left="0dp"
|
android:left="0dp"
|
||||||
android:right="0dp"
|
android:right="0dp"
|
||||||
android:top="0dp" />
|
android:top="0dp" />
|
||||||
<solid android:color="@color/color_fab_inactive" />
|
<solid android:color="#9C9C9C" />
|
||||||
</shape>
|
</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"
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
android:width="24dp"
|
android:width="24dp"
|
||||||
android:height="24dp"
|
android:height="24dp"
|
||||||
android:tint="?attr/colorControlNormal"
|
|
||||||
android:viewportWidth="960"
|
android:viewportWidth="960"
|
||||||
android:viewportHeight="960">
|
android:viewportHeight="960">
|
||||||
<path
|
<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