add manifest generator

This commit is contained in:
CherretGit
2026-03-19 23:00:30 +07:00
parent 09cdb53791
commit b65b725ae5
11 changed files with 286 additions and 102 deletions

View File

@@ -0,0 +1,155 @@
package com.cherret.zaprett.ui.component
import android.content.Context
import android.net.Uri
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
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.stringResource
import androidx.compose.ui.unit.dp
import com.cherret.zaprett.R
import com.cherret.zaprett.data.StorageData
@Composable
fun InfoDialog(title: String, message: String, onDismiss: () -> Unit) {
AlertDialog(
title = { Text(text = title) },
text = { Text(text = message) },
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
@Composable
fun TextDialog(title: String, message: String, initialText: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var inputText by remember { mutableStateOf(initialText) }
AlertDialog(
title = { Text(text = title) },
text = {
TextField(
value = inputText,
onValueChange = { inputText = it },
placeholder = { Text(message) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
if (inputText.isNotEmpty()) {
onConfirm(inputText)
onDismiss()
}
else {
onDismiss()
}
}
) {
Text(stringResource(R.string.btn_continue))
}
},
dismissButton = {
TextButton(
onClick = { onDismiss() }
) {
Text(stringResource(R.string.btn_dismiss))
}
}
)
}
@Composable
fun GenerateManifestDialog(path: String, onConfirm: (StorageData) -> Unit, onDismiss: () -> Unit) {
var id by remember { mutableStateOf("") }
var name by remember { mutableStateOf("") }
var version by remember { mutableStateOf("") }
var author by remember { mutableStateOf("") }
var description by remember { mutableStateOf("") }
AlertDialog(
title = { Text(text = stringResource(R.string.title_generate_manifest)) },
text = {
Column(
modifier = Modifier.fillMaxWidth()
.verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
TextField(
value = id,
onValueChange = { id = it },
placeholder = { Text(stringResource(R.string.hint_manifest_id)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
TextField(
value = name,
onValueChange = { name = it },
placeholder = { Text(stringResource(R.string.hint_manifest_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
TextField(
value = version,
onValueChange = { version = it },
placeholder = { Text(stringResource(R.string.hint_manifest_version)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
TextField(
value = author,
onValueChange = { author = it },
placeholder = { Text(stringResource(R.string.hint_manifest_author)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
TextField(
value = description,
onValueChange = { description = it },
placeholder = { Text(stringResource(R.string.hint_manifest_description)) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
}
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(
onClick = { onConfirm(
StorageData(
schema = 1,
id = id,
name = name,
version = version,
author = author,
description = description,
dependencies = emptyList(),
file = path
)
) }
) {
Text(stringResource(R.string.btn_continue))
}
},
dismissButton = {
TextButton(onClick = {
onDismiss()
}) {
Text(stringResource(R.string.btn_dismiss))
}
},
)
}

View File

@@ -127,7 +127,7 @@ fun SettingDropDown(title: String, selected: String, items: List<DropdownItem>)
.fillMaxWidth()
.height(80.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
onClick = { expanded = true },
onClick = { expanded = !expanded },
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(
@@ -181,56 +181,4 @@ fun SettingsSection(title: String) {
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp)
)
}
@Composable
fun InfoDialog(title: String, message: String, onDismiss: () -> Unit) {
AlertDialog(
title = { Text(text = title) },
text = { Text(text = message) },
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
@Composable
fun TextDialog(title: String, message: String, initialText: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var inputText by remember { mutableStateOf(initialText) }
AlertDialog(
title = { Text(text = title) },
text = {
TextField(
value = inputText,
onValueChange = { inputText = it },
placeholder = { Text(message) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
if (inputText.isNotEmpty()) {
onConfirm(inputText)
onDismiss()
}
else {
onDismiss()
}
}
) {
Text(stringResource(R.string.btn_continue))
}
},
dismissButton = {
TextButton(
onClick = { onDismiss() }
) {
Text(stringResource(R.string.btn_dismiss))
}
}
)
}

View File

@@ -63,9 +63,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.cherret.zaprett.R
import com.cherret.zaprett.data.ListType
import com.cherret.zaprett.ui.component.GenerateManifestDialog
import com.cherret.zaprett.ui.component.ListSwitchItem
import com.cherret.zaprett.ui.viewmodel.HostsViewModel
import com.cherret.zaprett.utils.getHostListMode
import com.cherret.zaprett.utils.getManifestsPath
import com.cherret.zaprett.utils.getZaprettPath
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -82,10 +85,17 @@ fun HostsScreen(navController: NavController, viewModel: HostsViewModel = viewMo
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
if (getHostListMode(prefs) == ListType.whitelist) viewModel.copySelectedFile(context, "/lists/include", it)
else viewModel.copySelectedFile(context, "/lists/exclude", it) }
val path = when (getHostListMode(prefs)) {
ListType.whitelist -> getZaprettPath().resolve("files/lists/include")
ListType.blacklist -> getZaprettPath().resolve("files/lists/exclude")
}
viewModel.prepareImport(context, path, uri)
}
}
val error by viewModel.errorFlow.collectAsState()
val showGenerateManifestDialog by viewModel.showGenerateManifestDialog.collectAsState()
val pendingName by viewModel.pendingFileName.collectAsState()
val pendingUri by viewModel.pendingFileUri.collectAsState()
LaunchedEffect(Unit) {
viewModel.refresh()
@@ -208,6 +218,17 @@ fun HostsScreen(navController: NavController, viewModel: HostsViewModel = viewMo
}
)
}
if (showGenerateManifestDialog && pendingName != null && pendingUri != null) {
GenerateManifestDialog(
path = pendingName!!,
onConfirm = { manifest ->
val manifestPath = when (getHostListMode(prefs)) {
ListType.whitelist -> getManifestsPath().resolve("lists/include")
ListType.blacklist -> getManifestsPath().resolve("lists/exclude")
}
viewModel.import(context, manifestPath, manifest)
}, onDismiss = {viewModel.cancelImport() })
}
}
@Composable

View File

@@ -63,9 +63,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.cherret.zaprett.R
import com.cherret.zaprett.data.ListType
import com.cherret.zaprett.ui.component.GenerateManifestDialog
import com.cherret.zaprett.ui.component.ListSwitchItem
import com.cherret.zaprett.ui.viewmodel.IpsetViewModel
import com.cherret.zaprett.utils.getHostListMode
import com.cherret.zaprett.utils.getManifestsPath
import com.cherret.zaprett.utils.getZaprettPath
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -82,10 +85,17 @@ fun IpsetsScreen(navController: NavController, viewModel: IpsetViewModel = viewM
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
if (getHostListMode(prefs) == ListType.whitelist) viewModel.copySelectedFile(context, "/ipset/include", it)
else viewModel.copySelectedFile(context, "/ipset/exclude", it) }
val path = when (getHostListMode(prefs)) {
ListType.whitelist -> getZaprettPath().resolve("files/ipset/include")
ListType.blacklist -> getZaprettPath().resolve("files/ipset/exclude")
}
viewModel.prepareImport(context, path, uri)
}
}
val error by viewModel.errorFlow.collectAsState()
val showGenerateManifestDialog by viewModel.showGenerateManifestDialog.collectAsState()
val pendingName by viewModel.pendingFileName.collectAsState()
val pendingUri by viewModel.pendingFileUri.collectAsState()
LaunchedEffect(Unit) {
viewModel.refresh()
@@ -208,6 +218,17 @@ fun IpsetsScreen(navController: NavController, viewModel: IpsetViewModel = viewM
}
)
}
if (showGenerateManifestDialog && pendingName != null && pendingUri != null) {
GenerateManifestDialog(
path = pendingName!!,
onConfirm = { manifest ->
val manifestPath = when (getHostListMode(prefs)) {
ListType.whitelist -> getManifestsPath().resolve("ipset/include")
ListType.blacklist -> getManifestsPath().resolve("ipset/exclude")
}
viewModel.import(context, manifestPath, manifest)
}, onDismiss = {viewModel.cancelImport() })
}
}
@Composable

View File

@@ -57,9 +57,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.cherret.zaprett.R
import com.cherret.zaprett.data.ServiceType
import com.cherret.zaprett.ui.component.GenerateManifestDialog
import com.cherret.zaprett.ui.component.ListSwitchItem
import com.cherret.zaprett.ui.viewmodel.StrategyViewModel
import com.cherret.zaprett.utils.getManifestsPath
import com.cherret.zaprett.utils.getServiceType
import com.cherret.zaprett.utils.getZaprettPath
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -75,17 +78,19 @@ fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel =
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { viewModel.copySelectedFile(
context,
when(getServiceType(sharedPreferences)) {
ServiceType.nfqws -> "/strategies/nfqws"
ServiceType.nfqws2 -> "/strategies/nfqws2"
ServiceType.byedpi -> "/strategies/byedpi"
},
it
) }
uri?.let {
val path = when (getServiceType(sharedPreferences)) {
ServiceType.byedpi -> getZaprettPath().resolve("files/strategies/byedpi")
ServiceType.nfqws -> getZaprettPath().resolve("files/strategies/nfqws")
ServiceType.nfqws2 -> getZaprettPath().resolve("files/strategies/nfqws2")
}
viewModel.prepareImport(context, path, uri)
}
}
val error by viewModel.errorFlow.collectAsState()
val showGenerateManifestDialog by viewModel.showGenerateManifestDialog.collectAsState()
val pendingName by viewModel.pendingFileName.collectAsState()
val pendingUri by viewModel.pendingFileUri.collectAsState()
LaunchedEffect(Unit) {
viewModel.refresh()
@@ -205,6 +210,18 @@ fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel =
}
)
}
if (showGenerateManifestDialog && pendingName != null && pendingUri != null) {
GenerateManifestDialog(
path = pendingName!!,
onConfirm = { manifest ->
val manifestPath = when (getServiceType(sharedPreferences)) {
ServiceType.byedpi -> getManifestsPath().resolve("strategies/byedpi")
ServiceType.nfqws -> getManifestsPath().resolve("strategies/nfqws")
ServiceType.nfqws2 -> getManifestsPath().resolve("strategies/nfqws2")
}
viewModel.import(context, manifestPath, manifest)
}, onDismiss = {viewModel.cancelImport() })
}
}
@Composable

View File

@@ -18,19 +18,23 @@ import androidx.lifecycle.AndroidViewModel
import com.cherret.zaprett.R
import com.cherret.zaprett.data.StorageData
import com.cherret.zaprett.utils.checkStoragePermission
import com.cherret.zaprett.utils.getZaprettPath
import com.cherret.zaprett.utils.restartService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
abstract class BaseListsViewModel(application: Application) : AndroidViewModel(application) {
val context = application
val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
}
var allItems by mutableStateOf<List<StorageData>>(emptyList())
private set
var activeItems by mutableStateOf<List<StorageData>>(emptyList())
@@ -38,12 +42,18 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
val checked = mutableStateMapOf<StorageData, Boolean>()
var isRefreshing by mutableStateOf(false)
private set
private val _pendingFileName = MutableStateFlow<String?>(null)
val pendingFileName: StateFlow<String?> = _pendingFileName.asStateFlow()
private val _pendingFileUri = MutableStateFlow<Uri?>(null)
val pendingFileUri: StateFlow<Uri?> = _pendingFileUri.asStateFlow()
private val _errorFlow = MutableStateFlow("")
val errorFlow = _errorFlow.asStateFlow()
private var _showNoPermissionDialog = MutableStateFlow(false)
val showNoPermissionDialog: StateFlow<Boolean> = _showNoPermissionDialog
private var _showGenerateManifestDialog = MutableStateFlow(false)
val showGenerateManifestDialog: StateFlow<Boolean> = _showGenerateManifestDialog
abstract fun loadAllItems(): Array<StorageData>
abstract fun loadActiveItems(): Array<StorageData>
@@ -69,7 +79,7 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
fun hideNoPermissionDialog() {
_showNoPermissionDialog.value = false
}
fun showRestartSnackbar(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
scope.launch {
val result = snackbarHostState.showSnackbar(
@@ -89,51 +99,48 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
_errorFlow.value = ""
}
fun prepareImport(context: Context, path: File, uri: Uri) {
val name = context.contentResolver.query(uri, null, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst() && nameIndex != -1) cursor.getString(nameIndex) else null
} ?: "copied_file"
_pendingFileName.value = path.resolve(name).absolutePath
_pendingFileUri.value = uri
_showGenerateManifestDialog.value = true
}
fun cancelImport() {
_pendingFileName.value = null
_pendingFileUri.value = null
_showGenerateManifestDialog.value = true
}
fun import(context: Context, manifestPath: File, manifest: StorageData) {
val uri = _pendingFileUri.value ?: return
val manifestFile = manifestPath.resolve("${manifest.id}.json")
manifestFile.parentFile!!.mkdirs()
manifestFile.writeText(json.encodeToString(manifest))
copySelectedFile(context, manifest.file, uri)
_pendingFileName.value = null
_pendingFileUri.value = null
_showGenerateManifestDialog.value = false
refresh()
}
fun copySelectedFile(context: Context, path: String, uri: Uri) {
//if (!Environment.isExternalStorageManager()) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R){
if (!Environment.isExternalStorageManager()) return
}
else if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.READ_EXTERNAL_STORAGE)!= PackageManager.PERMISSION_GRANTED || ContextCompat.checkSelfPermission(context, android.Manifest.permission.WRITE_EXTERNAL_STORAGE)!= PackageManager.PERMISSION_GRANTED) return
val contentResolver = context.contentResolver
val fileName = contentResolver.query(uri, null, null, null, null)?.use { cursor ->
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (cursor.moveToFirst() && nameIndex != -1) cursor.getString(nameIndex) else "copied_file"
} ?: "copied_file"
val directory = getZaprettPath().resolve(path)
if (!directory.exists()) {
directory.mkdirs()
}
val directory = File(path)
try {
val outputFile = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
if (Environment.isExternalStorageManager()) {
val outputDir = getZaprettPath().resolve(path)
if (!outputDir.exists()) {
outputDir.mkdirs()
}
File(outputDir, fileName)
} else {
val outputDir = File(context.filesDir, path)
if (!outputDir.exists()) {
outputDir.mkdirs()
}
File(outputDir, fileName)
}
} else {
val outputDir = File(context.filesDir, path)
if (!outputDir.exists()) {
outputDir.mkdirs()
}
File(outputDir, fileName)
}
directory.parentFile?.mkdirs()
contentResolver.openInputStream(uri)?.use { inputStream ->
FileOutputStream(outputFile).use { outputStream ->
FileOutputStream(directory).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
refresh()
} catch (e: IOException) {
e.printStackTrace()
}

View File

@@ -42,6 +42,7 @@ class HostsViewModel(application: Application): BaseListsViewModel(application)
}
}
}
refresh()
}
override fun onCheckedChange(item: StorageData, isChecked: Boolean, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {

View File

@@ -42,6 +42,7 @@ class IpsetViewModel(application: Application): BaseListsViewModel(application)
}
}
}
refresh()
}
override fun onCheckedChange(item: StorageData, isChecked: Boolean, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {

View File

@@ -57,6 +57,7 @@ class StrategyViewModel(application: Application): BaseListsViewModel(applicatio
showRestartSnackbar(context, snackbarHostState, scope)
}
}
refresh()
}
override fun onCheckedChange(item: StorageData, isChecked: Boolean, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {

View File

@@ -118,4 +118,10 @@
<string name="reset_settings_title">Сброс настроек</string>
<string name="reset_settings_message">Вы действительно хотите сбросить настройки?</string>
<string name="error_hash_mismatch">Не совпала контрольная сумма файла, попробуйте позже</string>
<string name="title_generate_manifest">Генерация манифеста</string>
<string name="hint_manifest_id">Введите ID манифеста</string>
<string name="hint_manifest_name">Введите имя манифеста</string>
<string name="hint_manifest_version">Введите версию манифеста</string>
<string name="hint_manifest_author">Введите автора манифеста</string>
<string name="hint_manifest_description">Введите описание манифеста</string>
</resources>

View File

@@ -124,4 +124,10 @@
<string name="reset_settings_title">Reset settings</string>
<string name="reset_settings_message">Are you sure you want to reset the settings?</string>
<string name="error_hash_mismatch">File checksum mismatch, please try again later</string>
<string name="title_generate_manifest">Generate manifest</string>
<string name="hint_manifest_id">Enter manifest ID</string>
<string name="hint_manifest_name">Enter manifest name</string>
<string name="hint_manifest_version">Enter manifest version</string>
<string name="hint_manifest_author">Enter manifest author</string>
<string name="hint_manifest_description">Enter manifest description</string>
</resources>