35 Commits
1.2.0 ... 1_7_0

Author SHA1 Message Date
Cherret
458741485b hosts repo and code refactor 2025-04-27 23:55:30 +07:00
CherretGit
ca51cb1e81 Update update.json 2025-04-07 20:27:25 +07:00
CherretGit
542b14ddd2 Update changelog.md 2025-04-07 20:27:15 +07:00
Cherret
c88687882e Merge remote-tracking branch 'origin/main' 2025-04-07 20:17:57 +07:00
Cherret
355987fddb QS Button 2025-04-07 20:17:07 +07:00
CherretGit
b43b197233 Update update.json 2025-04-07 01:27:49 +07:00
CherretGit
eec81a32d9 Update changelog.md 2025-04-07 01:27:38 +07:00
Cherret
3e5b96b562 Update version 2025-04-07 01:19:22 +07:00
Cherret
acf579da89 Merge remote-tracking branch 'origin/main' 2025-04-07 01:17:28 +07:00
Cherret
180922699c Remove host button 2025-04-07 01:15:41 +07:00
CherretGit
3fdd68ca55 Update update.json 2025-04-05 02:58:28 +07:00
CherretGit
20bdcac931 Update changelog.md 2025-04-05 02:58:05 +07:00
Cherret
76e7fb8310 Merge remote-tracking branch 'origin/main' 2025-04-05 02:41:14 +07:00
Cherret
4ed064ff9c Bug Fixing, Firebase Analytics 2025-04-05 02:40:49 +07:00
Cherret
b758decd6b Bug Fixing, Firebase Analytics 2025-04-05 02:38:25 +07:00
CherretGit
4a74050ec6 Update update.json 2025-04-02 13:39:07 +07:00
CherretGit
d9aa02a436 Update update.json 2025-03-31 02:58:36 +07:00
CherretGit
18b3c0e3c6 Update update.json 2025-03-31 02:56:02 +07:00
CherretGit
1870edfb5c Update changelog.md 2025-03-31 02:55:44 +07:00
Cherret
9b138d55ee Merge remote-tracking branch 'origin/main' 2025-03-31 02:54:43 +07:00
Cherret
03e1ea4f06 Minor Update 2025-03-31 02:47:08 +07:00
CherretGit
9e95ba9922 Update workflow.yml 2025-03-30 17:44:25 +07:00
CherretGit
aac80da9a7 Update workflow.yml 2025-03-30 17:25:59 +07:00
CherretGit
2b611ce8cd Update workflow.yml 2025-03-30 16:43:35 +07:00
CherretGit
6967ee286c Update workflow.yml 2025-03-30 16:02:44 +07:00
CherretGit
83737d5df9 Update workflow.yml 2025-03-30 15:55:00 +07:00
CherretGit
a05c6af2c1 Update workflow.yml 2025-03-30 15:47:35 +07:00
CherretGit
7b7f94f0e2 Create workflow.yml 2025-03-30 15:40:41 +07:00
CherretGit
8367eac9bc Update README.md 2025-03-30 14:43:20 +07:00
CherretGit
e95792b8c9 Update README.md 2025-03-30 14:42:02 +07:00
CherretGit
7352943bb3 Update README.md 2025-03-30 14:41:22 +07:00
CherretGit
b4da6bda5a Update README.md 2025-03-28 21:16:46 +07:00
Cherret
4ecc9a40d4 Merge remote-tracking branch 'origin/main' 2025-03-28 17:27:31 +07:00
Cherret
96dc70473e Auto Update 2025-03-28 17:26:11 +07:00
CherretGit
b05616d7ef Update changelog.md 2025-03-28 17:25:48 +07:00
21 changed files with 1368 additions and 576 deletions

66
.github/workflows/workflow.yml vendored Normal file
View File

@@ -0,0 +1,66 @@
name: Build and Release
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag for the release'
required: true
type: string
release_name:
description: 'Release Name'
required: true
type: string
release_notes:
description: 'Release Description'
required: true
type: string
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK
uses: actions/setup-java@v4
with:
java-version: '21'
distribution: 'temurin'
cache: gradle
- name: Grant execute permission for gradlew
run: chmod +x gradlew
- name: Set up Android SDK
uses: android-actions/setup-android@v2
- name: Build APK
run: ./gradlew assembleRelease
- name: Decode Keystore
run: |
echo "${{ secrets.KEYSTORE }}" | base64 --decode > keystore.jks
- name: Sign the APK
run: |
$ANDROID_HOME/build-tools/$(ls $ANDROID_HOME/build-tools | sort -V | tail -1)/apksigner sign \
--ks keystore.jks \
--ks-pass "pass:${{ secrets.KEY_STORE_PASSWORD }}" \
--key-pass "pass:${{ secrets.KEY_PASSWORD }}" \
--out app/build/outputs/apk/release/app-release.apk \
app/build/outputs/apk/release/app-release-unsigned.apk
- name: Verify APK signature
run: |
$ANDROID_HOME/build-tools/$(ls $ANDROID_HOME/build-tools | sort -V | tail -1)/apksigner verify \
app/build/outputs/apk/release/app-release.apk
- name: Create GitHub Release
uses: softprops/action-gh-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.event.inputs.tag }}
name: ${{ github.event.inputs.release_name }}
body: ${{ github.event.inputs.release_notes }}
files: |
app/build/outputs/apk/release/app-release.apk

View File

@@ -6,8 +6,7 @@
На данный момент приложение умеет: На данный момент приложение умеет:
* Включать, выключать и перезапускать модуль * Включать, выключать и перезапускать модуль
* Работа с листами (добавление, включение и выключение) * Работа с листами (добавление, включение и выключение)
* Авто обновление приложения
## Скриншоты: ## Скриншоты:
![Главная страница](images/1.png) <img src="images/1.png" width="300"><img src="images/2.png" width="300"><img src="images/3.png" width="300">
![Хосты](images/2.png)
![Настройки](images/3.png)

View File

