56 Commits

Author SHA1 Message Date
zarazaex69 bea7c8a0d3 mv sub 2 down 2026-05-06 01:20:35 +03:00
zarazaex69 45779f0bce add material you color 2026-05-06 01:13:22 +03:00
zarazaex69 642bc7a437 fix server select 2026-05-06 01:03:50 +03:00
zarazaex69 74f6762cf6 fix icon bug 2026-05-06 00:46:34 +03:00
zarazaex69 8aa17ae4bd fix: крестик вместо кнопки Отмена в диалогах + правильная блокировка кнопок
- Все диалоги (обновление, страны) теперь имеют крестик (×) вверху справа вместо кнопки Отмена/Позже
- Добавлен layout dialog_title_with_close.xml с кастомным заголовком и кнопкой закрытия
- Исправлена блокировка кнопок: setButtonsEnabled теперь блокирует и btnSummaryLite тоже
- applyRunningState упрощён — при isRunning разблокирует через setButtonsEnabled
- Во время теста (isTesting): fab/меню блокируются, молния остаётся активной как стоп
- По завершении теста: всё разблокируется корректно
2026-05-06 00:20:21 +03:00
zarazaex69 9f5f3580e0 fix: множество исправлений UI и логики
- Адаптивная иконка: пересоздана с safe-zone отступами (62%) для всех плотностей — лого не обрезается кругом на Android 16
- Строгий таймаут 6 сек: measureOutboundDelayInternal теперь использует context с таймаутом — больше нет бесконечных зависаний пинга
- Индикатор теста: 'Проверено успешно: X / Y' вместо 'Number of running test tasks: left / total'
- Текст начала теста: 'Тестирование N серверов' вместо 'Testing N configs'
- Цвет прогресс-бара: адаптирован к теме (md_theme_primary) вместо оранжевого
- Все диалоги переведены на MaterialAlertDialogBuilder — скруглённые углы, Material3 стиль
- Кнопка FAB '>': блокирует ВСЕ кнопки сразу при нажатии (не только себя)
- Кнопка молнии: блокирует всё сразу при нажатии, а не после обновления списков
- 'Подключаемся к быстрейшему серверу': блокирует UI до завершения подключения
- sub_update кнопка: блокирует интерфейс на время обновления, разблокирует по завершении
- isRunning: applyRunningState теперь явно разблокирует/блокирует кнопки при любом переходе состояния
- Обновление подписок через VPN: при каждом подключении автоматически обновляем подписки через VPN-прокси (через 2 сек после старта)
2026-05-06 00:02:38 +03:00
zarazaex69 5123d996f4 style: update FAB colors and add ripple effect 2026-05-05 16:49:55 +03:00
zarazaex69 cb1ea3c3a3 feat(ui): enable edge-to-edge layout and fix status bar color 2026-05-04 19:03:12 +03:00
zarazaex69 9249ff9bce feat: sync status bar color with toolbar background 2026-05-04 15:05:29 +03:00
zarazaex69 b8cbcac477 refactor(ui): update icons and remove import/export proxy app menu items 2026-05-04 14:45:42 +03:00
zarazaex69 a4c4d764a0 fix: экзорцизм сабмодуля material-design-icons 2026-05-04 14:11:06 +03:00
zarazaex69 b6122866d5 fix: полное удаление сломанного сабмодуля material-design-icons 2026-05-04 14:08:21 +03:00
zarazaex69 b87a0e8a6e REMOVEE 2026-05-04 14:03:08 +03:00
zarazaex69 3ccd7493a2 feat: кнопки блокируются во время теста, молния останавливает скан
- Кнопки FAB и меню серые и недоступны пока идёт пинг-тест
- Нажатие на молнию во время теста — останавливает его (не перезапускает)
- Иконка молнии меняется на стоп пока идёт тест
- isTesting LiveData управляет состоянием UI
2026-05-04 08:26:12 +03:00
zarazaex69 60ce213f67 Replace delay test URL with api.ipify.org 2026-04-26 23:39:29 +03:00
zarazaex69 d0161826e2 Reduce MeasureDelay timeout from 12s to 6s and remove unnecessary delays 2026-04-26 23:14:39 +03:00
zarazaex69 641fcb943d feat: Parallelize config preparation for speed tests 2026-04-26 22:54:17 +03:00
zarazaex69 1fbb4f2bd3 feat: add copy server button to clipboard 2026-04-22 18:48:34 +03:00
zarazaex69 d3a5a1af9c fix: preserve favorite status during subscription update 2026-04-21 23:07:51 +03:00
zarazaex69 33971a576a fix: #7 2026-04-21 23:00:36 +03:00
zarazaex69 a04c53a045 fix: real ping IPC stalls by batching and throttling UI updates 2026-04-21 04:27:37 +03:00
zarazaex69 278095015b feat: remove apk 2026-04-21 03:06:16 +03:00
zarazaex69 3051309138 build: replace remote libv2ray download with local gomobile build process 2026-04-13 17:56:14 +03:00
zarazaex69 48373cfa0d feat: initialize AndroidLibXrayLite module with Xray-core integration and lifecycle management 2026-04-13 17:52:19 +03:00
zarazaex69 fb00560c72 feat: optimize batch ping performance using Go-level concurrency and update default delay test URLs 2026-04-13 17:40:54 +03:00
zarazaex69 f81fb78702 feat: add search functionality to filter configurations in MainActivity 2026-04-13 16:19:39 +03:00
zarazaex69 340112272e feat: implement partial update animations for favorite status in MainRecyclerAdapter 2026-04-13 15:41:23 +03:00
zarazaex69 e220c380e2 feat: implement server favoriting system with UI toggle and priority sorting 2026-04-13 15:26:41 +03:00
zarazaex69 611ac81228 feat: add SubscriptionManager for handling subscription data and MMKV storage operations 2026-04-13 14:29:49 +03:00
zarazaex69 db511c9ab1 feat(ui): rename subscription import function and add startup import logic 2026-04-13 11:44:45 +03:00
zarazaex69 2623b3110b feat(service,receiver): add broadcast-based service control 2026-04-13 01:59:26 +03:00
zarazaex69 4bf125b4f5 ci(build): set prerelease flag to false for release builds 2026-04-12 16:12:24 +03:00
zarazaex69 50297d88cc feat(ui): add MessageUtil import to MainActivity 2026-04-12 15:26:49 +03:00
zarazaex69 2975b0cd1a refactor(service,dto,ui): improve ping logic and server reachability handling 2026-04-12 15:20:26 +03:00
zarazaex69 ea1f584422 fix(service,ui): add state synchronization and improve service state tracking 2026-04-12 15:15:52 +03:00
zarazaex69 73c81d02e7 feat(service): add wake lock support and implement core restart on shutdown 2026-04-11 20:36:26 +03:00
zarazaex69 8d803b8ff9 fix(service): simplify stop logic and improve null safety in service manager 2026-04-11 20:21:18 +03:00
zarazaex69 81d979546c fix(update): add exception handling and improve version comparison logic 2026-04-11 20:10:45 +03:00
zarazaex69 9362d1ef01 fix(ui): add exception handling and improve delay timing in async operations 2026-04-11 20:08:14 +03:00
zarazaex69 ac9760aedd refactor(settings): remove auto-remove invalid servers feature 2026-04-11 20:04:59 +03:00
zarazaex69 1178b87fb7 chore(build): update APK binary 2026-04-11 19:02:00 +03:00
zarazaex69 4fea86944f ci(build): remove unused licenseFdroidReleaseReport task 2026-04-11 18:50:35 +03:00
zarazaex69 ff2fea8c03 fix(service): add retry logic and timeout handling for ping measurements 2026-04-11 18:37:41 +03:00
zarazaex69 415f2230e6 chore(build): update APK binary 2026-04-11 13:53:39 +03:00
zarazaex69 fb827c1fb1 chore(build): migrate to AGP 8.x and remove multidex support 2026-04-11 13:34:05 +03:00
zarazaex69 cb4e3aab54 fix(viewmodel): reload server list when cache is empty before update 2026-04-11 13:05:50 +03:00
zarazaex69 732d7248af fix(service): handle missing service reference and notify UI on shutdown 2026-04-11 13:03:00 +03:00
zarazaex69 0c32ddc642 fix(config): add subscription-level synchronization locks 2026-04-11 12:35:33 +03:00
zarazaex69 153c545400 feat(subscription): add per-subscription update status tracking 2026-04-11 12:29:14 +03:00
zarazaex69 9af6472f8b feat(subscription): add update state management and UI feedback 2026-04-11 12:21:13 +03:00
zarazaex69 346be42e37 fix(config): preserve ping data when updating subscriptions 2026-04-11 12:12:20 +03:00
zarazaex69 dd9e076ab1 chore(build): update APK binary 2026-04-11 02:22:10 +03:00
zarazaex69 903b0d4414 style(colors): replace hardcoded colors with system accent references 2026-04-11 01:38:34 +03:00
zarazaex69 f58a4f8f6f style: fix indentation and mark non-translatable strings 2026-04-11 00:37:14 +03:00
zarazaex69 fdce3ea2c1 fix(service): prevent concurrent operations with synchronization locks 2026-04-11 00:31:28 +03:00
zarazaex69 f0d620676d chore(gradle): downgrade Kotlin version to 2.1.0 2026-04-10 18:41:54 +03:00
96 changed files with 3588 additions and 839 deletions
+24 -16
View File
@@ -66,21 +66,30 @@ jobs:
run: |
cp -r ${{ github.workspace }}/libs ${{ github.workspace }}/V2rayNG/app
- name: Fetch AndroidLibXrayLite tag
run: |
pushd AndroidLibXrayLite
CURRENT_TAG=$(git describe --tags --abbrev=0)
echo "Current tag in this repo: $CURRENT_TAG"
echo "CURRENT_TAG=$CURRENT_TAG" >> $GITHUB_ENV
popd
- name: Download libv2ray
uses: robinraju/release-downloader@v1.12
- name: Setup Go
uses: actions/setup-go@v5
with:
repository: '2dust/AndroidLibXrayLite'
tag: ${{ env.CURRENT_TAG }}
fileName: 'libv2ray.aar'
out-file-path: V2rayNG/app/libs/
go-version: '1.22'
cache-dependency-path: AndroidLibXrayLite/go.sum
- name: Build libv2ray
run: |
go install golang.org/x/mobile/cmd/gomobile@latest
go install golang.org/x/mobile/cmd/gobind@latest
export PATH=$PATH:$(go env GOPATH)/bin
gomobile init
pushd AndroidLibXrayLite
mkdir -p assets data
# Download geo assets if needed
bash gen_assets.sh download
cp -v data/*.dat assets/
go mod tidy
gomobile bind -v -androidapi 24 -trimpath -ldflags='-s -w -buildid=' -o libv2ray.aar ./
popd
mkdir -p V2rayNG/app/libs/
cp AndroidLibXrayLite/libv2ray.aar V2rayNG/app/libs/
- name: Setup Java
uses: actions/setup-java@v5
@@ -138,7 +147,6 @@ jobs:
exit 1
fi
chmod 755 gradlew
./gradlew licenseFdroidReleaseReport
./gradlew assembleRelease --info 2>&1 | grep -i "signing\|keystore" || true
- name: Upload arm64-v8a APK
@@ -168,5 +176,5 @@ jobs:
with:
files: ${{ github.workspace }}/V2rayNG/app/build/outputs/apk/*/release/*.apk
tag_name: ${{ github.event.inputs.release_tag || github.ref_name }}
prerelease: true
prerelease: false
generate_release_notes: true
+1
View File
@@ -65,3 +65,4 @@ Thumbs.db
.DS_Store
add_subscription_mmkv.py
.gitignore
material-design-icons
-3
View File
@@ -1,6 +1,3 @@
[submodule "AndroidLibXrayLite"]
path = AndroidLibXrayLite
url = https://github.com/2dust/AndroidLibXrayLite
[submodule "hev-socks5-tunnel"]
path = hev-socks5-tunnel
url = https://github.com/heiher/hev-socks5-tunnel
+70
View File
@@ -0,0 +1,70 @@
name: Build
on:
workflow_dispatch:
inputs:
release_tag:
required: false
type: string
pull_request:
branches:
- main
push:
branches:
- main
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v6.0.0
- name: Setup Golang
uses: actions/setup-go@v6.1.0
with:
go-version-file: 'go.mod'
- name: Install gomobile
run: |
go install golang.org/x/mobile/cmd/gomobile@latest
export PATH=$PATH:~/go/bin
- name: Setup Android SDK
uses: android-actions/setup-android@v3.2.0
with:
log-accepted-android-sdk-licenses: false
cmdline-tools-version: '12266719'
packages: 'platforms;android-35 build-tools;35.0.0 platform-tools'
- name: Install NDK
run: |
echo "y" | $ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager \
--channel=0 \
--install "ndk;28.2.13676358"
echo "ANDROID_NDK_HOME=$ANDROID_HOME/ndk/28.2.13676358" >> $GITHUB_ENV
- name: Build
run: |
mkdir -p assets data
bash gen_assets.sh download
cp -v data/*.dat assets/
gomobile init
go mod tidy
gomobile bind -v -androidapi 24 -trimpath -ldflags='-s -w -buildid=' ./
- name: Upload build artifacts
if: github.event.inputs.release_tag == ''
uses: actions/upload-artifact@v6.0.0
with:
name: libv2ray
path: |
${{ github.workspace }}/libv2ray*r
- name: Upload AndroidLibXrayLite to release
if: github.event.inputs.release_tag != ''
uses: svenstaro/upload-release-action@v2
with:
file: ./libv2ray*r
tag: ${{ github.event.inputs.release_tag }}
file_glob: true
+98
View File
@@ -0,0 +1,98 @@
name: Check and Update xray-core
on:
schedule:
- cron: '0 0 * * *'
workflow_dispatch:
jobs:
update:
runs-on: ubuntu-latest
steps:
- name: Checkout our repository
uses: actions/checkout@v5
with:
fetch-depth: '0'
- name: Fetch latest release tag from external repository
id: fetch-release
run: |
EXTERNAL_REPO="XTLS/Xray-core"
LATEST_TAG=$(curl -s https://api.github.com/repos/$EXTERNAL_REPO/tags | jq -r '.[0]')
LATEST_TAG_NAME=$(echo $LATEST_TAG | jq -r .name)
LATEST_TAG_SHA=$(echo $LATEST_TAG | jq -r .commit.sha)
echo "Latest tag from external repo: $LATEST_TAG_NAME"
echo "LATEST_TAG_NAME=$LATEST_TAG_NAME" >> $GITHUB_ENV
echo "LATEST_TAG_SHA=$LATEST_TAG_SHA" >> $GITHUB_ENV
- name: Fetch current repository release tag
id: fetch-current-tag
run: |
CURRENT_TAG_NAME=$(git describe --tags --abbrev=0)
echo "Current tag in this repo: $CURRENT_TAG_NAME"
echo "CURRENT_TAG_NAME=$CURRENT_TAG_NAME" >> $GITHUB_ENV
- name: Compare tags
id: compare-tags
run: |
if [ "$LATEST_TAG_NAME" != "$CURRENT_TAG_NAME" ]; then
if [ "$(printf '%s\n' "$LATEST_TAG_NAME" "$CURRENT_TAG_NAME" | sort -V | tail -n1)" == "$CURRENT_TAG_NAME" ]; then
echo "Upstream LATEST_TAG_NAME less than the CURRENT_TAG_NAME, no update needed."
else
echo "Tags are different. Updating..."
echo "needs_update=true" >> $GITHUB_ENV
fi
else
echo "Tags are the same. No update needed."
echo "needs_update=false" >> $GITHUB_ENV
fi
- name: Setup Golang
if: env.needs_update == 'true'
uses: actions/setup-go@v5.4.0
with:
go-version: 'stable'
- name: Update and commit changes
if: env.needs_update == 'true'
run: |
# Sync Go version from upstream go.mod
EXTERNAL_REPO="XTLS/Xray-core"
GO_VERSION=$(curl -s https://raw.githubusercontent.com/$EXTERNAL_REPO/${{ env.LATEST_TAG_NAME }}/go.mod | awk '/^go / {print $2; exit}')
if [ -n "$GO_VERSION" ]; then
echo "Syncing Go version to $GO_VERSION"
go mod edit -go=$GO_VERSION
else
echo "Failed to detect Go version from upstream go.mod"
fi
go get github.com/xtls/xray-core@${{ env.LATEST_TAG_SHA }}
go get golang.org/x/mobile@latest
# Clean up and verify module dependencies
go mod tidy -v
# Show changes
git diff
- name: Commit and push changes
id: auto-commit-action
if: env.needs_update == 'true'
uses: stefanzweifel/git-auto-commit-action@v5.1.0
with:
commit_message: Updating xray-core to ${{ env.LATEST_TAG_NAME }} ${{ env.LATEST_TAG_SHA }}
tagging_message: ${{ env.LATEST_TAG_NAME }}
- name: Trigger build
if: env.needs_update == 'true' && steps.auto-commit-action.outputs.changes_detected == 'true'
run: |
curl -X POST \
-H "Accept: application/vnd.github.v3+json" \
-H "Authorization: token ${{ secrets.GITHUB_TOKEN }}" \
https://api.github.com/repos/${{ github.repository }}/actions/workflows/main.yml/dispatches \
-d "{
\"ref\": \"main\",
\"inputs\": {
\"release_tag\": \"${{ env.LATEST_TAG_NAME }}\"
}
}"
+165
View File
@@ -0,0 +1,165 @@
GNU LESSER GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
This version of the GNU Lesser General Public License incorporates
the terms and conditions of version 3 of the GNU General Public
License, supplemented by the additional permissions listed below.
0. Additional Definitions.
As used herein, "this License" refers to version 3 of the GNU Lesser
General Public License, and the "GNU GPL" refers to version 3 of the GNU
General Public License.
"The Library" refers to a covered work governed by this License,
other than an Application or a Combined Work as defined below.
An "Application" is any work that makes use of an interface provided
by the Library, but which is not otherwise based on the Library.
Defining a subclass of a class defined by the Library is deemed a mode
of using an interface provided by the Library.
A "Combined Work" is a work produced by combining or linking an
Application with the Library. The particular version of the Library
with which the Combined Work was made is also called the "Linked
Version".
The "Minimal Corresponding Source" for a Combined Work means the
Corresponding Source for the Combined Work, excluding any source code
for portions of the Combined Work that, considered in isolation, are
based on the Application, and not on the Linked Version.
The "Corresponding Application Code" for a Combined Work means the
object code and/or source code for the Application, including any data
and utility programs needed for reproducing the Combined Work from the
Application, but excluding the System Libraries of the Combined Work.
1. Exception to Section 3 of the GNU GPL.
You may convey a covered work under sections 3 and 4 of this License
without being bound by section 3 of the GNU GPL.
2. Conveying Modified Versions.
If you modify a copy of the Library, and, in your modifications, a
facility refers to a function or data to be supplied by an Application
that uses the facility (other than as an argument passed when the
facility is invoked), then you may convey a copy of the modified
version:
a) under this License, provided that you make a good faith effort to
ensure that, in the event an Application does not supply the
function or data, the facility still operates, and performs
whatever part of its purpose remains meaningful, or
b) under the GNU GPL, with none of the additional permissions of
this License applicable to that copy.
3. Object Code Incorporating Material from Library Header Files.
The object code form of an Application may incorporate material from
a header file that is part of the Library. You may convey such object
code under terms of your choice, provided that, if the incorporated
material is not limited to numerical parameters, data structure
layouts and accessors, or small macros, inline functions and templates
(ten or fewer lines in length), you do both of the following:
a) Give prominent notice with each copy of the object code that the
Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the object code with a copy of the GNU GPL and this license
document.
4. Combined Works.
You may convey a Combined Work under terms of your choice that,
taken together, effectively do not restrict modification of the
portions of the Library contained in the Combined Work and reverse
engineering for debugging such modifications, if you also do each of
the following:
a) Give prominent notice with each copy of the Combined Work that
the Library is used in it and that the Library and its use are
covered by this License.
b) Accompany the Combined Work with a copy of the GNU GPL and this license
document.
c) For a Combined Work that displays copyright notices during
execution, include the copyright notice for the Library among
these notices, as well as a reference directing the user to the
copies of the GNU GPL and this license document.
d) Do one of the following:
0) Convey the Minimal Corresponding Source under the terms of this
License, and the Corresponding Application Code in a form
suitable for, and under terms that permit, the user to
recombine or relink the Application with a modified version of
the Linked Version to produce a modified Combined Work, in the
manner specified by section 6 of the GNU GPL for conveying
Corresponding Source.
1) Use a suitable shared library mechanism for linking with the
Library. A suitable mechanism is one that (a) uses at run time
a copy of the Library already present on the user's computer
system, and (b) will operate properly with a modified version
of the Library that is interface-compatible with the Linked
Version.
e) Provide Installation Information, but only if you would otherwise
be required to provide such information under section 6 of the
GNU GPL, and only to the extent that such information is
necessary to install and execute a modified version of the
Combined Work produced by recombining or relinking the
Application with a modified version of the Linked Version. (If
you use option 4d0, the Installation Information must accompany
the Minimal Corresponding Source and Corresponding Application
Code. If you use option 4d1, you must provide the Installation
Information in the manner specified by section 6 of the GNU GPL
for conveying Corresponding Source.)
5. Combined Libraries.
You may place library facilities that are a work based on the
Library side by side in a single library together with other library
facilities that are not Applications and are not covered by this
License, and convey such a combined library under terms of your
choice, if you do both of the following:
a) Accompany the combined library with a copy of the same work based
on the Library, uncombined with any other library facilities,
conveyed under the terms of this License.
b) Give prominent notice with the combined library that part of it
is a work based on the Library, and explaining where to find the
accompanying uncombined form of the same work.
6. Revised Versions of the GNU Lesser General Public License.
The Free Software Foundation may publish revised and/or new versions
of the GNU Lesser General Public License from time to time. Such new
versions will be similar in spirit to the present version, but may
differ in detail to address new problems or concerns.
Each version is given a distinguishing version number. If the
Library as you received it specifies that a certain numbered version
of the GNU Lesser General Public License "or any later version"
applies to it, you have the option of following the terms and
conditions either of that published version or of any later version
published by the Free Software Foundation. If the Library as you
received it does not specify a version number of the GNU Lesser
General Public License, you may choose any version of the GNU Lesser
General Public License ever published by the Free Software Foundation.
If the Library as you received it specifies that a proxy can decide
whether future versions of the GNU Lesser General Public License shall
apply, that proxy's public statement of acceptance of any version is
permanent authorization for you to choose that version for the
Library.
+13
View File
@@ -0,0 +1,13 @@
# AndroidLibXrayLite
## Build requirements
* JDK
* Android SDK
* Go
* gomobile
## Build instructions
1. `git clone [repo] && cd AndroidLibXrayLite`
2. `gomobile init`
3. `go mod tidy -v`
4. `gomobile bind -v -androidapi 21 -ldflags='-s -w' ./`
+42
View File
@@ -0,0 +1,42 @@
#!/bin/bash
set -o errexit
set -o pipefail
set -o nounset
# Set magic variables for current file & dir
__dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
__file="${__dir}/$(basename "${BASH_SOURCE[0]}")"
__base="$(basename "${__file}" .sh)"
DATADIR="${__dir}/data"
# Check for required dependencies
check_dependencies() {
command -v jq >/dev/null 2>&1 || { echo >&2 "jq is required but it's not installed. Aborting."; exit 1; }
command -v go >/dev/null 2>&1 || { echo >&2 "Go is required but it's not installed. Aborting."; exit 1; }
}
# Download data function
download_dat() {
echo "Downloading geoip.dat..."
curl -sL https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geoip.dat -o "$DATADIR/geoip.dat"
echo "Downloading geosite.dat..."
curl -sL https://github.com/Loyalsoldier/v2ray-rules-dat/releases/latest/download/geosite.dat -o "$DATADIR/geosite.dat"
echo "Downloading geoip-only-cn-private.dat..."
curl -sL https://raw.githubusercontent.com/Loyalsoldier/geoip/release/geoip-only-cn-private.dat -o "$DATADIR/geoip-only-cn-private.dat"
}
# Main execution logic
ACTION="${1:-download}"
check_dependencies
case $ACTION in
"download") download_dat ;;
*) echo "Invalid action: $ACTION" ; exit 1 ;;
esac
+50
View File
@@ -0,0 +1,50 @@
module github.com/2dust/AndroidLibXrayLite
go 1.26
require (
github.com/xtls/xray-core v1.260327.0
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60
)
require (
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 // indirect
github.com/cloudflare/circl v1.6.3 // indirect
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect
github.com/google/btree v1.1.3 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/juju/ratelimit v1.0.2 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/miekg/dns v1.1.72 // indirect
github.com/pelletier/go-toml v1.9.5 // indirect
github.com/pires/go-proxyproto v0.11.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/sagernet/sing v0.7.13 // indirect
github.com/sagernet/sing-shadowsocks v0.2.9 // indirect
github.com/vishvananda/netlink v1.3.1 // indirect
github.com/vishvananda/netns v0.0.5 // indirect
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect
go.uber.org/mock v0.6.0 // indirect
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect
golang.org/x/mod v0.34.0 // indirect
golang.org/x/net v0.52.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
golang.org/x/time v0.14.0 // indirect
golang.org/x/tools v0.43.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect
lukechampine.com/blake3 v1.4.1 // indirect
)
+130
View File
@@ -0,0 +1,130 @@
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22 h1:00ziBGnLWQEcR9LThDwvxOznJJquJ9bYUdmBFnawLMU=
github.com/apernet/quic-go v0.59.1-0.20260217092621-db4786c77a22/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4=
github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U=
github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg=
github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI=
github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI=
github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs=
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
github.com/pires/go-proxyproto v0.11.0 h1:gUQpS85X/VJMdUsYyEgyn59uLJvGqPhJV5YvG68wXH4=
github.com/pires/go-proxyproto v0.11.0/go.mod h1:ZKAAyp3cgy5Y5Mo4n9AlScrkCZwUy0g3Jf+slqQVcuU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs=
github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagernet/sing v0.7.13 h1:XNYgd8e3cxMULs/LLJspdn/deHrnPWyrrglNHeCUAYM=
github.com/sagernet/sing v0.7.13/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak=
github.com/sagernet/sing-shadowsocks v0.2.9 h1:Paep5zCszRKsEn8587O0MnhFWKJwDW1Y4zOYYlIxMkM=
github.com/sagernet/sing-shadowsocks v0.2.9/go.mod h1:TE/Z6401Pi8tgr0nBZcM/xawAI6u3F6TTbz4nH/qw+8=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=
github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4=
github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY=
github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM=
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8=
github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI=
github.com/xtls/xray-core v1.260327.0 h1:g4TzxMwyPrxslZh6uD+FiG3lXKTrnNO+b4ky2OhogHE=
github.com/xtls/xray-core v1.260327.0/go.mod h1:OXMlhBloFry8mw0KwWLWLd3RQyXJzEYsCGlgsX36h60=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M=
go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM=
golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc=
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60 h1:MOzyaj0wu2xneBkzkg9LHNYjDBB4W5vP043A2SYQRPA=
golang.org/x/mobile v0.0.0-20260312152759-81488f6aeb60/go.mod h1:th6VJvzjMbrYF8SduQY5rpD0HG0GleGxjadkqSxFs3k=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A=
golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk=
gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q=
lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg=
lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo=
+405
View File
@@ -0,0 +1,405 @@
package libv2ray
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"time"
coreapplog "github.com/xtls/xray-core/app/log"
corecommlog "github.com/xtls/xray-core/common/log"
corenet "github.com/xtls/xray-core/common/net"
corefilesystem "github.com/xtls/xray-core/common/platform/filesystem"
"github.com/xtls/xray-core/common/serial"
core "github.com/xtls/xray-core/core"
corestats "github.com/xtls/xray-core/features/stats"
coreserial "github.com/xtls/xray-core/infra/conf/serial"
_ "github.com/xtls/xray-core/main/distro/all"
mobasset "golang.org/x/mobile/asset"
)
// Constants for environment variables
const (
coreAsset = "xray.location.asset"
coreCert = "xray.location.cert"
xudpBaseKey = "xray.xudp.basekey"
tunFdKey = "xray.tun.fd"
)
// CoreController represents a controller for managing Xray core instance lifecycle
type CoreController struct {
CallbackHandler CoreCallbackHandler
statsManager corestats.Manager
coreMutex sync.Mutex
coreInstance *core.Instance
IsRunning bool
}
// PingCallback defines interface for receiving individual ping results in batch mode
type PingCallback interface {
OnResult(guid string, delay int64)
}
// CoreCallbackHandler defines interface for receiving callbacks and notifications from the core service
type CoreCallbackHandler interface {
Startup() int
Shutdown() int
OnEmitStatus(int, string) int
}
// consoleLogWriter implements a log writer without datetime stamps
// as Android system already adds timestamps to each log line
type consoleLogWriter struct {
logger *log.Logger // Standard logger
}
// setEnvVariable safely sets an environment variable and logs any errors encountered.
func setEnvVariable(key, value string) {
if err := os.Setenv(key, value); err != nil {
log.Printf("Failed to set environment variable %s: %v. Please check your configuration.", key, err)
}
}
// InitCoreEnv initializes environment variables and file system handlers for the core
// It sets up asset path, certificate path, XUDP base key and customizes the file reader
// to support Android asset system
func InitCoreEnv(envPath string, key string) {
// Set asset/cert paths
if len(envPath) > 0 {
setEnvVariable(coreAsset, envPath)
setEnvVariable(coreCert, envPath)
}
// Set XUDP encryption key
if len(key) > 0 {
setEnvVariable(xudpBaseKey, key)
}
// Custom file reader with path validation
corefilesystem.NewFileReader = func(path string) (io.ReadCloser, error) {
if _, err := os.Stat(path); os.IsNotExist(err) {
_, file := filepath.Split(path)
return mobasset.Open(file)
}
return os.Open(path)
}
}
// NewCoreController initializes and returns a new CoreController instance
// Sets up the console log handler and associates it with the provided callback handler
func NewCoreController(s CoreCallbackHandler) *CoreController {
// Register custom logger
if err := coreapplog.RegisterHandlerCreator(
coreapplog.LogType_Console,
func(lt coreapplog.LogType, options coreapplog.HandlerCreatorOptions) (corecommlog.Handler, error) {
return corecommlog.NewLogger(createStdoutLogWriter()), nil
},
); err != nil {
log.Printf("Failed to register log handler: %v", err)
}
return &CoreController{
CallbackHandler: s,
}
}
// StartLoop initializes and starts the core processing loop
// Thread-safe method that configures and runs the Xray core with the provided configuration
// Returns immediately if the core is already running
func (x *CoreController) StartLoop(configContent string, tunFd int32) (err error) {
// Set TUN fd key, 0 means do not use TUN
setEnvVariable(tunFdKey, strconv.Itoa(int(tunFd)))
x.coreMutex.Lock()
defer x.coreMutex.Unlock()
if x.IsRunning {
log.Println("Core is already running")
return nil
}
return x.doStartLoop(configContent)
}
// StopLoop safely stops the core processing loop and releases resources
// Thread-safe method that shuts down the core instance and triggers necessary callbacks
func (x *CoreController) StopLoop() error {
x.coreMutex.Lock()
defer x.coreMutex.Unlock()
if x.IsRunning {
x.doShutdown()
x.CallbackHandler.OnEmitStatus(0, "Core stopped")
}
return nil
}
// QueryStats retrieves and resets traffic statistics for a specific outbound tag and direction
// Returns the accumulated traffic value and resets the counter to zero
// Returns 0 if the stats manager is not initialized or the counter doesn't exist
func (x *CoreController) QueryStats(tag string, direct string) int64 {
if x.statsManager == nil {
return 0
}
counter := x.statsManager.GetCounter(fmt.Sprintf("outbound>>>%s>>>traffic>>>%s", tag, direct))
if counter == nil {
return 0
}
return counter.Set(0)
}
// MeasureDelay measures network latency to a specified URL through the current core instance
// Uses a 6-second timeout context and returns the round-trip time in milliseconds
// An error is returned if the connection fails or returns an unexpected status
func (x *CoreController) MeasureDelay(url string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
return measureInstDelay(ctx, x.coreInstance, url)
}
// MeasureOutboundDelay measures the outbound delay for a given configuration and URL
func MeasureOutboundDelay(ConfigureFileContent string, url string) (int64, error) {
return measureOutboundDelayInternal(ConfigureFileContent, url)
}
// MeasureOutboundDelayBatch measures the outbound delay for multiple configurations in parallel
func MeasureOutboundDelayBatch(itemsJson string, url string, callback PingCallback) {
type PingItem struct {
Guid string `json:"guid"`
Config string `json:"config"`
}
var items []PingItem
if err := json.Unmarshal([]byte(itemsJson), &items); err != nil {
log.Printf("Failed to unmarshal batch items: %v", err)
return
}
if len(items) == 0 {
return
}
// Semaphore to limit concurrency (max 128 concurrent tests)
sem := make(chan struct{}, 128)
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(it PingItem) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
delay, _ := measureOutboundDelayInternal(it.Config, url)
if callback != nil {
callback.OnResult(it.Guid, delay)
}
}(item)
}
wg.Wait()
}
func measureOutboundDelayInternal(ConfigureFileContent string, url string) (int64, error) {
return measureOutboundDelayWithContext(ConfigureFileContent, url)
}
func measureOutboundDelayWithContext(ConfigureFileContent string, url string) (int64, error) {
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
config, err := coreserial.LoadJSONConfig(strings.NewReader(ConfigureFileContent))
if err != nil {
return -1, fmt.Errorf("config load error: %w", err)
}
// Simplify config for testing
config.Inbound = nil
var essentialApp []*serial.TypedMessage
for _, app := range config.App {
if app.Type == "xray.app.proxyman.OutboundConfig" ||
app.Type == "xray.app.dispatcher.Config" ||
app.Type == "xray.app.log.Config" {
essentialApp = append(essentialApp, app)
}
}
config.App = essentialApp
inst, err := core.New(config)
if err != nil {
return -1, fmt.Errorf("instance creation failed: %w", err)
}
if err := inst.Start(); err != nil {
return -1, fmt.Errorf("startup failed: %w", err)
}
defer inst.Close()
return measureInstDelay(ctx, inst, url)
}
// CheckVersionX returns the library and Xray versions
func CheckVersionX() string {
var version = 35
return fmt.Sprintf("Lib v%d, Xray-core v%s", version, core.Version())
}
// doShutdown shuts down the Xray instance and cleans up resources
func (x *CoreController) doShutdown() {
if x.coreInstance != nil {
if err := x.coreInstance.Close(); err != nil {
log.Printf("core shutdown error: %v", err)
}
x.coreInstance = nil
}
x.IsRunning = false
x.statsManager = nil
}
// doStartLoop sets up and starts the Xray core
func (x *CoreController) doStartLoop(configContent string) error {
log.Println("initializing core...")
config, err := coreserial.LoadJSONConfig(strings.NewReader(configContent))
if err != nil {
return fmt.Errorf("config error: %w", err)
}
x.coreInstance, err = core.New(config)
if err != nil {
return fmt.Errorf("core init failed: %w", err)
}
x.statsManager = x.coreInstance.GetFeature(corestats.ManagerType()).(corestats.Manager)
log.Println("starting core...")
x.IsRunning = true
if err := x.coreInstance.Start(); err != nil {
x.IsRunning = false
return fmt.Errorf("startup failed: %w", err)
}
x.CallbackHandler.Startup()
x.CallbackHandler.OnEmitStatus(0, "Started successfully, running")
log.Println("Starting core successfully")
return nil
}
// measureInstDelay measures the delay for an instance to a given URL
func measureInstDelay(ctx context.Context, inst *core.Instance, url string) (int64, error) {
if inst == nil {
return -1, errors.New("core instance is nil")
}
tr := &http.Transport{
TLSHandshakeTimeout: 6 * time.Second,
DisableKeepAlives: false,
DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
dest, err := corenet.ParseDestination(fmt.Sprintf("%s:%s", network, addr))
if err != nil {
return nil, err
}
return core.Dial(ctx, inst, dest)
},
}
client := &http.Client{
Transport: tr,
Timeout: 6 * time.Second,
}
if url == "" {
url = "https://api.ipify.org"
}
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return -1, fmt.Errorf("failed to create HTTP request: %w", err)
}
var minDuration int64 = -1
success := false
var lastErr error
// Use 2 attempts as requested by user
const attempts = 2
for i := 0; i < attempts; i++ {
select {
case <-ctx.Done():
// Return immediately when context is canceled
if !success {
return -1, ctx.Err()
}
return minDuration, nil
default:
// Continue execution
}
start := time.Now()
resp, err := client.Do(req)
if err != nil {
lastErr = err
continue
}
// Read body and close resp immediately
body, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
lastErr = fmt.Errorf("failed to read response body: %w", err)
continue
}
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
lastErr = fmt.Errorf("invalid status: %s", resp.Status)
continue
}
// Strict IP check: the body must contain a valid IP address
ipStr := strings.TrimSpace(string(body))
if net.ParseIP(ipStr) == nil {
lastErr = fmt.Errorf("response body is not a valid IP: %s", ipStr)
continue
}
duration := time.Since(start).Milliseconds()
if !success || duration < minDuration {
minDuration = duration
}
success = true
}
if !success {
return -1, lastErr
}
return minDuration, nil
}
// Log writer implementation
func (w *consoleLogWriter) Write(s string) error {
w.logger.Print(s)
return nil
}
func (w *consoleLogWriter) Close() error {
return nil
}
// createStdoutLogWriter creates a logger that won't print date/time stamps
func createStdoutLogWriter() corecommlog.WriterCreator {
return func() corecommlog.Writer {
return &consoleLogWriter{
logger: log.New(os.Stdout, "", 0),
}
}
}
+23 -52
View File
@@ -1,7 +1,5 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
id("com.jaredsburrows.license")
}
android {
@@ -18,8 +16,6 @@ android {
versionCode = envVersionCode?.toIntOrNull() ?: 717
versionName = envVersionName ?: "2.0.17"
multiDexEnabled = true
val abiFilterList = (properties["ABI_FILTERS"] as? String)?.split(';')
splits {
@@ -90,7 +86,7 @@ android {
sourceSets {
getByName("main") {
jniLibs.srcDirs("libs")
jniLibs.directories.add("libs")
}
}
@@ -106,50 +102,6 @@ android {
}
}
applicationVariants.all {
val variant = this
val isFdroid = variant.productFlavors.any { it.name == "fdroid" }
if (isFdroid) {
val versionCodes =
mapOf(
"armeabi-v7a" to 2, "arm64-v8a" to 1, "x86" to 4, "x86_64" to 3, "universal" to 0
)
variant.outputs
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
.forEach { output ->
val abi = output.getFilter("ABI") ?: "universal"
output.outputFileName = "v2rayNG_${variant.versionName}-fdroid_${abi}.apk"
if (versionCodes.containsKey(abi)) {
output.versionCodeOverride =
(100 * variant.versionCode + versionCodes[abi]!!).plus(5000000)
} else {
return@forEach
}
}
} else {
val versionCodes =
mapOf("armeabi-v7a" to 4, "arm64-v8a" to 4, "x86" to 4, "x86_64" to 4, "universal" to 4)
variant.outputs
.map { it as com.android.build.gradle.internal.api.ApkVariantOutputImpl }
.forEach { output ->
val abi = if (output.getFilter("ABI") != null)
output.getFilter("ABI")
else
"universal"
output.outputFileName = "v2rayNG_${variant.versionName}_${abi}.apk"
if (versionCodes.containsKey(abi)) {
output.versionCodeOverride =
(1000000 * versionCodes[abi]!!).plus(variant.versionCode)
} else {
return@forEach
}
}
}
}
buildFeatures {
viewBinding = true
buildConfig = true
@@ -163,6 +115,28 @@ android {
}
androidComponents {
onVariants { variant ->
val isFdroid = variant.productFlavors.any { it.second == "fdroid" }
variant.outputs.forEach { output ->
val abi = output.filters.find {
it.filterType == com.android.build.api.variant.FilterConfiguration.FilterType.ABI
}?.identifier ?: "universal"
if (isFdroid) {
val versionCodes = mapOf(
"armeabi-v7a" to 2, "arm64-v8a" to 1, "x86" to 4, "x86_64" to 3, "universal" to 0
)
versionCodes[abi]?.let { code ->
output.versionCode.set((100 * (output.versionCode.get() ?: 0) + code) + 5000000)
}
} else {
output.versionCode.set(1000000 * 4 + (output.versionCode.get() ?: 0))
}
}
}
}
dependencies {
// Core Libraries
implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar", "*.jar"))))
@@ -210,9 +184,6 @@ dependencies {
implementation(libs.work.runtime.ktx)
implementation(libs.work.multiprocess)
// Multidex Support
implementation(libs.multidex)
// Testing Libraries
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)
+11
View File
@@ -28,6 +28,7 @@
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="PackageVisibilityPolicy,QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
@@ -210,6 +211,16 @@
</intent-filter>
</receiver>
<receiver
android:name=".receiver.ServiceControlReceiver"
android:exported="false"
android:process=":RunSoLibV2RayDaemon">
<intent-filter>
<action android:name="${applicationId}.action.service.stop" />
<action android:name="${applicationId}.action.service.start" />
</intent-filter>
</receiver>
<service
android:name=".service.QSTileService"
android:exported="true"
@@ -1,15 +1,17 @@
package xyz.zarazaex.olc
import android.app.Application
import android.content.Context
import androidx.multidex.MultiDexApplication
import android.os.Build
import androidx.work.Configuration
import androidx.work.WorkManager
import com.google.android.material.color.DynamicColors
import com.tencent.mmkv.MMKV
import xyz.zarazaex.olc.AppConfig.ANG_PACKAGE
import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.handler.SettingsManager
class AngApplication : MultiDexApplication() {
class AngApplication : Application() {
companion object {
lateinit var application: AngApplication
}
@@ -32,8 +34,6 @@ class AngApplication : MultiDexApplication() {
*/
override fun onCreate() {
super.onCreate()
// Apply Material You dynamic colors (Android 12+)
DynamicColors.applyToActivitiesIfAvailable(this)
val mmkvDir = java.io.File(filesDir, "mmkv")
if (!java.io.File(mmkvDir, "MAIN").exists()) {
@@ -53,6 +53,11 @@ class AngApplication : MultiDexApplication() {
MMKV.initialize(this)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
MmkvManager.decodeSettingsBool(AppConfig.PREF_DYNAMIC_COLORS, false)) {
DynamicColors.applyToActivitiesIfAvailable(this)
}
// Initialize WorkManager with the custom configuration
WorkManager.initialize(this, workManagerConfiguration)
@@ -72,8 +72,10 @@ object AppConfig {
const val PREF_USE_HEV_TUNNEL = "pref_use_hev_tunnel_v2"
const val PREF_HEV_TUNNEL_LOGLEVEL = "pref_hev_tunnel_loglevel"
const val PREF_HEV_TUNNEL_RW_TIMEOUT = "pref_hev_tunnel_rw_timeout_v2"
const val PREF_AUTO_REMOVE_INVALID_AFTER_TEST = "pref_auto_remove_invalid_after_test"
const val PREF_AUTO_SORT_AFTER_TEST = "pref_auto_sort_after_test"
const val PREF_SHOW_COPY_BUTTON = "pref_show_copy_button"
const val PREF_DYNAMIC_COLORS = "pref_dynamic_colors"
const val PREF_SUBSCRIPTIONS_BOTTOM = "pref_subscriptions_bottom"
/** Cache keys. */
const val CACHE_SUBSCRIPTION_ID = "cache_subscription_id"
@@ -85,6 +87,8 @@ object AppConfig {
const val BROADCAST_ACTION_SERVICE = "$ANG_PACKAGE.action.service"
const val BROADCAST_ACTION_ACTIVITY = "$ANG_PACKAGE.action.activity"
const val BROADCAST_ACTION_WIDGET_CLICK = "$ANG_PACKAGE.action.widget.click"
const val BROADCAST_ACTION_SERVICE_STOP = "$ANG_PACKAGE.action.service.stop"
const val BROADCAST_ACTION_SERVICE_START = "$ANG_PACKAGE.action.service.start"
/** Tasker extras. */
const val TASKER_EXTRA_BUNDLE = "com.twofortyfouram.locale.intent.extra.BUNDLE"
@@ -118,8 +122,8 @@ object AppConfig {
const val APP_PRIVACY_POLICY = "$GITHUB_RAW_URL/2dust/v2rayNG/master/CR.md"
const val APP_PROMOTION_URL = "aHR0cHM6Ly85LjIzNDQ1Ni54eXovYWJjLmh0bWw="
const val TG_CHANNEL_URL = "https://t.me/github_2dust"
const val DELAY_TEST_URL = "https://www.gstatic.com/generate_204"
const val DELAY_TEST_URL2 = "https://www.google.com/generate_204"
const val DELAY_TEST_URL = "https://api.ipify.org"
const val DELAY_TEST_URL2 = "https://api64.ipify.org"
// const val IP_API_URL = "https://speed.cloudflare.com/meta"
const val IP_API_URL = "https://api.ip.sb/geoip"
@@ -163,6 +167,7 @@ object AppConfig {
const val MSG_MEASURE_CONFIG_CANCEL = 72
const val MSG_MEASURE_CONFIG_NOTIFY = 73
const val MSG_MEASURE_CONFIG_FINISH = 74
const val MSG_MEASURE_CONFIG_BATCH = 75
/** Notification channel IDs and names. */
const val RAY_NG_CHANNEL_ID = "RAY_NG_M_CH_ID"
@@ -10,4 +10,6 @@ interface MainAdapterListener :BaseAdapterListener {
fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean)
fun onCopyToClipboard(guid: String)
}
@@ -0,0 +1,14 @@
package xyz.zarazaex.olc.dto
import java.io.Serializable
data class PingResultItem(
val guid: String,
val delay: Long
) : Serializable
data class PingProgressUpdate(
val results: ArrayList<PingResultItem>,
val finished: Int,
val total: Int
) : Serializable
@@ -13,6 +13,7 @@ data class ProfileItem(
val configType: EConfigType,
var subscriptionId: String = "",
var addedTime: Long = System.currentTimeMillis(),
var isFavorite: Boolean = false,
var remarks: String = "",
var description: String? = null,
@@ -127,4 +128,39 @@ data class ProfileItem(
&& this.pinnedCA256 == obj.pinnedCA256
)
}
override fun hashCode(): Int {
var result = server?.hashCode() ?: 0
result = 31 * result + (serverPort?.hashCode() ?: 0)
result = 31 * result + (password?.hashCode() ?: 0)
result = 31 * result + (method?.hashCode() ?: 0)
result = 31 * result + (flow?.hashCode() ?: 0)
result = 31 * result + (username?.hashCode() ?: 0)
result = 31 * result + (network?.hashCode() ?: 0)
result = 31 * result + (headerType?.hashCode() ?: 0)
result = 31 * result + (host?.hashCode() ?: 0)
result = 31 * result + (path?.hashCode() ?: 0)
result = 31 * result + (seed?.hashCode() ?: 0)
result = 31 * result + (quicSecurity?.hashCode() ?: 0)
result = 31 * result + (quicKey?.hashCode() ?: 0)
result = 31 * result + (mode?.hashCode() ?: 0)
result = 31 * result + (serviceName?.hashCode() ?: 0)
result = 31 * result + (authority?.hashCode() ?: 0)
result = 31 * result + (xhttpMode?.hashCode() ?: 0)
result = 31 * result + (security?.hashCode() ?: 0)
result = 31 * result + (sni?.hashCode() ?: 0)
result = 31 * result + (alpn?.hashCode() ?: 0)
result = 31 * result + (fingerPrint?.hashCode() ?: 0)
result = 31 * result + (publicKey?.hashCode() ?: 0)
result = 31 * result + (shortId?.hashCode() ?: 0)
result = 31 * result + (secretKey?.hashCode() ?: 0)
result = 31 * result + (localAddress?.hashCode() ?: 0)
result = 31 * result + (reserved?.hashCode() ?: 0)
result = 31 * result + (mtu ?: 0)
result = 31 * result + (obfsPassword?.hashCode() ?: 0)
result = 31 * result + (portHopping?.hashCode() ?: 0)
result = 31 * result + (portHoppingInterval?.hashCode() ?: 0)
result = 31 * result + (pinnedCA256?.hashCode() ?: 0)
return result
}
}
@@ -2,9 +2,12 @@ package xyz.zarazaex.olc.dto
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
fun getTestDelayString(): String {
if (testDelayMillis == 0L) {
return ""
return when {
testDelayMillis == 0L -> ""
testDelayMillis < 0L -> "Error"
else -> "${testDelayMillis}ms"
}
return testDelayMillis.toString() + "ms"
}
fun isReachable(): Boolean = testDelayMillis > 0L
}
@@ -0,0 +1,15 @@
package xyz.zarazaex.olc.dto
enum class SubscriptionUpdateStatus {
IDLE,
LOADING,
SUCCESS,
FAILED,
SKIPPED
}
data class SubscriptionStatus(
val guid: String,
val status: SubscriptionUpdateStatus = SubscriptionUpdateStatus.IDLE,
val configCount: Int = 0
)
@@ -26,9 +26,21 @@ import xyz.zarazaex.olc.util.JsonUtil
import xyz.zarazaex.olc.util.QRCodeDecoder
import xyz.zarazaex.olc.util.Utils
import java.net.URI
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
object AngConfigManager {
private val subscriptionLocks = mutableMapOf<String, Any>()
private fun getSubscriptionLock(subid: String): Any {
return synchronized(subscriptionLocks) {
subscriptionLocks.getOrPut(subid) { Any() }
}
}
/**
* Shares the configuration to the clipboard.
@@ -217,49 +229,55 @@ object AngConfigManager {
* @return The number of configurations parsed.
*/
private fun parseBatchConfig(servers: String?, subid: String, append: Boolean): Int {
try {
if (servers == null) {
return 0
}
// Find the currently selected server that matches the subscription ID
val removedSelected = if (subid.isNotBlank() && !append) {
MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true }
?.let { MmkvManager.decodeServerConfig(it) }
?.takeIf { it.subscriptionId == subid }
} else {
null
}
return synchronized(getSubscriptionLock(subid)) {
try {
if (servers == null) {
return@synchronized 0
}
val removedSelected = if (subid.isNotBlank() && !append) {
MmkvManager.getSelectServer()
.takeIf { it?.isNotBlank() == true }
?.let { MmkvManager.decodeServerConfig(it) }
?.takeIf { it.subscriptionId == subid }
} else {
null
}
val subItem = MmkvManager.decodeSubscription(subid)
val subItem = MmkvManager.decodeSubscription(subid)
// Parse all configs first (no I/O during parsing)
val configs = mutableListOf<ProfileItem>()
servers.lines()
.distinct()
.reversed()
.forEach {
val config = parseConfig(it, subid, subItem)
if (config != null) {
configs.add(config)
val oldServerData = if (!append) {
saveOldServerData(subid)
} else {
emptyMap()
}
val configs = mutableListOf<ProfileItem>()
servers.lines()
.distinct()
.reversed()
.forEach {
val config = parseConfig(it, subid, subItem)
if (config != null) {
configs.add(config)
}
}
if (configs.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val keyToProfile = batchSaveConfigs(configs, subid, append)
restoreOldServerData(keyToProfile, oldServerData)
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
}
// Batch save all parsed configs (only one serverList read/write)
if (configs.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val keyToProfile = batchSaveConfigs(configs, subid)
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
matchKey?.let { MmkvManager.setSelectServer(it) }
return@synchronized configs.size
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
}
return configs.size
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse batch config", e)
return@synchronized 0
}
return 0
}
/**
@@ -270,15 +288,23 @@ object AngConfigManager {
* @param subid The subscription ID.
* @return Map of generated keys to their corresponding ProfileItem.
*/
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String): Map<String, ProfileItem> {
private fun batchSaveConfigs(configs: List<ProfileItem>, subid: String, append: Boolean): Map<String, ProfileItem> {
val keyToProfile = mutableMapOf<String, ProfileItem>()
val serverList = MmkvManager.decodeServerList(subid)
val serverList = if (append) {
MmkvManager.decodeServerList(subid)
} else {
mutableListOf()
}
var needSetSelected = MmkvManager.getSelectServer().isNullOrBlank()
val existingProfiles = serverList.mapNotNull { guid ->
MmkvManager.decodeServerConfig(guid)?.let { guid to it }
}.toMap()
val existingProfiles = if (append) {
serverList.mapNotNull { guid ->
MmkvManager.decodeServerConfig(guid)?.let { guid to it }
}.toMap()
} else {
emptyMap()
}
configs.forEach { config ->
val existingKey = existingProfiles.entries.firstOrNull { (_, existing) ->
@@ -286,6 +312,8 @@ object AngConfigManager {
}?.key
if (existingKey != null) {
config.isFavorite = existingProfiles[existingKey]?.isFavorite ?: false
MmkvManager.encodeProfileDirect(existingKey, JsonUtil.toJson(config))
keyToProfile[existingKey] = config
} else {
val key = Utils.getUuid()
@@ -306,6 +334,43 @@ object AngConfigManager {
return keyToProfile
}
private fun saveOldServerData(subid: String): Map<ProfileItem, Pair<Long, Boolean>> {
val serverData = mutableMapOf<ProfileItem, Pair<Long, Boolean>>()
val serverList = MmkvManager.decodeServerList(subid)
serverList.forEach { guid ->
val profile = MmkvManager.decodeServerConfig(guid)
if (profile != null) {
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
val delay = aff?.testDelayMillis ?: 0L
if (delay > 0 || profile.isFavorite) {
serverData[profile] = Pair(delay, profile.isFavorite)
}
}
}
return serverData
}
private fun restoreOldServerData(keyToProfile: Map<String, ProfileItem>, oldServerData: Map<ProfileItem, Pair<Long, Boolean>>) {
if (oldServerData.isEmpty()) return
keyToProfile.forEach { (key, newProfile) ->
val oldData = oldServerData[newProfile]
if (oldData != null) {
val (oldPing, isFavorite) = oldData
if (oldPing > 0) {
MmkvManager.encodeServerTestDelayMillis(key, oldPing)
}
if (isFavorite) {
newProfile.isFavorite = true
MmkvManager.encodeServerConfig(key, newProfile)
}
}
}
}
/**
* Finds a matched profile key from the given key-profile map using multi-level matching.
* Matching priority (from highest to lowest):
@@ -370,67 +435,68 @@ object AngConfigManager {
* @return The number of configurations parsed.
*/
private fun parseCustomConfigServer(server: String?, subid: String, append: Boolean): Int {
if (server == null) {
return 0
}
if (server.contains("inbounds")
&& server.contains("outbounds")
&& server.contains("routing")
) {
try {
val serverList: Array<Any> =
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
return synchronized(getSubscriptionLock(subid)) {
if (server == null) {
return@synchronized 0
}
if (server.contains("inbounds")
&& server.contains("outbounds")
&& server.contains("routing")
) {
try {
val serverList: Array<Any> =
JsonUtil.fromJson(server, Array<Any>::class.java) ?: arrayOf()
if (serverList.isNotEmpty()) {
if (serverList.isNotEmpty()) {
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
var count = 0
for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
count += 1
}
return@synchronized count
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
}
try {
val config = CustomFmt.parse(server) ?: return@synchronized 0
config.subscriptionId = subid
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
var count = 0
for (srv in serverList.reversed()) {
val config = CustomFmt.parse(JsonUtil.toJson(srv)) ?: continue
config.subscriptionId = subid
config.description = generateDescription(config)
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, JsonUtil.toJsonPretty(srv) ?: "")
count += 1
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return@synchronized 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
}
return@synchronized 0
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return@synchronized R.string.toast_incorrect_protocol
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
return count
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return@synchronized 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom config server JSON array", e)
return@synchronized 0
} else {
return@synchronized 0
}
try {
// For compatibility
val config = CustomFmt.parse(server) ?: return 0
config.subscriptionId = subid
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse custom config server as single config", e)
}
return 0
} else if (server.startsWith("[Interface]") && server.contains("[Peer]")) {
try {
val config = WireguardFmt.parseWireguardConfFile(server) ?: return R.string.toast_incorrect_protocol
config.description = generateDescription(config)
if (!append) {
MmkvManager.removeServerViaSubid(subid)
}
val key = MmkvManager.encodeServerConfig("", config)
MmkvManager.encodeServerRaw(key, server)
return 1
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to parse WireGuard config file", e)
}
return 0
} else {
return 0
}
}
@@ -500,9 +566,13 @@ object AngConfigManager {
fun updateConfigViaSubAll(): SubscriptionUpdateResult {
return try {
val subscriptions = MmkvManager.decodeSubscriptions()
subscriptions.fold(SubscriptionUpdateResult()) { acc, subscription ->
acc + updateConfigViaSub(subscription)
// Parallel fetch — each sub downloads concurrently on IO pool
val results = runBlocking(Dispatchers.IO) {
subscriptions.map { sub ->
async { updateConfigViaSub(sub) }
}.awaitAll()
}
results.fold(SubscriptionUpdateResult()) { acc, r -> acc + r }
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to update config via all subscriptions", e)
SubscriptionUpdateResult()
@@ -542,16 +612,18 @@ object AngConfigManager {
Log.i(AppConfig.TAG, url)
val userAgent = it.subscription.userAgent
val timeout = if (url.startsWith("https://key.zarazaex.xyz/sub")) 3000 else 6000
var configText = try {
val httpPort = SettingsManager.getHttpPort()
HttpUtil.getUrlContentWithUserAgent(url, userAgent, 15000, httpPort)
HttpUtil.getUrlContentWithUserAgent(url, userAgent, timeout, httpPort)
} catch (e: Exception) {
Log.e(AppConfig.ANG_PACKAGE, "Update subscription: proxy not ready or other error", e)
""
}
if (configText.isEmpty()) {
configText = try {
HttpUtil.getUrlContentWithUserAgent(url, userAgent)
HttpUtil.getUrlContentWithUserAgent(url, userAgent, timeout)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Update subscription: Failed to get URL content with user agent", e)
""
@@ -0,0 +1,196 @@
package xyz.zarazaex.olc.handler
import android.util.Log
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.util.HttpUtil
import xyz.zarazaex.olc.util.JsonUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
object CountryDetector {
const val UNKNOWN = "??"
// ── Emoji flag → ISO 2-letter country code ────────────────────────────────
/** Extract first flag emoji found in [text] and return its ISO country code (e.g. "RU"). */
fun extractFlagCode(text: String): String? {
val codePoints = text.codePoints().toArray()
var i = 0
while (i < codePoints.size - 1) {
val cp1 = codePoints[i]
val cp2 = codePoints[i + 1]
if (cp1 in 0x1F1E6..0x1F1FF && cp2 in 0x1F1E6..0x1F1FF) {
val c1 = ('A'.code + (cp1 - 0x1F1E6)).toChar()
val c2 = ('A'.code + (cp2 - 0x1F1E6)).toChar()
return "$c1$c2"
}
i++
}
return null
}
/** Get best country code for a server (emoji first, then cache). */
fun getCountryCode(remarks: String, serverIp: String?): String {
extractFlagCode(remarks)?.let { return it }
if (!serverIp.isNullOrBlank() && !isPrivateIp(serverIp)) {
MmkvManager.getCountryCache(serverIp)?.let { return it }
}
return UNKNOWN
}
// ── Flag emoji rendering ──────────────────────────────────────────────────
/** ISO code → flag emoji string */
fun codeToFlag(code: String): String {
if (code.length != 2 || code == UNKNOWN) return "🌍"
return try {
val first = 0x1F1E6 + (code[0].uppercaseChar().code - 'A'.code)
val second = 0x1F1E6 + (code[1].uppercaseChar().code - 'A'.code)
String(intArrayOf(first, second), 0, 2)
} catch (e: Exception) { "🌍" }
}
/** ISO code → human-readable country name (or code if unknown) */
fun codeToName(code: String): String = COUNTRY_NAMES[code.uppercase()] ?: code
// ── Background lookup ─────────────────────────────────────────────────────
private val semaphore = Semaphore(5)
/**
* Looks up countries for all [ips] not yet cached via ip-api.com/batch.
* Saves results to MmkvManager cache. Called from IO coroutine.
*/
suspend fun lookupAndCacheAll(ips: List<String>) {
val uncached = ips
.filter { !it.isNullOrBlank() && !isPrivateIp(it) }
.distinct()
.filter { MmkvManager.getCountryCache(it) == null }
if (uncached.isEmpty()) return
// ip-api.com/batch: max 100 per request, returns [{query, countryCode}]
uncached.chunked(100).forEach { chunk ->
semaphore.withPermit {
try {
lookupBatch(chunk)
} catch (e: Exception) {
Log.w(AppConfig.TAG, "Country batch lookup failed: ${e.message}")
}
}
}
}
private data class IpApiRequest(val query: String, val fields: String = "countryCode")
private suspend fun lookupBatch(ips: List<String>) = withContext(Dispatchers.IO) {
val body = JsonUtil.toJson(ips.map { IpApiRequest(it) })
val response = HttpUtil.postJson("http://ip-api.com/batch", body, 10000) ?: return@withContext
try {
val arr = com.google.gson.JsonParser.parseString(response).asJsonArray
arr.forEach { el ->
val obj = el.asJsonObject
val ip = obj.get("query")?.asString ?: return@forEach
val code = obj.get("countryCode")?.asString?.uppercase()
?.takeIf { it.length == 2 } ?: return@forEach
MmkvManager.setCountryCache(ip, code)
}
} catch (e: Exception) {
Log.w(AppConfig.TAG, "Country batch parse failed: ${e.message}")
}
}
// ── Private IP check ─────────────────────────────────────────────────────
fun isPrivateIp(ip: String): Boolean {
if (ip.contains('.').not()) return false // IPv6 skip for now
return try {
val parts = ip.split('.').map { it.toInt() }
if (parts.size != 4) return false
val a = parts[0]; val b = parts[1]
a == 10 || a == 127 ||
(a == 172 && b in 16..31) ||
(a == 192 && b == 168) ||
(a == 100 && b in 64..127)
} catch (e: Exception) { false }
}
// ── Country names map ─────────────────────────────────────────────────────
val COUNTRY_NAMES: Map<String, String> = mapOf(
"AF" to "Афганистан", "AL" to "Албания", "DZ" to "Алжир",
"AD" to "Андорра", "AO" to "Ангола", "AG" to "Антигуа и Барбуда",
"AR" to "Аргентина", "AM" to "Армения", "AU" to "Австралия",
"AT" to "Австрия", "AZ" to "Азербайджан", "BS" to "Багамы",
"BH" to "Бахрейн", "BD" to "Бангладеш", "BB" to "Барбадос",
"BY" to "Беларусь", "BE" to "Бельгия", "BZ" to "Белиз",
"BJ" to "Бенин", "BT" to "Бутан", "BO" to "Боливия",
"BA" to "Босния и Герцеговина", "BW" to "Ботсвана",
"BR" to "Бразилия", "BN" to "Бруней", "BG" to "Болгария",
"BF" to "Буркина-Фасо", "BI" to "Бурунди", "CV" to "Кабо-Верде",
"KH" to "Камбоджа", "CM" to "Камерун", "CA" to "Канада",
"CF" to "ЦАР", "TD" to "Чад", "CL" to "Чили",
"CN" to "Китай", "CO" to "Колумбия", "KM" to "Коморы",
"CG" to "Конго", "CD" to "ДР Конго", "CR" to "Коста-Рика",
"HR" to "Хорватия", "CU" to "Куба", "CY" to "Кипр",
"CZ" to "Чехия", "DK" to "Дания", "DJ" to "Джибути",
"DM" to "Доминика", "DO" to "Доминикана", "EC" to "Эквадор",
"EG" to "Египет", "SV" to "Сальвадор", "GQ" to "Экв. Гвинея",
"ER" to "Эритрея", "EE" to "Эстония", "SZ" to "Эсватини",
"ET" to "Эфиопия", "FJ" to "Фиджи", "FI" to "Финляндия",
"FR" to "Франция", "GA" to "Габон", "GM" to "Гамбия",
"GE" to "Грузия", "DE" to "Германия", "GH" to "Гана",
"GR" to "Греция", "GD" to "Гренада", "GT" to "Гватемала",
"GN" to "Гвинея", "GW" to "Гвинея-Бисау", "GY" to "Гайана",
"HT" to "Гаити", "HN" to "Гондурас", "HU" to "Венгрия",
"IS" to "Исландия", "IN" to "Индия", "ID" to "Индонезия",
"IR" to "Иран", "IQ" to "Ирак", "IE" to "Ирландия",
"IL" to "Израиль", "IT" to "Италия", "JM" to "Ямайка",
"JP" to "Япония", "JO" to "Иордания", "KZ" to "Казахстан",
"KE" to "Кения", "KI" to "Кирибати", "KP" to "Сев. Корея",
"KR" to "Юж. Корея", "KW" to "Кувейт", "KG" to "Киргизия",
"LA" to "Лаос", "LV" to "Латвия", "LB" to "Ливан",
"LS" to "Лесото", "LR" to "Либерия", "LY" to "Ливия",
"LI" to "Лихтенштейн", "LT" to "Литва", "LU" to "Люксембург",
"MG" to "Мадагаскар", "MW" to "Малави", "MY" to "Малайзия",
"MV" to "Мальдивы", "ML" to "Мали", "MT" to "Мальта",
"MH" to "Маршалловы о-ва", "MR" to "Мавритания",
"MU" to "Маврикий", "MX" to "Мексика", "FM" to "Микронезия",
"MD" to "Молдова", "MC" to "Монако", "MN" to "Монголия",
"ME" to "Черногория", "MA" to "Марокко", "MZ" to "Мозамбик",
"MM" to "Мьянма", "NA" to "Намибия", "NR" to "Науру",
"NP" to "Непал", "NL" to "Нидерланды", "NZ" to "Нов. Зеландия",
"NI" to "Никарагуа", "NE" to "Нигер", "NG" to "Нигерия",
"MK" to "Сев. Македония", "NO" to "Норвегия", "OM" to "Оман",
"PK" to "Пакистан", "PW" to "Палау", "PA" to "Панама",
"PG" to "Папуа — Нов. Гвинея", "PY" to "Парагвай",
"PE" to "Перу", "PH" to "Филиппины", "PL" to "Польша",
"PT" to "Португалия", "QA" to "Катар", "RO" to "Румыния",
"RU" to "Россия", "RW" to "Руанда",
"KN" to "Сент-Китс и Невис", "LC" to "Сент-Люсия",
"VC" to "Сент-Винсент", "WS" to "Самоа",
"SM" to "Сан-Марино", "ST" to "Сан-Томе и Принсипи",
"SA" to "Саудовская Аравия", "SN" to "Сенегал",
"RS" to "Сербия", "SC" to "Сейшелы", "SL" to "Сьерра-Леоне",
"SG" to "Сингапур", "SK" to "Словакия", "SI" to "Словения",
"SB" to "Соломоновы о-ва", "SO" to "Сомали",
"ZA" to "ЮАР", "SS" to "Юж. Судан", "ES" to "Испания",
"LK" to "Шри-Ланка", "SD" to "Судан", "SR" to "Суринам",
"SE" to "Швеция", "CH" to "Швейцария", "SY" to "Сирия",
"TW" to "Тайвань", "TJ" to "Таджикистан", "TZ" to "Танзания",
"TH" to "Таиланд", "TL" to "Восточный Тимор", "TG" to "Того",
"TO" to "Тонга", "TT" to "Тринидад и Тобаго",
"TN" to "Тунис", "TR" to "Турция", "TM" to "Туркменистан",
"TV" to "Тувалу", "UG" to "Уганда", "UA" to "Украина",
"AE" to "ОАЭ", "GB" to "Великобритания", "US" to "США",
"UY" to "Уругвай", "UZ" to "Узбекистан", "VU" to "Вануату",
"VE" to "Венесуэла", "VN" to "Вьетнам", "YE" to "Йемен",
"ZM" to "Замбия", "ZW" to "Зимбабве",
"HK" to "Гонконг", "MO" to "Макао", "PS" to "Палестина",
"XK" to "Косово", "EU" to "Европейский союз",
"NL" to "Нидерланды"
)
}
@@ -26,6 +26,7 @@ object MmkvManager {
private const val ID_SUB = "SUB"
private const val ID_ASSET = "ASSET"
private const val ID_SETTING = "SETTING"
private const val ID_COUNTRY_CACHE = "COUNTRY_CACHE"
private const val KEY_SELECTED_SERVER = "SELECTED_SERVER"
private const val KEY_ANG_CONFIGS = "ANG_CONFIGS"
private const val KEY_SUB_SERVER_PREFIX = "SUB_SERVERS_"
@@ -39,6 +40,7 @@ object MmkvManager {
private val subStorage by lazy { MMKV.mmkvWithID(ID_SUB, MMKV.MULTI_PROCESS_MODE) }
private val assetStorage by lazy { MMKV.mmkvWithID(ID_ASSET, MMKV.MULTI_PROCESS_MODE) }
private val settingsStorage by lazy { MMKV.mmkvWithID(ID_SETTING, MMKV.MULTI_PROCESS_MODE) }
private val countryCacheStorage by lazy { MMKV.mmkvWithID(ID_COUNTRY_CACHE, MMKV.MULTI_PROCESS_MODE) }
//endregion
@@ -292,34 +294,6 @@ object MmkvManager {
return count
}
/**
* Removes invalid server configurations.
*
* @param guid The server GUID.
* @return The number of server configurations removed.
*/
fun removeInvalidServer(guid: String): Int {
var count = 0
if (guid.isNotEmpty()) {
decodeServerAffiliationInfo(guid)?.let { aff ->
if (aff.testDelayMillis < 0L) {
removeServer(guid)
count++
}
}
} else {
serverAffStorage.allKeys()?.forEach { key ->
decodeServerAffiliationInfo(key)?.let { aff ->
if (aff.testDelayMillis < 0L) {
removeServer(key)
count++
}
}
}
}
return count
}
/**
* Encodes the raw server configuration.
*
@@ -717,4 +691,23 @@ object MmkvManager {
}
//endregion
//region Country Cache
/** Returns cached ISO country code for [ip], or null if not cached. */
fun getCountryCache(ip: String): String? = countryCacheStorage.decodeString(ip)
/** Persists ISO country code for [ip]. */
fun setCountryCache(ip: String, code: String) { countryCacheStorage.encode(ip, code) }
/** Loads the user's country filter preference (set of ISO codes to SHOW, empty = show all). */
fun getCountryFilter(): Set<String> =
settingsStorage.decodeStringSet("pref_country_filter") ?: emptySet()
/** Saves the user's country filter preference. */
fun setCountryFilter(codes: Set<String>) {
settingsStorage.encode("pref_country_filter", codes)
}
//endregion
}
@@ -42,6 +42,7 @@ object SettingsManager {
migrateServerListToSubscriptions()
migrateHysteria2PinSHA256()
migrateAutoSort()
migrateDelayTestUrl()
}
/**
@@ -572,5 +573,17 @@ object SettingsManager {
}
}
}
private fun migrateDelayTestUrl() {
val migrationKey = "delay_test_url_migrated_v3"
if (MmkvManager.decodeSettingsBool(migrationKey, false)) {
return
}
val currentUrl = MmkvManager.decodeSettingsString(AppConfig.PREF_DELAY_TEST_URL)
if (currentUrl == null || currentUrl.contains("generate_204") || currentUrl.contains("gstatic.com") || currentUrl.contains("google.com") || currentUrl.contains("api.ip.sb")) {
MmkvManager.encodeSettings(AppConfig.PREF_DELAY_TEST_URL, AppConfig.DELAY_TEST_URL)
}
MmkvManager.encodeSettings(migrationKey, true)
}
}
@@ -94,7 +94,7 @@ object SpeedtestManager {
var result: String
var elapsed = -1L
val testUrl = "https://icanhazip.com"
val testUrl = "https://api.ipify.org"
val conn = HttpUtil.createProxyConnection(testUrl, port, 15000, 15000) ?: return Pair(elapsed, "")
try {
val start = SystemClock.elapsedRealtime()
@@ -17,47 +17,55 @@ import java.io.FileOutputStream
object UpdateCheckerManager {
suspend fun checkForUpdate(includePreRelease: Boolean = false): CheckUpdateResult = withContext(Dispatchers.IO) {
val url = if (includePreRelease) {
AppConfig.APP_API_URL
} else {
AppConfig.APP_API_URL.concatUrl("latest")
}
try {
val url = if (includePreRelease) {
AppConfig.APP_API_URL
} else {
AppConfig.APP_API_URL.concatUrl("latest")
}
var response = HttpUtil.getUrlContent(url, 5000)
if (response.isNullOrEmpty()) {
val httpPort = SettingsManager.getHttpPort()
response = HttpUtil.getUrlContent(url, 5000, httpPort)
?: throw IllegalStateException("Failed to get response")
}
var response = HttpUtil.getUrlContent(url, 5000)
if (response.isNullOrEmpty()) {
val httpPort = SettingsManager.getHttpPort()
response = HttpUtil.getUrlContent(url, 5000, httpPort)
?: throw IllegalStateException("Failed to get response")
}
val latestRelease = if (includePreRelease) {
JsonUtil.fromJson(response, Array<GitHubRelease>::class.java)
?.firstOrNull()
?: throw IllegalStateException("No pre-release found")
} else {
JsonUtil.fromJson(response, GitHubRelease::class.java)
}
if (latestRelease == null) {
return@withContext CheckUpdateResult(hasUpdate = false)
}
val latestRelease = if (includePreRelease) {
JsonUtil.fromJson(response, Array<GitHubRelease>::class.java)
?.firstOrNull()
?: throw IllegalStateException("No pre-release found")
} else {
JsonUtil.fromJson(response, GitHubRelease::class.java)
}
if (latestRelease == null) {
return@withContext CheckUpdateResult(hasUpdate = false)
}
val latestVersion = latestRelease.tagName.removePrefix("v")
Log.i(
AppConfig.TAG,
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
)
return@withContext if (compareVersions(latestVersion, BuildConfig.VERSION_NAME) > 0) {
val downloadUrl = getDownloadUrl(latestRelease, Build.SUPPORTED_ABIS[0])
CheckUpdateResult(
hasUpdate = true,
latestVersion = latestVersion,
releaseNotes = latestRelease.body,
downloadUrl = downloadUrl,
isPreRelease = latestRelease.prerelease
val latestVersion = latestRelease.tagName.removePrefix("v")
Log.i(
AppConfig.TAG,
"Found new version: $latestVersion (current: ${BuildConfig.VERSION_NAME})"
)
} else {
CheckUpdateResult(hasUpdate = false)
return@withContext if (compareVersions(latestVersion, BuildConfig.VERSION_NAME) > 0) {
val downloadUrl = getDownloadUrl(latestRelease, Build.SUPPORTED_ABIS[0])
CheckUpdateResult(
hasUpdate = true,
latestVersion = latestVersion,
releaseNotes = latestRelease.body,
downloadUrl = downloadUrl,
isPreRelease = latestRelease.prerelease
)
} else {
CheckUpdateResult(hasUpdate = false)
}
} catch (e: NumberFormatException) {
Log.e(AppConfig.TAG, "Failed to parse version: ${e.message}")
return@withContext CheckUpdateResult(hasUpdate = false)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to check for update: ${e.message}")
throw e
}
}
@@ -95,12 +103,15 @@ object UpdateCheckerManager {
}
private fun compareVersions(version1: String, version2: String): Int {
val v1 = version1.split(".")
val v2 = version2.split(".")
val cleanVersion1 = version1.split("-")[0]
val cleanVersion2 = version2.split("-")[0]
val v1 = cleanVersion1.split(".")
val v2 = cleanVersion2.split(".")
for (i in 0 until maxOf(v1.size, v2.size)) {
val num1 = if (i < v1.size) v1[i].toInt() else 0
val num2 = if (i < v2.size) v2[i].toInt() else 0
val num1 = if (i < v1.size) v1[i].toIntOrNull() ?: 0 else 0
val num2 = if (i < v2.size) v2[i].toIntOrNull() ?: 0 else 0
if (num1 != num2) return num1 - num2
}
return 0
@@ -74,6 +74,21 @@ object V2RayNativeManager {
}
}
/**
* Measure outbound connection delay for multiple configs in batch.
*
* @param configsJson JSON array of {guid, config}
* @param testUrl The URL to test against
* @param callback Callback for individual results
*/
fun measureOutboundDelayBatch(configsJson: String, testUrl: String, callback: libv2ray.PingCallback) {
try {
Libv2ray.measureOutboundDelayBatch(configsJson, testUrl, callback)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Failed to measure outbound delay batch", e)
}
}
/**
* Create a new core controller instance.
*
@@ -30,6 +30,9 @@ object V2RayServiceManager {
private val coreController: CoreController = V2RayNativeManager.newCoreController(CoreCallback())
private val mMsgReceive = ReceiveMessageHandler()
private var currentConfig: ProfileItem? = null
private val operationLock = Any()
@Volatile private var isOperationInProgress = false
@Volatile var isIntentionalStop = false
var serviceControl: SoftReference<ServiceControl>? = null
set(value) {
@@ -47,6 +50,7 @@ object V2RayServiceManager {
context.toast(R.string.app_tile_first_use)
return false
}
isIntentionalStop = false
startContextService(context)
return true
}
@@ -57,13 +61,28 @@ object V2RayServiceManager {
* @param guid The GUID of the server configuration to use (optional).
*/
fun startVService(context: Context, guid: String? = null) {
Log.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
if (guid != null) {
MmkvManager.setSelectServer(guid)
synchronized(operationLock) {
if (isOperationInProgress) {
Log.w(AppConfig.TAG, "StartCore-Manager: Operation already in progress")
return
}
isOperationInProgress = true
}
startContextService(context)
try {
Log.i(AppConfig.TAG, "StartCore-Manager: startVService from ${context::class.java.simpleName}")
if (guid != null) {
MmkvManager.setSelectServer(guid)
}
isIntentionalStop = false
startContextService(context)
} finally {
synchronized(operationLock) {
isOperationInProgress = false
}
}
}
/**
@@ -71,8 +90,16 @@ object V2RayServiceManager {
* @param context The context from which the service is stopped.
*/
fun stopVService(context: Context) {
//context.toast(R.string.toast_services_stop)
MessageUtil.sendMsg2Service(context, AppConfig.MSG_STATE_STOP, "")
Log.i(AppConfig.TAG, "StartCore-Manager: stopVService called")
isIntentionalStop = true
val svc = serviceControl?.get()
if (svc != null) {
svc.stopService()
return
}
val intent = Intent(AppConfig.BROADCAST_ACTION_SERVICE_STOP)
intent.setPackage(AppConfig.ANG_PACKAGE)
context.sendBroadcast(intent)
}
/**
@@ -228,23 +255,26 @@ object V2RayServiceManager {
fun stopCoreLoop(): Boolean {
val service = getService() ?: return false
try {
service.unregisterReceiver(mMsgReceive)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
}
NotificationManager.cancelNotification()
if (coreController.isRunning) {
CoroutineScope(Dispatchers.IO).launch {
try {
coreController.stopLoop()
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to stop V2Ray loop", e)
} finally {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
}
}
}
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
NotificationManager.cancelNotification()
try {
service.unregisterReceiver(mMsgReceive)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to unregister receiver", e)
} else {
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_STOP_SUCCESS, "")
}
return true
@@ -334,10 +364,24 @@ object V2RayServiceManager {
override fun shutdown(): Long {
val serviceControl = serviceControl?.get() ?: return -1
return try {
serviceControl.stopService()
Log.w(AppConfig.TAG, "StartCore-Manager: Core shutdown callback, attempting restart")
val service = serviceControl.getService()
MessageUtil.sendMsg2UI(service, AppConfig.MSG_STATE_NOT_RUNNING, "")
if (isIntentionalStop) {
Log.i(AppConfig.TAG, "StartCore-Manager: Intentional stop, skipping restart")
return 0
}
CoroutineScope(Dispatchers.IO).launch {
kotlinx.coroutines.delay(1000L)
val ctx = service.applicationContext
if (coreController.isRunning == false) {
Log.i(AppConfig.TAG, "StartCore-Manager: Restarting service after core shutdown")
startVService(ctx)
}
}
0
} catch (e: Exception) {
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to stop service", e)
Log.e(AppConfig.TAG, "StartCore-Manager: Failed to handle core shutdown", e)
-1
}
}
@@ -365,34 +409,46 @@ object V2RayServiceManager {
* @param intent The intent being received.
*/
override fun onReceive(ctx: Context?, intent: Intent?) {
val serviceControl = serviceControl?.get() ?: return
when (intent?.getIntExtra("key", 0)) {
AppConfig.MSG_REGISTER_CLIENT -> {
val svc = serviceControl?.get() ?: return
if (coreController.isRunning) {
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_RUNNING, "")
MessageUtil.sendMsg2UI(svc.getService(), AppConfig.MSG_STATE_RUNNING, "")
} else {
MessageUtil.sendMsg2UI(serviceControl.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
MessageUtil.sendMsg2UI(svc.getService(), AppConfig.MSG_STATE_NOT_RUNNING, "")
}
}
AppConfig.MSG_UNREGISTER_CLIENT -> {
// nothing to do
}
AppConfig.MSG_STATE_START -> {
// nothing to do
}
AppConfig.MSG_STATE_STOP -> {
Log.i(AppConfig.TAG, "StartCore-Manager: Stop service")
serviceControl.stopService()
synchronized(operationLock) {
isOperationInProgress = false
}
val svc = serviceControl?.get()
if (svc != null) {
svc.stopService()
} else if (ctx != null) {
Log.w(AppConfig.TAG, "StartCore-Manager: serviceControl null on stop, stopping core directly")
stopCoreLoop()
ctx.stopService(Intent(ctx, V2RayVpnService::class.java))
ctx.stopService(Intent(ctx, V2RayProxyOnlyService::class.java))
}
}
AppConfig.MSG_STATE_RESTART -> {
Log.i(AppConfig.TAG, "StartCore-Manager: Restart service")
serviceControl.stopService()
synchronized(operationLock) {
isOperationInProgress = false
}
serviceControl?.get()?.stopService()
Thread.sleep(500L)
startVService(serviceControl.getService())
if (ctx != null) startVService(ctx)
}
AppConfig.MSG_MEASURE_DELAY -> {
@@ -1311,7 +1311,7 @@ object V2rayConfigManager {
if (start != null && end != null) {
val minStart = maxOf(5, start)
val minEnd = maxOf(minStart, end)
"$minStart-$minEnd"
"$minStart-$minEnd"
} else {
"30"
}
@@ -0,0 +1,19 @@
package xyz.zarazaex.olc.receiver
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.handler.V2RayServiceManager
class ServiceControlReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
AppConfig.BROADCAST_ACTION_SERVICE_STOP -> {
V2RayServiceManager.isIntentionalStop = true
V2RayServiceManager.stopVService(context)
}
AppConfig.BROADCAST_ACTION_SERVICE_START -> V2RayServiceManager.startVServiceFromToggle(context)
}
}
}
@@ -1,87 +1,144 @@
package xyz.zarazaex.olc.service
import android.content.Context
import java.util.concurrent.atomic.AtomicInteger
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.dto.PingProgressUpdate
import xyz.zarazaex.olc.dto.PingResultItem
import xyz.zarazaex.olc.handler.SettingsManager
import xyz.zarazaex.olc.handler.V2RayNativeManager
import xyz.zarazaex.olc.handler.V2rayConfigManager
import xyz.zarazaex.olc.util.JsonUtil
import xyz.zarazaex.olc.util.MessageUtil
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicInteger
/**
* Worker that runs a batch of real-ping tests independently.
* Each batch owns its own CoroutineScope/dispatcher and can be cancelled separately.
* Worker that runs a batch of real-ping tests independently. Optimized to use Go-level concurrency
* for improved performance.
*/
class RealPingWorkerService(
private val context: Context,
private val guids: List<String>,
private val onFinish: (status: String) -> Unit = {}
private val context: Context,
private val guids: List<String>,
private val onFinish: (status: String) -> Unit = {}
) {
private val job = SupervisorJob()
private val cpu = Runtime.getRuntime().availableProcessors().coerceAtLeast(1)
private val dispatcher = Executors.newFixedThreadPool(cpu * 16).asCoroutineDispatcher()
private val scope = CoroutineScope(job + dispatcher + CoroutineName("RealPingBatchWorker"))
private val scope =
CoroutineScope(job + Dispatchers.Default + CoroutineName("RealPingBatchWorker"))
private val runningCount = AtomicInteger(0)
private val totalCount = AtomicInteger(0)
private val totalCount = AtomicInteger(guids.size)
private val finishedCount = AtomicInteger(0)
private val pendingResults = ArrayList<PingResultItem>()
private val pendingLock = Any()
private val delayTestUrl = SettingsManager.getDelayTestUrl()
companion object {
private const val RESULT_BATCH_SIZE = 32
private const val FLUSH_INTERVAL_MS = 1000L
}
data class PingItem(val guid: String, val config: String)
fun start() {
val jobs = guids.map { guid ->
totalCount.incrementAndGet()
scope.launch {
runningCount.incrementAndGet()
try {
val result = startRealPing(guid)
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, result))
} catch (e: Exception) {
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_SUCCESS, Pair(guid, -1L))
} finally {
val count = totalCount.decrementAndGet()
val left = runningCount.decrementAndGet()
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "$left / $count")
}
scope.launch(Dispatchers.IO) {
while (isActive) {
delay(FLUSH_INTERVAL_MS)
flushPendingResults()
}
}
scope.launch {
scope.launch(Dispatchers.IO) {
try {
joinAll(*jobs.toTypedArray())
// Prepare configurations in parallel for faster startup
val shuffledGuids = guids.shuffled()
val deferredItems = shuffledGuids.map { guid ->
async(Dispatchers.IO) {
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
if (configResult.status) {
PingItem(guid, configResult.content)
} else {
reportResult(guid, -1L)
null
}
}
}
val items = deferredItems.awaitAll().filterNotNull()
if (items.isNotEmpty()) {
val configsJson = JsonUtil.toJson(items)
V2RayNativeManager.measureOutboundDelayBatch(
configsJson,
delayTestUrl,
object : libv2ray.PingCallback {
override fun onResult(guid: String?, delay: Long) {
if (guid != null) {
reportResult(guid, delay)
}
}
}
)
}
flushPendingResults()
onFinish("0")
} catch (_: CancellationException) {
} catch (e: Exception) {
flushPendingResults()
onFinish("-1")
} finally {
close()
cancel()
}
}
}
private fun reportResult(guid: String, delay: Long) {
val finished = finishedCount.incrementAndGet()
var readyBatch: PingProgressUpdate? = null
synchronized(pendingLock) {
pendingResults.add(PingResultItem(guid, delay))
if (pendingResults.size >= RESULT_BATCH_SIZE || finished >= totalCount.get()) {
readyBatch = createProgressUpdateLocked(finished)
pendingResults.clear()
}
}
readyBatch?.let(::sendBatchUpdate)
}
private fun flushPendingResults() {
val finished = finishedCount.get()
val update =
synchronized(pendingLock) {
if (pendingResults.isEmpty()) {
null
} else {
createProgressUpdateLocked(finished).also { pendingResults.clear() }
}
}
update?.let(::sendBatchUpdate)
}
private fun createProgressUpdateLocked(finished: Int): PingProgressUpdate {
return PingProgressUpdate(
results = ArrayList(pendingResults),
finished = finished,
total = totalCount.get()
)
}
private fun sendBatchUpdate(update: PingProgressUpdate) {
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_BATCH, update)
MessageUtil.sendMsg2UI(context, AppConfig.MSG_MEASURE_CONFIG_NOTIFY, "${update.finished} / ${update.total}")
}
fun cancel() {
job.cancel()
}
private fun close() {
try {
dispatcher.close()
} catch (_: Throwable) {
// ignore
}
}
private fun startRealPing(guid: String): Long {
val retFailure = -1L
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
if (!configResult.status) {
return retFailure
}
return V2RayNativeManager.measureOutboundDelay(configResult.content, SettingsManager.getDelayTestUrl())
}
}
@@ -32,7 +32,7 @@ class V2RayProxyOnlyService : Service(), ServiceControl {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(AppConfig.TAG, "StartCore-Proxy: Service command received")
V2RayServiceManager.startCoreLoop(null)
return START_STICKY
return START_NOT_STICKY
}
/**
@@ -13,6 +13,7 @@ import android.net.ProxyInfo
import android.net.VpnService
import android.os.Build
import android.os.ParcelFileDescriptor
import android.os.PowerManager
import android.os.StrictMode
import android.util.Log
import androidx.annotation.RequiresApi
@@ -25,6 +26,7 @@ import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.handler.NotificationManager
import xyz.zarazaex.olc.handler.SettingsManager
import xyz.zarazaex.olc.handler.V2RayServiceManager
import xyz.zarazaex.olc.util.MessageUtil
import xyz.zarazaex.olc.util.MyContextWrapper
import xyz.zarazaex.olc.util.Utils
import java.lang.ref.SoftReference
@@ -34,6 +36,7 @@ class V2RayVpnService : VpnService(), ServiceControl {
private lateinit var mInterface: ParcelFileDescriptor
private var isRunning = false
private var tun2SocksService: Tun2SocksControl? = null
private var wakeLock: PowerManager.WakeLock? = null
/**destroy
* Unfortunately registerDefaultNetworkCallback is going to return our VPN interface: https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
@@ -78,6 +81,9 @@ class V2RayVpnService : VpnService(), ServiceControl {
val policy = StrictMode.ThreadPolicy.Builder().permitAll().build()
StrictMode.setThreadPolicy(policy)
V2RayServiceManager.serviceControl = SoftReference(this)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "v2rayng:vpn")
.also { it.acquire() }
}
override fun onRevoke() {
@@ -93,15 +99,16 @@ class V2RayVpnService : VpnService(), ServiceControl {
override fun onDestroy() {
super.onDestroy()
Log.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
NotificationManager.cancelNotification()
stopAllService(false)
wakeLock?.let { if (it.isHeld) it.release() }
wakeLock = null
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
Log.i(AppConfig.TAG, "StartCore-VPN: Service command received")
setupVpnService()
startService()
return START_STICKY
//return super.onStartCommand(intent, flags, startId)
return START_NOT_STICKY
}
override fun getService(): Service {
@@ -2,6 +2,7 @@ package xyz.zarazaex.olc.ui
import android.content.Context
import android.os.Bundle
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.MenuItem
import android.view.View
@@ -10,6 +11,7 @@ import android.widget.FrameLayout
import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.widget.Toolbar
import androidx.core.content.ContextCompat
import androidx.core.graphics.ColorUtils
import androidx.core.view.WindowCompat
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
@@ -121,6 +123,7 @@ abstract class BaseActivity : AppCompatActivity() {
setSupportActionBar(it)
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
title?.let { t -> this.title = t }
syncStatusBarWithToolbar(it)
}
progressBar = findViewById(R.id.progress_bar)
}
@@ -178,9 +181,18 @@ abstract class BaseActivity : AppCompatActivity() {
setSupportActionBar(it)
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
title?.let { t -> supportActionBar?.title = t }
syncStatusBarWithToolbar(it)
}
}
private fun syncStatusBarWithToolbar(toolbar: Toolbar) {
val tv = TypedValue()
theme.resolveAttribute(com.google.android.material.R.attr.colorSurface, tv, true)
val bgColor = tv.data
WindowCompat.getInsetsController(window, window.decorView)?.isAppearanceLightStatusBars =
ColorUtils.calculateLuminance(bgColor) > 0.5
}
/**
* Show the base layout's ProgressBar.
*
@@ -2,7 +2,8 @@ package xyz.zarazaex.olc.ui
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AlertDialog
import android.widget.TextView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.lifecycle.lifecycleScope
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.BuildConfig
@@ -12,9 +13,9 @@ import xyz.zarazaex.olc.dto.CheckUpdateResult
import xyz.zarazaex.olc.extension.toast
import xyz.zarazaex.olc.extension.toastError
import xyz.zarazaex.olc.extension.toastSuccess
import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.handler.UpdateCheckerManager
import xyz.zarazaex.olc.handler.V2RayNativeManager
import xyz.zarazaex.olc.util.MarkdownUtil
import xyz.zarazaex.olc.util.Utils
import kotlinx.coroutines.launch
@@ -24,33 +25,29 @@ class CheckUpdateActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.update_check_for_update))
binding.layoutCheckUpdate.setOnClickListener {
checkForUpdates(binding.checkPreRelease.isChecked)
checkForUpdates()
}
binding.checkPreRelease.setOnCheckedChangeListener { _, isChecked ->
MmkvManager.encodeSettings(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, isChecked)
}
binding.checkPreRelease.isChecked = true
MmkvManager.encodeSettings(AppConfig.PREF_CHECK_UPDATE_PRE_RELEASE, true)
// Hide the pre-release toggle - we always check releases
binding.checkPreRelease.visibility = android.view.View.GONE
"v${BuildConfig.VERSION_NAME} (${V2RayNativeManager.getLibVersion()})".also {
binding.tvVersion.text = it
}
checkForUpdates(binding.checkPreRelease.isChecked)
checkForUpdates()
}
private fun checkForUpdates(includePreRelease: Boolean) {
private fun checkForUpdates() {
toast(R.string.update_checking_for_update)
showLoading()
lifecycleScope.launch {
try {
val result = UpdateCheckerManager.checkForUpdate(includePreRelease)
val result = UpdateCheckerManager.checkForUpdate(false)
if (result.hasUpdate) {
showUpdateDialog(result)
} else {
@@ -67,15 +64,21 @@ class CheckUpdateActivity : BaseActivity() {
}
private fun showUpdateDialog(result: CheckUpdateResult) {
AlertDialog.Builder(this)
.setTitle(getString(R.string.update_new_version_found, result.latestVersion))
.setMessage(result.releaseNotes)
val message = result.releaseNotes?.let { MarkdownUtil.parseBasic(it) } ?: ""
val titleStr = getString(R.string.update_new_version_found, result.latestVersion)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(titleStr)
.setMessage(message)
.setPositiveButton(R.string.update_now) { _, _ ->
result.downloadUrl?.let {
Utils.openUri(this, it)
}
}
.setNegativeButton(android.R.string.cancel, null)
.show()
.create()
dialog.show()
val titleView = layoutInflater.inflate(R.layout.dialog_title_with_close, null)
titleView.findViewById<TextView>(R.id.dialog_title_text).text = titleStr
titleView.findViewById<android.widget.ImageButton>(R.id.dialog_close_btn).setOnClickListener { dialog.dismiss() }
dialog.setCustomTitle(titleView)
}
}
}
@@ -12,6 +12,7 @@ import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.swiperefreshlayout.widget.SwipeRefreshLayout
import com.google.android.material.snackbar.Snackbar
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.R
import xyz.zarazaex.olc.contracts.MainAdapterListener
@@ -137,9 +138,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
* @param guid The server unique identifier
*/
private fun share2Clipboard(guid: String) {
if (AngConfigManager.share2Clipboard(ownerActivity, guid) == 0) {
ownerActivity.toastSuccess(R.string.toast_success)
} else {
if (AngConfigManager.share2Clipboard(ownerActivity, guid) != 0) {
ownerActivity.toastError(R.string.toast_failure)
}
}
@@ -264,6 +263,10 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
setSelectServer(guid)
}
override fun onCopyToClipboard(guid: String) {
share2Clipboard(guid)
}
override fun onShare(guid: String, profile: ProfileItem, position: Int, more: Boolean) {
val isCustom = profile.configType == EConfigType.CUSTOM || profile.configType == EConfigType.POLICYGROUP
@@ -284,6 +287,13 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
//binding.refreshLayout.isRefreshing = false
}
/**
* Scrolls to the top of the list (called on double-tap on tab)
*/
fun scrollToTop() {
binding.recyclerView.smoothScrollToPosition(0)
}
/**
* Scrolls to the currently selected server in the RecyclerView
*/
@@ -9,14 +9,19 @@ import android.util.Log
import android.view.KeyEvent
import android.view.Menu
import android.view.MenuItem
import android.view.View
import android.widget.TextView
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.appcompat.app.ActionBarDrawerToggle
import androidx.appcompat.app.AlertDialog
import androidx.appcompat.widget.SearchView
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import androidx.core.content.ContextCompat
import androidx.core.view.GravityCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.isVisible
import androidx.lifecycle.lifecycleScope
import com.google.android.material.navigation.NavigationView
@@ -29,11 +34,13 @@ import xyz.zarazaex.olc.enums.PermissionType
import xyz.zarazaex.olc.extension.toast
import xyz.zarazaex.olc.extension.toastError
import xyz.zarazaex.olc.handler.AngConfigManager
import xyz.zarazaex.olc.handler.CountryDetector
import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.handler.SettingsChangeManager
import xyz.zarazaex.olc.handler.SettingsManager
import xyz.zarazaex.olc.handler.UpdateCheckerManager
import xyz.zarazaex.olc.handler.V2RayServiceManager
import xyz.zarazaex.olc.util.MessageUtil
import xyz.zarazaex.olc.util.Utils
import xyz.zarazaex.olc.viewmodel.MainViewModel
import kotlinx.coroutines.Dispatchers
@@ -42,16 +49,17 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelectedListener {
private val binding by lazy {
ActivityMainBinding.inflate(layoutInflater)
}
private val binding by lazy {ActivityMainBinding.inflate(layoutInflater)}
private var isLiteTesting = false
private var easterEggClickCount = 0
private var isEasterEggActive = false
/** Был ли VPN уже запущен в предыдущем колбэке — чтобы детектировать момент подключения */
private var wasRunning = false
val mainViewModel: MainViewModel by viewModels()
private lateinit var groupPagerAdapter: GroupPagerAdapter
private var tabMediator: TabLayoutMediator? = null
@Volatile private var isFabOperationInProgress = false
private val requestVpnPermission = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (it.resultCode == RESULT_OK) {
@@ -71,7 +79,22 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(binding.root)
setupToolbar(binding.toolbar, false, getString(R.string.title_server))
setupToolbar(binding.toolbar, false, getString(R.string.app_name))
// edge-to-edge: контент идёт под статус-бар, AppBarLayout тянется под него же
WindowCompat.setDecorFitsSystemWindows(window, false)
ViewCompat.setOnApplyWindowInsetsListener(binding.appBarLayout) { v, insets ->
v.setPadding(0, insets.getInsets(WindowInsetsCompat.Type.statusBars()).top, 0, 0)
insets
}
// Нижние кнопки поднимаются над навигационной панелью
ViewCompat.setOnApplyWindowInsetsListener(binding.bottomContainer) { v, insets ->
val navBarHeight = insets.getInsets(WindowInsetsCompat.Type.navigationBars()).bottom
v.setPadding(0, 0, 0, navBarHeight)
insets
}
placeTabGroup()
groupPagerAdapter = GroupPagerAdapter(this, emptyList())
binding.viewPager.adapter = groupPagerAdapter
@@ -84,6 +107,12 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
toggle.syncState()
binding.navView.setNavigationItemSelectedListener(this)
ViewCompat.setOnApplyWindowInsetsListener(binding.drawerContentLayout) { v, insets ->
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
v.setPadding(0, systemBars.top, 0, systemBars.bottom)
insets
}
findViewById<android.widget.TextView>(R.id.drawer_settings)?.setOnClickListener {
requestActivityLauncher.launch(Intent(this, SettingsActivity::class.java))
binding.drawerLayout.closeDrawer(androidx.core.view.GravityCompat.START)
@@ -148,7 +177,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
setupGroupTab()
setupViewModel()
mainViewModel.reloadServerList()
importConfigViaSub()
importAllSubsOnStartup()
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
}
@@ -159,24 +188,57 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
private fun setupViewModel() {
mainViewModel.updateTestResultAction.observe(this) { setTestState(it) }
mainViewModel.isTesting.observe(this) { testing ->
if (testing) {
// Во время теста: блокируем всё кроме кнопки молнии (стоп)
binding.fab.isEnabled = false
binding.fab.alpha = 0.5f
val menu = binding.toolbar.menu
menu.findItem(R.id.real_ping_all)?.let { it.isEnabled = false; it.icon?.alpha = 128 }
menu.findItem(R.id.filter_by_country)?.let { it.isEnabled = false; it.icon?.alpha = 128 }
menu.findItem(R.id.sub_update)?.let { it.isEnabled = false; it.icon?.alpha = 128 }
// Молния — стоп-кнопка, всегда активна во время теста
binding.btnSummaryLite.isEnabled = true
binding.btnSummaryLite.alpha = 1.0f
binding.btnSummaryLite.setImageResource(R.drawable.ic_stop_24dp)
binding.btnSummaryLite.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
} else {
setButtonsEnabled(true)
binding.btnSummaryLite.setImageResource(R.drawable.bolt_24)
binding.btnSummaryLite.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
}
}
mainViewModel.liteTestFinished.observe(this) { finished ->
if (finished && isLiteTesting) {
isLiteTesting = false
mainViewModel.sortByTestResults()
mainViewModel.reloadServerList()
val firstServer = mainViewModel.serversCache.firstOrNull()
if (firstServer != null) {
MmkvManager.setSelectServer(firstServer.guid)
val firstReachable = mainViewModel.serversCache.firstOrNull { cache ->
(MmkvManager.decodeServerAffiliationInfo(cache.guid)?.testDelayMillis ?: 0L) > 0L
}
if (firstReachable != null) {
MmkvManager.setSelectServer(firstReachable.guid)
mainViewModel.reloadServerList() // reload AFTER selection so indicator renders correctly
showStatus("Подключаемся к быстрейшему серверу")
// Блокируем кнопки на время подключения
setButtonsEnabled(false)
applyRunningState(isLoading = true, isRunning = false)
startV2RayWithPermission()
} else {
showStatus("Серверы не найдены!")
mainViewModel.reloadServerList()
showStatus("Нет доступных серверов!")
setButtonsEnabled(true)
}
}
}
mainViewModel.isRunning.observe(this) { isRunning ->
applyRunningState(false, isRunning)
// Как только VPN только что подключился — обновляем подписки через него
if (isRunning && !wasRunning) {
updateSubsViaVpn()
}
wasRunning = isRunning
}
mainViewModel.startListenBroadcast()
mainViewModel.initAssets(assets)
@@ -198,15 +260,71 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
binding.viewPager.setCurrentItem(targetIndex, false)
binding.tabGroup.isVisible = groups.size > 1
// Double-tap on a tab scrolls to top of that group
binding.tabGroup.addOnTabSelectedListener(object : com.google.android.material.tabs.TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: com.google.android.material.tabs.TabLayout.Tab?) {}
override fun onTabUnselected(tab: com.google.android.material.tabs.TabLayout.Tab?) {}
override fun onTabReselected(tab: com.google.android.material.tabs.TabLayout.Tab?) {
val currentItem = binding.viewPager.currentItem
val itemId = groupPagerAdapter.getItemId(currentItem)
val fragment = supportFragmentManager.findFragmentByTag("f$itemId") as? GroupServerFragment
fragment?.scrollToTop()
}
})
}
private fun setButtonsEnabled(enabled: Boolean) {
binding.fab.isEnabled = enabled
binding.fab.alpha = if (enabled) 1.0f else 0.5f
setSecondaryButtonsEnabled(enabled)
}
private fun setSecondaryButtonsEnabled(enabled: Boolean) {
binding.btnSummaryLite.isEnabled = enabled
binding.btnSummaryLite.alpha = if (enabled) 1.0f else 0.5f
val menu = binding.toolbar.menu
menu.findItem(R.id.real_ping_all)?.let {
it.isEnabled = enabled
it.icon?.alpha = if (enabled) 255 else 128
}
menu.findItem(R.id.filter_by_country)?.let {
it.isEnabled = enabled
it.icon?.alpha = if (enabled) 255 else 128
}
menu.findItem(R.id.sub_update)?.let {
it.isEnabled = enabled
it.icon?.alpha = if (enabled) 255 else 128
}
}
private fun handleFabAction() {
if (isFabOperationInProgress) {
return
}
isFabOperationInProgress = true
val isRunning = mainViewModel.isRunning.value == true
// Блокируем все кнопки сразу
setButtonsEnabled(false)
applyRunningState(isLoading = true, isRunning = false)
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
} else {
startV2RayWithPermission()
lifecycleScope.launch {
try {
if (isRunning) {
Log.d(AppConfig.TAG, "FAB: stopping service")
V2RayServiceManager.stopVService(this@MainActivity)
} else {
Log.d(AppConfig.TAG, "FAB: starting service")
startV2RayWithPermission()
}
} catch (e: Exception) {
Log.e(AppConfig.TAG, "FAB: error", e)
applyRunningState(isLoading = false, isRunning = mainViewModel.isRunning.value == true)
} finally {
isFabOperationInProgress = false
}
}
}
@@ -220,29 +338,58 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
private fun handleLiteAction() {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
// If testing is in progress - stop it
if (mainViewModel.isTesting.value == true) {
mainViewModel.cancelAllTests()
showStatus("Тест остановлен")
isLiteTesting = false
return
}
showStatus("Обновление профилей...")
showLoading()
isLiteTesting = true
lifecycleScope.launch(Dispatchers.IO) {
val result = mainViewModel.updateConfigViaSubAll()
delay(500L)
launch(Dispatchers.Main) {
if (result.configCount > 0) {
mainViewModel.reloadServerList()
showStatus("Обновлено ${result.configCount} профилей. Запуск теста...")
} else {
showStatus("Запуск теста...")
if (isFabOperationInProgress) {
return
}
isFabOperationInProgress = true
// Блокируем все кнопки сразу при нажатии
setButtonsEnabled(false)
lifecycleScope.launch {
try {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this@MainActivity)
delay(1000)
}
hideLoading()
delay(500L)
showStatus("Выполняется замер задержки. Ожидаем завершения...")
mainViewModel.testAllRealPing()
showStatus("Обновление профилей...")
showLoading()
isLiteTesting = true
launch(Dispatchers.IO) {
val result = mainViewModel.updateConfigViaSubAll()
val removed = mainViewModel.removeDuplicateByIpAll()
withContext(Dispatchers.Main) {
mainViewModel.reloadServerList()
if (result.configCount > 0) {
val status = if (removed > 0)
"Обновлено ${result.configCount} профилей, удалено $removed дубл. IP. Запуск теста..."
else
"Обновлено ${result.configCount} профилей. Запуск теста..."
showStatus(status)
} else {
showStatus("Запуск теста...")
}
hideLoading()
showStatus("Выполняется замер задержки. Ожидаем завершения...")
mainViewModel.testAllRealPing()
}
}
delay(1500)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error in handleLiteAction", e)
} finally {
isFabOperationInProgress = false
}
}
}
@@ -269,12 +416,24 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
fun restartV2Ray() {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this)
if (isFabOperationInProgress) {
return
}
isFabOperationInProgress = true
lifecycleScope.launch {
delay(500)
startV2Ray()
try {
if (mainViewModel.isRunning.value == true) {
V2RayServiceManager.stopVService(this@MainActivity)
delay(1000)
}
startV2Ray()
delay(1000)
} catch (e: Exception) {
Log.e(AppConfig.TAG, "Error in restartV2Ray", e)
} finally {
isFabOperationInProgress = false
}
}
}
@@ -300,21 +459,36 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
private fun showStatus(resId: Int) = showStatus(getString(resId))
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
private fun accentColor(): ColorStateList {
val typedValue = android.util.TypedValue()
theme.resolveAttribute(androidx.appcompat.R.attr.colorPrimary, typedValue, true)
val color = if (typedValue.resourceId != 0)
ContextCompat.getColor(this, typedValue.resourceId)
else
typedValue.data
return ColorStateList.valueOf(color)
}
private fun applyRunningState(isLoading: Boolean, isRunning: Boolean) {
if (isLoading) {
binding.fab.setImageResource(R.drawable.ic_fab_check)
// Идёт процесс подключения/отключения — блокируем всё
setButtonsEnabled(false)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
return
}
if (isRunning) {
binding.fab.setImageResource(R.drawable.ic_stop_24dp)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
binding.btnSummaryLite.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_active))
// Подключены: только FAB (отключить) активен, остальное блокируем
setSecondaryButtonsEnabled(false)
binding.fab.isEnabled = true
binding.fab.alpha = 1.0f
binding.fab.backgroundTintList = accentColor()
binding.btnSummaryLite.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
binding.fab.contentDescription = getString(R.string.action_stop_service)
setTestState(getString(R.string.connection_connected))
binding.layoutTest.isFocusable = true
} else {
binding.fab.setImageResource(R.drawable.ic_play_24dp)
setButtonsEnabled(true)
binding.fab.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
binding.btnSummaryLite.backgroundTintList = ColorStateList.valueOf(ContextCompat.getColor(this, R.color.color_fab_inactive))
binding.fab.contentDescription = getString(R.string.tasker_start_service)
@@ -325,6 +499,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
override fun onResume() {
super.onResume()
MessageUtil.sendMsg2Service(this, AppConfig.MSG_REGISTER_CLIENT, "")
}
override fun onPause() {
@@ -333,6 +508,32 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.menu_main, menu)
val searchItem = menu.findItem(R.id.search_view)
val searchView = searchItem.actionView as SearchView
searchView.setOnQueryTextListener(object : SearchView.OnQueryTextListener {
override fun onQueryTextSubmit(query: String?): Boolean {
return false
}
override fun onQueryTextChange(newText: String?): Boolean {
mainViewModel.filterConfig(newText.orEmpty())
return true
}
})
searchItem.setOnActionExpandListener(object : MenuItem.OnActionExpandListener {
override fun onMenuItemActionExpand(item: MenuItem): Boolean {
return true
}
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
mainViewModel.filterConfig("")
return true
}
})
return super.onCreateOptionsMenu(menu)
}
@@ -346,10 +547,16 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
R.id.sub_update -> {
setButtonsEnabled(false)
importConfigViaSub()
true
}
R.id.filter_by_country -> {
showCountryFilterDialog()
true
}
else -> super.onOptionsItemSelected(item)
}
@@ -439,7 +646,49 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
return true
}
/**
* Обновляет подписки через уже поднятый VPN (httpPort > 0).
* Вызывается сразу после того, как VPN перешёл в состояние isRunning = true.
*/
private fun updateSubsViaVpn() {
lifecycleScope.launch(Dispatchers.IO) {
// Даём VPN пару секунд инициализироваться
delay(2000)
Log.d(AppConfig.TAG, "updateSubsViaVpn: starting post-connect subscription update")
val result = mainViewModel.updateConfigViaSubAll()
if (result.configCount > 0) {
val removed = mainViewModel.removeDuplicateByIpAll()
withContext(Dispatchers.Main) {
mainViewModel.reloadServerList()
val msg = if (removed > 0)
"Подписки обновлены: ${result.configCount} профилей, удалено $removed дубл. IP"
else
"Подписки обновлены: ${result.configCount} профилей"
showStatus(msg)
}
}
}
}
private fun importAllSubsOnStartup() {
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.updateConfigViaSubAll()
val removed = mainViewModel.removeDuplicateByIpAll()
delay(500L)
launch(Dispatchers.Main) {
if (result.configCount > 0) {
mainViewModel.reloadServerList()
val status = if (removed > 0)
"${getString(R.string.title_update_config_count, result.configCount)} (удалено $removed дубл. IP)"
else
getString(R.string.title_update_config_count, result.configCount)
showStatus(status)
}
hideLoading()
}
}
}
/**
* import config from sub
*/
@@ -466,6 +715,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
mainViewModel.reloadServerList()
}
hideLoading()
applyRunningState(isLoading = false, isRunning = mainViewModel.isRunning.value == true)
}
}
return true
@@ -486,7 +736,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
private fun delAllConfig() {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
MaterialAlertDialogBuilder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
@@ -505,7 +755,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
private fun delDuplicateConfig() {
AlertDialog.Builder(this).setMessage(R.string.del_config_comfirm)
MaterialAlertDialogBuilder(this).setMessage(R.string.del_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
@@ -523,25 +773,6 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
.show()
}
private fun delInvalidConfig() {
AlertDialog.Builder(this).setMessage(R.string.del_invalid_config_comfirm)
.setPositiveButton(android.R.string.ok) { _, _ ->
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
val ret = mainViewModel.removeInvalidServer()
launch(Dispatchers.Main) {
mainViewModel.reloadServerList()
showStatus(getString(R.string.title_del_config_count, ret))
hideLoading()
}
}
}
.setNegativeButton(android.R.string.cancel) { _, _ ->
//do noting
}
.show()
}
private fun sortByTestResults() {
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
@@ -644,7 +875,64 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
tabMediator?.detach()
super.onDestroy()
}
// ── Country filter dialog ─────────────────────────────────────────────────
private fun showCountryFilterDialog() {
showLoading()
lifecycleScope.launch(Dispatchers.IO) {
mainViewModel.refreshCountryCache()
// Collect all countries including UNKNOWN
val allCountriesMap = mainViewModel.collectAllCountries().toMutableMap()
// Add Unknown entry
allCountriesMap[CountryDetector.UNKNOWN] = "🌐 Неизвестно"
val currentFilter = mainViewModel.countryFilter // empty = show all
withContext(Dispatchers.Main) {
hideLoading()
if (allCountriesMap.size <= 1) {
showStatus("Нет серверов с известной страной")
return@withContext
}
val codes = allCountriesMap.keys.toTypedArray()
val labels = allCountriesMap.values.toTypedArray()
// In exclude mode: checked = should be EXCLUDED
// currentFilter stores included set (empty = show all)
// Convert to excluded set for UI
val allCodes = codes.toSet()
val excludedByFilter = if (currentFilter.isEmpty()) emptySet()
else allCodes - currentFilter
val checked = BooleanArray(codes.size) { codes[it] in excludedByFilter }
val dialog = MaterialAlertDialogBuilder(this@MainActivity)
.setTitle("Исключить страны")
.setMultiChoiceItems(labels, checked) { _, which, isChecked ->
checked[which] = isChecked
}
.setPositiveButton("Применить") { _, _ ->
val excluded = codes.filterIndexed { i, _ -> checked[i] }.toSet()
val included = if (excluded.isEmpty()) emptySet()
else allCodes - excluded
mainViewModel.applyCountryFilter(included)
val msg = if (excluded.isEmpty()) "Показаны все страны"
else "Скрыто: ${excluded.joinToString { CountryDetector.codeToFlag(it) }}"
showStatus(msg)
}
.setNeutralButton("Сбросить") { _, _ ->
mainViewModel.applyCountryFilter(emptySet())
showStatus("Показаны все страны")
}
.create()
dialog.show()
dialog.setCustomTitle(buildDialogTitleWithClose("Исключить страны") { dialog.dismiss() })
}
}
}
private fun checkForUpdatesOnStartup() {
showStatus("Проверка обновлений...")
lifecycleScope.launch {
@@ -663,18 +951,28 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
private fun showUpdateAvailableDialog(result: xyz.zarazaex.olc.dto.CheckUpdateResult) {
AlertDialog.Builder(this)
.setTitle(getString(R.string.update_new_version_found, result.latestVersion))
.setMessage(result.releaseNotes)
val message = result.releaseNotes?.let { xyz.zarazaex.olc.util.MarkdownUtil.parseBasic(it) } ?: ""
val titleStr = getString(R.string.update_new_version_found, result.latestVersion)
val dialog = MaterialAlertDialogBuilder(this)
.setTitle(titleStr)
.setMessage(message)
.setPositiveButton(R.string.update_now) { _, _ ->
result.downloadUrl?.let {
Utils.openUri(this, it)
}
}
.setNegativeButton(android.R.string.ok, null)
.show()
.create()
dialog.show()
dialog.setCustomTitle(buildDialogTitleWithClose(titleStr) { dialog.dismiss() })
}
private fun buildDialogTitleWithClose(title: String, onClose: () -> Unit): View {
val view = layoutInflater.inflate(R.layout.dialog_title_with_close, null)
view.findViewById<TextView>(R.id.dialog_title_text).text = title
view.findViewById<android.widget.ImageButton>(R.id.dialog_close_btn).setOnClickListener { onClose() }
return view
}
private fun activateEasterEgg() {
if (isEasterEggActive) return
isEasterEggActive = true
@@ -719,4 +1017,17 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
}
}
}
private fun placeTabGroup() {
val tabGroup = binding.tabGroup
val bottomSlot = binding.tabSlotBottom
val topSlot = binding.tabSlotTop
val subsBottom = MmkvManager.decodeSettingsBool(AppConfig.PREF_SUBSCRIPTIONS_BOTTOM, false)
(tabGroup.parent as? android.view.ViewGroup)?.removeView(tabGroup)
if (subsBottom) {
bottomSlot.addView(tabGroup, 0)
} else {
topSlot.addView(tabGroup)
}
}
}
@@ -7,6 +7,7 @@ import android.view.View
import android.view.ViewGroup
import androidx.core.content.ContextCompat
import androidx.recyclerview.widget.RecyclerView
import java.util.Collections
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.R
import xyz.zarazaex.olc.contracts.MainAdapterListener
@@ -14,39 +15,143 @@ import xyz.zarazaex.olc.databinding.ItemRecyclerFooterBinding
import xyz.zarazaex.olc.databinding.ItemRecyclerMainBinding
import xyz.zarazaex.olc.dto.ProfileItem
import xyz.zarazaex.olc.dto.ServersCache
import xyz.zarazaex.olc.extension.nullIfBlank
import xyz.zarazaex.olc.handler.AngConfigManager
import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.helper.ItemTouchHelperAdapter
import xyz.zarazaex.olc.helper.ItemTouchHelperViewHolder
import xyz.zarazaex.olc.viewmodel.MainViewModel
import java.util.Collections
class MainRecyclerAdapter(
private val mainViewModel: MainViewModel,
private val adapterListener: MainAdapterListener?
private val mainViewModel: MainViewModel,
private val adapterListener: MainAdapterListener?
) : RecyclerView.Adapter<MainRecyclerAdapter.BaseViewHolder>(), ItemTouchHelperAdapter {
companion object {
private const val VIEW_TYPE_ITEM = 1
private const val VIEW_TYPE_FOOTER = 2
private const val PAYLOAD_FAVORITE = "PAYLOAD_FAVORITE"
}
private val doubleColumnDisplay = MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
private val doubleColumnDisplay =
MmkvManager.decodeSettingsBool(AppConfig.PREF_DOUBLE_COLUMN_DISPLAY, false)
private val showCopyButton =
MmkvManager.decodeSettingsBool(AppConfig.PREF_SHOW_COPY_BUTTON, false)
private var data: MutableList<ServersCache> = mutableListOf()
private var recyclerView: RecyclerView? = null
override fun onAttachedToRecyclerView(rv: RecyclerView) {
super.onAttachedToRecyclerView(rv)
recyclerView = rv
(rv.itemAnimator as? androidx.recyclerview.widget.SimpleItemAnimator)?.apply {
moveDuration = 400
removeDuration = 300
addDuration = 300
}
}
override fun onDetachedFromRecyclerView(rv: RecyclerView) {
super.onDetachedFromRecyclerView(rv)
recyclerView = null
}
@SuppressLint("NotifyDataSetChanged")
fun setData(newData: MutableList<ServersCache>?, position: Int = -1) {
data = newData?.toMutableList() ?: mutableListOf()
val parsedNewData = newData?.toList() ?: emptyList()
if (position >= 0 && position in data.indices) {
notifyItemChanged(position)
} else {
notifyDataSetChanged()
if (data.isEmpty() || parsedNewData.isEmpty() || position >= 0) {
data = parsedNewData.toMutableList()
if (position >= 0 && position in data.indices) {
notifyItemChanged(position)
} else {
notifyDataSetChanged()
}
return
}
val oldData = data
val lm = recyclerView?.layoutManager as? androidx.recyclerview.widget.LinearLayoutManager
val firstVisible = lm?.findFirstVisibleItemPosition()?.coerceAtLeast(0) ?: 0
val isAtTop = firstVisible == 0 && (lm?.findViewByPosition(0)?.top ?: 0) >= 0
val firstVisibleGuid = if (!isAtTop) oldData.getOrNull(firstVisible)?.guid else null
val diffResult =
androidx.recyclerview.widget.DiffUtil.calculateDiff(
object : androidx.recyclerview.widget.DiffUtil.Callback() {
override fun getOldListSize() = oldData.size
override fun getNewListSize() = parsedNewData.size
override fun areItemsTheSame(oldPos: Int, newPos: Int): Boolean {
return oldData[oldPos].guid == parsedNewData[newPos].guid
}
override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
val oldProfile = oldData[oldPos].profile
val newProfile = parsedNewData[newPos].profile
val oldGuid = oldData[oldPos].guid
val newGuid = parsedNewData[newPos].guid
return oldProfile == newProfile &&
oldProfile.isFavorite == newProfile.isFavorite &&
(oldGuid == MmkvManager.getSelectServer()) == (newGuid == MmkvManager.getSelectServer()) &&
MmkvManager.decodeServerAffiliationInfo(oldGuid)?.testDelayMillis ==
MmkvManager.decodeServerAffiliationInfo(newGuid)?.testDelayMillis
}
override fun getChangePayload(oldPos: Int, newPos: Int): Any? {
if (oldData[oldPos].profile.isFavorite != parsedNewData[newPos].profile.isFavorite) {
return PAYLOAD_FAVORITE
}
return super.getChangePayload(oldPos, newPos)
}
},
true
)
data = parsedNewData.toMutableList()
diffResult.dispatchUpdatesTo(this)
if (isAtTop) {
lm?.scrollToPositionWithOffset(0, 0)
} else if (firstVisibleGuid != null) {
val newPos = parsedNewData.indexOfFirst { it.guid == firstVisibleGuid }
if (newPos >= 0) lm?.scrollToPosition(newPos)
}
}
override fun getItemCount() = data.size + 1
override fun onBindViewHolder(holder: BaseViewHolder, position: Int, payloads: MutableList<Any>) {
if (payloads.isNotEmpty() && holder is MainViewHolder) {
for (payload in payloads) {
if (payload == PAYLOAD_FAVORITE) {
val item = data.getOrNull(holder.bindingAdapterPosition) ?: data.getOrNull(position) ?: continue
val isFav = item.profile.isFavorite
// Set correct icon immediately, then animate scale bounce
holder.itemMainBinding.ivFavorite.setImageResource(
if (isFav) R.drawable.ic_star_filled else R.drawable.kid_star_outline_24
)
animateFavorite(holder.itemMainBinding.ivFavorite)
}
}
} else {
super.onBindViewHolder(holder, position, payloads)
}
}
private fun animateFavorite(view: android.widget.ImageView) {
view.animate().cancel()
view.animate()
.scaleX(1.4f)
.scaleY(1.4f)
.setDuration(120)
.withEndAction {
view.animate()
.scaleX(1.0f)
.scaleY(1.0f)
.setDuration(120)
.start()
}
.start()
}
override fun onBindViewHolder(holder: BaseViewHolder, position: Int) {
if (holder is MainViewHolder) {
val context = holder.itemMainBinding.root.context
@@ -55,41 +160,65 @@ class MainRecyclerAdapter(
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
//Name address
// Name address
holder.itemMainBinding.tvName.text = profile.remarks
holder.itemMainBinding.tvStatistics.text = getAddress(profile)
//TestResult
// TestResult
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
holder.itemMainBinding.tvTestResult.text = aff?.getTestDelayString().orEmpty()
if ((aff?.testDelayMillis ?: 0L) < 0L) {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPingRed))
holder.itemMainBinding.tvTestResult.setTextColor(
ContextCompat.getColor(context, R.color.colorPingRed)
)
} else {
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPing))
holder.itemMainBinding.tvTestResult.setTextColor(
ContextCompat.getColor(context, R.color.colorPing)
)
}
//layoutIndicator
// layoutIndicator
if (guid == MmkvManager.getSelectServer()) {
holder.itemMainBinding.layoutIndicator.setBackgroundResource(R.color.colorIndicator)
} else {
holder.itemMainBinding.layoutIndicator.setBackgroundResource(0)
}
//subscription remarks
// subscription remarks
val subRemarks = getSubscriptionRemarks(profile)
holder.itemMainBinding.tvSubscription.text = subRemarks
holder.itemMainBinding.layoutSubscription.visibility = if (subRemarks.isEmpty()) View.GONE else View.VISIBLE
holder.itemMainBinding.layoutSubscription.visibility =
if (subRemarks.isEmpty()) View.GONE else View.VISIBLE
val isFav = profile.isFavorite
holder.itemMainBinding.ivFavorite.setImageResource(
if (isFav) R.drawable.ic_star_filled else R.drawable.kid_star_outline_24
)
holder.itemMainBinding.ivFavorite.setOnClickListener {
profile.isFavorite = !profile.isFavorite
MmkvManager.encodeServerConfig(guid, profile)
holder.itemMainBinding.ivFavorite.setImageResource(
if (profile.isFavorite) R.drawable.ic_star_filled else R.drawable.kid_star_outline_24
)
animateFavorite(holder.itemMainBinding.ivFavorite)
mainViewModel.reloadServerList()
}
holder.itemMainBinding.ivCopy.visibility = if (showCopyButton) View.VISIBLE else View.GONE
holder.itemMainBinding.ivCopy.setOnClickListener {
adapterListener?.onCopyToClipboard(guid)
}
holder.itemMainBinding.infoContainer.setOnClickListener {
adapterListener?.onSelectServer(guid)
}
}
}
/**
* Gets the server address information
* Hides part of IP or domain information for privacy protection
* Gets the server address information Hides part of IP or domain information for privacy
* protection
* @param profile The server configuration
* @return Formatted address string
*/
@@ -104,10 +233,11 @@ class MainRecyclerAdapter(
*/
private fun getSubscriptionRemarks(profile: ProfileItem): String {
val subRemarks =
if (mainViewModel.subscriptionId.isEmpty())
MmkvManager.decodeSubscription(profile.subscriptionId)?.remarks?.firstOrNull()
else
null
if (mainViewModel.subscriptionId.isEmpty())
MmkvManager.decodeSubscription(profile.subscriptionId)
?.remarks
?.firstOrNull()
else null
return subRemarks?.toString() ?: ""
}
@@ -128,10 +258,21 @@ class MainRecyclerAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
return when (viewType) {
VIEW_TYPE_ITEM ->
MainViewHolder(ItemRecyclerMainBinding.inflate(LayoutInflater.from(parent.context), parent, false))
MainViewHolder(
ItemRecyclerMainBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
else ->
FooterViewHolder(ItemRecyclerFooterBinding.inflate(LayoutInflater.from(parent.context), parent, false))
FooterViewHolder(
ItemRecyclerFooterBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
)
}
}
@@ -154,10 +295,10 @@ class MainRecyclerAdapter(
}
class MainViewHolder(val itemMainBinding: ItemRecyclerMainBinding) :
BaseViewHolder(itemMainBinding.root), ItemTouchHelperViewHolder
BaseViewHolder(itemMainBinding.root), ItemTouchHelperViewHolder
class FooterViewHolder(val itemFooterBinding: ItemRecyclerFooterBinding) :
BaseViewHolder(itemFooterBinding.root)
BaseViewHolder(itemFooterBinding.root)
override fun onItemMove(fromPosition: Int, toPosition: Int): Boolean {
mainViewModel.swapServer(fromPosition, toPosition)
@@ -172,6 +313,5 @@ class MainRecyclerAdapter(
// do nothing
}
override fun onItemDismiss(position: Int) {
}
}
override fun onItemDismiss(position: Int) {}
}
@@ -90,7 +90,7 @@ class PerAppProxyActivity : BaseActivity() {
}
appsAll = apps
adapter = PerAppProxyAdapter(apps, viewModel)
adapter = PerAppProxyAdapter(apps.toMutableList(), viewModel)
binding.recyclerView.adapter = adapter
} catch (e: Exception) {
@@ -134,17 +134,6 @@ class PerAppProxyActivity : BaseActivity() {
true
}
R.id.import_proxy_app -> {
importProxyApp()
allowPerAppProxy()
true
}
R.id.export_proxy_app -> {
exportProxyApp()
true
}
else -> super.onOptionsItemSelected(item)
}
@@ -1,5 +1,6 @@
package xyz.zarazaex.olc.ui
import android.annotation.SuppressLint
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
@@ -9,9 +10,9 @@ import xyz.zarazaex.olc.dto.AppInfo
import xyz.zarazaex.olc.viewmodel.PerAppProxyViewModel
class PerAppProxyAdapter(
val apps: List<AppInfo>,
val apps: MutableList<AppInfo>,
val viewModel: PerAppProxyViewModel
) :RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
) : RecyclerView.Adapter<PerAppProxyAdapter.BaseViewHolder>() {
companion object {
private const val VIEW_TYPE_HEADER = 0
@@ -29,17 +30,12 @@ class PerAppProxyAdapter(
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): BaseViewHolder {
val ctx = parent.context
return when (viewType) {
VIEW_TYPE_HEADER -> {
val view = View(ctx)
view.layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
0
)
view.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 0)
BaseViewHolder(view)
}
else -> AppViewHolder(ItemRecyclerBypassListBinding.inflate(LayoutInflater.from(ctx), parent, false))
}
}
@@ -48,30 +44,53 @@ class PerAppProxyAdapter(
open class BaseViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)
inner class AppViewHolder(private val itemBypassBinding: ItemRecyclerBypassListBinding) : BaseViewHolder(itemBypassBinding.root),
View.OnClickListener {
inner class AppViewHolder(private val itemBypassBinding: ItemRecyclerBypassListBinding) :
BaseViewHolder(itemBypassBinding.root), View.OnClickListener {
private lateinit var appInfo: AppInfo
fun bind(appInfo: AppInfo) {
this.appInfo = appInfo
itemBypassBinding.icon.setImageDrawable(appInfo.appIcon)
itemBypassBinding.name.text = if (appInfo.isSystemApp) {
String.format("** %s", appInfo.appName)
"** ${appInfo.appName}"
} else {
appInfo.appName
}
itemBypassBinding.packageName.text = appInfo.packageName
itemBypassBinding.checkBox.isChecked = viewModel.contains(appInfo.packageName)
itemView.setOnClickListener(this)
}
@SuppressLint("NotifyDataSetChanged")
override fun onClick(v: View?) {
val packageName = appInfo.packageName
viewModel.toggle(packageName)
itemBypassBinding.checkBox.isChecked = viewModel.contains(packageName)
val isNowSelected = viewModel.contains(packageName)
itemBypassBinding.checkBox.isChecked = isNowSelected
// Move selected items to top, unselected back to their position
val currentPos = apps.indexOf(appInfo)
if (currentPos < 0) return
if (isNowSelected) {
// Find first non-selected item position (insert before it)
val insertAt = apps.indexOfFirst { !viewModel.contains(it.packageName) }
.takeIf { it >= 0 } ?: 0
if (currentPos != insertAt) {
apps.removeAt(currentPos)
apps.add(insertAt, appInfo)
notifyItemMoved(currentPos + 1, insertAt + 1) // +1 for header
}
} else {
// Move to end of selected group
val lastSelected = apps.indexOfLast { viewModel.contains(it.packageName) }
val insertAt = if (lastSelected < 0) 0 else lastSelected + 1
if (currentPos != insertAt) {
apps.removeAt(currentPos)
apps.add(insertAt, appInfo)
notifyItemMoved(currentPos + 1, insertAt + 1)
}
}
}
}
}
@@ -1,7 +1,10 @@
package xyz.zarazaex.olc.ui
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.preference.CheckBoxPreference
import androidx.preference.EditTextPreference
import androidx.preference.ListPreference
@@ -28,6 +31,7 @@ class SettingsActivity : BaseActivity() {
class SettingsFragment : PreferenceFragmentCompat() {
private val dynamicColors by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_DYNAMIC_COLORS) }
private val localDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_LOCAL_DNS_ENABLED) }
private val fakeDns by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_FAKE_DNS_ENABLED) }
private val appendHttpProxy by lazy { findPreference<CheckBoxPreference>(AppConfig.PREF_APPEND_HTTP_PROXY) }
@@ -63,8 +67,22 @@ class SettingsActivity : BaseActivity() {
addPreferencesFromResource(R.xml.pref_settings)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) {
dynamicColors?.isVisible = false
}
initPreferenceSummaries()
dynamicColors?.setOnPreferenceChangeListener { _, _ ->
Toast.makeText(context, R.string.restart_required, Toast.LENGTH_SHORT).show()
val intent = requireActivity().packageManager
.getLaunchIntentForPackage(requireActivity().packageName)
?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
requireActivity().finish()
intent?.let { startActivity(it) }
true
}
localDns?.setOnPreferenceChangeListener { _, any ->
updateLocalDns(any as Boolean)
true
@@ -41,7 +41,6 @@ class SubSettingActivity : BaseActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
//setContentView(binding.root)
setContentViewWithToolbar(binding.root, showHomeAsUp = true, title = getString(R.string.title_sub_setting))
adapter = SubSettingRecyclerAdapter(viewModel, ActivityAdapterListener())
@@ -53,6 +52,14 @@ class SubSettingActivity : BaseActivity() {
mItemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter))
mItemTouchHelper?.attachToRecyclerView(binding.recyclerView)
viewModel.isUpdating.observe(this) { isUpdating ->
adapter.setUpdating(isUpdating)
}
viewModel.subscriptionStatuses.observe(this) { statuses ->
adapter.notifyDataSetChanged()
}
}
override fun onResume() {
@@ -62,44 +69,105 @@ class SubSettingActivity : BaseActivity() {
override fun onCreateOptionsMenu(menu: Menu): Boolean {
menuInflater.inflate(R.menu.action_sub_setting, menu)
viewModel.isUpdating.observe(this) { isUpdating ->
menu.findItem(R.id.sub_update)?.isEnabled = !isUpdating
menu.findItem(R.id.add_config)?.isEnabled = !isUpdating
}
return super.onCreateOptionsMenu(menu)
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
R.id.add_config -> {
startActivity(Intent(this, SubEditActivity::class.java))
true
}
override fun onOptionsItemSelected(item: MenuItem): Boolean {
return when (item.itemId) {
R.id.add_config -> {
startActivity(Intent(this, SubEditActivity::class.java))
true
}
R.id.sub_update -> {
showLoading()
R.id.sub_update -> {
if (viewModel.isUpdating.value == true) {
return true
}
lifecycleScope.launch(Dispatchers.IO) {
val result = AngConfigManager.updateConfigViaSubAll()
delay(500L)
launch(Dispatchers.Main) {
if (result.successCount + result.failureCount + result.skipCount == 0) {
showLoading()
viewModel.isUpdating.value = true
viewModel.subscriptionStatuses.value = emptyMap()
lifecycleScope.launch(Dispatchers.Main) {
val subscriptions = viewModel.getAll()
var totalConfigCount = 0
var successCount = 0
var failureCount = 0
var skipCount = 0
val jobs = subscriptions.map { subscription ->
launch(Dispatchers.IO) {
val subId = subscription.guid
launch(Dispatchers.Main) {
viewModel.updateSubscriptionStatus(subId, xyz.zarazaex.olc.dto.SubscriptionUpdateStatus.LOADING)
}
val result = AngConfigManager.updateConfigViaSub(subscription)
launch(Dispatchers.Main) {
when {
result.successCount > 0 -> {
viewModel.updateSubscriptionStatus(
subId,
xyz.zarazaex.olc.dto.SubscriptionUpdateStatus.SUCCESS,
result.configCount
)
}
result.skipCount > 0 -> {
viewModel.updateSubscriptionStatus(
subId,
xyz.zarazaex.olc.dto.SubscriptionUpdateStatus.SKIPPED
)
}
else -> {
viewModel.updateSubscriptionStatus(
subId,
xyz.zarazaex.olc.dto.SubscriptionUpdateStatus.FAILED
)
}
}
}
synchronized(this@SubSettingActivity) {
totalConfigCount += result.configCount
successCount += result.successCount
failureCount += result.failureCount
skipCount += result.skipCount
}
}
}
jobs.forEach { it.join() }
delay(500L)
viewModel.isUpdating.value = false
if (successCount + failureCount + skipCount == 0) {
toast(R.string.title_update_subscription_no_subscription)
} else if (result.successCount > 0 && result.failureCount + result.skipCount == 0) {
toast(getString(R.string.title_update_config_count, result.configCount))
} else if (successCount > 0 && failureCount + skipCount == 0) {
toast(getString(R.string.title_update_config_count, totalConfigCount))
} else {
toast(
getString(
R.string.title_update_subscription_result,
result.configCount, result.successCount, result.failureCount, result.skipCount
totalConfigCount, successCount, failureCount, skipCount
)
)
}
hideLoading()
refreshData()
}
true
}
true
else -> super.onOptionsItemSelected(item)
}
else -> super.onOptionsItemSelected(item)
}
@SuppressLint("NotifyDataSetChanged")
@@ -8,8 +8,10 @@ import android.view.ViewGroup
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.R
import xyz.zarazaex.olc.contracts.BaseAdapterListener
import xyz.zarazaex.olc.databinding.ItemRecyclerSubSettingBinding
import xyz.zarazaex.olc.dto.SubscriptionUpdateStatus
import xyz.zarazaex.olc.helper.ItemTouchHelperAdapter
import xyz.zarazaex.olc.helper.ItemTouchHelperViewHolder
import xyz.zarazaex.olc.util.Utils
@@ -20,6 +22,15 @@ class SubSettingRecyclerAdapter(
private val adapterListener: BaseAdapterListener?
) : RecyclerView.Adapter<SubSettingRecyclerAdapter.MainViewHolder>(), ItemTouchHelperAdapter {
private var isUpdating = false
fun setUpdating(updating: Boolean) {
if (isUpdating != updating) {
isUpdating = updating
notifyDataSetChanged()
}
}
override fun getItemCount() = viewModel.getAll().size
override fun onBindViewHolder(holder: MainViewHolder, position: Int) {
@@ -32,16 +43,60 @@ class SubSettingRecyclerAdapter(
holder.itemSubSettingBinding.tvLastUpdated.text = Utils.formatTimestamp(subItem.lastUpdated)
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
val subStatus = viewModel.getSubscriptionStatus(subId)
when (subStatus?.status) {
SubscriptionUpdateStatus.LOADING -> {
holder.itemSubSettingBinding.progressBar.visibility = View.VISIBLE
holder.itemSubSettingBinding.tvUpdateStatus.visibility = View.VISIBLE
holder.itemSubSettingBinding.tvUpdateStatus.text = holder.itemView.context.getString(R.string.title_updating)
holder.itemSubSettingBinding.tvUpdateStatus.setTextColor(Color.GRAY)
}
SubscriptionUpdateStatus.SUCCESS -> {
holder.itemSubSettingBinding.progressBar.visibility = View.GONE
holder.itemSubSettingBinding.tvUpdateStatus.visibility = View.VISIBLE
holder.itemSubSettingBinding.tvUpdateStatus.text = "${subStatus.configCount}"
holder.itemSubSettingBinding.tvUpdateStatus.setTextColor(Color.parseColor("#4CAF50"))
}
SubscriptionUpdateStatus.FAILED -> {
holder.itemSubSettingBinding.progressBar.visibility = View.GONE
holder.itemSubSettingBinding.tvUpdateStatus.visibility = View.VISIBLE
holder.itemSubSettingBinding.tvUpdateStatus.text = ""
holder.itemSubSettingBinding.tvUpdateStatus.setTextColor(Color.parseColor("#F44336"))
}
SubscriptionUpdateStatus.SKIPPED -> {
holder.itemSubSettingBinding.progressBar.visibility = View.GONE
holder.itemSubSettingBinding.tvUpdateStatus.visibility = View.VISIBLE
holder.itemSubSettingBinding.tvUpdateStatus.text = ""
holder.itemSubSettingBinding.tvUpdateStatus.setTextColor(Color.GRAY)
}
else -> {
holder.itemSubSettingBinding.progressBar.visibility = View.GONE
holder.itemSubSettingBinding.tvUpdateStatus.visibility = View.GONE
}
}
val isEnabled = !isUpdating
holder.itemSubSettingBinding.layoutEdit.isClickable = isEnabled
holder.itemSubSettingBinding.layoutEdit.alpha = if (isEnabled) 1.0f else 0.5f
holder.itemSubSettingBinding.layoutEdit.setOnClickListener {
adapterListener?.onEdit(subId, position)
if (isEnabled) {
adapterListener?.onEdit(subId, position)
}
}
holder.itemSubSettingBinding.layoutRemove.isClickable = isEnabled
holder.itemSubSettingBinding.layoutRemove.alpha = if (isEnabled) 1.0f else 0.5f
holder.itemSubSettingBinding.layoutRemove.setOnClickListener {
adapterListener?.onRemove(subId, position)
if (isEnabled) {
adapterListener?.onRemove(subId, position)
}
}
holder.itemSubSettingBinding.chkEnable.isEnabled = isEnabled
holder.itemSubSettingBinding.chkEnable.alpha = if (isEnabled) 1.0f else 0.5f
holder.itemSubSettingBinding.chkEnable.setOnCheckedChangeListener { it, isChecked ->
if (!it.isPressed) return@setOnCheckedChangeListener
if (!it.isPressed || !isEnabled) return@setOnCheckedChangeListener
subItem.enabled = isChecked
viewModel.update(subId, subItem)
}
@@ -56,8 +111,12 @@ class SubSettingRecyclerAdapter(
holder.itemSubSettingBinding.layoutShare.visibility = View.VISIBLE
holder.itemSubSettingBinding.chkEnable.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutLastUpdated.visibility = View.VISIBLE
holder.itemSubSettingBinding.layoutShare.isClickable = isEnabled
holder.itemSubSettingBinding.layoutShare.alpha = if (isEnabled) 1.0f else 0.5f
holder.itemSubSettingBinding.layoutShare.setOnClickListener {
adapterListener?.onShare(subItem.url)
if (isEnabled) {
adapterListener?.onShare(subItem.url)
}
}
}
}
@@ -250,5 +250,27 @@ object HttpUtil {
}
}
}
/**
* POST JSON body to [url] and return response as String or null.
*/
fun postJson(url: String, body: String, timeout: Int = 10000): String? {
var conn: java.net.HttpURLConnection? = null
return try {
conn = java.net.URL(url).openConnection() as java.net.HttpURLConnection
conn.requestMethod = "POST"
conn.connectTimeout = timeout
conn.readTimeout = timeout
conn.doOutput = true
conn.setRequestProperty("Content-Type", "application/json")
conn.setRequestProperty("Connection", "close")
conn.outputStream.use { it.write(body.toByteArray()) }
conn.inputStream.bufferedReader().readText()
} catch (_: Exception) {
null
} finally {
conn?.disconnect()
}
}
}
@@ -0,0 +1,82 @@
package xyz.zarazaex.olc.util
import android.content.Context
import android.text.SpannableStringBuilder
import android.text.Spanned
import android.text.style.StyleSpan
import android.graphics.Typeface
/**
* Simple markdown to Spanned converter for basic ** bold ** syntax without external libraries.
*/
object MarkdownUtil {
/**
* Convert simple markdown to Spanned. Supports:
* - **bold**
* - # headers (rendered as bold)
* - - list items (bullet)
*/
fun parseBasic(text: String): CharSequence {
val lines = text.split("\n")
val builder = SpannableStringBuilder()
for (line in lines) {
val processedLine = processLine(line.trimEnd())
builder.append(processedLine)
builder.append("\n")
}
// Remove trailing newlines
while (builder.isNotEmpty() && builder.last() == '\n') {
builder.delete(builder.length - 1, builder.length)
}
return builder
}
private fun processLine(line: String): CharSequence {
// Handle headers
val headerLine = when {
line.startsWith("### ") -> line.removePrefix("### ")
line.startsWith("## ") -> line.removePrefix("## ")
line.startsWith("# ") -> line.removePrefix("# ")
else -> null
}
if (headerLine != null) {
val sb = SpannableStringBuilder(processBold(headerLine))
sb.setSpan(StyleSpan(Typeface.BOLD), 0, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
return sb
}
// Handle list items
if (line.startsWith("- ") || line.startsWith("* ")) {
return SpannableStringBuilder("" + processBold(line.substring(2)))
}
return processBold(line)
}
private fun processBold(text: String): SpannableStringBuilder {
val sb = SpannableStringBuilder()
var i = 0
while (i < text.length) {
if (i + 1 < text.length && text[i] == '*' && text[i + 1] == '*') {
val end = text.indexOf("**", i + 2)
if (end > 0) {
val boldText = text.substring(i + 2, end)
val start = sb.length
sb.append(boldText)
sb.setSpan(StyleSpan(Typeface.BOLD), start, sb.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
i = end + 2
} else {
sb.append(text[i])
i++
}
} else {
sb.append(text[i])
i++
}
}
return sb
}
}
@@ -15,6 +15,7 @@ import xyz.zarazaex.olc.AngApplication
import xyz.zarazaex.olc.AppConfig
import xyz.zarazaex.olc.R
import xyz.zarazaex.olc.dto.GroupMapItem
import xyz.zarazaex.olc.dto.PingProgressUpdate
import xyz.zarazaex.olc.dto.ServersCache
import xyz.zarazaex.olc.dto.SubscriptionCache
import xyz.zarazaex.olc.dto.SubscriptionUpdateResult
@@ -22,6 +23,7 @@ import xyz.zarazaex.olc.dto.TestServiceMessage
import xyz.zarazaex.olc.extension.matchesPattern
import xyz.zarazaex.olc.extension.serializable
import xyz.zarazaex.olc.handler.AngConfigManager
import xyz.zarazaex.olc.handler.CountryDetector
import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.handler.SettingsManager
import xyz.zarazaex.olc.handler.SpeedtestManager
@@ -37,14 +39,18 @@ import java.util.Collections
import java.util.regex.PatternSyntaxException
class MainViewModel(application: Application) : AndroidViewModel(application) {
private var serverList = mutableListOf<String>() // MmkvManager.decodeServerList()
private var serverList = mutableListOf<String>()
var subscriptionId: String = MmkvManager.decodeSettingsString(AppConfig.CACHE_SUBSCRIPTION_ID, "").orEmpty()
var keywordFilter = ""
/** ISO codes to show (empty = show all) */
var countryFilter: Set<String> = MmkvManager.getCountryFilter()
private set
val serversCache = mutableListOf<ServersCache>()
val isRunning by lazy { MutableLiveData<Boolean>() }
val updateListAction by lazy { MutableLiveData<Int>() }
val updateTestResultAction by lazy { MutableLiveData<String>() }
val liteTestFinished = MutableLiveData<Boolean>()
val isTesting by lazy { MutableLiveData<Boolean>().also { it.value = false } }
private val tcpingTestScope by lazy { CoroutineScope(Dispatchers.IO) }
/**
@@ -89,21 +95,29 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
else -> emptyList()
}
data class ServerWithDelay(val guid: String, val delay: Long)
data class ServerWithDelay(val guid: String, val delay: Long, val isFav: Boolean)
val allServers = mutableListOf<ServerWithDelay>()
groupSubs.forEach { sub ->
val subServers = MmkvManager.decodeServerList(sub.guid)
subServers.forEach { guid ->
val delay = MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
allServers.add(ServerWithDelay(guid, if (delay <= 0L) 999999 else delay))
val isFav = MmkvManager.decodeServerConfig(guid)?.isFavorite ?: false
val sortKey = when {
delay > 0L -> delay
delay == 0L -> Long.MAX_VALUE - 1
else -> Long.MAX_VALUE
}
allServers.add(ServerWithDelay(guid, sortKey, isFav))
}
}
allServers.sortBy { it.delay }
allServers.sortWith(compareBy({ !it.isFav }, { it.delay }))
allServers.map { it.guid }.toMutableList()
} else {
MmkvManager.decodeServerList(subscriptionId)
val list = MmkvManager.decodeServerList(subscriptionId)
list.sortWith(compareBy { !(MmkvManager.decodeServerConfig(it)?.isFavorite ?: false) })
list
}
updateCache()
@@ -149,10 +163,18 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val searchRegex = try {
if (kw.isNotEmpty()) Regex(kw, setOf(RegexOption.IGNORE_CASE)) else null
} catch (e: PatternSyntaxException) {
null // Fallback to literal search if regex is invalid
null
}
val activeCountryFilter = countryFilter
for (guid in serverList) {
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
// Country filter
if (activeCountryFilter.isNotEmpty()) {
val code = CountryDetector.getCountryCode(profile.remarks, profile.server)
if (code !in activeCountryFilter) continue
}
if (kw.isEmpty()) {
serversCache.add(ServersCache(guid, profile))
continue
@@ -172,6 +194,48 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
/** Sets a new country filter and reloads list. Pass empty set to show all. */
fun applyCountryFilter(codes: Set<String>) {
countryFilter = codes
MmkvManager.setCountryFilter(codes)
reloadServerList()
}
/**
* Returns all known countries from ALL servers across all subscriptions (for showing in filter dialog).
* Key = ISO code, Value = human-readable name + flag.
*/
fun collectAllCountries(): Map<String, String> {
val result = mutableMapOf<String, String>()
var hasUnknown = false
for (guid in MmkvManager.decodeAllServerList()) {
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
val code = CountryDetector.getCountryCode(profile.remarks, profile.server)
if (code == CountryDetector.UNKNOWN) {
hasUnknown = true
} else {
result[code] = "${CountryDetector.codeToFlag(code)} ${CountryDetector.codeToName(code)}"
}
}
if (hasUnknown) {
result[CountryDetector.UNKNOWN] = "🌐 Неизвестно"
}
return result.toSortedMap()
}
/** Trigger background geo-lookup for IPs not yet cached. */
fun refreshCountryCache() {
viewModelScope.launch(Dispatchers.IO) {
val ips = MmkvManager.decodeAllServerList().mapNotNull {
MmkvManager.decodeServerConfig(it)?.server?.trim()
}.distinct()
CountryDetector.lookupAndCacheAll(ips)
withContext(Dispatchers.Main) {
reloadServerList()
}
}
}
/**
* Updates the configuration via subscription for all servers.
* @return Detailed result of the subscription update operation.
@@ -182,27 +246,20 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
} else if (subscriptionId.startsWith("group_")) {
val allSubs = MmkvManager.decodeSubscriptions()
val groupSubs = when (subscriptionId) {
"group_white" -> allSubs.filter {
it.subscription.remarks.startsWith("БЕЛЫЕ", ignoreCase = true) ||
"group_white" -> allSubs.filter {
it.subscription.remarks.startsWith("БЕЛЫЕ", ignoreCase = true) ||
it.subscription.remarks.startsWith("WHITE", ignoreCase = true)
}
"group_black" -> allSubs.filter {
it.subscription.remarks.startsWith("ЧЕРНЫЕ", ignoreCase = true) ||
"group_black" -> allSubs.filter {
it.subscription.remarks.startsWith("ЧЕРНЫЕ", ignoreCase = true) ||
it.subscription.remarks.startsWith("BLACK", ignoreCase = true)
}
else -> emptyList()
}
var totalResult = SubscriptionUpdateResult()
groupSubs.forEach { sub ->
val result = AngConfigManager.updateConfigViaSub(SubscriptionCache(sub.guid, sub.subscription))
totalResult = SubscriptionUpdateResult(
configCount = totalResult.configCount + result.configCount,
successCount = totalResult.successCount + result.successCount,
failureCount = totalResult.failureCount + result.failureCount,
skipCount = totalResult.skipCount + result.skipCount
)
// Parallel fetch for group subs (sequential, called from IO context)
return groupSubs.fold(SubscriptionUpdateResult()) { acc, sub ->
acc + AngConfigManager.updateConfigViaSub(SubscriptionCache(sub.guid, sub.subscription))
}
return totalResult
} else {
val subItem = MmkvManager.decodeSubscription(subscriptionId) ?: return SubscriptionUpdateResult()
return AngConfigManager.updateConfigViaSub(SubscriptionCache(subscriptionId, subItem))
@@ -254,6 +311,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
}
/**
* Cancels all running ping tests.
*/
fun cancelAllTests() {
MessageUtil.sendMsg2TestService(
getApplication(),
TestServiceMessage(key = AppConfig.MSG_MEASURE_CONFIG_CANCEL)
)
isTesting.value = false
}
/**
* Tests the real ping for all servers.
*/
@@ -262,32 +330,43 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
getApplication(),
TestServiceMessage(key = AppConfig.MSG_MEASURE_CONFIG_CANCEL)
)
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
updateListAction.value = -1
viewModelScope.launch(Dispatchers.Default) {
if (serversCache.isEmpty()) {
return@launch
}
val actualSubId = if (subscriptionId.startsWith("group_")) {
""
} else {
subscriptionId
}
MessageUtil.sendMsg2TestService(
getApplication(),
TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG,
subscriptionId = actualSubId,
serverGuids = if (keywordFilter.isNotEmpty() || subscriptionId.startsWith("group_")) {
serversCache.map { it.guid }
} else {
emptyList()
// Auto-deduplicate by IP before scanning so we don't waste time on dupes
viewModelScope.launch(Dispatchers.IO) {
val removed = removeDuplicateByIpAll()
withContext(Dispatchers.Main) {
if (removed > 0) {
reloadServerList()
}
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
updateListAction.value = -1
isTesting.value = true
viewModelScope.launch(Dispatchers.Default) {
if (serversCache.isEmpty()) {
withContext(Dispatchers.Main) { reloadServerList() }
}
)
)
if (serversCache.isEmpty()) {
withContext(Dispatchers.Main) { isTesting.value = false }
return@launch
}
val actualSubId = if (subscriptionId.startsWith("group_")) "" else subscriptionId
MessageUtil.sendMsg2TestService(
getApplication(),
TestServiceMessage(
key = AppConfig.MSG_MEASURE_CONFIG,
subscriptionId = actualSubId,
serverGuids = if (keywordFilter.isNotEmpty() || subscriptionId.startsWith("group_")) {
serversCache.map { it.guid }
} else {
emptyList()
}
)
)
}
}
}
}
@@ -424,6 +503,103 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return deleteServer.count()
}
/**
* Removes servers with duplicate IP addresses (same `server` field),
* keeping the one with the best ping result (or the first encountered if untested).
* @return Number of removed servers.
*/
fun removeDuplicateByIp(): Int {
val selectedGuid = MmkvManager.getSelectServer()
// Group all currently visible servers by their IP address
val byIp = LinkedHashMap<String, MutableList<ServersCache>>()
for (sc in serversCache) {
val ip = sc.profile.server?.trim()?.lowercase() ?: continue
byIp.getOrPut(ip) { mutableListOf() }.add(sc)
}
val toDelete = mutableListOf<String>()
for ((_, group) in byIp) {
if (group.size <= 1) continue
val best = group.minWithOrNull(compareBy(
{ it.guid != selectedGuid },
{ !it.profile.isFavorite },
{
val d = MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: 0L
when {
d > 0L -> d
d == 0L -> Long.MAX_VALUE - 1
else -> Long.MAX_VALUE
}
}
))!!
group.filter { it.guid != best.guid }.forEach { toDelete.add(it.guid) }
}
for (guid in toDelete) {
MmkvManager.removeServer(guid)
}
return toDelete.size
}
/**
* Removes duplicate servers by IP across ALL subscriptions (for use after sub update / before scan).
* Per-subscription deduplication: within each sub keeps the best (favorite > lowest ping > first).
*/
/**
* Removes servers with duplicate IP addresses across ALL subscriptions globally.
* Keeps the best one per IP (favorite > lowest ping > first encountered).
* @return Number of removed servers.
*/
fun removeDuplicateByIpAll(): Int {
val selectedGuid = MmkvManager.getSelectServer()
// Collect every server GUID across all subscriptions
data class Entry(val guid: String, val ip: String, val isFav: Boolean)
val allEntries = mutableListOf<Entry>()
val allSubIds = MmkvManager.decodeSubsList().toMutableList()
// Add the default (no-sub) slot if not already present
if (!allSubIds.contains(AppConfig.DEFAULT_SUBSCRIPTION_ID)) {
allSubIds.add(0, AppConfig.DEFAULT_SUBSCRIPTION_ID)
}
for (subId in allSubIds) {
for (guid in MmkvManager.decodeServerList(subId)) {
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
val ip = profile.server?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ?: continue
allEntries.add(Entry(guid, ip, profile.isFavorite))
}
}
// Group by IP globally
val byIp = LinkedHashMap<String, MutableList<Entry>>()
for (e in allEntries) {
byIp.getOrPut(e.ip) { mutableListOf() }.add(e)
}
val toDelete = mutableListOf<String>()
for ((_, group) in byIp) {
if (group.size <= 1) continue
val best = group.minWith(compareBy(
{ it.guid != selectedGuid },
{ !it.isFav },
{
val d = MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: 0L
when {
d > 0L -> d
d == 0L -> Long.MAX_VALUE - 1
else -> Long.MAX_VALUE
}
}
))
group.filter { it.guid != best.guid }.forEach { toDelete.add(it.guid) }
}
for (guid in toDelete) {
MmkvManager.removeServer(guid)
}
return toDelete.size
}
/**
* Removes all servers.
* @return The number of removed servers.
@@ -442,26 +618,28 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
return count
}
/**
* Removes invalid servers.
* @return The number of removed servers.
*/
fun removeInvalidServer(): Int {
var count = 0
if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) {
count += MmkvManager.removeInvalidServer("")
} else {
val serversCopy = serversCache.toList()
for (item in serversCopy) {
count += MmkvManager.removeInvalidServer(item.guid)
}
}
return count
}
/**
* Sorts servers by their test results.
*/
/**
* Sorts serversCache in-place by test delay in real time (during a ping test).
* Favorites always come first, then sorted ascending by delay (failed/untested go to bottom).
*/
@Synchronized
fun sortServersCacheInPlace() {
serversCache.sortWith(compareBy(
{ !it.profile.isFavorite },
{
val delay = MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: 0L
when {
delay > 0L -> delay
delay == 0L -> Long.MAX_VALUE - 1 // untested
else -> Long.MAX_VALUE // failed
}
}
))
}
fun sortByTestResults() {
if (subscriptionId.isEmpty()) {
MmkvManager.decodeSubsList().forEach { guid ->
@@ -475,7 +653,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
}
private fun sortByTestResultsForGroup(groupId: String) {
data class ServerDelay(var guid: String, var testDelayMillis: Long, var subId: String)
data class ServerDelay(var guid: String, var testDelayMillis: Long, var subId: String, var isFav: Boolean)
val allSubs = MmkvManager.decodeSubscriptions()
val groupSubs = when (groupId) {
@@ -496,11 +674,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val serverList = MmkvManager.decodeServerList(sub.guid)
serverList.forEach { guid ->
val delay = MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
allServerDelays.add(ServerDelay(guid, if (delay <= 0L) 999999 else delay, sub.guid))
val isFav = MmkvManager.decodeServerConfig(guid)?.isFavorite ?: false
val sortKey = when {
delay > 0L -> delay
delay == 0L -> Long.MAX_VALUE - 1
else -> Long.MAX_VALUE
}
allServerDelays.add(ServerDelay(guid, sortKey, sub.guid, isFav))
}
}
allServerDelays.sortBy { it.testDelayMillis }
allServerDelays.sortWith(compareBy({ !it.isFav }, { it.testDelayMillis }))
val serversBySubId = allServerDelays.groupBy { it.subId }
serversBySubId.forEach { (subId, servers) ->
@@ -514,16 +698,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
* @param subId The subscription ID to sort servers for.
*/
private fun sortByTestResultsForSub(subId: String) {
data class ServerDelay(var guid: String, var testDelayMillis: Long)
data class ServerDelay(var guid: String, var testDelayMillis: Long, var isFav: Boolean)
val serverDelays = mutableListOf<ServerDelay>()
val serverListToSort = MmkvManager.decodeServerList(subId)
serverListToSort.forEach { key ->
val delay = MmkvManager.decodeServerAffiliationInfo(key)?.testDelayMillis ?: 0L
serverDelays.add(ServerDelay(key, if (delay <= 0L) 999999 else delay))
val isFav = MmkvManager.decodeServerConfig(key)?.isFavorite ?: false
val sortKey = when {
delay > 0L -> delay
delay == 0L -> Long.MAX_VALUE - 1
else -> Long.MAX_VALUE
}
serverDelays.add(ServerDelay(key, sortKey, isFav))
}
serverDelays.sortBy { it.testDelayMillis }
serverDelays.sortWith(compareBy({ !it.isFav }, { it.testDelayMillis }))
val sortedServerList = serverDelays.map { it.guid }.toMutableList()
@@ -577,16 +767,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
fun onTestsFinished() {
viewModelScope.launch(Dispatchers.Default) {
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_REMOVE_INVALID_AFTER_TEST)) {
removeInvalidServer()
}
if (MmkvManager.decodeSettingsBool(AppConfig.PREF_AUTO_SORT_AFTER_TEST, true)) {
sortByTestResults()
}
withContext(Dispatchers.Main) {
reloadServerList()
isTesting.value = false
liteTestFinished.value = true
liteTestFinished.value = false
}
@@ -623,7 +810,17 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
AppConfig.MSG_MEASURE_CONFIG_SUCCESS -> {
val resultPair = intent.serializable<Pair<String, Long>>("content") ?: return
MmkvManager.encodeServerTestDelayMillis(resultPair.first, resultPair.second)
updateListAction.value = getPosition(resultPair.first)
sortServersCacheInPlace()
updateListAction.value = -1
}
AppConfig.MSG_MEASURE_CONFIG_BATCH -> {
val update = intent.serializable<PingProgressUpdate>("content") ?: return
update.results.forEach { result ->
MmkvManager.encodeServerTestDelayMillis(result.guid, result.delay)
}
sortServersCacheInPlace()
updateListAction.value = -1
}
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> {
@@ -636,6 +833,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
val content = intent.getStringExtra("content")
if (content == "0") {
onTestsFinished()
} else {
// cancelled or finished with non-zero count still in queue — mark as not testing
isTesting.value = false
}
}
}
@@ -1,8 +1,11 @@
package xyz.zarazaex.olc.viewmodel
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import xyz.zarazaex.olc.dto.SubscriptionCache
import xyz.zarazaex.olc.dto.SubscriptionItem
import xyz.zarazaex.olc.dto.SubscriptionStatus
import xyz.zarazaex.olc.dto.SubscriptionUpdateStatus
import xyz.zarazaex.olc.handler.MmkvManager
import xyz.zarazaex.olc.handler.SettingsChangeManager
import xyz.zarazaex.olc.handler.SettingsManager
@@ -11,11 +14,25 @@ class SubscriptionsViewModel : ViewModel() {
private val subscriptions: MutableList<SubscriptionCache> =
MmkvManager.decodeSubscriptions().toMutableList()
val isUpdating = MutableLiveData<Boolean>(false)
val subscriptionStatuses = MutableLiveData<Map<String, SubscriptionStatus>>(emptyMap())
fun getAll(): List<SubscriptionCache> = subscriptions.toList()
fun reload() {
subscriptions.clear()
subscriptions.addAll(MmkvManager.decodeSubscriptions())
subscriptionStatuses.value = emptyMap()
}
fun updateSubscriptionStatus(guid: String, status: SubscriptionUpdateStatus, configCount: Int = 0) {
val currentStatuses = subscriptionStatuses.value?.toMutableMap() ?: mutableMapOf()
currentStatuses[guid] = SubscriptionStatus(guid, status, configCount)
subscriptionStatuses.postValue(currentStatuses)
}
fun getSubscriptionStatus(guid: String): SubscriptionStatus? {
return subscriptionStatuses.value?.get(guid)
}
fun remove(subId: String): Boolean {
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
>
<path
android:fillColor="#FFFFFFFF"
android:pathData="M320,880L360,600L160,600L520,80L600,80L560,400L800,400L400,880L320,880Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M19,6.41L17.59,5L12,10.59L6.41,5L5,6.41L10.59,12L5,17.59L6.41,19L12,13.41L17.59,19L19,17.59L13.41,12z" />
</vector>
@@ -4,6 +4,6 @@
android:viewportWidth="24.0"
android:viewportHeight="24.0">
<path
android:fillColor="#FF000000"
android:fillColor="@android:color/white"
android:pathData="M16,1L4,1c-1.1,0 -2,0.9 -2,2v14h2L4,3h12L16,1zM19,5L8,5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h11c1.1,0 2,-0.9 2,-2L21,7c0,-1.1 -0.9,-2 -2,-2zM19,21L8,21L8,7h11v14z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zm-6,0C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z" />
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24.0"
android:viewportHeight="24.0"
android:tint="?attr/colorAccent">
<path
android:fillColor="@android:color/white"
android:pathData="M22,9.24l-7.19,-0.62L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21 12,17.27 18.18,21l-1.63,-7.03L22,9.24zM12,15.4l-3.76,2.27 1,-4.28l-3.32,-2.88 4.38,-0.38L12,6.1l1.71,4.04 4.38,0.38 -3.32,2.88 1,4.28L12,15.4z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M305,256L417,111Q429,95 445.5,87.5Q462,80 480,80Q498,80 514.5,87.5Q531,95 543,111L655,256L825,313Q851,321 866,342.5Q881,364 881,390Q881,402 877.5,414Q874,426 866,437L756,593L760,757Q761,792 737,816Q713,840 681,840Q679,840 659,837L480,787L301,837Q296,839 290,839.5Q284,840 279,840Q247,840 223,816Q199,792 200,757L204,592L95,437Q87,426 83.5,414Q80,402 80,390Q80,365 94.5,343.5Q109,322 135,313L305,256Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M305,256L417,111Q429,95 445.5,87.5Q462,80 480,80Q498,80 514.5,87.5Q531,95 543,111L655,256L825,313Q851,321 866,342.5Q881,364 881,390Q881,402 877.5,414Q874,426 866,437L756,593L760,757Q761,792 737,816Q713,840 681,840Q679,840 659,837L480,787L301,837Q296,839 290,839.5Q284,840 279,840Q247,840 223,816Q199,792 200,757L204,592L95,437Q87,426 83.5,414Q80,402 80,390Q80,365 94.5,343.5Q109,322 135,313L305,256Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M305,256L417,111Q429,95 445.5,87.5Q462,80 480,80Q498,80 514.5,87.5Q531,95 543,111L655,256L825,313Q851,321 866,342.5Q881,364 881,390Q881,402 877.5,414Q874,426 866,437L756,593L760,757Q761,792 737,816Q713,840 681,840Q679,840 659,837L480,787L301,837Q296,839 290,839.5Q284,840 279,840Q247,840 223,816Q199,792 200,757L204,592L95,437Q87,426 83.5,414Q80,402 80,390Q80,365 94.5,343.5Q109,322 135,313L305,256ZM354,325L160,389Q160,389 160,389Q160,389 160,389L284,568L280,759Q280,759 280,759Q280,759 280,759L480,704L680,760Q680,760 680,760Q680,760 680,760L676,568L800,391Q800,391 800,391Q800,391 800,391L606,325L480,160Q480,160 480,160Q480,160 480,160L354,325ZM480,460L480,460Q480,460 480,460Q480,460 480,460L480,460L480,460Q480,460 480,460Q480,460 480,460L480,460L480,460Q480,460 480,460Q480,460 480,460L480,460L480,460Q480,460 480,460Q480,460 480,460L480,460L480,460Q480,460 480,460Q480,460 480,460L480,460Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M480,880Q397,880 324,848.5Q251,817 197,763Q143,709 111.5,636Q80,563 80,480Q80,397 111.5,324Q143,251 197,197Q251,143 324,111.5Q397,80 480,80Q563,80 636,111.5Q709,143 763,197Q817,251 848.5,324Q880,397 880,480Q880,563 848.5,636Q817,709 763,763Q709,817 636,848.5Q563,880 480,880ZM440,798L440,720Q407,720 383.5,696.5Q360,673 360,640L360,600L168,408Q165,426 162.5,444Q160,462 160,480Q160,601 239.5,692Q319,783 440,798ZM716,696Q736,674 752,648.5Q768,623 778.5,595.5Q789,568 794.5,539Q800,510 800,480Q800,382 745.5,301Q691,220 600,184L600,200Q600,233 576.5,256.5Q553,280 520,280L440,280L440,360Q440,377 428.5,388.5Q417,400 400,400L320,400L320,480L560,480Q577,480 588.5,491.5Q600,503 600,520L600,640L640,640Q666,640 687,655.5Q708,671 716,696Z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#FFFFFFFF"
android:pathData="M98,423L266,255Q280,241 299,235Q318,229 338,233L390,244Q336,308 305,360Q274,412 245,486L98,423ZM303,514Q326,442 365.5,378Q405,314 461,258Q549,170 662,126.5Q775,83 873,100Q890,198 847,311Q804,424 716,512Q661,567 596,607.5Q531,648 459,671L303,514ZM579,394Q602,417 635.5,417Q669,417 692,394Q715,371 715,337.5Q715,304 692,281Q669,258 635.5,258Q602,258 579,281Q556,304 556,337.5Q556,371 579,394ZM551,875L487,728Q561,699 613.5,668Q666,637 730,583L740,635Q744,655 738,674.5Q732,694 718,708L551,875ZM162,642Q197,607 247,606.5Q297,606 332,641Q367,676 367,726Q367,776 332,811Q307,836 248.5,854Q190,872 87,886Q101,783 119,725Q137,667 162,642Z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12L21,5l-9,-4zM12,11.99h7c-0.53,4.12 -3.28,7.79 -7,8.94L12,12L5,12L5,6.3l7,-3.11v8.8z"/>
</vector>
@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24"
android:tint="?attr/colorControlNormal">
<path
android:fillColor="@android:color/white"
android:pathData="M12,1L3,5v6c0,5.55 3.84,10.74 9,12 5.16,-1.26 9,-6.45 9,-12V5l-9,-4z"/>
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurface"
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -7.99,3.58 -7.99,8s3.57,8 7.99,8c3.73,0 6.84,-2.55 7.73,-6h-2.08c-0.82,2.33 -3.04,4 -5.65,4 -3.31,0 -6,-2.69 -6,-6s2.69,-6 6,-6c1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35z"/>
</vector>
@@ -15,58 +15,58 @@
android:orientation="vertical"
android:padding="@dimen/padding_spacing_dp16">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="12dp"
android:text="@string/split_tunneling_description"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
android:alpha="0.7" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:orientation="horizontal"
android:gravity="center_horizontal">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:id="@+id/container_per_app_proxy"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<LinearLayout
android:id="@+id/container_per_app_proxy"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_per_app_proxy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:text="@string/per_app_proxy_settings_enable"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_per_app_proxy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="2"
android:text="@string/per_app_proxy_settings_enable"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
<LinearLayout
android:id="@+id/container_bypass_apps"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_bypass_apps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/switch_bypass_apps_mode"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
</LinearLayout>
<LinearLayout
android:id="@+id/container_bypass_apps"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_gravity="center"
android:layout_weight="1"
android:gravity="center"
android:orientation="horizontal">
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/switch_bypass_apps"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/switch_bypass_apps_mode"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
@@ -77,4 +77,4 @@
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.GridLayoutManager" />
</LinearLayout>
</LinearLayout>
@@ -1,83 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true">
android:fitsSystemWindows="true"
android:orientation="vertical">
<LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
<LinearLayout
android:id="@+id/layout_check_update"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<LinearLayout
android:layout_width="match_parent"
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_check_update_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_source_code_24dp" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/check_pre_release"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:maxLines="1"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/update_check_pre_release"
android:textAppearance="@style/TextAppearance.AppCompat.Small"
app:theme="@style/BrandedSwitch" />
</LinearLayout>
<LinearLayout
android:id="@+id/layout_check_update"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center|start"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<ImageView
android:layout_width="@dimen/image_size_dp24"
android:layout_height="@dimen/image_size_dp24"
android:importantForAccessibility="no"
app:srcCompat="@drawable/ic_check_update_24dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/update_check_for_update"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:orientation="horizontal"
android:padding="@dimen/padding_spacing_dp16">
<TextView
android:id="@+id/tv_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/title_about"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
android:paddingStart="@dimen/padding_spacing_dp16"
android:text="@string/update_check_for_update"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
</LinearLayout>
</ScrollView>
<!-- Hidden pre-release toggle (kept in layout for binding compatibility) -->
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/check_pre_release"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:visibility="gone" />
<View
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<TextView
android:id="@+id/tv_version"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:padding="@dimen/padding_spacing_dp16"
android:gravity="center"
android:layout_marginBottom="16dp"
android:alpha="0.5"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
@@ -3,22 +3,27 @@
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/drawer_layout"
android:layout_width="match_parent"
android:layout_height="match_parent">
android:layout_height="match_parent"
android:background="@color/md_theme_surface">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:fitsSystemWindows="true"
android:orientation="vertical">
<com.google.android.material.appbar.AppBarLayout
android:id="@+id/app_bar_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content">
android:layout_height="wrap_content"
android:background="@color/md_theme_surface"
app:elevation="0dp">
<androidx.appcompat.widget.Toolbar
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar"
android:layout_width="match_parent"
android:layout_height="?attr/actionBarSize" />
android:layout_height="?attr/actionBarSize"
app:title="@string/app_name"
app:titleCentered="true" />
</com.google.android.material.appbar.AppBarLayout>
@@ -42,17 +47,18 @@
android:layout_height="wrap_content"
android:indeterminate="true"
android:visibility="invisible"
app:indicatorColor="@color/color_fab_active" />
app:indicatorColor="@color/md_theme_primary" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_group"
<LinearLayout
android:id="@+id/tab_slot_top"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabIndicatorFullWidth="true"
app:tabMode="fixed"
app:tabGravity="fill"
app:tabMaxWidth="0dp"
app:tabTextAppearance="@style/TabLayoutTextStyle" />
android:orientation="vertical" />
<View
android:layout_width="match_parent"
android:layout_height="1dp"
android:background="@color/divider_color_light" />
<androidx.viewpager2.widget.ViewPager2
android:id="@+id/view_pager"
@@ -63,6 +69,7 @@
android:layout_weight="1" />
<LinearLayout
android:id="@+id/bottom_container"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
@@ -72,6 +79,25 @@
android:layout_height="1dp"
android:background="@color/divider_color_light" />
<LinearLayout
android:id="@+id/tab_slot_bottom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<com.google.android.material.tabs.TabLayout
android:id="@+id/tab_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:tabIndicatorFullWidth="true"
app:tabMode="auto"
app:tabGravity="fill"
app:tabMaxWidth="0dp"
app:tabRippleColor="@android:color/transparent"
app:tabTextAppearance="@style/TabLayoutTextStyle" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -87,9 +113,10 @@
android:layout_marginEnd="16dp"
android:clickable="true"
android:focusable="true"
android:src="@drawable/ic_lite_bolt"
android:src="@drawable/bolt_24"
app:tint="@color/colorWhite"
app:backgroundTint="@color/color_fab_inactive"
app:rippleColor="#33FFFFFF"
app:fabSize="normal"
app:maxImageSize="28dp"
app:elevation="4dp"
@@ -104,8 +131,9 @@
android:contentDescription="@string/tasker_start_service"
android:focusable="true"
android:nextFocusLeft="@+id/layout_test"
android:src="@drawable/ic_play_24dp"
android:src="@drawable/shield_24"
app:tint="@color/colorWhite"
app:rippleColor="#33FFFFFF"
app:fabSize="normal"
app:maxImageSize="28dp"
app:elevation="4dp"
@@ -150,6 +178,7 @@
</LinearLayout>
<LinearLayout
android:id="@+id/drawer_content_layout"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_gravity="start"
@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="24dp"
android:paddingEnd="8dp"
android:paddingTop="20dp"
android:paddingBottom="12dp">
<TextView
android:id="@+id/dialog_title_text"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_centerVertical="true"
android:layout_toStartOf="@+id/dialog_close_btn"
android:textAppearance="@style/TextAppearance.Material3.HeadlineSmall"
android:textColor="?attr/colorOnSurface" />
<ImageButton
android:id="@+id/dialog_close_btn"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_alignParentEnd="true"
android:layout_centerVertical="true"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Закрыть"
android:src="@drawable/ic_close_24dp"
android:tint="?attr/colorOnSurfaceVariant" />
</RelativeLayout>
@@ -14,30 +14,23 @@
android:layout_height="@dimen/view_height_dp48"
android:padding="@dimen/padding_spacing_dp8" />
<LinearLayout
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/name"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1.0"
android:gravity="center"
android:orientation="vertical">
android:maxLines="1"
android:paddingStart="@dimen/padding_spacing_dp8"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="1"
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
<!-- package_name hidden but kept for adapter compatibility -->
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/package_name"
android:layout_width="0dp"
android:layout_height="0dp"
android:visibility="gone" />
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/package_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:maxLines="3"
android:paddingTop="@dimen/padding_spacing_dp8"
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
</LinearLayout>
<androidx.appcompat.widget.AppCompatCheckBox
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/check_box"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
@@ -45,4 +38,4 @@
android:focusable="false"
android:padding="@dimen/padding_spacing_dp8" />
</LinearLayout>
</LinearLayout>
@@ -99,7 +99,35 @@
</LinearLayout>
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="vertical"
android:gravity="end">
<ImageView
android:id="@+id/iv_favorite"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="@dimen/padding_spacing_dp8"
android:src="@drawable/kid_star_outline_24" />
<ImageView
android:id="@+id/iv_copy"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:focusable="true"
android:padding="@dimen/padding_spacing_dp8"
android:visibility="gone"
app:srcCompat="@drawable/ic_copy"
app:tint="?attr/colorAccent" />
</LinearLayout>
</LinearLayout>
@@ -168,9 +168,26 @@
<TextView
android:id="@+id/tv_last_updated"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"/>
<ProgressBar
android:id="@+id/progress_bar"
style="?android:attr/progressBarStyleSmall"
android:layout_width="16dp"
android:layout_height="16dp"
android:layout_marginStart="@dimen/padding_spacing_dp8"
android:visibility="gone"/>
<TextView
android:id="@+id/tv_update_status"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"/>
android:layout_marginStart="@dimen/padding_spacing_dp8"
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
android:visibility="gone"/>
</LinearLayout>
@@ -19,16 +19,4 @@
android:title="@string/menu_item_invert_selection"
app:showAsAction="withText" />
<item
android:id="@+id/import_proxy_app"
android:icon="@drawable/ic_description_24dp"
android:title="@string/menu_item_import_proxy_app"
app:showAsAction="withText" />
<item
android:id="@+id/export_proxy_app"
android:icon="@drawable/ic_description_24dp"
android:title="@string/menu_item_export_proxy_app"
app:showAsAction="withText" />
</menu>
+13 -1
View File
@@ -1,6 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/search_view"
android:icon="@drawable/ic_search_24dp"
android:title="@string/menu_item_search"
app:actionViewClass="androidx.appcompat.widget.SearchView"
app:showAsAction="always|collapseActionView" />
<item
android:id="@+id/real_ping_all"
android:icon="@drawable/ic_outline_filter_alt_24"
@@ -9,7 +15,13 @@
<item
android:id="@+id/sub_update"
android:icon="@drawable/ic_check_update_24dp"
android:icon="@drawable/update_24"
android:title="@string/title_sub_update"
app:showAsAction="always" />
<item
android:id="@+id/filter_by_country"
android:icon="@drawable/public_24"
android:title="Фильтр по странам"
app:showAsAction="always" />
</menu>
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
<monochrome android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background" />
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 10 KiB

@@ -156,8 +156,6 @@
<string name="title_pref_is_booted">Auto connect at startup</string>
<string name="summary_pref_is_booted">Automatically connects to the selected server at startup, which may be unsuccessful</string>
<string name="title_pref_auto_remove_invalid_after_test">Auto delete invalid config after testing</string>
<string name="summary_pref_auto_remove_invalid_after_test">Test results may not be accurate; deleted config cannot be recovered.</string>
<string name="title_pref_auto_sort_after_test">Auto sort after testing</string>
<string name="summary_pref_auto_sort_after_test">Test results may not be accurate;</string>
@@ -417,4 +415,5 @@
<item>WebDAV</item>
</string-array>
<string name="title_updating">جارٍ التحديث…</string>
</resources>
@@ -154,8 +154,6 @@
<string name="title_pref_is_booted">Auto connect at startup</string>
<string name="summary_pref_is_booted">Automatically connects to the selected server at startup, which may be unsuccessful</string>
<string name="title_pref_auto_remove_invalid_after_test">Auto delete invalid config after testing</string>
<string name="summary_pref_auto_remove_invalid_after_test">Test results may not be accurate; deleted config cannot be recovered.</string>
<string name="title_pref_auto_sort_after_test">Auto sort after testing</string>
<string name="summary_pref_auto_sort_after_test">Test results may not be accurate;</string>
@@ -423,4 +421,5 @@
<item>WebDAV</item>
</string-array>
<string name="title_updating">আপডেট হচ্ছে…</string>
</resources>
@@ -155,8 +155,6 @@
<string name="title_pref_is_booted">منپیز خوتکار مجال ره ونی</string>
<string name="summary_pref_is_booted">مجال ره وندن، خوساخوس و سرور پسند بیڌه منپیز ابۊ که گاشڌ نا مووفق بۊ.</string>
<string name="title_pref_auto_remove_invalid_after_test">پاک کردن خوتکار کانفیگ نا موئتبر بئڌ آزمایش</string>
<string name="summary_pref_auto_remove_invalid_after_test">نتیجه یل آزمایش گاشڌ دییق نبۊن؛ کانفیگ پاک وابیڌه ن نتری وورگنی.</string>
<string name="title_pref_auto_sort_after_test">ترتیب خوتکار بئڌ آزمایش</string>
<string name="summary_pref_auto_sort_after_test">نتیجه یل آزمایش گاشڌ دییق نبۊن؛</string>
@@ -433,4 +431,5 @@
<item>WebDAV</item>
</string-array>
<string name="title_updating">در حال به‌روزرسانی…</string>
</resources>
@@ -152,8 +152,6 @@
<string name="title_pref_is_booted">اتصال خودکار هنگام راه اندازی</string>
<string name="summary_pref_is_booted">هنگام راه اندازی به طور خودکار به سرور انتخابی متصل می شود که ممکن است ناموفق باشد.</string>
<string name="title_pref_auto_remove_invalid_after_test">Auto delete invalid config after testing</string>
<string name="summary_pref_auto_remove_invalid_after_test">Test results may not be accurate; deleted config cannot be recovered.</string>
<string name="title_pref_auto_sort_after_test">Auto sort after testing</string>
<string name="summary_pref_auto_sort_after_test">Test results may not be accurate;</string>
@@ -432,4 +430,5 @@
<item>WebDAV</item>
</string-array>
<string name="title_updating">در حال به‌روزرسانی…</string>
</resources>
@@ -1,26 +1,23 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="color_fab_active">#88AC8A</color>
<color name="color_fab_active">#C0C0C0</color>
<color name="color_fab_inactive">#646464</color>
<color name="divider_color_light">#424242</color>
<!-- Primary colors - main tone: gray -->
<color name="md_theme_primary">#C0C0C0</color>
<color name="md_theme_onPrimary">#303030</color>
<color name="md_theme_primaryContainer">#474747</color>
<color name="md_theme_onPrimaryContainer">#E0E0E0</color>
<!-- Secondary colors - accent color: green -->
<color name="md_theme_secondary">#88AC8A</color>
<color name="md_theme_secondary">#90CAF9</color>
<color name="md_theme_onSecondary">#FFFFFF</color>
<color name="md_theme_secondaryContainer">#6F3800</color>
<color name="md_theme_onSecondaryContainer">#FFE8D6</color>
<!-- Tertiary colors - tertiary color: green -->
<color name="md_theme_tertiary">#83D6B5</color>
<color name="md_theme_tertiary">#64B5F6</color>
<color name="md_theme_onTertiary">#00382E</color>
<color name="md_theme_tertiaryContainer">#005143</color>
<color name="md_theme_onTertiaryContainer">#A0F2D0</color>
<color name="md_theme_onTertiaryContainer">#BBDEFB</color>
<!-- Error colors -->
<color name="md_theme_error">#FFB4AB</color>
@@ -156,8 +156,6 @@
<string name="title_pref_is_booted">Автоподключение при запуске</string>
<string name="summary_pref_is_booted">Автоматически подключаться к выбранному серверу при запуске приложения (может оказаться неудачным)</string>
<string name="title_pref_auto_remove_invalid_after_test">Автоудаление нерабочих профилей</string>
<string name="summary_pref_auto_remove_invalid_after_test">Автоматическое удаление нерабочих профилей после проверки (результаты проверки могут быть неточными; восстановить удалённые профили невозможно)</string>
<string name="title_pref_auto_sort_after_test">Автосортировка профилей</string>
<string name="summary_pref_auto_sort_after_test">Автоматическая сортировка профилей после проверки (результаты проверки могут быть неточными)</string>
@@ -238,6 +236,9 @@
<string name="title_pref_group_all_display">Общая вкладка групп</string>
<string name="summary_pref_group_all_display">Показывать дополнительную вкладку со всеми профилями групп</string>
<string name="title_pref_show_copy_button">Кнопка копирования</string>
<string name="summary_pref_show_copy_button">Показывать кнопку копирования конфигурации сервера в буфер обмена</string>
<!-- AboutActivity -->
<string name="title_pref_feedback">Обратная связь</string>
<string name="summary_pref_feedback">Предложить улучшение или сообщить об ошибке на GitHub</string>
@@ -262,6 +263,11 @@
<string name="title_language">Язык</string>
<string name="title_ui_settings">Настройки интерфейса</string>
<string name="title_pref_ui_mode_night">Тема интерфейса</string>
<string name="title_pref_dynamic_colors">Динамические цвета (Material You)</string>
<string name="summary_pref_dynamic_colors">Использовать цвета обоев (Android 12+). Требует перезапуска.</string>
<string name="restart_required">Требуется перезапуск приложения</string>
<string name="title_pref_subscriptions_bottom">Подписки снизу</string>
<string name="summary_pref_subscriptions_bottom">Переместить вкладки подписок под список серверов</string>
<string name="title_pref_use_hev_tunnel">Использовать Hev TUN</string>
<string name="summary_pref_use_hev_tunnel">Если включено, TUN будет использовать hev-socks5-tunnel; иначе будет использован xray-core</string>
<string name="title_pref_hev_tunnel_loglevel">Подробность журнала HevTun</string>
@@ -298,6 +304,7 @@
<string name="title_import_config_count">Импортировано профилей: %d</string>
<string name="title_export_config_count">Экспортировано профилей: %d</string>
<string name="title_update_config_count">Обновлено профилей: %d</string>
<string name="title_updating">Обновление…</string>
<string name="title_update_subscription_result">Обновлено профилей: %1$d (успешно: %2$d, ошибок: %3$d, пропущено: %4$d)</string>
<string name="title_update_subscription_no_subscription">Нет подписок</string>
<string name="toast_server_not_found_in_group">Выбранный профиль не найден в текущей группе</string>
@@ -153,8 +153,6 @@
<string name="title_pref_is_booted">Auto connect at startup</string>
<string name="summary_pref_is_booted">Automatically connects to the selected server at startup, which may be unsuccessful</string>
<string name="title_pref_auto_remove_invalid_after_test">Auto delete invalid config after testing</string>
<string name="summary_pref_auto_remove_invalid_after_test">Test results may not be accurate; deleted config cannot be recovered.</string>
<string name="title_pref_auto_sort_after_test">Auto sort after testing</string>
<string name="summary_pref_auto_sort_after_test">Test results may not be accurate;</string>
@@ -419,4 +417,5 @@
<item>WebDAV</item>
</string-array>
<string name="title_updating">Đang cập nhật…</string>
</resources>
@@ -152,8 +152,6 @@
<string name="title_pref_is_booted">开机时自动连接</string>
<string name="summary_pref_is_booted">开机时自动连接选择的服务器,可能会不成功</string>
<string name="title_pref_auto_remove_invalid_after_test">测试后自动删除无效配置</string>
<string name="summary_pref_auto_remove_invalid_after_test">测试结果可能不准确;已删除的配置无法恢复。</string>
<string name="title_pref_auto_sort_after_test">测试后自动排序</string>
<string name="summary_pref_auto_sort_after_test">测试结果可能不准确;</string>
@@ -425,4 +423,5 @@
<item>WebDAV</item>
</string-array>
<string name="title_updating">更新中…</string>
</resources>
@@ -153,8 +153,6 @@
<string name="title_pref_is_booted">開機時自動連線</string>
<string name="summary_pref_is_booted">開機時自動連線選擇的伺服器,可能會不成功</string>
<string name="title_pref_auto_remove_invalid_after_test">測試後自動刪除無效配置</string>
<string name="summary_pref_auto_remove_invalid_after_test">測試結果可能不準確;已刪除的配置無法復原。 </string>
<string name="title_pref_auto_sort_after_test">測試後自動排序</string>
<string name="summary_pref_auto_sort_after_test">測試結果可能不準確;</string>
@@ -425,4 +423,5 @@
<item>WebDAV</item>
</string-array>
<string name="title_updating">更新中…</string>
</resources>
+6 -9
View File
@@ -1,30 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="colorPing">#009966</color>
<color name="colorPing">#1565C0</color>
<color name="colorPingRed">#FF0099</color>
<color name="colorConfigType">#88AC8A</color>
<color name="colorConfigType">#1976D2</color>
<color name="colorWhite">#FFFFFF</color>
<color name="color_fab_active">#88AC8A</color>
<color name="color_fab_active">#000000</color>
<color name="color_fab_inactive">#9C9C9C</color>
<color name="divider_color_light">#E0E0E0</color>
<color name="colorIndicator">@color/md_theme_primary</color>
<!-- Primary colors - main tone: black -->
<color name="md_theme_primary">#000000</color>
<color name="md_theme_onPrimary">#FFFFFF</color>
<color name="md_theme_primaryContainer">#E0E0E0</color>
<color name="md_theme_onPrimaryContainer">#000000</color>
<!-- Secondary colors - accent color: green -->
<color name="md_theme_secondary">#88AC8A</color>
<color name="md_theme_secondary">#1976D2</color>
<color name="md_theme_onSecondary">#FFFFFF</color>
<color name="md_theme_secondaryContainer">#FFE8D6</color>
<color name="md_theme_onSecondaryContainer">#2B1700</color>
<!-- Tertiary colors - accent color: green -->
<color name="md_theme_tertiary">#009966</color>
<color name="md_theme_tertiary">#1565C0</color>
<color name="md_theme_onTertiary">#FFFFFF</color>
<color name="md_theme_tertiaryContainer">#A0F2D0</color>
<color name="md_theme_tertiaryContainer">#BBDEFB</color>
<color name="md_theme_onTertiaryContainer">#00201A</color>
<!-- Error colors -->
@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#FFFFFF</color>
</resources>
<color name="ic_launcher_background">#1C1C1E</color>
</resources>
+19 -11
View File
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name" translatable="false">olcNG</string>
<string name="app_name" translatable="false">olcng</string>
<string name="app_widget_name">Switch</string>
<string name="app_tile_name">Switch</string>
<string name="app_tile_first_use">First use of this feature, please use the app to add server</string>
<string name="navigation_drawer_open">Open navigation drawer</string>
<string name="navigation_drawer_close">Close navigation drawer</string>
<string name="migration_success">Data migration success!</string>
<string name="drawer_forked_text">forked from <a href="https://github.com/2dust/v2rayng">V2RayNG</a></string>
<string name="drawer_developed_text">developed by developers from <a href="https://t.me/openlibrecommunity">Olc</a></string>
<string name="drawer_forked_text" translatable="false">forked from <a href="https://github.com/2dust/v2rayng">V2RayNG</a></string>
<string name="drawer_developed_text" translatable="false">developed by developers from <a href="https://t.me/openlibrecommunity">Olc</a></string>
<string name="action_stop_service">Stop service</string>
<string name="migration_fail">Data migration failed!</string>
<string name="pull_down_to_refresh">Please pull down to refresh!</string>
@@ -144,8 +144,9 @@
<string name="msg_downloading_content">Downloading content</string>
<string name="menu_item_export_proxy_app">Export to Clipboard</string>
<string name="menu_item_import_proxy_app">Import from Clipboard</string>
<string name="per_app_proxy_settings">Per-app settings</string>
<string name="per_app_proxy_settings_enable">Enable per-app</string>
<string name="per_app_proxy_settings">Раздельное туннелирование</string>
<string name="per_app_proxy_settings_enable">Включить</string>
<string name="split_tunneling_description">Выберите приложения, которые будут использовать VPN. В режиме обхода — выбранные приложения НЕ используют VPN.</string>
<!-- Preferences -->
<string name="title_settings">Settings</string>
@@ -157,11 +158,12 @@
<string name="title_pref_is_booted">Auto connect at startup</string>
<string name="summary_pref_is_booted">Automatically connects to the selected server at startup, which may be unsuccessful</string>
<string name="title_pref_auto_remove_invalid_after_test">Auto delete invalid config after testing</string>
<string name="summary_pref_auto_remove_invalid_after_test">Test results may not be accurate; deleted config cannot be recovered.</string>
<string name="title_pref_auto_sort_after_test">Auto sort after testing</string>
<string name="summary_pref_auto_sort_after_test">Test results may not be accurate;</string>
<string name="title_pref_show_copy_button">Show copy button</string>
<string name="summary_pref_show_copy_button">Show button to copy server configuration to clipboard</string>
<string name="title_mux_settings">Mux Settings</string>
<string name="title_pref_mux_enabled">Enable Mux</string>
<string name="summary_pref_mux_enabled">Faster, but it may cause unstable connectivity\nCustomize how to handle TCP, UDP and QUIC below</string>
@@ -249,7 +251,7 @@
<string name="summary_pref_tg_group">Join Telegram Group</string>
<string name="toast_tg_app_not_found">Telegram app not found</string>
<string name="title_privacy_policy">Privacy policy</string>
<string name="title_qr_code">QR code</string>
<string name="title_qr_code" translatable="false">QR code</string>
<string name="title_about">About</string>
<string name="title_source_code">Source code</string>
<string name="title_oss_license">Open Source licenses</string>
@@ -268,11 +270,16 @@
<string name="title_language">Language</string>
<string name="title_ui_settings">UI settings</string>
<string name="title_pref_ui_mode_night">UI mode settings</string>
<string name="title_pref_dynamic_colors">Dynamic colors (Material You)</string>
<string name="summary_pref_dynamic_colors">Use wallpaper-based colors (Android 12+). Requires app restart.</string>
<string name="title_pref_subscriptions_bottom">Subscriptions panel at the bottom</string>
<string name="summary_pref_subscriptions_bottom">Move the subscription tabs below the server list</string>
<string name="title_pref_use_hev_tunnel">Enable Hev TUN Feature</string>
<string name="summary_pref_use_hev_tunnel">When enabled, TUN will use hev-socks5-tunnel; otherwise, it will use xray-core.</string>
<string name="title_pref_hev_tunnel_loglevel">Hev Tun Log Level</string>
<string name="title_pref_hev_tunnel_rw_timeout">Hev Tun read/write timeout (seconds) (tcp,udp default 300,60)</string>
<string name="restart_required">Restart required to apply changes</string>
<string name="title_logcat">Logcat</string>
<string name="logcat_copy">Copy</string>
<string name="logcat_clear">Clear</string>
@@ -304,6 +311,7 @@
<string name="title_import_config_count">Import %d configs</string>
<string name="title_export_config_count">Export %d configs</string>
<string name="title_update_config_count">Update %d configs</string>
<string name="title_updating">Updating…</string>
<string name="title_update_subscription_result">Updated %1$d configs (%2$d success, %3$d failed, %4$d skipped)</string>
<string name="title_update_subscription_no_subscription">No subscriptions</string>
<string name="toast_server_not_found_in_group">Selected server not found in current group</string>
@@ -338,14 +346,14 @@
<string name="connection_test_pending">Check Connectivity</string>
<string name="connection_test_testing">Testing…</string>
<string name="connection_test_testing_count">Testing %d configs</string>
<string name="connection_test_testing_count">Тестирование %d серверов</string>
<string name="connection_test_available">Success: Connection took %dms</string>
<string name="connection_test_error">Fail to detect internet connection: %s</string>
<string name="connection_test_fail">Internet Unavailable</string>
<string name="connection_test_error_status_code">Error code: #%d</string>
<string name="connection_connected">Connected, tap to check connection</string>
<string name="connection_not_connected">Not connected</string>
<string name="connection_runing_task_left">Number of running test tasks: %s</string>
<string name="connection_not_connected">Готово к подключению</string>
<string name="connection_runing_task_left">Проверено успешно: %s</string>
<string name="import_subscription_success">Subscription imported Successfully</string>
<string name="import_subscription_failure">Import subscription failed</string>
+1 -3
View File
@@ -8,13 +8,11 @@
<item name="colorPrimaryContainer">@color/md_theme_primaryContainer</item>
<item name="colorOnPrimaryContainer">@color/md_theme_onPrimaryContainer</item>
<!-- Secondary colors - accent color: orange -->
<item name="colorSecondary">@color/md_theme_secondary</item>
<item name="colorOnSecondary">@color/md_theme_onSecondary</item>
<item name="colorSecondaryContainer">@color/md_theme_secondaryContainer</item>
<item name="colorOnSecondaryContainer">@color/md_theme_onSecondaryContainer</item>
<!-- Tertiary colors - tertiary color: green -->
<item name="colorTertiary">@color/md_theme_tertiary</item>
<item name="colorOnTertiary">@color/md_theme_onTertiary</item>
<item name="colorTertiaryContainer">@color/md_theme_tertiaryContainer</item>
@@ -51,7 +49,7 @@
<item name="colorPrimaryInverse">@color/md_theme_inversePrimary</item>
<!-- Status bar and navigation bar - system bars -->
<item name="android:statusBarColor">@color/md_theme_surface</item>
<item name="android:statusBarColor">@android:color/transparent</item>
<item name="android:navigationBarColor">@color/md_theme_surface</item>
<!-- Typography: use Roboto (Google's Material font) everywhere -->
+18 -6
View File
@@ -28,6 +28,12 @@
android:summary="@string/summary_pref_group_all_display"
android:title="@string/title_pref_group_all_display" />
<CheckBoxPreference
android:key="pref_show_copy_button"
android:defaultValue="false"
android:summary="@string/summary_pref_show_copy_button"
android:title="@string/title_pref_show_copy_button" />
<ListPreference
android:defaultValue="auto"
android:entries="@array/language_select"
@@ -44,6 +50,18 @@
android:summary="%s"
android:title="@string/title_pref_ui_mode_night" />
<CheckBoxPreference
android:defaultValue="false"
android:key="pref_subscriptions_bottom"
android:summary="@string/summary_pref_subscriptions_bottom"
android:title="@string/title_pref_subscriptions_bottom" />
<CheckBoxPreference
android:defaultValue="false"
android:key="pref_dynamic_colors"
android:summary="@string/summary_pref_dynamic_colors"
android:title="@string/title_pref_dynamic_colors" />
</PreferenceCategory>
<PreferenceCategory android:title="@string/title_vpn_settings">
@@ -266,12 +284,6 @@
android:summary="@string/summary_pref_is_booted"
android:title="@string/title_pref_is_booted" />
<CheckBoxPreference
android:key="pref_auto_remove_invalid_after_test"
android:defaultValue="false"
android:summary="@string/summary_pref_auto_remove_invalid_after_test"
android:title="@string/title_pref_auto_remove_invalid_after_test" />
<CheckBoxPreference
android:key="pref_auto_sort_after_test"
android:defaultValue="false"
-8
View File
@@ -2,12 +2,4 @@
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.kotlin.android) apply false
}
buildscript {
dependencies {
classpath(libs.gradle.license.plugin)
}
}
+1 -14
View File
@@ -15,20 +15,7 @@ org.gradle.jvmargs=-Xmx4096m -Dfile.encoding=UTF-8
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
kotlin.incremental=true
android.defaults.buildfeatures.resvalues=true
android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
android.enableAppCompileTimeRClass=false
android.usesSdkInManifest.disallowed=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.r8.optimizedResourceShrinking=false
android.builtInKotlin=false
android.newDsl=false
android.generateSyncIssueWhenLibraryConstraintsAreEnabled=false
+2 -1
View File
@@ -2,7 +2,7 @@
agp = "9.1.0"
desugarJdkLibs = "2.1.5"
gradleLicensePlugin = "0.9.8"
kotlin = "2.3.10"
kotlin = "2.1.0"
coreKtx = "1.17.0"
junit = "4.13.2"
junitVersion = "1.3.0"
@@ -66,6 +66,7 @@ recyclerview = { module = "androidx.recyclerview:recyclerview", version.ref = "r
preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtx" }
androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" }
androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
+152
View File
@@ -0,0 +1,152 @@
import com.google.gson.Gson
import java.io.File
import java.io.RandomAccessFile
import java.net.URL
import java.util.UUID
data class SubscriptionItem(
var remarks: String = "",
var url: String = "",
var enabled: Boolean = true,
val addedTime: Long = System.currentTimeMillis(),
var lastUpdated: Long = -1,
var autoUpdate: Boolean = false,
val updateInterval: Int? = null,
var prevProfile: String? = null,
var nextProfile: String? = null,
var filter: String? = null,
var allowInsecureUrl: Boolean = false,
var userAgent: String? = null
)
class MMKVWriter(private val filePath: String) {
private val gson = Gson()
fun writeEntry(key: String, json: String) {
val file = RandomAccessFile(filePath, "rw")
file.seek(file.length())
val keyBytes = key.toByteArray(Charsets.UTF_8)
val jsonBytes = json.toByteArray(Charsets.UTF_8)
file.writeInt(keyBytes.size)
file.write(keyBytes)
file.writeInt(jsonBytes.size)
file.write(jsonBytes)
file.close()
}
}
class SubscriptionManager(private val mmkvPath: String) {
private val gson = Gson()
private val writer = MMKVWriter(mmkvPath)
fun addSubscription(remarks: String, url: String, autoUpdate: Boolean = true): String {
val guid = UUID.randomUUID().toString().replace("-", "")
val subItem = SubscriptionItem(
remarks = remarks,
url = url,
enabled = true,
addedTime = System.currentTimeMillis(),
lastUpdated = -1,
autoUpdate = autoUpdate,
filter = "",
allowInsecureUrl = false,
userAgent = ""
)
val json = gson.toJson(subItem)
writer.writeEntry(guid, json)
return guid
}
fun updateSubscription(guid: String, url: String): Boolean {
return try {
println("Обновление подписки $guid...")
val content = URL(url).readText()
val lines = content.lines().filter { it.isNotBlank() }
println("Загружено ${lines.size} конфигураций")
val subItem = readSubscription(guid) ?: return false
subItem.lastUpdated = System.currentTimeMillis()
val json = gson.toJson(subItem)
writer.writeEntry(guid, json)
true
} catch (e: Exception) {
println("Ошибка обновления: ${e.message}")
false
}
}
private fun readSubscription(guid: String): SubscriptionItem? {
val file = File(mmkvPath)
if (!file.exists()) return null
val data = file.readBytes()
var pos = 0
while (pos < data.size) {
if (pos + 4 > data.size) break
val keyLen = java.nio.ByteBuffer.wrap(data, pos, 4).int
pos += 4
if (pos + keyLen > data.size) break
val key = String(data, pos, keyLen, Charsets.UTF_8)
pos += keyLen
if (pos + 4 > data.size) break
val jsonLen = java.nio.ByteBuffer.wrap(data, pos, 4).int
pos += 4
if (pos + jsonLen > data.size) break
val json = String(data, pos, jsonLen, Charsets.UTF_8)
pos += jsonLen
if (key == guid) {
return gson.fromJson(json, SubscriptionItem::class.java)
}
}
return null
}
}
fun main() {
val mmkvPath = "/home/zarazaex/Projects/olcng/V2rayNG/app/src/main/assets/mmkv/SUB"
val manager = SubscriptionManager(mmkvPath)
val guid1 = manager.addSubscription(
remarks = "БЕЛЫЕ Z",
url = "https://raw.githubusercontent.com/zieng2/wl/refs/heads/main/vless_universal.txt",
autoUpdate = true
)
println("Добавлена подписка БЕЛЫЕ Z: $guid1")
val guid2 = manager.addSubscription(
remarks = "БЕЛЫЕ W",
url = "https://raw.githubusercontent.com/whoahaow/rjsxrd/refs/heads/main/githubmirror/bypass/bypass-all.txt",
autoUpdate = true
)
println("Добавлена подписка БЕЛЫЕ W: $guid2")
val guid3 = manager.addSubscription(
remarks = "KEY",
url = "https://key.zarazaex.xyz/sub",
autoUpdate = true
)
println("Добавлена подписка KEY: $guid3")
println("\nОбновление подписок...")
manager.updateSubscription(guid1, "https://raw.githubusercontent.com/zieng2/wl/refs/heads/main/vless_universal.txt")
manager.updateSubscription(guid2, "https://raw.githubusercontent.com/whoahaow/rjsxrd/refs/heads/main/githubmirror/bypass/bypass-all.txt")
manager.updateSubscription(guid3, "https://key.zarazaex.xyz/sub")
println("\nПодписки успешно добавлены и обновлены в $mmkvPath")
}
BIN
View File
Binary file not shown.