91 Commits
2_7_0 ... m3e

Author SHA1 Message Date
CherretGit
80abf2380a Merge pull request #25 from CherretGit/main
bump version and optimize libbyedpi
2025-12-08 23:16:54 +07:00
CherretGit
35b0068b8c optimize libbyedpi 2025-12-08 23:11:49 +07:00
cheesedroid
cdcd8b85b5 Update build.gradle.kts 2025-12-08 18:11:45 +03:00
white
fd69451adc Merge remote-tracking branch 'origin/m3e' into m3e
# Conflicts:
#	app/src/main/java/com/cherret/zaprett/ui/screen/StrategySelectionScreen.kt
2025-12-08 12:58:26 +03:00
white
611e8ae39c remove unnecessary modifier, return OptIn 2025-12-08 12:57:35 +03:00
CherretGit
21e80d0463 Merge pull request #24 from CherretGit/main
remove unnecessary modifier
2025-12-08 16:57:12 +07:00
white
98f2af3853 update branch from main 2025-12-08 12:50:33 +03:00
CherretGit
2dd181494a remove unnecessary modifier 2025-12-08 16:19:47 +07:00
white
77823252f2 add autostop on strategy testing error 2025-12-08 12:07:38 +03:00
CherretGit
87a9740aad remove unused import 2025-12-08 16:04:52 +07:00
CherretGit
efb60d0c93 fix shadow 2025-12-08 16:02:44 +07:00
white
f0c3baa3bd change NoHostsCard and it's logic, added getAllStrategies(prefs).isEmpty() testing 2025-12-08 11:44:15 +03:00
CherretGit
d00677270a update dependencies + m3e 2025-12-07 20:58:01 +07:00
white
57217bbe1c add module error handling (and showing) in HostsScreen.kt, IpsetsScreen.kt, StrategyScreen.kt, StrategySelectionScreen.kt 2025-12-04 17:43:04 +03:00
white
6f9bd86d70 update agp 2025-12-04 16:45:32 +03:00
white
80f5934060 remove unwanted popBackStack() in error dialog 2025-12-04 16:25:56 +03:00
white
0065b8a92b added module errors handling on service start, stop, restart, added automated service status updating on start, stop, restart 2025-12-04 16:08:05 +03:00
CherretGit
b5e3d256a6 Merge remote-tracking branch 'origin/main' 2025-11-29 17:33:31 +07:00
CherretGit
77bbd41609 fix strings 2025-11-29 17:33:22 +07:00
CherretGit
4e1568b405 Update workflow.yml 2025-11-26 19:26:13 +07:00
CherretGit
b8844c8cca add reset settings option 2025-11-22 17:53:14 +07:00
CherretGit
1d6eb32473 Update changelog.md 2025-11-04 02:46:33 +07:00
CherretGit
ded89165dc Update changelog.md 2025-11-04 02:46:16 +07:00
CherretGit
ed7452b7cb Update update.json 2025-11-04 02:43:59 +07:00
CherretGit
717368b4e9 bump version 2025-11-03 18:03:43 +07:00
CherretGit
36885a8410 fix some crashed from firebase crashlytics 2025-11-03 17:59:27 +07:00
CherretGit
ad614b3b4a fix ByeDpiVpnService 2025-11-03 02:19:06 +07:00
CherretGit
5c8425573a change commands in ZaprettManager 2025-11-03 02:05:14 +07:00
CherretGit
59e30206f7 Merge remote-tracking branch 'origin/main' 2025-10-30 21:42:50 +07:00
CherretGit
c4dcf934aa Config to json 2025-10-30 21:42:43 +07:00
egor-white
1bbeb4e987 Update README.md 2025-10-27 16:49:10 +03:00
egor-white
003a4c57a3 update changelog 2025-10-18 15:57:54 +03:00
egor-white
432873409c Update update.json 2025-10-18 15:57:20 +03:00
CherretGit
c5b60b1ee9 use card onClick in settings items 2025-10-18 19:41:51 +07:00
CherretGit
2f349e5108 bump app version 2025-10-18 19:29:27 +07:00
CherretGit
e8a8fa69db fix JNI seg fault 2025-10-18 19:21:51 +07:00
CherretGit
c094a072b8 fix app list mode 2025-10-18 18:46:40 +07:00
white
786321d852 edit strings.xml 2025-10-18 12:57:21 +03:00
white
7a17c93622 change selection screen title font size 2025-10-18 12:55:56 +03:00
white
cf38ef7ac2 some strategy selection interface changes 2025-10-18 12:20:30 +03:00
CherretGit
94979d2b2e fix testDomain 2025-10-18 12:42:06 +07:00
CherretGit
bd1bdf8298 Merge remote-tracking branch 'origin/main' 2025-10-18 01:56:19 +07:00
CherretGit
adc98db3f1 add info in strategy selection 2025-10-18 01:56:11 +07:00
CherretGit
9bbc3b771f filter list/ipset/strategy files to only .txt 2025-10-17 15:37:59 +07:00
egor-white
51356e63eb Update update.json 2025-10-16 16:43:08 +03:00
egor-white
f3f2cf999c Update changelog.md 2025-10-16 16:42:32 +03:00
white
69f395275e change build.gradle version 2025-10-16 16:18:03 +03:00
CherretGit
39dc2baad3 Merge remote-tracking branch 'origin/main' 2025-10-16 19:46:05 +07:00
CherretGit
ad5c556241 fix ipsets loading 2025-10-16 19:45:57 +07:00
white
48e7497e79 add no hosts dialog, optimize imports 2025-10-16 15:45:19 +03:00
white
2954cef0a6 small fixes, added vpn permission request before selection 2025-10-14 20:31:05 +03:00
CherretGit
c49cce1de4 fix JNI 2025-10-14 23:05:49 +07:00
white
4786ef8faf optimise sum imports, fix two active strategies 2025-10-12 19:48:44 +03:00
white
0b69d2bf4d add automatic strategy selection, remove phantom switch in settings, add ipset repository url setting, made some refactoring, disable analytics and autoupdate in debug releases by default, etc 2025-10-12 19:01:22 +03:00
CherretGit
9208885de4 Merge remote-tracking branch 'origin/main' 2025-10-10 16:51:45 +07:00
CherretGit
44fbb5fa98 useModule to view model 2025-10-10 16:51:37 +07:00
CherretGit
7c2fba684d Update workflow.yml 2025-10-09 23:19:20 +07:00
CherretGit
3ac0ca73e1 Update workflow.yml 2025-10-09 23:07:12 +07:00
CherretGit
abb3a8e4e4 Update workflow.yml 2025-10-09 22:57:43 +07:00
CherretGit
73cbd89390 Update workflow.yml 2025-10-09 22:49:55 +07:00
CherretGit
8cfe6f4389 Update workflow.yml 2025-10-09 22:39:42 +07:00
CherretGit
9a89fcccae Update workflow.yml 2025-10-09 22:38:50 +07:00
CherretGit
cd44a09cfc git submodule fix 2025-10-09 01:05:33 +07:00
CherretGit
14d6ea6caa rewrite JNI to Rust 2025-10-09 00:31:05 +07:00
white
6caff9b611 change repo source to ipsets, add ipset parsing to byedpi 2025-09-30 19:24:15 +03:00
white
5eba2461dd add ipsets screen 2025-09-30 19:00:55 +03:00
CherretGit
5a6bf9780b ipset repo and autostart 2025-09-30 20:35:46 +07:00
CherretGit
ce6e1f07b8 Merge remote-tracking branch 'origin/main' 2025-09-30 18:40:25 +07:00
CherretGit
1e985807d9 micro bug fix 2025-09-30 18:40:12 +07:00
CherretGit
80d885167e Update update.json 2025-08-31 22:38:42 +07:00
CherretGit
01f4333fee Update changelog.md 2025-08-31 22:38:21 +07:00
CherretGit
c67846a7e4 bump version 2025-08-31 22:19:48 +07:00
white
c4da9d1c29 add updater error handling 2025-08-31 17:58:10 +03:00
CherretGit
0386fbbccd Merge remote-tracking branch 'origin/main' 2025-08-31 03:08:49 +07:00
CherretGit
109042d5e7 add DebugScreen, markdown in update dialog 2025-08-31 03:08:42 +07:00
CherretGit
34595512d0 Update README.md 2025-08-30 12:59:54 +07:00
CherretGit
7d2b969337 Merge remote-tracking branch 'origin/main' 2025-08-30 02:18:57 +07:00
CherretGit
da66743014 move UI elements to components 2025-08-30 02:18:49 +07:00
CherretGit
6146504125 Update update.json 2025-08-28 02:54:12 +07:00
CherretGit
0d8deb2e9f Update changelog.md 2025-08-28 02:54:04 +07:00
CherretGit
fd06b6c75b navigationBarsPadding in repo screen 2025-08-28 02:35:33 +07:00
CherretGit
ce91b578b8 bump version 2025-08-28 02:31:53 +07:00
CherretGit
68b25d8efa edit button in settings 2025-08-28 02:26:50 +07:00
CherretGit
b8d26a1a57 handle file download error 2025-08-28 02:18:38 +07:00
CherretGit
e6ffe78eb1 fix byedpi blacklist (again) 2025-08-28 01:08:27 +07:00
CherretGit
d754abf794 move card status update to ViewModel 2025-08-28 01:03:32 +07:00
CherretGit
2bdc69b1ba move card status update to ViewModel 2025-08-28 01:03:28 +07:00
CherretGit
902b2939cd remove spacer 2025-08-27 19:11:16 +07:00
CherretGit
c70752f8c5 Fix UI errors 2025-08-27 19:05:17 +07:00
CherretGit
a385e498ea fix repo 2025-08-27 17:43:33 +07:00
CherretGit
3f6086c11f add repository error handling, fix Byedpi blacklist, possible UI error fix 2025-08-27 04:18:49 +07:00
59 changed files with 3474 additions and 2138 deletions

View File

@@ -37,6 +37,19 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v2
- name: Setup NDK
run: sdkmanager "ndk;27.0.12077973" "cmake;3.22.1"
- name: Install toolchain
run: |
rustup default stable
rustup update stable
cargo install cargo-ndk
rustup target add armv7-linux-androideabi
rustup target add aarch64-linux-android
rustup target add i686-linux-android
rustup target add x86_64-linux-android
- name: Build APK
run: ./gradlew assembleRelease

4
.gitmodules vendored
View File

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

View File

@@ -2,5 +2,26 @@
<project version="4">
<component name="AppInsightsSettings">
<option name="selectedTabId" value="Android Vitals" />
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="com.cherret.zaprett" />
<option name="mobileSdkAppId" value="1:1005804036856:android:e7db5546b8bb4daf91510d" />
<option name="projectId" value="zaprett-app" />
<option name="projectNumber" value="1005804036856" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>

View File

@@ -4,10 +4,10 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-07-09T09:45:05.074315845Z">
<DropdownSelection timestamp="2025-10-03T07:21:08.712998131Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/white/.android/avd/Pixel_8.avd" />
<DeviceId pluginId="LocalEmulator" identifier="path=/home/dimap/.android/avd/Medium_Phone.avd" />
</handle>
</Target>
</DropdownSelection>

13
.idea/deviceManager.xml generated Normal file
View File

@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>

2
.idea/vcs.xml generated
View File

@@ -2,11 +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" />
<mapping directory="$PROJECT_DIR$/rust/byedpi" vcs="Git" />
</component>
</project>

View File