@@ -2,6 +2,8 @@ plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android) alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
} }
android { android {
@@ -12,8 +14,8 @@ android {
applicationId = "com.cherret.zaprett" applicationId = "com.cherret.zaprett"
minSdk = 30 minSdk = 30
targetSdk = 35 targetSdk = 35
versionCode = 2 versionCode = 8
versionName = "1.1" versionName = "1.7"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
} }
@@ -46,6 +48,11 @@ dependencies {
implementation("androidx.navigation:navigation-compose:2.8.9") implementation("androidx.navigation:navigation-compose:2.8.9")
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.moshi:moshi-kotlin:1.15.2")
implementation(platform("com.google.firebase:firebase-bom:33.12.0"))
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-crashlytics")
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)

29
app/google-services.json Normal file
View File

@@ -0,0 +1,29 @@
{
"project_info": {
"project_number": "1005804036856",
"project_id": "zaprett-app",
"storage_bucket": "zaprett-app.firebasestorage.app"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:1005804036856:android:e7db5546b8bb4daf91510d",
"android_client_info": {
"package_name": "com.cherret.zaprett"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "AIzaSyASt83pAMxMI4txNAXaHDpX1R9crfoZAMk"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
],
"configuration_version": "1"
}

View File

@@ -7,6 +7,9 @@
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<application <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -17,6 +20,15 @@
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.Zaprett" android:theme="@style/Theme.Zaprett"
tools:targetApi="31"> tools:targetApi="31">
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"
android:exported="true" android:exported="true"
@@ -28,6 +40,16 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".QSTileService"
android:exported="true"
android:label="@string/qs_name"
android:icon="@drawable/ic_launcher_monochrome"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>

View File

@@ -23,21 +23,27 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
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.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost 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 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.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.theme.ZaprettTheme import com.cherret.zaprett.ui.theme.ZaprettTheme
import com.topjohnwu.superuser.Shell 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: androidx.compose.ui.graphics.vector.ImageVector) {
object home : Screen("home", R.string.title_home, Icons.Default.Home) object home : Screen("home", R.string.title_home, Icons.Default.Home)
@@ -45,21 +51,27 @@ sealed class Screen(val route: String, @StringRes val nameResId: Int, val icon:
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.settings)
val hideNavBar = listOf("hosts_repo")
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private lateinit var firebaseAnalytics: FirebaseAnalytics
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE) firebaseAnalytics = Firebase.analytics
Shell.setDefaultBuilder(Shell.Builder.create()
.setTimeout(10))
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
ZaprettTheme { ZaprettTheme {
val sharedPreferences = remember { getSharedPreferences("settings", MODE_PRIVATE) }
var showPermissionDialog by remember { mutableStateOf(!Environment.isExternalStorageManager()) }
var showWelcomeDialog by remember { mutableStateOf(sharedPreferences.getBoolean("welcome_dialog", true)) }
BottomBar() BottomBar()
if (!Environment.isExternalStorageManager()) { if (showPermissionDialog) {
permissionDialog() PermissionDialog { showPermissionDialog = false }
} }
if (sharedPreferences.getBoolean("welcome_dialog", true)) { if (showWelcomeDialog) {
welcomeDialog() WelcomeDialog {
sharedPreferences.edit() { putBoolean("welcome_dialog", false) }
showWelcomeDialog = false
}
} }
} }
} }
@@ -70,29 +82,31 @@ class MainActivity : ComponentActivity() {
val navController = rememberNavController() val navController = rememberNavController()
Scaffold( Scaffold(
bottomBar = { bottomBar = {
NavigationBar { val navBackStackEntry = navController.currentBackStackEntryAsState().value
val navBackStackEntry = navController.currentBackStackEntryAsState().value val currentDestination = navBackStackEntry?.destination
val currentDestination = navBackStackEntry?.destination if (currentDestination?.route !in hideNavBar) {
topLevelRoutes.forEach { topLevelRoute -> NavigationBar {
NavigationBarItem( topLevelRoutes.forEach { topLevelRoute ->
icon = { NavigationBarItem(
Icon( icon = {
topLevelRoute.icon, Icon(
contentDescription = stringResource(id = topLevelRoute.nameResId) topLevelRoute.icon,
) contentDescription = stringResource(id = topLevelRoute.nameResId)
}, )
label = { Text(text = stringResource(id = topLevelRoute.nameResId)) }, },
selected = currentDestination?.route == topLevelRoute.route, label = { Text(text = stringResource(id = topLevelRoute.nameResId)) },
onClick = { selected = currentDestination?.route == topLevelRoute.route,
navController.navigate(topLevelRoute.route) { onClick = {
popUpTo(navController.graph.findStartDestination().id) { navController.navigate(topLevelRoute.route) {
saveState = true popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
} }
launchSingleTop = true
restoreState = true
} }
} )
) }
} }
} }
} }
@@ -103,76 +117,47 @@ class MainActivity : ComponentActivity() {
Modifier.padding(innerPadding) Modifier.padding(innerPadding)
) { ) {
composable(Screen.home.route) { HomeScreen() } composable(Screen.home.route) { HomeScreen() }
composable(Screen.hosts.route) { HostsScreen() } composable(Screen.hosts.route) { HostsScreen(navController) }
composable(Screen.settings.route) { SettingsScreen() } composable(Screen.settings.route) { SettingsScreen() }
composable("hosts_repo") { HostsRepoScreen(navController) }
} }
} }
} }
@Composable @Composable
fun welcomeDialog() { fun WelcomeDialog(onDismiss: () -> Unit) {
val sharedPreferences = AlertDialog(
LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE) title = { Text(text = stringResource(R.string.app_name)) },
val editor = sharedPreferences.edit() text = { Text(text = stringResource(R.string.text_welcome)) },
val openDialog = remember { mutableStateOf(true) } onDismissRequest = onDismiss,
if (openDialog.value) { confirmButton = {
AlertDialog( TextButton(onClick = onDismiss) {
title = { Text(stringResource(R.string.btn_continue))
Text(text = stringResource(R.string.app_name)) }
}, }
text = { )
Text(text = stringResource(R.string.text_welcome))
},
onDismissRequest = {
editor.putBoolean("welcome_dialog", false).apply()
openDialog.value = false
},
confirmButton = {
TextButton(
onClick = {
editor.putBoolean("welcome_dialog", false).apply()
openDialog.value = false
}
) {
Text(stringResource(R.string.btn_continue))
}
},
)
}
} }
@Composable @Composable
fun permissionDialog() { fun PermissionDialog(onDismiss: () -> Unit) {
val openDialog = remember { mutableStateOf(true) } val context = LocalContext.current
if (openDialog.value) { AlertDialog(
AlertDialog( title = { Text(text = stringResource(R.string.error_no_storage_title)) },
title = { text = { Text(text = stringResource(R.string.error_no_storage_message)) },
Text(text = stringResource(R.string.error_no_storage_title)) onDismissRequest = onDismiss,
}, confirmButton = {
text = { TextButton(
Text(text = stringResource(R.string.error_no_storage_message)) onClick = {
}, val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
onDismissRequest = { val uri = Uri.fromParts("package", context.packageName, null)
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION) intent.data = uri
val uri = Uri.fromParts("package", packageName, null) context.startActivity(intent)
intent.setData(uri) onDismiss()
startActivity(intent)
openDialog.value = false
},
confirmButton = {
TextButton(
onClick = {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
val uri = Uri.fromParts("package", packageName, null)
intent.setData(uri)
startActivity(intent)
openDialog.value = false
}
) {
Text(stringResource(R.string.btn_continue))
} }
}, ) {
) Text(stringResource(R.string.btn_continue))
} }
}
)
} }
} }

View File

