Merge remote-tracking branch 'origin/main' into a10

# Conflicts:
#	app/build.gradle.kts
#	app/src/main/java/com/cherret/zaprett/MainActivity.kt
#	app/src/main/java/com/cherret/zaprett/ui/viewmodel/BaseListsViewModel.kt
#	app/src/main/java/com/cherret/zaprett/ui/viewmodel/HomeViewModel.kt
#	app/src/main/res/values-ru/strings.xml
#	app/src/main/res/values/strings.xml
#	gradle/libs.versions.toml
This commit is contained in:
egor-white
2025-07-08 14:39:23 +03:00
51 changed files with 2422 additions and 599 deletions

View File

@@ -28,6 +28,9 @@ jobs:
distribution: 'temurin' distribution: 'temurin'
cache: gradle cache: gradle
- name: Setup Git submodules
run: git submodule update --init --recursive
- name: Grant execute permission for gradlew - name: Grant execute permission for gradlew
run: chmod +x gradlew run: chmod +x gradlew

6
.gitmodules vendored Normal file
View File

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

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

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

View File

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

2
.idea/misc.xml generated
View File

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

6
.idea/vcs.xml generated
View File

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

View File

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

View File

@@ -9,18 +9,26 @@ plugins {
android { android {
namespace = "com.cherret.zaprett" namespace = "com.cherret.zaprett"
compileSdk = 36 compileSdk = 35
defaultConfig { defaultConfig {
applicationId = "com.cherret.zaprett" applicationId = "com.cherret.zaprett"
minSdk = 29 minSdk = 29
targetSdk = 36 targetSdk = 35
versionCode = 11 versionCode = 14
versionName = "1.10" versionName = "2.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" 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 { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
@@ -45,18 +53,42 @@ android {
buildToolsVersion = "36.0.0" buildToolsVersion = "36.0.0"
} }
tasks.register<Exec>("runNdkBuild") {
group = "build"
val ndkDir = android.ndkDirectory
executable = if (System.getProperty("os.name").startsWith("Windows", ignoreCase = true)) {
"$ndkDir\\ndk-build.cmd"
} else {
"$ndkDir/ndk-build"
}
setArgs(listOf(
"NDK_PROJECT_PATH=build/intermediates/ndkBuild",
"NDK_LIBS_OUT=src/main/jniLibs",
"APP_BUILD_SCRIPT=src/main/jni/Android.mk",
"NDK_APPLICATION_MK=src/main/jni/Application.mk"
))
println("Command: $commandLine")
}
tasks.preBuild {
dependsOn("runNdkBuild")
}
dependencies { dependencies {
implementation(libs.material3) implementation("androidx.compose.material3:material3:1.3.1")
implementation(libs.androidx.material3.window.size.class1) implementation("androidx.compose.material3:material3-window-size-class:1.3.1")
implementation(libs.androidx.material3.adaptive.navigation.suite) implementation("androidx.compose.material3:material3-adaptive-navigation-suite:1.4.0-alpha10")
implementation(libs.androidx.navigation.compose) implementation("androidx.navigation:navigation-compose:2.8.9")
implementation(libs.androidx.material.icons.extended) implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation(libs.libsu.core) implementation ("com.github.topjohnwu.libsu:core:6.0.0")
implementation(libs.okhttp) implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.14")
implementation(libs.kotlinx.serialization.json) implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
implementation(platform(libs.firebase.bom)) implementation(platform("com.google.firebase:firebase-bom:33.12.0"))
implementation(libs.firebase.analytics) implementation("com.google.firebase:firebase-analytics")
implementation(libs.firebase.crashlytics) implementation("com.google.firebase:firebase-crashlytics")
implementation("androidx.fragment:fragment-compose:1.8.8")
implementation(libs.androidx.core.ktx) implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx) implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)

View File

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

View File

@@ -10,6 +10,9 @@
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" /> <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application <application
android:requestLegacyExternalStorage="true" android:requestLegacyExternalStorage="true"
android:allowBackup="true" android:allowBackup="true"
@@ -43,7 +46,7 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service <service
android:name=".QSTileService" android:name=".utils.QSTileService"
android:exported="true" android:exported="true"
android:label="@string/qs_name" android:label="@string/qs_name"
android:icon="@drawable/ic_launcher_monochrome" android:icon="@drawable/ic_launcher_monochrome"
@@ -52,6 +55,15 @@
<action android:name="android.service.quicksettings.action.QS_TILE" /> <action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter> </intent-filter>
</service> </service>
<service
android:name=".byedpi.ByeDpiVpnService"
android:exported="true"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="specialUse">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
</application> </application>
</manifest> </manifest>

View File

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

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

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

View File

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

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

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

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

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

View File

@@ -1,6 +1,8 @@
package com.cherret.zaprett package com.cherret.zaprett
import android.Manifest import android.Manifest
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
@@ -11,12 +13,15 @@ import android.provider.Settings
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.StringRes import androidx.annotation.StringRes
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Home 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.Settings
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
@@ -26,16 +31,16 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.core.content.edit
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable import androidx.navigation.compose.composable
@@ -48,31 +53,53 @@ import com.cherret.zaprett.ui.screen.HostsScreen
import com.cherret.zaprett.ui.screen.SettingsScreen import com.cherret.zaprett.ui.screen.SettingsScreen
import com.cherret.zaprett.ui.screen.StrategyScreen import com.cherret.zaprett.ui.screen.StrategyScreen
import com.cherret.zaprett.ui.theme.ZaprettTheme 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.StrategyRepoViewModel
import com.cherret.zaprett.utils.checkModuleInstallation
import com.google.firebase.Firebase import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics import com.google.firebase.analytics.analytics
sealed class Screen(val route: String, @StringRes val nameResId: Int, val icon: ImageVector) { sealed class Screen(val route: String, @StringRes val nameResId: Int, val icon: ImageVector) {
object home : Screen("home", R.string.title_home, Icons.Default.Home) object home : Screen("home", R.string.title_home, Icons.Default.Home)
object hosts : Screen("hosts", R.string.title_hosts, Icons.Default.Dashboard) object hosts : Screen("hosts", R.string.title_hosts, Icons.Default.Lan)
object strategies : Screen("strategies", R.string.title_strategies, Icons.Default.Dns) object strategies : Screen("strategies", R.string.title_strategies, Icons.Default.MultipleStop)
object settings : Screen("settings", R.string.title_settings, Icons.Default.Settings) object settings : Screen("settings", R.string.title_settings, Icons.Default.Settings)
} }
val topLevelRoutes = listOf(Screen.home, Screen.hosts, Screen.strategies, Screen.settings) val topLevelRoutes = listOf(Screen.home, Screen.hosts, Screen.strategies, Screen.settings)
val hideNavBar = listOf("repo?source={source}") val hideNavBar = listOf("repo?source={source}")
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
private val viewModel: HomeViewModel by viewModels()
private lateinit var notificationPermissionLauncher: ActivityResultLauncher<String>
private lateinit var firebaseAnalytics: FirebaseAnalytics private lateinit var firebaseAnalytics: FirebaseAnalytics
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
vpnPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
viewModel.startVpn()
viewModel.clearVpnPermissionRequest()
}
}
notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> }
firebaseAnalytics = Firebase.analytics firebaseAnalytics = Firebase.analytics
enableEdgeToEdge() enableEdgeToEdge()
setContent { setContent {
ZaprettTheme { ZaprettTheme {
val sharedPreferences = remember { getSharedPreferences("settings", MODE_PRIVATE) } val sharedPreferences = remember { getSharedPreferences("settings", MODE_PRIVATE) }
var showPermissionDialog by remember { LaunchedEffect(Unit) {
checkModuleInstallation { result ->
if (getSharedPreferences("settings", Context.MODE_PRIVATE).getBoolean("use_module", false) && !result) sharedPreferences.edit {
putBoolean(
"use_module",
false
)
}
}
}
var showStoragePermissionDialog by remember {
mutableStateOf( mutableStateOf(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
!Environment.isExternalStorageManager() !Environment.isExternalStorageManager()
@@ -87,13 +114,37 @@ class MainActivity : ComponentActivity() {
var showWelcomeDialog by remember { var showWelcomeDialog by remember {
mutableStateOf(sharedPreferences.getBoolean("welcome_dialog", true)) mutableStateOf(sharedPreferences.getBoolean("welcome_dialog", true))
} }
firebaseAnalytics.setAnalyticsCollectionEnabled(
sharedPreferences.getBoolean("send_firebase_analytics", true) var showWelcomeDialog by remember { mutableStateOf(sharedPreferences.getBoolean("welcome_dialog", true)) }
) firebaseAnalytics.setAnalyticsCollectionEnabled(sharedPreferences.getBoolean("send_firebase_analytics", true))
BottomBar() BottomBar()
if (showPermissionDialog) { if (showStoragePermissionDialog) {
PermissionDialog { showPermissionDialog = false } PermissionDialog(
title = stringResource(R.string.error_no_storage_title),
message = stringResource(R.string.error_no_storage_message),
onConfirm = {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
val uri = Uri.fromParts("package", packageName, null)
intent.data = uri
startActivity(intent)
showStoragePermissionDialog = false
},
onDismiss = { showStoragePermissionDialog = false }
)
} }
if (showNotificationPermissionDialog) {
PermissionDialog(
title = stringResource(R.string.notification_permission_title),
message = stringResource(R.string.notification_permission_message),
onConfirm = {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
showNotificationPermissionDialog = false
},
onDismiss = { showNotificationPermissionDialog = false }
)
}
if (showWelcomeDialog) { if (showWelcomeDialog) {
WelcomeDialog { WelcomeDialog {
sharedPreferences.edit { putBoolean("welcome_dialog", false) } sharedPreferences.edit { putBoolean("welcome_dialog", false) }
@@ -104,6 +155,7 @@ class MainActivity : ComponentActivity() {
} }
} }
@Composable @Composable
fun BottomBar() { fun BottomBar() {
val navController = rememberNavController() val navController = rememberNavController()
@@ -143,21 +195,20 @@ class MainActivity : ComponentActivity() {
startDestination = Screen.home.route, startDestination = Screen.home.route,
Modifier.padding(innerPadding) Modifier.padding(innerPadding)
) { ) {
composable(Screen.home.route) { HomeScreen() } composable(Screen.home.route) { HomeScreen(viewModel = viewModel, vpnPermissionLauncher) }
composable(Screen.hosts.route) { HostsScreen(navController) } composable(Screen.hosts.route) { HostsScreen(navController) }
composable(Screen.strategies.route) { StrategyScreen(navController) } composable(Screen.strategies.route) { StrategyScreen(navController) }
composable(Screen.settings.route) { SettingsScreen() } composable(Screen.settings.route) { SettingsScreen() }
composable( composable(route = "repo?source={source}",arguments = listOf(navArgument("source") {})) { backStackEntry ->
route = "repo?source={source}",
arguments = listOf(navArgument("source") {})
) { backStackEntry ->
val source = backStackEntry.arguments?.getString("source") val source = backStackEntry.arguments?.getString("source")
when (source) { when (source) {
"hosts" -> { "hosts" -> {
RepoScreen(navController, ::getAllLists, ::getHostList, "/lists") val viewModel: HostRepoViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
RepoScreen(navController, viewModel)
} }
"strategies" -> { "strategies" -> {
RepoScreen(navController, ::getAllStrategies, ::getStrategiesList, "/strategies") val viewModel: StrategyRepoViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
RepoScreen(navController, viewModel)
} }
} }
} }
@@ -180,11 +231,10 @@ class MainActivity : ComponentActivity() {
} }
@Composable @Composable
fun PermissionDialog(onDismiss: () -> Unit) { fun PermissionDialog(title: String, message: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
val context = LocalContext.current
AlertDialog( AlertDialog(
title = { Text(text = stringResource(R.string.error_no_storage_title)) }, title = { Text(title) },
text = { Text(text = stringResource(R.string.error_no_storage_message)) }, text = { Text(message) },
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
confirmButton = { confirmButton = {
TextButton( TextButton(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,26 +1,42 @@
package com.cherret.zaprett.ui.screen package com.cherret.zaprett.ui.screen
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Dangerous
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RestartAlt import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Stop import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.WavingHand
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold import androidx.compose.material3.Scaffold
@@ -32,14 +48,21 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel import androidx.lifecycle.viewmodel.compose.viewModel
@@ -50,18 +73,38 @@ import kotlinx.coroutines.CoroutineScope
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun HomeScreen(viewModel: HomeViewModel = viewModel()) { fun HomeScreen(viewModel: HomeViewModel = viewModel(), vpnLauncher: ActivityResultLauncher<Intent>) {
val context = LocalContext.current
val sharedPreferences: SharedPreferences = remember { context.getSharedPreferences("settings", Context.MODE_PRIVATE) }
val requestVpnPermission by viewModel.requestVpnPermission.collectAsState()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val cardText = viewModel.cardText val cardText = viewModel.cardText
val cardIcon = viewModel.cardIcon;
val changeLog = viewModel.changeLog val changeLog = viewModel.changeLog
val newVersion = viewModel.newVersion val newVersion = viewModel.newVersion
val updateAvailable = viewModel.updateAvailable val updateAvailable = viewModel.updateAvailable
val showUpdateDialog = viewModel.showUpdateDialog.value val showUpdateDialog = viewModel.showUpdateDialog.value
val moduleVer = viewModel.moduleVer;
val nfqwsVer = viewModel.nfqwsVer;
val byedpiVer = viewModel.byedpiVer;
val serviceMode = viewModel.serviceMode
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
viewModel.checkForUpdate() viewModel.checkForUpdate()
viewModel.checkServiceStatus() viewModel.checkServiceStatus()
viewModel.checkModuleInfo()
}
LaunchedEffect(requestVpnPermission) {
if (requestVpnPermission) {
val intent = VpnService.prepare(context)
if (intent != null) {
vpnLauncher.launch(intent)
} else {
viewModel.startVpn()
viewModel.clearVpnPermissionRequest()
}
}
} }
Scaffold( Scaffold(
@@ -79,38 +122,65 @@ fun HomeScreen(viewModel: HomeViewModel = viewModel()) {
}, },
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
content = { paddingValues -> content = { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) { Column(modifier = Modifier
ServiceStatusCard(viewModel, cardText, snackbarHostState, scope) .padding(paddingValues)
.verticalScroll(rememberScrollState())) {
ServiceStatusCard(viewModel, cardText, cardIcon, snackbarHostState, scope)
UpdateCard(updateAvailable) { viewModel.showUpdateDialog() } UpdateCard(updateAvailable) { viewModel.showUpdateDialog() }
if (showUpdateDialog) { if (showUpdateDialog) {
UpdateDialog(viewModel, changeLog.value.orEmpty(), newVersion) { viewModel.dismissUpdateDialog() } UpdateDialog(viewModel, changeLog.value.orEmpty(), newVersion) { viewModel.dismissUpdateDialog() }
} }
ServiceControlButtons(viewModel, snackbarHostState, scope) ServiceControlButtons(
viewModel,
sharedPreferences,
snackbarHostState,
scope
)
ModuleInfoCard(moduleVer, nfqwsVer, byedpiVer, serviceMode)
} }
} }
) )
} }
@Composable @Composable
private fun ServiceStatusCard(viewModel: HomeViewModel, cardText: MutableState<Int>, snackbarHostState: SnackbarHostState, scope: CoroutineScope) { private fun ServiceStatusCard(viewModel: HomeViewModel, cardText: MutableState<Int>, cardIcon : MutableState<ImageVector>, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
ElevatedCard( ElevatedCard(
elevation = CardDefaults.cardElevation(6.dp), elevation = CardDefaults.cardElevation(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 10.dp, top = 25.dp, end = 10.dp) .padding(start = 10.dp, top = 25.dp, end = 10.dp)
.width(240.dp) //.height(150.dp)
.height(150.dp), .wrapContentHeight(),
onClick = { viewModel.onCardClick(snackbarHostState, scope) } onClick = { viewModel.onCardClick() }
) { ) {
Text( Row (
text = stringResource(cardText.value),
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal)),
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxSize()
.padding(16.dp), .padding(16.dp),
textAlign = TextAlign.Center verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) )
{
Icon(
painter = rememberVectorPainter(cardIcon.value),
modifier = Modifier
.width(60.dp)
.height(60.dp),
contentDescription = "icon"
)
Text(
text = stringResource(cardText.value),
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal)),
fontSize = 16.sp,
//maxLines = 3,
//overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
textAlign = TextAlign.Center
)
}
} }
} }
@@ -144,7 +214,7 @@ private fun UpdateCard(updateAvailable: MutableState<Boolean>, onClick: () -> Un
} }
@Composable @Composable
private fun ServiceControlButtons(viewModel: HomeViewModel, snackbarHostState: SnackbarHostState, scope: CoroutineScope) { private fun ServiceControlButtons(viewModel: HomeViewModel, sharedPreferences: SharedPreferences, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
FilledTonalButton( FilledTonalButton(
onClick = { viewModel.onBtnStartService(snackbarHostState, scope) }, onClick = { viewModel.onBtnStartService(snackbarHostState, scope) },
modifier = Modifier modifier = Modifier
@@ -171,21 +241,82 @@ private fun ServiceControlButtons(viewModel: HomeViewModel, snackbarHostState: S
) )
Text(stringResource(R.string.btn_stop_service)) Text(stringResource(R.string.btn_stop_service))
} }
FilledTonalButton( if (sharedPreferences.getBoolean("use_module", false)) {
onClick = { viewModel.onBtnRestart(snackbarHostState, scope) }, FilledTonalButton(
modifier = Modifier onClick = { viewModel.onBtnRestart(snackbarHostState, scope) },
.padding(horizontal = 5.dp, vertical = 8.dp) modifier = Modifier
.fillMaxWidth() .padding(horizontal = 5.dp, vertical = 8.dp)
) { .fillMaxWidth()
Icon( ) {
imageVector = Icons.Default.RestartAlt, Icon(
contentDescription = stringResource(R.string.btn_restart_service), imageVector = Icons.Default.RestartAlt,
modifier = Modifier.size(20.dp) contentDescription = stringResource(R.string.btn_restart_service),
) modifier = Modifier.size(20.dp)
Text(stringResource(R.string.btn_restart_service)) )
Text(stringResource(R.string.btn_restart_service))
}
} }
} }
@Composable
private fun ModuleInfoCard(
moduleVer: MutableState<String>,
nfqwsVer: MutableState<String>,
byedpiVer: MutableState<String>,
serviceMode: MutableState<Int>
) {
ElevatedCard(
elevation = CardDefaults.cardElevation(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 10.dp)
) {
ModuleInfoItem(Icons.Default.Build, stringResource(R.string.service_mode), stringResource(serviceMode.value))
HorizontalDivider()
ModuleInfoItem(Icons.Default.Extension, stringResource(R.string.module_version), moduleVer.value)
HorizontalDivider()
ModuleInfoItem(Icons.Default.Dangerous, stringResource(R.string.nfqws_version), nfqwsVer.value)
HorizontalDivider()
ModuleInfoItem(Icons.Default.WavingHand, stringResource(R.string.ciadpi_version), byedpiVer.value)
}
}
@Composable
private fun ModuleInfoItem(
icon: ImageVector, header : String, value : String
) {
Row (
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Icon(
painter = rememberVectorPainter(icon),
modifier = Modifier
.size(50.dp)
.padding(16.dp),
contentDescription = "icon"
)
Column (
modifier = Modifier
.padding(horizontal = 16.dp)
){
Text(
text = header,
modifier = Modifier
.fillMaxWidth(),
fontSize = 18.sp,
textAlign = TextAlign.Justify,
)
Text(
text = value
)
}
}
}
@Composable @Composable
fun UpdateDialog(viewModel: HomeViewModel, changeLog: String, newVersion: MutableState<String?>, onDismiss: () -> Unit) { fun UpdateDialog(viewModel: HomeViewModel, changeLog: String, newVersion: MutableState<String?>, onDismiss: () -> Unit) {
AlertDialog( AlertDialog(
@@ -210,4 +341,4 @@ fun UpdateDialog(viewModel: HomeViewModel, changeLog: String, newVersion: Mutabl
} }
} }
) )
} }

View File

@@ -70,7 +70,7 @@ fun HostsScreen(navController: NavController, viewModel: HostsViewModel = viewMo
val filePickerLauncher = rememberLauncherForActivityResult( val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument() contract = ActivityResultContracts.OpenDocument()
) { uri -> ) { uri ->
uri?.let { viewModel.copySelectedFile(context, "/lists", it, snackbarHostState, scope) } uri?.let { viewModel.copySelectedFile(context, "/lists", it) }
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {

View File

@@ -2,6 +2,7 @@ package com.cherret.zaprett.ui.screen
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
@@ -30,11 +31,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
@@ -47,39 +44,24 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import androidx.navigation.NavController import androidx.navigation.NavController
import com.cherret.zaprett.RepoItemInfo
import com.cherret.zaprett.R import com.cherret.zaprett.R
import com.cherret.zaprett.download import com.cherret.zaprett.ui.viewmodel.BaseRepoViewModel
import com.cherret.zaprett.getFileSha256
import com.cherret.zaprett.getZaprettPath
import com.cherret.zaprett.registerDownloadListenerHost
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RepoScreen(navController: NavController, getAllLists: () -> Array<String>, getHostList: ((List<RepoItemInfo>?) -> Unit) -> Unit, targetPath: String) { fun RepoScreen(navController: NavController, viewModel: BaseRepoViewModel) {
val context = LocalContext.current val context = LocalContext.current
val allLists by remember { mutableStateOf(getAllLists()) } val hostLists = viewModel.hostLists.value
var hostLists by remember { mutableStateOf<List<RepoItemInfo>?>(null) } val isUpdate = viewModel.isUpdate
val isUpdate = remember { mutableStateMapOf<String, Boolean>() } val isInstalling = viewModel.isInstalling
val isUpdateInstalling = viewModel.isUpdateInstalling
val isRefreshing = viewModel.isRefreshing.value
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
getHostList { viewModel.refresh()
hostLists = it
}
}
LaunchedEffect(hostLists) {
if (hostLists != null) {
withContext(Dispatchers.IO) {
hostLists!!.forEach { item ->
isUpdate[item.name] = !allLists.any { getFileSha256(File(it)) == item.hash }
}
}
}
} }
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -101,66 +83,52 @@ fun RepoScreen(navController: NavController, getAllLists: () -> Array<String>, g
windowInsets = WindowInsets(0) windowInsets = WindowInsets(0)
) )
}, },
content = { paddingValues -> content = { paddingValues ->
PullToRefreshBox( PullToRefreshBox(
isRefreshing = isRefreshing, isRefreshing = isRefreshing,
onRefresh = { onRefresh = { viewModel.refresh() },
isRefreshing = true
getHostList {
hostLists = it
isRefreshing = false
}
},
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
LazyColumn( LazyColumn(
contentPadding = paddingValues, contentPadding = paddingValues,
modifier = Modifier.fillMaxSize() modifier = Modifier.fillMaxSize()
) { ) {
when { if (hostLists.isEmpty()) {
hostLists?.isEmpty() != false -> { item {
item { Box(
Box( modifier = Modifier.fillParentMaxSize(),
modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center
contentAlignment = Alignment.Center ) {
) { Text(
Text( stringResource(R.string.empty_list),
stringResource(R.string.empty_list), textAlign = TextAlign.Center
textAlign = TextAlign.Center )
)
}
} }
} }
else -> { } else {
items(hostLists.orEmpty()) { item -> items(hostLists) { item ->
var isButtonEnabled by remember { mutableStateOf(!allLists.any { File(it).name == item.name }) } val isInstalled = viewModel.isItemInstalled(item)
var isInstalling by remember { mutableStateOf(false) } val installing = isInstalling[item.name] == true
var isButtonUpdateEnabled by remember { mutableStateOf(true) } val updating = isUpdateInstalling[item.name] == true
var isUpdateInstalling by remember { mutableStateOf(false) }
ElevatedCard( ElevatedCard(
elevation = CardDefaults.cardElevation( elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
defaultElevation = 6.dp colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
), modifier = Modifier
colors = CardDefaults.cardColors( .fillMaxWidth()
containerColor = MaterialTheme.colorScheme.surfaceContainer, .padding(start = 10.dp, top = 25.dp, end = 10.dp)
), ) {
modifier = Modifier Column(Modifier.fillMaxWidth()) {
.fillMaxWidth()
.padding(start = 10.dp, top = 25.dp, end = 10.dp),
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .padding(16.dp)
) { ) {
Text( Text(text = item.name, modifier = Modifier.weight(1f))
text = item.name,
modifier = Modifier.weight(1f)
)
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp) .padding(start = 16.dp)
@@ -170,8 +138,8 @@ fun RepoScreen(navController: NavController, getAllLists: () -> Array<String>, g
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
Row( Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 16.dp) .padding(start = 16.dp)
@@ -181,39 +149,18 @@ fun RepoScreen(navController: NavController, getAllLists: () -> Array<String>, g
modifier = Modifier.weight(1f) modifier = Modifier.weight(1f)
) )
} }
HorizontalDivider(thickness = Dp.Hairline) HorizontalDivider(thickness = Dp.Hairline)
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth(),
.fillMaxWidth(),
horizontalArrangement = Arrangement.End horizontalArrangement = Arrangement.End
) { ) {
if (isUpdate[item.name] == true && allLists.any { File(it).name == item.name }) { if (isUpdate[item.name] == true && isInstalled) {
FilledTonalButton( FilledTonalButton(
onClick = { onClick = { viewModel.update(item) },
isUpdateInstalling = true enabled = !updating,
isButtonUpdateEnabled = false modifier = Modifier.padding(horizontal = 5.dp)
val downloadId = download(context, item.url)
registerDownloadListenerHost(
context,
downloadId
) { uri ->
val sourceFile = File(uri.path!!)
val targetFile = File(
getZaprettPath() + targetPath,
uri.lastPathSegment!!
)
sourceFile.copyTo(targetFile, overwrite = true)
sourceFile.delete()
isUpdateInstalling = false
getHostList {
hostLists = it
}
isUpdate[item.name] = false
}
},
enabled = isButtonUpdateEnabled,
modifier = Modifier
.padding(start = 5.dp, end = 5.dp),
) { ) {
Icon( Icon(
imageVector = Icons.Default.Update, imageVector = Icons.Default.Update,
@@ -221,37 +168,16 @@ fun RepoScreen(navController: NavController, getAllLists: () -> Array<String>, g
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Text( Text(
if (!isUpdateInstalling) stringResource(R.string.btn_update_host) else stringResource( if (updating) stringResource(R.string.btn_updating_host)
R.string.btn_updating_host else stringResource(R.string.btn_update_host)
)
) )
} }
} }
FilledTonalButton( FilledTonalButton(
onClick = { onClick = { viewModel.install(item) },
isInstalling = true enabled = !installing && !isInstalled,
isButtonEnabled = false modifier = Modifier.padding(horizontal = 5.dp)
val downloadId = download(context, item.url)
registerDownloadListenerHost(
context,
downloadId
) { uri ->
val sourceFile = File(uri.path!!)
val targetFile = File(
getZaprettPath() + targetPath,
uri.lastPathSegment!!
)
sourceFile.copyTo(targetFile, overwrite = true)
sourceFile.delete()
isInstalling = false
getHostList {
hostLists = it
}
}
},
enabled = isButtonEnabled,
modifier = Modifier
.padding(start = 5.dp, end = 5.dp),
) { ) {
Icon( Icon(
imageVector = Icons.Default.InstallMobile, imageVector = Icons.Default.InstallMobile,
@@ -259,9 +185,11 @@ fun RepoScreen(navController: NavController, getAllLists: () -> Array<String>, g
modifier = Modifier.size(20.dp) modifier = Modifier.size(20.dp)
) )
Text( Text(
if (isButtonEnabled) stringResource(R.string.btn_install_host) else if (isInstalling) stringResource( when {
R.string.btn_installing_host installing -> stringResource(R.string.btn_installing_host)
) else stringResource(R.string.btn_installed_host) isInstalled -> stringResource(R.string.btn_installed_host)
else -> stringResource(R.string.btn_install_host)
}
) )
} }
} }
@@ -272,6 +200,6 @@ fun RepoScreen(navController: NavController, getAllLists: () -> Array<String>, g
} }
} }
}, },
snackbarHost = {SnackbarHost(hostState = snackbarHostState)} snackbarHost = { SnackbarHost(hostState = snackbarHostState) }
) )
} }

View File

@@ -1,11 +1,13 @@
package com.cherret.zaprett.ui.screen package com.cherret.zaprett.ui.screen
import android.content.Context import android.content.Context
import android.content.Intent
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
@@ -20,11 +22,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.cherret.zaprett.BuildConfig import com.cherret.zaprett.BuildConfig
import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.R import com.cherret.zaprett.R
import com.cherret.zaprett.checkModuleInstallation import com.cherret.zaprett.byedpi.ServiceStatus
import com.cherret.zaprett.checkRoot import com.cherret.zaprett.utils.checkModuleInstallation
import com.cherret.zaprett.getStartOnBoot import com.cherret.zaprett.utils.checkRoot
import com.cherret.zaprett.setStartOnBoot import com.cherret.zaprett.utils.getStartOnBoot
import com.cherret.zaprett.utils.setStartOnBoot
import com.cherret.zaprett.utils.stopService
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -38,12 +43,20 @@ fun SettingsScreen() {
val autoRestart = remember { mutableStateOf(getStartOnBoot()) } val autoRestart = remember { mutableStateOf(getStartOnBoot()) }
val autoUpdate = remember { mutableStateOf(sharedPreferences.getBoolean("auto_update", true)) } val autoUpdate = remember { mutableStateOf(sharedPreferences.getBoolean("auto_update", true)) }
val sendFirebaseAnalytics = remember { mutableStateOf(sharedPreferences.getBoolean("send_firebase_analytics", true)) } val sendFirebaseAnalytics = remember { mutableStateOf(sharedPreferences.getBoolean("send_firebase_analytics", true)) }
val ipv6 = remember { mutableStateOf(sharedPreferences.getBoolean("ipv6",false)) }
val openNoRootDialog = remember { mutableStateOf(false) } val openNoRootDialog = remember { mutableStateOf(false) }
val openNoModuleDialog = remember { mutableStateOf(false) } val openNoModuleDialog = remember { mutableStateOf(false) }
val showAboutDialog = remember { mutableStateOf(false) } val showAboutDialog = remember { mutableStateOf(false) }
val showHostsRepoUrlDialog = remember { mutableStateOf(false) }
val showStrategyRepoUrlDialog = remember { mutableStateOf(false) }
val showIPDialog = remember { mutableStateOf(false) }
val showPortDialog = remember { mutableStateOf(false) }
val showDNSDialog = remember { mutableStateOf(false) }
val textDialogValue = remember { mutableStateOf("") }
val settingsList = listOf( val settingsList = listOf(
SettingItem( Setting.Section(stringResource(R.string.general_section)),
Setting.Toggle(
title = stringResource(R.string.btn_use_root), title = stringResource(R.string.btn_use_root),
checked = useModule.value, checked = useModule.value,
onToggle = { isChecked -> onToggle = { isChecked ->
@@ -54,11 +67,14 @@ fun SettingsScreen() {
openNoRootDialog = openNoRootDialog, openNoRootDialog = openNoRootDialog,
openNoModuleDialog = openNoModuleDialog openNoModuleDialog = openNoModuleDialog
) { success -> ) { success ->
if (success) useModule.value = isChecked if (success) {
useModule.value = isChecked
if (!isChecked) stopService { }
}
} }
} }
), ),
SettingItem( Setting.Toggle(
title = stringResource(R.string.btn_update_on_boot), title = stringResource(R.string.btn_update_on_boot),
checked = updateOnBoot.value, checked = updateOnBoot.value,
onToggle = { onToggle = {
@@ -66,14 +82,14 @@ fun SettingsScreen() {
editor.putBoolean("update_on_boot", it).apply() editor.putBoolean("update_on_boot", it).apply()
} }
), ),
SettingItem( Setting.Toggle(
title = stringResource(R.string.btn_autorestart), title = stringResource(R.string.btn_autorestart),
checked = autoRestart.value, checked = autoRestart.value,
onToggle = { onToggle = {
if (handleAutoRestart(context, it)) autoRestart.value = it if (handleAutoRestart(context, it)) autoRestart.value = it
} }
), ),
SettingItem( Setting.Toggle(
title = stringResource(R.string.btn_autoupdate), title = stringResource(R.string.btn_autoupdate),
checked = autoUpdate.value, checked = autoUpdate.value,
onToggle = { onToggle = {
@@ -81,17 +97,60 @@ fun SettingsScreen() {
editor.putBoolean("auto_update", it).apply() editor.putBoolean("auto_update", it).apply()
} }
), ),
SettingItem( Setting.Toggle(
title = stringResource(R.string.btn_send_firebase_analytics), title = stringResource(R.string.btn_send_firebase_analytics),
checked = sendFirebaseAnalytics.value, checked = sendFirebaseAnalytics.value,
onToggle = { onToggle = {
sendFirebaseAnalytics.value = it sendFirebaseAnalytics.value = it
editor.putBoolean("send_firebase_analytics", it).apply() editor.putBoolean("send_firebase_analytics", it).apply()
} }
),
Setting.Action(
title = stringResource(R.string.btn_repository_url_lists),
onClick = {
textDialogValue.value = sharedPreferences.getString("hosts_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json") ?: "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json"
showHostsRepoUrlDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.btn_repository_url_strategies),
onClick = {
textDialogValue.value = sharedPreferences.getString("strategy_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json") ?: "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json"
showStrategyRepoUrlDialog.value = true
}
),
Setting.Section(stringResource(R.string.byedpi_section)),
Setting.Toggle(
title = stringResource(R.string.btn_ipv6),
checked = ipv6.value,
onToggle = {
ipv6.value = it
editor.putBoolean("ipv6", it).apply()
}
),
Setting.Action(
title = stringResource(R.string.btn_ip),
onClick = {
textDialogValue.value = sharedPreferences.getString("ip", "127.0.0.1") ?: "127.0.0.1"
showIPDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.btn_port),
onClick = {
textDialogValue.value = sharedPreferences.getString("port", "1080") ?: "1080"
showPortDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.btn_dns),
onClick = {
textDialogValue.value = sharedPreferences.getString("dns", "8.8.8.8") ?: "8.8.8.8"
showDNSDialog.value = true
}
) )
) )
if (openNoRootDialog.value) { if (openNoRootDialog.value) {
InfoDialog( InfoDialog(
title = stringResource(R.string.error_root_title), title = stringResource(R.string.error_root_title),
@@ -112,6 +171,36 @@ fun SettingsScreen() {
AboutDialog(onDismiss = { showAboutDialog.value = false }) AboutDialog(onDismiss = { showAboutDialog.value = false })
} }
if (showHostsRepoUrlDialog.value) {
TextDialog(stringResource(R.string.btn_repository_url_lists), stringResource(R.string.hint_enter_repository_url_lists), textDialogValue.value, onConfirm = {
editor.putString("hosts_repo_url", it).apply()
}, onDismiss = { showHostsRepoUrlDialog.value = false })
}
if (showStrategyRepoUrlDialog.value) {
TextDialog(stringResource(R.string.btn_repository_url_strategies), stringResource(R.string.hint_enter_repository_url_strategies), textDialogValue.value, onConfirm = {
editor.putString("strategy_repo_url", it).apply()
}, onDismiss = { showStrategyRepoUrlDialog.value = false })
}
if (showIPDialog.value) {
TextDialog(stringResource(R.string.btn_ip), stringResource(R.string.hint_ip), textDialogValue.value, onConfirm = {
editor.putString("ip", it).apply()
}, onDismiss = { showIPDialog.value = false })
}
if (showPortDialog.value) {
TextDialog(stringResource(R.string.btn_port), stringResource(R.string.hint_port), textDialogValue.value, onConfirm = {
editor.putString("port", it).apply()
}, onDismiss = { showPortDialog.value = false })
}
if (showDNSDialog.value) {
TextDialog(stringResource(R.string.btn_dns), stringResource(R.string.hint_dns), textDialogValue.value, onConfirm = {
editor.putString("dns", it).apply()
}, onDismiss = { showDNSDialog.value = false })
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
@@ -152,20 +241,24 @@ fun SettingsScreen() {
verticalArrangement = Arrangement.spacedBy(8.dp) verticalArrangement = Arrangement.spacedBy(8.dp)
) { ) {
items(settingsList) { setting -> items(settingsList) { setting ->
ElevatedCard( when (setting) {
modifier = Modifier.fillMaxWidth(), is Setting.Toggle -> {
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(modifier = Modifier.clickable {
setting.onToggle(!setting.checked)
}) {
SettingsItem( SettingsItem(
title = setting.title, title = setting.title,
onToggle = setting.onToggle,
checked = setting.checked, checked = setting.checked,
onCheckedChange = setting.onToggle onCheckedChange = setting.onToggle
) )
} }
is Setting.Action -> {
SettingsTextItem(
title = setting.title,
setting.onClick
)
}
is Setting.Section -> {
SettingsSection(setting.title)
}
} }
} }
} }
@@ -174,24 +267,68 @@ fun SettingsScreen() {
} }
@Composable @Composable
private fun SettingsItem(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) { private fun SettingsItem(title: String, checked: Boolean, onToggle: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit) {
Row( ElevatedCard(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp) .height(80.dp)
.clickable { onToggle(!checked) },
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) { ) {
Text( Row(
text = title, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f) modifier = Modifier
) .fillMaxWidth()
Switch( .padding(16.dp)
checked = checked, ) {
onCheckedChange = onCheckedChange Text(
) text = title,
modifier = Modifier.weight(1f)
)
Switch(
checked = checked,
onCheckedChange = onCheckedChange
)
}
} }
} }
@Composable
private fun SettingsTextItem(title: String, onClick: () -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.clickable { onClick() },
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = title,
modifier = Modifier.weight(1f)
)
Icon(imageVector = Icons.AutoMirrored.Default.ArrowForward, contentDescription = "test")
}
}
}
@Composable
private fun SettingsSection(title: String) {
Text(
text = title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp)
)
}
private fun useModule(context: Context, checked: Boolean, updateOnBoot: MutableState<Boolean>, openNoRootDialog: MutableState<Boolean>, openNoModuleDialog: MutableState<Boolean>, callback: (Boolean) -> Unit) { private fun useModule(context: Context, checked: Boolean, updateOnBoot: MutableState<Boolean>, openNoRootDialog: MutableState<Boolean>, openNoModuleDialog: MutableState<Boolean>, callback: (Boolean) -> Unit) {
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val editor = sharedPreferences.edit() val editor = sharedPreferences.edit()
@@ -203,6 +340,13 @@ private fun useModule(context: Context, checked: Boolean, updateOnBoot: MutableS
editor.putBoolean("use_module", true) editor.putBoolean("use_module", true)
.putBoolean("update_on_boot", true) .putBoolean("update_on_boot", true)
.apply() .apply()
if (ByeDpiVpnService.status == ServiceStatus.Connected) {
context.startService(Intent(context, ByeDpiVpnService::class.java).apply {
action = "STOP_VPN"
})
}
editor.remove("lists").apply()
editor.remove("active_strategy").apply()
updateOnBoot.value = true updateOnBoot.value = true
callback(true) callback(true)
} else { } else {
@@ -246,6 +390,44 @@ private fun InfoDialog(title: String, message: String, onDismiss: () -> Unit) {
) )
} }
@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 @Composable
private fun AboutDialog(onDismiss: () -> Unit) { private fun AboutDialog(onDismiss: () -> Unit) {
AlertDialog( AlertDialog(
@@ -258,8 +440,8 @@ private fun AboutDialog(onDismiss: () -> Unit) {
) )
} }
data class SettingItem( sealed class Setting {
val title: String, data class Toggle(val title: String, val checked: Boolean, val onToggle: (Boolean) -> Unit) : Setting()
val checked: Boolean, data class Action(val title: String, val onClick: () -> Unit) : Setting()
val onToggle: (Boolean) -> Unit data class Section(val title: String) : Setting()
) }

View File

@@ -1,5 +1,6 @@
package com.cherret.zaprett.ui.screen package com.cherret.zaprett.ui.screen
import android.content.Context
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -62,6 +63,7 @@ import com.cherret.zaprett.ui.viewmodel.StrategyViewModel
@Composable @Composable
fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel = viewModel()) { fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel = viewModel()) {
val context = LocalContext.current val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val allLists = viewModel.allItems val allLists = viewModel.allItems
@@ -70,7 +72,11 @@ fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel =
val filePickerLauncher = rememberLauncherForActivityResult( val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument() contract = ActivityResultContracts.OpenDocument()
) { uri -> ) { uri ->
uri?.let { viewModel.copySelectedFile(context, "/strategies", it, snackbarHostState, scope) } uri?.let { viewModel.copySelectedFile(
context,
if (sharedPreferences.getBoolean("use_module", false)) "/strategies/nfqws" else "/strategies/byedpi",
it
) }
} }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {

View File

@@ -14,8 +14,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import com.cherret.zaprett.R import com.cherret.zaprett.R
import com.cherret.zaprett.getZaprettPath import com.cherret.zaprett.utils.getZaprettPath
import com.cherret.zaprett.restartService import com.cherret.zaprett.utils.restartService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@@ -69,9 +69,7 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
} ?: "copied_file" } ?: "copied_file"
try { try {
// Determine output file location based on Android version
val outputFile = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { val outputFile = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+ with MANAGE_EXTERNAL_STORAGE permission
if (Environment.isExternalStorageManager()) { if (Environment.isExternalStorageManager()) {
val outputDir = File(getZaprettPath() + path) val outputDir = File(getZaprettPath() + path)
if (!outputDir.exists()) { if (!outputDir.exists()) {
@@ -79,7 +77,6 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
} }
File(outputDir, fileName) File(outputDir, fileName)
} else { } else {
// Fallback to app-specific storage if permission is not granted
val outputDir = File(context.filesDir, path) val outputDir = File(context.filesDir, path)
if (!outputDir.exists()) { if (!outputDir.exists()) {
outputDir.mkdirs() outputDir.mkdirs()
@@ -87,7 +84,6 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
File(outputDir, fileName) File(outputDir, fileName)
} }
} else { } else {
// Android 10: Use app-specific storage
val outputDir = File(context.filesDir, path) val outputDir = File(context.filesDir, path)
if (!outputDir.exists()) { if (!outputDir.exists()) {
outputDir.mkdirs() outputDir.mkdirs()
@@ -101,7 +97,6 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
} }
} }
refresh() refresh()
showRestartSnackbar(context, snackbarHostState, scope)
} catch (e: IOException) { } catch (e: IOException) {
e.printStackTrace() e.printStackTrace()
scope.launch { scope.launch {

View File

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

View File

@@ -2,28 +2,62 @@ package com.cherret.zaprett.ui.viewmodel
import android.app.Application import android.app.Application
import android.content.Context import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Debug
import android.provider.Settings
import android.util.Log
import android.widget.Toast
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.Help
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.AndroidViewModel
import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.R import com.cherret.zaprett.R
import com.cherret.zaprett.download import com.cherret.zaprett.byedpi.ServiceStatus
import com.cherret.zaprett.getChangelog import com.cherret.zaprett.utils.download
import com.cherret.zaprett.getStatus import com.cherret.zaprett.utils.getActiveStrategy
import com.cherret.zaprett.getUpdate import com.cherret.zaprett.utils.getBinVersion
import com.cherret.zaprett.installApk import com.cherret.zaprett.utils.getChangelog
import com.cherret.zaprett.registerDownloadListener import com.cherret.zaprett.utils.getModuleVersion
import com.cherret.zaprett.restartService import com.cherret.zaprett.utils.getStatus
import com.cherret.zaprett.startService import com.cherret.zaprett.utils.getUpdate
import com.cherret.zaprett.stopService import com.cherret.zaprett.utils.installApk
import com.cherret.zaprett.utils.registerDownloadListener
import com.cherret.zaprett.utils.restartService
import com.cherret.zaprett.utils.startService
import com.cherret.zaprett.utils.stopService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class HomeViewModel(application: Application) : AndroidViewModel(application) { class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val context = application private val context = application
private val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE) private val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val _requestVpnPermission = MutableStateFlow(false)
val requestVpnPermission = _requestVpnPermission.asStateFlow()
var cardText = mutableIntStateOf(R.string.status_not_availible) // MVP temporarily(maybe)
private set
var cardIcon = mutableStateOf(Icons.AutoMirrored.Filled.Help)
private set
var cardText = mutableIntStateOf(R.string.status_not_availible) var moduleVer = mutableStateOf(context.getString(R.string.unknown_text))
private set
var nfqwsVer = mutableStateOf(context.getString(R.string.unknown_text))
private set
var byedpiVer = mutableStateOf("0.17.1")
private set
var serviceMode = mutableIntStateOf(R.string.service_mode_ciadpi)
private set private set
var changeLog = mutableStateOf<String?>(null) var changeLog = mutableStateOf<String?>(null)
@@ -35,7 +69,8 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
var updateAvailable = mutableStateOf(false) var updateAvailable = mutableStateOf(false)
private set private set
private var downloadUrl = mutableStateOf<String?>(null) var downloadUrl = mutableStateOf<String?>(null)
private set
var showUpdateDialog = mutableStateOf(false) var showUpdateDialog = mutableStateOf(false)
@@ -55,23 +90,56 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
fun checkServiceStatus() { fun checkServiceStatus() {
if (prefs.getBoolean("use_module", false) && prefs.getBoolean("update_on_boot", false)) { if (prefs.getBoolean("use_module", false) && prefs.getBoolean("update_on_boot", false)) {
getStatus { isEnabled -> getStatus { isEnabled ->
cardText.intValue = if (isEnabled) R.string.status_enabled else R.string.status_disabled if (isEnabled){
cardText.value = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.value = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
}
}
}
else {
if (ByeDpiVpnService.status == ServiceStatus.Connected){
cardText.value = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.value = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
} }
} }
} }
fun onCardClick(snackbarHostState: SnackbarHostState, scope: CoroutineScope) { fun onCardClick() {
if (prefs.getBoolean("use_module", false)) { if (prefs.getBoolean("use_module", false)) {
getStatus { isEnabled -> getStatus { isEnabled ->
cardText.intValue = if (isEnabled) R.string.status_enabled else R.string.status_disabled if (isEnabled){
cardText.value = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.value = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
}
} }
} else { } else {
scope.launch { if (ByeDpiVpnService.status == ServiceStatus.Connected){
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled)) cardText.value = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.value = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
} }
} }
} }
fun startVpn() {
ContextCompat.startForegroundService(context, Intent(context, ByeDpiVpnService::class.java).apply { action = "START_VPN" })
}
fun onBtnStartService(snackbarHostState: SnackbarHostState, scope: CoroutineScope) { fun onBtnStartService(snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (prefs.getBoolean("use_module", false)) { if (prefs.getBoolean("use_module", false)) {
getStatus { isEnabled -> getStatus { isEnabled ->
@@ -85,12 +153,33 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
if (!isEnabled) startService {} if (!isEnabled) startService {}
} }
} else { } else {
scope.launch { if (ByeDpiVpnService.status == ServiceStatus.Disconnected || ByeDpiVpnService.status == ServiceStatus.Failed) {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled)) if (getActiveStrategy(prefs).isNotEmpty()) {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_starting_service))
}
_requestVpnPermission.value = true
}
else {
Toast.makeText(
context,
context.getString(R.string.toast_no_strategy_selected),
Toast.LENGTH_SHORT
).show()
}
}
else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_already_started))
}
} }
} }
} }
fun clearVpnPermissionRequest() {
_requestVpnPermission.value = false
}
fun onBtnStopService(snackbarHostState: SnackbarHostState, scope: CoroutineScope) { fun onBtnStopService(snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (prefs.getBoolean("use_module", false)) { if (prefs.getBoolean("use_module", false)) {
getStatus { isEnabled -> getStatus { isEnabled ->
@@ -104,8 +193,20 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
if (isEnabled) stopService {} if (isEnabled) stopService {}
} }
} else { } else {
scope.launch { if (ByeDpiVpnService.status == ServiceStatus.Connected) {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled)) scope.launch {
snackbarHostState.showSnackbar(
context.getString(R.string.snack_stopping_service)
)
}
context.startService(Intent(context, ByeDpiVpnService::class.java).apply {
action = "STOP_VPN"
})
}
else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_no_service))
}
} }
} }
} }
@@ -123,6 +224,18 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
} }
} }
fun checkModuleInfo() {
if (prefs.getBoolean("use_module", false)) {
getModuleVersion { value ->
moduleVer.value = value
}
getBinVersion { value ->
nfqwsVer.value = value
}
serviceMode.intValue = R.string.service_mode_nfqws;
}
}
fun showUpdateDialog() { fun showUpdateDialog() {
showUpdateDialog.value = true showUpdateDialog.value = true
} }
@@ -133,9 +246,25 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
fun onUpdateConfirm() { fun onUpdateConfirm() {
showUpdateDialog.value = false showUpdateDialog.value = false
val id = download(context, downloadUrl.value.orEmpty()) if (context.packageManager.canRequestPackageInstalls()){
registerDownloadListener(context, id) { uri -> val id = download(context, downloadUrl.value.orEmpty())
installApk(context, uri) registerDownloadListener(context, id) { uri ->
installApk(context, uri)
}
}
else {
val packageUri = Uri.fromParts("package", context.packageName, null)
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, packageUri).addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
} }
} }
fun parseArgs(ip: String, port: String, lines: List<String>): Array<String> {
val regex = Regex("""--?\S+(?:=(?:[^"'\s]+|"[^"]*"|'[^']*'))?|[^\s]+""")
val parsedArgs = lines
.flatMap { line -> regex.findAll(line).map { it.value } }
return arrayOf("ciadpi", "--ip", ip, "--port", port) + parsedArgs
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,7 +1,8 @@
package com.cherret.zaprett package com.cherret.zaprett.utils
import android.service.quicksettings.Tile import android.service.quicksettings.Tile
import android.service.quicksettings.TileService import android.service.quicksettings.TileService
import com.cherret.zaprett.R
class QSTileService: TileService() { class QSTileService: TileService() {
override fun onTileAdded() { override fun onTileAdded() {
@@ -31,11 +32,7 @@ class QSTileService: TileService() {
updateStatus() updateStatus()
} }
override fun onTileRemoved() { private fun updateStatus() {
super.onTileRemoved()
}
fun updateStatus() {
if (getSharedPreferences("settings", MODE_PRIVATE).getBoolean("use_module", false)) { if (getSharedPreferences("settings", MODE_PRIVATE).getBoolean("use_module", false)) {
getStatus { getStatus {
if (it) { if (it) {

View File

@@ -1,4 +1,4 @@
package com.cherret.zaprett package com.cherret.zaprett.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.DownloadManager import android.app.DownloadManager
@@ -6,6 +6,7 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -24,8 +25,8 @@ import java.security.MessageDigest
private val client = OkHttpClient() private val client = OkHttpClient()
private val json = Json { ignoreUnknownKeys = true } private val json = Json { ignoreUnknownKeys = true }
fun getHostList(callback: (List<RepoItemInfo>?) -> Unit) { fun getHostList(sharedPreferences: SharedPreferences, callback: (List<RepoItemInfo>?) -> Unit) {
val request = Request.Builder().url("https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json").build() val request = Request.Builder().url(sharedPreferences.getString("hosts_repo_url","https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json")?: "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json").build()
client.newCall(request).enqueue(object : Callback { client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
e.printStackTrace() e.printStackTrace()
@@ -34,7 +35,6 @@ fun getHostList(callback: (List<RepoItemInfo>?) -> Unit) {
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
response.use { response.use {
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw IOException()
callback(null) callback(null)
} }
val jsonString = response.body.string() val jsonString = response.body.string()
@@ -45,8 +45,8 @@ fun getHostList(callback: (List<RepoItemInfo>?) -> Unit) {
}) })
} }
fun getStrategiesList(callback: (List<RepoItemInfo>?) -> Unit) { fun getStrategiesList(sharedPreferences: SharedPreferences, callback: (List<RepoItemInfo>?) -> Unit) {
val request = Request.Builder().url("https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json").build() val request = Request.Builder().url(sharedPreferences.getString("strategies_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json")?: "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json").build()
client.newCall(request).enqueue(object : Callback { client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) { override fun onFailure(call: Call, e: IOException) {
e.printStackTrace() e.printStackTrace()
@@ -55,12 +55,11 @@ fun getStrategiesList(callback: (List<RepoItemInfo>?) -> Unit) {
override fun onResponse(call: Call, response: Response) { override fun onResponse(call: Call, response: Response) {
response.use { response.use {
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw IOException()
callback(null) callback(null)
} }
val jsonString = response.body.string() val jsonString = response.body.string()
val hostsInfo = json.decodeFromString<List<RepoItemInfo>>(jsonString) val strategiesInfo = json.decodeFromString<List<RepoItemInfo>>(jsonString)
callback(hostsInfo) callback(strategiesInfo)
} }
} }
}) })
@@ -114,6 +113,7 @@ data class RepoItemInfo(
val name: String, val name: String,
val author: String, val author: String,
val description: String, val description: String,
val type: String? = null,
val hash: String, val hash: String,
val url: String val url: String
) )

View File

@@ -1,4 +1,4 @@
package com.cherret.zaprett package com.cherret.zaprett.utils
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.DownloadManager import android.app.DownloadManager
@@ -16,6 +16,7 @@ import android.util.Log
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.core.net.toUri import androidx.core.net.toUri
import com.cherret.zaprett.BuildConfig
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import okhttp3.Call import okhttp3.Call
@@ -90,7 +91,7 @@ fun download(context: Context, url: String): Long {
} }
val request = DownloadManager.Request(url.toUri()).apply { val request = DownloadManager.Request(url.toUri()).apply {
setTitle(fileName) setTitle(fileName)
setDescription("Загрузка $fileName") setDescription(fileName)
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Use MediaStore for Android 10+ // Use MediaStore for Android 10+
@@ -128,11 +129,6 @@ fun installApk(context: Context, uri: Uri) {
} }
context.startActivity(intent) context.startActivity(intent)
} }
else {
val packageUri = Uri.fromParts("package", context.packageName, null)
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, packageUri)
context.startActivity(intent)
}
} }
fun registerDownloadListener(context: Context, downloadId: Long, onDownloaded: (Uri) -> Unit) {// AI Generated fun registerDownloadListener(context: Context, downloadId: Long, onDownloaded: (Uri) -> Unit) {// AI Generated
@@ -164,4 +160,4 @@ data class UpdateInfo(
val versionCode: Int?, val versionCode: Int?,
val downloadUrl: String?, val downloadUrl: String?,
val changelogUrl: String? val changelogUrl: String?
) )

View File

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

View File

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

View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.Zaprett" parent="android:Theme.Material.Light.NoActionBar" /> <style name="Theme.Zaprett" parent="android:Theme.Material.Light.NoActionBar" />
</resources> </resources>

View File

@@ -1 +1,5 @@
Добавлена вкладка "Стратегии" Исправлен вылет сервиса
Изменены иконки
Добавлена проверка наличия модуля при запуске приложения
Изменения в системе обновления приложения
Добавлена иконка в карточку состояния

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 134 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 113 KiB

BIN
images/7.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

View File

@@ -1,6 +1,6 @@
{ {
"version": "1.10", "version": "2.2",
"versionCode": 11, "versionCode": 14,
"downloadUrl": "https://github.com/CherretGit/zaprett-app/releases/download/1_10_0/app-release.apk", "downloadUrl": "https://github.com/CherretGit/zaprett-app/releases/download/2_2/app-release.apk",
"changelogUrl": "https://raw.githubusercontent.com/CherretGit/zaprett-app/refs/heads/main/changelog.md" "changelogUrl": "https://raw.githubusercontent.com/CherretGit/zaprett-app/refs/heads/main/changelog.md"
} }