95 Commits
1.2.0 ... 2_2

Author SHA1 Message Date
egor-white
c8c3b6de96 change ver 2025-07-07 12:25:46 +03:00
egor-white
b832be9556 add icon to status card 2025-07-07 12:22:38 +03:00
egor-white
817f3a0e7d move setupProxy checks to HomeViewModel.kt 2025-07-07 11:18:20 +03:00
egor-white
7d25f7e600 fix startVpn crashes 2025-07-06 20:48:35 +03:00
egor-white
8dcb4d1997 fix startVpn crashes 2025-07-06 12:34:39 +03:00
egor-white
ba2cf523bd Merge remote-tracking branch 'origin/main' 2025-07-05 14:35:51 +03:00
egor-white
c268ee8ccd get install unknown apps permission before loading, add module installation check on up startup 2025-07-05 14:30:23 +03:00
egor-white
72905f4180 change navbar icons 2025-07-05 13:19:38 +03:00
egor-white
a7bb250a64 move utils to com.cherret.zaprett.utils 2025-07-05 12:57:44 +03:00
CherretGit
cf15ed7559 Update workflow.yml 2025-07-04 18:38:21 +07:00
egor-white
77f6135f6a update ByeDpiVpnService.kt 2025-07-04 14:27:20 +03:00
egor-white
1997bb8403 Merge remote-tracking branch 'origin/main' 2025-07-04 14:17:03 +03:00
CherretGit
b266976a08 Update workflow.yml 2025-07-04 18:06:13 +07:00
egor-white
b5d9ec6f19 remove unused imports 2025-07-04 14:05:48 +03:00
egor-white
b0a4c3d2f5 move byedpi classes to package org.cherret.zaprett.byedpi 2025-07-04 14:04:48 +03:00
egor-white
8f2e7030d8 Update update.json 2025-07-04 12:23:25 +03:00
egor-white
aeb1c6123b Update changelog.md 2025-07-04 12:22:57 +03:00
egor-white
5a423f39c0 Update workflow.yml 2025-07-04 12:08:32 +03:00
egor-white
efeab387af change ver 2025-07-04 11:54:07 +03:00
egor-white
747a4b2052 bug fix 2025-07-04 11:53:16 +03:00
egor-white
6c6eeefcf1 add icon and module info card 2025-07-03 21:00:45 +03:00
CherretGit
0983262919 Update README.md 2025-06-30 12:31:03 +07:00
CherretGit
078c0085bb Update README.md 2025-06-30 12:30:13 +07:00
CherretGit
e87a0434de Update update.json 2025-06-30 01:16:34 +07:00
CherretGit
57d56cb5f2 Update changelog.md 2025-06-30 01:15:29 +07:00
CherretGit
26dfba1dda ciadpi version 2025-06-30 01:01:14 +07:00
CherretGit
e59a4ad75c Merge remote-tracking branch 'origin/main' 2025-06-30 00:48:41 +07:00
CherretGit
df38f29b55 remove unused imports 2025-06-30 00:47:10 +07:00
CherretGit
693f93e297 non root and etc 2025-06-30 00:42:04 +07:00
egor-white
7dff7ac36a update readmme 2025-06-21 20:25:01 +00:00
CherretGit
45a6e10fb5 Update README.md 2025-06-20 01:31:01 +07:00
CherretGit
353bb169f3 Merge remote-tracking branch 'origin/main' 2025-06-03 21:07:46 +07:00
CherretGit
aba5d650d8 Refactor: migrate to MVVM architecture 2025-06-03 21:05:18 +07:00
CherretGit
6b0e86a5cf Update README.md 2025-05-10 21:08:35 +07:00
CherretGit
67e14b32be Add files via upload 2025-05-10 21:06:31 +07:00
CherretGit
f1f3963137 Update update.json 2025-05-10 21:04:32 +07:00
CherretGit
c54ee5c22d Update changelog.md 2025-05-10 21:04:27 +07:00
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
Cherret
de65eaea5a Merge remote-tracking branch 'origin/main' 2025-05-01 20:23:16 +07:00
Cherret
e5f82030fd update settings 2025-05-01 20:22:11 +07:00
Cherret
10eeb2c9d5 update settings 2025-05-01 20:20:16 +07:00
CherretGit
cb475ac8c9 Update README.md 2025-04-29 09:21:30 +07:00
CherretGit
66ff70e728 Update update.json 2025-04-28 16:37:58 +07:00
CherretGit
2aa26a9b35 Update update.json 2025-04-28 00:17:31 +07:00
CherretGit
9a3e8d8cee Update changelog.md 2025-04-28 00:17:14 +07:00
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
61 changed files with 4320 additions and 998 deletions

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

@@ -0,0 +1,69 @@
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: Setup Git submodules
run: git submodule update --init --recursive
- 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

6
.gitmodules vendored Normal file
View File

@@ -0,0 +1,6 @@
[submodule "app/src/main/cpp/byedpi"]
path = app/src/main/cpp/byedpi
url = https://github.com/hufrea/byedpi
[submodule "app/src/main/jni/hev-socks5-tunnel"]
path = app/src/main/jni/hev-socks5-tunnel
url = https://github.com/heiher/hev-socks5-tunnel

6
.idea/appInsightsSettings.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="selectedTabId" value="Android Vitals" />
</component>
</project>

View File

@@ -1,5 +1,40 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="" withSubpackages="true" static="false" module="true" />
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
</JetCodeStyleSettings>

View File

@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-03-24T08:57:47.584581613Z">
<DropdownSelection timestamp="2025-06-22T07:39:53.690794081Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/dimap/.var/app/com.google.AndroidStudio/config/.android/avd/Medium_Phone.avd" />
<DeviceId pluginId="Default" identifier="serial=10.0.0.189:40463;connection=ffec9c3f" />
</handle>
</Target>
</DropdownSelection>

2
.idea/misc.xml generated
View File

@@ -1,6 +1,6 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">

6
.idea/vcs.xml generated
View File

@@ -2,5 +2,11 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/cpp/byedpi" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/jni/hev-socks5-tunnel" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/jni/hev-socks5-tunnel/src/core" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/jni/hev-socks5-tunnel/third-part/hev-task-system" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/jni/hev-socks5-tunnel/third-part/lwip" vcs="Git" />
<mapping directory="$PROJECT_DIR$/app/src/main/jni/hev-socks5-tunnel/third-part/yaml" vcs="Git" />
</component>
</project>

View File

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

View File

