16 Commits

Author SHA1 Message Date
Cherret
87ff9c5164 Merge remote-tracking branch 'origin/main' 2025-05-10 20:46:12 +07:00
Cherret
c61937a4b0 Update version 2025-05-10 20:45:56 +07:00
Cherret
edf54851ae Add strategies 2025-05-10 20:43:35 +07:00
CherretGit
b766deafa5 Update update.json 2025-05-05 23:40:11 +07:00
CherretGit
7974066105 Update changelog.md 2025-05-05 23:40:06 +07:00
Cherret
dc096ae3ed Merge remote-tracking branch 'origin/main' 2025-05-05 23:21:34 +07:00
Cherret
23172b1cd8 enable shrink 2025-05-05 23:21:24 +07:00
CherretGit
a9ed6a5e67 Update README.md 2025-05-05 23:01:45 +07:00
CherretGit
00b5334273 Update README.md 2025-05-05 14:25:50 +07:00
CherretGit
f11071fd4d Update README.md 2025-05-05 14:10:43 +07:00
CherretGit
82e28c1620 Update changelog.md 2025-05-01 21:12:47 +07:00
CherretGit
9ea170a2b2 Update update.json 2025-05-01 21:12:42 +07:00
Cherret
2d82aec1eb Merge remote-tracking branch 'origin/main' 2025-05-01 21:04:08 +07:00
Cherret
4ee72db907 disable shrink 2025-05-01 21:03:45 +07:00
CherretGit
96a1455ebc Update README.md 2025-05-01 20:43:42 +07:00
CherretGit
e5ed91e90d Add files via upload 2025-05-01 20:38:14 +07:00
18 changed files with 482 additions and 64 deletions

View File

