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) Приложение разработано для работы с модулем [zaprett](https://github.com/egor-white/zaprett)
### [Официальный Telegram-канал приложения](https://t.me/zaprett_module) ### [Официальный Telegram-канал приложения](https://t.me/zaprett_module)
## ВНИМАНИЕ приложение работает только с root правами
### Данное приложение является ремейком [приложения](https://github.com/egor-white/zaprett-app) от [egor-white](https://github.com/egor-white) ### Данное приложение является ремейком [приложения](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) alias(libs.plugins.kotlin.compose)
id("com.google.gms.google-services") id("com.google.gms.google-services")
id("com.google.firebase.crashlytics") id("com.google.firebase.crashlytics")
kotlin("plugin.serialization") version "2.1.20"
} }
android { android {
@@ -14,8 +15,8 @@ android {
applicationId = "com.cherret.zaprett" applicationId = "com.cherret.zaprett"
minSdk = 30 minSdk = 30
targetSdk = 35 targetSdk = 35
versionCode = 9 versionCode = 11
versionName = "1.8" versionName = "1.10"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -51,7 +52,7 @@ dependencies {
implementation("androidx.compose.material:material-icons-extended:1.7.8") implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation ("com.github.topjohnwu.libsu:core:6.0.0") implementation ("com.github.topjohnwu.libsu:core:6.0.0")
implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.14") 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(platform("com.google.firebase:firebase-bom:33.12.0"))
implementation("com.google.firebase:firebase-analytics") implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-crashlytics") 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.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dashboard 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.Home
import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
@@ -28,6 +29,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.content.edit import androidx.core.content.edit
@@ -36,22 +38,25 @@ import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.cherret.zaprett.ui.screens.HomeScreen import com.cherret.zaprett.ui.screens.HomeScreen
import com.cherret.zaprett.ui.screens.HostsRepoScreen import com.cherret.zaprett.ui.screens.HostsRepoScreen
import com.cherret.zaprett.ui.screens.HostsScreen import com.cherret.zaprett.ui.screens.HostsScreen
import com.cherret.zaprett.ui.screens.SettingsScreen import com.cherret.zaprett.ui.screens.SettingsScreen
import com.cherret.zaprett.ui.screens.StrategyScreen
import com.cherret.zaprett.ui.theme.ZaprettTheme import com.cherret.zaprett.ui.theme.ZaprettTheme
import com.google.firebase.Firebase import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics 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 home : Screen("home", R.string.title_home, Icons.Default.Home)
object hosts : Screen("hosts", R.string.title_hosts, Icons.Default.Dashboard) 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) object settings : Screen("settings", R.string.title_settings, Icons.Default.Settings)
} }
val topLevelRoutes = listOf(Screen.home, Screen.hosts, Screen.settings) val topLevelRoutes = listOf(Screen.home, Screen.hosts, Screen.strategies, Screen.settings)
val hideNavBar = listOf("hosts_repo") val hideNavBar = listOf("repo?source={source}")
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var firebaseAnalytics: FirebaseAnalytics private lateinit var firebaseAnalytics: FirebaseAnalytics
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
@@ -119,8 +124,19 @@ class MainActivity : ComponentActivity() {
) { ) {
composable(Screen.home.route) { HomeScreen() } composable(Screen.home.route) { HomeScreen() }
composable(Screen.hosts.route) { HostsScreen(navController) } composable(Screen.hosts.route) { HostsScreen(navController) }
composable(Screen.strategies.route) { StrategyScreen(navController) }
composable(Screen.settings.route) { SettingsScreen() } 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() 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> { fun getActiveLists(): Array<String> {
val configFile = File("${getZaprettPath()}/config") val configFile = File("${getZaprettPath()}/config")
if (configFile.exists()) { if (configFile.exists()) {
@@ -127,6 +136,24 @@ fun getActiveLists(): Array<String> {
return emptyArray() 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) { fun enableList(path: String) {
val props = Properties() val props = Properties()
val configFile = getConfigFile() val configFile = getConfigFile()
@@ -136,7 +163,10 @@ fun enableList(path: String) {
props.load(input) props.load(input)
} }
} }
val activeLists = props.getProperty("activelists", "").split(",").toMutableList() val activeLists = props.getProperty("activelists", "")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (path !in activeLists) { if (path !in activeLists) {
activeLists.add(path) 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) { fun disableList(path: String) {
val props = Properties() val props = Properties()
val configFile = getConfigFile() val configFile = getConfigFile()
@@ -158,7 +213,10 @@ fun disableList(path: String) {
props.load(input) props.load(input)
} }
} }
val activeLists = props.getProperty("activelists", "").split(",").toMutableList() val activeLists = props.getProperty("activelists", "")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (path in activeLists) { if (path in activeLists) {
activeLists.remove(path) activeLists.remove(path)
} }
@@ -169,4 +227,29 @@ fun disableList(path: String) {
} catch (e: IOException) { } catch (e: IOException) {
throw RuntimeException(e) 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 android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import com.squareup.moshi.Moshi import kotlinx.serialization.Serializable
import com.squareup.moshi.Types import kotlinx.serialization.json.Json
import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory
import okhttp3.Call import okhttp3.Call
import okhttp3.Callback import okhttp3.Callback
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -23,12 +22,10 @@ import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
private val client = OkHttpClient() private val client = OkHttpClient()
private val json = Json { ignoreUnknownKeys = true }
fun getHostList(callback: (List<HostsInfo>?) -> Unit) { 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 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 { client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
e.printStackTrace() e.printStackTrace()
@@ -41,10 +38,29 @@ fun getHostList(callback: (List<HostsInfo>?) -> Unit) {
callback(null) callback(null)
} }
val jsonString = response.body.string() val jsonString = response.body.string()
val updateInfo = jsonAdapter.fromJson(jsonString) val hostsInfo = json.decodeFromString<List<HostsInfo>>(jsonString)
if (updateInfo != null) { callback(hostsInfo)
callback(updateInfo) }
}
})
}
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) } return digest.digest().joinToString("") { "%02x".format(it) }
} }
@Serializable
data class HostsInfo( data class HostsInfo(
val name: String, val name: String,
val description: String, val description: String,
val author: String,
val hash: String, val hash: String,
val url: String val url: String
) )