@@ -2,6 +2,9 @@ plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.compose)
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
kotlin("plugin.serialization") version "2.1.20"
}
android {
@@ -12,15 +15,24 @@ android {
applicationId = "com.cherret.zaprett"
minSdk = 30
targetSdk = 35
versionCode = 2
versionName = "1.1"
versionCode = 14
versionName = "2.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
buildTypes {
release {
isMinifyEnabled = false
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
@@ -36,9 +48,33 @@ android {
}
buildFeatures {
compose = true
buildConfig = true
}
}
tasks.register<Exec>("runNdkBuild") {
group = "build"
val ndkDir = android.ndkDirectory
executable = if (System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) {
"$ndkDir\\ndk-build.cmd"
} else {
"$ndkDir/ndk-build"
}
setArgs(listOf(
"NDK_PROJECT_PATH=build/intermediates/ndkBuild",
"NDK_LIBS_OUT=src/main/jniLibs",
"APP_BUILD_SCRIPT=src/main/jni/Android.mk",
"NDK_APPLICATION_MK=src/main/jni/Application.mk"
))
println("Command: $commandLine")
}
tasks.preBuild {
dependsOn("runNdkBuild")
}
dependencies {
implementation("androidx.compose.material3:material3:1.3.1")
implementation("androidx.compose.material3:material3-window-size-class:1.3.1")
@@ -46,6 +82,12 @@ dependencies {
implementation("androidx.navigation:navigation-compose:2.8.9")
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation ("com.github.topjohnwu.libsu:core:6.0.0")
implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.14")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
implementation(platform("com.google.firebase:firebase-bom:33.12.0"))
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-crashlytics")
implementation("androidx.fragment:fragment-compose:1.8.8")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
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

@@ -18,4 +18,10 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
#-renamesourcefileattribute SourceFile
-keep class com.cherret.zaprett.byedpi.TProxyService {
native long[] TProxyGetStats();
native void TProxyStartService(java.lang.String, int);
native void TProxyStopService();
}

View File

@@ -7,6 +7,12 @@
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
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"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -17,6 +23,16 @@
android:supportsRtl="true"
android:theme="@style/Theme.Zaprett"
tools:targetApi="31">
<meta-data android:name="firebase_analytics_collection_enabled" android:value="false" />
<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
android:name=".MainActivity"
android:exported="true"
@@ -28,6 +44,25 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<service
android:name=".utils.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>
<service
android:name=".byedpi.ByeDpiVpnService"
android:exported="true"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="specialUse">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
</application>
</manifest>

View File

@@ -0,0 +1,14 @@
cmake_minimum_required(VERSION 3.22.1)
project(byedpi_native)
file(GLOB BYE_DPI_SRC byedpi/*.c)
list(REMOVE_ITEM BYE_DPI_SRC ${CMAKE_CURRENT_SOURCE_DIR}/byedpi/win_service.c)
add_library(byedpi SHARED ${BYE_DPI_SRC} native-lib.c utils.c)
target_include_directories(byedpi PRIVATE byedpi)
target_compile_options(byedpi PRIVATE -std=c99 -O2 -Wall -Wno-unused -Wextra -Wno-unused-parameter -pedantic)
target_compile_definitions(byedpi PRIVATE ANDROID_APP)
target_link_libraries(byedpi PRIVATE android log)

83
app/src/main/cpp/main.h Normal file
View File

@@ -0,0 +1,83 @@
#define VERSION "17.1"
union sockaddr_u;
int get_default_ttl(void);
int get_addr(const char *str, union sockaddr_u *addr);
void *add(void **root, int *n, size_t ss);
void clear_params(void);
char *ftob(const char *str, ssize_t *sl);
char *data_from_str(const char *str, ssize_t *size);
size_t parse_cform(char *buffer, size_t blen, const char *str, size_t slen);
struct mphdr *parse_hosts(char *buffer, size_t size);
struct mphdr *parse_ipset(char *buffer, size_t size);
int parse_offset(struct part *part, const char *str);
static const char help_text[] = {
" -i, --ip, <ip> Listening IP, default 0.0.0.0\n"
" -p, --port <num> Listening port, default 1080\n"
#ifdef DAEMON
" -D, --daemon Daemonize\n"
" -w, --pidfile <filename> Write PID to file\n"
#endif
#ifdef __linux__
" -E, --transparent Transparent proxy mode\n"
#endif
" -c, --max-conn <count> Connection count limit, default 512\n"
" -N, --no-domain Deny domain resolving\n"
" -U, --no-udp Deny UDP association\n"
" -I --conn-ip <ip> Connection binded IP, default ::\n"
" -b, --buf-size <size> Buffer size, default 16384\n"
" -x, --debug <level> Print logs, 0, 1 or 2\n"
" -g, --def-ttl <num> TTL for all outgoing connections\n"
// desync options
#ifdef TCP_FASTOPEN_CONNECT
" -F, --tfo Enable TCP Fast Open\n"
#endif
" -A, --auto <t,r,s,n> Try desync params after this option\n"
" Detect: torst,redirect,ssl_err,none\n"
" -L, --auto-mode <0-3> Mode: 1 - post_resp, 2 - sort, 3 - 1+2\n"
" -u, --cache-ttl <sec> Lifetime of cached desync params for IP\n"
" -y, --cache-dump <file|-> Dump cache to file or stdout\n"
#ifdef TIMEOUT_SUPPORT
" -T, --timeout <sec> Timeout waiting for response, after which trigger auto\n"
#endif
" -K, --proto <t,h,u,i> Protocol whitelist: tls,http,udp,ipv4\n"
" -H, --hosts <file|:str> Hosts whitelist, filename or :string\n"
" -j, --ipset <file|:str> IP whitelist\n"
" -V, --pf <port[-portr]> Ports range whitelist\n"
" -R, --round <num[-numr]> Number of request to which desync will be applied\n"
" -s, --split <pos_t> Position format: offset[:repeats:skip][+flag1[flag2]]\n"
" Flags: +s - SNI offset, +h - HTTP host offset, +n - null\n"
" Additional flags: +e - end, +m - middle\n"
" -d, --disorder <pos_t> Split and send reverse order\n"
" -o, --oob <pos_t> Split and send as OOB data\n"
" -q, --disoob <pos_t> Split and send reverse order as OOB data\n"
#ifdef FAKE_SUPPORT
" -f, --fake <pos_t> Split and send fake packet\n"
#ifdef __linux__
" -S, --md5sig Add MD5 Signature option for fake packets\n"
#endif
" -n, --fake-sni <str> Change SNI in fake\n"
" Replaced: ? - rand let, # - rand num, * - rand let/num\n"
#endif
" -t, --ttl <num> TTL of fake packets, default 8\n"
" -O, --fake-offset <pos_t> Fake data start offset\n"
" -l, --fake-data <f|:str> Set custom fake packet\n"
" -Q, --fake-tls-mod <r,o> Modify fake TLS CH: rand,orig\n"
" -e, --oob-data <char> Set custom OOB data\n"
" -M, --mod-http <h,d,r> Modify HTTP: hcsmix,dcsmix,rmspace\n"
" -r, --tlsrec <pos_t> Make TLS record at position\n"
" -a, --udp-fake <count> UDP fakes count, default 0\n"
#ifdef __linux__
" -Y, --drop-sack Drop packets with SACK extension\n"
#endif
};

View File

@@ -0,0 +1,100 @@
#include <string.h>
#include <jni.h>
#include <android/log.h>
#include <malloc.h>
#include "byedpi/error.h"
#include "byedpi/proxy.h"
#include "utils.h"
static int g_proxy_fd = -1;
JNIEXPORT jint JNI_OnLoad(
__attribute__((unused)) JavaVM *vm,
__attribute__((unused)) void *reserved) {
default_params = params;
return JNI_VERSION_1_6;
}
JNIEXPORT jint JNICALL
Java_com_cherret_zaprett_byedpi_NativeBridge_jniCreateSocket(
JNIEnv *env,
__attribute__((unused)) jobject thiz,
jobjectArray args) {
if (g_proxy_fd != -1) {
LOG(LOG_S, "proxy already running, fd: %d", g_proxy_fd);
return -1;
}
int argc = (*env)->GetArrayLength(env, args);
char *argv[argc];
for (int i = 0; i < argc; i++) {
jstring arg = (jstring) (*env)->GetObjectArrayElement(env, args, i);
const char *arg_str = (*env)->GetStringUTFChars(env, arg, 0);
argv[i] = strdup(arg_str);
(*env)->ReleaseStringUTFChars(env, arg, arg_str);
}
int res = parse_args(argc, argv);
if (res < 0) {
uniperror("parse_args");
return -1;
}
int fd = listen_socket((union sockaddr_u *)&params.laddr);
for (int i = 0; i < argc; i++) {
free(argv[i]);
}
if (fd < 0) {
uniperror("listen_socket");
return -1;
}
g_proxy_fd = fd;
LOG(LOG_S, "listen_socket, fd: %d", fd);
return fd;
}
JNIEXPORT jint JNICALL
Java_com_cherret_zaprett_byedpi_NativeBridge_jniStartProxy(
__attribute__((unused)) JNIEnv *env,
__attribute__((unused)) jobject thiz) {
LOG(LOG_S, "start_proxy, fd: %d", g_proxy_fd);
if (start_event_loop(g_proxy_fd) < 0) {
uniperror("event_loop");
return get_e();
}
return 0;
}
JNIEXPORT jint JNICALL
Java_com_cherret_zaprett_byedpi_NativeBridge_jniStopProxy(
__attribute__((unused)) JNIEnv *env,
__attribute__((unused)) jobject thiz) {
LOG(LOG_S, "stop_proxy, fd: %d", g_proxy_fd);
if (g_proxy_fd < 0) {
LOG(LOG_S, "proxy is not running, fd: %d", g_proxy_fd);
return 0;
}
reset_params();
int res = shutdown(g_proxy_fd, SHUT_RDWR);
g_proxy_fd = -1;
if (res < 0) {
uniperror("shutdown");
return get_e();
}
return 0;
}

594
app/src/main/cpp/utils.c Normal file
View File

@@ -0,0 +1,594 @@
#include <getopt.h>
#include <stdlib.h>
#include <string.h>
#include "byedpi/params.h"
#include "error.h"
#include "main.h"
#include "packets.h"
#include "utils.h"
struct params default_params;
extern const struct option options[46];
void reset_params(void) {
clear_params();
params = default_params;
}
static struct desync_params *add_group(struct desync_params *prev)
{
struct desync_params *dp = calloc(1, sizeof(*prev));
if (!dp) {
return 0;
}
if (prev) {
dp->prev = prev;
prev->next = dp;
}
params.dp_n++;
return dp;
}
int parse_args(int argc, char **argv)
{
int optc = sizeof(options)/sizeof(*options);
for (int i = 0, e = optc; i < e; i++)
optc += options[i].has_arg;
char opt[optc + 1];
opt[optc] = 0;
for (int i = 0, o = 0; o < optc; i++, o++) {
opt[o] = options[i].val;
for (int c = options[i].has_arg; c; c--) {
o++;
opt[o] = ':';
}
}
//
params.laddr.in.sin_port = htons(1080);
if (!ipv6_support()) {
params.baddr.sa.sa_family = AF_INET;
}
const char *pid_file = 0;
bool daemonize = 0;
int rez;
int invalid = 0;
long val = 0;
char *end = 0;
bool all_limited = 1;
int curr_optind = 1;
struct desync_params *dp = add_group(0);
if (!dp) {
reset_params();
return -1;
}
params.dp = dp;
while (!invalid && (rez = getopt_long(
argc, argv, opt, options, 0)) != -1) {
switch (rez) {
case 'N':
params.resolve = 0;
break;
case 'X':
params.ipv6 = 0;
break;
case 'U':
params.udp = 0;
break;
case 'G':
params.http_connect = 1;
break;
#ifdef __linux__
case 'E':
params.transparent = 1;
break;
#endif
#ifdef DAEMON
case 'D':
daemonize = 1;
break;
case 'w':
pid_file = optarg;
break;
#endif
case 'h':
printf("%s", help_text);
reset_params();
return 0;
case 'v':
printf("%s\n", VERSION);
reset_params();
return 0;
case 'i':
if (get_addr(optarg, &params.laddr) < 0)
invalid = 1;
break;
case 'p':
val = strtol(optarg, &end, 0);
if (val <= 0 || val > 0xffff || *end)
invalid = 1;
else
params.laddr.in.sin_port = htons(val);
break;
case 'I':
if (get_addr(optarg, &params.baddr) < 0)
invalid = 1;
break;
case 'b':
val = strtol(optarg, &end, 0);
if (val <= 0 || val > INT_MAX/4 || *end)
invalid = 1;
else
params.bfsize = val;
break;
case 'c':
val = strtol(optarg, &end, 0);
if (val <= 0 || val >= (0xffff/2) || *end)
invalid = 1;
else
params.max_open = val;
break;
case 'x': //
params.debug = strtol(optarg, 0, 0);
if (params.debug < 0)
invalid = 1;
break;
case 'y': //
params.cache_file = optarg;
break;
// desync options
case 'F':
params.tfo = 1;
break;
case 'L':
end = optarg;
while (end && !invalid) {
switch (*end) {
case '0':
break;
case '1':
case 'p':
params.auto_level |= AUTO_POST;
break;
case '2':
case 's':
params.auto_level |= AUTO_SORT;
break;
case 'r':
params.auto_level = 0;
break;
case '3':
params.auto_level |= (AUTO_POST | AUTO_SORT);
break;
default:
invalid = 1;
continue;
}
end = strchr(end, ',');
if (end) end++;
}
break;
case 'A':
if (optind < curr_optind) {
optind = curr_optind;
continue;
}
if (!(dp->hosts || dp->proto || dp->pf[0] || dp->detect || dp->ipset)) {
all_limited = 0;
}
dp = add_group(dp);
if (!dp) {
reset_params();
return -1;
}
end = optarg;
while (end && !invalid) {
switch (*end) {
case 't':
dp->detect |= DETECT_TORST;
break;
case 'r':
dp->detect |= DETECT_HTTP_LOCAT;
break;
case 'a':
case 's':
dp->detect |= DETECT_TLS_ERR;
break;
case 'n':
break;
default:
invalid = 1;
continue;
}
end = strchr(end, ',');
if (end) end++;
}
if (dp->detect) {
params.auto_level |= AUTO_RECONN;
}
dp->_optind = optind;
break;
case 'B':
if (optind < curr_optind) {
continue;
}
if (*optarg == 'i') {
dp->pf[0] = htons(1);
continue;
}
val = strtol(optarg, &end, 0);
struct desync_params *itdp = params.dp;
while (itdp && itdp->id != val - 1) {
itdp = itdp->next;
}
if (!itdp)
invalid = 1;
else {
curr_optind = optind;
optind = itdp->_optind;
}
break;
case 'u':
val = strtol(optarg, &end, 0);
if (val <= 0 || *end)
invalid = 1;
else
params.cache_ttl = val;
break;
case 'T':;
#ifdef __linux__
float f = strtof(optarg, &end);
val = (long)(f * 1000);
#else
val = strtol(optarg, &end, 0);
#endif
if (val <= 0 || (unsigned long)val > UINT_MAX || *end)
invalid = 1;
else
params.timeout = val;
break;
case 'K':
end = optarg;
while (end && !invalid) {
switch (*end) {
case 't':
dp->proto |= IS_TCP | IS_HTTPS;
break;
case 'h':
dp->proto |= IS_TCP | IS_HTTP;
break;
case 'u':
dp->proto |= IS_UDP;
break;
case 'i':
dp->proto |= IS_IPV4;
break;
default:
invalid = 1;
continue;
}
end = strchr(end, ',');
if (end) end++;
}
break;
case 'H':;
if (dp->file_ptr) {
continue;
}
dp->file_ptr = ftob(optarg, &dp->file_size);
if (!dp->file_ptr) {
uniperror("read/parse");
invalid = 1;
continue;
}
dp->hosts = parse_hosts(dp->file_ptr, dp->file_size);
if (!dp->hosts) {
uniperror("parse_hosts");
reset_params();
return -1;
}
break;
case 'j':;
if (dp->ipset) {
continue;
}
ssize_t size;
char *data = ftob(optarg, &size);
if (!data) {
uniperror("read/parse");
invalid = 1;
continue;
}
dp->ipset = parse_ipset(data, size);
if (!dp->ipset) {
uniperror("parse_ipset");
invalid = 1;
}
free(data);
break;
case 's':
case 'd':
case 'o':
case 'q':
case 'f':
;
struct part *part = add((void *)&dp->parts,
&dp->parts_n, sizeof(struct part));
if (!part) {
reset_params();
return -1;
}
if (parse_offset(part, optarg)) {
invalid = 1;
break;
}
switch (rez) {
case 's': part->m = DESYNC_SPLIT;
break;
case 'd': part->m = DESYNC_DISORDER;
break;
case 'o': part->m = DESYNC_OOB;
break;
case 'q': part->m = DESYNC_DISOOB;
break;
case 'f': part->m = DESYNC_FAKE;
}
break;
case 't':
val = strtol(optarg, &end, 0);
if (val <= 0 || val > 255 || *end)
invalid = 1;
else
dp->ttl = val;
break;
case 'S':
dp->md5sig = 1;
break;
case 'O':
if (parse_offset(&dp->fake_offset, optarg)) {
invalid = 1;
break;
} else dp->fake_offset.m = 1;
break;
case 'Q':
end = optarg;
while (end && !invalid) {
switch (*end) {
case 'r':
dp->fake_mod |= FM_RAND;
break;
case 'o':
dp->fake_mod |= FM_ORIG;
break;
default:
invalid = 1;
continue;
}
end = strchr(end, ',');
if (end) end++;
}
break;
case 'n':;
const char **p = add((void *)&dp->fake_sni_list,
&dp->fake_sni_count, sizeof(optarg));
if (!p) {
invalid = 1;
continue;
}
*p = optarg;
break;
case 'l':
if (dp->fake_data.data) {
continue;
}
dp->fake_data.data = ftob(optarg, &dp->fake_data.size);
if (!dp->fake_data.data) {
uniperror("read/parse");
invalid = 1;
}
break;
case 'e':
val = parse_cform(dp->oob_char, 1, optarg, strlen(optarg));
if (val != 1) {
invalid = 1;
}
else dp->oob_char[1] = 1;
break;
case 'M':
end = optarg;
while (end && !invalid) {
switch (*end) {
case 'r':
dp->mod_http |= MH_SPACE;
break;
case 'h':
dp->mod_http |= MH_HMIX;
break;
case 'd':
dp->mod_http |= MH_DMIX;
break;
default:
invalid = 1;
continue;
}
end = strchr(end, ',');
if (end) end++;
}
break;
case 'r':
part = add((void *)&dp->tlsrec,
&dp->tlsrec_n, sizeof(struct part));
if (!part) {
reset_params();
return -1;
}
if (parse_offset(part, optarg)
|| part->pos > 0xffff) {
invalid = 1;
break;
}
break;
case 'a':
val = strtol(optarg, &end, 0);
if (val < 0 || val > INT_MAX || *end)
invalid = 1;
else
dp->udp_fake_count = val;
break;
case 'V':
val = strtol(optarg, &end, 0);
if (val <= 0 || val > USHRT_MAX)
invalid = 1;
else {
dp->pf[0] = htons(val);
if (*end == '-') {
val = strtol(end + 1, &end, 0);
if (val <= 0 || val > USHRT_MAX)
invalid = 1;
}
if (*end)
invalid = 1;
else
dp->pf[1] = htons(val);
}
break;
case 'R':
val = strtol(optarg, &end, 0);
if (val <= 0 || val > INT_MAX)
invalid = 1;
else {
dp->rounds[0] = val;
if (*end == '-') {
val = strtol(end + 1, &end, 0);
if (val <= 0 || val > INT_MAX)
invalid = 1;
}
if (*end)
invalid = 1;
else
dp->rounds[1] = val;
}
break;
case 'g':
val = strtol(optarg, &end, 0);
if (val <= 0 || val > 255 || *end)
invalid = 1;
else {
params.def_ttl = val;
params.custom_ttl = 1;
}
break;
case 'Y':
dp->drop_sack = 1;
break;
case 'Z':
params.wait_send = 1;
break;
case 'W':
params.await_int = atoi(optarg);
break;
case 'C':
if (get_addr(optarg, &dp->custom_dst_addr) < 0)
invalid = 1;
else
dp->custom_dst = 1;
break;
#ifdef __linux__
case 'P':
params.protect_path = optarg;
break;
#endif
case 0:
break;
case '?':
reset_params();
return -1;
default:
printf("?: %c\n", rez);
reset_params();
return -1;
}
}
if (invalid) {
fprintf(stderr, "invalid value: -%c %s\n", rez, optarg);
reset_params();
return -1;
}
if (all_limited) {
dp = add_group(dp);
if (!dp) {
reset_params();
return -1;
}
}
if ((size_t )params.dp_n > sizeof(dp->bit) * 8) {
LOG(LOG_E, "too many groups!\n");
}
if (params.baddr.sa.sa_family != AF_INET6) {
params.ipv6 = 0;
}
if (!params.def_ttl) {
if ((params.def_ttl = get_default_ttl()) < 1) {
reset_params();
return -1;
}
}
params.mempool = mem_pool(MF_EXTRA, CMP_BYTES);
if (!params.mempool) {
uniperror("mem_pool");
reset_params();
return -1;
}
srand((unsigned int)time(0));
return 0;
}

5
app/src/main/cpp/utils.h Normal file
View File

@@ -0,0 +1,5 @@
extern struct params default_params;
void reset_params(void);
int parse_args(int argc, char **argv);
bool ipv6_support(void);

View File

@@ -1,19 +1,27 @@
package com.cherret.zaprett
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Environment
import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.MultipleStop
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
@@ -23,76 +31,152 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import com.cherret.zaprett.ui.screens.HomeScreen
import com.cherret.zaprett.ui.screens.HostsScreen
import com.cherret.zaprett.ui.screens.SettingsScreen
import androidx.navigation.navArgument
import com.cherret.zaprett.ui.screen.HomeScreen
import com.cherret.zaprett.ui.screen.RepoScreen
import com.cherret.zaprett.ui.screen.HostsScreen
import com.cherret.zaprett.ui.screen.SettingsScreen
import com.cherret.zaprett.ui.screen.StrategyScreen
import com.cherret.zaprett.ui.theme.ZaprettTheme
import com.topjohnwu.superuser.Shell
import com.cherret.zaprett.ui.viewmodel.HomeViewModel
import com.cherret.zaprett.ui.viewmodel.HostRepoViewModel
import com.cherret.zaprett.ui.viewmodel.StrategyRepoViewModel
import com.cherret.zaprett.utils.checkModuleInstallation
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics
sealed class Screen(val route: String, @StringRes val nameResId: Int, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
sealed class Screen(val route: String, @StringRes val nameResId: Int, val icon: ImageVector) {
object home : Screen("home", R.string.title_home, Icons.Default.Home)
object hosts : Screen("hosts", R.string.title_hosts, Icons.Default.Dashboard)
object hosts : Screen("hosts", R.string.title_hosts, Icons.Default.Lan)
object strategies : Screen("strategies", R.string.title_strategies, Icons.Default.MultipleStop)
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("repo?source={source}")
class MainActivity : ComponentActivity() {
private val viewModel: HomeViewModel by viewModels()
private lateinit var notificationPermissionLauncher: ActivityResultLauncher<String>
private lateinit var firebaseAnalytics: FirebaseAnalytics
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
Shell.setDefaultBuilder(Shell.Builder.create()
.setTimeout(10))
vpnPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
viewModel.startVpn()
viewModel.clearVpnPermissionRequest()
}
}
notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> }
firebaseAnalytics = Firebase.analytics
enableEdgeToEdge()
setContent {
ZaprettTheme {
BottomBar()
if (!Environment.isExternalStorageManager()) {
permissionDialog()
val sharedPreferences = remember { getSharedPreferences("settings", MODE_PRIVATE) }
LaunchedEffect(Unit) {
checkModuleInstallation { result ->
if (getSharedPreferences("settings", Context.MODE_PRIVATE).getBoolean("use_module", false) && !result) sharedPreferences.edit {
putBoolean(
"use_module",
false
)
}
}
}
if (sharedPreferences.getBoolean("welcome_dialog", true)) {
welcomeDialog()
var showStoragePermissionDialog by remember { mutableStateOf(!Environment.isExternalStorageManager()) }
var showNotificationPermissionDialog by remember {
mutableStateOf(
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
)
}
var showWelcomeDialog by remember { mutableStateOf(sharedPreferences.getBoolean("welcome_dialog", true)) }
firebaseAnalytics.setAnalyticsCollectionEnabled(sharedPreferences.getBoolean("send_firebase_analytics", true))
BottomBar()
if (showStoragePermissionDialog) {
PermissionDialog(
title = stringResource(R.string.error_no_storage_title),
message = stringResource(R.string.error_no_storage_message),
onConfirm = {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
val uri = Uri.fromParts("package", packageName, null)
intent.data = uri
startActivity(intent)
showStoragePermissionDialog = false
},
onDismiss = { showStoragePermissionDialog = false }
)
}
if (showNotificationPermissionDialog) {
PermissionDialog(
title = stringResource(R.string.notification_permission_title),
message = stringResource(R.string.notification_permission_message),
onConfirm = {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
showNotificationPermissionDialog = false
},
onDismiss = { showNotificationPermissionDialog = false }
)
}
if (showWelcomeDialog) {
WelcomeDialog {
sharedPreferences.edit { putBoolean("welcome_dialog", false) }
showWelcomeDialog = false
}
}
}
}
}
@Composable
fun BottomBar() {
val navController = rememberNavController()
Scaffold(
bottomBar = {
NavigationBar {
val navBackStackEntry = navController.currentBackStackEntryAsState().value
val currentDestination = navBackStackEntry?.destination
topLevelRoutes.forEach { topLevelRoute ->
NavigationBarItem(
icon = {
Icon(
topLevelRoute.icon,
contentDescription = stringResource(id = topLevelRoute.nameResId)
)
},
label = { Text(text = stringResource(id = topLevelRoute.nameResId)) },
selected = currentDestination?.route == topLevelRoute.route,
onClick = {
navController.navigate(topLevelRoute.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
val navBackStackEntry = navController.currentBackStackEntryAsState().value
val currentDestination = navBackStackEntry?.destination
if (currentDestination?.route !in hideNavBar) {
NavigationBar {
topLevelRoutes.forEach { topLevelRoute ->
NavigationBarItem(
icon = {
Icon(
topLevelRoute.icon,
contentDescription = stringResource(id = topLevelRoute.nameResId)
)
},
label = { Text(text = stringResource(id = topLevelRoute.nameResId)) },
selected = currentDestination?.route == topLevelRoute.route,
onClick = {
navController.navigate(topLevelRoute.route) {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
launchSingleTop = true
restoreState = true
}
}
)
)
}
}
}
}
@@ -102,77 +186,53 @@ class MainActivity : ComponentActivity() {
startDestination = Screen.home.route,
Modifier.padding(innerPadding)
) {
composable(Screen.home.route) { HomeScreen() }
composable(Screen.hosts.route) { HostsScreen() }
composable(Screen.home.route) { HomeScreen(viewModel = viewModel, vpnPermissionLauncher) }
composable(Screen.hosts.route) { HostsScreen(navController) }
composable(Screen.strategies.route) { StrategyScreen(navController) }
composable(Screen.settings.route) { SettingsScreen() }
composable(route = "repo?source={source}",arguments = listOf(navArgument("source") {})) { backStackEntry ->
val source = backStackEntry.arguments?.getString("source")
when (source) {
"hosts" -> {
val viewModel: HostRepoViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
RepoScreen(navController, viewModel)
}
"strategies" -> {
val viewModel: StrategyRepoViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
RepoScreen(navController, viewModel)
}
}
}
}
}
}
@Composable
fun welcomeDialog() {
val sharedPreferences =
LocalContext.current.getSharedPreferences("settings", MODE_PRIVATE)
val editor = sharedPreferences.edit()
val openDialog = remember { mutableStateOf(true) }
if (openDialog.value) {
AlertDialog(
title = {
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))
}
},
)
}
fun WelcomeDialog(onDismiss: () -> Unit) {
AlertDialog(
title = { Text(text = stringResource(R.string.app_name)) },
text = { Text(text = stringResource(R.string.text_welcome)) },
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
@Composable
fun permissionDialog() {
val openDialog = remember { mutableStateOf(true) }
if (openDialog.value) {
AlertDialog(
title = {
Text(text = stringResource(R.string.error_no_storage_title))
},
text = {
Text(text = stringResource(R.string.error_no_storage_message))
},
onDismissRequest = {
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
},
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))
}
},
)
}
fun PermissionDialog(title: String, message: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
title = { Text(title) },
text = { Text(message) },
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onConfirm) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
}

View File

@@ -1,172 +0,0 @@
package com.cherret.zaprett
import android.os.Environment
import android.util.Log
import com.topjohnwu.superuser.Shell
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.util.Properties
fun checkRoot(callback: (Boolean) -> Unit) {
Shell.cmd("ls /").submit { result ->
callback(result.isSuccess)
}
}
fun checkModuleInstallation(callback: (Boolean) -> Unit) {
Shell.cmd("zaprett").submit { result ->
callback(result.out.toString().contains("zaprett"))
}
}
fun getStatus(callback: (Boolean) -> Unit) {
Shell.cmd("zaprett status").submit { result ->
callback(result.out.toString().contains("working"))
}
}
fun startService(callback: (Boolean) -> Unit) {
Shell.cmd("zaprett start").submit { result ->
callback(result.isSuccess)
}
}
fun stopService(callback: (Boolean) -> Unit) {
Shell.cmd("zaprett stop").submit { result ->
callback(result.isSuccess)
}
}
fun restartService(callback: (Boolean) -> Unit) {
Shell.cmd("zaprett restart").submit { result ->
callback(result.isSuccess)
}
}
fun getConfigFile(): File {
return File(Environment.getExternalStorageDirectory(), "zaprett/config")
}
fun setStartOnBoot(startOnBoot: Boolean) {
val configFile = getConfigFile()
if (configFile.exists()) {
val props = Properties()
try {
FileInputStream(configFile).use { input ->
props.load(input)
}
props.setProperty("autorestart", startOnBoot.toString())
FileOutputStream(configFile).use { output ->
props.store(output, "Don't place '/' in end of directory! Example: /sdcard")
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
}
fun getStartOnBoot(): Boolean {
val configFile = getConfigFile()
val props = Properties()
return try {
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
props.getProperty("autorestart", "false").toBoolean()
} else {
false
}
} catch (e: IOException) {
false
}
}
fun getZaprettPath(): String {
val props = Properties()
val configFile = getConfigFile()
if (configFile.exists()) {
return try {
FileInputStream(configFile).use { input ->
props.load(input)
}
props.getProperty("zaprettdir", "/sdcard/zaprett")
} catch (e: IOException) {
throw RuntimeException(e)
}
}
return "/sdcard/zaprett"
}
fun getAllLists(): Array<String> {
val listsDir = File("${getZaprettPath()}/lists/")
if (listsDir.exists() && listsDir.isDirectory) {
val onlyNames = listsDir.list() ?: return emptyArray()
return onlyNames.map { "$listsDir/$it" }.toTypedArray()
}
return emptyArray()
}
fun getActiveLists(): Array<String> {
val configFile = File("${getZaprettPath()}/config")
if (configFile.exists()) {
val props = Properties()
return try {
FileInputStream(configFile).use { input ->
props.load(input)
}
val activeLists = props.getProperty("activelists", "")
Log.d("Active lists", activeLists)
if (activeLists.isNotEmpty()) activeLists.split(",").toTypedArray() else emptyArray()
} catch (e: IOException) {
throw RuntimeException(e)
}
}
return emptyArray()
}
fun enableList(path: String) {
val props = Properties()
val configFile = getConfigFile()
try {
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
}
val activeLists = props.getProperty("activelists", "").split(",").toMutableList()
if (path !in activeLists) {
activeLists.add(path)
}
props.setProperty("activelists", activeLists.joinToString(","))
FileOutputStream(configFile).use { output ->
props.store(output, "Don't place '/' in end of directory! Example: /sdcard")
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
fun disableList(path: String) {
val props = Properties()
val configFile = getConfigFile()
try {
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
}
val activeLists = props.getProperty("activelists", "").split(",").toMutableList()
if (path in activeLists) {
activeLists.remove(path)
}
props.setProperty("activelists", activeLists.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

@@ -0,0 +1,189 @@
package com.cherret.zaprett.byedpi
import android.annotation.SuppressLint
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.content.SharedPreferences
import android.net.VpnService
import android.os.ParcelFileDescriptor
import android.util.Log
import android.widget.Toast
import androidx.core.app.NotificationCompat
import com.cherret.zaprett.MainActivity
import com.cherret.zaprett.R
import com.cherret.zaprett.utils.getActiveStrategy
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
class ByeDpiVpnService : VpnService() {
private var vpnInterface: ParcelFileDescriptor? = null
private lateinit var sharedPreferences: SharedPreferences
companion object {
private const val CHANNEL_ID = "zaprett_vpn_channel"
private const val NOTIFICATION_ID = 1
var status: ServiceStatus = ServiceStatus.Disconnected
}
@SuppressLint("ForegroundServiceType")
override fun onCreate() {
super.onCreate()
sharedPreferences = getSharedPreferences("settings", MODE_PRIVATE)
createNotificationChannel()
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
return when (intent?.action) {
"START_VPN" -> {
startForeground(NOTIFICATION_ID, createNotification())
setupProxy()
START_STICKY
}
"STOP_VPN" -> {
stopProxy()
stopSelf()
START_NOT_STICKY
}
else -> {
START_NOT_STICKY
}
}
}
override fun onDestroy() {
super.onDestroy()
vpnInterface?.close()
}
private fun createNotificationChannel() {
val channel = NotificationChannel(
CHANNEL_ID,
getString(R.string.notification_zaprett_proxy),
NotificationManager.IMPORTANCE_LOW
).apply {
description = getString(R.string.notification_zaprett_description)
}
val manager = getSystemService(NotificationManager::class.java)
manager?.createNotificationChannel(channel)
}
private fun createNotification(): Notification {
val notificationIntent = Intent(this, MainActivity::class.java)
val pendingIntent = PendingIntent.getActivity(
this,
0,
notificationIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
return NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(getString(R.string.notification_zaprett_proxy))
.setContentText(getString(R.string.notification_zaprett_description))
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentIntent(pendingIntent)
.addAction(0, getString(R.string.btn_stop_service),
PendingIntent.getService(
this,
0,
Intent(this, ByeDpiVpnService::class.java).setAction("STOP_VPN"),
PendingIntent.FLAG_IMMUTABLE,
)
)
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
.setOngoing(true)
.build()
}
private fun setupProxy() {
try {
startSocksProxy()
startByeDpi()
status = ServiceStatus.Connected
} catch (e: Exception) {
Log.e("proxy", "Failed to start")
status = ServiceStatus.Failed
stopSelf()
}
}
private fun startSocksProxy() {
val dns = sharedPreferences.getString("dns", "8.8.8.8")?: "8.8.8.8"
val ipv6 = sharedPreferences.getBoolean("ipv6", false)
val socksIp = sharedPreferences.getString("ip", "127.0.0.1")?: "127.0.0.1"
val socksPort = sharedPreferences.getString("port", "1080")?: "1080"
val builder = Builder()
builder.setSession(getString(R.string.notification_zaprett_proxy))
.setMtu(1500)
.addAddress("10.10.10.10", 32)
.addDnsServer(dns)
.addRoute("0.0.0.0", 0)
.setMetered(false)
.addDisallowedApplication(applicationContext.packageName)
if (ipv6) {
builder.addAddress("fd00::1", 128)
.addRoute("::", 0)
}
vpnInterface = builder.establish()
val tun2socksConfig = """
| misc:
| task-stack-size: 81920
| socks5:
| mtu: 8500
| address: $socksIp
| port: $socksPort
| udp: udp
""".trimMargin("| ")
val configPath = File.createTempFile("config", "tmp", cacheDir).apply {
writeText(tun2socksConfig)
deleteOnExit()
}
vpnInterface?.fd?.let { fd ->
TProxyService.TProxyStartService(configPath.absolutePath, fd)
}
}
private fun stopProxy() {
try {
vpnInterface?.close()
vpnInterface = null
NativeBridge().stopProxy()
TProxyService.TProxyStopService()
status = ServiceStatus.Disconnected
} catch (e: Exception) {
Log.e("proxy", "error stop proxy")
}
}
private fun startByeDpi() {
val socksIp = sharedPreferences.getString("ip", "127.0.0.1")?: "127.0.0.1"
val socksPort = sharedPreferences.getString("port", "1080")?: "1080"
val listSet = sharedPreferences.getStringSet("lists", emptySet())?: emptySet()
CoroutineScope(Dispatchers.IO).launch {
val args = parseArgs(socksIp, socksPort, getActiveStrategy(sharedPreferences), listSet)
val result = NativeBridge().startProxy(args)
if (result < 0) {
println("Failed to start byedpi proxy")
} else {
println("Byedpi proxy started successfully")
}
}
}
fun parseArgs(ip: String, port: String, rawArgs: List<String>, listSet: Set<String>): Array<String> {
val regex = Regex("""--?\S+(?:=(?:[^"'\s]+|"[^"]*"|'[^']*'))?|[^\s]+""")
val parsedArgs = rawArgs
.flatMap { args -> regex.findAll(args).map { it.value } }
.toMutableList()
if (listSet.isNotEmpty()) {
for (path in listSet) {
parsedArgs.add("--hosts")
parsedArgs.add(path)
}
}
return arrayOf("ciadpi", "--ip", ip, "--port", port) + parsedArgs
}
}

View File

@@ -0,0 +1,21 @@
package com.cherret.zaprett.byedpi
class NativeBridge {
companion object {
init {
System.loadLibrary("byedpi")
}
}
fun startProxy(args: Array<String>): Int {
jniCreateSocket(args)
return jniStartProxy()
}
fun stopProxy(): Int {
return jniStopProxy()
}
private external fun jniCreateSocket(args: Array<String>): Int
private external fun jniStartProxy(): Int
private external fun jniStopProxy(): Int
}

View File

@@ -0,0 +1,7 @@
package com.cherret.zaprett.byedpi
enum class ServiceStatus {
Disconnected,
Connected,
Failed,
}

View File

@@ -0,0 +1,11 @@
package com.cherret.zaprett.byedpi
object TProxyService {
init {
System.loadLibrary("hev-socks5-tunnel")
}
external fun TProxyStartService(config: String, fd: Int)
external fun TProxyGetStats(): LongArray
external fun TProxyStopService()
}

View File

@@ -0,0 +1,338 @@
package com.cherret.zaprett.ui.screen
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.AnimatedVisibility
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.Arrangement
import androidx.compose.foundation.layout.Column
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.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Dangerous
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.WavingHand
import androidx.compose.material3.AlertDialog
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.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.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
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.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import com.cherret.zaprett.BuildConfig
import com.cherret.zaprett.R
import com.cherret.zaprett.ui.viewmodel.HomeViewModel
import kotlinx.coroutines.CoroutineScope
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(viewModel: HomeViewModel = viewModel(), vpnLauncher: ActivityResultLauncher<Intent>) {
val context = LocalContext.current
val sharedPreferences: SharedPreferences = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
val requestVpnPermission by viewModel.requestVpnPermission.collectAsState()
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val cardText = viewModel.cardText
val cardIcon = viewModel.cardIcon;
val changeLog = viewModel.changeLog
val newVersion = viewModel.newVersion
val updateAvailable = viewModel.updateAvailable
val showUpdateDialog = viewModel.showUpdateDialog.value
val moduleVer = viewModel.moduleVer;
val nfqwsVer = viewModel.nfqwsVer;
val byedpiVer = viewModel.byedpiVer;
val serviceMode = viewModel.serviceMode
LaunchedEffect(Unit) {
viewModel.checkForUpdate()
viewModel.checkServiceStatus()
viewModel.checkModuleInfo()
}
LaunchedEffect(requestVpnPermission) {
if (requestVpnPermission) {
val intent = VpnService.prepare(context)
if (intent != null) {
vpnLauncher.launch(intent)
} else {
viewModel.startVpn()
viewModel.clearVpnPermissionRequest()
}
}
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.app_name),
fontSize = 40.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
)
},
windowInsets = WindowInsets(0)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
content = { paddingValues ->
Column(modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())) {
ServiceStatusCard(viewModel, cardText, cardIcon, snackbarHostState, scope)
UpdateCard(updateAvailable) { viewModel.showUpdateDialog() }
if (showUpdateDialog) {
UpdateDialog(viewModel, changeLog.value.orEmpty(), newVersion) { viewModel.dismissUpdateDialog() }
}
ServiceControlButtons(
viewModel,
sharedPreferences,
snackbarHostState,
scope
)
ModuleInfoCard(moduleVer, nfqwsVer, byedpiVer, serviceMode)
}
}
)
}
@Composable
private fun ServiceStatusCard(viewModel: HomeViewModel, cardText: MutableState<Int>, cardIcon : MutableState<ImageVector>, 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)
.height(150.dp),
onClick = { viewModel.onCardClick() }
) {
Row (
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
)
{
Icon(
painter = rememberVectorPainter(cardIcon.value),
modifier = Modifier
.width(60.dp)
.height(60.dp),
contentDescription = "icon"
)
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)
.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(viewModel: HomeViewModel, sharedPreferences: SharedPreferences, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
FilledTonalButton(
onClick = { viewModel.onBtnStartService(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 = { viewModel.onBtnStopService(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))
}
if (sharedPreferences.getBoolean("use_module", false)) {
FilledTonalButton(
onClick = { viewModel.onBtnRestart(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))
}
}
}
@Composable
private fun ModuleInfoCard(
moduleVer: MutableState<String>,
nfqwsVer: MutableState<String>,
byedpiVer: MutableState<String>,
serviceMode: MutableState<Int>
) {
ElevatedCard(
elevation = CardDefaults.cardElevation(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 10.dp)
) {
ModuleInfoItem(Icons.Default.Build, stringResource(R.string.service_mode), stringResource(serviceMode.value))
HorizontalDivider()
ModuleInfoItem(Icons.Default.Extension, stringResource(R.string.module_version), moduleVer.value)
HorizontalDivider()
ModuleInfoItem(Icons.Default.Dangerous, stringResource(R.string.nfqws_version), nfqwsVer.value)
HorizontalDivider()
ModuleInfoItem(Icons.Default.WavingHand, stringResource(R.string.ciadpi_version), byedpiVer.value)
}
}
@Composable
private fun ModuleInfoItem(
icon: ImageVector, header : String, value : String
) {
Row (
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Icon(
painter = rememberVectorPainter(icon),
modifier = Modifier
.size(50.dp)
.padding(16.dp),
contentDescription = "icon"
)
Column (
modifier = Modifier
.padding(horizontal = 16.dp)
){
Text(
text = header,
modifier = Modifier
.fillMaxWidth(),
fontSize = 18.sp,
textAlign = TextAlign.Justify,
)
Text(
text = value
)
}
}
}
@Composable
fun UpdateDialog(viewModel: HomeViewModel, changeLog: String, newVersion: MutableState<String?>, onDismiss: () -> Unit) {
AlertDialog(
title = { Text(text = stringResource(R.string.update_available)) },
text = {
Text(
text = stringResource(R.string.alert_version, BuildConfig.VERSION_NAME, newVersion.value.toString(), changeLog)
)
},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
onDismiss()
viewModel.onUpdateConfirm()
}) {
Text(stringResource(R.string.btn_update))
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.btn_dismiss))
}
}
)
}

View File

@@ -0,0 +1,220 @@
package com.cherret.zaprett.ui.screen
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.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.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
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.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.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.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.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.cherret.zaprett.R
import com.cherret.zaprett.ui.viewmodel.HostsViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HostsScreen(navController: NavController, viewModel: HostsViewModel = viewModel()) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val allLists = viewModel.allItems
val checked = viewModel.checked
val isRefreshing = viewModel.isRefreshing
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { viewModel.copySelectedFile(context, "/lists", it) }
}
LaunchedEffect(Unit) {
viewModel.refresh()
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.title_hosts),
fontSize = 40.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
)
},
windowInsets = WindowInsets(0)
)
},
content = { paddingValues ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
viewModel.refresh()
},
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
contentPadding = paddingValues,
modifier = Modifier.fillMaxSize()
) {
when {
allLists.isEmpty() -> {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.empty_list),
textAlign = TextAlign.Center
)
}
}
}
else -> {
items(allLists) { item ->
HostItem (
item = item,
isChecked = checked[item] == true,
onCheckedChange = { isChecked ->
viewModel.onCheckedChange(item, isChecked, snackbarHostState, scope)
},
onDeleteClick = {
viewModel.deleteItem(item, snackbarHostState, scope)
}
)
}
}
}
}
}
},
floatingActionButton = {
FloatingMenu(navController, filePickerLauncher)
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
)
}
@Composable
private fun HostItem(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=hosts") { 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"))
}

View File

@@ -0,0 +1,205 @@
package com.cherret.zaprett.ui.screen
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
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.remember
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.ui.viewmodel.BaseRepoViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun RepoScreen(navController: NavController, viewModel: BaseRepoViewModel) {
val context = LocalContext.current
val hostLists = viewModel.hostLists.value
val isUpdate = viewModel.isUpdate
val isInstalling = viewModel.isInstalling
val isUpdateInstalling = viewModel.isUpdateInstalling
val isRefreshing = viewModel.isRefreshing.value
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
viewModel.refresh()
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.title_repo),
fontSize = 30.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 = { viewModel.refresh() },
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
contentPadding = paddingValues,
modifier = Modifier.fillMaxSize()
) {
if (hostLists.isEmpty()) {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.empty_list),
textAlign = TextAlign.Center
)
}
}
} else {
items(hostLists) { item ->
val isInstalled = viewModel.isItemInstalled(item)
val installing = isInstalling[item.name] == true
val updating = isUpdateInstalling[item.name] == true
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)
) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(text = item.name, modifier = Modifier.weight(1f))
}
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp)
) {
Text(
text = stringResource(R.string.title_author, item.author),
modifier = Modifier.weight(1f)
)
}
Row(
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 && isInstalled) {
FilledTonalButton(
onClick = { viewModel.update(item) },
enabled = !updating,
modifier = Modifier.padding(horizontal = 5.dp)
) {
Icon(
imageVector = Icons.Default.Update,
contentDescription = stringResource(R.string.btn_remove_host),
modifier = Modifier.size(20.dp)
)
Text(
if (updating) stringResource(R.string.btn_updating_host)
else stringResource(R.string.btn_update_host)
)
}
}
FilledTonalButton(
onClick = { viewModel.install(item) },
enabled = !installing && !isInstalled,
modifier = Modifier.padding(horizontal = 5.dp)
) {
Icon(
imageVector = Icons.Default.InstallMobile,
contentDescription = stringResource(R.string.btn_remove_host),
modifier = Modifier.size(20.dp)
)
Text(
when {
installing -> stringResource(R.string.btn_installing_host)
isInstalled -> stringResource(R.string.btn_installed_host)
else -> stringResource(R.string.btn_install_host)
}
)
}
}
}
}
}
}
}
}
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
)
}

View File

@@ -0,0 +1,447 @@
package com.cherret.zaprett.ui.screen
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.clickable
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.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
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.painterResource
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.sp
import com.cherret.zaprett.BuildConfig
import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.R
import com.cherret.zaprett.byedpi.ServiceStatus
import com.cherret.zaprett.utils.checkModuleInstallation
import com.cherret.zaprett.utils.checkRoot
import com.cherret.zaprett.utils.getStartOnBoot
import com.cherret.zaprett.utils.setStartOnBoot
import com.cherret.zaprett.utils.stopService
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen() {
val context = LocalContext.current
val sharedPreferences = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
val editor = remember { sharedPreferences.edit() }
val useModule = remember { mutableStateOf(sharedPreferences.getBoolean("use_module", false)) }
val updateOnBoot = remember { mutableStateOf(sharedPreferences.getBoolean("update_on_boot", false)) }
val autoRestart = remember { mutableStateOf(getStartOnBoot()) }
val autoUpdate = remember { mutableStateOf(sharedPreferences.getBoolean("auto_update", true)) }
val sendFirebaseAnalytics = remember { mutableStateOf(sharedPreferences.getBoolean("send_firebase_analytics", true)) }
val ipv6 = remember { mutableStateOf(sharedPreferences.getBoolean("ipv6",false)) }
val openNoRootDialog = remember { mutableStateOf(false) }
val openNoModuleDialog = remember { mutableStateOf(false) }
val showAboutDialog = remember { mutableStateOf(false) }
val showHostsRepoUrlDialog = remember { mutableStateOf(false) }
val showStrategyRepoUrlDialog = remember { mutableStateOf(false) }
val showIPDialog = remember { mutableStateOf(false) }
val showPortDialog = remember { mutableStateOf(false) }
val showDNSDialog = remember { mutableStateOf(false) }
val textDialogValue = remember { mutableStateOf("") }
val settingsList = listOf(
Setting.Section(stringResource(R.string.general_section)),
Setting.Toggle(
title = stringResource(R.string.btn_use_root),
checked = useModule.value,
onToggle = { isChecked ->
useModule(
context = context,
checked = isChecked,
updateOnBoot = updateOnBoot,
openNoRootDialog = openNoRootDialog,
openNoModuleDialog = openNoModuleDialog
) { success ->
if (success) {
useModule.value = isChecked
if (!isChecked) stopService { }
}
}
}
),
Setting.Toggle(
title = stringResource(R.string.btn_update_on_boot),
checked = updateOnBoot.value,
onToggle = {
updateOnBoot.value = it
editor.putBoolean("update_on_boot", it).apply()
}
),
Setting.Toggle(
title = stringResource(R.string.btn_autorestart),
checked = autoRestart.value,
onToggle = {
if (handleAutoRestart(context, it)) autoRestart.value = it
}
),
Setting.Toggle(
title = stringResource(R.string.btn_autoupdate),
checked = autoUpdate.value,
onToggle = {
autoUpdate.value = it
editor.putBoolean("auto_update", it).apply()
}
),
Setting.Toggle(
title = stringResource(R.string.btn_send_firebase_analytics),
checked = sendFirebaseAnalytics.value,
onToggle = {
sendFirebaseAnalytics.value = it
editor.putBoolean("send_firebase_analytics", it).apply()
}
),
Setting.Action(
title = stringResource(R.string.btn_repository_url_lists),
onClick = {
textDialogValue.value = sharedPreferences.getString("hosts_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json") ?: "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json"
showHostsRepoUrlDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.btn_repository_url_strategies),
onClick = {
textDialogValue.value = sharedPreferences.getString("strategy_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json") ?: "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json"
showStrategyRepoUrlDialog.value = true
}
),
Setting.Section(stringResource(R.string.byedpi_section)),
Setting.Toggle(
title = stringResource(R.string.btn_ipv6),
checked = ipv6.value,
onToggle = {
ipv6.value = it
editor.putBoolean("ipv6", it).apply()
}
),
Setting.Action(
title = stringResource(R.string.btn_ip),
onClick = {
textDialogValue.value = sharedPreferences.getString("ip", "127.0.0.1") ?: "127.0.0.1"
showIPDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.btn_port),
onClick = {
textDialogValue.value = sharedPreferences.getString("port", "1080") ?: "1080"
showPortDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.btn_dns),
onClick = {
textDialogValue.value = sharedPreferences.getString("dns", "8.8.8.8") ?: "8.8.8.8"
showDNSDialog.value = true
}
)
)
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.value) {
AboutDialog(onDismiss = { showAboutDialog.value = false })
}
if (showHostsRepoUrlDialog.value) {
TextDialog(stringResource(R.string.btn_repository_url_lists), stringResource(R.string.hint_enter_repository_url_lists), textDialogValue.value, onConfirm = {
editor.putString("hosts_repo_url", it).apply()
}, onDismiss = { showHostsRepoUrlDialog.value = false })
}
if (showStrategyRepoUrlDialog.value) {
TextDialog(stringResource(R.string.btn_repository_url_strategies), stringResource(R.string.hint_enter_repository_url_strategies), textDialogValue.value, onConfirm = {
editor.putString("strategy_repo_url", it).apply()
}, onDismiss = { showStrategyRepoUrlDialog.value = false })
}
if (showIPDialog.value) {
TextDialog(stringResource(R.string.btn_ip), stringResource(R.string.hint_ip), textDialogValue.value, onConfirm = {
editor.putString("ip", it).apply()
}, onDismiss = { showIPDialog.value = false })
}
if (showPortDialog.value) {
TextDialog(stringResource(R.string.btn_port), stringResource(R.string.hint_port), textDialogValue.value, onConfirm = {
editor.putString("port", it).apply()
}, onDismiss = { showPortDialog.value = false })
}
if (showDNSDialog.value) {
TextDialog(stringResource(R.string.btn_dns), stringResource(R.string.hint_dns), textDialogValue.value, onConfirm = {
editor.putString("dns", it).apply()
}, onDismiss = { showDNSDialog.value = false })
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.title_settings),
fontSize = 40.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
)
},
actions = {
var expanded by remember { mutableStateOf(false) }
IconButton(onClick = { expanded = !expanded }) {
Icon(Icons.Default.MoreVert, contentDescription = null)
}
DropdownMenu(
expanded = expanded,
onDismissRequest = { expanded = false }
) {
DropdownMenuItem(
text = { Text(stringResource(R.string.about_title)) },
onClick = {
expanded = false
showAboutDialog.value = true
}
)
}
},
windowInsets = WindowInsets(0)
)
},
content = { paddingValues ->
LazyColumn(
modifier = Modifier
.padding(paddingValues)
.fillMaxSize(),
contentPadding = PaddingValues(horizontal = 10.dp, vertical = 25.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(settingsList) { setting ->
when (setting) {
is Setting.Toggle -> {
SettingsItem(
title = setting.title,
onToggle = setting.onToggle,
checked = setting.checked,
onCheckedChange = setting.onToggle
)
}
is Setting.Action -> {
SettingsTextItem(
title = setting.title,
setting.onClick
)
}
is Setting.Section -> {
SettingsSection(setting.title)
}
}
}
}
}
)
}
@Composable
private fun SettingsItem(title: String, checked: Boolean, onToggle: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.clickable { onToggle(!checked) },
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = title,
modifier = Modifier.weight(1f)
)
Switch(
checked = checked,
onCheckedChange = onCheckedChange
)
}
}
}
@Composable
private fun SettingsTextItem(title: String, onClick: () -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.clickable { onClick() },
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = title,
modifier = Modifier.weight(1f)
)
Icon(imageVector = Icons.AutoMirrored.Default.ArrowForward, contentDescription = "test")
}
}
}
@Composable
private fun SettingsSection(title: String) {
Text(
text = title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp)
)
}
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 editor = sharedPreferences.edit()
if (checked) {
checkRoot { hasRoot ->
if (hasRoot) {
checkModuleInstallation { hasModule ->
if (hasModule) {
editor.putBoolean("use_module", true)
.putBoolean("update_on_boot", true)
.apply()
if (ByeDpiVpnService.status == ServiceStatus.Connected) {
context.startService(Intent(context, ByeDpiVpnService::class.java).apply {
action = "STOP_VPN"
})
}
editor.remove("lists").apply()
editor.remove("active_strategy").apply()
updateOnBoot.value = true
callback(true)
} else {
openNoModuleDialog.value = true
}
}
} else {
openNoRootDialog.value = true
}
}
} else {
editor.putBoolean("use_module", false)
.putBoolean("update_on_boot", false)
.apply()
updateOnBoot.value = false
callback(true)
}
}
private fun handleAutoRestart(context: Context, checked: Boolean): Boolean {
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
return if (sharedPreferences.getBoolean("use_module", false)) {
setStartOnBoot(checked)
true
} else {
false
}
}
@Composable
private fun InfoDialog(title: String, message: String, onDismiss: () -> Unit) {
AlertDialog(
title = { Text(text = title) },
text = { Text(text = message) },
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
@Composable
private fun TextDialog(title: String, message: String, initialText: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var inputText by remember { mutableStateOf(initialText) }
AlertDialog(
title = { Text(text = title) },
text = {
TextField(
value = inputText,
onValueChange = { inputText = it },
placeholder = { Text(message) },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)},
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
if (inputText.isNotEmpty()) {
onConfirm(inputText)
onDismiss()
}
else {
onDismiss()
}
}
) {
Text(stringResource(R.string.btn_continue))
}
},
dismissButton = {
TextButton(
onClick = { onDismiss() }
) {
Text(stringResource(R.string.btn_dismiss))
}
}
)
}
@Composable
private fun AboutDialog(onDismiss: () -> Unit) {
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
.size(64.dp))},
text = { Text(text = stringResource(R.string.about_text, BuildConfig.VERSION_NAME)) },
onDismissRequest = onDismiss,
confirmButton = { }
)
}
sealed class Setting {
data class Toggle(val title: String, val checked: Boolean, val onToggle: (Boolean) -> Unit) : Setting()
data class Action(val title: String, val onClick: () -> Unit) : Setting()
data class Section(val title: String) : Setting()
}

View File

@@ -0,0 +1,226 @@
package com.cherret.zaprett.ui.screen
import android.content.Context
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
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.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.CardDefaults
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.HorizontalDivider
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.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.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.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.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.cherret.zaprett.R
import com.cherret.zaprett.ui.viewmodel.StrategyViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel = viewModel()) {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val allLists = viewModel.allItems
val checked = viewModel.checked
val isRefreshing = viewModel.isRefreshing
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { viewModel.copySelectedFile(
context,
if (sharedPreferences.getBoolean("use_module", false)) "/strategies/nfqws" else "/strategies/byedpi",
it
) }
}
LaunchedEffect(Unit) {
viewModel.refresh()
}
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 = {
viewModel.refresh()
},
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
contentPadding = paddingValues,
modifier = Modifier.fillMaxSize()
) {
when {
allLists.isEmpty() -> {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.empty_list),
textAlign = TextAlign.Center
)
}
}
}
else -> {
items(allLists) { item ->
StrategyItem(
item = item,
isChecked = checked[item] == true,
onCheckedChange = { isChecked ->
viewModel.onCheckedChange(item, isChecked, snackbarHostState, scope)
},
onDeleteClick = {
viewModel.deleteItem(item, 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"))
}

View File

@@ -1,243 +0,0 @@
package com.cherret.zaprett.ui.screens
import android.content.Context
import android.content.Context.MODE_PRIVATE
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilledTonalButton
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.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.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.sp
import com.cherret.zaprett.R
import com.cherret.zaprett.getStatus
import com.cherret.zaprett.restartService
import com.cherret.zaprett.startService
import com.cherret.zaprett.stopService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@Composable
fun HomeScreen() {
val context = LocalContext.current
val sharedPreferences = remember { context.getSharedPreferences("settings", MODE_PRIVATE) }
val cardText = remember { mutableStateOf(R.string.status_not_availible) }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
LaunchedEffect(Unit) {
if (sharedPreferences.getBoolean("use_module", false) && sharedPreferences.getBoolean("update_on_boot", false)) {
getStatus {
if (it) {
cardText.value = R.string.status_enabled
}
else {
cardText.value = R.string.status_disabled
}
}
}
}
Scaffold(
topBar = {
val primaryColor = MaterialTheme.colorScheme.surfaceVariant
Box(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) {
Canvas(modifier = Modifier.size(100.dp, 50.dp)) {
rotate(degrees = -30f) {
drawOval(
color = primaryColor,
size = Size(200f, 140f),
topLeft = Offset(-20f, -20f)
)
}
}
Text(
text = stringResource(R.string.app_name),
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.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)
)
}
}
},
snackbarHost = {SnackbarHost(hostState = snackbarHostState)}
)
}
fun onCardClick(context: Context, cardText: MutableState<Int>, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus {
if (it) {
cardText.value = R.string.status_enabled
}
else {
cardText.value = R.string.status_disabled
}
}
}
else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled))
}
}
}
fun onBtnStartService(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("use_module", false)) {
getStatus {
if (it) {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_already_started))
}
} else {
startService {}
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_starting_service))
}
}
}
} else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled))
}
}
}
fun onBtnStopService(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("use_module", false)) {
getStatus {
if (it) {
stopService{}
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_stopping_service))
}
}
else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_no_service))
}
}
}
}
else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled))
}
}
}
fun onBtnRestart(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("use_module", false)) {
restartService{}
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_reload))
}
}
else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled))
}
}
}

View File

@@ -1,210 +0,0 @@
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.Canvas
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.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FloatingActionButton
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.runtime.Composable
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.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.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.sp
import com.cherret.zaprett.R
import com.cherret.zaprett.disableList
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.launch
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HostsScreen() {
val context = LocalContext.current
var allLists by remember { mutableStateOf(getAllLists()) }
var activeLists by remember { mutableStateOf(getActiveLists()) }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
var isRefreshing by remember { mutableStateOf(false) }
val checked = remember {
mutableStateMapOf<String, Boolean>().apply {
allLists.forEach { list ->
this[list] = activeLists.contains(list)
}
}
}
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri: Uri? ->
uri?.let {
copySelectedFile(context, it, snackbarHostState, scope)
}
}
)
Scaffold(
topBar = {
val primaryColor = MaterialTheme.colorScheme.surfaceVariant
Box(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) {
Canvas(modifier = Modifier.size(100.dp, 50.dp)) {
rotate(degrees = -30f) {
drawOval(
color = primaryColor,
size = Size(200f, 140f),
topLeft = Offset(-30f, -30f)
)
}
}
Text(
text = stringResource(R.string.title_hosts),
fontSize = 40.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
)
}
},
content = { paddingValues ->
Column(
modifier = Modifier
.padding(paddingValues)
) {
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
allLists = getAllLists()
activeLists = getActiveLists()
checked.clear()
allLists.forEach { list ->
checked[list] = activeLists.contains(list)
}
isRefreshing = false
},
modifier = Modifier
) {
LazyColumn {
items(allLists) { item ->
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(
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))
}
}
)
}
}
}
}
}
}
},
floatingActionButton = {
FloatingActionButton(modifier = Modifier
.size(80.dp, 80.dp),
onClick = {addHost(filePickerLauncher)}) {
Icon(Icons.Default.Add, contentDescription = "Restart")
}
},
snackbarHost = {SnackbarHost(hostState = snackbarHostState)}
)
}
fun addHost(launcher: ActivityResultLauncher<Array<String>>) {
launcher.launch(arrayOf("*/*"))
}
fun copySelectedFile(context: Context, uri: Uri?, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (Environment.isExternalStorageManager()) {
if (uri == null) 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)
}
}
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.pls_restart_snack))
}
} catch (e: IOException) {
e.printStackTrace()
}
}
}

