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'
cache: gradle
- name: Setup Git submodules
run: git submodule update --init --recursive
- name: Grant execute permission for 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>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2025-03-24T08:57:47.584581613Z">
<DropdownSelection timestamp="2025-06-22T07:39:53.690794081Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/dimap/.var/app/com.google.AndroidStudio/config/.android/avd/Medium_Phone.avd" />
<DeviceId pluginId="Default" identifier="serial=10.0.0.189:40463;connection=ffec9c3f" />
</handle>
</Target>
</DropdownSelection>

2
.idea/misc.xml generated
View File

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

6
.idea/vcs.xml generated
View File

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

View File

@@ -2,9 +2,9 @@
## О приложении
Приложение разработано для работы с модулем [zaprett](https://github.com/egor-white/zaprett)
### [Официальный 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/6.png" width="300">
<img src="images/7.png" width="300">

View File

@@ -9,18 +9,26 @@ plugins {
android {
namespace = "com.cherret.zaprett"
compileSdk = 36
compileSdk = 35
defaultConfig {
applicationId = "com.cherret.zaprett"
minSdk = 29
targetSdk = 36
versionCode = 11
versionName = "1.10"
targetSdk = 35
versionCode = 14
versionName = "2.2"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
ndk {
abiFilters += listOf("armeabi-v7a", "arm64-v8a", "x86", "x86_64")
}
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
buildTypes {
release {
isMinifyEnabled = true
@@ -45,18 +53,42 @@ android {
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 {
implementation(libs.material3)
implementation(libs.androidx.material3.window.size.class1)
implementation(libs.androidx.material3.adaptive.navigation.suite)
implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.material.icons.extended)
implementation(libs.libsu.core)
implementation(libs.okhttp)
implementation(libs.kotlinx.serialization.json)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.analytics)
implementation(libs.firebase.crashlytics)
implementation("androidx.compose.material3:material3:1.3.1")
implementation("androidx.compose.material3:material3-window-size-class:1.3.1")
implementation("androidx.compose.material3:material3-adaptive-navigation-suite:1.4.0-alpha10")
implementation("androidx.navigation:navigation-compose:2.8.9")
implementation("androidx.compose.material:material-icons-extended:1.7.8")
implementation ("com.github.topjohnwu.libsu:core:6.0.0")
implementation("com.squareup.okhttp3:okhttp:5.0.0-alpha.14")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.8.0")
implementation(platform("com.google.firebase:firebase-bom:33.12.0"))
implementation("com.google.firebase:firebase-analytics")
implementation("com.google.firebase:firebase-crashlytics")
implementation("androidx.fragment:fragment-compose:1.8.8")
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.activity.compose)

View File

@@ -19,3 +19,9 @@
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-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.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<application
android:requestLegacyExternalStorage="true"
android:allowBackup="true"
@@ -43,7 +46,7 @@
</intent-filter>
</activity>
<service
android:name=".QSTileService"
android:name=".utils.QSTileService"
android:exported="true"
android:label="@string/qs_name"
android:icon="@drawable/ic_launcher_monochrome"
@@ -52,6 +55,15 @@
<action android:name="android.service.quicksettings.action.QS_TILE" />
</intent-filter>
</service>
<service
android:name=".byedpi.ByeDpiVpnService"
android:exported="true"
android:permission="android.permission.BIND_VPN_SERVICE"
android:foregroundServiceType="specialUse">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
</service>
</application>
</manifest>

View File

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

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

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

View File

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

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

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

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

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

View File

@@ -1,6 +1,8 @@
package com.cherret.zaprett
import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
@@ -11,12 +13,15 @@ import android.provider.Settings
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.annotation.StringRes
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Dashboard
import androidx.compose.material.icons.filled.Dns
import androidx.compose.material.icons.filled.Home
import androidx.compose.material.icons.filled.Lan
import androidx.compose.material.icons.filled.MultipleStop
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Icon
@@ -26,16 +31,16 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.core.content.edit
import androidx.core.content.ContextCompat
import androidx.core.content.edit
import androidx.navigation.NavGraph.Companion.findStartDestination
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
@@ -48,31 +53,53 @@ import com.cherret.zaprett.ui.screen.HostsScreen
import com.cherret.zaprett.ui.screen.SettingsScreen
import com.cherret.zaprett.ui.screen.StrategyScreen
import com.cherret.zaprett.ui.theme.ZaprettTheme
import com.cherret.zaprett.ui.viewmodel.HomeViewModel
import com.cherret.zaprett.ui.viewmodel.HostRepoViewModel
import com.cherret.zaprett.ui.viewmodel.StrategyRepoViewModel
import com.cherret.zaprett.utils.checkModuleInstallation
import com.google.firebase.Firebase
import com.google.firebase.analytics.FirebaseAnalytics
import com.google.firebase.analytics.analytics
sealed class Screen(val route: String, @StringRes val nameResId: Int, val icon: ImageVector) {
object home : Screen("home", R.string.title_home, Icons.Default.Home)
object hosts : Screen("hosts", R.string.title_hosts, Icons.Default.Dashboard)
object strategies : Screen("strategies", R.string.title_strategies, Icons.Default.Dns)
object hosts : Screen("hosts", R.string.title_hosts, Icons.Default.Lan)
object strategies : Screen("strategies", R.string.title_strategies, Icons.Default.MultipleStop)
object settings : Screen("settings", R.string.title_settings, Icons.Default.Settings)
}
val topLevelRoutes = listOf(Screen.home, Screen.hosts, Screen.strategies, Screen.settings)
val hideNavBar = listOf("repo?source={source}")
class MainActivity : ComponentActivity() {
private val viewModel: HomeViewModel by viewModels()
private lateinit var notificationPermissionLauncher: ActivityResultLauncher<String>
private lateinit var firebaseAnalytics: FirebaseAnalytics
private lateinit var vpnPermissionLauncher: ActivityResultLauncher<Intent>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
vpnPermissionLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result ->
if (result.resultCode == RESULT_OK) {
viewModel.startVpn()
viewModel.clearVpnPermissionRequest()
}
}
notificationPermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> }
firebaseAnalytics = Firebase.analytics
enableEdgeToEdge()
setContent {
ZaprettTheme {
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(
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
!Environment.isExternalStorageManager()
@@ -87,13 +114,37 @@ class MainActivity : ComponentActivity() {
var showWelcomeDialog by remember {
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()
if (showPermissionDialog) {
PermissionDialog { showPermissionDialog = false }
if (showStoragePermissionDialog) {
PermissionDialog(
title = stringResource(R.string.error_no_storage_title),
message = stringResource(R.string.error_no_storage_message),
onConfirm = {
val intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
val uri = Uri.fromParts("package", packageName, null)
intent.data = uri
startActivity(intent)
showStoragePermissionDialog = false
},
onDismiss = { showStoragePermissionDialog = false }
)
}
if (showNotificationPermissionDialog) {
PermissionDialog(
title = stringResource(R.string.notification_permission_title),
message = stringResource(R.string.notification_permission_message),
onConfirm = {
notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
showNotificationPermissionDialog = false
},
onDismiss = { showNotificationPermissionDialog = false }
)
}
if (showWelcomeDialog) {
WelcomeDialog {
sharedPreferences.edit { putBoolean("welcome_dialog", false) }
@@ -104,6 +155,7 @@ class MainActivity : ComponentActivity() {
}
}
@Composable
fun BottomBar() {
val navController = rememberNavController()
@@ -143,21 +195,20 @@ class MainActivity : ComponentActivity() {
startDestination = Screen.home.route,
Modifier.padding(innerPadding)
) {
composable(Screen.home.route) { HomeScreen() }
composable(Screen.home.route) { HomeScreen(viewModel = viewModel, vpnPermissionLauncher) }
composable(Screen.hosts.route) { HostsScreen(navController) }
composable(Screen.strategies.route) { StrategyScreen(navController) }
composable(Screen.settings.route) { SettingsScreen() }
composable(
route = "repo?source={source}",
arguments = listOf(navArgument("source") {})
) { backStackEntry ->
composable(route = "repo?source={source}",arguments = listOf(navArgument("source") {})) { backStackEntry ->
val source = backStackEntry.arguments?.getString("source")
when (source) {
"hosts" -> {
RepoScreen(navController, ::getAllLists, ::getHostList, "/lists")
val viewModel: HostRepoViewModel = androidx.lifecycle.viewmodel.compose.viewModel()
RepoScreen(navController, viewModel)
}
"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
fun PermissionDialog(onDismiss: () -> Unit) {
val context = LocalContext.current
fun PermissionDialog(title: String, message: String, onConfirm: () -> Unit, onDismiss: () -> Unit) {
AlertDialog(
title = { Text(text = stringResource(R.string.error_no_storage_title)) },
text = { Text(text = stringResource(R.string.error_no_storage_message)) },
title = { Text(title) },
text = { Text(message) },
onDismissRequest = onDismiss,
confirmButton = {
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
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.VpnService
import androidx.activity.result.ActivityResultLauncher
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Build
import androidx.compose.material.icons.filled.Dangerous
import androidx.compose.material.icons.filled.Extension
import androidx.compose.material.icons.filled.PlayArrow
import androidx.compose.material.icons.filled.RestartAlt
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.WavingHand
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -32,14 +48,21 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.lifecycle.viewmodel.compose.viewModel
@@ -50,18 +73,38 @@ import kotlinx.coroutines.CoroutineScope
@OptIn(ExperimentalMaterial3Api::class)
@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 snackbarHostState = remember { SnackbarHostState() }
val cardText = viewModel.cardText
val cardIcon = viewModel.cardIcon;
val changeLog = viewModel.changeLog
val newVersion = viewModel.newVersion
val updateAvailable = viewModel.updateAvailable
val showUpdateDialog = viewModel.showUpdateDialog.value
val moduleVer = viewModel.moduleVer;
val nfqwsVer = viewModel.nfqwsVer;
val byedpiVer = viewModel.byedpiVer;
val serviceMode = viewModel.serviceMode
LaunchedEffect(Unit) {
viewModel.checkForUpdate()
viewModel.checkServiceStatus()
viewModel.checkModuleInfo()
}
LaunchedEffect(requestVpnPermission) {
if (requestVpnPermission) {
val intent = VpnService.prepare(context)
if (intent != null) {
vpnLauncher.launch(intent)
} else {
viewModel.startVpn()
viewModel.clearVpnPermissionRequest()
}
}
}
Scaffold(
@@ -79,38 +122,65 @@ fun HomeScreen(viewModel: HomeViewModel = viewModel()) {
},
snackbarHost = { SnackbarHost(snackbarHostState) },
content = { paddingValues ->
Column(modifier = Modifier.padding(paddingValues)) {
ServiceStatusCard(viewModel, cardText, snackbarHostState, scope)
Column(modifier = Modifier
.padding(paddingValues)
.verticalScroll(rememberScrollState())) {
ServiceStatusCard(viewModel, cardText, cardIcon, snackbarHostState, scope)
UpdateCard(updateAvailable) { viewModel.showUpdateDialog() }
if (showUpdateDialog) {
UpdateDialog(viewModel, changeLog.value.orEmpty(), newVersion) { viewModel.dismissUpdateDialog() }
}
ServiceControlButtons(viewModel, snackbarHostState, scope)
ServiceControlButtons(
viewModel,
sharedPreferences,
snackbarHostState,
scope
)
ModuleInfoCard(moduleVer, nfqwsVer, byedpiVer, serviceMode)
}
}
)
}
@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(
elevation = CardDefaults.cardElevation(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primary),
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 25.dp, end = 10.dp)
.width(240.dp)
.height(150.dp),
onClick = { viewModel.onCardClick(snackbarHostState, scope) }
//.height(150.dp)
.wrapContentHeight(),
onClick = { viewModel.onCardClick() }
) {
Text(
text = stringResource(cardText.value),
fontFamily = FontFamily(Font(R.font.unbounded, FontWeight.Normal)),
Row (
modifier = Modifier
.fillMaxWidth()
.fillMaxSize()
.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
private fun ServiceControlButtons(viewModel: HomeViewModel, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
private fun ServiceControlButtons(viewModel: HomeViewModel, sharedPreferences: SharedPreferences, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
FilledTonalButton(
onClick = { viewModel.onBtnStartService(snackbarHostState, scope) },
modifier = Modifier
@@ -171,21 +241,82 @@ private fun ServiceControlButtons(viewModel: HomeViewModel, snackbarHostState: S
)
Text(stringResource(R.string.btn_stop_service))
}
FilledTonalButton(
onClick = { viewModel.onBtnRestart(snackbarHostState, scope) },
modifier = Modifier
.padding(horizontal = 5.dp, vertical = 8.dp)
.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.RestartAlt,
contentDescription = stringResource(R.string.btn_restart_service),
modifier = Modifier.size(20.dp)
)
Text(stringResource(R.string.btn_restart_service))
if (sharedPreferences.getBoolean("use_module", false)) {
FilledTonalButton(
onClick = { viewModel.onBtnRestart(snackbarHostState, scope) },
modifier = Modifier
.padding(horizontal = 5.dp, vertical = 8.dp)
.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.RestartAlt,
contentDescription = stringResource(R.string.btn_restart_service),
modifier = Modifier.size(20.dp)
)
Text(stringResource(R.string.btn_restart_service))
}
}
}
@Composable
private fun ModuleInfoCard(
moduleVer: MutableState<String>,
nfqwsVer: MutableState<String>,
byedpiVer: MutableState<String>,
serviceMode: MutableState<Int>
) {
ElevatedCard(
elevation = CardDefaults.cardElevation(6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 10.dp)
) {
ModuleInfoItem(Icons.Default.Build, stringResource(R.string.service_mode), stringResource(serviceMode.value))
HorizontalDivider()
ModuleInfoItem(Icons.Default.Extension, stringResource(R.string.module_version), moduleVer.value)
HorizontalDivider()
ModuleInfoItem(Icons.Default.Dangerous, stringResource(R.string.nfqws_version), nfqwsVer.value)
HorizontalDivider()
ModuleInfoItem(Icons.Default.WavingHand, stringResource(R.string.ciadpi_version), byedpiVer.value)
}
}
@Composable
private fun ModuleInfoItem(
icon: ImageVector, header : String, value : String
) {
Row (
modifier = Modifier
.fillMaxWidth()
.padding(8.dp)
) {
Icon(
painter = rememberVectorPainter(icon),
modifier = Modifier
.size(50.dp)
.padding(16.dp),
contentDescription = "icon"
)
Column (
modifier = Modifier
.padding(horizontal = 16.dp)
){
Text(
text = header,
modifier = Modifier
.fillMaxWidth(),
fontSize = 18.sp,
textAlign = TextAlign.Justify,
)
Text(
text = value
)
}
}
}
@Composable
fun UpdateDialog(viewModel: HomeViewModel, changeLog: String, newVersion: MutableState<String?>, onDismiss: () -> Unit) {
AlertDialog(

View File

@@ -70,7 +70,7 @@ fun HostsScreen(navController: NavController, viewModel: HostsViewModel = viewMo
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { uri ->
uri?.let { viewModel.copySelectedFile(context, "/lists", it, snackbarHostState, scope) }
uri?.let { viewModel.copySelectedFile(context, "/lists", it) }
}
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.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -30,11 +31,7 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateMapOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
@@ -47,39 +44,24 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavController
import com.cherret.zaprett.RepoItemInfo
import com.cherret.zaprett.R
import com.cherret.zaprett.download
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
import com.cherret.zaprett.ui.viewmodel.BaseRepoViewModel
@OptIn(ExperimentalMaterial3Api::class)
@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 allLists by remember { mutableStateOf(getAllLists()) }
var hostLists by remember { mutableStateOf<List<RepoItemInfo>?>(null) }
val isUpdate = remember { mutableStateMapOf<String, Boolean>() }
val hostLists = viewModel.hostLists.value
val isUpdate = viewModel.isUpdate
val isInstalling = viewModel.isInstalling
val isUpdateInstalling = viewModel.isUpdateInstalling
val isRefreshing = viewModel.isRefreshing.value
val snackbarHostState = remember { SnackbarHostState() }
var isRefreshing by remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
getHostList {
hostLists = it
}
}
LaunchedEffect(hostLists) {
if (hostLists != null) {
withContext(Dispatchers.IO) {
hostLists!!.forEach { item ->
isUpdate[item.name] = !allLists.any { getFileSha256(File(it)) == item.hash }
}
}
}
viewModel.refresh()
}
Scaffold(
topBar = {
TopAppBar(
@@ -101,66 +83,52 @@ fun RepoScreen(navController: NavController, getAllLists: () -> Array<String>, g
windowInsets = WindowInsets(0)
)
},
content = { paddingValues ->
content = { paddingValues ->
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
isRefreshing = true
getHostList {
hostLists = it
isRefreshing = false
}
},
onRefresh = { viewModel.refresh() },
modifier = Modifier.fillMaxSize()
) {
LazyColumn(
contentPadding = paddingValues,
modifier = Modifier.fillMaxSize()
) {
when {
hostLists?.isEmpty() != false -> {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.empty_list),
textAlign = TextAlign.Center
)
}
if (hostLists.isEmpty()) {
item {
Box(
modifier = Modifier.fillParentMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.empty_list),
textAlign = TextAlign.Center
)
}
}
else -> {
items(hostLists.orEmpty()) { item ->
var isButtonEnabled by remember { mutableStateOf(!allLists.any { File(it).name == item.name }) }
var isInstalling by remember { mutableStateOf(false) }
var isButtonUpdateEnabled by remember { mutableStateOf(true) }
var isUpdateInstalling by remember { mutableStateOf(false) }
ElevatedCard(
elevation = CardDefaults.cardElevation(
defaultElevation = 6.dp
),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 25.dp, end = 10.dp),
) {
} else {
items(hostLists) { item ->
val isInstalled = viewModel.isItemInstalled(item)
val installing = isInstalling[item.name] == true
val updating = isUpdateInstalling[item.name] == true
ElevatedCard(
elevation = CardDefaults.cardElevation(defaultElevation = 6.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainer),
modifier = Modifier
.fillMaxWidth()
.padding(start = 10.dp, top = 25.dp, end = 10.dp)
) {
Column(Modifier.fillMaxWidth()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = item.name,
modifier = Modifier.weight(1f)
)
Text(text = item.name, modifier = Modifier.weight(1f))
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp)
@@ -170,8 +138,8 @@ fun RepoScreen(navController: NavController, getAllLists: () -> Array<String>, g
modifier = Modifier.weight(1f)
)
}
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp)
@@ -181,39 +149,18 @@ fun RepoScreen(navController: NavController, getAllLists: () -> Array<String>, g
modifier = Modifier.weight(1f)
)
}
HorizontalDivider(thickness = Dp.Hairline)
Row(
modifier = Modifier
.fillMaxWidth(),
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End
) {
if (isUpdate[item.name] == true && allLists.any { File(it).name == item.name }) {
if (isUpdate[item.name] == true && isInstalled) {
FilledTonalButton(
onClick = {
isUpdateInstalling = true
isButtonUpdateEnabled = false
val downloadId = download(context, item.url)
registerDownloadListenerHost(
context,
downloadId
) { uri ->
val sourceFile = File(uri.path!!)
val targetFile = File(
getZaprettPath() + 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),
onClick = { viewModel.update(item) },
enabled = !updating,
modifier = Modifier.padding(horizontal = 5.dp)
) {
Icon(
imageVector = Icons.Default.Update,
@@ -221,37 +168,16 @@ fun RepoScreen(navController: NavController, getAllLists: () -> Array<String>, g
modifier = Modifier.size(20.dp)
)
Text(
if (!isUpdateInstalling) stringResource(R.string.btn_update_host) else stringResource(
R.string.btn_updating_host
)
if (updating) stringResource(R.string.btn_updating_host)
else stringResource(R.string.btn_update_host)
)
}
}
FilledTonalButton(
onClick = {
isInstalling = true
isButtonEnabled = false
val downloadId = download(context, item.url)
registerDownloadListenerHost(
context,
downloadId
) { uri ->
val sourceFile = File(uri.path!!)
val targetFile = File(
getZaprettPath() + 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),
onClick = { viewModel.install(item) },
enabled = !installing && !isInstalled,
modifier = Modifier.padding(horizontal = 5.dp)
) {
Icon(
imageVector = Icons.Default.InstallMobile,
@@ -259,9 +185,11 @@ fun RepoScreen(navController: NavController, getAllLists: () -> Array<String>, g
modifier = Modifier.size(20.dp)
)
Text(
if (isButtonEnabled) stringResource(R.string.btn_install_host) else if (isInstalling) stringResource(
R.string.btn_installing_host
) else stringResource(R.string.btn_installed_host)
when {
installing -> stringResource(R.string.btn_installing_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
import android.content.Context
import android.content.Intent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowForward
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -20,11 +22,14 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.cherret.zaprett.BuildConfig
import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.R
import com.cherret.zaprett.checkModuleInstallation
import com.cherret.zaprett.checkRoot
import com.cherret.zaprett.getStartOnBoot
import com.cherret.zaprett.setStartOnBoot
import com.cherret.zaprett.byedpi.ServiceStatus
import com.cherret.zaprett.utils.checkModuleInstallation
import com.cherret.zaprett.utils.checkRoot
import com.cherret.zaprett.utils.getStartOnBoot
import com.cherret.zaprett.utils.setStartOnBoot
import com.cherret.zaprett.utils.stopService
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -38,12 +43,20 @@ fun SettingsScreen() {
val autoRestart = remember { mutableStateOf(getStartOnBoot()) }
val autoUpdate = remember { mutableStateOf(sharedPreferences.getBoolean("auto_update", true)) }
val sendFirebaseAnalytics = remember { mutableStateOf(sharedPreferences.getBoolean("send_firebase_analytics", true)) }
val ipv6 = remember { mutableStateOf(sharedPreferences.getBoolean("ipv6",false)) }
val openNoRootDialog = remember { mutableStateOf(false) }
val openNoModuleDialog = remember { mutableStateOf(false) }
val showAboutDialog = remember { mutableStateOf(false) }
val showHostsRepoUrlDialog = remember { mutableStateOf(false) }
val showStrategyRepoUrlDialog = remember { mutableStateOf(false) }
val showIPDialog = remember { mutableStateOf(false) }
val showPortDialog = remember { mutableStateOf(false) }
val showDNSDialog = remember { mutableStateOf(false) }
val textDialogValue = remember { mutableStateOf("") }
val settingsList = listOf(
SettingItem(
Setting.Section(stringResource(R.string.general_section)),
Setting.Toggle(
title = stringResource(R.string.btn_use_root),
checked = useModule.value,
onToggle = { isChecked ->
@@ -54,11 +67,14 @@ fun SettingsScreen() {
openNoRootDialog = openNoRootDialog,
openNoModuleDialog = openNoModuleDialog
) { 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),
checked = updateOnBoot.value,
onToggle = {
@@ -66,14 +82,14 @@ fun SettingsScreen() {
editor.putBoolean("update_on_boot", it).apply()
}
),
SettingItem(
Setting.Toggle(
title = stringResource(R.string.btn_autorestart),
checked = autoRestart.value,
onToggle = {
if (handleAutoRestart(context, it)) autoRestart.value = it
}
),
SettingItem(
Setting.Toggle(
title = stringResource(R.string.btn_autoupdate),
checked = autoUpdate.value,
onToggle = {
@@ -81,17 +97,60 @@ fun SettingsScreen() {
editor.putBoolean("auto_update", it).apply()
}
),
SettingItem(
Setting.Toggle(
title = stringResource(R.string.btn_send_firebase_analytics),
checked = sendFirebaseAnalytics.value,
onToggle = {
sendFirebaseAnalytics.value = it
editor.putBoolean("send_firebase_analytics", it).apply()
}
),
Setting.Action(
title = stringResource(R.string.btn_repository_url_lists),
onClick = {
textDialogValue.value = sharedPreferences.getString("hosts_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json") ?: "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json"
showHostsRepoUrlDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.btn_repository_url_strategies),
onClick = {
textDialogValue.value = sharedPreferences.getString("strategy_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json") ?: "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json"
showStrategyRepoUrlDialog.value = true
}
),
Setting.Section(stringResource(R.string.byedpi_section)),
Setting.Toggle(
title = stringResource(R.string.btn_ipv6),
checked = ipv6.value,
onToggle = {
ipv6.value = it
editor.putBoolean("ipv6", it).apply()
}
),
Setting.Action(
title = stringResource(R.string.btn_ip),
onClick = {
textDialogValue.value = sharedPreferences.getString("ip", "127.0.0.1") ?: "127.0.0.1"
showIPDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.btn_port),
onClick = {
textDialogValue.value = sharedPreferences.getString("port", "1080") ?: "1080"
showPortDialog.value = true
}
),
Setting.Action(
title = stringResource(R.string.btn_dns),
onClick = {
textDialogValue.value = sharedPreferences.getString("dns", "8.8.8.8") ?: "8.8.8.8"
showDNSDialog.value = true
}
)
)
if (openNoRootDialog.value) {
InfoDialog(
title = stringResource(R.string.error_root_title),
@@ -112,6 +171,36 @@ fun SettingsScreen() {
AboutDialog(onDismiss = { showAboutDialog.value = false })
}
if (showHostsRepoUrlDialog.value) {
TextDialog(stringResource(R.string.btn_repository_url_lists), stringResource(R.string.hint_enter_repository_url_lists), textDialogValue.value, onConfirm = {
editor.putString("hosts_repo_url", it).apply()
}, onDismiss = { showHostsRepoUrlDialog.value = false })
}
if (showStrategyRepoUrlDialog.value) {
TextDialog(stringResource(R.string.btn_repository_url_strategies), stringResource(R.string.hint_enter_repository_url_strategies), textDialogValue.value, onConfirm = {
editor.putString("strategy_repo_url", it).apply()
}, onDismiss = { showStrategyRepoUrlDialog.value = false })
}
if (showIPDialog.value) {
TextDialog(stringResource(R.string.btn_ip), stringResource(R.string.hint_ip), textDialogValue.value, onConfirm = {
editor.putString("ip", it).apply()
}, onDismiss = { showIPDialog.value = false })
}
if (showPortDialog.value) {
TextDialog(stringResource(R.string.btn_port), stringResource(R.string.hint_port), textDialogValue.value, onConfirm = {
editor.putString("port", it).apply()
}, onDismiss = { showPortDialog.value = false })
}
if (showDNSDialog.value) {
TextDialog(stringResource(R.string.btn_dns), stringResource(R.string.hint_dns), textDialogValue.value, onConfirm = {
editor.putString("dns", it).apply()
}, onDismiss = { showDNSDialog.value = false })
}
Scaffold(
topBar = {
TopAppBar(
@@ -152,20 +241,24 @@ fun SettingsScreen() {
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
items(settingsList) { setting ->
ElevatedCard(
modifier = Modifier.fillMaxWidth(),
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(modifier = Modifier.clickable {
setting.onToggle(!setting.checked)
}) {
when (setting) {
is Setting.Toggle -> {
SettingsItem(
title = setting.title,
onToggle = setting.onToggle,
checked = setting.checked,
onCheckedChange = setting.onToggle
)
}
is Setting.Action -> {
SettingsTextItem(
title = setting.title,
setting.onClick
)
}
is Setting.Section -> {
SettingsSection(setting.title)
}
}
}
}
@@ -174,24 +267,68 @@ fun SettingsScreen() {
}
@Composable
private fun SettingsItem(title: String, checked: Boolean, onCheckedChange: (Boolean) -> Unit) {
Row(
verticalAlignment = Alignment.CenterVertically,
private fun SettingsItem(title: String, checked: Boolean, onToggle: (Boolean) -> Unit, onCheckedChange: (Boolean) -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.height(80.dp)
.clickable { onToggle(!checked) },
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Text(
text = title,
modifier = Modifier.weight(1f)
)
Switch(
checked = checked,
onCheckedChange = onCheckedChange
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
) {
Text(
text = title,
modifier = Modifier.weight(1f)
)
Switch(
checked = checked,
onCheckedChange = onCheckedChange
)
}
}
}
@Composable
private fun SettingsTextItem(title: String, onClick: () -> Unit) {
ElevatedCard(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.clickable { onClick() },
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = title,
modifier = Modifier.weight(1f)
)
Icon(imageVector = Icons.AutoMirrored.Default.ArrowForward, contentDescription = "test")
}
}
}
@Composable
private fun SettingsSection(title: String) {
Text(
text = title,
fontSize = 20.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(vertical = 8.dp, horizontal = 4.dp)
)
}
private fun useModule(context: Context, checked: Boolean, updateOnBoot: MutableState<Boolean>, openNoRootDialog: MutableState<Boolean>, openNoModuleDialog: MutableState<Boolean>, callback: (Boolean) -> Unit) {
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val editor = sharedPreferences.edit()
@@ -203,6 +340,13 @@ private fun useModule(context: Context, checked: Boolean, updateOnBoot: MutableS
editor.putBoolean("use_module", true)
.putBoolean("update_on_boot", true)
.apply()
if (ByeDpiVpnService.status == ServiceStatus.Connected) {
context.startService(Intent(context, ByeDpiVpnService::class.java).apply {
action = "STOP_VPN"
})
}
editor.remove("lists").apply()
editor.remove("active_strategy").apply()
updateOnBoot.value = true
callback(true)
} else {
@@ -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
private fun AboutDialog(onDismiss: () -> Unit) {
AlertDialog(
@@ -258,8 +440,8 @@ private fun AboutDialog(onDismiss: () -> Unit) {
)
}
data class SettingItem(
val title: String,
val checked: Boolean,
val onToggle: (Boolean) -> Unit
)
sealed class Setting {
data class Toggle(val title: String, val checked: Boolean, val onToggle: (Boolean) -> Unit) : Setting()
data class Action(val title: String, val onClick: () -> Unit) : Setting()
data class Section(val title: String) : Setting()
}

View File

@@ -1,5 +1,6 @@
package com.cherret.zaprett.ui.screen
import android.content.Context
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
@@ -62,6 +63,7 @@ import com.cherret.zaprett.ui.viewmodel.StrategyViewModel
@Composable
fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel = viewModel()) {
val context = LocalContext.current
val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val allLists = viewModel.allItems
@@ -70,7 +72,11 @@ fun StrategyScreen(navController: NavController, viewModel: StrategyViewModel =
val filePickerLauncher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument()
) { 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) {

View File

@@ -14,8 +14,8 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.AndroidViewModel
import com.cherret.zaprett.R
import com.cherret.zaprett.getZaprettPath
import com.cherret.zaprett.restartService
import com.cherret.zaprett.utils.getZaprettPath
import com.cherret.zaprett.utils.restartService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.io.File
@@ -69,9 +69,7 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
} ?: "copied_file"
try {
// Determine output file location based on Android version
val outputFile = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
// Android 11+ with MANAGE_EXTERNAL_STORAGE permission
if (Environment.isExternalStorageManager()) {
val outputDir = File(getZaprettPath() + path)
if (!outputDir.exists()) {
@@ -79,7 +77,6 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
}
File(outputDir, fileName)
} else {
// Fallback to app-specific storage if permission is not granted
val outputDir = File(context.filesDir, path)
if (!outputDir.exists()) {
outputDir.mkdirs()
@@ -87,7 +84,6 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
File(outputDir, fileName)
}
} else {
// Android 10: Use app-specific storage
val outputDir = File(context.filesDir, path)
if (!outputDir.exists()) {
outputDir.mkdirs()
@@ -101,7 +97,6 @@ abstract class BaseListsViewModel(application: Application) : AndroidViewModel(a
}
}
refresh()
showRestartSnackbar(context, snackbarHostState, scope)
} catch (e: IOException) {
e.printStackTrace()
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.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.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.core.content.ContextCompat
import androidx.lifecycle.AndroidViewModel
import com.cherret.zaprett.byedpi.ByeDpiVpnService
import com.cherret.zaprett.R
import com.cherret.zaprett.download
import com.cherret.zaprett.getChangelog
import com.cherret.zaprett.getStatus
import com.cherret.zaprett.getUpdate
import com.cherret.zaprett.installApk
import com.cherret.zaprett.registerDownloadListener
import com.cherret.zaprett.restartService
import com.cherret.zaprett.startService
import com.cherret.zaprett.stopService
import com.cherret.zaprett.byedpi.ServiceStatus
import com.cherret.zaprett.utils.download
import com.cherret.zaprett.utils.getActiveStrategy
import com.cherret.zaprett.utils.getBinVersion
import com.cherret.zaprett.utils.getChangelog
import com.cherret.zaprett.utils.getModuleVersion
import com.cherret.zaprett.utils.getStatus
import com.cherret.zaprett.utils.getUpdate
import com.cherret.zaprett.utils.installApk
import com.cherret.zaprett.utils.registerDownloadListener
import com.cherret.zaprett.utils.restartService
import com.cherret.zaprett.utils.startService
import com.cherret.zaprett.utils.stopService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class HomeViewModel(application: Application) : AndroidViewModel(application) {
private val context = application
private val prefs = context.getSharedPreferences("settings", Context.MODE_PRIVATE)
private val _requestVpnPermission = MutableStateFlow(false)
val requestVpnPermission = _requestVpnPermission.asStateFlow()
var cardText = mutableIntStateOf(R.string.status_not_availible) // MVP temporarily(maybe)
private set
var cardIcon = mutableStateOf(Icons.AutoMirrored.Filled.Help)
private set
var 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
var changeLog = mutableStateOf<String?>(null)
@@ -35,7 +69,8 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
var updateAvailable = mutableStateOf(false)
private set
private var downloadUrl = mutableStateOf<String?>(null)
var downloadUrl = mutableStateOf<String?>(null)
private set
var showUpdateDialog = mutableStateOf(false)
@@ -55,23 +90,56 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
fun checkServiceStatus() {
if (prefs.getBoolean("use_module", false) && prefs.getBoolean("update_on_boot", false)) {
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)) {
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 {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled))
if (ByeDpiVpnService.status == ServiceStatus.Connected){
cardText.value = R.string.status_enabled
cardIcon.value = Icons.Filled.CheckCircle
}
else {
cardText.value = R.string.status_disabled
cardIcon.value = Icons.Filled.Cancel
}
}
}
fun startVpn() {
ContextCompat.startForegroundService(context, Intent(context, ByeDpiVpnService::class.java).apply { action = "START_VPN" })
}
fun onBtnStartService(snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (prefs.getBoolean("use_module", false)) {
getStatus { isEnabled ->
@@ -85,12 +153,33 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
if (!isEnabled) startService {}
}
} else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled))
if (ByeDpiVpnService.status == ServiceStatus.Disconnected || ByeDpiVpnService.status == ServiceStatus.Failed) {
if (getActiveStrategy(prefs).isNotEmpty()) {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_starting_service))
}
_requestVpnPermission.value = true
}
else {
Toast.makeText(
context,
context.getString(R.string.toast_no_strategy_selected),
Toast.LENGTH_SHORT
).show()
}
}
else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_already_started))
}
}
}
}
fun clearVpnPermissionRequest() {
_requestVpnPermission.value = false
}
fun onBtnStopService(snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
if (prefs.getBoolean("use_module", false)) {
getStatus { isEnabled ->
@@ -104,8 +193,20 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
if (isEnabled) stopService {}
}
} else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_module_disabled))
if (ByeDpiVpnService.status == ServiceStatus.Connected) {
scope.launch {
snackbarHostState.showSnackbar(
context.getString(R.string.snack_stopping_service)
)
}
context.startService(Intent(context, ByeDpiVpnService::class.java).apply {
action = "STOP_VPN"
})
}
else {
scope.launch {
snackbarHostState.showSnackbar(context.getString(R.string.snack_no_service))
}
}
}
}
@@ -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() {
showUpdateDialog.value = true
}
@@ -133,9 +246,25 @@ class HomeViewModel(application: Application) : AndroidViewModel(application) {
fun onUpdateConfirm() {
showUpdateDialog.value = false
val id = download(context, downloadUrl.value.orEmpty())
registerDownloadListener(context, id) { uri ->
installApk(context, uri)
if (context.packageManager.canRequestPackageInstalls()){
val id = download(context, downloadUrl.value.orEmpty())
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,41 +1,47 @@
package com.cherret.zaprett.ui.viewmodel
import android.app.Application
import android.content.Context
import androidx.compose.material3.SnackbarHostState
import com.cherret.zaprett.disableList
import com.cherret.zaprett.enableList
import com.cherret.zaprett.getActiveLists
import com.cherret.zaprett.getAllLists
import com.cherret.zaprett.getStatus
import com.cherret.zaprett.utils.disableList
import com.cherret.zaprett.utils.enableList
import com.cherret.zaprett.utils.getActiveLists
import com.cherret.zaprett.utils.getAllLists
import com.cherret.zaprett.utils.getStatus
import kotlinx.coroutines.CoroutineScope
import java.io.File
class HostsViewModel(application: Application): BaseListsViewModel(application) {
private val sharedPreferences = application.getSharedPreferences("settings", Context.MODE_PRIVATE)
override fun loadAllItems(): Array<String> = getAllLists()
override fun loadActiveItems(): Array<String> = getActiveLists()
override fun loadActiveItems(): Array<String> = getActiveLists(sharedPreferences)
override fun deleteItem(item: String, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
val wasChecked = checked[item] == true
disableList(item)
disableList(item, sharedPreferences)
val success = File(item).delete()
if (success) refresh()
getStatus { isEnabled ->
if (isEnabled && wasChecked) {
showRestartSnackbar(context, snackbarHostState, scope)
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled && wasChecked) {
showRestartSnackbar(context, snackbarHostState, scope)
}
}
}
}
override fun onCheckedChange(item: String, isChecked: Boolean, snackbarHostState: SnackbarHostState, scope: CoroutineScope) {
checked[item] = isChecked
if (isChecked) enableList(item) else disableList(item)
getStatus { isEnabled ->
if (isEnabled) {
showRestartSnackbar(
context,
snackbarHostState,
scope
)
if (isChecked) enableList(item, sharedPreferences) else disableList(item, sharedPreferences)
if (sharedPreferences.getBoolean("use_module", false)) {
getStatus { isEnabled ->
if (isEnabled) {
showRestartSnackbar(
context,
snackbarHostState,
scope
)
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package com.cherret.zaprett
package com.cherret.zaprett.utils
import android.annotation.SuppressLint
import android.app.DownloadManager
@@ -6,6 +6,7 @@ import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.content.SharedPreferences
import android.net.Uri
import android.os.Build
import androidx.core.content.ContextCompat
@@ -24,8 +25,8 @@ import java.security.MessageDigest
private val client = OkHttpClient()
private val json = Json { ignoreUnknownKeys = true }
fun getHostList(callback: (List<RepoItemInfo>?) -> Unit) {
val request = Request.Builder().url("https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json").build()
fun getHostList(sharedPreferences: SharedPreferences, callback: (List<RepoItemInfo>?) -> Unit) {
val request = Request.Builder().url(sharedPreferences.getString("hosts_repo_url","https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json")?: "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/hosts.json").build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
@@ -34,7 +35,6 @@ fun getHostList(callback: (List<RepoItemInfo>?) -> Unit) {
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) {
throw IOException()
callback(null)
}
val jsonString = response.body.string()
@@ -45,8 +45,8 @@ fun getHostList(callback: (List<RepoItemInfo>?) -> Unit) {
})
}
fun getStrategiesList(callback: (List<RepoItemInfo>?) -> Unit) {
val request = Request.Builder().url("https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json").build()
fun getStrategiesList(sharedPreferences: SharedPreferences, callback: (List<RepoItemInfo>?) -> Unit) {
val request = Request.Builder().url(sharedPreferences.getString("strategies_repo_url", "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json")?: "https://raw.githubusercontent.com/CherretGit/zaprett-hosts-repo/refs/heads/main/strategies.json").build()
client.newCall(request).enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
e.printStackTrace()
@@ -55,12 +55,11 @@ fun getStrategiesList(callback: (List<RepoItemInfo>?) -> Unit) {
override fun onResponse(call: Call, response: Response) {
response.use {
if (!response.isSuccessful) {
throw IOException()
callback(null)
}
val jsonString = response.body.string()
val hostsInfo = json.decodeFromString<List<RepoItemInfo>>(jsonString)
callback(hostsInfo)
val strategiesInfo = json.decodeFromString<List<RepoItemInfo>>(jsonString)
callback(strategiesInfo)
}
}
})
@@ -114,6 +113,7 @@ data class RepoItemInfo(
val name: String,
val author: String,
val description: String,
val type: String? = null,
val hash: 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.app.DownloadManager
@@ -16,6 +16,7 @@ import android.util.Log
import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import com.cherret.zaprett.BuildConfig
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.Call
@@ -90,7 +91,7 @@ fun download(context: Context, url: String): Long {
}
val request = DownloadManager.Request(url.toUri()).apply {
setTitle(fileName)
setDescription("Загрузка $fileName")
setDescription(fileName)
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Use MediaStore for Android 10+
@@ -128,11 +129,6 @@ fun installApk(context: Context, uri: Uri) {
}
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

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"?>
<resources>
<style name="Theme.Zaprett" parent="android:Theme.Material.Light.NoActionBar" />
</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",
"versionCode": 11,
"downloadUrl": "https://github.com/CherretGit/zaprett-app/releases/download/1_10_0/app-release.apk",
"version": "2.2",
"versionCode": 14,
"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"
}