View File

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

View File

@@ -98,7 +98,7 @@ fun HostsScreen(navController: NavController) {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
stringResource(R.string.btn_no_hosts), stringResource(R.string.empty_list),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
@@ -106,16 +106,21 @@ fun HostsScreen(navController: NavController) {
} }
else -> { else -> {
items(allLists) { item -> items(allLists) { item ->
HostItem( StrategyItem(
item = item, item = item,
isChecked = checked[item] == true, isChecked = checked[item] == true,
onCheckedChange = { isChecked -> onCheckedChange = { isChecked ->
checked[item] = isChecked checked[item] = isChecked
if (isChecked) enableList(item) else disableList(item) if (isChecked) enableList(item) else disableList(item)
showRestartSnackbar(context, snackbarHostState, scope) getStatus { isEnabled ->
if (isEnabled) {
showRestartSnackbar(context, snackbarHostState, scope)
}
}
}, },
onDeleteClick = { onDeleteClick = {
if (deleteHost(item)) { val wasChecked = checked[item] == true
if (deleteStrategy(item)) {
allLists = getAllLists() allLists = getAllLists()
activeLists = getActiveLists() activeLists = getActiveLists()
checked.clear() checked.clear()
@@ -123,7 +128,11 @@ fun HostsScreen(navController: NavController) {
checked[list] = activeLists.contains(list) 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 @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( ElevatedCard(
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer), 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)) }, text = { Text(stringResource(R.string.btn_download_host)) },
onClick = { onClick = {
expanded = false expanded = false
navController.navigate("hosts_repo") { launchSingleTop = true } navController.navigate("repo?source=hosts") { launchSingleTop = true }
}, },
leadingIcon = { leadingIcon = {
Icon(Icons.Default.Download, contentDescription = stringResource(R.string.btn_download_host)) 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)) }, text = { Text(stringResource(R.string.btn_add_host)) },
onClick = { onClick = {
expanded = false expanded = false
addHost(launcher) addStrategy(launcher)
}, },
leadingIcon = { leadingIcon = {
Icon(Icons.Default.UploadFile, contentDescription = stringResource(R.string.btn_add_host)) 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")) 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) val hostFile = File(item)
return if (hostFile.exists()) { return if (hostFile.exists()) {
hostFile.delete() hostFile.delete()

View File

@@ -50,9 +50,7 @@ import androidx.navigation.NavController
import com.cherret.zaprett.HostsInfo import com.cherret.zaprett.HostsInfo
import com.cherret.zaprett.R import com.cherret.zaprett.R
import com.cherret.zaprett.download import com.cherret.zaprett.download
import com.cherret.zaprett.getAllLists
import com.cherret.zaprett.getFileSha256 import com.cherret.zaprett.getFileSha256
import com.cherret.zaprett.getHostList
import com.cherret.zaprett.getZaprettPath import com.cherret.zaprett.getZaprettPath
import com.cherret.zaprett.registerDownloadListenerHost import com.cherret.zaprett.registerDownloadListenerHost
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -61,7 +59,7 @@ import java.io.File
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HostsRepoScreen(navController: NavController) { fun HostsRepoScreen(navController: NavController, getAllLists: () -> Array<String>, getHostList: ((List<HostsInfo>?) -> Unit) -> Unit, targetPath: String) {
val context = LocalContext.current val context = LocalContext.current
var allLists by remember { mutableStateOf(getAllLists()) } var allLists by remember { mutableStateOf(getAllLists()) }
var hostLists by remember { mutableStateOf<List<HostsInfo>?>(null) } var hostLists by remember { mutableStateOf<List<HostsInfo>?>(null) }
@@ -87,8 +85,8 @@ fun HostsRepoScreen(navController: NavController) {
TopAppBar( TopAppBar(
title = { title = {
Text( Text(
text = stringResource(R.string.title_hosts_repo), text = stringResource(R.string.title_repo),
fontSize = 40.sp, fontSize = 30.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal)) fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
) )
}, },
@@ -127,7 +125,7 @@ fun HostsRepoScreen(navController: NavController) {
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Text( Text(
stringResource(R.string.btn_no_hosts), stringResource(R.string.empty_list),
textAlign = TextAlign.Center textAlign = TextAlign.Center
) )
} }
@@ -161,6 +159,17 @@ fun HostsRepoScreen(navController: NavController) {
modifier = Modifier.weight(1f) 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( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
@@ -190,7 +199,7 @@ fun HostsRepoScreen(navController: NavController) {
) { uri -> ) { uri ->
val sourceFile = File(uri.path!!) val sourceFile = File(uri.path!!)
val targetFile = File( val targetFile = File(
getZaprettPath() + "/lists", getZaprettPath() + targetPath,
uri.lastPathSegment!! uri.lastPathSegment!!
) )
sourceFile.copyTo(targetFile, overwrite = true) sourceFile.copyTo(targetFile, overwrite = true)
@@ -229,7 +238,7 @@ fun HostsRepoScreen(navController: NavController) {
) { uri -> ) { uri ->
val sourceFile = File(uri.path!!) val sourceFile = File(uri.path!!)
val targetFile = File( val targetFile = File(
getZaprettPath() + "/lists", getZaprettPath() + targetPath,
uri.lastPathSegment!! uri.lastPathSegment!!
) )
sourceFile.copyTo(targetFile, overwrite = true) 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> <resources>
<string name="title_home">Главная</string> <string name="title_home">Главная</string>
<string name="title_hosts">Хосты</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="title_settings">Настройки</string>
<string name="btn_continue">Продолжить</string> <string name="btn_continue">Продолжить</string>
<string name="btn_update">Обновить</string> <string name="btn_update">Обновить</string>
@@ -29,7 +30,8 @@
<string name="btn_download_host">Скачать</string> <string name="btn_download_host">Скачать</string>
<string name="btn_add_host">Добавить</string> <string name="btn_add_host">Добавить</string>
<string name="btn_back">Назад</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_install_host">Установить</string>
<string name="btn_update_host">Обновить</string> <string name="btn_update_host">Обновить</string>
<string name="btn_installing_host">Установка</string> <string name="btn_installing_host">Установка</string>
@@ -38,7 +40,7 @@
<string name="btn_autorestart">Переодически перезапускать сервис</string> <string name="btn_autorestart">Переодически перезапускать сервис</string>
<string name="btn_autoupdate">Авто обновление</string> <string name="btn_autoupdate">Авто обновление</string>
<string name="btn_send_firebase_analytics">Отправлять аналитику Firebase</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_already_started">Сервис уже запущен.</string>
<string name="snack_starting_service">Запускаем сервис…</string> <string name="snack_starting_service">Запускаем сервис…</string>
<string name="snack_no_service">Сервис не запущен</string> <string name="snack_no_service">Сервис не запущен</string>

View File

@@ -2,7 +2,8 @@
<string name="app_name" translatable="false">zaprett</string> <string name="app_name" translatable="false">zaprett</string>
<string name="title_home">Home</string> <string name="title_home">Home</string>
<string name="title_hosts">Hosts</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="title_settings">Settings</string>
<string name="btn_continue">Continue</string> <string name="btn_continue">Continue</string>
<string name="btn_update">Update</string> <string name="btn_update">Update</string>
@@ -29,7 +30,8 @@
<string name="btn_download_host">Download</string> <string name="btn_download_host">Download</string>
<string name="btn_add_host">Add</string> <string name="btn_add_host">Add</string>
<string name="btn_back">Back</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_install_host">Install</string>
<string name="btn_update_host">Update</string> <string name="btn_update_host">Update</string>
<string name="btn_installing_host">Installing</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_no_service">Service is not launched</string>
<string name="snack_stopping_service">Stopping service…</string> <string name="snack_stopping_service">Stopping service…</string>
<string name="error_no_storage_title">No permission</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="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_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> <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", "version": "1.9",
"versionCode": 8, "versionCode": 10,
"downloadUrl": "https://github.com/CherretGit/zaprett-app/releases/download/1_7_0/app-release.apk", "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" "changelogUrl": "https://raw.githubusercontent.com/CherretGit/zaprett-app/refs/heads/main/changelog.md"
} }