add ipsets screen

This commit is contained in:
white
2025-09-30 19:00:55 +03:00
parent 5a6bf9780b
commit 5eba2461dd
10 changed files with 454 additions and 5 deletions

View File

@@ -22,6 +22,7 @@ import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.MultipleStop
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.SettingsInputComposite
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
@@ -49,6 +50,7 @@ import androidx.navigation.navArgument
import com.cherret.zaprett.ui.screen.DebugScreen
import com.cherret.zaprett.ui.screen.HomeScreen
import com.cherret.zaprett.ui.screen.HostsScreen
import com.cherret.zaprett.ui.screen.IpsetsScreen
import com.cherret.zaprett.ui.screen.RepoScreen
import com.cherret.zaprett.ui.screen.SettingsScreen
import com.cherret.zaprett.ui.screen.StrategyScreen
@@ -66,9 +68,10 @@ sealed class Screen(val route: String, @StringRes val nameResId: Int, val icon:
object home : Screen("home", R.string.title_home, Icons.Default.Home)
object hosts : Screen("hosts", R.string.title_hosts, Icons.Default.Lan)
object strategies : Screen("strategies", R.string.title_strategies, Icons.Default.MultipleStop)
object ipsets : Screen("ipsets", R.string.title_ipset, Icons.Default.SettingsInputComposite)
object settings : Screen("settings", R.string.title_settings, Icons.Default.Settings)
}
val topLevelRoutes = listOf(Screen.home, Screen.hosts, Screen.strategies, Screen.settings)
val topLevelRoutes = listOf(Screen.home, Screen.hosts, Screen.strategies, Screen.ipsets, Screen.settings)
val hideNavBar = listOf("repo?source={source}", "debugScreen")
class MainActivity : ComponentActivity() {
private val viewModel: HomeViewModel by viewModels()
@@ -216,6 +219,7 @@ class MainActivity : ComponentActivity() {
composable(Screen.home.route) { HomeScreen(viewModel = viewModel, vpnPermissionLauncher) }
composable(Screen.hosts.route) { HostsScreen(navController) }
composable(Screen.strategies.route) { StrategyScreen(navController) }
composable(Screen.ipsets.route) { IpsetsScreen(navController) }
composable(Screen.settings.route) { SettingsScreen(navController) }
composable(route = "repo?source={source}",arguments = listOf(navArgument("source") {})) { backStackEntry ->
val source = backStackEntry.arguments?.getString("source")

View File

@@ -0,0 +1,223 @@
package com.cherret.zaprett.ui.screen
import android.content.Context
import android.content.SharedPreferences
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.cherret.zaprett.R
import com.cherret.zaprett.ui.component.ListSwitchItem
import com.cherret.zaprett.ui.viewmodel.HostsViewModel
import com.cherret.zaprett.ui.viewmodel.IpsetViewModel
import com.cherret.zaprett.utils.getHostListMode
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IpsetsScreen(navController: NavController, viewModel: IpsetViewModel = viewModel()) {
val context = LocalContext.current
val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val allLists = viewModel.allItems
val checked = viewModel.checked
val isRefreshing = viewModel.isRefreshing
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
if (getHostListMode(prefs) == "whitelist") viewModel.copySelectedFile(context, "/ipset/include", it)
else viewModel.copySelectedFile(context, "/ipset/exclude", it) }
}
LaunchedEffect(Unit) {
viewModel.refresh()
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.title_ipset),
fontSize = 40.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
)
},
windowInsets = WindowInsets(0)
)
},
content = { paddingValues ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
viewModel.refresh()
},
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + 80.dp
),
modifier = Modifier.navigationBarsPadding().fillMaxSize()
) {
item {
IpsetTypeChoose(viewModel, prefs)
}
when {
allLists.isEmpty() -> {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.empty_list),
textAlign = TextAlign.Center
)
}
}
}
else -> {
items(allLists) { item ->
ListSwitchItem (
item = item,
isChecked = checked[item] == true,
onCheckedChange = { isChecked ->
viewModel.onCheckedChange(item, isChecked, snackbarHostState, scope)
},
onDeleteClick = {
viewModel.deleteItem(item, snackbarHostState, scope)
}
)
}
}
}
}
}
},
floatingActionButton = {
FloatingMenu(navController, filePickerLauncher)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
)
}
@Composable
private fun FloatingMenu(navController: NavController, launcher: ActivityResultLauncher<Array<String>>) {
var expanded by remember { mutableStateOf(false) }
FloatingActionButton(
modifier = Modifier.size(80.dp),
onClick = { expanded = !expanded }
) {
Icon(Icons.Default.Add, contentDescription = stringResource(R.string.btn_add_host))
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.btn_download_host)) },
onClick = {
expanded = false
navController.navigate("repo?source=hosts") { launchSingleTop = true }
},
leadingIcon = {
Icon(Icons.Default.Download, contentDescription = stringResource(R.string.btn_download_host))
}
)
DropdownMenuItem(
text = { Text(stringResource(R.string.btn_add_host)) },
onClick = {
expanded = false
addHost(launcher)
},
leadingIcon = {
Icon(Icons.Default.UploadFile, contentDescription = stringResource(R.string.btn_add_host))
}
)
}
}
@Composable
fun IpsetTypeChoose(viewModel: IpsetViewModel, prefs : SharedPreferences) {
val listType = remember { mutableStateOf(getHostListMode(prefs))}
val options = listOf(stringResource(R.string.title_whitelist), stringResource(R.string.title_blacklist))
val selectedIndex = if (listType.value == "whitelist") 0 else 1
SingleChoiceSegmentedButtonRow (
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
options.forEachIndexed { index, label ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = options.size
),
onClick = {
listType.value = if (index == 0) "whitelist" else "blacklist"
viewModel.setListType(listType.value)
},
selected = index == selectedIndex,
label = {
Text(
label
)
}
)
}
}
}
private fun addHost(launcher: ActivityResultLauncher<Array<String>>) {
launcher.launch(arrayOf("text/plain"))
}