@@ -79,7 +79,7 @@ fun getStartOnBoot(): Boolean {
} else { } else {
false false
} }
} catch (e: IOException) { } catch (_: IOException) {
false false
} }
} }
@@ -92,12 +92,12 @@ fun getZaprettPath(): String {
FileInputStream(configFile).use { input -> FileInputStream(configFile).use { input ->
props.load(input) props.load(input)
} }
props.getProperty("zaprettdir", "/sdcard/zaprett") props.getProperty("zaprettdir", Environment.getExternalStorageDirectory().path + "/zaprett")
} catch (e: IOException) { } catch (e: IOException) {
throw RuntimeException(e) throw RuntimeException(e)
} }
} }
return "/sdcard/zaprett" return Environment.getExternalStorageDirectory().path + "/zaprett"
} }
fun getAllLists(): Array<String> { fun getAllLists(): Array<String> {

View File

@@ -0,0 +1,65 @@
package com.cherret.zaprett
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
class QSTileService: TileService() {
override fun onTileAdded() {
super.onTileAdded()
updateStatus()
}
override fun onStartListening() {
super.onStartListening()
updateStatus()
}
override fun onStopListening() {
super.onStopListening()
}
override fun onClick() {
super.onClick()
if (qsTile.state == Tile.STATE_INACTIVE) {
qsTile.subtitle = getString(R.string.qs_starting)
qsTile.state = Tile.STATE_UNAVAILABLE
qsTile.updateTile()
startService {}
}
else {
qsTile.subtitle = getString(R.string.qs_stopping)
qsTile.state = Tile.STATE_UNAVAILABLE
qsTile.updateTile()
stopService {}
}
updateStatus()
}
override fun onTileRemoved() {
super.onTileRemoved()
}
fun updateStatus() {
if (getSharedPreferences("settings", MODE_PRIVATE).getBoolean("use_module", false)) {
getStatus {
if (it) {
qsTile.label = getString(R.string.qs_name)
qsTile.subtitle = getString(R.string.qs_working)
qsTile.state = Tile.STATE_ACTIVE
qsTile.updateTile()
} else {
qsTile.label = getString(R.string.qs_name)
qsTile.subtitle = getString(R.string.qs_not_working)
qsTile.state = Tile.STATE_INACTIVE
qsTile.updateTile()
}
}
}
else {
qsTile.label = getString(R.string.qs_name)
qsTile.subtitle = getString(R.string.qs_not_available)
qsTile.state = Tile.STATE_UNAVAILABLE
qsTile.updateTile()
}
}
}

View File

@@ -0,0 +1,101 @@
package com.cherret.zaprett
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
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 okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okio.IOException
import java.io.File
import java.security.MessageDigest
private val client = OkHttpClient()
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()
callback(null)
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) {
throw IOException()
callback(null)
}
val jsonString = response.body!!.string()
val updateInfo = jsonAdapter.fromJson(jsonString)
if (updateInfo != null) {
callback(updateInfo)
}
}
}
})
}
fun registerDownloadListenerHost(context: Context, downloadId: Long, onDownloaded: (Uri) -> Unit) {// AI Generated
val receiver = object : BroadcastReceiver() {
@SuppressLint("Range")
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != downloadId) return
val dm = context?.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager ?: return
val query = DownloadManager.Query().setFilterById(downloadId)
dm.query(query)?.use { cursor ->
if (cursor.moveToFirst()) {
val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
if (status == DownloadManager.STATUS_SUCCESSFUL) {
val uriString = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
if (uriString != null) {
val uri = uriString.toUri()
context.unregisterReceiver(this)
onDownloaded(uri)
}
}
}
}
}
}
val intentFilter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(receiver, intentFilter, Context.RECEIVER_EXPORTED)
} else {
ContextCompat.registerReceiver(context, receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), ContextCompat.RECEIVER_EXPORTED)
}
}
fun getFileSha256(file: File): String {
val digest = MessageDigest.getInstance("SHA-256")
file.inputStream().use { input ->
val buffer = ByteArray(1024)
var bytesRead: Int
while (input.read(buffer).also { bytesRead = it } != -1) {
digest.update(buffer, 0, bytesRead)
}
}
return digest.digest().joinToString("") { "%02x".format(it) }
}
data class HostsInfo(
val name: String,
val description: String,
val hash: String,
val url: String
)

View File

@@ -0,0 +1,142 @@
package com.cherret.zaprett
import android.annotation.SuppressLint
import android.app.DownloadManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.net.Uri
import android.os.Build
import android.os.Environment
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 okhttp3.Call
import okhttp3.Callback
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okio.IOException
import java.io.File
private val client = OkHttpClient()
fun getUpdate(context: Context, 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()
callback(null)
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) {
throw IOException()
callback(null)
}
val jsonString = response.body!!.string()
val updateInfo = jsonAdapter.fromJson(jsonString)
if (updateInfo != null) {
val packageVersionCode = context.packageManager.getPackageInfo(context.packageName, 0).longVersionCode
updateInfo.versionCode?.let { versionCode ->
if (versionCode > packageVersionCode)
callback(updateInfo)
}
}
}
}
})
}
fun getChangelog(changelogUrl: String, callback: (String?) -> Unit) {
val request = Request.Builder().url(changelogUrl).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) {
callback(null)
return
}
val changelogText = response.body!!.string()
callback(changelogText)
}
}
})
}
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")
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
setDestinationInExternalPublicDir(Environment.DIRECTORY_DOWNLOADS, fileName)
setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI or DownloadManager.Request.NETWORK_MOBILE)
}
return downloadManager.enqueue(request)
}
fun installApk(context: Context, uri: Uri) {
val file = File(uri.path!!)
val apkUri = FileProvider.getUriForFile(context, "${context.packageName}.provider", file)
if (context.packageManager.canRequestPackageInstalls()) {
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(apkUri, "application/vnd.android.package-archive")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
context.startActivity(intent)
}
else {
val packageUri = Uri.fromParts("package", context.packageName, null)
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, packageUri)
context.startActivity(intent)
}
}
fun registerDownloadListener(context: Context, downloadId: Long, onDownloaded: (Uri) -> Unit) {// AI Generated
val receiver = object : BroadcastReceiver() {
@SuppressLint("Range")
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != downloadId) return
val downloadManager = context?.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager ?: return
downloadManager.query(DownloadManager.Query().setFilterById(downloadId)).use { cursor ->
if (cursor.moveToFirst() && cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) == DownloadManager.STATUS_SUCCESSFUL) {
context.unregisterReceiver(this)
onDownloaded(cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)).toUri())
}
}
}
}
val intentFilter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(receiver, intentFilter, Context.RECEIVER_EXPORTED)
} else {
ContextCompat.registerReceiver(context, receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), ContextCompat.RECEIVER_EXPORTED)
}
}
data class UpdateInfo(
val version: String?,
val versionCode: Int?,
val downloadUrl: String?,
val changelogUrl: String?
)

View File