@@ -1,19 +1,42 @@
# zaprett
## О приложении
![GitHub Downloads (all assets, all releases)](https://img.shields.io/github/downloads/CherretGit/zaprett-app/total)
![GitHub Downloads (all assets, latest release)](https://img.shields.io/github/downloads/CherretGit/zaprett-app/latest/total)
![GitHub Actions Workflow Status](https://img.shields.io/github/actions/workflow/status/CherretGit/zaprett-app/workflow.yml)
Приложение разработано для работы с модулем [zaprett](https://github.com/egor-white/zaprett)
### [Официальный Telegram-канал приложения](https://t.me/zaprett_module)
## ВНИМАНИЕ приложению желательно наличие root прав на устройстве, но есть режим работы без них на основе byedpi
> [!IMPORTANT]
> 📢 [Официальный Telegram-канал приложения](https://t.me/zaprett_module)
> [!CAUTION]
> ⚠️ Для корректной работы приложения **желательны root-права**, однако предусмотрен режим без root на основе **byedpi**
---
На данный момент приложение умеет:
* Запускать, останавливать и перезапускать сервис
* Работа с листами (добавление, включение и выключение, загрузка из репозитория)
* Работа с стратегиями (добавление, выбор, загрузка из репозитория)
* Авто обновление приложения
---
## [Репозиторий с хостами и стратегиями](https://github.com/CherretGit/zaprett-hosts-repo)
---
#### Данное приложение является ремейком [приложения](https://github.com/egor-white/zaprett-app) от [egor-white](https://github.com/egor-white), разработка которого была прекращена в пользу этого приложения
---
## Скриншоты:
<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">
<img src="images/7.png" width="300">
<p align="center">
<img src="images/1.png" width="180">
<img src="images/2.png" width="180">
<img src="images/3.png" width="180"><br>
<img src="images/4.png" width="180">
<img src="images/5.png" width="180">
<img src="images/6.png" width="180"><br>
<img src="images/7.png" width="180">
</p>

View File

@@ -5,6 +5,7 @@ plugins {
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
kotlin("plugin.serialization") version "2.1.20"
id("org.mozilla.rust-android-gradle.rust-android") version "0.9.6"
}
android {
@@ -15,19 +16,10 @@ android {
applicationId = "com.cherret.zaprett"
minSdk = 29
targetSdk = 35
versionCode = 19
versionName = "2.7"
versionCode = 25
versionName = "2.13"
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 {
@@ -37,6 +29,12 @@ android {
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
buildConfigField("boolean", "send_firebase_analytics", "true")
buildConfigField("boolean", "auto_update", "true")
}
debug {
buildConfigField("boolean", "send_firebase_analytics", "false")
buildConfigField("boolean", "auto_update", "false")
}
}
compileOptions {
@@ -75,6 +73,27 @@ tasks.preBuild {
dependsOn("runNdkBuild")
}
cargo {
module = "../rust"
libname = "byedpi"
targets = listOf("arm", "arm64", "x86", "x86_64")
profile = "release"
}
tasks.preBuild {
dependsOn("cargoBuild")
}
tasks.register<Exec>("cargoClean") {
workingDir = file("../rust")
commandLine("cargo", "clean")
group = "build"
}
tasks.named("clean") {
dependsOn("cargoClean")
}
dependencies {
implementation(libs.compose.material3)
implementation(libs.compose.material3.window.size)
@@ -89,6 +108,7 @@ dependencies {
implementation(libs.firebase.crashlytics)
implementation(libs.fragment.compose)
implementation(libs.coil.compose)
implementation(libs.compose.markdown)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)

View File

@@ -1,83 +0,0 @@
#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

@@ -1,100 +0,0 @@
#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;
}

View File

@@ -1,594 +0,0 @@
#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;
}

View File

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

View File

@@ -1,3 +1,4 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package com.cherret.zaprett
import android.Manifest
@@ -22,7 +23,9 @@ 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.material.icons.filled.SettingsInputComposite
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
@@ -46,16 +49,21 @@ import androidx.navigation.compose.composable
import androidx.navigation.compose.currentBackStackEntryAsState
import androidx.navigation.compose.rememberNavController
import androidx.navigation.navArgument
import com.cherret.zaprett.ui.screen.DebugScreen
import com.cherret.zaprett.ui.screen.HomeScreen
import com.cherret.zaprett.ui.screen.HostsScreen
import com.cherret.zaprett.ui.screen.IpsetsScreen
import com.cherret.zaprett.ui.screen.RepoScreen
import com.cherret.zaprett.ui.screen.SettingsScreen
import com.cherret.zaprett.ui.screen.StrategyScreen
import com.cherret.zaprett.ui.screen.StrategySelectionScreen
import com.cherret.zaprett.ui.theme.ZaprettTheme
import com.cherret.zaprett.ui.viewmodel.HomeViewModel
import com.cherret.zaprett.ui.viewmodel.HostRepoViewModel
import com.cherret.zaprett.ui.viewmodel.IpsetRepoViewModel
import com.cherret.zaprett.ui.viewmodel.StrategyRepoViewModel
import com.cherret.zaprett.utils.checkModuleInstallation
import com.cherret.zaprett.utils.checkStoragePermission
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics
@@ -64,10 +72,11 @@ sealed class Screen(val route: String, @StringRes val nameResId: Int, val icon:
object home : Screen("home", R.string.title_home, Icons.Default.Home)
object hosts : Screen("hosts", R.string.title_hosts, Icons.Default.Lan)
object strategies : Screen("strategies", R.string.title_strategies, Icons.Default.MultipleStop)
object ipsets : Screen("ipsets", R.string.title_ipset, Icons.Default.SettingsInputComposite)
object settings : Screen("settings", R.string.title_settings, Icons.Default.Settings)
}
val topLevelRoutes = listOf(Screen.home, Screen.hosts, Screen.strategies, Screen.settings)
val hideNavBar = listOf("repo?source={source}")
val topLevelRoutes = listOf(Screen.home, Screen.hosts, Screen.strategies, Screen.ipsets, Screen.settings)
val hideNavBar = listOf("repo?source={source}", "debugScreen", "selectionScreen")
class MainActivity : ComponentActivity() {
private val viewModel: HomeViewModel by viewModels()
private lateinit var notificationPermissionLauncher: ActivityResultLauncher<String>
@@ -104,16 +113,7 @@ class MainActivity : ComponentActivity() {
}
}
var showStoragePermissionDialog by remember {
mutableStateOf(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
!Environment.isExternalStorageManager()
} else {
ContextCompat.checkSelfPermission(
this,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) != PackageManager.PERMISSION_GRANTED
}
)
mutableStateOf(!checkStoragePermission(this))
}
var showNotificationPermissionDialog by remember {
mutableStateOf(
@@ -122,7 +122,7 @@ class MainActivity : ComponentActivity() {
)
}
var showWelcomeDialog by remember { mutableStateOf(sharedPreferences.getBoolean("welcome_dialog", true)) }
firebaseAnalytics.setAnalyticsCollectionEnabled(sharedPreferences.getBoolean("send_firebase_analytics", true))
firebaseAnalytics.setAnalyticsCollectionEnabled(sharedPreferences.getBoolean("send_firebase_analytics", BuildConfig.send_firebase_analytics))
BottomBar()
if (showStoragePermissionDialog) {
PermissionDialog(
@@ -189,7 +189,7 @@ class MainActivity : ComponentActivity() {
contentDescription = stringResource(id = topLevelRoute.nameResId)
)
},
label = { Text(text = stringResource(id = topLevelRoute.nameResId)) },
label = { Text(text = stringResource(id = topLevelRoute.nameResId)) }, alwaysShowLabel = false,
selected = currentDestination?.route == topLevelRoute.route,
onClick = {
navController.navigate(topLevelRoute.route) {
@@ -214,7 +214,8 @@ class MainActivity : ComponentActivity() {
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(Screen.ipsets.route) { IpsetsScreen(navController) }
composable(Screen.settings.route) { SettingsScreen(navController) }
composable(route = "repo?source={source}",arguments = listOf(navArgument("source") {})) { backStackEntry ->
val source = backStackEntry.arguments?.getString("source")
when (source) {
@@ -222,12 +223,18 @@ class MainActivity : ComponentActivity() {
val viewModel: HostRepoViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
RepoScreen(navController, viewModel)
}
"ipsets" -> {
val viewModel: IpsetRepoViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
RepoScreen(navController, viewModel)
}
"strategies" -> {
val viewModel: StrategyRepoViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
RepoScreen(navController, viewModel)
}
}
}
composable("debugScreen") { DebugScreen(navController) }
composable("selectionScreen") { StrategySelectionScreen(navController, vpnPermissionLauncher) }
}
}
}
@@ -259,5 +266,4 @@ class MainActivity : ComponentActivity() {
}
)
}
}

View File

@@ -14,7 +14,11 @@ import androidx.core.app.NotificationCompat
import com.cherret.zaprett.MainActivity
import com.cherret.zaprett.R
import com.cherret.zaprett.data.ServiceStatus
import com.cherret.zaprett.utils.disableList
import com.cherret.zaprett.utils.getActiveByeDPIStrategyContent
import com.cherret.zaprett.utils.getActiveExcludeIpsets
import com.cherret.zaprett.utils.getActiveExcludeLists
import com.cherret.zaprett.utils.getActiveIpsets
import com.cherret.zaprett.utils.getActiveLists
import com.cherret.zaprett.utils.getActiveStrategy
import com.cherret.zaprett.utils.getAppsListMode
@@ -174,7 +178,7 @@ class ByeDpiVpnService : VpnService() {
try {
vpnInterface?.close()
vpnInterface = null
NativeBridge().stopProxy()
NativeBridge().jniStopProxy()
TProxyService.TProxyStopService()
status = ServiceStatus.Disconnected
} catch (e: Exception) {
@@ -186,9 +190,16 @@ class ByeDpiVpnService : VpnService() {
val socksIp = sharedPreferences.getString("ip", "127.0.0.1")?: "127.0.0.1"
val socksPort = sharedPreferences.getString("port", "1080")?: "1080"
val listSet = if (getHostListMode(sharedPreferences) == "whitelist") getActiveLists(sharedPreferences) else getActiveExcludeLists(sharedPreferences)
val ipsetSet = if (getHostListMode(sharedPreferences) == "whitelist") getActiveIpsets(sharedPreferences) else getActiveExcludeIpsets(sharedPreferences)
CoroutineScope(Dispatchers.IO).launch {
val args = parseArgs(socksIp, socksPort, getActiveStrategy(sharedPreferences), prepareList(listSet), sharedPreferences)
val result = NativeBridge().startProxy(args)
val args = parseArgs(
socksIp,
socksPort,
getActiveByeDPIStrategyContent(sharedPreferences),
prepareList(listSet),
prepareIpset(ipsetSet),
sharedPreferences)
val result = NativeBridge().jniStartProxy(args)
if (result < 0) {
Log.d("proxy","Failed to start byedpi proxy")
} else {
@@ -205,11 +216,15 @@ class ByeDpiVpnService : VpnService() {
withContext(Dispatchers.IO) {
hostlist.printWriter().use { out ->
lists.forEach {
if (it.exists()) {
it.bufferedReader().useLines {
it.forEach {
out.println(it)
}
}
} else {
disableList(it.name, sharedPreferences)
}
}
}
}
@@ -218,20 +233,52 @@ class ByeDpiVpnService : VpnService() {
return ""
}
private fun parseArgs(ip: String, port: String, rawArgs: List<String>, list : String, sharedPreferences: SharedPreferences): Array<String> {
private suspend fun prepareIpset(actsets: Array<String>): String {
if (actsets.isNotEmpty()) {
val lists: Array<File> = actsets.map { File(it) }.toTypedArray()
val hostlist = withContext(Dispatchers.IO) {
File.createTempFile("ipset", ".txt", cacheDir)
}.apply { deleteOnExit() }
withContext(Dispatchers.IO) {
hostlist.printWriter().use { out ->
lists.forEach {
if (it.exists()) {
it.bufferedReader().useLines {
it.forEach {
out.println(it)
}
}
} else {
disableList(it.name, sharedPreferences)
}
}
}
}
return hostlist.absolutePath
}
return ""
}
private fun parseArgs(ip: String, port: String, rawArgs: List<String>, list : String, ipset : String, sharedPreferences: SharedPreferences): Array<String> {
val regex = Regex("""--?\S+(?:=(?:[^"'\s]+|"[^"]*"|'[^']*'))?|[^\s]+""")
val parsedArgs = rawArgs
.flatMap { args -> regex.findAll(args).map { it.value } }
.flatMap { arg ->
if (getHostListMode(sharedPreferences) == "whitelist") {
when {
arg == "\$hostlist" && list.isNotEmpty() -> listOf("-H", list)
arg == "\$hostlist" && list.isNotEmpty() -> listOf("--hosts", list)
arg == "\$hostlist" && list.isEmpty() -> emptyList()
arg == "\$ipset" && list.isNotEmpty() -> listOf("--ipset", list)
arg == "\$ipset" && list.isEmpty() -> emptyList()
else -> listOf(arg)
}
} else {
when {
arg == "\$hostlist" && list.isNotEmpty() -> listOf("--hosts", list, "-An", list)
arg == "\$ipset" && ipset.isNotEmpty() -> listOf("--ipset", ipset, "-An", ipset)
arg == "\$hostlist" || arg == "\$ipset" -> emptyList()
else -> listOf(arg)
}
else {
listOf("-H", list, "-An", arg)
}
}
.toMutableList()

View File

@@ -6,16 +6,7 @@ class NativeBridge {
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
external fun jniStartProxy(args: Array<String>): Int
external fun jniStopProxy(): Int
}

View File

@@ -1,5 +1,5 @@
package com.cherret.zaprett.data
enum class ItemType {
byedpi, nfqws, list, list_exclude
byedpi, nfqws, list, list_exclude, ipset, ipset_exclude
}

View File

@@ -0,0 +1,11 @@
package com.cherret.zaprett.data
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.ui.graphics.vector.ImageVector
import com.cherret.zaprett.R
data class ServiceStatusUI(
val textRes: Int = R.string.status_not_availible,
val icon: ImageVector = Icons.AutoMirrored.Filled.Help
)

View File

@@ -0,0 +1,7 @@
package com.cherret.zaprett.data
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,8 @@
package com.cherret.zaprett.data
data class StrategyCheckResult (
val path : String,
val progress : Float,
var domains: List<String>,
val status : StrategyTestingStatus,
)

View File

@@ -0,0 +1,7 @@
package com.cherret.zaprett.data
import com.cherret.zaprett.R
enum class StrategyTestingStatus(val resId: Int) {
Waiting(R.string.strategy_status_waiting), Testing(R.string.strategy_status_testing), Completed(R.string.strategy_status_tested)
}

View File

@@ -0,0 +1,26 @@
package com.cherret.zaprett.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ZaprettConfig(
@SerialName("active_lists")
val activeLists: List<String> = emptyList(),
@SerialName("active_ipsets")
val activeIpsets: List<String> = emptyList(),
@SerialName("active_exclude_lists")
val activeExcludeLists: List<String> = emptyList(),
@SerialName("active_exclude_ipsets")
val activeExcludeIpsets: List<String> = emptyList(),
@SerialName("list_type")
val listType: String = "whitelist",
@SerialName("strategy")
val strategy: String = "",
@SerialName("app_list")
val appList: String = "none",
@SerialName("whitelist")
val whitelist: List<String> = emptyList(),
@SerialName("blacklist")
val blacklist: List<String> = emptyList()
)

View File

@@ -0,0 +1,308 @@
package com.cherret.zaprett.ui.component
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
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.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
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.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.InstallMobile
import androidx.compose.material.icons.filled.Update
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.FilledTonalIconButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.LinearProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ProgressIndicatorDefaults
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
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.res.stringResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.cherret.zaprett.R
import com.cherret.zaprett.data.StrategyCheckResult
import com.cherret.zaprett.data.StrategyTestingStatus
import com.cherret.zaprett.ui.viewmodel.BaseRepoViewModel
import com.cherret.zaprett.utils.RepoItemInfo
import com.cherret.zaprett.utils.disableStrategy
import com.cherret.zaprett.utils.enableStrategy
import com.cherret.zaprett.utils.getActiveStrategy
import kotlinx.coroutines.launch
@Composable
fun ListSwitchItem(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
fun RepoItem(
item: RepoItemInfo,
viewModel: BaseRepoViewModel,
isInstalling: Map<String, Boolean>,
isUpdateInstalling: Map<String, Boolean>,
isUpdate: Map<String, Boolean>,
modifier: Modifier = Modifier
) {
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)
}
)
}
}
}
}
}
@Composable
fun StrategySelectionItem(strategy : StrategyCheckResult, prefs : SharedPreferences, context : Context, snackbarHostState : SnackbarHostState) {
val scope = rememberCoroutineScope()
var expanded by remember { mutableStateOf(false) }
ElevatedCard (
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
onClick = {
if (strategy.status == StrategyTestingStatus.Completed && strategy.domains.isNotEmpty()) {
expanded = !expanded
}
},
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, end = 10.dp, top = 25.dp, bottom = 0.dp)
) {
Column (
Modifier
.fillMaxWidth()
.padding(16.dp)
)
{
Row {
Text(
text = strategy.path,
modifier = Modifier
.weight(1f)
)
FilledTonalIconButton(
onClick = {
if (getActiveStrategy(prefs).isNotEmpty()) disableStrategy(getActiveStrategy(prefs)[0], prefs)
enableStrategy(strategy.path, prefs)
scope.launch {
snackbarHostState.showSnackbar(
message = context.getString(R.string.strategy_applied)
)
}
},
enabled = strategy.status == StrategyTestingStatus.Completed
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = "apply"
)
}
}
Row {
Text(
text = stringResource(strategy.status.resId),
modifier = Modifier
.weight(1f),
fontSize = 12.sp,
)
}
Row (
modifier = Modifier
.fillMaxWidth()
.padding(top = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.End
) {
LinearProgressIndicator(
modifier = Modifier
.weight(1f),
progress = {
strategy.progress
},
color = ProgressIndicatorDefaults.linearColor,
trackColor = ProgressIndicatorDefaults.linearTrackColor,
strokeCap = ProgressIndicatorDefaults.LinearStrokeCap,
)
Text(
text = "${(strategy.progress*100).toInt()}%",
modifier = Modifier
.padding(start = 16.dp),
)
}
}
AnimatedVisibility(
visible = expanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Card (
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier
.padding(horizontal = 8.dp)
.fillMaxWidth()
) {
Text(
text = stringResource(R.string.selection_available_domains)
)
LazyColumn(modifier = Modifier.heightIn(max = 300.dp)) {
items(strategy.domains) { item ->
Card(
elevation = CardDefaults.cardElevation(4.dp),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp)
) {
Text(
text = item,
modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyLarge
)
}
}
}
}
}
}
}

View File

@@ -0,0 +1,146 @@
package com.cherret.zaprett.ui.component
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Row
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.cherret.zaprett.R
@Composable
fun SettingsItem(title: String, checked: Boolean, onToggle: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(80.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
onClick = { onToggle(!checked) },
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
fun SettingsActionItem(title: String, onClick: () -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(80.dp),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
onClick = { onClick() },
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 = null)
}
}
}
@Composable
fun SettingsSection(title: String) {
Text(
text = title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp)
)
}
@Composable
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
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))
}
}
)
}

View File

@@ -0,0 +1,157 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package com.cherret.zaprett.ui.screen
import android.app.Activity
import android.content.Context
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
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.MoreVert
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
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.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 androidx.navigation.NavController
import com.cherret.zaprett.R
import com.cherret.zaprett.data.Setting
import com.cherret.zaprett.ui.component.SettingsActionItem
import com.cherret.zaprett.ui.component.SettingsItem
import com.cherret.zaprett.ui.component.SettingsSection
import com.cherret.zaprett.ui.component.TextDialog
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DebugScreen(navController: NavController) {
val context = LocalContext.current
val sharedPreferences = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
val editor = remember { sharedPreferences.edit() }
val showUpdateUrlDialog = remember { mutableStateOf(false) }
val textDialogValue = remember { mutableStateOf("") }
val showResetSettingsDialog = remember { mutableStateOf(false) }
val settingsList = listOf(
Setting.Action(
title = stringResource(R.string.btn_update_repository_url),
onClick = {
textDialogValue.value = sharedPreferences.getString("update_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-app/refs/heads/main/update.json")?: "https://raw.githubusercontent.com/CherretGit/zaprett-app/refs/heads/main/update.json"
showUpdateUrlDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.reset_settings_title),
onClick = {
showResetSettingsDialog.value = true
}
)
)
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.title_debug),
fontSize = 40.sp,
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal))
)
},
navigationIcon = {
IconButton(onClick = { navController.popBackStack() }) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(R.string.btn_back)
)
}
},
windowInsets = WindowInsets(0)
)
},
content = { paddingValues ->
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 -> {
SettingsActionItem(
title = setting.title,
setting.onClick
)
}
is Setting.Section -> {
SettingsSection(setting.title)
}
}
}
}
}
)
if (showUpdateUrlDialog.value) {
TextDialog(stringResource(R.string.btn_update_repository_url), stringResource(R.string.hint_enter_update_repository_url), textDialogValue.value, onConfirm = {
editor.putString("update_repo_url", it).apply()
}, onDismiss = { showUpdateUrlDialog.value = false })
}
if (showResetSettingsDialog.value) {
AlertDialog(title = { Text(text = stringResource(R.string.reset_settings_title)) },
text = { Text(text = stringResource(R.string.reset_settings_message)) },
onDismissRequest = {
showResetSettingsDialog.value = false
},
dismissButton = {
TextButton(onClick = {
showResetSettingsDialog.value = false
}) {
Text(stringResource(R.string.btn_dismiss))
}
},
confirmButton = {
TextButton(onClick = {
context.deleteSharedPreferences("settings")
showResetSettingsDialog.value = false
val activity = context as Activity
val intent = activity.intent
activity.finish()
activity.startActivity(intent)
}) {
Text(text = stringResource(R.string.btn_continue))
}
})
}
}

View File