View File

@@ -107,6 +107,7 @@ fun SettingsScreen(navController: NavController, viewModel : SettingsViewModel =
val openNoModuleDialog = remember { mutableStateOf(false) }
val showAboutDialog = remember { mutableStateOf(false) }
val showHostsRepoUrlDialog = remember { mutableStateOf(false) }
val showIpsetRepoUrlDialog = remember { mutableStateOf(false) }
val showStrategyRepoUrlDialog = remember { mutableStateOf(false) }
val showIPDialog = remember { mutableStateOf(false) }
val showPortDialog = remember { mutableStateOf(false) }
@@ -160,6 +161,13 @@ fun SettingsScreen(navController: NavController, viewModel : SettingsViewModel =
editor.putBoolean("send_firebase_analytics", it).apply()
}
),
Setting.Toggle(
title = "",
checked = false,
onToggle = {
}
),
Setting.Action(
title = stringResource(R.string.btn_repository_url_lists),
onClick = {
@@ -167,6 +175,13 @@ fun SettingsScreen(navController: NavController, viewModel : SettingsViewModel =
showHostsRepoUrlDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.ipset_repo_url),
onClick = {
textDialogValue.value = sharedPreferences.getString("ipset_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/ipsets.json") ?: "https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/ipsets.json"
showIpsetRepoUrlDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.btn_repository_url_strategies),
onClick = {

View File

@@ -1,5 +1,6 @@
package com.cherret.zaprett.ui.viewmodel
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
@@ -25,7 +26,8 @@ import kotlinx.coroutines.launch
import java.io.File
abstract class BaseRepoViewModel(application: Application) : AndroidViewModel(application) {
val context = application.applicationContext
@SuppressLint("StaticFieldLeak")
val context: Context = application.applicationContext
val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val _errorFlow = MutableStateFlow<Throwable?>(null)
val errorFlow: StateFlow<Throwable?> = _errorFlow

View File

@@ -0,0 +1,65 @@
package com.cherret.zaprett.ui.viewmodel
import android.app.Application
import android.content.Context
import androidx.compose.material3.SnackbarHostState
import com.cherret.zaprett.utils.disableIpset
import com.cherret.zaprett.utils.disableList
import com.cherret.zaprett.utils.enableIpset
import com.cherret.zaprett.utils.enableList
import com.cherret.zaprett.utils.getActiveExcludeIpsets
import com.cherret.zaprett.utils.getActiveExcludeLists
import com.cherret.zaprett.utils.getAllExcludeIpsets
import com.cherret.zaprett.utils.getAllIpsets
import com.cherret.zaprett.utils.getHostListMode
import com.cherret.zaprett.utils.getStatus
import com.cherret.zaprett.utils.setHostListMode
import kotlinx.coroutines.CoroutineScope
import java.io.File
class IpsetViewModel(application: Application): BaseListsViewModel(application) {
private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
override fun loadAllItems(): Array<String> =
if (getHostListMode(sharedPreferences) == "whitelist") getAllIpsets()
else getAllExcludeIpsets()
override fun loadActiveItems(): Array<String> =
if (getHostListMode(sharedPreferences) == "whitelist") getActiveExcludeIpsets(sharedPreferences)
else getActiveExcludeLists(sharedPreferences)
override fun deleteItem(item: String, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
val wasChecked = checked[item] == true
disableList(item, sharedPreferences)
val success = File(item).delete()
if (success) refresh()
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled && wasChecked) {
snackbarHostState.currentSnackbarData?.dismiss()
showRestartSnackbar(context, snackbarHostState, scope)
}
}
}
}
override fun onCheckedChange(item: String, isChecked: Boolean, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
checked[item] = isChecked
if (isChecked) enableIpset(item, sharedPreferences) else disableIpset(item, sharedPreferences)
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled) {
snackbarHostState.currentSnackbarData?.dismiss()
showRestartSnackbar(
context,
snackbarHostState,
scope
)
}
}
}
}
fun setListType(type : String) {
setHostListMode(sharedPreferences, type)
refresh()
}
}