@@ -2,9 +2,13 @@ package com.cherret.zaprett.ui.screens
import android.content.Context import android.content.Context
import android.content.Context.MODE_PRIVATE import android.content.Context.MODE_PRIVATE
import androidx.compose.foundation.Canvas import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.layout.Box import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -12,8 +16,10 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
@@ -21,16 +27,18 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.rotate
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.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
@@ -40,144 +48,177 @@ 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.compose.ui.unit.sp
import com.cherret.zaprett.R import com.cherret.zaprett.R
import com.cherret.zaprett.download
import com.cherret.zaprett.getChangelog
import com.cherret.zaprett.getStatus import com.cherret.zaprett.getStatus
import com.cherret.zaprett.getUpdate
import com.cherret.zaprett.installApk
import com.cherret.zaprett.registerDownloadListener
import com.cherret.zaprett.restartService import com.cherret.zaprett.restartService
import com.cherret.zaprett.startService import com.cherret.zaprett.startService
import com.cherret.zaprett.stopService import com.cherret.zaprett.stopService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen() { fun HomeScreen() {
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = remember { context.getSharedPreferences("settings", MODE_PRIVATE) } val sharedPreferences = remember { context.getSharedPreferences("settings", MODE_PRIVATE) }
val cardText = remember { mutableStateOf(R.string.status_not_availible) } val cardText = remember { mutableIntStateOf(R.string.status_not_availible) }
val changeLog = remember { mutableStateOf<String?>(null) }
val newVersion = remember { mutableStateOf<String?>(null) }
val updateAvailable = remember { mutableStateOf(false) }
val downloadUrl = remember { mutableStateOf<String?>(null) }
var showUpdateDialog by remember { mutableStateOf(false) }
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (sharedPreferences.getBoolean("use_module", false) && sharedPreferences.getBoolean("update_on_boot", false)) { if (sharedPreferences.getBoolean("auto_update", true)) {
getStatus { getUpdate(context) {
if (it) { if (it != null) {
cardText.value = R.string.status_enabled downloadUrl.value = it.downloadUrl.toString()
} getChangelog(it.changelogUrl.toString()) { log -> changeLog.value = log }
else { newVersion.value = it.version?.toString()
cardText.value = R.string.status_disabled updateAvailable.value = true
} }
} }
}
if (sharedPreferences.getBoolean("use_module", false) && sharedPreferences.getBoolean("update_on_boot", false)) {
getStatus { isEnabled ->
cardText.intValue = if (isEnabled) R.string.status_enabled else R.string.status_disabled
}
} }
} }
Scaffold( Scaffold(
topBar = { topBar = {
val primaryColor = MaterialTheme.colorScheme.surfaceVariant TopAppBar(
Box(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) { title = {
Canvas(modifier = Modifier.size(100.dp, 50.dp)) { Text(
rotate(degrees = -30f) { text = stringResource(R.string.app_name),
drawOval( fontSize = 40.sp,
color = primaryColor, fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
size = Size(200f, 140f), )
topLeft = Offset(-20f, -20f) },
) windowInsets = WindowInsets(0)
} )
}
Text(
text = stringResource(R.string.app_name),
fontSize = 40.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
)
}
}, },
snackbarHost = { SnackbarHost(snackbarHostState) },
content = { paddingValues -> content = { paddingValues ->
Column( Column(modifier = Modifier.padding(paddingValues)) {
modifier = Modifier ServiceStatusCard(context, cardText, snackbarHostState, scope)
.padding(paddingValues) UpdateCard(updateAvailable) { showUpdateDialog = true }
) { if (showUpdateDialog) {
ElevatedCard( UpdateDialog(context, downloadUrl.value.orEmpty(), changeLog.value.orEmpty(), newVersion) { showUpdateDialog = false }
elevation = CardDefaults.cardElevation(
defaultElevation = 6.dp
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primary,
),
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 25.dp, end = 10.dp)
.size(width = 240.dp, height = 150.dp),
onClick = { onCardClick(context, cardText, snackbarHostState, scope) }
) {
Text(
text = stringResource(cardText.value),
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal)),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
textAlign = TextAlign.Center,
)
}
FilledTonalButton(
onClick = { onBtnStartService(context, snackbarHostState, scope) },
modifier = Modifier
.padding(start = 5.dp, top = 8.dp, end = 5.dp)
.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = stringResource(R.string.btn_start_service),
modifier = Modifier.size(20.dp)
)
Text(
stringResource(R.string.btn_start_service)
)
}
FilledTonalButton(
onClick = { onBtnStopService(context, snackbarHostState, scope) },
modifier = Modifier
.padding(start = 5.dp, top = 8.dp, end = 5.dp)
.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Stop,
contentDescription = stringResource(R.string.btn_stop_service),
modifier = Modifier.size(20.dp)
)
Text(
stringResource(R.string.btn_stop_service)
)
}
FilledTonalButton(
onClick = { onBtnRestart(context, snackbarHostState, scope) },
modifier = Modifier
.padding(start = 5.dp, top = 8.dp, end = 5.dp)
.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.RestartAlt,
contentDescription = stringResource(R.string.btn_restart_service),
modifier = Modifier.size(20.dp)
)
Text(
stringResource(R.string.btn_restart_service)
)
} }
ServiceControlButtons(context, snackbarHostState, scope)
} }
}, }
snackbarHost = {SnackbarHost(hostState = snackbarHostState)}
) )
} }
@Composable
private fun ServiceStatusCard(context: Context, cardText: MutableState<Int>, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
ElevatedCard(
elevation = CardDefaults.cardElevation(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary),
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 25.dp, end = 10.dp)
.size(width = 240.dp, height = 150.dp),
onClick = { onCardClick(context, cardText, snackbarHostState, scope) }
) {
Text(
text = stringResource(cardText.value),
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal)),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
textAlign = TextAlign.Center
)
}
}
@Composable
private fun UpdateCard(updateAvailable: MutableState<Boolean>, onClick: () -> Unit) {
AnimatedVisibility(
visible = updateAvailable.value,
enter = fadeIn() + expandVertically(),
exit = shrinkVertically() + fadeOut()
) {
ElevatedCard(
elevation = CardDefaults.cardElevation(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 10.dp, end = 10.dp)
.size(width = 140.dp, height = 70.dp),
onClick = onClick
) {
Text(
text = stringResource(R.string.update_available),
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal)),
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
textAlign = TextAlign.Center
)
}
}
}
@Composable
private fun ServiceControlButtons(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
FilledTonalButton(
onClick = { onBtnStartService(context, snackbarHostState, scope) },
modifier = Modifier
.padding(horizontal = 5.dp, vertical = 8.dp)
.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.PlayArrow,
contentDescription = stringResource(R.string.btn_start_service),
modifier = Modifier.size(20.dp)
)
Text(stringResource(R.string.btn_start_service))
}
FilledTonalButton(
onClick = { onBtnStopService(context, snackbarHostState, scope) },
modifier = Modifier
.padding(horizontal = 5.dp, vertical = 8.dp)
.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Stop,
contentDescription = stringResource(R.string.btn_stop_service),
modifier = Modifier.size(20.dp)
)
Text(stringResource(R.string.btn_stop_service))
}
FilledTonalButton(
onClick = { onBtnRestart(context, snackbarHostState, scope) },
modifier = Modifier
.padding(horizontal = 5.dp, vertical = 8.dp)
.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.RestartAlt,
contentDescription = stringResource(R.string.btn_restart_service),
modifier = Modifier.size(20.dp)
)
Text(stringResource(R.string.btn_restart_service))
}
}
fun onCardClick(context: Context, cardText: MutableState<Int>, snackbarHostState: SnackbarHostState, scope: CoroutineScope) { fun onCardClick(context: Context, cardText: MutableState<Int>, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (sharedPreferences.getBoolean("use_module", false)) { if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { getStatus { isEnabled ->
if (it) { cardText.value = if (isEnabled) R.string.status_enabled else R.string.status_disabled
cardText.value = R.string.status_enabled
}
else {
cardText.value = R.string.status_disabled
}
} }
} else {
}
else {
scope.launch { scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled)) snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled))
} }
@@ -185,18 +226,17 @@ fun onCardClick(context: Context, cardText: MutableState<Int>, snackbarHostState
} }
fun onBtnStartService(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) { fun onBtnStartService(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("use_module", false)) { val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
getStatus { if (sharedPreferences.getBoolean("use_module", false)) {
if (it) { getStatus { isEnabled ->
scope.launch { scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_already_started)) snackbarHostState.showSnackbar(
} context.getString(
} else { if (isEnabled) R.string.snack_already_started else R.string.snack_starting_service
startService {} )
scope.launch { )
snackbarHostState.showSnackbar(context.getString(R.string.snack_starting_service))
}
} }
if (!isEnabled) startService {}
} }
} else { } else {
scope.launch { scope.launch {
@@ -206,22 +246,19 @@ fun onBtnStartService(context: Context, snackbarHostState: SnackbarHostState, sc
} }
fun onBtnStopService(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) { fun onBtnStopService(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("use_module", false)) { val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
getStatus { if (sharedPreferences.getBoolean("use_module", false)) {
if (it) { getStatus { isEnabled ->
stopService{} scope.launch {
scope.launch { snackbarHostState.showSnackbar(
snackbarHostState.showSnackbar(context.getString(R.string.snack_stopping_service)) context.getString(
} if (isEnabled) R.string.snack_stopping_service else R.string.snack_no_service
} )
else { )
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_no_service))
}
} }
if (isEnabled) stopService {}
} }
} } else {
else {
scope.launch { scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled)) snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled))
} }
@@ -229,15 +266,45 @@ fun onBtnStopService(context: Context, snackbarHostState: SnackbarHostState, sco
} }
fun onBtnRestart(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) { fun onBtnRestart(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("use_module", false)) { val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
restartService{} if (sharedPreferences.getBoolean("use_module", false)) {
restartService {}
scope.launch { scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_reload)) snackbarHostState.showSnackbar(context.getString(R.string.snack_reload))
} }
} } else {
else {
scope.launch { scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled)) snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled))
} }
} }
} }
@Composable
fun UpdateDialog(context: Context, downloadUrl: String, changeLog: String, newVersion: MutableState<String?>, onDismiss: () -> Unit) {
AlertDialog(
title = { Text(text = stringResource(R.string.update_available)) },
text = {
Text(
text = "${stringResource(R.string.alert_version)}: ${context.packageManager.getPackageInfo(context.packageName, 0).versionName}${newVersion.value}\n" +
"${stringResource(R.string.alert_changelog)}:\n$changeLog"
)
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
onDismiss()
val downloadId = download(context, downloadUrl)
registerDownloadListener(context, downloadId) { uri ->
installApk(context, uri)
}
}) {
Text(stringResource(R.string.btn_update))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.btn_dismiss))
}
}
)
}