View File

@@ -1,241 +0,0 @@
package com.cherret.zaprett.ui.screens
import android.content.Context
import androidx.compose.foundation.Canvas
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.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.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.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.sp
import com.cherret.zaprett.R
import com.cherret.zaprett.checkModuleInstallation
import com.cherret.zaprett.checkRoot
import com.cherret.zaprett.getStartOnBoot
import com.cherret.zaprett.setStartOnBoot
@Composable
fun SettingsScreen() {
val context = LocalContext.current
val sharedPreferences = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
val editor = remember { sharedPreferences.edit() }
val useModule = remember { mutableStateOf(sharedPreferences.getBoolean("use_module", false)) }
val updateOnBoot = remember { mutableStateOf(sharedPreferences.getBoolean("update_on_boot", false)) }
val autoRestart = remember { mutableStateOf(getStartOnBoot()) }
val openNoRootDialog = remember { mutableStateOf(false) }
val openNoModuleDialog = remember { mutableStateOf(false) }
showNoRootDialog(openNoRootDialog)
showNoModuleDialog(openNoModuleDialog)
Scaffold(
topBar = {
val primaryColor = MaterialTheme.colorScheme.surfaceVariant
Box(modifier = Modifier.padding(start = 20.dp, top = 10.dp)) {
Canvas(modifier = Modifier.size(100.dp, 50.dp)) {
rotate(degrees = -30f) {
drawOval(
color = primaryColor,
size = Size(200f, 140f),
topLeft = Offset(-30f, -30f)
)
}
}
Text(
text = stringResource(R.string.title_settings),
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(
text = stringResource(R.string.btn_use_root),
modifier = Modifier.weight(1f)
)
Switch(
checked = useModule.value,
onCheckedChange = { isChecked ->
useModule(
context,
isChecked,
updateOnBoot,
openNoRootDialog,
openNoModuleDialog
) {
if (it) {
useModule.value = isChecked
}
}
}
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = stringResource(R.string.btn_update_on_boot),
modifier = Modifier.weight(1f)
)
Switch(
checked = updateOnBoot.value,
onCheckedChange = { updateOnBoot.value = it; editor.putBoolean("update_on_boot", it).apply()}
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = stringResource(R.string.btn_autorestart),
modifier = Modifier.weight(1f)
)
Switch(
checked = autoRestart.value,
onCheckedChange = { if (autoRestart(context, it)) autoRestart.value = it;}
)
}
}
}
}
)
}
fun useModule(context: Context, checked: Boolean, updateOnBoot: MutableState<Boolean>, openNoRootDialog: MutableState<Boolean>, openNoModuleDialog: MutableState<Boolean>, callback: (Boolean) -> Unit): Boolean {
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val editor = sharedPreferences.edit()
if (checked) {
checkRoot {
if (it) {
checkModuleInstallation {
if (it) {
editor.putBoolean("use_module", true).putBoolean("update_on_boot", true).apply()
updateOnBoot.value = true
callback(true)
}
else {
openNoModuleDialog.value = true
}
}
}
else {
openNoRootDialog.value = true
}
}
}
else {
editor.putBoolean("use_module", false).putBoolean("update_on_boot", false)
.apply()
return true
}
return false
}
fun autoRestart(context: Context, checked: Boolean): Boolean {
if (context.getSharedPreferences("settings", Context.MODE_PRIVATE).getBoolean("use_module", false)) {
setStartOnBoot(checked)
return true
}
return 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
fun showNoModuleDialog(openDialog: MutableState<Boolean>) {
if (openDialog.value) {
AlertDialog(
title = {
Text(text = stringResource(R.string.error_no_module_title))
},
text = {
Text(text = stringResource(R.string.error_no_module_message))
},
onDismissRequest = {
openDialog.value = false
},
confirmButton = {
TextButton(
onClick = {
openDialog.value = false
}
) {
Text(stringResource(R.string.btn_continue))
}
},
)
}
}

View File

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

View File

@@ -0,0 +1,88 @@
package com.cherret.zaprett.ui.viewmodel
import android.app.Application
import android.content.Context
import android.net.Uri
import android.os.Environment
import android.provider.OpenableColumns
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import com.cherret.zaprett.R
import com.cherret.zaprett.utils.getZaprettPath
import com.cherret.zaprett.utils.restartService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
import java.io.IOException
abstract class BaseListsViewModel(application: Application) : AndroidViewModel(application) {
val context = application
var allItems by mutableStateOf<List<String>>(emptyList())
private set
var activeItems by mutableStateOf<List<String>>(emptyList())
private set
val checked = mutableStateMapOf<String, Boolean>()
var isRefreshing by mutableStateOf(false)
private set
abstract fun loadAllItems(): Array<String>
abstract fun loadActiveItems(): Array<String>
abstract fun onCheckedChange(item: String, isChecked: Boolean, snackbarHostState: SnackbarHostState, scope: CoroutineScope)
abstract fun deleteItem(item: String, snackbarHostState: SnackbarHostState, scope: CoroutineScope)
fun refresh() {
isRefreshing = true
allItems = loadAllItems().toList()
activeItems = loadActiveItems().toList()
checked.clear()
allItems.forEach { list ->
checked[list] = activeItems.contains(list)
}
isRefreshing = false
}
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))
}
}
}
fun copySelectedFile(context: Context, path: String, uri: Uri) {
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 directory = File(getZaprettPath() + path)
if (!directory.exists()) {
directory.mkdirs()
}
val outputFile = File(getZaprettPath() + path, fileName)
try {
contentResolver.openInputStream(uri)?.use { inputStream ->
FileOutputStream(outputFile).use { outputStream ->
inputStream.copyTo(outputStream)
}
}
refresh()
} catch (e: IOException) {
e.printStackTrace()
}
}
}