@@ -2,12 +2,14 @@
## О приложении
Приложение разработано для работы с модулем [zaprett](https://github.com/egor-white/zaprett)
### [Официальный Telegram-канал приложения](https://t.me/zaprett_module)
## ВНИМАНИЕ приложение работает только с root правами
### Данное приложение является ремейком [приложения](https://github.com/egor-white/zaprett-app) от [egor-white](https://github.com/egor-white)
На данный момент приложение умеет:
* Включать, выключать и перезапускать модуль
* Работа с листами (добавление, включение и выключение)
* Запускать, останавливать и перезапускать модуль
* Работа с листами (добавление, включение и выключение, загрузка из репозитория)
* Авто обновление приложения
## [Репозиторий с хостами](https://github.com/CherretGit/zaprett-hosts-repo)
## Скриншоты:
<img src="images/1.png" width="300"><img src="images/2.png" width="300"><img src="images/3.png" width="300">
<img src="images/1.png" width="300"><img src="images/2.png" width="300"><img src="images/3.png" width="300"><img src="images/4.png" width="300"><img src="images/5.png" width="300">

View File

@@ -4,6 +4,7 @@ plugins {
alias(libs.plugins.kotlin.compose)
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
kotlin("plugin.serialization") version "2.1.20"
}
android {
@@ -14,8 +15,8 @@ android {
applicationId = "com.cherret.zaprett"
minSdk = 30
targetSdk = 35
versionCode = 9
versionName = "1.8"
versionCode = 11
versionName = "1.10"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
@@ -51,7 +52,7 @@ dependencies {
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation ("com.github.topjohnwu.libsu:core:6.0.0")
implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.14")
implementation("com.squareup.moshi:moshi-kotlin:1.15.2")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
implementation(platform("com.google.firebase:firebase-bom:33.12.0"))
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-crashlytics")

View File

@@ -13,6 +13,7 @@ import androidx.annotation.StringRes
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
@@ -28,6 +29,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.edit
@@ -36,22 +38,25 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.cherret.zaprett.ui.screens.HomeScreen
import com.cherret.zaprett.ui.screens.HostsRepoScreen
import com.cherret.zaprett.ui.screens.HostsScreen
import com.cherret.zaprett.ui.screens.SettingsScreen
import com.cherret.zaprett.ui.screens.StrategyScreen
import com.cherret.zaprett.ui.theme.ZaprettTheme
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics
sealed class Screen(val route: String, @StringRes val nameResId: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
sealed class Screen(val route: String, @StringRes val nameResId: Int, val icon: ImageVector) {
object home : Screen("home", R.string.title_home, Icons.Default.Home)
object hosts : Screen("hosts", R.string.title_hosts, Icons.Default.Dashboard)
object strategies : Screen("strategies", R.string.title_strategies, Icons.Default.Dns)
object settings : Screen("settings", R.string.title_settings, Icons.Default.Settings)
}
val topLevelRoutes = listOf(Screen.home, Screen.hosts, Screen.settings)
val hideNavBar = listOf("hosts_repo")
val topLevelRoutes = listOf(Screen.home, Screen.hosts, Screen.strategies, Screen.settings)
val hideNavBar = listOf("repo?source={source}")
class MainActivity : ComponentActivity() {
private lateinit var firebaseAnalytics: FirebaseAnalytics
override fun onCreate(savedInstanceState: Bundle?) {
@@ -119,8 +124,19 @@ class MainActivity : ComponentActivity() {
) {
composable(Screen.home.route) { HomeScreen() }
composable(Screen.hosts.route) { HostsScreen(navController) }
composable(Screen.strategies.route) { StrategyScreen(navController) }
composable(Screen.settings.route) { SettingsScreen() }
composable("hosts_repo") { HostsRepoScreen(navController) }
composable(route = "repo?source={source}",arguments = listOf(navArgument("source") {})) { backStackEntry ->
val source = backStackEntry.arguments?.getString("source")
when (source) {
"hosts" -> {
HostsRepoScreen(navController, ::getAllLists, ::getHostList, "/lists")
}
"strategies" -> {
HostsRepoScreen(navController, ::getAllStrategies, ::getStrategiesList, "/strategies")
}
}
}
}
}
}

View File

@@ -109,6 +109,15 @@ fun getAllLists(): Array<String> {
return emptyArray()
}
fun getAllStrategies(): Array<String> {
val listsDir = File("${getZaprettPath()}/strategies/")
if (listsDir.exists() && listsDir.isDirectory) {
val onlyNames = listsDir.list() ?: return emptyArray()
return onlyNames.map { "$listsDir/$it" }.toTypedArray()
}
return emptyArray()
}
fun getActiveLists(): Array<String> {
val configFile = File("${getZaprettPath()}/config")
if (configFile.exists()) {
@@ -127,6 +136,24 @@ fun getActiveLists(): Array<String> {
return emptyArray()
}
fun getActiveStrategies(): Array<String> {
val configFile = File("${getZaprettPath()}/config")
if (configFile.exists()) {
val props = Properties()
return try {
FileInputStream(configFile).use { input ->
props.load(input)
}
val activeStrategies = props.getProperty("strategy", "")
Log.d("Active strategies", activeStrategies)
if (activeStrategies.isNotEmpty()) activeStrategies.split(",").toTypedArray() else emptyArray()
} catch (e: IOException) {
throw RuntimeException(e)
}
}
return emptyArray()
}
fun enableList(path: String) {
val props = Properties()
val configFile = getConfigFile()
@@ -136,7 +163,10 @@ fun enableList(path: String) {
props.load(input)
}
}
val activeLists = props.getProperty("activelists", "").split(",").toMutableList()
val activeLists = props.getProperty("activelists", "")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (path !in activeLists) {
activeLists.add(path)
}
@@ -149,6 +179,31 @@ fun enableList(path: String) {
}
}
fun enableStrategy(path: String) {
val props = Properties()
val configFile = getConfigFile()
try {
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
}
val activeStrategies = props.getProperty("strategy", "")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (path !in activeStrategies) {
activeStrategies.add(path)
}
props.setProperty("strategy", activeStrategies.joinToString(","))
FileOutputStream(configFile).use { output ->
props.store(output, "Don't place '/' in end of directory! Example: /sdcard")
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
fun disableList(path: String) {
val props = Properties()
val configFile = getConfigFile()
@@ -158,7 +213,10 @@ fun disableList(path: String) {
props.load(input)
}
}
val activeLists = props.getProperty("activelists", "").split(",").toMutableList()
val activeLists = props.getProperty("activelists", "")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (path in activeLists) {
activeLists.remove(path)
}
@@ -169,4 +227,29 @@ fun disableList(path: String) {
} catch (e: IOException) {
throw RuntimeException(e)
}
}
}
fun disableStrategy(path: String) {
val props = Properties()
val configFile = getConfigFile()
try {
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
}
val activeStrategies = props.getProperty("strategy", "")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (path in activeStrategies) {
activeStrategies.remove(path)
}
props.setProperty("strategy", activeStrategies.joinToString(","))
FileOutputStream(configFile).use { output ->
props.store(output, "Don't place '/' in end of directory! Example: /sdcard")
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}

View File

@@ -10,9 +10,8 @@ import android.net.Uri
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import com.squareup.moshi.Moshi
import com.squareup.moshi.Types
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
@@ -23,12 +22,10 @@ import java.io.File
import java.security.MessageDigest
private val client = OkHttpClient()
private val json = Json { ignoreUnknownKeys = true }
fun getHostList(callback: (List<HostsInfo>?) -> Unit) {
val request = Request.Builder().url("https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json").build()
val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
val type = Types.newParameterizedType(List::class.java, HostsInfo::class.java)
val jsonAdapter = moshi.adapter<List<HostsInfo>>(type)
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
@@ -41,10 +38,29 @@ fun getHostList(callback: (List<HostsInfo>?) -> Unit) {
callback(null)
}
val jsonString = response.body.string()
val updateInfo = jsonAdapter.fromJson(jsonString)
if (updateInfo != null) {
callback(updateInfo)
val hostsInfo = json.decodeFromString<List<HostsInfo>>(jsonString)
callback(hostsInfo)
}
}
})
}
fun getStrategiesList(callback: (List<HostsInfo>?) -> Unit) {
val request = Request.Builder().url("https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json").build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
callback(null)
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) {
throw IOException()
callback(null)
}
val jsonString = response.body.string()
val hostsInfo = json.decodeFromString<List<HostsInfo>>(jsonString)
callback(hostsInfo)
}
}
})
@@ -93,9 +109,11 @@ fun getFileSha256(file: File): String {
return digest.digest().joinToString("") { "%02x".format(it) }
}
@Serializable
data class HostsInfo(
val name: String,
val description: String,
val author: String,
val hash: String,
val url: String
)