View File

@@ -39,7 +39,7 @@ fun getHostList(sharedPreferences: SharedPreferences, callback: (Result<List<Rep
fun getIpsetList(sharedPreferences: SharedPreferences, callback: (Result<List<RepoItemInfo>>) -> Unit) {
getRepo(
sharedPreferences.getString(
"hosts_repo_url",
"ipset_repo_url",
"https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/ipsets.json"
) ?: "https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/ipsets.json",
callback

View File

@@ -164,6 +164,27 @@ fun getActiveLists(sharedPreferences: SharedPreferences): Array<String> {
return sharedPreferences.getStringSet("lists", emptySet())?.toTypedArray() ?: emptyArray()
}
}
fun getActiveIpsets(sharedPreferences: SharedPreferences): Array<String> {
if (sharedPreferences.getBoolean("use_module", false)) {
val configFile = getConfigFile()
if (configFile.exists()) {
val props = Properties()
return try {
FileInputStream(configFile).use { input ->
props.load(input)
}
val activeLists = props.getProperty("active_ipsets", "")
Log.d("Active ipsets", activeLists)
if (activeLists.isNotEmpty()) activeLists.split(",")
.toTypedArray() else emptyArray()
} catch (e: IOException) {
throw RuntimeException(e)
}
}
return emptyArray()
}
else return emptyArray()
}
fun getActiveExcludeLists(sharedPreferences: SharedPreferences): Array<String> {
if (sharedPreferences.getBoolean("use_module", false)) {
val configFile = getConfigFile()
@@ -187,6 +208,28 @@ fun getActiveExcludeLists(sharedPreferences: SharedPreferences): Array<String> {
}
}
fun getActiveExcludeIpsets(sharedPreferences: SharedPreferences): Array<String> {
if (sharedPreferences.getBoolean("use_module", false)) {
val configFile = getConfigFile()
if (configFile.exists()) {
val props = Properties()
return try {
FileInputStream(configFile).use { input ->
props.load(input)
}
val activeLists = props.getProperty("active_exclude_ipsets", "")
Log.d("Active ipsets", activeLists)
if (activeLists.isNotEmpty()) activeLists.split(",")
.toTypedArray() else emptyArray()
} catch (e: IOException) {
throw RuntimeException(e)
}
}
return emptyArray()
}
else return emptyArray()
}
fun getActiveNfqwsStrategies(): Array<String> {
val configFile = File("${getZaprettPath()}/config")
if (configFile.exists()) {
@@ -265,7 +308,50 @@ fun enableList(path: String, sharedPreferences: SharedPreferences) {
}
}
}
fun enableIpset(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
val configFile = getConfigFile()
try {
val props = Properties()
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
}
val activeLists = props.getProperty(
if (getHostListMode(sharedPreferences) == "whitelist") "active_ipsets"
else "active_exclude_ipsets",
"")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (path !in activeLists) {
activeLists.add(path)
}
props.setProperty(
if (getHostListMode(sharedPreferences) == "whitelist") "active_ipsets"
else "active_exclude_ipsets",
activeLists.joinToString(",")
)
FileOutputStream(configFile).use { output ->
props.store(output, "Don't place '/' in end of directory! Example: /sdcard")
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
else {
val currentSet = sharedPreferences.getStringSet(
if (getHostListMode(sharedPreferences) == "whitelist") "ipsets"
else "exclude_ipsets", emptySet())?.toMutableSet() ?: mutableSetOf()
if (path !in currentSet) {
currentSet.add(path)
sharedPreferences.edit { putStringSet(
if (getHostListMode(sharedPreferences) == "whitelist") "ipsets"
else "exclude_ipsetss", currentSet) }
}
}
}
fun enableStrategy(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
val props = Properties()
@@ -347,6 +433,57 @@ fun disableList(path: String, sharedPreferences: SharedPreferences) {
}
}
fun disableIpset(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
val props = Properties()
val configFile = getConfigFile()
try {
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
}
val activeLists = props.getProperty(
if (getHostListMode(sharedPreferences) == "whitelist") "active_ipsets"
else "active_exclude_ipsets",
"")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (path in activeLists) {
activeLists.remove(path)
}
props.setProperty(
if (getHostListMode(sharedPreferences) == "whitelist") "active_ipsets"
else "active_exclude_ipsets",
activeLists.joinToString(",")
)
FileOutputStream(configFile).use { output ->
props.store(output, "Don't place '/' in end of directory! Example: /sdcard")
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
else {
val currentSet = sharedPreferences.getStringSet(
if (getHostListMode(sharedPreferences) == "whitelist") "ipsets"
else "exclude_ipsets", emptySet())?.toMutableSet() ?: mutableSetOf()
if (path in currentSet) {
currentSet.remove(path)
sharedPreferences.edit { putStringSet(
if (getHostListMode(sharedPreferences) == "whitelist") "ipsets"
else "exclude_ipsets", currentSet) }
}
if (currentSet.isEmpty()) {
sharedPreferences.edit { remove(
if (getHostListMode(sharedPreferences) == "whitelist") "ipsets"
else "exclude_ipsets"
) }
}
}
}
fun disableStrategy(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
val props = Properties()

View File

@@ -99,4 +99,5 @@
<string name="btn_copy_log">Скопировать лог</string>
<string name="log_copied">Лог скопирован</string>
<string name="download_error">Произошла ошибка скачивания, пожалуйста, сообщите о ней разработчикам</string>
<string name="ipset_repo_url">URL репозитория ipset</string>
</resources>

View File

@@ -103,4 +103,6 @@
<string name="btn_copy_log">Copy log</string>
<string name="log_copied">Log copied</string>
<string name="download_error">Occurred a file download error, please report to developers</string>
<string name="ipset_repo_url">Ipset repository URL</string>
<string name="title_ipset" translatable="false">Ipset</string>
</resources>