View File

@@ -0,0 +1,123 @@
package com.cherret.zaprett.ui.viewmodel
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.cherret.zaprett.utils.RepoItemInfo
import com.cherret.zaprett.R
import com.cherret.zaprett.utils.download
import com.cherret.zaprett.utils.getFileSha256
import com.cherret.zaprett.utils.getZaprettPath
import com.cherret.zaprett.utils.registerDownloadListenerHost
import com.cherret.zaprett.utils.restartService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import java.io.File
abstract class BaseRepoViewModel(application: Application) : AndroidViewModel(application) {
val context = application.applicationContext
val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
var hostLists = mutableStateOf<List<RepoItemInfo>>(emptyList())
protected set
var isRefreshing = mutableStateOf(false)
protected set
val isUpdate = mutableStateMapOf<String, Boolean>()
val isInstalling = mutableStateMapOf<String, Boolean>()
val isUpdateInstalling = mutableStateMapOf<String, Boolean>()
abstract fun getInstalledLists(): Array<String>
abstract fun getRepoList(callback: (List<RepoItemInfo>?) -> Unit)
fun refresh() {
isRefreshing.value = true
getRepoList { list ->
viewModelScope.launch(Dispatchers.IO) {
val safeList = list ?: emptyList()
val useModule = sharedPreferences.getBoolean("use_module", false)
val filteredList = safeList.filter { item ->
when (item.type) {
"list" -> true
"nfqws" -> useModule
"byedpi" -> !useModule
else -> false
}
}
hostLists.value = filteredList
isUpdate.clear()
val existingHashes = getInstalledLists().map { getFileSha256(File(it)) }
for (item in filteredList) {
isUpdate[item.name] = item.hash !in existingHashes
}
isRefreshing.value = false
}
}
}
fun isItemInstalled(item: RepoItemInfo): Boolean {
return getInstalledLists().any { File(it).name == item.name }
}
fun install(item: RepoItemInfo) {
isInstalling[item.name] = true
val downloadId = download(context, item.url)
registerDownloadListenerHost(context, downloadId) { uri ->
viewModelScope.launch(Dispatchers.IO) {
val sourceFile = File(uri.path!!)
val targetDir = when (item.type) {
"byedpi" -> File(getZaprettPath(), "strategies/byedpi")
"nfqws" -> File(getZaprettPath(), "strategies/nfqws")
else -> File(getZaprettPath(), "lists")
}
val targetFile = File(targetDir, uri.lastPathSegment!!)
sourceFile.copyTo(targetFile, overwrite = true)
sourceFile.delete()
isInstalling[item.name] = false
isUpdate[item.name] = false
refresh()
}
}
}
fun update(item: RepoItemInfo) {
isUpdateInstalling[item.name] = true
val downloadId = download(context, item.url)
registerDownloadListenerHost(context, downloadId) { uri ->
viewModelScope.launch(Dispatchers.IO) {
val sourceFile = File(uri.path!!)
val targetDir = when (item.type) {
"byedpi" -> File(getZaprettPath(), "strategies/byedpi")
"nfqws" -> File(getZaprettPath(), "strategies/nfqws")
else -> File(getZaprettPath(), "lists")
}
val targetFile = File(targetDir, uri.lastPathSegment!!)
sourceFile.copyTo(targetFile, overwrite = true)
sourceFile.delete()
isUpdateInstalling[item.name] = false
isUpdate[item.name] = false
refresh()
}
}
}
fun showRestartSnackbar(snackbarHostState: SnackbarHostState) {
viewModelScope.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

@@ -0,0 +1,258 @@
package com.cherret.zaprett.ui.viewmodel
import android.app.Application
import android.content.Context
import android.content.Intent
import android.widget.Toast
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.R
import com.cherret.zaprett.byedpi.ServiceStatus
import com.cherret.zaprett.utils.download
import com.cherret.zaprett.utils.getActiveStrategy
import com.cherret.zaprett.utils.getBinVersion
import com.cherret.zaprett.utils.getChangelog
import com.cherret.zaprett.utils.getModuleVersion
import com.cherret.zaprett.utils.getStatus
import com.cherret.zaprett.utils.getUpdate
import com.cherret.zaprett.utils.installApk
import com.cherret.zaprett.utils.registerDownloadListener
import com.cherret.zaprett.utils.restartService
import com.cherret.zaprett.utils.startService
import com.cherret.zaprett.utils.stopService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val context = application
private val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val _requestVpnPermission = MutableStateFlow(false)
val requestVpnPermission = _requestVpnPermission.asStateFlow()
var cardText = mutableIntStateOf(R.string.status_not_availible) // MVP temporarily(maybe)
private set
var cardIcon = mutableStateOf(Icons.AutoMirrored.Filled.Help)
private set
var moduleVer = mutableStateOf(context.getString(R.string.unknown_text))
private set
var nfqwsVer = mutableStateOf(context.getString(R.string.unknown_text))
private set
var byedpiVer = mutableStateOf("0.17.1")
private set
var serviceMode = mutableIntStateOf(R.string.service_mode_ciadpi)
private set
var changeLog = mutableStateOf<String?>(null)
private set
var newVersion = mutableStateOf<String?>(null)
private set
var updateAvailable = mutableStateOf(false)
private set
var downloadUrl = mutableStateOf<String?>(null)
private set
var showUpdateDialog = mutableStateOf(false)
fun checkForUpdate() {
if (prefs.getBoolean("auto_update", true)) {
getUpdate {
if (it != null) {
downloadUrl.value = it.downloadUrl.toString()
getChangelog(it.changelogUrl.toString()) { log -> changeLog.value = log }
newVersion.value = it.version
updateAvailable.value = true
}
}
}
}
fun checkServiceStatus() {
if (prefs.getBoolean("use_module", false) && prefs.getBoolean("update_on_boot", false)) {
getStatus { isEnabled ->
if (isEnabled){
cardText.value = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.value = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
}
}
}
else {
if (ByeDpiVpnService.status == ServiceStatus.Connected){
cardText.value = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.value = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
}
}
}
fun onCardClick() {
if (prefs.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled){
cardText.value = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.value = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
}
}
} else {
if (ByeDpiVpnService.status == ServiceStatus.Connected){
cardText.value = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.value = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
}
}
}
fun startVpn() {
ContextCompat.startForegroundService(context, Intent(context, ByeDpiVpnService::class.java).apply { action = "START_VPN" })
}
fun onBtnStartService(snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (prefs.getBoolean("use_module", false)) {
getStatus { isEnabled ->
scope.launch {
snackbarHostState.showSnackbar(
context.getString(
if (isEnabled) R.string.snack_already_started else R.string.snack_starting_service
)
)
}
if (!isEnabled) startService {}
}
} else {
if (ByeDpiVpnService.status == ServiceStatus.Disconnected || ByeDpiVpnService.status == ServiceStatus.Failed) {
if (getActiveStrategy(prefs).isNotEmpty()) {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_starting_service))
}
_requestVpnPermission.value = true
}
else {
Toast.makeText(
context,
context.getString(R.string.toast_no_strategy_selected),
Toast.LENGTH_SHORT
).show()
}
}
else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_already_started))
}
}
}
}
fun clearVpnPermissionRequest() {
_requestVpnPermission.value = false
}
fun onBtnStopService(snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (prefs.getBoolean("use_module", false)) {
getStatus { isEnabled ->
scope.launch {
snackbarHostState.showSnackbar(
context.getString(
if (isEnabled) R.string.snack_stopping_service else R.string.snack_no_service
)
)
}
if (isEnabled) stopService {}
}
} else {
if (ByeDpiVpnService.status == ServiceStatus.Connected) {
scope.launch {
snackbarHostState.showSnackbar(
context.getString(R.string.snack_stopping_service)
)
}
context.startService(Intent(context, ByeDpiVpnService::class.java).apply {
action = "STOP_VPN"
})
}
else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_no_service))
}
}
}
}
fun onBtnRestart(snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (prefs.getBoolean("use_module", false)) {
restartService {}
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_reload))
}
} else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled))
}
}
}
fun checkModuleInfo() {
if (prefs.getBoolean("use_module", false)) {
getModuleVersion { value ->
moduleVer.value = value
}
getBinVersion { value ->
nfqwsVer.value = value
}
serviceMode.intValue = R.string.service_mode_nfqws;
}
}
fun showUpdateDialog() {
showUpdateDialog.value = true
}
fun dismissUpdateDialog() {
showUpdateDialog.value = false
}
fun onUpdateConfirm() {
showUpdateDialog.value = false
val id = download(context, downloadUrl.value.orEmpty())
registerDownloadListener(context, id) { uri ->
installApk(context, uri)
}
}
fun parseArgs(ip: String, port: String, lines: List<String>): Array<String> {
val regex = Regex("""--?\S+(?:=(?:[^"'\s]+|"[^"]*"|'[^']*'))?|[^\s]+""")
val parsedArgs = lines
.flatMap { line -> regex.findAll(line).map { it.value } }
return arrayOf("ciadpi", "--ip", ip, "--port", port) + parsedArgs
}
}