View File

@@ -0,0 +1,234 @@
package com.cherret.zaprett.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.InstallMobile
import androidx.compose.material.icons.filled.Update
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
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.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
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
import kotlinx.coroutines.withContext
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HostsRepoScreen(navController: NavController) {
val context = LocalContext.current
var allLists by remember { mutableStateOf(getAllLists()) }
var hostLists by remember { mutableStateOf<List<HostsInfo>?>(null) }
val isUpdate = remember { mutableStateMapOf<String, Boolean>() }
val snackbarHostState = remember { SnackbarHostState() }
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
getHostList {
hostLists = it
}
}
LaunchedEffect(hostLists) {
if (hostLists != null) {
withContext(Dispatchers.IO) {
hostLists!!.forEach { item ->
isUpdate[item.name] = !allLists.any { getFileSha256(File(it)) == item.hash }
}
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.title_hosts_repo),
fontSize = 40.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
)
},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.btn_back)
)
}
},
windowInsets = WindowInsets(0)
)
},
content = { paddingValues ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
getHostList {
hostLists = it
}
isRefreshing = false
},
modifier = Modifier.fillMaxSize()
) {
if (hostLists != null) {
LazyColumn(
contentPadding = paddingValues,
modifier = Modifier.fillMaxSize()
) {
items(hostLists.orEmpty()) { item ->
var isButtonEnabled by remember { mutableStateOf(!allLists.any {File(it).name == item.name})}
var isInstalling by remember { mutableStateOf(false) }
var isButtonUpdateEnabled by remember { mutableStateOf(true) }
var isUpdateInstalling by remember { mutableStateOf(false) }
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.name,
modifier = Modifier.weight(1f)
)
}
Row(verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp)
) {
Text(
text = item.description,
modifier = Modifier.weight(1f)
)
}
HorizontalDivider(thickness = Dp.Hairline)
Row(
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
if (isUpdate[item.name] == true && allLists.any {File(it).name == item.name}) {
FilledTonalButton(
onClick = {
isUpdateInstalling = true
isButtonUpdateEnabled = false
val downloadId = download(context, item.url)
registerDownloadListenerHost (context, downloadId) { uri ->
val sourceFile = File(uri.path!!)
val targetFile = File(getZaprettPath() + "/lists", uri.lastPathSegment!!)
sourceFile.copyTo(targetFile, overwrite = true)
sourceFile.delete()
isUpdateInstalling = false
getHostList {
hostLists = it
}
isUpdate[item.name] = false
}
},
enabled = isButtonUpdateEnabled,
modifier = Modifier
.padding(start = 5.dp, end = 5.dp),
) {
Icon(
imageVector = Icons.Default.Update,
contentDescription = stringResource(R.string.btn_remove_host),
modifier = Modifier.size(20.dp)
)
Text(
if (!isUpdateInstalling) stringResource(R.string.btn_update_host) else stringResource(R.string.btn_updating_host)
)
}
}
FilledTonalButton(
onClick = {
isInstalling = true
isButtonEnabled = false
val downloadId = download(context, item.url)
registerDownloadListenerHost (context, downloadId) { uri ->
val sourceFile = File(uri.path!!)
val targetFile = File(getZaprettPath() + "/lists", uri.lastPathSegment!!)
sourceFile.copyTo(targetFile, overwrite = true)
sourceFile.delete()
isInstalling = false
getHostList {
hostLists = it
}
}
},
enabled = isButtonEnabled,
modifier = Modifier
.padding(start = 5.dp, end = 5.dp),
) {
Icon(
imageVector = Icons.Default.InstallMobile,
contentDescription = stringResource(R.string.btn_remove_host),
modifier = Modifier.size(20.dp)
)
Text(
if (isButtonEnabled) stringResource(R.string.btn_install_host) else if (isInstalling) stringResource(R.string.btn_installing_host) else stringResource(R.string.btn_installed_host)
)
}
}
}
}
}
}
}
},
snackbarHost = {SnackbarHost(hostState = snackbarHostState)}
)
}

View File