View File

@@ -13,8 +13,8 @@ import android.provider.Settings
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.squareup.moshi.Moshi
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
@@ -24,11 +24,10 @@ import okio.IOException
import java.io.File
private val client = OkHttpClient()
private val json = Json { ignoreUnknownKeys = true }
fun getUpdate(callback: (UpdateInfo?) -> Unit) {
val request = Request.Builder().url("https://raw.githubusercontent.com/CherretGit/zaprett-app/refs/heads/main/update.json").build()
val moshi = Moshi.Builder().add(KotlinJsonAdapterFactory()).build()
val jsonAdapter = moshi.adapter(UpdateInfo::class.java)
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
@@ -41,7 +40,7 @@ fun getUpdate(callback: (UpdateInfo?) -> Unit) {
callback(null)
}
val jsonString = response.body.string()
val updateInfo = jsonAdapter.fromJson(jsonString)
val updateInfo = json.decodeFromString<UpdateInfo>(jsonString)
updateInfo?.versionCode?.let { versionCode ->
if (versionCode > BuildConfig.VERSION_CODE)
callback(updateInfo)
@@ -74,12 +73,6 @@ fun getChangelog(changelogUrl: String, callback: (String?) -> Unit) {
fun download(context: Context, url: String): Long {
val fileName = url.substringAfterLast("/")
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
if (Environment.isExternalStorageManager()) {
val file = File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), fileName)
if (file.exists()) {
file.delete()
}
}
val request = DownloadManager.Request(url.toUri()).apply {
setTitle(fileName)
setDescription("Загрузка $fileName")
@@ -90,7 +83,6 @@ fun download(context: Context, url: String): Long {
return downloadManager.enqueue(request)
}
fun installApk(context: Context, uri: Uri) {
val file = File(uri.path!!)
val apkUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
@@ -131,6 +123,7 @@ fun registerDownloadListener(context: Context, downloadId: Long, onDownloaded: (
}
}
@Serializable
data class UpdateInfo(
val version: String?,
val versionCode: Int?,

View File

@@ -98,7 +98,7 @@ fun HostsScreen(navController: NavController) {
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.btn_no_hosts),
stringResource(R.string.empty_list),
textAlign = TextAlign.Center
)
}
@@ -106,16 +106,21 @@ fun HostsScreen(navController: NavController) {
}
else -> {
items(allLists) { item ->
HostItem(
StrategyItem(
item = item,
isChecked = checked[item] == true,
onCheckedChange = { isChecked ->
checked[item] = isChecked
if (isChecked) enableList(item) else disableList(item)
showRestartSnackbar(context, snackbarHostState, scope)
getStatus { isEnabled ->
if (isEnabled) {
showRestartSnackbar(context, snackbarHostState, scope)
}
}
},
onDeleteClick = {
if (deleteHost(item)) {
val wasChecked = checked[item] == true
if (deleteStrategy(item)) {
allLists = getAllLists()
activeLists = getActiveLists()
checked.clear()
@@ -123,7 +128,11 @@ fun HostsScreen(navController: NavController) {
checked[list] = activeLists.contains(list)
}
}
showRestartSnackbar(context, snackbarHostState, scope)
getStatus { isEnabled ->
if (isEnabled && wasChecked) {
showRestartSnackbar(context, snackbarHostState, scope)
}
}
}
)
}
@@ -140,7 +149,7 @@ fun HostsScreen(navController: NavController) {
}
@Composable
private fun HostItem(item: String, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit, onDeleteClick: () -> Unit) {
private fun StrategyItem(item: String, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit, onDeleteClick: () -> Unit) {
ElevatedCard(
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
@@ -194,7 +203,7 @@ private fun FloatingMenu(navController: NavController, launcher: ActivityResultL
text = { Text(stringResource(R.string.btn_download_host)) },
onClick = {
expanded = false
navController.navigate("hosts_repo") { launchSingleTop = true }
navController.navigate("repo?source=hosts") { launchSingleTop = true }
},
leadingIcon = {
Icon(Icons.Default.Download, contentDescription = stringResource(R.string.btn_download_host))
@@ -204,7 +213,7 @@ private fun FloatingMenu(navController: NavController, launcher: ActivityResultL
text = { Text(stringResource(R.string.btn_add_host)) },
onClick = {
expanded = false
addHost(launcher)
addStrategy(launcher)
},
leadingIcon = {
Icon(Icons.Default.UploadFile, contentDescription = stringResource(R.string.btn_add_host))
@@ -213,7 +222,7 @@ private fun FloatingMenu(navController: NavController, launcher: ActivityResultL
}
}
private fun addHost(launcher: ActivityResultLauncher<Array<String>>) {
private fun addStrategy(launcher: ActivityResultLauncher<Array<String>>) {
launcher.launch(arrayOf("text/plain"))
}
@@ -240,7 +249,7 @@ private fun copySelectedFile(context: Context, uri: Uri, snackbarHostState: Snac
}
}
private fun deleteHost(item: String): Boolean {
private fun deleteStrategy(item: String): Boolean {
val hostFile = File(item)
return if (hostFile.exists()) {
hostFile.delete()

View File

@@ -50,9 +50,7 @@ import androidx.navigation.NavController
import com.cherret.zaprett.HostsInfo
import com.cherret.zaprett.R
import com.cherret.zaprett.download
import com.cherret.zaprett.getAllLists
import com.cherret.zaprett.getFileSha256
import com.cherret.zaprett.getHostList
import com.cherret.zaprett.getZaprettPath
import com.cherret.zaprett.registerDownloadListenerHost
import kotlinx.coroutines.Dispatchers
@@ -61,7 +59,7 @@ import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HostsRepoScreen(navController: NavController) {
fun HostsRepoScreen(navController: NavController, getAllLists: () -> Array<String>, getHostList: ((List<HostsInfo>?) -> Unit) -> Unit, targetPath: String) {
val context = LocalContext.current
var allLists by remember { mutableStateOf(getAllLists()) }
var hostLists by remember { mutableStateOf<List<HostsInfo>?>(null) }
@@ -87,8 +85,8 @@ fun HostsRepoScreen(navController: NavController) {
TopAppBar(
title = {
Text(
text = stringResource(R.string.title_hosts_repo),
fontSize = 40.sp,
text = stringResource(R.string.title_repo),
fontSize = 30.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
)
},
@@ -127,7 +125,7 @@ fun HostsRepoScreen(navController: NavController) {
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.btn_no_hosts),
stringResource(R.string.empty_list),
textAlign = TextAlign.Center
)
}
@@ -161,6 +159,17 @@ fun HostsRepoScreen(navController: NavController) {
modifier = Modifier.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp)
) {
Text(
text = stringResource(R.string.title_author, item.author),
modifier = Modifier.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
@@ -190,7 +199,7 @@ fun HostsRepoScreen(navController: NavController) {
) { uri ->
val sourceFile = File(uri.path!!)
val targetFile = File(
getZaprettPath() + "/lists",
getZaprettPath() + targetPath,
uri.lastPathSegment!!
)
sourceFile.copyTo(targetFile, overwrite = true)
@@ -229,7 +238,7 @@ fun HostsRepoScreen(navController: NavController) {
) { uri ->
val sourceFile = File(uri.path!!)
val targetFile = File(
getZaprettPath() + "/lists",
getZaprettPath() + targetPath,
uri.lastPathSegment!!
)
sourceFile.copyTo(targetFile, overwrite = true)

View File

@@ -0,0 +1,284 @@
package com.cherret.zaprett.ui.screens
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.*
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.Delete
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.*
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.*
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.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.cherret.zaprett.R
import com.cherret.zaprett.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StrategyScreen(navController: NavController) {
val context = LocalContext.current
var allStrategies by remember { mutableStateOf(getAllStrategies()) }
var activeStrategies by remember { mutableStateOf(getActiveStrategies()) }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var isRefreshing by remember { mutableStateOf(false) }
val checked = remember {
mutableStateMapOf<String, Boolean>().apply {
allStrategies.forEach { list -> this[list] = activeStrategies.contains(list) }
}
}
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { copySelectedFile(context, it, snackbarHostState, scope) }
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.title_strategies),
fontSize = 40.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
)
},
windowInsets = WindowInsets(0)
)
},
content = { paddingValues ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
allStrategies = getAllStrategies()
activeStrategies = getActiveStrategies()
checked.clear()
allStrategies.forEach { list ->
checked[list] = activeStrategies.contains(list)
}
isRefreshing = false
},
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
contentPadding = paddingValues,
modifier = Modifier.fillMaxSize()
) {
when {
allStrategies.isEmpty() != false -> {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.empty_list),
textAlign = TextAlign.Center
)
}
}
}
else -> {
items(allStrategies) { item ->
StrategyItem(
item = item,
isChecked = checked[item] == true,
onCheckedChange = { isChecked ->
checked[item] = isChecked
if (isChecked) {
checked.keys.forEach { key ->
checked[key] = false
disableStrategy(key)
}
checked[item] = true
enableStrategy(item)
}
else {
checked[item] = false
disableStrategy(item)
}
getStatus { isEnabled ->
if (isEnabled) {
showRestartSnackbar(context, snackbarHostState, scope)
}
}
},
onDeleteClick = {
val wasChecked = checked[item] == true
if (deleteStrategy(item)) {
allStrategies = getAllStrategies()
activeStrategies = getActiveStrategies()
checked.clear()
allStrategies.forEach { list ->
checked[list] = activeStrategies.contains(list)
}
}
getStatus { isEnabled ->
if (isEnabled && wasChecked) {
showRestartSnackbar(context, snackbarHostState, scope)
}
}
}
)
}
}
}
}
}
},
floatingActionButton = {
FloatingMenu(navController, filePickerLauncher)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
)
}
@Composable
private fun StrategyItem(item: String, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit, onDeleteClick: () -> Unit) {
ElevatedCard(
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 25.dp, end = 10.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(text = item, modifier = Modifier.weight(1f))
Switch(checked = isChecked, onCheckedChange = onCheckedChange)
}
HorizontalDivider(thickness = Dp.Hairline)
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
FilledTonalButton(
onClick = onDeleteClick,
modifier = Modifier.padding(horizontal = 5.dp)
) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.btn_remove_host),
modifier = Modifier.size(20.dp)
)
Text(stringResource(R.string.btn_remove_host))
}
}
}
}
@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=strategies") { 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
addStrategy(launcher)
},
leadingIcon = {
Icon(Icons.Default.UploadFile, contentDescription = stringResource(R.string.btn_add_host))
}
)
}
}
private fun addStrategy(launcher: ActivityResultLauncher<Array<String>>) {
launcher.launch(arrayOf("text/plain"))
}
private fun copySelectedFile(context: Context, uri: Uri, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (!Environment.isExternalStorageManager()) 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 outputFile = File(getZaprettPath() + "/strategies", fileName)
try {
contentResolver.openInputStream(uri)?.use { inputStream ->
FileOutputStream(outputFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
showRestartSnackbar(context, snackbarHostState, scope)
} catch (e: IOException) {
e.printStackTrace()
}
}
private fun deleteStrategy(item: String): Boolean {
val hostFile = File(item)
return if (hostFile.exists()) {
hostFile.delete()
true
} else {
false
}
}
private fun showRestartSnackbar(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
scope.launch {
val result = snackbarHostState.showSnackbar(
context.getString(R.string.pls_restart_snack),
actionLabel = context.getString(R.string.btn_restart_service)
)
if (result == SnackbarResult.ActionPerformed) {
restartService {}
snackbarHostState.showSnackbar(context.getString(R.string.snack_reload))
}
}
}

View File

@@ -2,7 +2,8 @@
<resources>
<string name="title_home">Главная</string>
<string name="title_hosts">Хосты</string>
<string name="title_hosts_repo">Репозиторий</string>
<string name="title_strategies">Стратегии</string>
<string name="title_repo">Репозиторий</string>
<string name="title_settings">Настройки</string>
<string name="btn_continue">Продолжить</string>
<string name="btn_update">Обновить</string>
@@ -29,7 +30,8 @@
<string name="btn_download_host">Скачать</string>
<string name="btn_add_host">Добавить</string>
<string name="btn_back">Назад</string>
<string name="btn_no_hosts">Нет хостов</string>
<string name="empty_list">Пусто :(</string>
<string name="title_author">Автор: %1$s</string>
<string name="btn_install_host">Установить</string>
<string name="btn_update_host">Обновить</string>
<string name="btn_installing_host">Установка</string>
@@ -38,7 +40,7 @@
<string name="btn_autorestart">Переодически перезапускать сервис</string>
<string name="btn_autoupdate">Авто обновление</string>
<string name="btn_send_firebase_analytics">Отправлять аналитику Firebase</string>
<string name="alert_version">Version: %1$s → %2$s\nСписок изменений:\n%3$s</string>
<string name="alert_version">Версия: %1$s → %2$s\nСписок изменений:\n%3$s</string>
<string name="snack_already_started">Сервис уже запущен.</string>
<string name="snack_starting_service">Запускаем сервис…</string>
<string name="snack_no_service">Сервис не запущен</string>

View File

@@ -2,7 +2,8 @@
<string name="app_name" translatable="false">zaprett</string>
<string name="title_home">Home</string>
<string name="title_hosts">Hosts</string>
<string name="title_hosts_repo">Repository</string>
<string name="title_strategies">Strategies</string>
<string name="title_repo">Repository</string>
<string name="title_settings">Settings</string>
<string name="btn_continue">Continue</string>
<string name="btn_update">Update</string>
@@ -29,7 +30,8 @@
<string name="btn_download_host">Download</string>
<string name="btn_add_host">Add</string>
<string name="btn_back">Back</string>
<string name="btn_no_hosts">No Hosts</string>
<string name="empty_list">Empty :(</string>
<string name="title_author">Author: %1$s</string>
<string name="btn_install_host">Install</string>
<string name="btn_update_host">Update</string>
<string name="btn_installing_host">Installing</string>
@@ -44,7 +46,7 @@
<string name="snack_no_service">Service is not launched</string>
<string name="snack_stopping_service">Stopping service…</string>
<string name="error_no_storage_title">No permission</string>
<string name="error_no_storage_message">The application requires permission to acess the storage to work properly</string>
<string name="error_no_storage_message">The application requires permission to access the storage to work properly</string>
<string name="btn_show_full_path">Show full list\'s path</string>
<string name="pls_reboot_snack">Reboot your device for the changes to take effect</string>
<string name="pls_restart_snack">Restart zaprett service for the changes to take effects</string>

View File

@@ -1,2 +1 @@
Добавлена возможность загружать хосты из репозитория
Рефакторинг кода
Размер apk файла уменьшен до 4МБ

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 84 KiB

BIN
images/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
images/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -1,6 +1,6 @@
{
"version": "1.7",
"versionCode": 8,
"downloadUrl": "https://github.com/CherretGit/zaprett-app/releases/download/1_7_0/app-release.apk",
"version": "1.9",
"versionCode": 10,
"downloadUrl": "https://github.com/CherretGit/zaprett-app/releases/download/1_9_0/app-release.apk",
"changelogUrl": "https://raw.githubusercontent.com/CherretGit/zaprett-app/refs/heads/main/changelog.md"
}