View File

@@ -0,0 +1,11 @@
package com.cherret.zaprett.ui.viewmodel
import android.app.Application
import com.cherret.zaprett.utils.RepoItemInfo
import com.cherret.zaprett.utils.getAllLists
import com.cherret.zaprett.utils.getHostList
class HostRepoViewModel(application: Application): BaseRepoViewModel(application) {
override fun getInstalledLists(): Array<String> = getAllLists()
override fun getRepoList(callback: (List<RepoItemInfo>?) -> Unit) = getHostList(sharedPreferences, callback)
}

View File

@@ -0,0 +1,48 @@
package com.cherret.zaprett.ui.viewmodel
import android.app.Application
import android.content.Context
import androidx.compose.material3.SnackbarHostState
import com.cherret.zaprett.utils.disableList
import com.cherret.zaprett.utils.enableList
import com.cherret.zaprett.utils.getActiveLists
import com.cherret.zaprett.utils.getAllLists
import com.cherret.zaprett.utils.getStatus
import kotlinx.coroutines.CoroutineScope
import java.io.File
class HostsViewModel(application: Application): BaseListsViewModel(application) {
private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
override fun loadAllItems(): Array<String> = getAllLists()
override fun loadActiveItems(): Array<String> = getActiveLists(sharedPreferences)
override fun deleteItem(item: String, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
val wasChecked = checked[item] == true
disableList(item, sharedPreferences)
val success = File(item).delete()
if (success) refresh()
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled && wasChecked) {
showRestartSnackbar(context, snackbarHostState, scope)
}
}
}
}
override fun onCheckedChange(item: String, isChecked: Boolean, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
checked[item] = isChecked
if (isChecked) enableList(item, sharedPreferences) else disableList(item, sharedPreferences)
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled) {
showRestartSnackbar(
context,
snackbarHostState,
scope
)
}
}
}
}
}