@@ -7,54 +7,30 @@ import android.provider.OpenableColumns
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CardDefaults import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ElevatedCard import androidx.compose.material.icons.filled.Download
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.*
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.*
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.rotate
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.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.cherret.zaprett.R import com.cherret.zaprett.R
import com.cherret.zaprett.disableList import com.cherret.zaprett.*
import com.cherret.zaprett.enableList
import com.cherret.zaprett.getActiveLists
import com.cherret.zaprett.getAllLists
import com.cherret.zaprett.getZaprettPath
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@@ -63,7 +39,7 @@ import java.io.IOException
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HostsScreen() { fun HostsScreen(navController: NavController) {
val context = LocalContext.current val context = LocalContext.current
var allLists by remember { mutableStateOf(getAllLists()) } var allLists by remember { mutableStateOf(getAllLists()) }
var activeLists by remember { mutableStateOf(getActiveLists()) } var activeLists by remember { mutableStateOf(getActiveLists()) }
@@ -72,139 +48,199 @@ fun HostsScreen() {
var isRefreshing by remember { mutableStateOf(false) } var isRefreshing by remember { mutableStateOf(false) }
val checked = remember { val checked = remember {
mutableStateMapOf<String, Boolean>().apply { mutableStateMapOf<String, Boolean>().apply {
allLists.forEach { list -> allLists.forEach { list -> this[list] = activeLists.contains(list) }
this[list] = activeLists.contains(list)
}
} }
} }
val filePickerLauncher = rememberLauncherForActivityResult( val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(), contract = ActivityResultContracts.OpenDocument()
onResult = { uri: Uri? -> ) { uri ->
uri?.let { uri?.let { copySelectedFile(context, it, snackbarHostState, scope) }
copySelectedFile(context, it, snackbarHostState, scope) }
}
}
)
Scaffold( Scaffold(
topBar = { topBar = {
val primaryColor = MaterialTheme.colorScheme.surfaceVariant TopAppBar(
Box(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) { title = {
Canvas(modifier = Modifier.size(100.dp, 50.dp)) { Text(
rotate(degrees = -30f) { text = stringResource(R.string.title_hosts),
drawOval( fontSize = 40.sp,
color = primaryColor, fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
size = Size(200f, 140f), )
topLeft = Offset(-30f, -30f) },
) windowInsets = WindowInsets(0)
} )
}
Text(
text = stringResource(R.string.title_hosts),
fontSize = 40.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
)
}
}, },
content = { paddingValues -> content = { paddingValues ->
Column( PullToRefreshBox(
modifier = Modifier isRefreshing = isRefreshing,
.padding(paddingValues) onRefresh = {
isRefreshing = true
allLists = getAllLists()
activeLists = getActiveLists()
checked.clear()
allLists.forEach { list ->
checked[list] = activeLists.contains(list)
}
isRefreshing = false
},
modifier = Modifier.fillMaxSize()
) { ) {
PullToRefreshBox( LazyColumn(
isRefreshing = isRefreshing, contentPadding = paddingValues,
onRefresh = { modifier = Modifier.fillMaxSize()
isRefreshing = true
allLists = getAllLists()
activeLists = getActiveLists()
checked.clear()
allLists.forEach { list ->
checked[list] = activeLists.contains(list)
}
isRefreshing = false
},
modifier = Modifier
) { ) {
LazyColumn { items(allLists) { item ->
items(allLists) { item -> HostItem(
ElevatedCard( item = item,
elevation = CardDefaults.cardElevation( isChecked = checked[item] == true,
defaultElevation = 6.dp onCheckedChange = { isChecked ->
), checked[item] = isChecked
colors = CardDefaults.cardColors( if (isChecked) enableList(item) else disableList(item)
containerColor = MaterialTheme.colorScheme.surfaceVariant, showRestartSnackbar(context, snackbarHostState, scope)
), },
modifier = Modifier onDeleteClick = {
.fillMaxWidth() if (deleteHost(item)) {
.padding(start = 10.dp, top = 25.dp, end = 10.dp), allLists = getAllLists()
) { activeLists = getActiveLists()
Row( checked.clear()
verticalAlignment = Alignment.CenterVertically, allLists.forEach { list ->
modifier = Modifier checked[list] = activeLists.contains(list)
.fillMaxWidth() }
.padding(16.dp)
) {
Text(
text = item,
modifier = Modifier.weight(1f)
)
Switch(
checked = checked[item] == true,
onCheckedChange = { isChecked ->
checked[item] = isChecked
if (isChecked) {
enableList(item)
} else {
disableList(item)
}
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.pls_restart_snack))
}
}
)
} }
showRestartSnackbar(context, snackbarHostState, scope)
} }
} )
} }
} }
} }
}, },
floatingActionButton = { floatingActionButton = {
FloatingActionButton(modifier = Modifier FloatingMenu(navController, filePickerLauncher)
.size(80.dp, 80.dp),
onClick = {addHost(filePickerLauncher)}) {
Icon(Icons.Default.Add, contentDescription = "Restart")
}
}, },
snackbarHost = {SnackbarHost(hostState = snackbarHostState)} snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) )
} }
fun addHost(launcher: ActivityResultLauncher<Array<String>>) { @Composable
launcher.launch(arrayOf("*/*")) private fun HostItem(item: String, isChecked: Boolean, onCheckedChange: (Boolean) -> Unit, onDeleteClick: () -> Unit) {
} ElevatedCard(
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
fun copySelectedFile(context: Context, uri: Uri?, snackbarHostState: SnackbarHostState, scope: CoroutineScope) { colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
if (Environment.isExternalStorageManager()) { modifier = Modifier
if (uri == null) return .fillMaxWidth()
val contentResolver = context.contentResolver .padding(start = 10.dp, top = 25.dp, end = 10.dp)
val fileName = contentResolver.query(uri, null, null, null, null)?.use { cursor -> ) {
val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) Row(
if (cursor.moveToFirst() && nameIndex != -1) cursor.getString(nameIndex) else "copied_file" verticalAlignment = Alignment.CenterVertically,
} ?: "copied_file" modifier = Modifier
val outputFile = File(getZaprettPath() + "/lists", fileName) .fillMaxWidth()
try { .padding(16.dp)
contentResolver.openInputStream(uri)?.use { inputStream -> ) {
FileOutputStream(outputFile).use { outputStream -> Text(text = item, modifier = Modifier.weight(1f))
inputStream.copyTo(outputStream) 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))
} }
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.pls_restart_snack))
}
} catch (e: IOException) {
e.printStackTrace()
} }
} }
} }
@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("hosts_repo") { 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
addHost(launcher)
},
leadingIcon = {
Icon(Icons.Default.UploadFile, contentDescription = stringResource(R.string.btn_add_host))
}
)
}
}
private fun addHost(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() + "/lists", 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 deleteHost(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

@@ -1,31 +1,15 @@
package com.cherret.zaprett.ui.screens package com.cherret.zaprett.ui.screens
import android.content.Context import android.content.Context
import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.*
import androidx.compose.foundation.layout.Box import androidx.compose.material.icons.Icons
import androidx.compose.foundation.layout.Column import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.foundation.layout.Row import androidx.compose.material3.*
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.runtime.*
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.drawscope.rotate
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
@@ -38,204 +22,206 @@ import com.cherret.zaprett.checkRoot
import com.cherret.zaprett.getStartOnBoot import com.cherret.zaprett.getStartOnBoot
import com.cherret.zaprett.setStartOnBoot import com.cherret.zaprett.setStartOnBoot
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun SettingsScreen() { fun SettingsScreen() {
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) } val sharedPreferences = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
val editor = remember { sharedPreferences.edit() } val editor = remember { sharedPreferences.edit() }
val useModule = remember { mutableStateOf(sharedPreferences.getBoolean("use_module", false)) } val useModule = remember { mutableStateOf(sharedPreferences.getBoolean("use_module", false)) }
val updateOnBoot = remember { mutableStateOf(sharedPreferences.getBoolean("update_on_boot", false)) } val updateOnBoot = remember { mutableStateOf(sharedPreferences.getBoolean("update_on_boot", false)) }
val autoRestart = remember { mutableStateOf(getStartOnBoot()) } val autoRestart = remember { mutableStateOf(getStartOnBoot()) }
val autoUpdate = remember { mutableStateOf(sharedPreferences.getBoolean("auto_update", true)) }
val openNoRootDialog = remember { mutableStateOf(false) } val openNoRootDialog = remember { mutableStateOf(false) }
val openNoModuleDialog = remember { mutableStateOf(false) } val openNoModuleDialog = remember { mutableStateOf(false) }
showNoRootDialog(openNoRootDialog) var showAboutDialog by remember { mutableStateOf(false) }
showNoModuleDialog(openNoModuleDialog)
if (openNoRootDialog.value) {
InfoDialog(
title = stringResource(R.string.error_root_title),
message = stringResource(R.string.error_root_message),
onDismiss = { openNoRootDialog.value = false }
)
}
if (openNoModuleDialog.value) {
InfoDialog(
title = stringResource(R.string.error_no_module_title),
message = stringResource(R.string.error_no_module_message),
onDismiss = { openNoModuleDialog.value = false }
)
}
if (showAboutDialog) {
AboutDialog(context, onDismiss = { showAboutDialog = false })
}
Scaffold( Scaffold(
topBar = { topBar = {
val primaryColor = MaterialTheme.colorScheme.surfaceVariant TopAppBar(
Box(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) { title = {
Canvas(modifier = Modifier.size(100.dp, 50.dp)) { Text(
rotate(degrees = -30f) { text = stringResource(R.string.title_settings),
drawOval( fontSize = 40.sp,
color = primaryColor, fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
size = Size(200f, 140f), )
topLeft = Offset(-30f, -30f) },
) actions = {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = !expanded }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
} }
} DropdownMenu(
Text( expanded = expanded,
text = stringResource(R.string.title_settings), onDismissRequest = { expanded = false }
fontSize = 40.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
)
}
},
content = { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
) {
ElevatedCard(
elevation = CardDefaults.cardElevation(
defaultElevation = 6.dp
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
),
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 25.dp, end = 10.dp),
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) { ) {
Text( DropdownMenuItem(
text = stringResource(R.string.btn_use_root), text = { Text(stringResource(R.string.about_title)) },
modifier = Modifier.weight(1f) onClick = {
) expanded = false
Switch( showAboutDialog = true
checked = useModule.value,
onCheckedChange = { isChecked ->
useModule(
context,
isChecked,
updateOnBoot,
openNoRootDialog,
openNoModuleDialog
) {
if (it) {
useModule.value = isChecked
}
}
} }
) )
} }
Row( },
verticalAlignment = Alignment.CenterVertically, windowInsets = WindowInsets(0)
modifier = Modifier )
.fillMaxWidth() },
.padding(16.dp) content = { paddingValues ->
) { Column(modifier = Modifier.padding(paddingValues)) {
Text( ElevatedCard(
text = stringResource(R.string.btn_update_on_boot), elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
modifier = Modifier.weight(1f) colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant),
) modifier = Modifier
Switch( .fillMaxWidth()
checked = updateOnBoot.value, .padding(horizontal = 10.dp, vertical = 25.dp)
onCheckedChange = { updateOnBoot.value = it; editor.putBoolean("update_on_boot", it).apply()} ) {
) SettingsItem(
} title = stringResource(R.string.btn_use_root),
Row( checked = useModule.value,
verticalAlignment = Alignment.CenterVertically, onCheckedChange = { isChecked ->
modifier = Modifier useModule(
.fillMaxWidth() context = context,
.padding(16.dp) checked = isChecked,
) { updateOnBoot = updateOnBoot,
Text( openNoRootDialog = openNoRootDialog,
text = stringResource(R.string.btn_autorestart), openNoModuleDialog = openNoModuleDialog
modifier = Modifier.weight(1f) ) { success ->
) if (success) useModule.value = isChecked
Switch( }
checked = autoRestart.value, }
onCheckedChange = { if (autoRestart(context, it)) autoRestart.value = it;} )
) SettingsItem(
} title = stringResource(R.string.btn_update_on_boot),
checked = updateOnBoot.value,
onCheckedChange = {
updateOnBoot.value = it
editor.putBoolean("update_on_boot", it).apply()
}
)
SettingsItem(
title = stringResource(R.string.btn_autorestart),
checked = autoRestart.value,
onCheckedChange = {
if (handleAutoRestart(context, it)) autoRestart.value = it
}
)
SettingsItem(
title = stringResource(R.string.btn_autoupdate),
checked = autoUpdate.value,
onCheckedChange = {
autoUpdate.value = it
editor.putBoolean("auto_update", it).apply()
}
)
} }
} }
} }
) )
} }
fun useModule(context: Context, checked: Boolean, updateOnBoot: MutableState<Boolean>, openNoRootDialog: MutableState<Boolean>, openNoModuleDialog: MutableState<Boolean>, callback: (Boolean) -> Unit): Boolean { @Composable
private fun SettingsItem(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = title,
modifier = Modifier.weight(1f)
)
Switch(
checked = checked,
onCheckedChange = onCheckedChange
)
}
}
private fun useModule(context: Context, checked: Boolean, updateOnBoot: MutableState<Boolean>, openNoRootDialog: MutableState<Boolean>, openNoModuleDialog: MutableState<Boolean>, callback: (Boolean) -> Unit) {
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val editor = sharedPreferences.edit() val editor = sharedPreferences.edit()
if (checked) { if (checked) {
checkRoot { checkRoot { hasRoot ->
if (it) { if (hasRoot) {
checkModuleInstallation { checkModuleInstallation { hasModule ->
if (it) { if (hasModule) {
editor.putBoolean("use_module", true).putBoolean("update_on_boot", true).apply() editor.putBoolean("use_module", true)
.putBoolean("update_on_boot", true)
.apply()
updateOnBoot.value = true updateOnBoot.value = true
callback(true) callback(true)
} } else {
else {
openNoModuleDialog.value = true openNoModuleDialog.value = true
} }
} }
} } else {
else {
openNoRootDialog.value = true openNoRootDialog.value = true
} }
} }
} } else {
else { editor.putBoolean("use_module", false)
editor.putBoolean("use_module", false).putBoolean("update_on_boot", false) .putBoolean("update_on_boot", false)
.apply() .apply()
return true updateOnBoot.value = false
callback(true)
} }
return false
} }
fun autoRestart(context: Context, checked: Boolean): Boolean { private fun handleAutoRestart(context: Context, checked: Boolean): Boolean {
if (context.getSharedPreferences("settings", Context.MODE_PRIVATE).getBoolean("use_module", false)) { val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
return if (sharedPreferences.getBoolean("use_module", false)) {
setStartOnBoot(checked) setStartOnBoot(checked)
return true true
} } else {
return false false
}
@Composable
fun showNoRootDialog(openDialog: MutableState<Boolean>) {
if (openDialog.value) {
AlertDialog(
title = {
Text(text = stringResource(R.string.error_root_title))
},
text = {
Text(text = stringResource(R.string.error_root_message))
},
onDismissRequest = {
openDialog.value = false
},
confirmButton = {
TextButton(
onClick = {
openDialog.value = false
}
) {
Text(stringResource(R.string.btn_continue))
}
},
)
} }
} }
@Composable @Composable
fun showNoModuleDialog(openDialog: MutableState<Boolean>) { private fun InfoDialog(title: String, message: String, onDismiss: () -> Unit) {
if (openDialog.value) { AlertDialog(
AlertDialog( title = { Text(text = title) },
title = { text = { Text(text = message) },
Text(text = stringResource(R.string.error_no_module_title)) onDismissRequest = onDismiss,
}, confirmButton = {
text = { TextButton(onClick = onDismiss) {
Text(text = stringResource(R.string.error_no_module_message)) Text(stringResource(R.string.btn_continue))
}, }
onDismissRequest = { }
openDialog.value = false )
}, }
confirmButton = {
TextButton( @Composable
onClick = { private fun AboutDialog(context: Context, onDismiss: () -> Unit) {
openDialog.value = false AlertDialog(
} title = { Text(text = stringResource(R.string.about_title)) },
) { icon = {Icon(painterResource(R.drawable.ic_launcher_monochrome), contentDescription = stringResource(R.string.app_name), modifier = Modifier
Text(stringResource(R.string.btn_continue)) .size(64.dp))},
} text = { Text(text = stringResource(R.string.about_text, context.packageManager.getPackageInfo(context.packageName, 0).versionName.toString())) },
}, onDismissRequest = onDismiss,
) confirmButton = { }
} )
} }