@@ -1,9 +1,14 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package com.cherret.zaprett.ui.screen
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.VpnService
import android.os.Build
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
@@ -35,6 +40,7 @@ import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
@@ -65,10 +71,15 @@ 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 androidx.navigation.NavController
import com.cherret.zaprett.BuildConfig
import com.cherret.zaprett.R
import com.cherret.zaprett.data.ServiceStatusUI
import com.cherret.zaprett.ui.viewmodel.HomeViewModel
import dev.jeziellago.compose.markdowntext.MarkdownText
import kotlinx.coroutines.CoroutineScope
import kotlinx.serialization.SerializationException
import java.io.IOException
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -78,8 +89,7 @@ fun HomeScreen(viewModel: HomeViewModel = viewModel(), vpnLauncher: ActivityResu
val requestVpnPermission by viewModel.requestVpnPermission.collectAsState()
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val cardText = viewModel.cardText
val cardIcon = viewModel.cardIcon;
val status by viewModel.serviceStatus.collectAsState()
val changeLog = viewModel.changeLog
val newVersion = viewModel.newVersion
val updateAvailable = viewModel.updateAvailable
@@ -88,6 +98,7 @@ fun HomeScreen(viewModel: HomeViewModel = viewModel(), vpnLauncher: ActivityResu
val nfqwsVer = viewModel.nfqwsVer;
val byedpiVer = viewModel.byedpiVer;
val serviceMode = viewModel.serviceMode
val error by viewModel.errorFlow.collectAsState()
LaunchedEffect(Unit) {
viewModel.checkForUpdate()
viewModel.checkServiceStatus()
@@ -106,6 +117,37 @@ fun HomeScreen(viewModel: HomeViewModel = viewModel(), vpnLauncher: ActivityResu
}
}
if (error.isNotEmpty()) {
AlertDialog(
onDismissRequest = {
viewModel.clearError()
},
title = { Text(stringResource(R.string.error_text)) },
text = {
Text(stringResource(R.string.error_unknown))
},
dismissButton = {
TextButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip: ClipData = ClipData.newPlainText("Error log", error)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) {
Toast.makeText(context, context.getString(R.string.log_copied), Toast.LENGTH_SHORT).show()
}
}) {
Text(stringResource(R.string.btn_copy_log))
}
},
confirmButton = {
TextButton(onClick = {
viewModel.clearError()
}) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
Scaffold(
topBar = {
TopAppBar(
@@ -124,7 +166,7 @@ fun HomeScreen(viewModel: HomeViewModel = viewModel(), vpnLauncher: ActivityResu
Column(modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())) {
ServiceStatusCard(viewModel, cardText, cardIcon, snackbarHostState, scope)
ServiceStatusCard(viewModel, status, snackbarHostState, scope)
UpdateCard(updateAvailable) { viewModel.showUpdateDialog() }
if (showUpdateDialog) {
UpdateDialog(viewModel, changeLog.value.orEmpty(), newVersion) { viewModel.dismissUpdateDialog() }
@@ -142,7 +184,7 @@ fun HomeScreen(viewModel: HomeViewModel = viewModel(), vpnLauncher: ActivityResu
}
@Composable
private fun ServiceStatusCard(viewModel: HomeViewModel, cardText: MutableState<Int>, cardIcon : MutableState<ImageVector>, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
private fun ServiceStatusCard(viewModel: HomeViewModel, status: ServiceStatusUI, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
ElevatedCard(
elevation = CardDefaults.cardElevation(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary),
@@ -162,14 +204,14 @@ private fun ServiceStatusCard(viewModel: HomeViewModel, cardText: MutableState<I
)
{
Icon(
painter = rememberVectorPainter(cardIcon.value),
painter = rememberVectorPainter(status.icon),
modifier = Modifier
.width(60.dp)
.height(60.dp),
contentDescription = "icon"
)
Text(
text = stringResource(cardText.value),
text = stringResource(status.textRes),
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal)),
fontSize = 16.sp,
//maxLines = 3,
@@ -195,9 +237,7 @@ private fun UpdateCard(updateAvailable: MutableState<Boolean>, onClick: () -> Un
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),
.padding(start = 10.dp, top = 10.dp, end = 10.dp),
onClick = onClick
) {
Text(
@@ -321,8 +361,8 @@ fun UpdateDialog(viewModel: HomeViewModel, changeLog: String, newVersion: Mutabl
AlertDialog(
title = { Text(text = stringResource(R.string.update_available)) },
text = {
Text(
text = stringResource(R.string.alert_version, BuildConfig.VERSION_NAME, newVersion.value.toString(), changeLog)
MarkdownText(
markdown = stringResource(R.string.alert_version, BuildConfig.VERSION_NAME, newVersion.value.toString(), changeLog)
)
},
onDismissRequest = onDismiss,

View File

@@ -1,54 +1,54 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package com.cherret.zaprett.ui.screen
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.widget.Toast
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.PaddingValues
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.navigationBarsPadding
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.AlertDialog
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.ExperimentalMaterial3ExpressiveApi
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.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
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.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -58,12 +58,12 @@ 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.component.ListSwitchItem
import com.cherret.zaprett.ui.viewmodel.HostsViewModel
import com.cherret.zaprett.utils.getHostListMode
@@ -77,6 +77,7 @@ fun HostsScreen(navController: NavController, viewModel: HostsViewModel = viewMo
val allLists = viewModel.allItems
val checked = viewModel.checked
val isRefreshing = viewModel.isRefreshing
val showPermissionDialog by viewModel.showNoPermissionDialog.collectAsState()
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
@@ -84,11 +85,43 @@ fun HostsScreen(navController: NavController, viewModel: HostsViewModel = viewMo
if (getHostListMode(prefs) == "whitelist") viewModel.copySelectedFile(context, "/lists/include", it)
else viewModel.copySelectedFile(context, "/lists/exclude", it) }
}
val error by viewModel.errorFlow.collectAsState()
LaunchedEffect(Unit) {
viewModel.refresh()
}
if (error.isNotEmpty()) {
AlertDialog(
onDismissRequest = {
viewModel.clearError()
},
title = { Text(stringResource(R.string.error_text)) },
text = {
Text(stringResource(R.string.error_unknown))
},
dismissButton = {
TextButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip: ClipData = ClipData.newPlainText("Error log", error)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) {
Toast.makeText(context, context.getString(R.string.log_copied), Toast.LENGTH_SHORT).show()
}
}) {
Text(stringResource(R.string.btn_copy_log))
}
},
confirmButton = {
TextButton(onClick = {
viewModel.clearError()
}) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
Scaffold(
topBar = {
TopAppBar(
@@ -111,8 +144,11 @@ fun HostsScreen(navController: NavController, viewModel: HostsViewModel = viewMo
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
contentPadding = paddingValues,
modifier = Modifier.fillMaxSize()
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + 80.dp
),
modifier = Modifier.navigationBarsPadding().fillMaxSize()
) {
item {
ListTypeChoose(viewModel, prefs)
@@ -133,7 +169,7 @@ fun HostsScreen(navController: NavController, viewModel: HostsViewModel = viewMo
}
else -> {
items(allLists) { item ->
HostItem (
ListSwitchItem (
item = item,
isChecked = checked[item] == true,
onCheckedChange = { isChecked ->
@@ -154,43 +190,23 @@ fun HostsScreen(navController: NavController, viewModel: HostsViewModel = viewMo
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
)
if (showPermissionDialog) {
AlertDialog(
title = { Text(text = stringResource(R.string.error_no_storage_title)) },
text = { Text(text = stringResource(R.string.no_storage_permission_message)) },
onDismissRequest = {
viewModel.hideNoPermissionDialog()
navController.popBackStack()
},
confirmButton = {
TextButton(onClick = {
viewModel.hideNoPermissionDialog()
navController.popBackStack()
}) {
Text(stringResource(R.string.btn_continue))
}
@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))
}
}
}
}

View File

@@ -0,0 +1,281 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package com.cherret.zaprett.ui.screen
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import android.widget.Toast
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBarsPadding
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.Download
import androidx.compose.material.icons.filled.UploadFile
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
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.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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.sp
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.cherret.zaprett.R
import com.cherret.zaprett.ui.component.ListSwitchItem
import com.cherret.zaprett.ui.viewmodel.HostsViewModel
import com.cherret.zaprett.ui.viewmodel.IpsetViewModel
import com.cherret.zaprett.utils.getHostListMode
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun IpsetsScreen(navController: NavController, viewModel: IpsetViewModel = viewModel()) {
val context = LocalContext.current
val prefs = 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 showPermissionDialog by viewModel.showNoPermissionDialog.collectAsState()
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let {
if (getHostListMode(prefs) == "whitelist") viewModel.copySelectedFile(context, "/ipset/include", it)
else viewModel.copySelectedFile(context, "/ipset/exclude", it) }
}
val error by viewModel.errorFlow.collectAsState()
LaunchedEffect(Unit) {
viewModel.refresh()
}
if (error.isNotEmpty()) {
AlertDialog(
onDismissRequest = {
viewModel.clearError()
},
title = { Text(stringResource(R.string.error_text)) },
text = {
Text(stringResource(R.string.error_unknown))
},
dismissButton = {
TextButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip: ClipData = ClipData.newPlainText("Error log", error)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) {
Toast.makeText(context, context.getString(R.string.log_copied), Toast.LENGTH_SHORT).show()
}
}) {
Text(stringResource(R.string.btn_copy_log))
}
},
confirmButton = {
TextButton(onClick = {
viewModel.clearError()
}) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.title_ipset),
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(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + 80.dp
),
modifier = Modifier.navigationBarsPadding().fillMaxSize()
) {
item {
IpsetTypeChoose(viewModel, prefs)
}
when {
allLists.isEmpty() -> {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.empty_list),
textAlign = TextAlign.Center
)
}
}
}
else -> {
items(allLists) { item ->
ListSwitchItem (
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) }
)
if (showPermissionDialog) {
AlertDialog(
title = { Text(text = stringResource(R.string.error_no_storage_title)) },
text = { Text(text = stringResource(R.string.no_storage_permission_message)) },
onDismissRequest = {
viewModel.hideNoPermissionDialog()
navController.popBackStack()
},
confirmButton = {
TextButton(onClick = {
viewModel.hideNoPermissionDialog()
navController.popBackStack()
}) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
}
@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=ipsets") { 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))
}
)
}
}
@Composable
fun IpsetTypeChoose(viewModel: IpsetViewModel, prefs : SharedPreferences) {
val listType = remember { mutableStateOf(getHostListMode(prefs))}
val options = listOf(stringResource(R.string.title_whitelist), stringResource(R.string.title_blacklist))
val selectedIndex = if (listType.value == "whitelist") 0 else 1
SingleChoiceSegmentedButtonRow (
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
) {
options.forEachIndexed { index, label ->
SegmentedButton(
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = options.size
),
onClick = {
listType.value = if (index == 0) "whitelist" else "blacklist"
viewModel.setListType(listType.value)
},
selected = index == selectedIndex,
label = {
Text(
label
)
}
)
}
}
}
private fun addHost(launcher: ActivityResultLauncher<Array<String>>) {
launcher.launch(arrayOf("text/plain"))
}

View File

@@ -1,36 +1,35 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package com.cherret.zaprett.ui.screen
import androidx.compose.foundation.layout.Arrangement
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.widget.Toast
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.layout.navigationBarsPadding
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.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
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.TextButton
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -40,12 +39,13 @@ 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.component.RepoItem
import com.cherret.zaprett.ui.viewmodel.BaseRepoViewModel
import kotlinx.serialization.SerializationException
import java.io.IOException
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -57,11 +57,102 @@ fun RepoScreen(navController: NavController, viewModel: BaseRepoViewModel) {
val isUpdateInstalling = viewModel.isUpdateInstalling
val isRefreshing = viewModel.isRefreshing.value
val snackbarHostState = remember { SnackbarHostState() }
val error by viewModel.errorFlow.collectAsState()
val downloadError by viewModel.downloadErrorFlow.collectAsState()
val showPermissionDialog by viewModel.showPermissionDialog.collectAsState()
LaunchedEffect(Unit) {
viewModel.refresh()
}
if (error != null) {
AlertDialog(
onDismissRequest = {
viewModel.clearError()
navController.popBackStack()
},
title = { Text(stringResource(R.string.error_text)) },
text = {
Text(
when (error) {
is IOException -> stringResource(R.string.error_server_data)
is SerializationException -> stringResource(R.string.error_processing_data)
else -> stringResource(R.string.error_unknown)
}
)
},
dismissButton = {
TextButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip: ClipData = ClipData.newPlainText("Error log", error?.message)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) {
Toast.makeText(context, context.getString(R.string.log_copied), Toast.LENGTH_SHORT).show()
}
}) {
Text(stringResource(R.string.btn_copy_log))
}
},
confirmButton = {
TextButton(onClick = {
viewModel.clearError()
navController.popBackStack()
}) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
if (downloadError != null) {
AlertDialog(
onDismissRequest = {
viewModel.clearDownloadError()
},
title = { Text(stringResource(R.string.error_text)) },
text = {
Text(stringResource(R.string.download_error))
},
dismissButton = {
TextButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip: ClipData = ClipData.newPlainText("Error log", downloadError)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) {
Toast.makeText(context, context.getString(R.string.log_copied), Toast.LENGTH_SHORT).show()
}
}) {
Text(stringResource(R.string.btn_copy_log))
}
},
confirmButton = {
TextButton(onClick = {
viewModel.clearDownloadError()
}) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
if (showPermissionDialog) {
AlertDialog(
title = { Text(text = stringResource(R.string.error_no_storage_title)) },
text = { Text(text = stringResource(R.string.no_storage_permission_message)) },
onDismissRequest = {
viewModel.hideNoPermissionDialog()
navController.popBackStack()
},
confirmButton = {
TextButton(onClick = {
viewModel.hideNoPermissionDialog()
navController.popBackStack()
}) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
Scaffold(
topBar = {
TopAppBar(
@@ -91,7 +182,7 @@ fun RepoScreen(navController: NavController, viewModel: BaseRepoViewModel) {
) {
LazyColumn(
contentPadding = paddingValues,
modifier = Modifier.fillMaxSize()
modifier = Modifier.navigationBarsPadding().fillMaxSize()
) {
if (hostLists.isEmpty()) {
item {
@@ -107,95 +198,14 @@ fun RepoScreen(navController: NavController, viewModel: BaseRepoViewModel) {
}
} 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)
RepoItem(
item = item,
viewModel = viewModel,
isInstalling = isInstalling,
isUpdateInstalling = isUpdateInstalling,
isUpdate = isUpdate
)
}
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)
}
)
}
}
}
}
}
}
}
}

View File

@@ -1,23 +1,59 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package com.cherret.zaprett.ui.screen
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.graphics.drawable.Drawable
import android.net.Uri
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
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.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.AttachMoney
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.Checkbox
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
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.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -32,14 +68,22 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.core.content.edit
import androidx.core.net.toUri
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import coil3.compose.AsyncImage
import coil3.request.ImageRequest
import com.cherret.zaprett.BuildConfig
import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.R
import com.cherret.zaprett.data.ServiceStatus
import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.ui.component.SettingsActionItem
import com.cherret.zaprett.ui.component.SettingsItem
import com.cherret.zaprett.ui.component.SettingsSection
import com.cherret.zaprett.data.AppListType
import com.cherret.zaprett.data.ServiceStatus
import com.cherret.zaprett.ui.component.InfoDialog
import com.cherret.zaprett.data.Setting
import com.cherret.zaprett.ui.component.TextDialog
import com.cherret.zaprett.ui.viewmodel.SettingsViewModel
import com.cherret.zaprett.utils.checkModuleInstallation
import com.cherret.zaprett.utils.checkRoot
@@ -48,25 +92,24 @@ import com.cherret.zaprett.utils.getStartOnBoot
import com.cherret.zaprett.utils.setAppsListMode
import com.cherret.zaprett.utils.setStartOnBoot
import com.cherret.zaprett.utils.stopService
import androidx.core.content.edit
import androidx.core.net.toUri
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SettingsScreen(viewModel : SettingsViewModel = viewModel()) {
fun SettingsScreen(navController: NavController, viewModel : SettingsViewModel = viewModel()) {
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 useModule = viewModel.useModule.collectAsState()
val updateOnBoot = remember { mutableStateOf(sharedPreferences.getBoolean("update_on_boot", true)) }
val autoRestart = viewModel.autoRestart.collectAsState()
val autoUpdate = remember { mutableStateOf(sharedPreferences.getBoolean("auto_update", BuildConfig.auto_update)) }
val sendFirebaseAnalytics = remember { mutableStateOf(sharedPreferences.getBoolean("send_firebase_analytics", BuildConfig.send_firebase_analytics)) }
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 showIpsetRepoUrlDialog = remember { mutableStateOf(false) }
val showStrategyRepoUrlDialog = remember { mutableStateOf(false) }
val showIPDialog = remember { mutableStateOf(false) }
val showPortDialog = remember { mutableStateOf(false) }
@@ -76,6 +119,7 @@ fun SettingsScreen(viewModel : SettingsViewModel = viewModel()) {
val showBlackDialog = remember { mutableStateOf(false) }
val showAppsListsSheet = remember { mutableStateOf(false) }
val showSystemApps = remember { mutableStateOf(sharedPreferences.getBoolean("show_system_apps", false)) }
val showChangeProbeTimeout = remember { mutableStateOf(false) }
val settingsList = listOf(
Setting.Section(stringResource(R.string.general_section)),
@@ -83,18 +127,11 @@ fun SettingsScreen(viewModel : SettingsViewModel = viewModel()) {
title = stringResource(R.string.btn_use_root),
checked = useModule.value,
onToggle = { isChecked ->
useModule(
viewModel.useModule(
context = context,
checked = isChecked,
updateOnBoot = updateOnBoot,
openNoRootDialog = openNoRootDialog,
openNoModuleDialog = openNoModuleDialog
) { success ->
if (success) {
useModule.value = isChecked
if (!isChecked) stopService { }
}
}
openNoModuleDialog = openNoModuleDialog)
}
),
Setting.Toggle(
@@ -128,6 +165,13 @@ fun SettingsScreen(viewModel : SettingsViewModel = viewModel()) {
showHostsRepoUrlDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.ipset_repo_url),
onClick = {
textDialogValue.value = sharedPreferences.getString("ipset_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/ipsets.json") ?: "https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/ipsets.json"
showIpsetRepoUrlDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.btn_repository_url_strategies),
onClick = {
@@ -135,9 +179,7 @@ fun SettingsScreen(viewModel : SettingsViewModel = viewModel()) {
showStrategyRepoUrlDialog.value = true
}
),
Setting.Section(
title = stringResource(R.string.shared_section)
),
Setting.Section(title = stringResource(R.string.shared_section)),
Setting.Action(
title = stringResource(R.string.btn_applist),
onClick = {
@@ -156,6 +198,20 @@ fun SettingsScreen(viewModel : SettingsViewModel = viewModel()) {
showBlackDialog.value = true
}
),
Setting.Section(stringResource(R.string.title_selection)),
Setting.Action(
title = stringResource(R.string.begin_selection),
onClick = {
navController.navigate("selectionScreen")
}
),
Setting.Action(
title = stringResource(R.string.change_probe_timeout),
onClick = {
textDialogValue.value = sharedPreferences.getLong("probe_timeout", 1000L).toString()
showChangeProbeTimeout.value = true
}
),
Setting.Section(stringResource(R.string.byedpi_section)),
Setting.Toggle(
title = stringResource(R.string.btn_ipv6),
@@ -186,14 +242,12 @@ fun SettingsScreen(viewModel : SettingsViewModel = viewModel()) {
showDNSDialog.value = true
}
),
Setting.Section(
title = stringResource(R.string.zapret_section)
),
Setting.Section(title = stringResource(R.string.zapret_section)),
Setting.Toggle(
title = stringResource(R.string.btn_autorestart),
checked = autoRestart.value,
onToggle = {
if (handleAutoRestart(context, it)) autoRestart.value = it
viewModel.handleAutoRestart(context)
}
)
)
@@ -215,7 +269,7 @@ fun SettingsScreen(viewModel : SettingsViewModel = viewModel()) {
}
if (showAboutDialog.value) {
AboutDialog(onDismiss = { showAboutDialog.value = false })
AboutDialog(navController, onDismiss = { showAboutDialog.value = false })
}
if (showHostsRepoUrlDialog.value) {
@@ -223,10 +277,15 @@ fun SettingsScreen(viewModel : SettingsViewModel = viewModel()) {
editor.putString("hosts_repo_url", it).apply()
}, onDismiss = { showHostsRepoUrlDialog.value = false })
}
if (showIpsetRepoUrlDialog.value) {
TextDialog(stringResource(R.string.btn_repository_url_ipsets), stringResource(R.string.hint_enter_repository_url_ipsets), textDialogValue.value, onConfirm = {
editor.putString("ipsets_repo_url", it).apply()
}, onDismiss = { showIpsetRepoUrlDialog.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()
editor.putString("strategies_repo_url", it).apply()
}, onDismiss = { showStrategyRepoUrlDialog.value = false })
}
@@ -284,6 +343,12 @@ fun SettingsScreen(viewModel : SettingsViewModel = viewModel()) {
)
}
if (showChangeProbeTimeout.value) {
TextDialog(stringResource(R.string.probe_timeout), stringResource(R.string.hint_enter_probe_timeout), textDialogValue.value, onConfirm = {
editor.putLong("probe_timeout", it.toLong()).apply()
}, onDismiss = { showChangeProbeTimeout.value = false })
}
Scaffold(
topBar = {
TopAppBar(
@@ -334,7 +399,7 @@ fun SettingsScreen(viewModel : SettingsViewModel = viewModel()) {
)
}
is Setting.Action -> {
SettingsTextItem(
SettingsActionItem(
title = setting.title,
setting.onClick
)
@@ -349,59 +414,6 @@ fun SettingsScreen(viewModel : SettingsViewModel = viewModel()) {
)
}
@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 = null)
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ListBottomSheet(
@@ -484,121 +496,13 @@ private fun ListBottomSheet(
}
}
@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()
editor.remove("applist").apply()
editor.remove("whitelist").apply()
editor.remove("blacklist").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) {
private fun AboutDialog(navController: NavController, onDismiss: () -> Unit) {
val context = LocalContext.current
val interactionSource = remember { MutableInteractionSource() }
var clickCount by remember { mutableIntStateOf(0) }
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
@@ -607,7 +511,13 @@ private fun AboutDialog(onDismiss: () -> Unit) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(text = stringResource(R.string.about_text, BuildConfig.VERSION_NAME))
Text(text = stringResource(R.string.about_text, BuildConfig.VERSION_NAME), modifier = Modifier.clickable(interactionSource = interactionSource, null) {
clickCount++
if (clickCount == 7) {
onDismiss()
navController.navigate("debugScreen") { launchSingleTop = true }
}
})
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
verticalAlignment = Alignment.CenterVertically,
@@ -801,9 +711,3 @@ private fun AppItem(viewModel: SettingsViewModel, packageName : String, enabled
)
}
}
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

@@ -1,43 +1,43 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package com.cherret.zaprett.ui.screen
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.widget.Toast
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.PaddingValues
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.navigationBarsPadding
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.AlertDialog
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.ExperimentalMaterial3ExpressiveApi
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.TextButton
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.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -51,12 +51,12 @@ 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.component.ListSwitchItem
import com.cherret.zaprett.ui.viewmodel.StrategyViewModel
@OptIn(ExperimentalMaterial3Api::class)
@@ -69,6 +69,7 @@ fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel =
val allLists = viewModel.allItems
val checked = viewModel.checked
val isRefreshing = viewModel.isRefreshing
val showPermissionDialog by viewModel.showNoPermissionDialog.collectAsState()
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
@@ -78,11 +79,43 @@ fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel =
it
) }
}
val error by viewModel.errorFlow.collectAsState()
LaunchedEffect(Unit) {
viewModel.refresh()
}
if (error.isNotEmpty()) {
AlertDialog(
onDismissRequest = {
viewModel.clearError()
},
title = { Text(stringResource(R.string.error_text)) },
text = {
Text(stringResource(R.string.error_unknown))
},
dismissButton = {
TextButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip: ClipData = ClipData.newPlainText("Error log", error)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) {
Toast.makeText(context, context.getString(R.string.log_copied), Toast.LENGTH_SHORT).show()
}
}) {
Text(stringResource(R.string.btn_copy_log))
}
},
confirmButton = {
TextButton(onClick = {
viewModel.clearError()
}) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
Scaffold(
topBar = {
TopAppBar(
@@ -105,8 +138,11 @@ fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel =
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
contentPadding = paddingValues,
modifier = Modifier.fillMaxSize()
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + 80.dp
),
modifier = Modifier.navigationBarsPadding().fillMaxSize()
) {
when {
allLists.isEmpty() -> {
@@ -124,7 +160,7 @@ fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel =
}
else -> {
items(allLists) { item ->
StrategyItem(
ListSwitchItem(
item = item,
isChecked = checked[item] == true,
onCheckedChange = { isChecked ->
@@ -145,43 +181,23 @@ fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel =
},
snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
)
if (showPermissionDialog) {
AlertDialog(
title = { Text(text = stringResource(R.string.error_no_storage_title)) },
text = { Text(text = stringResource(R.string.no_storage_permission_message)) },
onDismissRequest = {
viewModel.hideNoPermissionDialog()
navController.popBackStack()
},
confirmButton = {
TextButton(onClick = {
viewModel.hideNoPermissionDialog()
navController.popBackStack()
}) {
Text(stringResource(R.string.btn_continue))
}
@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))
}
}
}
}

View File

@@ -0,0 +1,239 @@
@file:OptIn(ExperimentalMaterial3ExpressiveApi::class)
package com.cherret.zaprett.ui.screen
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.net.VpnService
import android.os.Build
import android.widget.Toast
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
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.lazy.LazyColumn
import androidx.compose.material3.ExperimentalMaterial3Api
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.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Info
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.TextButton
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.navigation.NavController
import com.cherret.zaprett.R
import com.cherret.zaprett.ui.component.StrategySelectionItem
import com.cherret.zaprett.ui.viewmodel.StrategySelectionViewModel
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun StrategySelectionScreen(navController: NavController, vpnLauncher: ActivityResultLauncher<Intent>, viewModel : StrategySelectionViewModel = viewModel()){
val snackbarHostState = remember { SnackbarHostState() }
val strategyStates = viewModel.strategyStates
val context = LocalContext.current
val prefs = context.getSharedPreferences("settings", MODE_PRIVATE)
var showDialog = remember { mutableStateOf(false) }
val requestVpnPermission by viewModel.requestVpnPermission.collectAsState()
val error by viewModel.errorFlow.collectAsState()
if (showDialog.value) {
InfoAlert { showDialog.value = false }
}
LaunchedEffect(requestVpnPermission) {
if (requestVpnPermission) {
val intent = VpnService.prepare(context)
if (intent != null) {
vpnLauncher.launch(intent)
} else {
viewModel.startVpn()
viewModel.clearVpnPermissionRequest()
}
}
}
if (error.isNotEmpty()) {
AlertDialog(
onDismissRequest = {
viewModel.clearError()
},
title = { Text(stringResource(R.string.error_text)) },
text = {
Text(stringResource(R.string.error_unknown))
},
dismissButton = {
TextButton(onClick = {
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip: ClipData = ClipData.newPlainText("Error log", error)
clipboard.setPrimaryClip(clip)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S) {
Toast.makeText(context, context.getString(R.string.log_copied), Toast.LENGTH_SHORT).show()
}
}) {
Text(stringResource(R.string.btn_copy_log))
}
},
confirmButton = {
TextButton(onClick = {
viewModel.clearError()
}) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(
text = stringResource(R.string.title_selection),
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)
)
}
},
actions = {
IconButton(
onClick = {
showDialog.value = true
}
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = "info"
)
}
},
windowInsets = WindowInsets(0)
)
},
snackbarHost = { SnackbarHost(snackbarHostState) },
content = { paddingValues ->
LazyColumn (
contentPadding = PaddingValues(
top = paddingValues.calculateTopPadding(),
bottom = paddingValues.calculateBottomPadding() + 40.dp
),
modifier = Modifier.fillMaxSize()
) {
item {
Row (
modifier = Modifier
.fillMaxWidth(),
horizontalArrangement = Arrangement.Center
)
{
NoHostsCard(viewModel.noHostsCard)
FilledTonalButton(
onClick = {
viewModel.viewModelScope.launch {
snackbarHostState.showSnackbar(
context.getString(R.string.begin_selection_snack)
)
viewModel.performTest()
}
}
) {
Text(stringResource(R.string.begin_selection))
}
}
}
when {
strategyStates.isEmpty() -> {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.empty_list),
textAlign = TextAlign.Center
)
}
}
}
else -> {
items(strategyStates, key = { it.path }) { item ->
StrategySelectionItem(item, prefs, context, snackbarHostState)
}
}
}
}
}
)
}
@Composable
fun InfoAlert(onDismiss: () -> Unit) {
AlertDialog(
title = { Text(text = stringResource(R.string.strategy_selection_info_title)) },
text = { Text(text = stringResource(R.string.strategy_selection_info_msg)) },
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = onDismiss) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
@Composable
private fun NoHostsCard(noHostsCard: MutableState<Boolean>) {
if (noHostsCard.value) {
AlertDialog(
title = { Text(text = stringResource(R.string.selection_no_hosts_title)) },
text = { Text(text = stringResource(R.string.selection_no_hosts_message)) },
onDismissRequest = {
noHostsCard.value = false
},
confirmButton = {
TextButton(onClick = { noHostsCard.value = false }) {
Text(stringResource(R.string.btn_continue))
}
}
)
}
}

View File

@@ -2,7 +2,10 @@ package com.cherret.zaprett.ui.theme
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
@@ -32,6 +35,7 @@ private val LightColorScheme = lightColorScheme(
*/
)
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ZaprettTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
@@ -49,9 +53,10 @@ fun ZaprettTheme(
else -> LightColorScheme
}
MaterialTheme(
MaterialExpressiveTheme(
colorScheme = colorScheme,
typography = Typography,
motionScheme = MotionScheme.expressive(),
content = content
)
}

View File

@@ -16,9 +16,13 @@ import androidx.compose.runtime.setValue
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import com.cherret.zaprett.R
import com.cherret.zaprett.utils.checkStoragePermission
import com.cherret.zaprett.utils.getZaprettPath
import com.cherret.zaprett.utils.restartService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.io.FileOutputStream
@@ -34,12 +38,20 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
var isRefreshing by mutableStateOf(false)
private set
private val _errorFlow = MutableStateFlow("")
val errorFlow = _errorFlow.asStateFlow()
private var _showNoPermissionDialog = MutableStateFlow(false)
val showNoPermissionDialog: StateFlow<Boolean> = _showNoPermissionDialog
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() {
when (checkStoragePermission(context)) {
true -> {
isRefreshing = true
allItems = loadAllItems().toList()
activeItems = loadActiveItems().toList()
@@ -49,6 +61,13 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
}
isRefreshing = false
}
false -> _showNoPermissionDialog.value = true
}
}
fun hideNoPermissionDialog() {
_showNoPermissionDialog.value = false
}
fun showRestartSnackbar(context: Context, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
scope.launch {
@@ -57,12 +76,18 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
actionLabel = context.getString(R.string.btn_restart_service)
)
if (result == SnackbarResult.ActionPerformed) {
restartService {}
restartService { error ->
_errorFlow.value = error
}
snackbarHostState.showSnackbar(context.getString(R.string.snack_reload))
}
}
}
fun clearError() {
_errorFlow.value = ""
}
fun copySelectedFile(context: Context, path: String, uri: Uri) {
//if (!Environment.isExternalStorageManager()) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R){

View File

@@ -1,5 +1,6 @@
package com.cherret.zaprett.ui.viewmodel
import android.annotation.SuppressLint
import android.app.Application
import android.content.Context
import android.content.SharedPreferences
@@ -12,19 +13,32 @@ import androidx.lifecycle.viewModelScope
import com.cherret.zaprett.utils.RepoItemInfo
import com.cherret.zaprett.R
import com.cherret.zaprett.data.ItemType
import com.cherret.zaprett.utils.checkStoragePermission
import com.cherret.zaprett.utils.download
import com.cherret.zaprett.utils.getFileSha256
import com.cherret.zaprett.utils.getHostListMode
import com.cherret.zaprett.utils.getZaprettPath
import com.cherret.zaprett.utils.registerDownloadListenerHost
import com.cherret.zaprett.utils.registerDownloadListener
import com.cherret.zaprett.utils.restartService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import java.io.File
abstract class BaseRepoViewModel(application: Application) : AndroidViewModel(application) {
val context = application.applicationContext
@SuppressLint("StaticFieldLeak")
val context: Context = application.applicationContext
val sharedPreferences: SharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val _errorFlow = MutableStateFlow<Throwable?>(null)
val errorFlow: StateFlow<Throwable?> = _errorFlow
private val _downloadErrorFlow = MutableStateFlow<String?>(null)
val downloadErrorFlow: StateFlow<String?> = _downloadErrorFlow
private var _showPermissionDialog = MutableStateFlow(false)
val showPermissionDialog: StateFlow<Boolean> = _showPermissionDialog
var hostLists = mutableStateOf<List<RepoItemInfo>>(emptyList())
protected set
@@ -37,19 +51,22 @@ abstract class BaseRepoViewModel(application: Application) : AndroidViewModel(ap
val isUpdateInstalling = mutableStateMapOf<String, Boolean>()
abstract fun getInstalledLists(): Array<String>
abstract fun getRepoList(callback: (List<RepoItemInfo>?) -> Unit)
abstract fun getRepoList(callback: (Result<List<RepoItemInfo>>) -> Unit)
fun refresh() {
isRefreshing.value = true
getRepoList { list ->
getRepoList { result ->
viewModelScope.launch(Dispatchers.IO) {
val safeList = list ?: emptyList()
result
.onSuccess { safeList ->
val useModule = sharedPreferences.getBoolean("use_module", false)
val listType = getHostListMode(sharedPreferences)
val filteredList = safeList.filter { item ->
when (item.type) {
ItemType.list -> listType == "whitelist"
ItemType.list_exclude -> listType == "blacklist"
ItemType.ipset -> listType == "whitelist"
ItemType.ipset_exclude -> listType == "blacklist"
ItemType.nfqws -> useModule
ItemType.byedpi -> !useModule
}
@@ -60,9 +77,21 @@ abstract class BaseRepoViewModel(application: Application) : AndroidViewModel(ap
for (item in filteredList) {
isUpdate[item.name] = item.hash !in existingHashes
}
}
.onFailure { e ->
_errorFlow.value = e
}
}
isRefreshing.value = false
}
}
fun clearError() {
_errorFlow.value = null
}
fun clearDownloadError() {
_downloadErrorFlow.value = null
}
fun isItemInstalled(item: RepoItemInfo): Boolean {
@@ -70,9 +99,11 @@ abstract class BaseRepoViewModel(application: Application) : AndroidViewModel(ap
}
fun install(item: RepoItemInfo) {
when (checkStoragePermission(context)) {
true -> {
isInstalling[item.name] = true
val downloadId = download(context, item.url)
registerDownloadListenerHost(context, downloadId) { uri ->
registerDownloadListener(context, downloadId, { uri ->
viewModelScope.launch(Dispatchers.IO) {
val sourceFile = File(uri.path!!)
val targetDir = when (item.type) {
@@ -80,6 +111,8 @@ abstract class BaseRepoViewModel(application: Application) : AndroidViewModel(ap
ItemType.nfqws -> File(getZaprettPath(), "strategies/nfqws")
ItemType.list -> File(getZaprettPath(), "lists/include")
ItemType.list_exclude -> File(getZaprettPath(), "lists/exclude")
ItemType.ipset -> File(getZaprettPath(), "ipset/include")
ItemType.ipset_exclude -> File(getZaprettPath(), "ipset/exclude")
}
val targetFile = File(targetDir, uri.lastPathSegment!!)
sourceFile.copyTo(targetFile, overwrite = true)
@@ -88,12 +121,28 @@ abstract class BaseRepoViewModel(application: Application) : AndroidViewModel(ap
isUpdate[item.name] = false
refresh()
}
}, onError = {
isInstalling[item.name] = false
isUpdate[item.name] = false
refresh()
_downloadErrorFlow.value = it
})
}
false -> _showPermissionDialog.value = true
}
}
fun hideNoPermissionDialog() {
_showPermissionDialog.value = false
}
fun update(item: RepoItemInfo) {
isUpdateInstalling[item.name] = true
val downloadId = download(context, item.url)
registerDownloadListenerHost(context, downloadId) { uri ->
registerDownloadListener(
context,
downloadId,
onDownloaded = { uri ->
viewModelScope.launch(Dispatchers.IO) {
val sourceFile = File(uri.path!!)
val targetDir = when (item.type) {
@@ -101,6 +150,8 @@ abstract class BaseRepoViewModel(application: Application) : AndroidViewModel(ap
ItemType.nfqws -> File(getZaprettPath(), "strategies/nfqws")
ItemType.list -> File(getZaprettPath(), "lists/include")
ItemType.list_exclude -> File(getZaprettPath(), "lists/exclude")
ItemType.ipset -> File(getZaprettPath(), "ipset/include")
ItemType.ipset_exclude -> File(getZaprettPath(), "ipset/exclude")
}
val targetFile = File(targetDir, uri.lastPathSegment!!)
sourceFile.copyTo(targetFile, overwrite = true)
@@ -109,7 +160,14 @@ abstract class BaseRepoViewModel(application: Application) : AndroidViewModel(ap
isUpdate[item.name] = false
refresh()
}
},
onError = { error ->
isUpdateInstalling[item.name] = false
isUpdate[item.name] = false
refresh()
_downloadErrorFlow.value = error
}
)
}
fun showRestartSnackbar(snackbarHostState: SnackbarHostState) {

View File

@@ -7,7 +7,6 @@ import android.net.Uri
import android.provider.Settings
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.SnackbarHostState
@@ -15,9 +14,11 @@ import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import com.cherret.zaprett.BuildConfig
import com.cherret.zaprett.R
import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.data.ServiceStatus
import com.cherret.zaprett.data.ServiceStatusUI
import com.cherret.zaprett.utils.download
import com.cherret.zaprett.utils.getActiveStrategy
import com.cherret.zaprett.utils.getBinVersion
@@ -32,6 +33,7 @@ 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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
@@ -40,10 +42,11 @@ class HomeViewModel(application: Application) : AndroidViewModel(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
private val _serviceStatus = MutableStateFlow(ServiceStatusUI())
val serviceStatus: StateFlow<ServiceStatusUI> = _serviceStatus.asStateFlow()
private val _errorFlow = MutableStateFlow("")
val errorFlow = _errorFlow.asStateFlow()
var moduleVer = mutableStateOf(context.getString(R.string.unknown_text))
private set
@@ -51,7 +54,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
var nfqwsVer = mutableStateOf(context.getString(R.string.unknown_text))
private set
var byedpiVer = mutableStateOf("0.17.2")
var byedpiVer = mutableStateOf("0.17.3")
private set
var serviceMode = mutableIntStateOf(R.string.service_mode_ciadpi)
@@ -72,8 +75,8 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
var showUpdateDialog = mutableStateOf(false)
fun checkForUpdate() {
if (prefs.getBoolean("auto_update", true)) {
getUpdate {
if (prefs.getBoolean("auto_update", BuildConfig.auto_update)) {
getUpdate(prefs) {
if (it != null) {
downloadUrl.value = it.downloadUrl.toString()
getChangelog(it.changelogUrl.toString()) { log -> changeLog.value = log }
@@ -84,53 +87,35 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
}
}
fun checkServiceStatus() {
if (prefs.getBoolean("use_module", false) && prefs.getBoolean("update_on_boot", false)) {
private fun updateServiceStatus(useModule: Boolean) {
if (useModule) {
getStatus { isEnabled ->
if (isEnabled){
cardText.intValue = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.intValue = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
_serviceStatus.value = if (isEnabled) {
ServiceStatusUI(R.string.status_enabled, Icons.Filled.CheckCircle)
} else {
ServiceStatusUI(R.string.status_disabled, Icons.Filled.Cancel)
}
}
}
else {
if (ByeDpiVpnService.status == ServiceStatus.Connected){
cardText.intValue = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.intValue = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
} else {
_serviceStatus.value = if (ByeDpiVpnService.status == ServiceStatus.Connected) {
ServiceStatusUI(R.string.status_enabled, Icons.Filled.CheckCircle)
} else {
ServiceStatusUI(R.string.status_disabled, Icons.Filled.Cancel)
}
}
}
fun checkServiceStatus() {
val updateOnBoot = prefs.getBoolean("update_on_boot", true)
if (updateOnBoot) {
val useModule = prefs.getBoolean("use_module", false)
updateServiceStatus(useModule)
}
}
fun onCardClick() {
if (prefs.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled){
cardText.intValue = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.intValue = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
}
}
} else {
if (ByeDpiVpnService.status == ServiceStatus.Connected){
cardText.intValue = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.intValue = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
}
}
val useModule = prefs.getBoolean("use_module", false)
updateServiceStatus(useModule)
}
fun startVpn() {
@@ -147,7 +132,10 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
)
)
}
if (!isEnabled) startService {}
if (!isEnabled) startService { error ->
_errorFlow.value = error
onCardClick()
}
}
} else {
if (ByeDpiVpnService.status == ServiceStatus.Disconnected || ByeDpiVpnService.status == ServiceStatus.Failed) {
@@ -187,7 +175,10 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
)
)
}
if (isEnabled) stopService {}
if (isEnabled) stopService { error ->
_errorFlow.value = error
onCardClick()
}
}
} else {
if (ByeDpiVpnService.status == ServiceStatus.Connected) {
@@ -210,7 +201,10 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
fun onBtnRestart(snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (prefs.getBoolean("use_module", false)) {
restartService {}
restartService { error ->
_errorFlow.value = error
onCardClick()
}
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_reload))
}
@@ -245,9 +239,12 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
showUpdateDialog.value = false
if (context.packageManager.canRequestPackageInstalls()){
val id = download(context, downloadUrl.value.orEmpty())
registerDownloadListener(context, id) { uri ->
registerDownloadListener(context, id, { uri ->
installApk(context, uri)
}
},
onError = {
})
}
else {
val packageUri = Uri.fromParts("package", context.packageName, null)
@@ -257,6 +254,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
}
}
// unused?
fun parseArgs(ip: String, port: String, lines: List<String>): Array<String> {
val regex = Regex("""--?\S+(?:=(?:[^"'\s]+|"[^"]*"|'[^']*'))?|[^\s]+""")
val parsedArgs = lines
@@ -264,4 +262,7 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
return arrayOf("ciadpi", "--ip", ip, "--port", port) + parsedArgs
}
fun clearError() {
_errorFlow.value = ""
}
}

View File

@@ -7,5 +7,5 @@ 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)
override fun getRepoList(callback: (Result<List<RepoItemInfo>>) -> Unit) = getHostList(sharedPreferences, callback)
}

View File

@@ -32,6 +32,7 @@ class HostsViewModel(application: Application): BaseListsViewModel(application)
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled && wasChecked) {
snackbarHostState.currentSnackbarData?.dismiss()
showRestartSnackbar(context, snackbarHostState, scope)
}
}
@@ -44,6 +45,7 @@ class HostsViewModel(application: Application): BaseListsViewModel(application)
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled) {
snackbarHostState.currentSnackbarData?.dismiss()
showRestartSnackbar(
context,
snackbarHostState,

View File

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

View File

@@ -0,0 +1,65 @@
package com.cherret.zaprett.ui.viewmodel
import android.app.Application
import android.content.Context
import androidx.compose.material3.SnackbarHostState
import com.cherret.zaprett.utils.disableIpset
import com.cherret.zaprett.utils.disableList
import com.cherret.zaprett.utils.enableIpset
import com.cherret.zaprett.utils.enableList
import com.cherret.zaprett.utils.getActiveExcludeIpsets
import com.cherret.zaprett.utils.getActiveExcludeLists
import com.cherret.zaprett.utils.getActiveIpsets
import com.cherret.zaprett.utils.getAllExcludeIpsets
import com.cherret.zaprett.utils.getAllIpsets
import com.cherret.zaprett.utils.getHostListMode
import com.cherret.zaprett.utils.getStatus
import com.cherret.zaprett.utils.setHostListMode
import kotlinx.coroutines.CoroutineScope
import java.io.File
class IpsetViewModel(application: Application): BaseListsViewModel(application) {
private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
override fun loadAllItems(): Array<String> =
if (getHostListMode(sharedPreferences) == "whitelist") getAllIpsets()
else getAllExcludeIpsets()
override fun loadActiveItems(): Array<String> =
if (getHostListMode(sharedPreferences) == "whitelist") getActiveIpsets(sharedPreferences)
else getActiveExcludeIpsets(sharedPreferences)
override fun deleteItem(item: String, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
val wasChecked = checked[item] == true
disableIpset(item, sharedPreferences)
val success = File(item).delete()
if (success) refresh()
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled && wasChecked) {
snackbarHostState.currentSnackbarData?.dismiss()
showRestartSnackbar(context, snackbarHostState, scope)
}
}
}
}
override fun onCheckedChange(item: String, isChecked: Boolean, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
checked[item] = isChecked
if (isChecked) enableIpset(item, sharedPreferences) else disableIpset(item, sharedPreferences)
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled) {
snackbarHostState.currentSnackbarData?.dismiss()
showRestartSnackbar(
context,
snackbarHostState,
scope
)
}
}
}
}
fun setListType(type : String) {
setHostListMode(sharedPreferences, type)
refresh()
}
}

View File

@@ -1,15 +1,24 @@
package com.cherret.zaprett.ui.viewmodel
import android.app.Application
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.graphics.drawable.Drawable
import androidx.compose.runtime.MutableState
import androidx.lifecycle.AndroidViewModel
import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.data.AppListType
import com.cherret.zaprett.data.ServiceStatus
import com.cherret.zaprett.utils.addPackageToList
import com.cherret.zaprett.utils.checkModuleInstallation
import com.cherret.zaprett.utils.checkRoot
import com.cherret.zaprett.utils.getAppList
import com.cherret.zaprett.utils.getStartOnBoot
import com.cherret.zaprett.utils.removePackageFromList
import com.cherret.zaprett.utils.setStartOnBoot
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@@ -24,9 +33,18 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
private val _selectedPackages = MutableStateFlow<Set<String>>(emptySet())
val selectedPackages: StateFlow<Set<String>> = _selectedPackages.asStateFlow()
private val _currentListType = MutableStateFlow(AppListType.Whitelist)
private val _useModule = MutableStateFlow(false)
val useModule: StateFlow<Boolean> = _useModule
private val _autoRestart = MutableStateFlow(false)
val autoRestart: StateFlow<Boolean> = _autoRestart
init {
refreshApplications()
_useModule.value = context.getSharedPreferences("settings", MODE_PRIVATE).getBoolean("use_module", false)
getStartOnBoot(prefs) { value ->
_autoRestart.value = value
}
}
suspend fun getAppIconBitmap(packageName: String): Drawable? = withContext(Dispatchers.IO) {
@@ -100,4 +118,46 @@ class SettingsViewModel(application: Application) : AndroidViewModel(application
refreshApplications()
}
fun useModule(context: Context, checked: Boolean, openNoRootDialog: MutableState<Boolean>, openNoModuleDialog: MutableState<Boolean>) {
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).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()
editor.remove("applist").apply()
editor.remove("whitelist").apply()
editor.remove("blacklist").apply()
_useModule.value = true
} else {
openNoModuleDialog.value = true
}
}
} else {
openNoRootDialog.value = true
}
}
} else {
editor.putBoolean("use_module", false).apply()
_useModule.value = false
}
}
fun handleAutoRestart(context: Context) {
val sharedPreferences = context.getSharedPreferences("settings", MODE_PRIVATE)
if (sharedPreferences.getBoolean("use_module", false)) {
setStartOnBoot(prefs) { value ->
_autoRestart.value = value
}
}
}
}

View File

@@ -13,5 +13,5 @@ class StrategyRepoViewModel(application: Application): BaseRepoViewModel(applica
} else {
getAllByeDPIStrategies()
}
override fun getRepoList(callback: (List<RepoItemInfo>?) -> Unit) = getStrategiesList(sharedPreferences, callback)
override fun getRepoList(callback: (Result<List<RepoItemInfo>>) -> Unit) = getStrategiesList(sharedPreferences, callback)
}

View File

@@ -0,0 +1,208 @@
package com.cherret.zaprett.ui.viewmodel
import android.app.Application
import android.content.Context.MODE_PRIVATE
import android.content.Intent
import android.util.Log
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import com.cherret.zaprett.R
import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.data.ServiceStatus
import com.cherret.zaprett.data.StrategyCheckResult
import com.cherret.zaprett.data.StrategyTestingStatus
import com.cherret.zaprett.utils.disableStrategy
import com.cherret.zaprett.utils.enableStrategy
import com.cherret.zaprett.utils.getActiveLists
import com.cherret.zaprett.utils.getActiveStrategy
import com.cherret.zaprett.utils.getAllStrategies
import com.cherret.zaprett.utils.getStatus
import com.cherret.zaprett.utils.startService
import com.cherret.zaprett.utils.stopService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient
import okhttp3.Request
import java.io.File
import java.net.InetSocketAddress
import java.net.Proxy
import java.util.concurrent.TimeUnit
class StrategySelectionViewModel(application: Application) : AndroidViewModel(application) {
val prefs = application.getSharedPreferences("settings", MODE_PRIVATE)
val context = application
private val _requestVpnPermission = MutableStateFlow(false)
val requestVpnPermission = _requestVpnPermission.asStateFlow()
private val _errorFlow = MutableStateFlow("")
val errorFlow = _errorFlow.asStateFlow()
val strategyStates = mutableStateListOf<StrategyCheckResult>()
var noHostsCard = mutableStateOf(false)
private set
init {
loadStrategies()
checkHosts()
}
fun buildHttpClient(): OkHttpClient {
val builder = OkHttpClient.Builder()
.callTimeout(prefs.getLong("probe_timeout", 1000L), TimeUnit.MILLISECONDS)
.followRedirects(true)
.followSslRedirects(true)
if (!prefs.getBoolean("use_module", false)) {
val ip = prefs.getString("ip", "127.0.0.1") ?: "127.0.0.1"
val port = prefs.getString("port", "1080")?.toIntOrNull() ?: 1080
val proxy = Proxy(Proxy.Type.SOCKS, InetSocketAddress(ip, port))
builder.proxy(proxy)
}
return builder.build()
}
fun loadStrategies() {
val strategyList = getAllStrategies(prefs)
strategyStates.clear()
strategyList.forEach { name ->
strategyStates += StrategyCheckResult(
path = name,
status = StrategyTestingStatus.Waiting,
progress = 0f,
domains = emptyList()
)
}
}
suspend fun testDomain(domain : String) : Boolean = withContext(Dispatchers.IO) {
val request = Request.Builder()
.url("https://${domain}")
.build()
try {
buildHttpClient().newCall(request).execute().use { response ->
val body = response.body.byteStream().readBytes()
val contentLength = response.body.contentLength()
contentLength <= 0 || body.size.toLong() >= contentLength
}
} catch (e: Exception) {
false
}
}
suspend fun countReachable(index: Int, urls: List<String>): Float = coroutineScope {
if (urls.isEmpty()) return@coroutineScope 0f
val results: List<String> = urls.map { url ->
async { if (testDomain(url)) url else null }
}.awaitAll().filterNotNull()
strategyStates[index].domains = results
(results.size.toFloat() / urls.size.toFloat()).coerceIn(0f, 1f)
}
suspend fun readActiveListsLines(): List<String> = withContext(Dispatchers.IO) {
val result = mutableListOf<String>()
getActiveLists(prefs).forEach { path ->
runCatching {
File(path).useLines { lines ->
lines.forEach { line ->
result += line
}
}
}.onFailure {
Log.e("Error", "Occured error when creating list for check")
}
}
result
}
suspend fun performTest() {
val targets = readActiveListsLines()
var stopTest : Boolean = false;
for (index in strategyStates.indices) {
val current = strategyStates[index]
if (stopTest) break
strategyStates[index] = current.copy(status = StrategyTestingStatus.Testing)
enableStrategy(current.path, prefs)
if (prefs.getBoolean("use_module", false)) {
getStatus { if (it) stopService { error ->
_errorFlow.value = error
if (error.isNotEmpty()) stopTest = true
} }
startService { error ->
_errorFlow.value = error
if (error.isNotEmpty()) stopTest = true
}
try {
val progress = countReachable(index, targets)
val old = strategyStates[index]
strategyStates[index] = old.copy(
progress = progress,
status = StrategyTestingStatus.Completed
)
} finally {
stopService { error ->
_errorFlow.value = error
if (error.isNotEmpty()) stopTest = true
}
disableStrategy(current.path, prefs)
}
}
else {
if (ByeDpiVpnService.status == ServiceStatus.Connected) {
context.startService(Intent(context, ByeDpiVpnService::class.java).apply {
action = "STOP_VPN"
})
delay(300L)
}
_requestVpnPermission.value = true
/*context.startService(Intent(context, ByeDpiVpnService::class.java).apply {
action = "START_VPN"
})*/
val connected = withTimeoutOrNull(10_000L) {
while (ByeDpiVpnService.status != ServiceStatus.Connected) {
delay(100L)
}
true
} ?: false
if (connected) delay(150L)
try {
val progress = countReachable(index,targets)
val old = strategyStates[index]
strategyStates[index] = old.copy(
progress = progress,
status = StrategyTestingStatus.Completed
)
} finally {
context.startService(Intent(context, ByeDpiVpnService::class.java).apply {
action = "STOP_VPN"
})
delay(200L)
disableStrategy(current.path, prefs)
}
}
}
val sorted = strategyStates.sortedByDescending { it.progress }
strategyStates.clear()
strategyStates.addAll(sorted)
}
fun checkHosts() {
if (getActiveLists(prefs).isEmpty() || getAllStrategies(prefs).isEmpty()) noHostsCard.value = true
Log.d("getActiveLists.isEmpty || getAllStrategies.isEmpty", getActiveLists(prefs).isEmpty().toString())
}
fun startVpn() {
ContextCompat.startForegroundService(context, Intent(context, ByeDpiVpnService::class.java).apply { action = "START_VPN" })
}
fun clearVpnPermissionRequest() {
_requestVpnPermission.value = false
}
fun clearError() {
_errorFlow.value = ""
}
}

View File

@@ -8,8 +8,8 @@ import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.data.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.getActiveByeDPIStrategy
import com.cherret.zaprett.utils.getActiveNfqwsStrategy
import com.cherret.zaprett.utils.getAllByeDPIStrategies
import com.cherret.zaprett.utils.getAllNfqwsStrategies
import com.cherret.zaprett.utils.getStatus
@@ -18,8 +18,8 @@ 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) {
private val strategyProvider: StrategyProvider
get() = if (sharedPreferences.getBoolean("use_module", false)) {
NfqwsStrategyProvider()
} else {
ByeDPIStrategyProvider(sharedPreferences)
@@ -36,6 +36,7 @@ class StrategyViewModel(application: Application): BaseListsViewModel(applicatio
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled && wasChecked) {
snackbarHostState.currentSnackbarData?.dismiss()
showRestartSnackbar(context, snackbarHostState, scope)
}
}
@@ -64,6 +65,7 @@ class StrategyViewModel(application: Application): BaseListsViewModel(applicatio
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled) {
snackbarHostState.currentSnackbarData?.dismiss()
showRestartSnackbar(
context,
snackbarHostState,
@@ -82,10 +84,10 @@ interface StrategyProvider {
class NfqwsStrategyProvider : StrategyProvider {
override fun getAll() = getAllNfqwsStrategies()
override fun getActive() = getActiveNfqwsStrategies()
override fun getActive() = getActiveNfqwsStrategy()
}
class ByeDPIStrategyProvider(private val sharedPreferences: SharedPreferences) : StrategyProvider {
override fun getAll() = getAllByeDPIStrategies()
override fun getActive() = getActiveByeDPIStrategies(sharedPreferences)
override fun getActive() = getActiveByeDPIStrategy(sharedPreferences)
}

View File

@@ -1,664 +0,0 @@
package com.cherret.zaprett.utils
import android.content.Context
import android.content.Context.MODE_PRIVATE
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
import com.cherret.zaprett.data.AppListType
fun checkRoot(callback: (Boolean) -> Unit) {
Shell.getShell().isRoot.let { callback(it) }
}
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("start_on_boot", 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("start_on_boot", "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/include")
return listsDir.listFiles { file -> file.isFile }
?.map { it.absolutePath }
?.toTypedArray()
?: emptyArray()
}
fun getAllExcludeLists(): Array<String> {
val listsDir = File("${getZaprettPath()}/lists/exclude/")
return listsDir.listFiles { file -> file.isFile }
?.map { it.absolutePath }
?.toTypedArray()
?: emptyArray()
}
fun getAllNfqwsStrategies(): Array<String> {
val listsDir = File("${getZaprettPath()}/strategies/nfqws")
return listsDir.listFiles { file -> file.isFile }
?.map { it.absolutePath }
?.toTypedArray()
?: emptyArray()
}
fun getAllByeDPIStrategies(): Array<String> {
val listsDir = File("${getZaprettPath()}/strategies/byedpi")
return listsDir.listFiles { file -> file.isFile }
?.map { it.absolutePath }
?.toTypedArray()
?: emptyArray()
}
fun getActiveLists(sharedPreferences: SharedPreferences): Array<String> {
if (sharedPreferences.getBoolean("use_module", false)) {
val configFile = getConfigFile()
if (configFile.exists()) {
val props = Properties()
return try {
FileInputStream(configFile).use { input ->
props.load(input)
}
val activeLists = props.getProperty("active_lists", "")
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 getActiveExcludeLists(sharedPreferences: SharedPreferences): Array<String> {
if (sharedPreferences.getBoolean("use_module", false)) {
val configFile = getConfigFile()
if (configFile.exists()) {
val props = Properties()
return try {
FileInputStream(configFile).use { input ->
props.load(input)
}
val activeLists = props.getProperty("active_exclude_lists", "")
if (activeLists.isNotEmpty()) activeLists.split(",")
.toTypedArray() else emptyArray()
} catch (e: IOException) {
throw RuntimeException(e)
}
}
return emptyArray()
}
else {
return sharedPreferences.getStringSet("exclude_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 configFile = getConfigFile()
try {
val props = Properties()
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
}
val activeLists = props.getProperty(
if (getHostListMode(sharedPreferences) == "whitelist") "active_lists"
else "active_exclude_lists",
"")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (path !in activeLists) {
activeLists.add(path)
}
props.setProperty(
if (getHostListMode(sharedPreferences) == "whitelist") "active_lists"
else "active_exclude_lists",
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(
if (getHostListMode(sharedPreferences) == "whitelist") "lists"
else "exclude_lists", emptySet())?.toMutableSet() ?: mutableSetOf()
if (path !in currentSet) {
currentSet.add(path)
sharedPreferences.edit { putStringSet(
if (getHostListMode(sharedPreferences) == "whitelist") "lists"
else "exclude_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(
if (getHostListMode(sharedPreferences) == "whitelist") "active_lists"
else "active_exclude_lists",
"")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (path in activeLists) {
activeLists.remove(path)
}
props.setProperty(
if (getHostListMode(sharedPreferences) == "whitelist") "active_lists"
else "active_exclude_lists",
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(
if (getHostListMode(sharedPreferences) == "whitelist") "lists"
else "exclude_lists", emptySet())?.toMutableSet() ?: mutableSetOf()
if (path in currentSet) {
currentSet.remove(path)
sharedPreferences.edit { putStringSet(
if (getHostListMode(sharedPreferences) == "whitelist") "lists"
else "exclude_lists", currentSet) }
}
if (currentSet.isEmpty()) {
sharedPreferences.edit { remove(
if (getHostListMode(sharedPreferences) == "whitelist") "lists"
else "exclude_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") }
}
}
fun addPackageToList(listType: AppListType, packageName: String, prefs : SharedPreferences, context : Context) {
if (prefs.getBoolean("use_module", false)){
val configFile = getConfigFile()
try {
val props = Properties()
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
}
if (listType == AppListType.Whitelist) {
val whitelist = props.getProperty("whitelist", "")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (packageName !in whitelist) {
whitelist.add(packageName)
}
props.setProperty("whitelist", whitelist.joinToString(","))
}
if (listType == AppListType.Blacklist) {
val blacklist = props.getProperty("blacklist", "")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (packageName !in blacklist) {
blacklist.add(packageName)
}
props.setProperty("blacklist", blacklist.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 prefs = context.getSharedPreferences("settings", MODE_PRIVATE)
if (listType == AppListType.Whitelist){
val set = prefs.getStringSet("whitelist", emptySet())?.toMutableSet() ?: mutableSetOf()
set.add(packageName)
prefs.edit().putStringSet("whitelist", set).apply()
}
if (listType == AppListType.Blacklist){
val set = prefs.getStringSet("blacklist", emptySet())?.toMutableSet() ?: mutableSetOf()
set.add(packageName)
prefs.edit().putStringSet("blacklist", set).apply()
}
}
}
fun removePackageFromList(listType: AppListType, packageName: String, prefs: SharedPreferences, context: Context) {
if (prefs.getBoolean("use_module", false)){
val props = Properties()
val configFile = getConfigFile()
try {
if (configFile.exists()) {
FileInputStream(configFile).use { input ->
props.load(input)
}
}
if (listType == AppListType.Whitelist){
val whitelist = props.getProperty("whitelist", "")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (packageName in whitelist) {
whitelist.remove(packageName)
}
props.setProperty("whitelist", whitelist.joinToString(","))
}
if (listType == AppListType.Blacklist) {
val blacklist = props.getProperty("blacklist", "")
.split(",")
.filter { it.isNotBlank() }
.toMutableList()
if (packageName in blacklist) {
blacklist.remove(packageName)
}
props.setProperty("blacklist", blacklist.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 prefs = context.getSharedPreferences("settings", MODE_PRIVATE)
if (listType == AppListType.Whitelist) {
val set = prefs.getStringSet("whitelist", emptySet())?.toMutableSet() ?: mutableSetOf()
set.remove(packageName)
prefs.edit().putStringSet("whitelist", set).apply()
}
if (listType == AppListType.Blacklist) {
val set = prefs.getStringSet("blacklist", emptySet())?.toMutableSet() ?: mutableSetOf()
set.remove(packageName)
prefs.edit().putStringSet("blacklist", set).apply()
}
}
}
fun isInList(listType: AppListType, packageName: String, prefs: SharedPreferences, context: Context) : Boolean {
if (prefs.getBoolean("use_module", false)) {
val configFile = getConfigFile()
if (configFile.exists()) {
val props = Properties()
try {
FileInputStream(configFile).use { input ->
props.load(input)
}
if (listType == AppListType.Whitelist) {
val whitelist = props.getProperty("whitelist", "")
return if (whitelist.isNotEmpty()) whitelist.split(",")
.toTypedArray().contains(packageName) else false
}
if (listType == AppListType.Blacklist) {
val blacklist = props.getProperty("blacklist", "")
return if (blacklist.isNotEmpty()) blacklist.split(",")
.toTypedArray().contains(packageName) else false
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
}
else {
val prefs = context.getSharedPreferences("settings", MODE_PRIVATE)
if(listType == AppListType.Whitelist){
val whitelist = prefs.getStringSet("whitelist", emptySet()) ?: emptySet()
return packageName in whitelist
}
else {
val blacklist = prefs.getStringSet("blacklist", emptySet()) ?: emptySet()
return packageName in blacklist
}
}
return false
}
fun getAppList(listType: AppListType, sharedPreferences : SharedPreferences, context : Context) : Set<String> {
if (sharedPreferences.getBoolean("use_module", false)) {
val configFile = File("${getZaprettPath()}/config")
if (configFile.exists()) {
val props = Properties()
try {
FileInputStream(configFile).use { input ->
props.load(input)
}
if (listType == AppListType.Whitelist) {
val whitelist = props.getProperty("whitelist", "")
return if (whitelist.isNotEmpty()) whitelist.split(",")
.toSet() else emptySet()
}
if (listType == AppListType.Blacklist) {
val blacklist = props.getProperty("blacklist", "")
return if (blacklist.isNotEmpty()) blacklist.split(",")
.toSet() else emptySet()
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
return emptySet()
}
else {
return if (listType == AppListType.Whitelist) context.getSharedPreferences("settings", MODE_PRIVATE)
.getStringSet("whitelist", emptySet()) ?: emptySet()
else context.getSharedPreferences("settings", MODE_PRIVATE)
.getStringSet("blacklist", emptySet()) ?: emptySet()
}
}
fun getAppsListMode(prefs : SharedPreferences) : String {
if(prefs.getBoolean("use_module", false)) {
val configFile = getConfigFile()
if (configFile.exists()) {
val props = Properties()
try {
FileInputStream(configFile).use { input ->
props.load(input)
}
val applist = props.getProperty("app_list", "")!!
Log.d("App list", "Equals to $applist")
return if (applist == "whitelist" || applist == "blacklist" || applist == "none") applist
else "none"
} catch (e: IOException) {
throw RuntimeException(e)
}
}
}
else {
return prefs.getString("applist", "")!!
}
return "none"
}
fun setAppsListMode(prefs: SharedPreferences, mode: String) {
if (prefs.getBoolean("use_module", false)) {
val configFile = getConfigFile()
val props = Properties()
if (configFile.exists()) {
try {
FileInputStream(configFile).use { input ->
props.load(input)
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
props.setProperty("app_list", mode)
try {
FileOutputStream(configFile).use { output ->
props.store(output, "Don't place '/' in end of directory! Example: /sdcard")
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
else {
prefs.edit { putString("app-list", mode) }
}
Log.d("App List", "Changed to $mode")
}
fun setHostListMode(prefs: SharedPreferences, mode: String) {
if (prefs.getBoolean("use_module", false)) {
val configFile = getConfigFile()
val props = Properties()
if (configFile.exists()) {
try {
FileInputStream(configFile).use { input ->
props.load(input)
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
props.setProperty("list_type", mode)
try {
FileOutputStream(configFile).use { output ->
props.store(output, "Don't place '/' in end of directory! Example: /sdcard")
}
} catch (e: IOException) {
throw RuntimeException(e)
}
}
else {
prefs.edit { putString("list_type", mode) }
}
Log.d("App List", "Changed to $mode")
}
fun getHostListMode(prefs : SharedPreferences) : String {
if(prefs.getBoolean("use_module", false)) {
val configFile = getConfigFile()
if (configFile.exists()) {
val props = Properties()
try {
FileInputStream(configFile).use { input ->
props.load(input)
}
val hostlist = props.getProperty("list_type", "whitelist")!!
return if (hostlist == "whitelist" || hostlist == "blacklist") hostlist
else "whitelist"
} catch (e: IOException) {
throw RuntimeException(e)
}
}
}
else {
return prefs.getString("list_type", "whitelist")!!
}
return "whitelist"
}

View File

@@ -26,47 +26,60 @@ 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-repo/refs/heads/main/hosts.json")?: "https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/hosts.json").build()
fun getHostList(sharedPreferences: SharedPreferences, callback: (Result<List<RepoItemInfo>>) -> Unit) {
getRepo(
sharedPreferences.getString(
"hosts_repo_url",
"https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/hosts.json"
) ?: "https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/hosts.json",
callback
)
}
fun getIpsetList(sharedPreferences: SharedPreferences, callback: (Result<List<RepoItemInfo>>) -> Unit) {
getRepo(
sharedPreferences.getString(
"ipset_repo_url",
"https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/ipsets.json"
) ?: "https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/ipsets.json",
callback
)
}
fun getStrategiesList(sharedPreferences: SharedPreferences, callback: (Result<List<RepoItemInfo>>) -> Unit) {
getRepo(
sharedPreferences.getString(
"strategies_repo_url",
"https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/strategies.json"
) ?: "https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/strategies.json",
callback
)
}
fun getRepo(url: String, callback: (Result<List<RepoItemInfo>>) -> Unit) {
val request = Request.Builder().url(url).build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
callback(null)
callback(Result.failure(e))
}
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) {
callback(null)
callback(Result.failure(IOException("Unexpected HTTP code ${response.code}")))
return
}
val jsonString = response.body.string()
val hostsInfo = json.decodeFromString<List<RepoItemInfo>>(jsonString)
callback(hostsInfo)
val result = runCatching {
json.decodeFromString<List<RepoItemInfo>>(jsonString)
}
callback(result)
}
}
})
}
fun getStrategiesList(sharedPreferences: SharedPreferences, callback: (List<RepoItemInfo>?) -> Unit) {
val request = Request.Builder().url(sharedPreferences.getString("strategies_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-repo/refs/heads/main/strategies.json")?: "https://raw.githubusercontent.com/CherretGit/zaprett-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
fun registerDownloadListener(context: Context, downloadId: Long, onDownloaded: (Uri) -> Unit, onError: (String) -> Unit) {// AI Generated
val receiver = object : BroadcastReceiver() {
@SuppressLint("Range")
override fun onReceive(context: Context?, intent: Intent?) {
@@ -77,7 +90,9 @@ fun registerDownloadListenerHost(context: Context, downloadId: Long, onDownloade
dm.query(query)?.use { cursor ->
if (cursor.moveToFirst()) {
val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
if (status == DownloadManager.STATUS_SUCCESSFUL) {
val reason = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_REASON))
when (status) {
DownloadManager.STATUS_SUCCESSFUL -> {
val uriString = cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI))
if (uriString != null) {
val uri = uriString.toUri()
@@ -85,6 +100,24 @@ fun registerDownloadListenerHost(context: Context, downloadId: Long, onDownloade
onDownloaded(uri)
}
}
DownloadManager.STATUS_FAILED -> {
context.unregisterReceiver(this)
val errorMessage = when (reason) {
DownloadManager.ERROR_CANNOT_RESUME -> "Cannot resume download"
DownloadManager.ERROR_DEVICE_NOT_FOUND -> "Device not found"
DownloadManager.ERROR_FILE_ALREADY_EXISTS -> "File already exists"
DownloadManager.ERROR_FILE_ERROR -> "File error"
DownloadManager.ERROR_HTTP_DATA_ERROR -> "HTTP data error"
DownloadManager.ERROR_INSUFFICIENT_SPACE -> "Insufficient space"
DownloadManager.ERROR_TOO_MANY_REDIRECTS -> "Too many redirects"
DownloadManager.ERROR_UNHANDLED_HTTP_CODE -> "Unhandled HTTP code"
DownloadManager.ERROR_UNKNOWN -> "Unknown error"
else -> "Download failed: reason=$reason"
}
onError(errorMessage)
}
}
}
}
}

View File

@@ -6,6 +6,7 @@ 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 android.os.Environment
@@ -25,9 +26,9 @@ 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()
// PLS EGOR-WHITE REFACTOR THIS
fun getUpdate(sharedPreferences: SharedPreferences, callback: (UpdateInfo?) -> Unit) {
val request = Request.Builder().url(sharedPreferences.getString("update_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-app/refs/heads/main/update.json")?: "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()
@@ -94,30 +95,6 @@ fun installApk(context: Context, uri: Uri) {
context.startActivity(intent)
}
}
fun registerDownloadListener(context: Context, downloadId: Long, onDownloaded: (Uri) -> Unit) {// AI Generated
val receiver = object : BroadcastReceiver() {
@SuppressLint("Range")
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action != DownloadManager.ACTION_DOWNLOAD_COMPLETE) return
if (intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) != downloadId) return
val downloadManager = context?.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager ?: return
downloadManager.query(DownloadManager.Query().setFilterById(downloadId)).use { cursor ->
if (cursor.moveToFirst() && cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) == DownloadManager.STATUS_SUCCESSFUL) {
context.unregisterReceiver(this)
onDownloaded(cursor.getString(cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI)).toUri())
}
}
}
}
val intentFilter = IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(receiver, intentFilter, Context.RECEIVER_EXPORTED)
} else {
ContextCompat.registerReceiver(context, receiver, IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), ContextCompat.RECEIVER_EXPORTED)
}
}
@Serializable
data class UpdateInfo(
val version: String?,

View File

@@ -0,0 +1,504 @@
package com.cherret.zaprett.utils
import android.Manifest
import android.content.Context
import android.content.Context.MODE_PRIVATE
import android.content.SharedPreferences
import android.content.pm.PackageManager
import android.os.Build
import android.os.Environment
import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import com.cherret.zaprett.data.AppListType
import com.cherret.zaprett.data.ZaprettConfig
import com.topjohnwu.superuser.Shell
import kotlinx.serialization.json.Json
import java.io.File
private val json = Json {
prettyPrint = true
ignoreUnknownKeys = true
coerceInputValues = true
encodeDefaults = true
}
private fun readConfig(): ZaprettConfig {
val configFile = getConfigFile()
if (!configFile.exists()) {
return ZaprettConfig()
}
return try {
val content = configFile.readText()
if (content.isBlank()) ZaprettConfig() else json.decodeFromString<ZaprettConfig>(content)
} catch (e: Exception) {
Log.e("ZaprettManager", "Error reading config, returning defaults", e)
ZaprettConfig()
}
}
private fun writeConfig(config: ZaprettConfig) {
val configFile = getConfigFile()
try {
configFile.parentFile?.mkdirs()
val content = json.encodeToString(config)
configFile.writeText(content)
} catch (e: Exception) {
Log.e("ZaprettManager", "Error writing config", e)
}
}
fun checkStoragePermission(context: Context): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Environment.isExternalStorageManager()
} else {
ContextCompat.checkSelfPermission(
context,
Manifest.permission.WRITE_EXTERNAL_STORAGE
) == PackageManager.PERMISSION_GRANTED
}
}
fun checkRoot(callback: (Boolean) -> Unit) {
Shell.getShell().isRoot.let { callback(it) }
}
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: (String) -> Unit) {
Shell.cmd("zaprett start 2>&1").submit { result ->
callback(
if (result.isSuccess) ""
else result.out.joinToString("\n")
)
}
}
fun stopService(callback: (String) -> Unit) {
Shell.cmd("zaprett stop 2>&1").submit { result ->
callback(
if (result.isSuccess) ""
else result.out.joinToString("\n")
)
}
}
fun restartService(callback: (String) -> Unit) {
Shell.cmd("zaprett restart 2>&1").submit { result ->
callback(
if (result.isSuccess) ""
else result.out.joinToString("\n")
)
}
}
fun getModuleVersion(callback: (String) -> Unit) {
Shell.cmd("zaprett module-version").submit { result ->
if (result.out.isNotEmpty()) callback(result.out.first()) else "undefined"
}
}
fun getBinVersion(callback: (String) -> Unit) {
Shell.cmd("zaprett binary-version").submit { result ->
if (result.out.isNotEmpty()) callback(result.out.first()) else "undefined"
}
}
fun getConfigFile(): File {
return File(Environment.getExternalStorageDirectory(), "zaprett/config.json")
}
fun setStartOnBoot(prefs: SharedPreferences, callback: (Boolean) -> Unit) {
if (prefs.getBoolean("use_module", false)) {
Shell.cmd("zaprett set-autostart").submit { result ->
if (result.out.isNotEmpty() && result.out.toString().contains("true")) callback(true) else callback(false)
}
}
}
fun getStartOnBoot(prefs: SharedPreferences, callback: (Boolean) -> Unit) {
if (prefs.getBoolean("use_module", false)) {
Shell.cmd("zaprett get-autostart").submit { result ->
if (result.out.isNotEmpty() && result.out.toString().contains("true")) callback(true) else callback(false)
}
} else {
callback(false)
}
}
fun getZaprettPath(): String {
return Environment.getExternalStorageDirectory().path + "/zaprett"
}
fun getAllLists(): Array<String> {
val listsDir = File("${getZaprettPath()}/lists/include")
return listsDir.listFiles { file -> file.isFile && file.extension.lowercase() == "txt" }
?.map { it.absolutePath }
?.toTypedArray()
?: emptyArray()
}
fun getAllIpsets(): Array<String> {
val listsDir = File("${getZaprettPath()}/ipset/include")
return listsDir.listFiles { file -> file.isFile && file.extension.lowercase() == "txt" }
?.map { it.absolutePath }
?.toTypedArray()
?: emptyArray()
}
fun getAllExcludeLists(): Array<String> {
val listsDir = File("${getZaprettPath()}/lists/exclude/")
return listsDir.listFiles { file -> file.isFile && file.extension.lowercase() == "txt" }
?.map { it.absolutePath }
?.toTypedArray()
?: emptyArray()
}
fun getAllExcludeIpsets(): Array<String> {
val listsDir = File("${getZaprettPath()}/ipset/exclude/")
return listsDir.listFiles { file -> file.isFile && file.extension.lowercase() == "txt" }
?.map { it.absolutePath }
?.toTypedArray()
?: emptyArray()
}
fun getAllNfqwsStrategies(): Array<String> {
val listsDir = File("${getZaprettPath()}/strategies/nfqws")
return listsDir.listFiles { file -> file.isFile && file.extension.lowercase() == "txt" }
?.map { it.absolutePath }
?.toTypedArray()
?: emptyArray()
}
fun getAllByeDPIStrategies(): Array<String> {
val listsDir = File("${getZaprettPath()}/strategies/byedpi")
return listsDir.listFiles { file -> file.isFile && file.extension.lowercase() == "txt" }
?.map { it.absolutePath }
?.toTypedArray()
?: emptyArray()
}
fun getAllStrategies(sharedPreferences: SharedPreferences): Array<String> {
return if (sharedPreferences.getBoolean("use_module", false)) getAllNfqwsStrategies()
else getAllByeDPIStrategies()
}
fun getActiveLists(sharedPreferences: SharedPreferences): Array<String> {
if (sharedPreferences.getBoolean("use_module", false)) {
return readConfig().activeLists.toTypedArray()
} else {
return sharedPreferences.getStringSet("lists", emptySet())?.toTypedArray() ?: emptyArray()
}
}
fun getActiveIpsets(sharedPreferences: SharedPreferences): Array<String> {
if (sharedPreferences.getBoolean("use_module", false)) {
return readConfig().activeIpsets.toTypedArray()
} else return sharedPreferences.getStringSet("ipsets", emptySet())?.toTypedArray() ?: emptyArray()
}
fun getActiveExcludeLists(sharedPreferences: SharedPreferences): Array<String> {
if (sharedPreferences.getBoolean("use_module", false)) {
return readConfig().activeExcludeLists.toTypedArray()
} else {
return sharedPreferences.getStringSet("exclude_lists", emptySet())?.toTypedArray() ?: emptyArray()
}
}
fun getActiveExcludeIpsets(sharedPreferences: SharedPreferences): Array<String> {
if (sharedPreferences.getBoolean("use_module", false)) {
return readConfig().activeExcludeIpsets.toTypedArray()
} else return sharedPreferences.getStringSet("exclude_ipsets", emptySet())?.toTypedArray() ?: emptyArray()
}
fun getActiveNfqwsStrategy(): Array<String> {
val strategy = readConfig().strategy
return if (strategy.isNotBlank()) arrayOf(strategy) else emptyArray()
}
fun getActiveByeDPIStrategy(sharedPreferences: SharedPreferences): Array<String> {
val path = sharedPreferences.getString("active_strategy", "")
if (!path.isNullOrBlank() && File(path).exists()) {
return arrayOf(path)
}
return emptyArray()
}
fun getActiveByeDPIStrategyContent(sharedPreferences: SharedPreferences): List<String> {
val path = sharedPreferences.getString("active_strategy", "")
if (!path.isNullOrBlank() && File(path).exists()) {
return File(path).readLines()
}
return emptyList()
}
fun getActiveStrategy(sharedPreferences: SharedPreferences): Array<String> {
return if (sharedPreferences.getBoolean("use_module", false)) getActiveNfqwsStrategy()
else getActiveByeDPIStrategy(sharedPreferences)
}
fun enableList(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
val config = readConfig()
val isWhitelist = getHostListMode(sharedPreferences) == "whitelist"
val currentLists = if (isWhitelist) config.activeLists else config.activeExcludeLists
if (path !in currentLists) {
val updatedLists = currentLists + path
val newConfig = if (isWhitelist) {
config.copy(activeLists = updatedLists)
} else {
config.copy(activeExcludeLists = updatedLists)
}
writeConfig(newConfig)
}
} else {
val key = if (getHostListMode(sharedPreferences) == "whitelist") "lists" else "exclude_lists"
val currentSet = sharedPreferences.getStringSet(key, emptySet())?.toMutableSet() ?: mutableSetOf()
if (path !in currentSet) {
currentSet.add(path)
sharedPreferences.edit { putStringSet(key, currentSet) }
}
}
}
fun enableIpset(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
val config = readConfig()
val isWhitelist = getHostListMode(sharedPreferences) == "whitelist"
val currentIpsets = if (isWhitelist) config.activeIpsets else config.activeExcludeIpsets
if (path !in currentIpsets) {
val updatedIpsets = currentIpsets + path
val newConfig = if (isWhitelist) {
config.copy(activeIpsets = updatedIpsets)
} else {
config.copy(activeExcludeIpsets = updatedIpsets)
}
writeConfig(newConfig)
}
} else {
val key = if (getHostListMode(sharedPreferences) == "whitelist") "ipsets" else "exclude_ipsets"
val currentSet = sharedPreferences.getStringSet(key, emptySet())?.toMutableSet() ?: mutableSetOf()
if (path !in currentSet) {
currentSet.add(path)
sharedPreferences.edit { putStringSet(key, currentSet) }
}
}
}
fun enableStrategy(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
val config = readConfig()
if (config.strategy != path) {
writeConfig(config.copy(strategy = path))
}
} else {
sharedPreferences.edit { putString("active_strategy", path) }
}
}
fun disableList(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
val config = readConfig()
val isWhitelist = getHostListMode(sharedPreferences) == "whitelist"
val currentLists = if (isWhitelist) config.activeLists else config.activeExcludeLists
if (path in currentLists) {
val updatedLists = currentLists.filter { it != path }
val newConfig = if (isWhitelist) {
config.copy(activeLists = updatedLists)
} else {
config.copy(activeExcludeLists = updatedLists)
}
writeConfig(newConfig)
}
} else {
val key = if (getHostListMode(sharedPreferences) == "whitelist") "lists" else "exclude_lists"
val currentSet = sharedPreferences.getStringSet(key, emptySet())?.toMutableSet() ?: mutableSetOf()
if (path in currentSet) {
currentSet.remove(path)
sharedPreferences.edit { putStringSet(key, currentSet) }
}
if (currentSet.isEmpty()) {
sharedPreferences.edit { remove(key) }
}
}
}
fun disableIpset(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
val config = readConfig()
val isWhitelist = getHostListMode(sharedPreferences) == "whitelist"
val currentIpsets = if (isWhitelist) config.activeIpsets else config.activeExcludeIpsets
if (path in currentIpsets) {
val updatedIpsets = currentIpsets.filter { it != path }
val newConfig = if (isWhitelist) {
config.copy(activeIpsets = updatedIpsets)
} else {
config.copy(activeExcludeIpsets = updatedIpsets)
}
writeConfig(newConfig)
}
} else {
val key = if (getHostListMode(sharedPreferences) == "whitelist") "ipsets" else "exclude_ipsets"
val currentSet = sharedPreferences.getStringSet(key, emptySet())?.toMutableSet() ?: mutableSetOf()
if (path in currentSet) {
currentSet.remove(path)
sharedPreferences.edit { putStringSet(key, currentSet) }
}
if (currentSet.isEmpty()) {
sharedPreferences.edit { remove(key) }
}
}
}
fun disableStrategy(path: String, sharedPreferences: SharedPreferences) {
if (sharedPreferences.getBoolean("use_module", false)) {
val config = readConfig()
if (config.strategy == path) {
writeConfig(config.copy(strategy = ""))
}
} else {
sharedPreferences.edit { remove("active_strategy") }
}
}
fun addPackageToList(listType: AppListType, packageName: String, prefs: SharedPreferences, context: Context) {
if (prefs.getBoolean("use_module", false)) {
val config = readConfig()
if (listType == AppListType.Whitelist) {
if (packageName !in config.whitelist) {
writeConfig(config.copy(whitelist = config.whitelist + packageName))
}
} else if (listType == AppListType.Blacklist) {
if (packageName !in config.blacklist) {
writeConfig(config.copy(blacklist = config.blacklist + packageName))
}
}
} else {
val prefs = context.getSharedPreferences("settings", MODE_PRIVATE)
if (listType == AppListType.Whitelist) {
val set = prefs.getStringSet("whitelist", emptySet())?.toMutableSet() ?: mutableSetOf()
set.add(packageName)
prefs.edit().putStringSet("whitelist", set).apply()
}
if (listType == AppListType.Blacklist) {
val set = prefs.getStringSet("blacklist", emptySet())?.toMutableSet() ?: mutableSetOf()
set.add(packageName)
prefs.edit().putStringSet("blacklist", set).apply()
}
}
}
fun removePackageFromList(listType: AppListType, packageName: String, prefs: SharedPreferences, context: Context) {
if (prefs.getBoolean("use_module", false)) {
val config = readConfig()
if (listType == AppListType.Whitelist) {
if (packageName in config.whitelist) {
writeConfig(config.copy(whitelist = config.whitelist.filter { it != packageName }))
}
} else if (listType == AppListType.Blacklist) {
if (packageName in config.blacklist) {
writeConfig(config.copy(blacklist = config.blacklist.filter { it != packageName }))
}
}
} else {
val prefs = context.getSharedPreferences("settings", MODE_PRIVATE)
if (listType == AppListType.Whitelist) {
val set = prefs.getStringSet("whitelist", emptySet())?.toMutableSet() ?: mutableSetOf()
set.remove(packageName)
prefs.edit().putStringSet("whitelist", set).apply()
}
if (listType == AppListType.Blacklist) {
val set = prefs.getStringSet("blacklist", emptySet())?.toMutableSet() ?: mutableSetOf()
set.remove(packageName)
prefs.edit().putStringSet("blacklist", set).apply()
}
}
}
fun isInList(listType: AppListType, packageName: String, prefs: SharedPreferences, context: Context): Boolean {
if (prefs.getBoolean("use_module", false)) {
val config = readConfig()
return if (listType == AppListType.Whitelist) {
packageName in config.whitelist
} else {
packageName in config.blacklist
}
} else {
val prefs = context.getSharedPreferences("settings", MODE_PRIVATE)
if (listType == AppListType.Whitelist) {
val whitelist = prefs.getStringSet("whitelist", emptySet()) ?: emptySet()
return packageName in whitelist
} else {
val blacklist = prefs.getStringSet("blacklist", emptySet()) ?: emptySet()
return packageName in blacklist
}
}
}
fun getAppList(listType: AppListType, sharedPreferences: SharedPreferences, context: Context): Set<String> {
if (sharedPreferences.getBoolean("use_module", false)) {
val config = readConfig()
return if (listType == AppListType.Whitelist) {
config.whitelist.toSet()
} else {
config.blacklist.toSet()
}
} else {
return if (listType == AppListType.Whitelist) context.getSharedPreferences("settings", MODE_PRIVATE)
.getStringSet("whitelist", emptySet()) ?: emptySet()
else context.getSharedPreferences("settings", MODE_PRIVATE)
.getStringSet("blacklist", emptySet()) ?: emptySet()
}
}
fun getAppsListMode(prefs: SharedPreferences): String {
if (prefs.getBoolean("use_module", false)) {
val applist = readConfig().appList
Log.d("App list", "Equals to $applist")
return if (applist == "whitelist" || applist == "blacklist" || applist == "none") applist
else "none"
} else {
return prefs.getString("app_list", "none")!!
}
}
fun setAppsListMode(prefs: SharedPreferences, mode: String) {
if (prefs.getBoolean("use_module", false)) {
val config = readConfig()
writeConfig(config.copy(appList = mode))
} else {
prefs.edit { putString("app_list", mode) }
}
Log.d("App List", "Changed to $mode")
}
fun setHostListMode(prefs: SharedPreferences, mode: String) {
if (prefs.getBoolean("use_module", false)) {
val config = readConfig()
writeConfig(config.copy(listType = mode))
} else {
prefs.edit { putString("list_type", mode) }
}
Log.d("App List", "Changed to $mode")
}
fun getHostListMode(prefs: SharedPreferences): String {
if (prefs.getBoolean("use_module", false)) {
val hostlist = readConfig().listType
return if (hostlist == "whitelist" || hostlist == "blacklist") hostlist
else "whitelist"
} else {
return prefs.getString("list_type", "whitelist")!!
}
}

View File

@@ -5,12 +5,13 @@
<string name="title_strategies">Стратегии</string>
<string name="title_repo">Репозиторий</string>
<string name="title_settings">Настройки</string>
<string name="title_debug">Отладка</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="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>
@@ -23,7 +24,7 @@
<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="update_available">Доступно обновление</string>
<string name="btn_update_on_boot">Обновлять статус при запуске Главной страницы</string>
<string name="btn_start_service">Запустить сервис</string>
<string name="btn_stop_service">Остановить сервис</string>
@@ -48,6 +49,8 @@
<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_update_repository_url">URL репозитория обновлений</string>
<string name="hint_enter_update_repository_url">Введите URL репозитория обновлений</string>
<string name="btn_ipv6">IPv6</string>
<string name="btn_ip">IP-адрес</string>
<string name="hint_ip">Введите IP-адрес</string>
@@ -89,4 +92,32 @@
<string name="radio_disabed">Выключено</string>
<string name="btn_show_system_apps">Показывать системные приложения</string>
<string name="search_field">Поиск…</string>
<string name="error_text">Ошибка</string>
<string name="error_server_data">Ошибка получения данных с сервера, пожалуйста проверьте интернет соединение</string>
<string name="error_processing_data">Ошибка при обработке данных</string>
<string name="error_unknown">Неизвестная ошибка</string>
<string name="btn_copy_log">Скопировать лог</string>
<string name="log_copied">Лог скопирован</string>
<string name="download_error">Произошла ошибка скачивания, пожалуйста, сообщите о ней разработчикам</string>
<string name="ipset_repo_url">URL репозитория ipset</string>
<string name="title_selection">Подбор стратегии</string>
<string name="btn_repository_url_ipsets">URL репозитория ipset</string>
<string name="hint_enter_repository_url_ipsets">Введите URL репозитория ipset</string>
<string name="strategy_status_waiting">ожидание...</string>
<string name="strategy_status_testing">проверка...</string>
<string name="strategy_status_tested">проверка завершена</string>
<string name="strategy_applied">Стратегия применена</string>
<string name="begin_selection">Начать подбор</string>
<string name="begin_selection_snack">Начат подбор стратегии. Не закрывайте эту вкладку</string>
<string name="change_probe_timeout">Изменить таймаут пробы</string>
<string name="probe_timeout">Таймаут пробы</string>
<string name="hint_enter_probe_timeout">Введите таймаут пробы</string>
<string name="strategy_selection_info_title">Информация</string>
<string name="strategy_selection_info_msg">"В этом разделе настроек приложения представлен перебор стратегий\n Подбор проходит среди скачанных стратегий, поэтому заранее скачайте из репозитория или добавьте из файловой системы интересующие вас стратегии для сравнения. \n Перед началом так же выберете один или несколько листов доменов на вкладке \"Листы\", затем нажмите на \"Начать подбор\". Не используйте для перебора списки с большим количеством доменов."</string>
<string name="selection_no_hosts_title">Нет включенных листов/стратегий</string>
<string name="selection_no_hosts_message">Не обнаружено активных списков хостов, либо доступных стратегий. Включите один или несколько, или скачайте стратегии для проверки, иначе подбор не сработает</string>
<string name="selection_available_domains">Доступные домены</string>
<string name="no_storage_permission_message">Нет разрешения на доступ к файлам</string>
<string name="reset_settings_title">Сброс настроек</string>
<string name="reset_settings_message">Вы действительно хотите сбросить настройки?</string>
</resources>

View File

@@ -5,12 +5,13 @@
<string name="title_strategies">Strategies</string>
<string name="title_repo">Repository</string>
<string name="title_settings">Settings</string>
<string name="title_debug">Debug</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="text_welcome">Welcome 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>
@@ -23,7 +24,7 @@
<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="update_available">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>
@@ -49,6 +50,8 @@
<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_update_repository_url">Update repository URL</string>
<string name="hint_enter_update_repository_url">Enter update repository URL</string>
<string name="btn_ipv6">IPv6</string>
<string name="btn_ip">IP address</string>
<string name="hint_ip">Enter IP address</string>
@@ -93,4 +96,33 @@
<string name="radio_disabed">Disabled</string>
<string name="btn_show_system_apps">Show system apps</string>
<string name="search_field">Search…</string>
<string name="error_text">Error</string>
<string name="error_server_data">Error retrieving data from the server, please check your internet connection</string>
<string name="error_processing_data">Error processing data</string>
<string name="error_unknown">Unknown error</string>
<string name="btn_copy_log">Copy log</string>
<string name="log_copied">Log copied</string>
<string name="download_error">Occurred a file download error, please report to developers</string>
<string name="ipset_repo_url">Ipset repository URL</string>
<string name="title_ipset" translatable="false">Ipset</string>
<string name="title_selection">Strategy selection</string>
<string name="btn_repository_url_ipsets">Ipsets repository url</string>
<string name="hint_enter_repository_url_ipsets">Enter ipsets repository URL</string>
<string name="strategy_status_waiting">waiting...</string>
<string name="strategy_status_testing">testing...</string>
<string name="strategy_status_tested">testing ended</string>
<string name="strategy_applied">Strategy applied</string>
<string name="begin_selection">Begin selection</string>
<string name="begin_selection_snack">Strategy selection has begun. Please keep this tab open.</string>
<string name="change_probe_timeout">Change probe timeout</string>
<string name="probe_timeout">Probe timeout</string>
<string name="hint_enter_probe_timeout">Enter probe timeout</string>
<string name="strategy_selection_info_title">Tip</string>
<string name="strategy_selection_info_msg">This section of the application settings allows you to iterate through strategies.\n The selection is based on downloaded strategies, so download the strategies you\'re interested in from the repository or add them from the file system for comparison.\n Before starting, select one or more domain lists in the \"Lists\" tab, then click \"Start selection\". Avoid using lists with a large number of domains.</string>
<string name="selection_no_hosts_title">No active hosts/strategies</string>
<string name="selection_no_hosts_message">No active host lists or available strategies were found. Please enable one or more, or download strategies for testing, otherwise the selection will not work.</string>
<string name="selection_available_domains">Available domains</string>
<string name="no_storage_permission_message">Missing permission to access files</string>
<string name="reset_settings_title">Reset settings</string>
<string name="reset_settings_message">Are you sure you want to reset the settings?</string>
</resources>

View File

@@ -1,4 +1,3 @@
Добавление черных списков
Исправление ошибок
Внимание! Эта версия приложения совместима с модулем версии 5.0+
1. Изменения под новую версию модуля
2. Исправление работы ipset с byedpi
3. Исправление вылетов

View File

@@ -1,23 +1,24 @@
[versions]
agp = "8.12.1"
kotlin = "2.2.10"
agp = "8.13.1"
kotlin = "2.2.21"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
espressoCore = "3.7.0"
lifecycleRuntimeKtx = "2.9.2"
activityCompose = "1.10.1"
composeBom = "2025.08.00"
compose-material3 = "1.3.2"
compose-material3-adaptive = "1.4.0-beta02"
navigation = "2.9.3"
lifecycleRuntimeKtx = "2.10.0"
activityCompose = "1.12.1"
composeBom = "2025.12.00"
compose-material3 = "1.5.0-alpha10"
compose-material3-adaptive = "1.5.0-alpha10"
navigation = "2.9.6"
compose-icons = "1.7.8"
libsu = "6.0.0"
okhttp = "5.1.0"
okhttp = "5.3.2"
serialization = "1.9.0"
firebase-bom = "34.1.0"
firebase-bom = "34.6.0"
fragment-compose = "1.8.9"
coil3 = "3.3.0"
compose-markdown = "0.5.7"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -47,6 +48,7 @@ firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics
firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" }
fragment-compose = { group = "androidx.fragment", name = "fragment-compose", version.ref = "fragment-compose" }
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil3" }
compose-markdown = { module = "com.github.jeziellago:compose-markdown", version.ref = "compose-markdown" }
[plugins]

View File

@@ -5,7 +5,7 @@ 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)
add_library(byedpi STATIC ${BYE_DPI_SRC})
target_include_directories(byedpi PRIVATE byedpi)
target_compile_options(byedpi PRIVATE -std=c99 -O2 -Wall -Wno-unused -Wextra -Wno-unused-parameter -pedantic)

349
rust/Cargo.lock generated Normal file
View File

@@ -0,0 +1,349 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "aho-corasick"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e60d3430d3a69478ad0993f19238d2df97c507009a52b3c10addcd7f6bcb916"
dependencies = [
"memchr",
]
[[package]]
name = "android_log-sys"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "84521a3cf562bc62942e294181d9eef17eb38ceb8c68677bc49f144e4c3d4f8d"
[[package]]
name = "android_logger"
version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dbb4e440d04be07da1f1bf44fb4495ebd58669372fe0cffa6e48595ac5bd88a3"
dependencies = [
"android_log-sys",
"env_filter",
"log",
]
[[package]]
name = "byedpi"
version = "0.1.0"
dependencies = [
"android_logger",
"cmake",
"jni",
"libc",
"log",
"once_cell",
]
[[package]]
name = "bytes"
version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d71b6127be86fdcfddb610f7182ac57211d4b18a3e9c82eb2d17662f2227ad6a"
[[package]]
name = "cc"
version = "1.2.40"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d05d92f4b1fd76aad469d46cdd858ca761576082cd37df81416691e50199fb"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cesu8"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c"
[[package]]
name = "cfg-if"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2fd1289c04a9ea8cb22300a459a72a385d7c73d3259e2ed7dcb2af674838cfa9"
[[package]]
name = "cmake"
version = "0.1.54"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7caa3f9de89ddbe2c607f4101924c5abec803763ae9534e4f4d7d8f84aa81f0"
dependencies = [
"cc",
]
[[package]]
name = "combine"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd"
dependencies = [
"bytes",
"memchr",
]
[[package]]
name = "env_filter"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "186e05a59d4c50738528153b83b0b0194d3a29507dfec16eccd4b342903397d0"
dependencies = [
"log",
"regex",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0399f9d26e5191ce32c498bebd31e7a3ceabc2745f0ac54af3f335126c3f24b3"
[[package]]
name = "jni"
version = "0.21.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97"
dependencies = [
"cesu8",
"cfg-if",
"combine",
"jni-sys",
"log",
"thiserror",
"walkdir",
"windows-sys 0.45.0",
]
[[package]]
name = "jni-sys"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
[[package]]
name = "libc"
version = "0.2.176"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "58f929b4d672ea937a23a1ab494143d968337a5f47e56d0815df1e0890ddf174"
[[package]]
name = "log"
version = "0.4.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34080505efa8e45a4b816c349525ebe327ceaa8559756f0356cba97ef3bf7432"
[[package]]
name = "memchr"
version = "2.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273"
[[package]]
name = "once_cell"
version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "proc-macro2"
version = "1.0.101"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "89ae43fd86e4158d6db51ad8e2b80f313af9cc74f5c0e03ccb87de09998732de"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.41"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1"
dependencies = [
"proc-macro2",
]
[[package]]
name = "regex"
version = "1.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b5288124840bee7b386bc413c487869b360b2b4ec421ea56425128692f2a82c"
dependencies = [
"aho-corasick",
"memchr",
"regex-automata",
"regex-syntax",
]
[[package]]
name = "regex-automata"
version = "0.4.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "833eb9ce86d40ef33cb1306d8accf7bc8ec2bfea4355cbdebb3df68b40925cad"
dependencies = [
"aho-corasick",
"memchr",
"regex-syntax",
]
[[package]]
name = "regex-syntax"
version = "0.8.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "caf4aa5b0f434c91fe5c7f1ecb6a5ece2130b02ad2a590589dda5146df959001"
[[package]]
name = "same-file"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502"
dependencies = [
"winapi-util",
]
[[package]]
name = "shlex"
version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "syn"
version = "2.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ede7c438028d4436d71104916910f5bb611972c5cfd7f89b8300a8186e6fada6"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "1.0.69"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "unicode-ident"
version = "1.0.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f63a545481291138910575129486daeaf8ac54aee4387fe7906919f7830c7d9d"
[[package]]
name = "walkdir"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b"
dependencies = [
"same-file",
"winapi-util",
]
[[package]]
name = "winapi-util"
version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.61.1",
]
[[package]]
name = "windows-link"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "45e46c0661abb7180e7b9c281db115305d49ca1709ab8242adf09666d2173c65"
[[package]]
name = "windows-sys"
version = "0.45.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f109e41dd4a3c848907eb83d5a42ea98b3769495597450cf6d153507b166f0f"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8"
[[package]]
name = "windows_aarch64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43"
[[package]]
name = "windows_i686_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f"
[[package]]
name = "windows_i686_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060"
[[package]]
name = "windows_x86_64_gnu"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3"
[[package]]
name = "windows_x86_64_msvc"
version = "0.42.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0"

23
rust/Cargo.toml Normal file
View File

@@ -0,0 +1,23 @@
[package]
name = "byedpi"
version = "0.1.0"
edition = "2024"
[dependencies]
jni = "0.21.1"
libc = "0.2.0"
android_logger = "0.15.1"
log = "0.4"
once_cell = "1.21.3"
[lib]
crate-type = ["cdylib"]
[build-dependencies]
cmake = "0.1.49"
[profile.release]
opt-level = "z"
lto = true
strip = true
codegen-units = 1

8
rust/build.rs Normal file
View File

@@ -0,0 +1,8 @@
use cmake::Config;
fn main() {
let dst = Config::new(".").build_target("byedpi").build();
let lib_path = dst.join("build");
println!("cargo:rustc-link-search=native={}", lib_path.display());
println!("cargo:rustc-link-lib=static=byedpi");
}

1
rust/byedpi Submodule

Submodule rust/byedpi added at 7efde1b129

74
rust/src/lib.rs Normal file
View File

@@ -0,0 +1,74 @@
use android_logger::Config;
use jni::JNIEnv;
use jni::objects::{JClass, JObjectArray, JString};
use jni::sys::jint;
use libc::{SHUT_RDWR, shutdown};
use log::{LevelFilter, info};
use std::ffi::CString;
use std::os::raw::c_char;
use std::sync::atomic::{AtomicBool, Ordering};
static PROXY_RUNNING: AtomicBool = AtomicBool::new(false);
#[link(name = "byedpi", kind = "static")]
unsafe extern "C" {
static mut server_fd: i32;
static mut optind: i32;
static mut optreset: i32;
fn main(argc: libc::c_int, argv: *const *const c_char) -> libc::c_int;
fn clear_params();
}
fn init_logger() {
android_logger::init_once(Config::default().with_max_level(LevelFilter::Info));
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_cherret_zaprett_byedpi_NativeBridge_jniStartProxy(
mut env: JNIEnv,
_class: JClass,
args: JObjectArray,
) -> jint {
init_logger();
if PROXY_RUNNING.swap(true, Ordering::SeqCst) {
info!("proxy already running");
return -1;
}
let argc = env.get_array_length(&args).unwrap_or(0) as usize;
let mut cstrings: Vec<CString> = Vec::with_capacity(argc);
for i in 0..argc {
let jstr: JString = env
.get_object_array_element(&args, i as i32)
.unwrap()
.into();
let rust_str: String = env.get_string(&jstr).unwrap().into();
cstrings.push(CString::new(rust_str).unwrap());
}
let mut argv: Vec<*const c_char> = cstrings.iter().map(|s| s.as_ptr()).collect();
argv.push(std::ptr::null());
info!("starting proxy");
unsafe {
optind = 1;
optreset = 1;
clear_params();
}
PROXY_RUNNING.store(true, Ordering::SeqCst);
let ret = unsafe { main(argc as i32, argv.as_ptr()) };
PROXY_RUNNING.store(false, Ordering::SeqCst);
ret as jint
}
#[unsafe(no_mangle)]
pub unsafe extern "system" fn Java_com_cherret_zaprett_byedpi_NativeBridge_jniStopProxy(
_env: JNIEnv,
_class: JClass,
) -> jint {
init_logger();
if !PROXY_RUNNING.load(Ordering::SeqCst) {
info!("failed to stop proxy");
return -1;
}
info!("stopping proxy");
let ret = unsafe { shutdown(server_fd, SHUT_RDWR) };
ret as jint
}

View File

@@ -1,6 +1,6 @@
{
"version": "2.7",
"versionCode": 19,
"downloadUrl": "https://github.com/CherretGit/zaprett-app/releases/download/2_7_0/app-release.apk",
"version": "2.12",
"versionCode": 24,
"downloadUrl": "https://github.com/CherretGit/zaprett-app/releases/download/2.12.0/app-release.apk",
"changelogUrl": "https://raw.githubusercontent.com/CherretGit/zaprett-app/refs/heads/main/changelog.md"
}