View File

@@ -0,0 +1,17 @@
package com.cherret.zaprett.ui.viewmodel
import android.app.Application
import com.cherret.zaprett.utils.RepoItemInfo
import com.cherret.zaprett.utils.getAllByeDPIStrategies
import com.cherret.zaprett.utils.getAllNfqwsStrategies
import com.cherret.zaprett.utils.getStrategiesList
class StrategyRepoViewModel(application: Application): BaseRepoViewModel(application) {
override fun getInstalledLists(): Array<String> =
if (sharedPreferences.getBoolean("use_module", false)) {
getAllNfqwsStrategies()
} else {
getAllByeDPIStrategies()
}
override fun getRepoList(callback: (List<RepoItemInfo>?) -> Unit) = getStrategiesList(sharedPreferences, callback)
}

View File

@@ -0,0 +1,91 @@
package com.cherret.zaprett.ui.viewmodel
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.material3.SnackbarHostState
import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.byedpi.ServiceStatus
import com.cherret.zaprett.utils.disableStrategy
import com.cherret.zaprett.utils.enableStrategy
import com.cherret.zaprett.utils.getActiveByeDPIStrategies
import com.cherret.zaprett.utils.getActiveNfqwsStrategies
import com.cherret.zaprett.utils.getAllByeDPIStrategies
import com.cherret.zaprett.utils.getAllNfqwsStrategies
import com.cherret.zaprett.utils.getStatus
import kotlinx.coroutines.CoroutineScope
import java.io.File
class StrategyViewModel(application: Application): BaseListsViewModel(application) {
private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val useModule = sharedPreferences.getBoolean("use_module", false)
private val strategyProvider: StrategyProvider = if (useModule) {
NfqwsStrategyProvider()
} else {
ByeDPIStrategyProvider(sharedPreferences)
}
override fun loadAllItems(): Array<String> = strategyProvider.getAll()
override fun loadActiveItems(): Array<String> = strategyProvider.getActive()
override fun deleteItem(item: String, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
val wasChecked = checked[item] == true
disableStrategy(item, sharedPreferences)
val success = File(item).delete()
if (success) refresh()
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled && wasChecked) {
showRestartSnackbar(context, snackbarHostState, scope)
}
}
}
else {
if (ByeDpiVpnService.status == ServiceStatus.Connected && wasChecked) {
showRestartSnackbar(context, snackbarHostState, scope)
}
}
}
override fun onCheckedChange(item: String, isChecked: Boolean, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
checked[item] = isChecked
if (isChecked) {
checked.keys.forEach { key ->
checked[key] = false
disableStrategy(key, sharedPreferences)
}
checked[item] = true
enableStrategy(item, sharedPreferences)
}
else {
checked[item] = false
disableStrategy(item, sharedPreferences)
}
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled) {
showRestartSnackbar(
context,
snackbarHostState,
scope
)
}
}
}
}
}
interface StrategyProvider {
fun getAll(): Array<String>
fun getActive(): Array<String>
}
class NfqwsStrategyProvider : StrategyProvider {
override fun getAll() = getAllNfqwsStrategies()
override fun getActive() = getActiveNfqwsStrategies()
}
class ByeDPIStrategyProvider(private val sharedPreferences: SharedPreferences) : StrategyProvider {
override fun getAll() = getAllByeDPIStrategies()
override fun getActive() = getActiveByeDPIStrategies(sharedPreferences)
}