View File

@@ -1,6 +1,5 @@
package com.cherret.zaprett.ui.theme package com.cherret.zaprett.ui.theme
import android.app.Activity
import android.os.Build import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme

View File

@@ -2,33 +2,57 @@
<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_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_dismiss">Отмена</string>
<string name="text_welcome">Привет в zaprett! Это приложение предназначено для обхода цензуры и иных блокировок. Для полноценной работоспособности необходимо установить Magisk модуль.</string> <string name="text_welcome">Привет в zaprett! Это приложение предназначено для обхода цензуры и иных блокировок. Для полноценной работоспособности необходимо установить Magisk модуль.</string>
<string name="btn_use_root">Использовать модуль</string> <string name="btn_use_root">Использовать модуль</string>
<string name="error_root_title">Root не получен</string> <string name="error_root_title">Root не получен</string>
<string name="error_root_message">Не получилось получить root доступ. Дайте права root для использования модуля Magisk</string> <string name="error_root_message">Не получилось получить root доступ. Дайте права root для использования модуля Magisk</string>
<string name="error_no_module_title">Модуль не установлен</string> <string name="error_no_module_title">Модуль не установлен</string>
<string name="error_no_module_message">Magisk модуль zaprett не найден. Пожалуйста, установите его</string> <string name="error_no_module_message">Magisk модуль zaprett не найден. Пожалуйста, установите его</string>
<string name="snack_reload">Перезагружаем zaprett...</string> <string name="snack_reload">Перезагружаем zaprett</string>
<string name="snack_module_disabled">Модуль Magisk отключен! Не получилось выполнить действие</string> <string name="snack_module_disabled">Модуль Magisk отключен! Не получилось выполнить действие</string>
<string name="title_strategy">Стратегия</string> <string name="title_strategy">Стратегия</string>
<string name="status_not_availible">Состояние zaprett неизвестно. Нажми для обновления</string> <string name="status_not_availible">Состояние zaprett неизвестно. Нажми для обновления</string>
<string name="status_enabled">Сервис zaprett работает. Нажми для обновления состояния</string> <string name="status_enabled">Сервис zaprett работает. Нажми для обновления состояния</string>
<string name="status_disabled">Сервис zaprett не работает. Нажми для обновления состояния</string> <string name="status_disabled">Сервис zaprett не работает. Нажми для обновления состояния</string>
<string name="status_crashed">Сервис zaprett вероятно крашнулся. Нажмите кнопку перезапуска ниже</string> <string name="status_crashed">Сервис zaprett вероятно крашнулся. Нажмите кнопку перезапуска ниже</string>
<string name="update_available">Доступно новое обновление!</string>
<string name="btn_update_on_boot">Обновлять статус при запуске Главной страницы</string> <string name="btn_update_on_boot">Обновлять статус при запуске Главной страницы</string>
<string name="btn_start_service">Запустить сервис</string> <string name="btn_start_service">Запустить сервис</string>
<string name="btn_stop_service">Остановить сервис</string> <string name="btn_stop_service">Остановить сервис</string>
<string name="btn_restart_service">Перезапустить сервис</string> <string name="btn_restart_service">Перезапустить сервис</string>
<string name="btn_remove_host">Удалить</string>
<string name="btn_download_host">Скачать</string>
<string name="btn_add_host">Добавить</string>
<string name="btn_back">Назад</string>
<string name="btn_install_host">Установить</string>
<string name="btn_update_host">Обновить</string>
<string name="btn_installing_host">Установка</string>
<string name="btn_updating_host">Обновление</string>
<string name="btn_installed_host">Установленно</string>
<string name="btn_autorestart">Переодически перезапускать сервис</string> <string name="btn_autorestart">Переодически перезапускать сервис</string>
<string name="btn_autoupdate">Авто обновление</string>
<string name="alert_version">Версия</string>
<string name="alert_changelog">Список изменений</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>
<string name="snack_stopping_service">Останавливаем сервис...</string> <string name="snack_stopping_service">Останавливаем сервис</string>
<string name="error_no_storage_title">Нет разрешения</string> <string name="error_no_storage_title">Нет разрешения</string>
<string name="error_no_storage_message">Для правильной работы приложения необходимо разрешение на доступ к хранилищу</string> <string name="error_no_storage_message">Для правильной работы приложения необходимо разрешение на доступ к хранилищу</string>
<string name="btn_show_full_path">Показывать полный путь к листу</string> <string name="btn_show_full_path">Показывать полный путь к листу</string>
<string name="pls_reboot_snack">Перезагрузите устройство для вступления изменений в силу</string> <string name="pls_reboot_snack">Перезагрузите устройство для вступления изменений в силу</string>
<string name="pls_restart_snack">Перезапустите zaprett для вступления изменений в силу</string> <string name="pls_restart_snack">Перезапустите zaprett для вступления изменений в силу</string>
<string name="qs_name">Zaprett</string>
<string name="qs_starting">Запуск…</string>
<string name="qs_stopping">Остановка…</string>
<string name="qs_not_available">Не доступно</string>
<string name="qs_working">Работает</string>
<string name="qs_not_working">Не работает</string>
<string name="about_title">О приложении</string>
<string name="about_text">Zaprett app От Cherret\nВерсия: %1$s</string>
</resources> </resources>

