3 Commits

Author SHA1 Message Date
CherretGit
154d8214bb fix error while checking updates 2026-03-19 23:19:15 +07:00
CherretGit
490709384e Revert "add requiresapi annotation to QS tile service"
This reverts commit fabea1f68d.
2026-03-19 23:01:35 +07:00
CherretGit
b65b725ae5 add manifest generator 2026-03-19 23:00:30 +07:00
15 changed files with 292 additions and 114 deletions

2
.idea/gradle.xml generated
View File

@@ -6,7 +6,6 @@
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="gradleHome" value="/usr/share/java/gradle" />
<option name="gradleJvm" value="#GRADLE_LOCAL_JAVA_HOME" />
<option name="modules">
<set>
@@ -16,6 +15,5 @@
</option>
</GradleProjectSettings>
</option>
<option name="parallelModelFetch" value="true" />
</component>
</project>

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

@@ -82,6 +82,7 @@ import com.cherret.zaprett.ui.viewmodel.HomeViewModel
import com.cherret.zaprett.utils.getServiceType
import dev.jeziellago.compose.markdowntext.MarkdownText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -103,9 +104,9 @@ fun HomeScreen(viewModel: HomeViewModel = viewModel(), vpnLauncher: ActivityResu
val serviceMode = viewModel.serviceMode
val error by viewModel.errorFlow.collectAsState()
LaunchedEffect(Unit) {
viewModel.checkForUpdate()
viewModel.checkServiceStatus()
viewModel.checkModuleInfo()
launch { viewModel.checkForUpdate() }
launch { viewModel.checkServiceStatus() }
launch { viewModel.checkModuleInfo() }
}
LaunchedEffect(requestVpnPermission) {

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

@@ -13,6 +13,7 @@ import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsText
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
@@ -88,6 +89,6 @@ object NetworkUtils {
updateInfo,
changeLog
)
}
}.onFailure { if (it is CancellationException) throw it }
}
}

View File

@@ -2,10 +2,8 @@ package com.cherret.zaprett.utils
import android.content.Intent
import android.content.SharedPreferences
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.annotation.RequiresApi
import androidx.core.content.ContextCompat
import com.cherret.zaprett.R
import com.cherret.zaprett.byedpi.ByeDpiVpnService
@@ -19,19 +17,16 @@ class QSTileService: TileService() {
prefs = applicationContext.getSharedPreferences("settings", MODE_PRIVATE)
}
@RequiresApi(Build.VERSION_CODES.Q)
override fun onTileAdded() {
super.onTileAdded()
updateStatus()
}
@RequiresApi(Build.VERSION_CODES.Q)
override fun onStartListening() {
super.onStartListening()
updateStatus()
}
@RequiresApi(Build.VERSION_CODES.Q)
override fun onClick() {
super.onClick()
if (qsTile.state == Tile.STATE_INACTIVE) {
@@ -59,7 +54,6 @@ class QSTileService: TileService() {
updateStatus()
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun updateStatus() {
if (getServiceType(prefs) != ServiceType.byedpi) {
getStatus {

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>