Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
87ff9c5164 | ||
|
|
c61937a4b0 | ||
|
|
edf54851ae | ||
|
|
b766deafa5 | ||
|
|
7974066105 | ||
|
|
dc096ae3ed | ||
|
|
23172b1cd8 | ||
|
|
a9ed6a5e67 | ||
|
|
00b5334273 | ||
|
|
f11071fd4d | ||
|
|
82e28c1620 | ||
|
|
9ea170a2b2 | ||
|
|
2d82aec1eb | ||
|
|
4ee72db907 | ||
|
|
96a1455ebc | ||
|
|
e5ed91e90d |
10
README.md
@@ -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">
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -170,3 +228,28 @@ fun disableList(path: String) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,11 +38,30 @@ 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
|
||||
)
|
||||
@@ -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?,
|
||||
|
||||
@@ -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)
|
||||
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,8 +128,12 @@ fun HostsScreen(navController: NavController) {
|
||||
checked[list] = activeLists.contains(list)
|
||||
}
|
||||
}
|
||||
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()
|
||||
|
||||
@@ -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)
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
Добавлена возможность загружать хосты из репозитория
|
||||
Рефакторинг кода
|
||||
Размер apk файла уменьшен до 4МБ
|
||||
|
||||
BIN
images/1.png
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 76 KiB |
BIN
images/2.png
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 110 KiB |
BIN
images/3.png
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 84 KiB |
BIN
images/4.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
BIN
images/5.png
Normal file
|
After Width: | Height: | Size: 100 KiB |
@@ -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"
|
||||
}
|
||||
|
||||