View File

@@ -2,28 +2,44 @@
<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">Hosts Repo</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_dismiss">Dismiss</string>
<string name="text_welcome">Hello to zaprett! This application is designed to bypass censorship and other blockages. For full functionality you need to install Magisk module.</string> <string name="text_welcome">Hello to zaprett! This application is designed to bypass censorship and other blockages. For full functionality you need to install Magisk module.</string>
<string name="btn_use_root">Use module</string> <string name="btn_use_root">Use module</string>
<string name="error_root_title">Can\'t get root</string> <string name="error_root_title">Can\'t get root</string>
<string name="error_root_message">Couldn\'t get root access. Give root access to use the Magisk module</string> <string name="error_root_message">Couldn\'t get root access. Give root access to use the Magisk module</string>
<string name="error_no_module_title">Module is not installed</string> <string name="error_no_module_title">Module is not installed</string>
<string name="error_no_module_message">Magisk module zaprett wasn\'t found. Please install it</string> <string name="error_no_module_message">Magisk module zaprett wasn\'t found. Please install it</string>
<string name="snack_reload">Reloading zaprett...</string> <string name="snack_reload">Reloading zaprett</string>
<string name="snack_module_disabled">Magisk module disabled! Cant\'t execute action</string> <string name="snack_module_disabled">Magisk module disabled! Cant\'t execute action</string>
<string name="title_strategy">Strategy</string> <string name="title_strategy">Strategy</string>
<string name="status_not_availible">Status of zaprett is unknown. Tap to update</string> <string name="status_not_availible">Status of zaprett is unknown. Tap to update</string>
<string name="status_enabled">zaprett service is working. Tap to update</string> <string name="status_enabled">zaprett service is working. Tap to update</string>
<string name="status_disabled">zaprett service disabled.</string> <string name="status_disabled">zaprett service disabled.</string>
<string name="status_crashed">zaprett service crashed. Tap restart button below</string> <string name="status_crashed">zaprett service crashed. Tap restart button below</string>
<string name="update_available">New update available!</string>
<string name="btn_update_on_boot">Update the status when the Home page is launched</string> <string name="btn_update_on_boot">Update the status when the Home page is launched</string>
<string name="btn_start_service">Start service</string> <string name="btn_start_service">Start service</string>
<string name="btn_stop_service">Stop service</string> <string name="btn_stop_service">Stop service</string>
<string name="btn_restart_service">Restart service</string> <string name="btn_restart_service">Restart service</string>
<string name="btn_remove_host">Remove</string>
<string name="btn_download_host">Download</string>
<string name="btn_add_host">Add</string>
<string name="btn_back">Back</string>
<string name="btn_install_host">Install</string>
<string name="btn_update_host">Update</string>
<string name="btn_installing_host">Installing</string>
<string name="btn_updating_host">Updating</string>
<string name="btn_installed_host">Installed</string>
<string name="btn_autorestart">Restart service periodicaly</string> <string name="btn_autorestart">Restart service periodicaly</string>
<string name="btn_autoupdate">Autoupdate</string>
<string name="alert_version">Version</string>
<string name="alert_changelog">Changelog</string>
<string name="snack_already_started">Service already started.</string> <string name="snack_already_started">Service already started.</string>
<string name="snack_starting_service">Starting service...</string> <string name="snack_starting_service">Starting service</string>
<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>
@@ -31,4 +47,12 @@
<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>
<string name="qs_name">Zaprett</string>
<string name="qs_starting">Starting…</string>
<string name="qs_stopping">Stopping…</string>
<string name="qs_not_available">Not available</string>
<string name="qs_working">Working</string>
<string name="qs_not_working">Not working</string>
<string name="about_title">About app</string>
<string name="about_text">Zaprett App By Cherret\nVersion: %1$s</string>
</resources> </resources>

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-path name="downloads" path="Download/" />
<external-files-path name="apk" path="zaprett/" />
</paths>

View File

@@ -3,4 +3,6 @@ plugins {
alias(libs.plugins.android.application) apply false alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.android) apply false alias(libs.plugins.kotlin.android) apply false
alias(libs.plugins.kotlin.compose) apply false alias(libs.plugins.kotlin.compose) apply false
id("com.google.gms.google-services") version "4.4.2" apply false
id("com.google.firebase.crashlytics") version "3.0.3" apply false
} }

View File

@@ -1,2 +1 @@
Исправление ошибок Кнопка в QS
Система обнновлений

View File

@@ -1,6 +1,6 @@
{ {
"version": "1.2", "version": "1.6",
"versionCode": 3, "versionCode": 7,
"downloadUrl": "https://github.com/CherretGit/zaprett-app/releases/download/1.2.0/app-release.apk", "downloadUrl": "https://github.com/CherretGit/zaprett-app/releases/download/1_6_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"
} }