View File

@@ -0,0 +1,331 @@
package com.cherret.zaprett.utils
import android.content.SharedPreferences
import android.os.Environment
import android.util.Log
import com.topjohnwu.superuser.Shell
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.util.Properties
import androidx.core.content.edit
fun checkRoot(callback: (Boolean) -> Unit) {
Shell.cmd("ls /").submit { result ->
callback(result.isSuccess)
}
}
fun checkModuleInstallation(callback: (Boolean) -> Unit) {
Shell.cmd("zaprett").submit { result ->
callback(result.out.toString().contains("zaprett"))
}
}
fun getStatus(callback: (Boolean) -> Unit) {
Shell.cmd("zaprett status").submit { result ->
callback(result.out.toString().contains("working"))
}
}
fun startService(callback: (Boolean) -> Unit) {
Shell.cmd("zaprett start").submit { result ->
callback(result.isSuccess)
}
}
fun stopService(callback: (Boolean) -> Unit) {
Shell.cmd("zaprett stop").submit { result ->
callback(result.isSuccess)
}
}
fun restartService(callback: (Boolean) -> Unit) {
Shell.cmd("zaprett restart").submit { result ->
callback(result.isSuccess)
}
}
fun getModuleVersion(callback: (String) -> Unit) {
Shell.cmd("zaprett module-ver").submit { result ->
if (result.out.isNotEmpty()) callback(result.out.first()) else "undefined"
}
}
fun getBinVersion(callback: (String) -> Unit) {
Shell.cmd("zaprett bin-ver").submit { result ->
if (result.out.isNotEmpty()) callback(result.out.first()) else "undefined"
}
}
fun getConfigFile(): File {
return File(Environment.getExternalStorageDirectory(), "zaprett/config")
}
fun setStartOnBoot(startOnBoot: Boolean) {
val configFile = getConfigFile()
if (configFile.exists()) {
val props = Properties()
try {
FileInputStream(configFile).use { input ->
props.load(input)
}
props.setProperty("autorestart", startOnBoot.toString())
FileOutputStream(configFile).use { output ->
props.store(output, "Don't place '/' in end of directory! Example: /sdcard")
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
}
fun getStartOnBoot(): Boolean {
val configFile = getConfigFile()
val props = Properties()
return try {
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
props.getProperty("autorestart", "false").toBoolean()
} else {
false
}
} catch (_: IOException) {
false
}
}
fun getZaprettPath(): String {
val props = Properties()
val configFile = getConfigFile()
if (configFile.exists()) {
return try {
FileInputStream(configFile).use { input ->
props.load(input)
}
props.getProperty("zaprettdir", Environment.getExternalStorageDirectory().path + "/zaprett")
} catch (e: IOException) {
throw RuntimeException(e)
}
}
return Environment.getExternalStorageDirectory().path + "/zaprett"
}
fun getAllLists(): Array<String> {
val listsDir = File("${getZaprettPath()}/lists/")
if (listsDir.exists() && listsDir.isDirectory) {
val onlyNames = listsDir.list() ?: return emptyArray()
return onlyNames.map { "$listsDir/$it" }.toTypedArray()
}
return emptyArray()
}
fun getAllNfqwsStrategies(): Array<String> {
val listsDir = File("${getZaprettPath()}/strategies/nfqws")
if (listsDir.exists() && listsDir.isDirectory) {
val onlyNames = listsDir.list() ?: return emptyArray()
return onlyNames.map { "$listsDir/$it" }.toTypedArray()
}
return emptyArray()
}
fun getAllByeDPIStrategies(): Array<String> {
val listsDir = File("${getZaprettPath()}/strategies/byedpi")
if (listsDir.exists() && listsDir.isDirectory) {
val onlyNames = listsDir.list() ?: return emptyArray()
return onlyNames.map { "$listsDir/$it" }.toTypedArray()
}
return emptyArray()
}
fun getActiveLists(sharedPreferences: SharedPreferences): Array<String> {
if (sharedPreferences.getBoolean("use_module", false)) {
val configFile = File("${getZaprettPath()}/config")
if (configFile.exists()) {
val props = Properties()
return try {
FileInputStream(configFile).use { input ->
props.load(input)
}
val activeLists = props.getProperty("activelists", "")
Log.d("Active lists", activeLists)
if (activeLists.isNotEmpty()) activeLists.split(",")
.toTypedArray() else emptyArray()
} catch (e: IOException) {
throw RuntimeException(e)
}
}
return emptyArray()
}
else {
return sharedPreferences.getStringSet("lists", emptySet())?.toTypedArray() ?: emptyArray()
}
}
fun getActiveNfqwsStrategies(): 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 getActiveByeDPIStrategies(sharedPreferences: SharedPreferences): Array<String> {
val path = sharedPreferences.getString("active_strategy", "")
if (!path.isNullOrBlank() && File(path).exists()) {
return arrayOf(path)
}
return emptyArray()
}
fun getActiveStrategy(sharedPreferences: SharedPreferences): List<String> {
val path = sharedPreferences.getString("active_strategy", "")
if (!path.isNullOrBlank() && File(path).exists()) {
return File(path).readLines()
}
return emptyList()
}
fun enableList(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
val props = Properties()
val configFile = getConfigFile()
try {
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
}
val activeLists = props.getProperty("activelists", "")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (path !in activeLists) {
activeLists.add(path)
}
props.setProperty("activelists", activeLists.joinToString(","))
FileOutputStream(configFile).use { output ->
props.store(output, "Don't place '/' in end of directory! Example: /sdcard")
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
else {
val currentSet = sharedPreferences.getStringSet("lists", emptySet())?.toMutableSet() ?: mutableSetOf()
if (path !in currentSet) {
currentSet.add(path)
sharedPreferences.edit { putStringSet("lists", currentSet) }
}
}
}
fun enableStrategy(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
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)
}
}
else {
sharedPreferences.edit { putString("active_strategy", path) }
}
}
fun disableList(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
val props = Properties()
val configFile = getConfigFile()
try {
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
}
val activeLists = props.getProperty("activelists", "")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (path in activeLists) {
activeLists.remove(path)
}
props.setProperty("activelists", activeLists.joinToString(","))
FileOutputStream(configFile).use { output ->
props.store(output, "Don't place '/' in end of directory! Example: /sdcard")
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
else {
val currentSet = sharedPreferences.getStringSet("lists", emptySet())?.toMutableSet() ?: mutableSetOf()
if (path in currentSet) {
currentSet.remove(path)
sharedPreferences.edit { putStringSet("lists", currentSet) }
}
if (currentSet.isEmpty()) {
sharedPreferences.edit { remove("lists") }
}
}
}
fun disableStrategy(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
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)
}
}
else {
sharedPreferences.edit { remove("active_strategy") }
}
}

View File

@@ -0,0 +1,58 @@
package com.cherret.zaprett.utils
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import com.cherret.zaprett.R
class QSTileService: TileService() {
override fun onTileAdded() {
super.onTileAdded()
updateStatus()
}
override fun onStartListening() {
super.onStartListening()
updateStatus()
}
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()
}
private 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,119 @@
package com.cherret.zaprett.utils
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.content.SharedPreferences
import android.net.Uri
import android.os.Build
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
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()
private val json = Json { ignoreUnknownKeys = true }
fun getHostList(sharedPreferences: SharedPreferences, callback: (List<RepoItemInfo>?) -> Unit) {
val request = Request.Builder().url(sharedPreferences.getString("hosts_repo_url","https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json")?: "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.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) {
callback(null)
}
val jsonString = response.body.string()
val hostsInfo = json.decodeFromString<List<RepoItemInfo>>(jsonString)
callback(hostsInfo)
}
}
})
}
fun getStrategiesList(sharedPreferences: SharedPreferences, callback: (List<RepoItemInfo>?) -> Unit) {
val request = Request.Builder().url(sharedPreferences.getString("strategies_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json")?: "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) {
callback(null)
}
val jsonString = response.body.string()
val strategiesInfo = json.decodeFromString<List<RepoItemInfo>>(jsonString)
callback(strategiesInfo)
}
}
})
}
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) }
}
@Serializable
data class RepoItemInfo(
val name: String,
val author: String,
val description: String,
val type: String? = null,
val hash: String,
val url: String
)

View File

@@ -0,0 +1,138 @@
package com.cherret.zaprett.utils
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.cherret.zaprett.BuildConfig
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
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()
private val json = Json { ignoreUnknownKeys = true }
fun getUpdate(callback: (UpdateInfo?) -> Unit) {
val request = Request.Builder().url("https://raw.githubusercontent.com/CherretGit/zaprett-app/refs/heads/main/update.json").build()
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 = json.decodeFromString<UpdateInfo>(jsonString)
updateInfo?.versionCode?.let { versionCode ->
if (versionCode > BuildConfig.VERSION_CODE)
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
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
if (!context.packageManager.canRequestPackageInstalls()){
val packageUri = Uri.fromParts("package", context.packageName, null)
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, packageUri)
context.startActivity(intent)
}
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)
}
}
@Serializable
data class UpdateInfo(
val version: String?,
val versionCode: Int?,
val downloadUrl: String?,
val changelogUrl: String?
)

View File

@@ -0,0 +1,16 @@
# Copyright (C) 2023 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
include $(call all-subdir-makefiles)

View File

@@ -0,0 +1,21 @@
# Copyright (C) 2023 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
APP_OPTIM := release
APP_PLATFORM := android-21
APP_ABI := armeabi-v7a arm64-v8a x86 x86_64
APP_CFLAGS := -O3 -DPKGNAME=com/cherret/zaprett/byedpi
APP_CPPFLAGS := -O3 -std=c++11
NDK_TOOLCHAIN_VERSION := clang

View File

@@ -2,33 +2,82 @@
<resources>
<string name="title_home">Главная</string>
<string name="title_hosts">Хосты</string>
<string name="title_strategies">Стратегии</string>
<string name="title_repo">Репозиторий</string>
<string name="title_settings">Настройки</string>
<string name="general_section">Основные настройки</string>
<string name="byedpi_section">Настройки ByeDPI</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="btn_use_root">Использовать модуль</string>
<string name="error_root_title">Root не получен</string>
<string name="error_root_message">Не получилось получить root доступ. Дайте права root для использования модуля Magisk</string>
<string name="error_no_module_title">Модуль не установлен</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="title_strategy">Стратегия</string>
<string name="status_not_availible">Состояние zaprett неизвестно. Нажми для обновления</string>
<string name="status_enabled">Сервис zaprett работает. Нажми для обновления состояния</string>
<string name="status_disabled">Сервис zaprett не работает. Нажми для обновления состояния</string>
<string name="status_crashed">Сервис zaprett вероятно крашнулся. Нажмите кнопку перезапуска ниже</string>
<string name="update_available">Доступно новое обновление!</string>
<string name="btn_update_on_boot">Обновлять статус при запуске Главной страницы</string>
<string name="btn_start_service">Запустить сервис</string>
<string name="btn_stop_service">Остановить сервис</string>
<string name="btn_restart_service">Перезапустить сервис</string>
<string name="notification_zaprett_description">zaprett proxy запущен</string>
<string name="toast_no_strategy_selected">Стратегия не выбрана</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="empty_list">Пусто :(</string>
<string name="title_author">Автор: %1$s</string>
<string name="btn_install_host">Установить</string>
<string name="btn_update_host">Обновить</string>
<string name="btn_installing_host">Установка</string>
<string name="btn_updating_host">Обновление</string>
<string name="btn_installed_host">Установленно</string>
<string name="btn_autorestart">Переодически перезапускать сервис</string>
<string name="btn_autoupdate">Авто обновление</string>
<string name="btn_send_firebase_analytics">Отправлять аналитику Firebase</string>
<string name="btn_repository_url_lists">URL репозитория хостов</string>
<string name="hint_enter_repository_url_lists">Введите URL репозитория хостов</string>
<string name="btn_repository_url_strategies">URL репозитория стратегий</string>
<string name="hint_enter_repository_url_strategies">Введите URL репозитория стратегий</string>
<string name="btn_ipv6">IPv6</string>
<string name="btn_ip">IP-адрес</string>
<string name="hint_ip">Введите IP-адрес</string>
<string name="btn_port">Порт</string>
<string name="hint_port">Введите порт</string>
<string name="btn_dns">DNS</string>
<string name="hint_dns">Введите DNS</string>
<string name="alert_version">Версия: %1$s → %2$s\nСписок изменений:\n%3$s</string>
<string name="snack_already_started">Сервис уже запущен.</string>
<string name="snack_starting_service">Запускаем сервис...</string>
<string name="snack_starting_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="notification_permission_title">Разрешение на уведомления</string>
<string name="notification_permission_message">Приложению нужно разрешение на отправку уведомлений</string>
<string name="error_no_storage_message">Для правильной работы приложения необходимо разрешение на доступ к хранилищу</string>
<string name="btn_show_full_path">Показывать полный путь к листу</string>
<string name="pls_reboot_snack">Перезагрузите устройство для вступления изменений в силу</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="module_version">Версия модуля</string>
<string name="nfqws_version">Версия nfqws</string>
<string name="ciadpi_version">Версия ciadpi</string>
<string name="unknown_text">неизвестно</string>
<string name="service_mode">Режим работы</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, egor-white\nВерсия: %1$s</string>
</resources>

View File

@@ -2,33 +2,85 @@
<string name="app_name" translatable="false">zaprett</string>
<string name="title_home">Home</string>
<string name="title_hosts">Hosts</string>
<string name="title_strategies">Strategies</string>
<string name="title_repo">Repository</string>
<string name="title_settings">Settings</string>
<string name="general_section">General settings</string>
<string name="byedpi_section">ByeDPI settings</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="btn_use_root">Use module</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_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="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="title_strategy">Strategy</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_disabled">zaprett service disabled.</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_start_service">Start service</string>
<string name="btn_stop_service">Stop service</string>
<string name="btn_restart_service">Restart service</string>
<string name="btn_autorestart">Restart service periodicaly</string>
<string name="notification_zaprett_proxy" translatable="false">zaprett proxy</string>
<string name="notification_zaprett_description">zaprett proxy is running</string>
<string name="toast_no_strategy_selected">No strategy selected</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="empty_list">Empty :(</string>
<string name="title_author">Author: %1$s</string>
<string name="btn_install_host">Install</string>
<string name="btn_update_host">Update</string>
<string name="btn_installing_host">Installing</string>
<string name="btn_updating_host">Updating</string>
<string name="btn_installed_host">Installed</string>
<string name="btn_autorestart">Restart service periodically</string>
<string name="btn_autoupdate">Autoupdate</string>
<string name="btn_send_firebase_analytics">Send Firebase Analytics</string>
<string name="btn_repository_url_lists">Hosts repository URL</string>
<string name="hint_enter_repository_url_lists">Enter hosts repository URL</string>
<string name="btn_repository_url_strategies">Strategies repository URL</string>
<string name="hint_enter_repository_url_strategies">Enter strategies repository URL</string>
<string name="btn_ipv6">IPv6</string>
<string name="btn_ip">IP address</string>
<string name="hint_ip">Enter IP address</string>
<string name="btn_port">Port</string>
<string name="hint_port">Enter port</string>
<string name="btn_dns">DNS</string>
<string name="hint_dns">Enter DNS</string>
<string name="alert_version">Version: %1$s → %2$s\nChangelog:\n%3$s</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_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_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="notification_permission_title">Notification Permission</string>
<string name="notification_permission_message">This app needs permission to send notifications</string>
<string name="btn_show_full_path">Show full list\'s path</string>
<string name="pls_reboot_snack">Reboot your device for the changes to take effect</string>
<string name="pls_restart_snack">Restart zaprett service for the changes to take effects</string>
<string name="qs_name">Zaprett</string>
<string name="qs_starting">Starting…</string>
<string name="qs_stopping">Stopping…</string>
<string name="module_version">Module version</string>
<string name="nfqws_version">nfqws version</string>
<string name="ciadpi_version">ciadpi version</string>
<string name="unknown_text">unknown</string>
<string name="service_mode">Service mode</string>
<string name="service_mode_nfqws" translatable="false">nfqws</string>
<string name="service_mode_ciadpi" translatable="false">ciadpi</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, egor-white\nVersion: %1$s</string>
</resources>

View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Zaprett" parent="android:Theme.Material.Light.NoActionBar" />
</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.kotlin.android) 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,3 @@
Исправление ошибок
Система обнновлений
Исправление багов в фрагменте хостов
Исправление сплющивания карточки информации о модуле
Перенос версии byedpi в соответствующее место

View File

@@ -1,5 +1,5 @@
[versions]
agp = "8.9.0"
agp = "8.10.1"
kotlin = "2.0.21"
coreKtx = "1.10.1"
junit = "4.13.2"
@@ -8,6 +8,7 @@ espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.6.1"
activityCompose = "1.8.0"
composeBom = "2024.09.00"
lifecycleService = "2.9.1"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 134 KiB

BIN
images/4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 KiB

BIN
images/5.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

BIN
images/6.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

View File

@@ -1,6 +1,6 @@
{
"version": "1.2",
"versionCode": 3,
"downloadUrl": "https://github.com/CherretGit/zaprett-app/releases/download/1.2.0/app-release.apk",
"version": "2.1",
"versionCode": 13,
"downloadUrl": "https://github.com/CherretGit/zaprett-app/releases/download/2_1/app-release.apk",
"changelogUrl": "https://raw.githubusercontent.com/CherretGit/zaprett-app/refs/heads/main/changelog.md"
}