Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fa5dd4bca | |||
| b2f3415421 | |||
| 6e1c774d86 | |||
| 8e9e709d12 | |||
| 649f305a82 | |||
| 44005dffd3 | |||
| bee7002f54 | |||
| 0363ebaabd | |||
| 88627bbf4f | |||
| ceec94e5db | |||
| b30fc13b0d | |||
| a9f5844b84 | |||
| 518edd096b | |||
| 450542d3b8 | |||
| b775851960 | |||
| 4c52c1e45c | |||
| e55ad93c52 | |||
| db931be24e | |||
| 1f1d110a82 | |||
| 2912d17aca | |||
| 261bd389f6 | |||
| 2c9eb2d8af | |||
| 35a396ea0a | |||
| 0fa72f5ee1 | |||
| bea7c8a0d3 | |||
| 45779f0bce | |||
| 642bc7a437 | |||
| 74f6762cf6 | |||
| 8aa17ae4bd | |||
| 9f5f3580e0 | |||
| 5123d996f4 | |||
| cb1ea3c3a3 | |||
| 9249ff9bce | |||
| b8cbcac477 | |||
| a4c4d764a0 | |||
| b6122866d5 | |||
| b87a0e8a6e | |||
| 3ccd7493a2 | |||
| 60ce213f67 | |||
| d0161826e2 | |||
| 641fcb943d | |||
| 1fbb4f2bd3 | |||
| d3a5a1af9c | |||
| 33971a576a | |||
| a04c53a045 | |||
| 278095015b | |||
| 3051309138 | |||
| 48373cfa0d | |||
| fb00560c72 | |||
| f81fb78702 | |||
| 340112272e | |||
| e220c380e2 | |||
| 611ac81228 | |||
| db511c9ab1 | |||
| 2623b3110b |
@@ -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
|
||||
|
||||
@@ -65,3 +65,4 @@ Thumbs.db
|
||||
.DS_Store
|
||||
add_subscription_mmkv.py
|
||||
.gitignore
|
||||
material-design-icons
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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 }}\"
|
||||
}
|
||||
}"
|
||||
@@ -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.
|
||||
@@ -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' ./`
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<item name="app_name" type="string">olcNG</item>
|
||||
<item name="app_name" type="string">olcng</item>
|
||||
</resources>
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">olcNG</string>
|
||||
<string name="app_name" translatable="false">olcng</string>
|
||||
</resources>
|
||||
|
||||
@@ -211,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"
|
||||
|
||||
@@ -2,11 +2,13 @@ package xyz.zarazaex.olc
|
||||
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
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 : Application() {
|
||||
@@ -32,8 +34,6 @@ class AngApplication : Application() {
|
||||
*/
|
||||
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 : Application() {
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -73,6 +73,10 @@ object AppConfig {
|
||||
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_SORT_AFTER_TEST = "pref_auto_sort_after_test"
|
||||
const val PREF_SHOW_COPY_BUTTON = "pref_show_copy_button"
|
||||
const val PREF_SHOW_SERVER_IP = "pref_show_server_ip"
|
||||
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"
|
||||
@@ -84,6 +88,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"
|
||||
@@ -117,8 +123,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"
|
||||
|
||||
@@ -162,6 +168,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
|
||||
}
|
||||
}
|
||||
@@ -3,8 +3,8 @@ package xyz.zarazaex.olc.dto
|
||||
data class ServerAffiliationInfo(var testDelayMillis: Long = 0L) {
|
||||
fun getTestDelayString(): String {
|
||||
return when {
|
||||
testDelayMillis == 0L -> ""
|
||||
testDelayMillis < 0L -> "Error"
|
||||
testDelayMillis == 0L -> "—ms"
|
||||
testDelayMillis < 0L -> "-ms"
|
||||
else -> "${testDelayMillis}ms"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,5 +2,7 @@ package xyz.zarazaex.olc.dto
|
||||
|
||||
data class ServersCache(
|
||||
val guid: String,
|
||||
val profile: ProfileItem
|
||||
val profile: ProfileItem,
|
||||
val testDelayMillis: Long = 0L,
|
||||
val isSelected: Boolean = false
|
||||
)
|
||||
@@ -26,6 +26,10 @@ 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 {
|
||||
|
||||
@@ -241,8 +245,8 @@ object AngConfigManager {
|
||||
|
||||
val subItem = MmkvManager.decodeSubscription(subid)
|
||||
|
||||
val oldPingData = if (!append) {
|
||||
saveOldPingData(subid)
|
||||
val oldServerData = if (!append) {
|
||||
saveOldServerData(subid)
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
@@ -263,7 +267,7 @@ object AngConfigManager {
|
||||
MmkvManager.removeServerViaSubid(subid)
|
||||
}
|
||||
val keyToProfile = batchSaveConfigs(configs, subid, append)
|
||||
restoreOldPingData(keyToProfile, oldPingData)
|
||||
restoreOldServerData(keyToProfile, oldServerData)
|
||||
val matchKey = findMatchedProfileKey(keyToProfile, removedSelected)
|
||||
matchKey?.let { MmkvManager.setSelectServer(it) }
|
||||
}
|
||||
@@ -308,6 +312,7 @@ object AngConfigManager {
|
||||
}?.key
|
||||
|
||||
if (existingKey != null) {
|
||||
config.isFavorite = existingProfiles[existingKey]?.isFavorite ?: false
|
||||
MmkvManager.encodeProfileDirect(existingKey, JsonUtil.toJson(config))
|
||||
keyToProfile[existingKey] = config
|
||||
} else {
|
||||
@@ -329,31 +334,39 @@ object AngConfigManager {
|
||||
return keyToProfile
|
||||
}
|
||||
|
||||
private fun saveOldPingData(subid: String): Map<ProfileItem, Long> {
|
||||
val pingData = mutableMapOf<ProfileItem, Long>()
|
||||
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)
|
||||
val aff = MmkvManager.decodeServerAffiliationInfo(guid)
|
||||
if (profile != null && aff != null && aff.testDelayMillis > 0) {
|
||||
pingData[profile] = aff.testDelayMillis
|
||||
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 pingData
|
||||
return serverData
|
||||
}
|
||||
|
||||
private fun restoreOldPingData(keyToProfile: Map<String, ProfileItem>, oldPingData: Map<ProfileItem, Long>) {
|
||||
if (oldPingData.isEmpty()) return
|
||||
private fun restoreOldServerData(keyToProfile: Map<String, ProfileItem>, oldServerData: Map<ProfileItem, Pair<Long, Boolean>>) {
|
||||
if (oldServerData.isEmpty()) return
|
||||
|
||||
keyToProfile.forEach { (key, newProfile) ->
|
||||
val oldPing = oldPingData.entries.firstOrNull { (oldProfile, _) ->
|
||||
oldProfile == newProfile
|
||||
}?.value
|
||||
val oldData = oldServerData[newProfile]
|
||||
|
||||
if (oldPing != null && oldPing > 0) {
|
||||
MmkvManager.encodeServerTestDelayMillis(key, oldPing)
|
||||
if (oldData != null) {
|
||||
val (oldPing, isFavorite) = oldData
|
||||
if (oldPing > 0) {
|
||||
MmkvManager.encodeServerTestDelayMillis(key, oldPing)
|
||||
}
|
||||
if (isFavorite) {
|
||||
newProfile.isFavorite = true
|
||||
MmkvManager.encodeServerConfig(key, newProfile)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -553,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()
|
||||
@@ -595,18 +612,19 @@ object AngConfigManager {
|
||||
Log.i(AppConfig.TAG, url)
|
||||
val userAgent = it.subscription.userAgent
|
||||
|
||||
val timeout = if (url.startsWith("https://key.zarazaex.xyz/sub")) 3000 else 15000
|
||||
val proxyTimeout = if (url.startsWith("https://key.zarazaex.xyz/sub")) 3000 else 5000
|
||||
val directTimeout = if (url.startsWith("https://key.zarazaex.xyz/sub")) 3000 else 11000
|
||||
|
||||
var configText = try {
|
||||
val httpPort = SettingsManager.getHttpPort()
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent, timeout, httpPort)
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent, proxyTimeout, 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, timeout)
|
||||
HttpUtil.getUrlContentWithUserAgent(url, userAgent, directTimeout)
|
||||
} 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
|
||||
|
||||
@@ -689,4 +691,29 @@ 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 EXCLUDE, empty = show all). */
|
||||
fun getCountryFilter(): Set<String> {
|
||||
// v2: semantics changed from "included" to "excluded" — reset old data on first read
|
||||
if (!settingsStorage.decodeBool("pref_country_filter_v2_migrated", false)) {
|
||||
settingsStorage.removeValueForKey("pref_country_filter")
|
||||
settingsStorage.encode("pref_country_filter_v2_migrated", true)
|
||||
}
|
||||
return 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()
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
@@ -32,6 +32,7 @@ object V2RayServiceManager {
|
||||
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) {
|
||||
@@ -49,6 +50,7 @@ object V2RayServiceManager {
|
||||
context.toast(R.string.app_tile_first_use)
|
||||
return false
|
||||
}
|
||||
isIntentionalStop = false
|
||||
startContextService(context)
|
||||
return true
|
||||
}
|
||||
@@ -74,6 +76,7 @@ object V2RayServiceManager {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
}
|
||||
|
||||
isIntentionalStop = false
|
||||
startContextService(context)
|
||||
} finally {
|
||||
synchronized(operationLock) {
|
||||
@@ -87,8 +90,16 @@ object V2RayServiceManager {
|
||||
* @param context The context from which the service is stopped.
|
||||
*/
|
||||
fun stopVService(context: Context) {
|
||||
Log.i(AppConfig.TAG, "StartCore-Manager: stopVService called, serviceControl=${serviceControl?.get()}")
|
||||
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)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -319,7 +330,7 @@ object V2RayServiceManager {
|
||||
// Only fetch IP info if the delay test was successful
|
||||
if (time >= 0) {
|
||||
SpeedtestManager.getRemoteIPInfo()?.let { ip ->
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result\n$ip")
|
||||
MessageUtil.sendMsg2UI(service, AppConfig.MSG_MEASURE_DELAY_SUCCESS, "$result $ip")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -356,6 +367,10 @@ object V2RayServiceManager {
|
||||
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
|
||||
|
||||
@@ -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,101 +1,152 @@
|
||||
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.MmkvManager
|
||||
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 kotlinx.coroutines.withTimeout
|
||||
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.
|
||||
// Keep the currently selected server at the front so it gets a result first.
|
||||
val selectedGuid = MmkvManager.getSelectServer()
|
||||
val shuffledGuids = if (selectedGuid != null && guids.contains(selectedGuid)) {
|
||||
val rest = guids.filter { it != selectedGuid }.shuffled()
|
||||
listOf(selectedGuid) + rest
|
||||
} else {
|
||||
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 suspend fun startRealPing(guid: String): Long {
|
||||
val configResult = V2rayConfigManager.getV2rayConfig4Speedtest(context, guid)
|
||||
if (!configResult.status) return -1L
|
||||
|
||||
val urls = listOf(
|
||||
SettingsManager.getDelayTestUrl(),
|
||||
SettingsManager.getDelayTestUrl(true)
|
||||
)
|
||||
|
||||
for (url in urls) {
|
||||
try {
|
||||
val delay = withTimeout(10000L) {
|
||||
V2RayNativeManager.measureOutboundDelay(configResult.content, url)
|
||||
}
|
||||
if (delay > 0) return delay
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
return -1L
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -99,18 +99,16 @@ class V2RayVpnService : VpnService(), ServiceControl {
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
Log.i(AppConfig.TAG, "StartCore-VPN: Service destroyed")
|
||||
stopAllService(false)
|
||||
wakeLock?.let { if (it.isHeld) it.release() }
|
||||
wakeLock = null
|
||||
NotificationManager.cancelNotification()
|
||||
MessageUtil.sendMsg2UI(this, AppConfig.MSG_STATE_STOP_SUCCESS, "")
|
||||
}
|
||||
|
||||
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,10 @@ abstract class BaseActivity : AppCompatActivity() {
|
||||
setSupportActionBar(it)
|
||||
supportActionBar?.setDisplayHomeAsUpEnabled(showHomeAsUp)
|
||||
title?.let { t -> this.title = t }
|
||||
val typedValue = TypedValue()
|
||||
theme.resolveAttribute(com.google.android.material.R.attr.colorOnSurface, typedValue, true)
|
||||
it.setTitleTextColor(typedValue.data)
|
||||
syncStatusBarWithToolbar(it)
|
||||
}
|
||||
progressBar = findViewById(R.id.progress_bar)
|
||||
}
|
||||
@@ -178,9 +184,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +64,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
} else {
|
||||
binding.recyclerView.layoutManager = GridLayoutManager(requireContext(), 1)
|
||||
}
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, R.drawable.custom_divider)
|
||||
addCustomDividerToRecyclerView(binding.recyclerView, R.drawable.server_list_divider)
|
||||
binding.recyclerView.adapter = adapter
|
||||
|
||||
itemTouchHelper = ItemTouchHelper(SimpleItemTouchHelperCallback(adapter, allowSwipe = false))
|
||||
@@ -75,19 +75,21 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
// // Set the distance to trigger sync to 160dp
|
||||
// binding.refreshLayout.setDistanceToTriggerSync((160 * resources.displayMetrics.density).toInt())
|
||||
|
||||
mainViewModel.updateListAction.observe(viewLifecycleOwner) { index ->
|
||||
if (mainViewModel.subscriptionId != subId) {
|
||||
return@observe
|
||||
}
|
||||
// Log.d(TAG, "GroupServerFragment updateListAction subId=$subId")
|
||||
adapter.setData(mainViewModel.serversCache, index)
|
||||
// Each fragment subscribes independently to the shared flow and filters its own subId.
|
||||
// No onResume subscription switch needed — the active fragment's subId is always correct.
|
||||
lifecycleScope.launch {
|
||||
mainViewModel.serverListFlow.collect { list ->
|
||||
if (mainViewModel.subscriptionId == subId) {
|
||||
adapter.setData(list)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Log.d(TAG, "GroupServerFragment onViewCreated: subId=$subId")
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Tell ViewModel which tab is active so it can rebuild the correct list.
|
||||
// This is the only place subscriptionId changes — no more races.
|
||||
mainViewModel.subscriptionIdChanged(subId)
|
||||
}
|
||||
|
||||
@@ -137,9 +139,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)
|
||||
}
|
||||
}
|
||||
@@ -220,7 +220,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
*/
|
||||
private fun removeServerSub(guid: String, position: Int) {
|
||||
mainViewModel.removeServer(guid)
|
||||
adapter.removeServerSub(guid, position)
|
||||
// adapter updates automatically via serverListFlow
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -230,15 +230,15 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
*/
|
||||
private fun setSelectServer(guid: String) {
|
||||
val selected = MmkvManager.getSelectServer()
|
||||
if (guid != selected) {
|
||||
if (guid == selected) {
|
||||
MmkvManager.setSelectServer("")
|
||||
} else {
|
||||
MmkvManager.setSelectServer(guid)
|
||||
val fromPosition = mainViewModel.getPosition(selected.orEmpty())
|
||||
val toPosition = mainViewModel.getPosition(guid)
|
||||
adapter.setSelectServer(fromPosition, toPosition)
|
||||
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
ownerActivity.restartV2Ray()
|
||||
}
|
||||
}
|
||||
// Republish snapshot so DiffUtil picks up the selection change in card background
|
||||
mainViewModel.reloadServerList()
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
ownerActivity.restartV2Ray()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,6 +264,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 +288,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
|
||||
*/
|
||||
@@ -294,9 +305,7 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
return
|
||||
}
|
||||
|
||||
// Find the position of the selected server
|
||||
val serversCache = mainViewModel.serversCache
|
||||
val position = serversCache.indexOfFirst { it.guid == selectedGuid }
|
||||
val position = mainViewModel.serverListFlow.value.indexOfFirst { it.guid == selectedGuid }
|
||||
val recyclerView = binding.recyclerView
|
||||
|
||||
if (position >= 0) {
|
||||
@@ -317,4 +326,4 @@ class GroupServerFragment : BaseFragment<FragmentGroupServerBinding>(),
|
||||
ownerActivity.toast(R.string.toast_server_not_found_in_group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package xyz.zarazaex.olc.ui
|
||||
|
||||
import android.content.Intent
|
||||
import android.content.res.ColorStateList
|
||||
import android.graphics.Color
|
||||
import android.net.Uri
|
||||
import android.net.VpnService
|
||||
import android.os.Bundle
|
||||
@@ -9,14 +10,21 @@ 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.core.graphics.drawable.DrawableCompat
|
||||
import com.google.android.material.color.MaterialColors
|
||||
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,6 +37,7 @@ 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
|
||||
@@ -47,6 +56,9 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
private var isLiteTesting = false
|
||||
private var easterEggClickCount = 0
|
||||
private var isEasterEggActive = false
|
||||
private var liteActionJob: kotlinx.coroutines.Job? = null
|
||||
/** Был ли VPN уже запущен в предыдущем колбэке — чтобы детектировать момент подключения */
|
||||
private var wasRunning = false
|
||||
|
||||
val mainViewModel: MainViewModel by viewModels()
|
||||
private lateinit var groupPagerAdapter: GroupPagerAdapter
|
||||
@@ -71,7 +83,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,15 +111,33 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
toggle.syncState()
|
||||
binding.navView.setNavigationItemSelectedListener(this)
|
||||
|
||||
findViewById<android.widget.TextView>(R.id.drawer_settings)?.setOnClickListener {
|
||||
val typedValue = android.util.TypedValue()
|
||||
theme.resolveAttribute(com.google.android.material.R.attr.colorOnSurface, typedValue, true)
|
||||
val onSurface = typedValue.data
|
||||
binding.toolbar.setTitleTextColor(onSurface)
|
||||
// MaterialToolbar с titleCentered рисует отдельный TextView — красим его явно
|
||||
for (i in 0 until binding.toolbar.childCount) {
|
||||
val child = binding.toolbar.getChildAt(i)
|
||||
if (child is android.widget.TextView) {
|
||||
child.setTextColor(onSurface)
|
||||
}
|
||||
}
|
||||
|
||||
ViewCompat.setOnApplyWindowInsetsListener(binding.drawerContentLayout) { v, insets ->
|
||||
val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
|
||||
v.setPadding(0, systemBars.top, 0, systemBars.bottom)
|
||||
insets
|
||||
}
|
||||
|
||||
findViewById<android.view.View>(R.id.drawer_settings)?.setOnClickListener {
|
||||
requestActivityLauncher.launch(Intent(this, SettingsActivity::class.java))
|
||||
binding.drawerLayout.closeDrawer(androidx.core.view.GravityCompat.START)
|
||||
}
|
||||
findViewById<android.widget.TextView>(R.id.drawer_per_app)?.setOnClickListener {
|
||||
findViewById<android.view.View>(R.id.drawer_per_app)?.setOnClickListener {
|
||||
requestActivityLauncher.launch(Intent(this, PerAppProxyActivity::class.java))
|
||||
binding.drawerLayout.closeDrawer(androidx.core.view.GravityCompat.START)
|
||||
}
|
||||
findViewById<android.widget.TextView>(R.id.drawer_check_update)?.setOnClickListener {
|
||||
findViewById<android.view.View>(R.id.drawer_check_update)?.setOnClickListener {
|
||||
startActivity(Intent(this, CheckUpdateActivity::class.java))
|
||||
binding.drawerLayout.closeDrawer(androidx.core.view.GravityCompat.START)
|
||||
}
|
||||
@@ -148,7 +193,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
setupGroupTab()
|
||||
setupViewModel()
|
||||
mainViewModel.reloadServerList()
|
||||
importConfigViaSub()
|
||||
importAllSubsOnStartup()
|
||||
|
||||
checkAndRequestPermission(PermissionType.POST_NOTIFICATIONS) {
|
||||
}
|
||||
@@ -159,26 +204,70 @@ 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.setIconResource(R.drawable.ic_stop_24dp)
|
||||
binding.btnSummaryLite.backgroundTintList = ColorStateList.valueOf(
|
||||
com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorPrimaryContainer, 0)
|
||||
)
|
||||
} else {
|
||||
setButtonsEnabled(true)
|
||||
binding.btnSummaryLite.setIconResource(R.drawable.bolt_24)
|
||||
binding.btnSummaryLite.backgroundTintList = ColorStateList.valueOf(
|
||||
com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorSecondaryContainer, 0)
|
||||
)
|
||||
if (!isLiteTesting) {
|
||||
showStatus("Проверка завершена")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mainViewModel.liteTestFinished.observe(this) { finished ->
|
||||
if (finished && isLiteTesting) {
|
||||
isLiteTesting = false
|
||||
|
||||
// Ищем лучший сервер ДО сортировки, прямо из текущего cache
|
||||
val firstReachable = mainViewModel.serversCache
|
||||
.filter { (MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: 0L) > 0L }
|
||||
.minByOrNull { MmkvManager.decodeServerAffiliationInfo(it.guid)?.testDelayMillis ?: Long.MAX_VALUE }
|
||||
|
||||
if (firstReachable != null) {
|
||||
MmkvManager.setSelectServer(firstReachable.guid)
|
||||
}
|
||||
|
||||
mainViewModel.suppressPinSelected = false
|
||||
mainViewModel.sortByTestResults()
|
||||
mainViewModel.reloadServerList()
|
||||
|
||||
val firstReachable = mainViewModel.serversCache.firstOrNull { cache ->
|
||||
(MmkvManager.decodeServerAffiliationInfo(cache.guid)?.testDelayMillis ?: 0L) > 0L
|
||||
}
|
||||
if (firstReachable != null) {
|
||||
MmkvManager.setSelectServer(firstReachable.guid)
|
||||
showStatus("Подключаемся к быстрейшему серверу")
|
||||
applyRunningState(isLoading = true, isRunning = false)
|
||||
startV2RayWithPermission()
|
||||
} else {
|
||||
showStatus("Нет доступных серверов!")
|
||||
setButtonsEnabled(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
mainViewModel.isRunning.observe(this) { isRunning ->
|
||||
applyRunningState(false, isRunning)
|
||||
if (!isFabOperationInProgress) {
|
||||
applyRunningState(false, isRunning)
|
||||
}
|
||||
// Как только VPN только что подключился — обновляем подписки через него
|
||||
if (isRunning && !wasRunning) {
|
||||
updateSubsViaVpn()
|
||||
}
|
||||
wasRunning = isRunning
|
||||
}
|
||||
mainViewModel.startListenBroadcast()
|
||||
mainViewModel.initAssets(assets)
|
||||
@@ -200,27 +289,63 @@ 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() {
|
||||
// Если идёт подключение (isLoading) — позволяем прервать и остановить сервис
|
||||
if (isFabOperationInProgress) {
|
||||
Log.d(AppConfig.TAG, "FAB: cancel in-progress, stopping service")
|
||||
isFabOperationInProgress = false
|
||||
lifecycleScope.launch {
|
||||
V2RayServiceManager.stopVService(this@MainActivity)
|
||||
}
|
||||
return
|
||||
}
|
||||
isFabOperationInProgress = true
|
||||
|
||||
val actuallyRunning = V2RayServiceManager.isRunning()
|
||||
if (mainViewModel.isRunning.value != actuallyRunning) {
|
||||
Log.w(AppConfig.TAG, "FAB: UI state mismatch, syncing: vm=${mainViewModel.isRunning.value}, actual=$actuallyRunning")
|
||||
mainViewModel.isRunning.value = actuallyRunning
|
||||
isFabOperationInProgress = false
|
||||
return
|
||||
}
|
||||
val isRunning = mainViewModel.isRunning.value == true
|
||||
|
||||
applyRunningState(isLoading = true, isRunning = false)
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
if (actuallyRunning) {
|
||||
if (isRunning) {
|
||||
Log.d(AppConfig.TAG, "FAB: stopping service")
|
||||
V2RayServiceManager.stopVService(this@MainActivity)
|
||||
} else {
|
||||
@@ -229,7 +354,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "FAB: error", e)
|
||||
applyRunningState(isLoading = false, isRunning = V2RayServiceManager.isRunning())
|
||||
applyRunningState(isLoading = false, isRunning = mainViewModel.isRunning.value == true)
|
||||
} finally {
|
||||
isFabOperationInProgress = false
|
||||
}
|
||||
@@ -246,12 +371,30 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
}
|
||||
|
||||
private fun handleLiteAction() {
|
||||
// Отмена на любом этапе: обновление подписок или тест
|
||||
if (mainViewModel.isTesting.value == true || liteActionJob?.isActive == true) {
|
||||
liteActionJob?.cancel()
|
||||
liteActionJob = null
|
||||
mainViewModel.cancelAllTests()
|
||||
mainViewModel.suppressPinSelected = false
|
||||
isLiteTesting = false
|
||||
isFabOperationInProgress = false
|
||||
showStatus("Остановлено")
|
||||
setButtonsEnabled(true)
|
||||
binding.btnSummaryLite.setIconResource(R.drawable.bolt_24)
|
||||
binding.btnSummaryLite.backgroundTintList = ColorStateList.valueOf(
|
||||
com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorSecondaryContainer, 0)
|
||||
)
|
||||
hideLoading()
|
||||
return
|
||||
}
|
||||
|
||||
if (isFabOperationInProgress) {
|
||||
return
|
||||
}
|
||||
isFabOperationInProgress = true
|
||||
|
||||
lifecycleScope.launch {
|
||||
liteActionJob = lifecycleScope.launch {
|
||||
try {
|
||||
if (mainViewModel.isRunning.value == true) {
|
||||
V2RayServiceManager.stopVService(this@MainActivity)
|
||||
@@ -260,30 +403,45 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
|
||||
showStatus("Обновление профилей...")
|
||||
showLoading()
|
||||
// Иконка молнии → стоп пока идёт обновление; FAB и меню блокируем
|
||||
binding.btnSummaryLite.setIconResource(R.drawable.ic_stop_24dp)
|
||||
binding.btnSummaryLite.isEnabled = true
|
||||
binding.btnSummaryLite.alpha = 1.0f
|
||||
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 }
|
||||
isLiteTesting = true
|
||||
mainViewModel.suppressPinSelected = true
|
||||
|
||||
launch(Dispatchers.IO) {
|
||||
val result = mainViewModel.updateConfigViaSubAll()
|
||||
delay(500L)
|
||||
launch(Dispatchers.Main) {
|
||||
if (result.configCount > 0) {
|
||||
mainViewModel.reloadServerList()
|
||||
showStatus("Обновлено ${result.configCount} профилей. Запуск теста...")
|
||||
} else {
|
||||
showStatus("Запуск теста...")
|
||||
}
|
||||
hideLoading()
|
||||
val result = withContext(Dispatchers.IO) { mainViewModel.updateConfigViaSubAll() }
|
||||
val removed = withContext(Dispatchers.IO) { mainViewModel.removeDuplicateByIpAll() }
|
||||
|
||||
delay(500L)
|
||||
showStatus("Выполняется замер задержки. Ожидаем завершения...")
|
||||
mainViewModel.testAllRealPing()
|
||||
}
|
||||
mainViewModel.reloadServerList()
|
||||
if (result.configCount > 0) {
|
||||
val status = if (removed > 0)
|
||||
"Обновлено ${result.configCount} профилей, удалено $removed дубл. IP. Запуск теста..."
|
||||
else
|
||||
"Обновлено ${result.configCount} профилей. Запуск теста..."
|
||||
showStatus(status)
|
||||
} else {
|
||||
showStatus("Запуск теста...")
|
||||
}
|
||||
delay(1500)
|
||||
hideLoading()
|
||||
|
||||
showStatus("Выполняется замер задержки. Ожидаем завершения...")
|
||||
mainViewModel.testAllRealPing()
|
||||
} catch (e: kotlinx.coroutines.CancellationException) {
|
||||
// Пользователь нажал стоп — уже обработано выше
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error in handleLiteAction", e)
|
||||
isLiteTesting = false
|
||||
hideLoading()
|
||||
} finally {
|
||||
isFabOperationInProgress = false
|
||||
liteActionJob = null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,6 +472,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
return
|
||||
}
|
||||
isFabOperationInProgress = true
|
||||
applyRunningState(isLoading = true, isRunning = false)
|
||||
|
||||
lifecycleScope.launch {
|
||||
try {
|
||||
@@ -321,10 +480,16 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
V2RayServiceManager.stopVService(this@MainActivity)
|
||||
delay(1000)
|
||||
}
|
||||
if (MmkvManager.getSelectServer().isNullOrEmpty()) {
|
||||
// Сервер был снят с выбора — просто остановились, разблокируем UI
|
||||
applyRunningState(isLoading = false, isRunning = false)
|
||||
return@launch
|
||||
}
|
||||
startV2Ray()
|
||||
delay(1000)
|
||||
} catch (e: Exception) {
|
||||
Log.e(AppConfig.TAG, "Error in restartV2Ray", e)
|
||||
applyRunningState(isLoading = false, isRunning = mainViewModel.isRunning.value == true)
|
||||
} finally {
|
||||
isFabOperationInProgress = false
|
||||
}
|
||||
@@ -353,36 +518,92 @@ 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) {
|
||||
val secContainer = ColorStateList.valueOf(
|
||||
com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorSecondaryContainer, 0)
|
||||
)
|
||||
if (isLoading) {
|
||||
binding.fab.setImageResource(R.drawable.ic_fab_check)
|
||||
// Во время подключения: только FAB доступен для отмены, всё остальное заблокировано
|
||||
binding.fab.isEnabled = true
|
||||
binding.fab.alpha = 1.0f
|
||||
binding.fab.backgroundTintList = secContainer
|
||||
binding.btnSummaryLite.isEnabled = false
|
||||
binding.btnSummaryLite.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 }
|
||||
setStatusDot(DotState.LOADING)
|
||||
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))
|
||||
setSecondaryButtonsEnabled(false)
|
||||
binding.fab.isEnabled = true
|
||||
binding.fab.alpha = 1.0f
|
||||
binding.fab.backgroundTintList = accentColor()
|
||||
binding.btnSummaryLite.backgroundTintList = secContainer
|
||||
binding.fab.contentDescription = getString(R.string.action_stop_service)
|
||||
setTestState(getString(R.string.connection_connected))
|
||||
binding.layoutTest.isFocusable = true
|
||||
setStatusDot(DotState.CONNECTED)
|
||||
} else {
|
||||
binding.fab.setImageResource(R.drawable.ic_play_24dp)
|
||||
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))
|
||||
setButtonsEnabled(true)
|
||||
binding.fab.backgroundTintList = accentColor()
|
||||
binding.btnSummaryLite.backgroundTintList = secContainer
|
||||
binding.fab.contentDescription = getString(R.string.tasker_start_service)
|
||||
setTestState(getString(R.string.connection_not_connected))
|
||||
binding.layoutTest.isFocusable = false
|
||||
setStatusDot(DotState.IDLE)
|
||||
}
|
||||
}
|
||||
|
||||
private enum class DotState { IDLE, CONNECTED, LOADING }
|
||||
|
||||
private fun setStatusDot(state: DotState) {
|
||||
val dot = binding.statusDot
|
||||
dot.animate().cancel()
|
||||
dot.alpha = 1f; dot.scaleX = 1f; dot.scaleY = 1f
|
||||
dot.backgroundTintList = ColorStateList.valueOf(when (state) {
|
||||
DotState.CONNECTED -> ContextCompat.getColor(this, R.color.status_connected)
|
||||
DotState.LOADING -> com.google.android.material.color.MaterialColors.getColor(this, androidx.appcompat.R.attr.colorPrimary, 0)
|
||||
DotState.IDLE -> com.google.android.material.color.MaterialColors.getColor(this, com.google.android.material.R.attr.colorOutline, 0)
|
||||
})
|
||||
if (state == DotState.LOADING) {
|
||||
pulseDot(dot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pulseDot(dot: android.view.View) {
|
||||
dot.animate()
|
||||
.alpha(0.25f)
|
||||
.setDuration(600)
|
||||
.withEndAction {
|
||||
if (dot.isAttachedToWindow) {
|
||||
dot.animate()
|
||||
.alpha(1f)
|
||||
.setDuration(600)
|
||||
.withEndAction {
|
||||
if (dot.isAttachedToWindow && mainViewModel.isTesting.value == true) {
|
||||
pulseDot(dot)
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
}.start()
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
val actuallyRunning = V2RayServiceManager.isRunning()
|
||||
if (mainViewModel.isRunning.value != actuallyRunning) {
|
||||
Log.w(AppConfig.TAG, "onResume: syncing state vm=${mainViewModel.isRunning.value}, actual=$actuallyRunning")
|
||||
mainViewModel.isRunning.value = actuallyRunning
|
||||
}
|
||||
MessageUtil.sendMsg2Service(this, AppConfig.MSG_REGISTER_CLIENT, "")
|
||||
}
|
||||
|
||||
@@ -392,6 +613,41 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
|
||||
override fun onCreateOptionsMenu(menu: Menu): Boolean {
|
||||
menuInflater.inflate(R.menu.menu_main, menu)
|
||||
|
||||
val iconColor = MaterialColors.getColor(this, com.google.android.material.R.attr.colorOnSurface, Color.BLACK)
|
||||
for (i in 0 until menu.size()) {
|
||||
menu.getItem(i).icon?.let {
|
||||
DrawableCompat.setTint(DrawableCompat.wrap(it).mutate(), iconColor)
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
searchView.alpha = 0f
|
||||
searchView.animate().alpha(1f).setDuration(220).start()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun onMenuItemActionCollapse(item: MenuItem): Boolean {
|
||||
mainViewModel.filterConfig("")
|
||||
return true
|
||||
}
|
||||
})
|
||||
|
||||
return super.onCreateOptionsMenu(menu)
|
||||
}
|
||||
|
||||
@@ -405,10 +661,16 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
}
|
||||
|
||||
R.id.sub_update -> {
|
||||
setSecondaryButtonsEnabled(false)
|
||||
importConfigViaSub()
|
||||
true
|
||||
}
|
||||
|
||||
R.id.filter_by_country -> {
|
||||
showCountryFilterDialog()
|
||||
true
|
||||
}
|
||||
|
||||
else -> super.onOptionsItemSelected(item)
|
||||
}
|
||||
|
||||
@@ -498,12 +760,56 @@ 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()
|
||||
setTestState(getString(R.string.connection_updating_profiles))
|
||||
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
|
||||
*/
|
||||
fun importConfigViaSub(): Boolean {
|
||||
showLoading()
|
||||
setTestState(getString(R.string.connection_updating_profiles))
|
||||
|
||||
lifecycleScope.launch(Dispatchers.IO) {
|
||||
val result = mainViewModel.updateConfigViaSubAll()
|
||||
@@ -525,6 +831,7 @@ class MainActivity : HelperBaseActivity(), NavigationView.OnNavigationItemSelect
|
||||
mainViewModel.reloadServerList()
|
||||
}
|
||||
hideLoading()
|
||||
applyRunningState(isLoading = false, isRunning = mainViewModel.isRunning.value == true)
|
||||
}
|
||||
}
|
||||
return true
|
||||
@@ -545,7 +852,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) {
|
||||
@@ -564,7 +871,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) {
|
||||
@@ -684,7 +991,56 @@ 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()
|
||||
|
||||
// currentFilter stores excluded set (empty = show all)
|
||||
val checked = BooleanArray(codes.size) { codes[it] in currentFilter }
|
||||
|
||||
val dialog = MaterialAlertDialogBuilder(this@MainActivity)
|
||||
.setTitle("Исключить страны")
|
||||
.setMultiChoiceItems(labels, checked) { _, which, isChecked ->
|
||||
checked[which] = isChecked
|
||||
}
|
||||
.setPositiveButton("Применить") { _, _ ->
|
||||
val excluded = codes.filterIndexed { i, _ -> checked[i] }.toSet()
|
||||
mainViewModel.applyCountryFilter(excluded)
|
||||
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 {
|
||||
@@ -703,18 +1059,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
|
||||
@@ -759,4 +1125,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,8 @@ import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import com.google.android.material.color.MaterialColors
|
||||
import java.util.Collections
|
||||
import xyz.zarazaex.olc.AppConfig
|
||||
import xyz.zarazaex.olc.R
|
||||
import xyz.zarazaex.olc.contracts.MainAdapterListener
|
||||
@@ -14,39 +16,140 @@ 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 val showServerIp =
|
||||
MmkvManager.decodeSettingsBool(AppConfig.PREF_SHOW_SERVER_IP, false)
|
||||
private var data: MutableList<ServersCache> = mutableListOf()
|
||||
private var minReachablePing: Long? = null
|
||||
private var maxReachablePing: Long? = null
|
||||
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()
|
||||
fun setData(newData: List<ServersCache>) {
|
||||
val oldData = data
|
||||
val parsedNewData = newData
|
||||
|
||||
if (position >= 0 && position in data.indices) {
|
||||
notifyItemChanged(position)
|
||||
} else {
|
||||
if (oldData.isEmpty() || parsedNewData.isEmpty()) {
|
||||
data = parsedNewData.toMutableList()
|
||||
recomputePingRange()
|
||||
notifyDataSetChanged()
|
||||
return
|
||||
}
|
||||
|
||||
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) =
|
||||
oldData[oldPos].guid == parsedNewData[newPos].guid
|
||||
|
||||
override fun areContentsTheSame(oldPos: Int, newPos: Int): Boolean {
|
||||
val old = oldData[oldPos]
|
||||
val new = parsedNewData[newPos]
|
||||
return old.profile == new.profile &&
|
||||
old.profile.isFavorite == new.profile.isFavorite &&
|
||||
old.isSelected == new.isSelected &&
|
||||
old.testDelayMillis == new.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()
|
||||
recomputePingRange()
|
||||
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,48 +158,120 @@ class MainRecyclerAdapter(
|
||||
|
||||
holder.itemView.setBackgroundColor(Color.TRANSPARENT)
|
||||
|
||||
//Name address
|
||||
// Name address
|
||||
holder.itemMainBinding.tvName.text = profile.remarks
|
||||
holder.itemMainBinding.tvStatistics.text = getAddress(profile)
|
||||
val addressText = getAddress(profile)
|
||||
holder.itemMainBinding.tvStatistics.text = addressText
|
||||
holder.itemMainBinding.tvStatistics.visibility =
|
||||
if (addressText.isEmpty()) View.GONE else View.VISIBLE
|
||||
|
||||
//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))
|
||||
} else {
|
||||
holder.itemMainBinding.tvTestResult.setTextColor(ContextCompat.getColor(context, R.color.colorPing))
|
||||
holder.itemMainBinding.tvTestResult.setTextColor(
|
||||
getPingColor(context, aff?.testDelayMillis)
|
||||
)
|
||||
(holder.itemMainBinding.tvTestResult.layoutParams as? ViewGroup.MarginLayoutParams)?.marginStart =
|
||||
if (addressText.isEmpty()) 0 else 6.dpToPx(context)
|
||||
|
||||
val isSelected = data[position].isSelected
|
||||
holder.itemMainBinding.cardContainer.apply {
|
||||
val selectedColor = MaterialColors.getColor(
|
||||
context,
|
||||
com.google.android.material.R.attr.colorSurfaceContainerHigh,
|
||||
Color.TRANSPARENT
|
||||
)
|
||||
|
||||
setCardBackgroundColor(if (isSelected) selectedColor else Color.TRANSPARENT)
|
||||
strokeWidth = 0
|
||||
strokeColor = Color.TRANSPARENT
|
||||
}
|
||||
|
||||
//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
|
||||
*/
|
||||
private fun getAddress(profile: ProfileItem): String {
|
||||
if (!showServerIp) {
|
||||
return ""
|
||||
}
|
||||
return AngConfigManager.generateDescription(profile)
|
||||
}
|
||||
|
||||
private fun getPingColor(context: android.content.Context, delayMillis: Long?): Int {
|
||||
val delay = delayMillis ?: return MaterialColors.getColor(
|
||||
context,
|
||||
com.google.android.material.R.attr.colorOnSurfaceVariant,
|
||||
ContextCompat.getColor(context, R.color.colorPing)
|
||||
)
|
||||
if (delay == 0L) {
|
||||
return MaterialColors.getColor(
|
||||
context,
|
||||
com.google.android.material.R.attr.colorOnSurfaceVariant,
|
||||
ContextCompat.getColor(context, R.color.colorPing)
|
||||
)
|
||||
}
|
||||
return when {
|
||||
delay < 0L -> ContextCompat.getColor(context, R.color.colorPingRed)
|
||||
minReachablePing == null || maxReachablePing == null -> ContextCompat.getColor(context, R.color.colorPingGood)
|
||||
minReachablePing == maxReachablePing -> ContextCompat.getColor(context, R.color.colorPingGood)
|
||||
else -> {
|
||||
val min = minReachablePing ?: delay
|
||||
val max = maxReachablePing ?: delay
|
||||
val relative = ((delay - min).toFloat() / (max - min).toFloat()).coerceIn(0f, 1f)
|
||||
when {
|
||||
relative <= 0.33f -> ContextCompat.getColor(context, R.color.colorPingGood)
|
||||
relative <= 0.66f -> ContextCompat.getColor(context, R.color.colorPingMedium)
|
||||
else -> ContextCompat.getColor(context, R.color.colorPingRed)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun recomputePingRange() {
|
||||
val delays = data.mapNotNull { item ->
|
||||
MmkvManager.decodeServerAffiliationInfo(item.guid)
|
||||
?.testDelayMillis
|
||||
?.takeIf { it > 0L }
|
||||
}
|
||||
minReachablePing = delays.minOrNull()
|
||||
maxReachablePing = delays.maxOrNull()
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the subscription remarks information
|
||||
* @param profile The server configuration
|
||||
@@ -104,34 +279,36 @@ 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() ?: ""
|
||||
}
|
||||
|
||||
fun removeServerSub(guid: String, position: Int) {
|
||||
val idx = data.indexOfFirst { it.guid == guid }
|
||||
if (idx >= 0) {
|
||||
data.removeAt(idx)
|
||||
notifyItemRemoved(idx)
|
||||
notifyItemRangeChanged(idx, data.size - idx)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSelectServer(fromPosition: Int, toPosition: Int) {
|
||||
notifyItemChanged(fromPosition)
|
||||
notifyItemChanged(toPosition)
|
||||
private fun Int.dpToPx(context: android.content.Context): Int {
|
||||
return (this * context.resources.displayMetrics.density).toInt()
|
||||
}
|
||||
|
||||
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,12 +331,14 @@ 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 {
|
||||
// ViewModel swaps both serverList and _serversCache, then publishSnapshot triggers setData.
|
||||
// We optimistically swap local data + animate immediately for smooth drag UX.
|
||||
mainViewModel.swapServer(fromPosition, toPosition)
|
||||
if (fromPosition < data.size && toPosition < data.size) {
|
||||
Collections.swap(data, fromPosition, toPosition)
|
||||
@@ -172,6 +351,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
|
||||
|
||||
@@ -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
|
||||
@@ -31,20 +33,36 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
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 = ""
|
||||
val serversCache = mutableListOf<ServersCache>()
|
||||
/** ISO codes to EXCLUDE (empty = show all) */
|
||||
var countryFilter: Set<String> = MmkvManager.getCountryFilter()
|
||||
private set
|
||||
|
||||
// Internal mutable cache — never exposed directly
|
||||
private val _serversCache = mutableListOf<ServersCache>()
|
||||
// Read-only snapshot for external consumers that need direct access (e.g. export, ping)
|
||||
val serversCache: List<ServersCache> get() = _serversCache.toList()
|
||||
|
||||
// Single source of truth for the list UI — emits a new immutable snapshot on every change
|
||||
private val _serverListFlow = MutableStateFlow<List<ServersCache>>(emptyList())
|
||||
val serverListFlow: StateFlow<List<ServersCache>> = _serverListFlow.asStateFlow()
|
||||
|
||||
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 } }
|
||||
var suppressPinSelected = false
|
||||
private val tcpingTestScope by lazy { CoroutineScope(Dispatchers.IO) }
|
||||
|
||||
/**
|
||||
@@ -89,30 +107,33 @@ 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
|
||||
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))
|
||||
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
|
||||
}
|
||||
|
||||
if (!suppressPinSelected) pinSelectedGuidToTop(serverList)
|
||||
updateCache()
|
||||
updateListAction.value = -1
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,10 +143,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun removeServer(guid: String) {
|
||||
serverList.remove(guid)
|
||||
MmkvManager.removeServer(guid)
|
||||
val index = getPosition(guid)
|
||||
val index = _serversCache.indexOfFirst { it.guid == guid }
|
||||
if (index >= 0) {
|
||||
serversCache.removeAt(index)
|
||||
_serversCache.removeAt(index)
|
||||
}
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,27 +161,38 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
Collections.swap(serverList, fromPosition, toPosition)
|
||||
Collections.swap(serversCache, fromPosition, toPosition)
|
||||
Collections.swap(_serversCache, fromPosition, toPosition)
|
||||
publishSnapshot()
|
||||
|
||||
MmkvManager.encodeServerList(serverList, subscriptionId)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the cache of servers.
|
||||
* Rebuilds _serversCache from serverList and publishes a new snapshot to serverListFlow.
|
||||
*/
|
||||
@Synchronized
|
||||
fun updateCache() {
|
||||
serversCache.clear()
|
||||
_serversCache.clear()
|
||||
val kw = keywordFilter.trim()
|
||||
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
|
||||
val selectedGuid = MmkvManager.getSelectServer().orEmpty()
|
||||
for (guid in serverList) {
|
||||
val profile = MmkvManager.decodeServerConfig(guid) ?: continue
|
||||
|
||||
if (activeCountryFilter.isNotEmpty()) {
|
||||
val code = CountryDetector.getCountryCode(profile.remarks, profile.server)
|
||||
if (code in activeCountryFilter) continue
|
||||
}
|
||||
|
||||
val delay = MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
|
||||
|
||||
if (kw.isEmpty()) {
|
||||
serversCache.add(ServersCache(guid, profile))
|
||||
_serversCache.add(ServersCache(guid, profile, delay, guid == selectedGuid))
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -172,7 +205,83 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
|| server.matchesPattern(searchRegex, kw)
|
||||
|| protocol.matchesPattern(searchRegex, kw)
|
||||
) {
|
||||
serversCache.add(ServersCache(guid, profile))
|
||||
_serversCache.add(ServersCache(guid, profile, delay, guid == selectedGuid))
|
||||
}
|
||||
}
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
/** Emits an immutable copy of _serversCache to the Flow. Must be called on Main or from @Synchronized blocks. */
|
||||
private fun publishSnapshot() {
|
||||
_serverListFlow.value = _serversCache.toList()
|
||||
}
|
||||
|
||||
/** Builds a snapshot of ServersCache for the given subId without changing global state. */
|
||||
fun reloadForSub(subId: String): MutableList<ServersCache>? {
|
||||
val guids = when {
|
||||
subId.isEmpty() -> MmkvManager.decodeAllServerList()
|
||||
subId.startsWith("group_") -> {
|
||||
val allSubs = MmkvManager.decodeSubscriptions()
|
||||
val groupSubs = when (subId) {
|
||||
"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) ||
|
||||
it.subscription.remarks.startsWith("BLACK", ignoreCase = true)
|
||||
}
|
||||
else -> emptyList()
|
||||
}
|
||||
groupSubs.flatMap { MmkvManager.decodeServerList(it.guid) }.toMutableList()
|
||||
}
|
||||
else -> MmkvManager.decodeServerList(subId)
|
||||
}
|
||||
return guids.mapNotNull { guid ->
|
||||
val profile = MmkvManager.decodeServerConfig(guid) ?: return@mapNotNull null
|
||||
val delay = MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
|
||||
ServersCache(guid, profile, delay)
|
||||
}.toMutableList()
|
||||
}
|
||||
|
||||
/** Sets excluded countries and reloads list. Pass empty set to show all. */
|
||||
fun applyCountryFilter(excludedCodes: Set<String>) {
|
||||
countryFilter = excludedCodes
|
||||
MmkvManager.setCountryFilter(excludedCodes)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -187,27 +296,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))
|
||||
@@ -221,9 +323,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun exportAllServer(): Int {
|
||||
val serverListCopy =
|
||||
if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) {
|
||||
serverList
|
||||
serverList.toList()
|
||||
} else {
|
||||
serversCache.map { it.guid }.toList()
|
||||
_serversCache.map { it.guid }
|
||||
}
|
||||
|
||||
val ret = AngConfigManager.shareNonCustomConfigsToClipboard(
|
||||
@@ -239,9 +341,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
fun testAllTcping() {
|
||||
tcpingTestScope.coroutineContext[Job]?.cancelChildren()
|
||||
SpeedtestManager.closeAllTcpSockets()
|
||||
MmkvManager.clearAllTestDelayResults(serversCache.map { it.guid }.toList())
|
||||
MmkvManager.clearAllTestDelayResults(_serversCache.map { it.guid })
|
||||
|
||||
val serversCopy = serversCache.toList()
|
||||
val serversCopy = _serversCache.toList()
|
||||
for (item in serversCopy) {
|
||||
item.profile.let { outbound ->
|
||||
val serverAddress = outbound.server
|
||||
@@ -251,7 +353,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val testResult = SpeedtestManager.tcping(serverAddress, serverPort.toInt())
|
||||
launch(Dispatchers.Main) {
|
||||
MmkvManager.encodeServerTestDelayMillis(item.guid, testResult)
|
||||
updateListAction.value = getPosition(item.guid)
|
||||
refreshPingInCache(listOf(item.guid))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -259,6 +361,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.
|
||||
*/
|
||||
@@ -267,35 +380,45 @@ 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()) {
|
||||
withContext(Dispatchers.Main) { reloadServerList() }
|
||||
}
|
||||
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()
|
||||
}
|
||||
if (!suppressPinSelected) {
|
||||
MmkvManager.clearAllTestDelayResults(_serversCache.map { it.guid })
|
||||
}
|
||||
publishSnapshot()
|
||||
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()
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,9 +523,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* @return The position of the server.
|
||||
*/
|
||||
fun getPosition(guid: String): Int {
|
||||
serversCache.forEachIndexed { index, it ->
|
||||
if (it.guid == guid)
|
||||
return index
|
||||
_serversCache.forEachIndexed { index, it ->
|
||||
if (it.guid == guid) return index
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -412,7 +534,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
* @return The number of removed servers.
|
||||
*/
|
||||
fun removeDuplicateServer(): Int {
|
||||
val serversCacheCopy = serversCache.toList().toMutableList()
|
||||
val serversCacheCopy = _serversCache.toList()
|
||||
val deleteServer = mutableListOf<String>()
|
||||
serversCacheCopy.forEachIndexed { index, sc ->
|
||||
val profile = sc.profile
|
||||
@@ -432,6 +554,102 @@ 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()
|
||||
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.
|
||||
@@ -441,11 +659,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (subscriptionId.isEmpty() && keywordFilter.isEmpty()) {
|
||||
MmkvManager.removeAllServer()
|
||||
} else {
|
||||
val serversCopy = serversCache.toList()
|
||||
val serversCopy = _serversCache.toList()
|
||||
for (item in serversCopy) {
|
||||
MmkvManager.removeServer(item.guid)
|
||||
}
|
||||
serversCache.toList().count()
|
||||
serversCopy.count()
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -453,6 +671,49 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
/**
|
||||
* 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 refreshPingInCache(guids: List<String>) {
|
||||
val guidSet = guids.toHashSet()
|
||||
for (i in _serversCache.indices) {
|
||||
val item = _serversCache[i]
|
||||
if (item.guid in guidSet) {
|
||||
val delay = MmkvManager.decodeServerAffiliationInfo(item.guid)?.testDelayMillis ?: 0L
|
||||
if (item.testDelayMillis != delay) {
|
||||
_serversCache[i] = item.copy(testDelayMillis = delay)
|
||||
}
|
||||
}
|
||||
}
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun sortServersCacheInPlace() {
|
||||
for (i in _serversCache.indices) {
|
||||
val item = _serversCache[i]
|
||||
val delay = MmkvManager.decodeServerAffiliationInfo(item.guid)?.testDelayMillis ?: 0L
|
||||
if (item.testDelayMillis != delay) {
|
||||
_serversCache[i] = item.copy(testDelayMillis = delay)
|
||||
}
|
||||
}
|
||||
_serversCache.sortWith(compareBy(
|
||||
{ !it.profile.isFavorite },
|
||||
{
|
||||
val delay = it.testDelayMillis
|
||||
when {
|
||||
delay > 0L -> delay
|
||||
delay == 0L -> Long.MAX_VALUE - 1
|
||||
else -> Long.MAX_VALUE
|
||||
}
|
||||
}
|
||||
))
|
||||
if (!suppressPinSelected) pinSelectedCacheItemToTop(_serversCache)
|
||||
publishSnapshot()
|
||||
}
|
||||
|
||||
fun sortByTestResults() {
|
||||
if (subscriptionId.isEmpty()) {
|
||||
MmkvManager.decodeSubsList().forEach { guid ->
|
||||
@@ -466,7 +727,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) {
|
||||
@@ -487,20 +748,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
val serverList = MmkvManager.decodeServerList(sub.guid)
|
||||
serverList.forEach { guid ->
|
||||
val delay = MmkvManager.decodeServerAffiliationInfo(guid)?.testDelayMillis ?: 0L
|
||||
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))
|
||||
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) ->
|
||||
val sortedList = servers.map { it.guid }.toMutableList()
|
||||
pinSelectedGuidToTop(sortedList)
|
||||
MmkvManager.encodeServerList(sortedList, subId)
|
||||
}
|
||||
}
|
||||
@@ -510,28 +773,50 @@ 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
|
||||
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))
|
||||
serverDelays.add(ServerDelay(key, sortKey, isFav))
|
||||
}
|
||||
serverDelays.sortBy { it.testDelayMillis }
|
||||
serverDelays.sortWith(compareBy({ !it.isFav }, { it.testDelayMillis }))
|
||||
|
||||
val sortedServerList = serverDelays.map { it.guid }.toMutableList()
|
||||
pinSelectedGuidToTop(sortedServerList)
|
||||
|
||||
// Save the sorted list for this subscription
|
||||
MmkvManager.encodeServerList(sortedServerList, subId)
|
||||
}
|
||||
|
||||
private fun pinSelectedGuidToTop(list: MutableList<String>) {
|
||||
val selectedGuid = MmkvManager.getSelectServer().orEmpty()
|
||||
if (selectedGuid.isEmpty()) return
|
||||
val index = list.indexOf(selectedGuid)
|
||||
if (index > 0) {
|
||||
list.removeAt(index)
|
||||
list.add(0, selectedGuid)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pinSelectedCacheItemToTop(list: MutableList<ServersCache>) {
|
||||
val selectedGuid = MmkvManager.getSelectServer().orEmpty()
|
||||
if (selectedGuid.isEmpty()) return
|
||||
val index = list.indexOfFirst { it.guid == selectedGuid }
|
||||
if (index > 0) {
|
||||
val selectedItem = list.removeAt(index)
|
||||
list.add(0, selectedItem)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initializes assets.
|
||||
@@ -583,7 +868,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
withContext(Dispatchers.Main) {
|
||||
reloadServerList()
|
||||
reloadServerList() // rebuilds _serversCache + publishSnapshot
|
||||
isTesting.value = false
|
||||
liteTestFinished.value = true
|
||||
liteTestFinished.value = false
|
||||
}
|
||||
@@ -620,7 +906,19 @@ 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)
|
||||
refreshPingInCache(listOf(resultPair.first))
|
||||
if (!suppressPinSelected) sortServersCacheInPlace()
|
||||
// publishSnapshot() already called inside refresh/sort above
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_CONFIG_BATCH -> {
|
||||
val update = intent.serializable<PingProgressUpdate>("content") ?: return
|
||||
update.results.forEach { result ->
|
||||
MmkvManager.encodeServerTestDelayMillis(result.guid, result.delay)
|
||||
}
|
||||
refreshPingInCache(update.results.map { it.guid })
|
||||
if (!suppressPinSelected) sortServersCacheInPlace()
|
||||
// publishSnapshot() already called inside refresh/sort above
|
||||
}
|
||||
|
||||
AppConfig.MSG_MEASURE_CONFIG_NOTIFY -> {
|
||||
@@ -633,6 +931,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -1,7 +1,7 @@
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="@color/divider_color_light" />
|
||||
<solid android:color="?attr/colorSecondaryContainer" />
|
||||
<size
|
||||
android:width="48dp"
|
||||
android:height="48dp" />
|
||||
</shape>
|
||||
android:width="24dp"
|
||||
android:height="24dp" />
|
||||
</shape>
|
||||
|
||||
@@ -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,12 @@
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item
|
||||
android:bottom="0dp"
|
||||
android:left="28dp"
|
||||
android:right="28dp"
|
||||
android:top="0dp">
|
||||
<shape>
|
||||
<size android:height="1dp" />
|
||||
<solid android:color="@color/server_list_divider" />
|
||||
</shape>
|
||||
</item>
|
||||
</layer-list>
|
||||
@@ -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,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="oval">
|
||||
<solid android:color="#FF888888" />
|
||||
<size android:width="10dp" android:height="10dp" />
|
||||
</shape>
|
||||
@@ -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>
|
||||
@@ -1,171 +1,228 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<ScrollView 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">
|
||||
|
||||
<LinearLayout xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<LinearLayout
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="top"
|
||||
android:orientation="vertical">
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="8dp"
|
||||
app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_soure_ccode"
|
||||
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">
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="top"
|
||||
android:orientation="vertical">
|
||||
|
||||
<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" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_soure_ccode"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/title_source_code"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center|start"
|
||||
android:orientation="horizontal"
|
||||
android:padding="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_source_code_24dp"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="16dp"
|
||||
android:text="@string/title_source_code"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:background="?attr/colorOutlineVariant" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_oss_licenses"
|
||||
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="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/license_24px"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="16dp"
|
||||
android:text="@string/title_oss_license"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:background="?attr/colorOutlineVariant" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_feedback"
|
||||
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="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_feedback_24dp"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="16dp"
|
||||
android:text="@string/title_pref_feedback"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:background="?attr/colorOutlineVariant" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_tg_channel"
|
||||
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="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_telegram_24dp"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="16dp"
|
||||
android:text="@string/title_tg_channel"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:background="?attr/colorOutlineVariant" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_privacy_policy"
|
||||
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="16dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_privacy_24dp"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="16dp"
|
||||
android:text="@string/title_privacy_policy"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_oss_licenses"
|
||||
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">
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/license_24px" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/title_oss_license"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_feedback"
|
||||
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_feedback_24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/title_pref_feedback"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_tg_channel"
|
||||
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_telegram_24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/title_tg_channel"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_privacy_policy"
|
||||
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_privacy_24dp" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="@dimen/padding_spacing_dp16"
|
||||
android:text="@string/title_privacy_policy"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
</LinearLayout>
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:cardBackgroundColor="?attr/colorSurfaceContainerLowest"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp16">
|
||||
android:padding="20dp">
|
||||
|
||||
<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" />
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelMedium"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_app_id"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="@string/title_about"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
|
||||
android:textColor="?attr/colorOutline" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
||||
|
||||
|
||||
@@ -5,33 +5,41 @@
|
||||
android:layout_height="match_parent"
|
||||
android:fitsSystemWindows="true">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
<com.google.android.material.appbar.AppBarLayout
|
||||
android:id="@+id/app_bar"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/colorSurface"
|
||||
app:elevation="0dp"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:titleTextAppearance="@style/TextAppearance.AppCompat.Title" />
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent">
|
||||
|
||||
<com.google.android.material.appbar.MaterialToolbar
|
||||
android:id="@+id/toolbar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="?attr/actionBarSize"
|
||||
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
<com.google.android.material.progressindicator.LinearProgressIndicator
|
||||
android:id="@+id/progress_bar"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||
app:layout_constraintTop_toBottomOf="@id/app_bar"
|
||||
android:indeterminate="true"
|
||||
android:visibility="gone"
|
||||
app:indicatorColor="@color/color_fab_active" />
|
||||
app:indicatorColor="?attr/colorPrimary"
|
||||
app:trackColor="?attr/colorSurfaceVariant" />
|
||||
|
||||
<FrameLayout
|
||||
android:id="@+id/content_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
app:layout_constraintTop_toBottomOf="@id/toolbar"
|
||||
app:layout_constraintTop_toBottomOf="@id/app_bar"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent">
|
||||
|
||||
</FrameLayout>
|
||||
app:layout_constraintEnd_toEndOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
@@ -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,29 @@
|
||||
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="?attr/colorSurface">
|
||||
|
||||
<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="?attr/colorSurface"
|
||||
app:elevation="0dp"
|
||||
app:liftOnScroll="false">
|
||||
|
||||
<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:titleTextColor="?attr/colorOnSurface"
|
||||
app:titleTextAppearance="@style/TextAppearance.Material3.TitleLarge" />
|
||||
|
||||
</com.google.android.material.appbar.AppBarLayout>
|
||||
|
||||
@@ -42,17 +49,20 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:indeterminate="true"
|
||||
android:visibility="invisible"
|
||||
app:indicatorColor="@color/color_fab_active" />
|
||||
app:indicatorColor="?attr/colorPrimary"
|
||||
app:trackCornerRadius="0dp"
|
||||
app:trackColor="@android:color/transparent" />
|
||||
|
||||
<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="?attr/colorOutlineVariant" />
|
||||
|
||||
<androidx.viewpager2.widget.ViewPager2
|
||||
android:id="@+id/view_pager"
|
||||
@@ -62,98 +72,151 @@
|
||||
android:scrollbars="vertical"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<!-- Bottom container: tab + action card -->
|
||||
<LinearLayout
|
||||
android:id="@+id/bottom_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
android:orientation="vertical"
|
||||
android:background="?attr/colorSurfaceContainer">
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:background="@color/divider_color_light" />
|
||||
android:background="?attr/colorOutlineVariant" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/tab_slot_bottom"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="8dp">
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/btn_summary_lite"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:src="@drawable/ic_lite_bolt"
|
||||
app:tint="@color/colorWhite"
|
||||
app:backgroundTint="@color/color_fab_inactive"
|
||||
app:fabSize="normal"
|
||||
app:maxImageSize="28dp"
|
||||
app:elevation="4dp"
|
||||
app:pressedTranslationZ="8dp"
|
||||
app:hoveredFocusedTranslationZ="6dp" />
|
||||
|
||||
<com.google.android.material.floatingactionbutton.FloatingActionButton
|
||||
android:id="@+id/fab"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/tasker_start_service"
|
||||
android:focusable="true"
|
||||
android:nextFocusLeft="@+id/layout_test"
|
||||
android:src="@drawable/ic_play_24dp"
|
||||
app:tint="@color/colorWhite"
|
||||
app:fabSize="normal"
|
||||
app:maxImageSize="28dp"
|
||||
app:elevation="4dp"
|
||||
app:pressedTranslationZ="8dp"
|
||||
app:hoveredFocusedTranslationZ="6dp" />
|
||||
<com.google.android.material.tabs.TabLayout
|
||||
android:id="@+id/tab_group"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:minHeight="56dp"
|
||||
android:background="?attr/colorSurface"
|
||||
app:tabIndicatorFullWidth="true"
|
||||
app:tabIndicatorHeight="3dp"
|
||||
app:tabMode="fixed"
|
||||
app:tabGravity="fill"
|
||||
app:tabMaxWidth="0dp"
|
||||
app:tabRippleColor="@android:color/transparent"
|
||||
app:tabIndicatorColor="?attr/colorPrimary"
|
||||
app:tabSelectedTextColor="?attr/colorPrimary"
|
||||
app:tabTextColor="?attr/colorOnSurfaceVariant"
|
||||
app:tabTextAppearance="@style/TabLayoutTextStyle" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_test"
|
||||
<!-- Bottom action row -->
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/connection_test_pending"
|
||||
android:focusable="true"
|
||||
android:nextFocusLeft="@+id/view_pager"
|
||||
android:nextFocusRight="@+id/fab"
|
||||
android:orientation="vertical"
|
||||
android:gravity="center"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="16dp">
|
||||
android:paddingEnd="16dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_test_state"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:maxLines="2"
|
||||
android:minLines="1"
|
||||
android:text="@string/connection_test_pending"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
|
||||
<!-- Bolt icon button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/btn_summary_lite"
|
||||
style="@style/Widget.ActionSquareButton"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
app:icon="@drawable/bolt_24"
|
||||
app:iconSize="22dp"
|
||||
app:backgroundTint="?attr/colorSecondaryContainer"
|
||||
app:iconTint="?attr/colorOnSecondaryContainer"
|
||||
app:rippleColor="?attr/colorOnSecondaryContainer"
|
||||
app:layout_constraintEnd_toStartOf="@+id/fab"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
android:layout_marginEnd="8dp" />
|
||||
|
||||
<!-- Connect / Disconnect icon button -->
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/fab"
|
||||
style="@style/Widget.ActionSquareButton"
|
||||
android:layout_width="52dp"
|
||||
android:layout_height="52dp"
|
||||
android:contentDescription="@string/tasker_start_service"
|
||||
app:icon="@drawable/shield_24"
|
||||
app:iconSize="26dp"
|
||||
app:backgroundTint="?attr/colorPrimary"
|
||||
app:iconTint="?attr/colorOnPrimary"
|
||||
app:rippleColor="?attr/colorOnPrimary"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent" />
|
||||
|
||||
<!-- Status pill: dot + text, tappable for ping -->
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/layout_test"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="52dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:contentDescription="@string/connection_test_pending"
|
||||
app:cardBackgroundColor="?attr/colorSurfaceContainerHigh"
|
||||
app:cardCornerRadius="26dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toStartOf="@+id/btn_summary_lite"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:layout_constraintBottom_toBottomOf="parent">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical"
|
||||
android:clipChildren="false"
|
||||
android:clipToPadding="false"
|
||||
android:paddingStart="16dp"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<View
|
||||
android:id="@+id/status_dot"
|
||||
android:layout_width="10dp"
|
||||
android:layout_height="10dp"
|
||||
android:background="@drawable/status_dot"
|
||||
android:layout_marginEnd="10dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_test_state"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:maxLines="1"
|
||||
android:ellipsize="end"
|
||||
android:text="@string/connection_test_pending"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</androidx.coordinatorlayout.widget.CoordinatorLayout>
|
||||
</RelativeLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Navigation Drawer -->
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:id="@+id/drawer_content_layout"
|
||||
android:layout_width="280dp"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="start"
|
||||
android:background="?android:attr/windowBackground"
|
||||
android:background="?attr/colorSurfaceContainerLow"
|
||||
android:orientation="vertical">
|
||||
|
||||
<com.google.android.material.navigation.NavigationView
|
||||
@@ -161,6 +224,7 @@
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@android:color/transparent"
|
||||
app:headerLayout="@layout/nav_header" />
|
||||
|
||||
<LinearLayout
|
||||
@@ -169,61 +233,65 @@
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/drawer_settings"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="start|center_vertical"
|
||||
android:drawablePadding="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:text="@string/title_settings"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
app:iconGravity="start"
|
||||
app:iconTint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/drawer_per_app"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="start|center_vertical"
|
||||
android:drawablePadding="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="12dp"
|
||||
android:text="@string/per_app_proxy_settings"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
app:iconGravity="start"
|
||||
app:iconTint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
<com.google.android.material.button.MaterialButton
|
||||
android:id="@+id/drawer_check_update"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="start|center_vertical"
|
||||
android:drawablePadding="12dp"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingBottom="16dp"
|
||||
android:text="@string/update_check_for_update"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Body1"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true" />
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelLarge"
|
||||
android:textColor="?attr/colorOnSurface"
|
||||
app:iconGravity="start"
|
||||
app:iconTint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginBottom="12dp"
|
||||
android:background="@color/divider_color_light" />
|
||||
android:background="?attr/colorOutlineVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_forked"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/drawer_forked_text"
|
||||
android:textColor="#9E9E9E"
|
||||
android:textColorLink="#9E9E9E"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:textColorLink="?attr/colorPrimary"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:linksClickable="true"
|
||||
android:textIsSelectable="true" />
|
||||
|
||||
@@ -233,8 +301,9 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="@string/drawer_developed_text"
|
||||
android:textColor="#9E9E9E"
|
||||
android:textColorLink="#9E9E9E"
|
||||
android:textColor="?attr/colorOnSurfaceVariant"
|
||||
android:textColorLink="?attr/colorPrimary"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:linksClickable="true"
|
||||
android:textIsSelectable="true" />
|
||||
</LinearLayout>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,44 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:id="@+id/item_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingTop="6dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingBottom="6dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/info_container"
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:id="@+id/card_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:nextFocusRight="@+id/layout_share"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="@dimen/padding_spacing_dp4"
|
||||
android:paddingTop="@dimen/padding_spacing_dp8"
|
||||
android:paddingEnd="@dimen/padding_spacing_dp4"
|
||||
android:paddingBottom="@dimen/padding_spacing_dp8">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_indicator"
|
||||
android:layout_width="@dimen/padding_spacing_dp4"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_gravity="center"
|
||||
android:orientation="vertical" />
|
||||
app:cardBackgroundColor="@android:color/transparent"
|
||||
app:cardCornerRadius="14dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:id="@+id/info_container"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
android:layout_weight="1"
|
||||
android:layout_gravity="center"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:nextFocusRight="@+id/layout_share"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="0dp"
|
||||
android:paddingTop="13dp"
|
||||
android:paddingEnd="4dp"
|
||||
android:paddingBottom="13dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
@@ -47,51 +49,78 @@
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="@dimen/padding_spacing_dp8">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="2"
|
||||
android:minLines="1"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="@dimen/padding_spacing_dp8">
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/layout_subscription"
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_marginEnd="@dimen/padding_spacing_dp4"
|
||||
android:background="@drawable/ic_circle">
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="12dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_subscription"
|
||||
android:id="@+id/tv_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
android:textSize="11sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
android:maxLines="2"
|
||||
android:minLines="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge"
|
||||
android:textColor="?attr/colorOnSurface" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_statistics"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:lines="1"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="5dp">
|
||||
|
||||
<androidx.constraintlayout.widget.ConstraintLayout
|
||||
android:id="@+id/layout_subscription"
|
||||
android:layout_width="18dp"
|
||||
android:layout_height="18dp"
|
||||
android:layout_gravity="bottom"
|
||||
android:layout_marginEnd="4dp"
|
||||
android:background="@drawable/ic_circle">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_subscription"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
|
||||
android:textSize="10sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_statistics"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:lines="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_test_result"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="6dp"
|
||||
android:lines="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
|
||||
android:textColor="@color/colorPing"
|
||||
android:textSize="11sp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -99,37 +128,41 @@
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="@dimen/padding_spacing_dp8"
|
||||
android:paddingTop="@dimen/padding_spacing_dp8"
|
||||
android:paddingEnd="@dimen/padding_spacing_dp8">
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="match_parent"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:paddingEnd="4dp">
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_test_result"
|
||||
<ImageView
|
||||
android:id="@+id/iv_favorite"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:lines="1"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small"
|
||||
android:textColor="@color/colorPing"
|
||||
android:textSize="11sp"
|
||||
tools:text="214ms" />
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:padding="6dp"
|
||||
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="6dp"
|
||||
android:visibility="gone"
|
||||
app:srcCompat="@drawable/ic_copy"
|
||||
app:tint="?attr/colorPrimary" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -4,110 +4,127 @@
|
||||
android:id="@+id/item_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/info_container"
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:nextFocusRight="@+id/layout_edit"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp8">
|
||||
app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:id="@+id/info_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp8">
|
||||
android:layout_gravity="center"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:nextFocusRight="@+id/layout_edit"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/remarks"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/img_locked"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginStart="16dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_lock_24dp"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/remarks"
|
||||
android:id="@+id/domainIp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
android:layout_marginTop="6dp"
|
||||
android:lines="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/img_locked"
|
||||
android:layout_width="@dimen/padding_spacing_dp16"
|
||||
android:layout_height="@dimen/padding_spacing_dp16"
|
||||
android:layout_gravity="center"
|
||||
android:layout_marginStart="@dimen/padding_spacing_dp16"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_lock_24dp" />
|
||||
<TextView
|
||||
android:id="@+id/outboundTag"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:lines="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelMedium"
|
||||
android:textColor="?attr/colorPrimary" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/domainIp"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:lines="1"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/outboundTag"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:lines="1"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp8">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_edit"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/menu_item_edit_config"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:nextFocusLeft="@+id/info_container"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp8">
|
||||
android:padding="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_edit_24dp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/chk_enable"
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_edit"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:theme="@style/BrandedSwitch" />
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/menu_item_edit_config"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:nextFocusLeft="@+id/info_container"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_edit_24dp"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/chk_enable"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:theme="@style/BrandedSwitch" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
@@ -4,195 +4,214 @@
|
||||
android:id="@+id/item_bg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp"
|
||||
android:paddingTop="4dp"
|
||||
android:paddingBottom="4dp"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/info_container"
|
||||
<com.google.android.material.card.MaterialCardView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:nextFocusRight="@+id/layout_share"
|
||||
android:orientation="horizontal"
|
||||
android:padding="@dimen/padding_spacing_dp8">
|
||||
app:cardBackgroundColor="?attr/colorSurfaceContainerLow"
|
||||
app:cardCornerRadius="16dp"
|
||||
app:cardElevation="0dp"
|
||||
app:strokeWidth="0dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/info_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical">
|
||||
android:layout_gravity="center"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:nextFocusRight="@+id/layout_share"
|
||||
android:orientation="horizontal"
|
||||
android:padding="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal">
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="@dimen/padding_spacing_dp8">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="2"
|
||||
android:minLines="1"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Subhead" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_share"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/title_configuration_share"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:nextFocusLeft="@+id/info_container"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp8">
|
||||
android:paddingStart="8dp">
|
||||
|
||||
<ImageView
|
||||
<TextView
|
||||
android:id="@+id/tv_name"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_share_24dp" />
|
||||
android:layout_height="wrap_content"
|
||||
android:maxLines="2"
|
||||
android:minLines="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodyLarge" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_edit"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/menu_item_edit_config"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp8">
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_edit_24dp" />
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_share"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/title_configuration_share"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:nextFocusLeft="@+id/info_container"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_share_24dp"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_edit"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/menu_item_edit_config"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_edit_24dp"
|
||||
app:tint="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_remove"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/menu_item_del_config"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_delete_24dp"
|
||||
app:tint="?attr/colorError" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_remove"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:clickable="true"
|
||||
android:contentDescription="@string/menu_item_del_config"
|
||||
android:focusable="true"
|
||||
android:gravity="center"
|
||||
android:orientation="vertical"
|
||||
android:padding="@dimen/padding_spacing_dp8">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="@dimen/image_size_dp24"
|
||||
android:layout_height="@dimen/image_size_dp24"
|
||||
android:importantForAccessibility="no"
|
||||
app:srcCompat="@drawable/ic_delete_24dp" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp">
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_url"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="@dimen/padding_spacing_dp8"
|
||||
android:paddingEnd="@dimen/padding_spacing_dp8">
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="6dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_url"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:lines="2"
|
||||
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="6dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/chk_enable"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:theme="@style/BrandedSwitch" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:id="@+id/layout_last_updated"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="horizontal"
|
||||
android:paddingTop="@dimen/padding_spacing_dp8">
|
||||
android:layout_marginTop="6dp"
|
||||
android:paddingStart="8dp"
|
||||
android:paddingEnd="8dp"
|
||||
android:paddingBottom="4dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_url"
|
||||
android:id="@+id/tv_last_updated"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
|
||||
android:textColor="?attr/colorOnSurfaceVariant" />
|
||||
|
||||
<ProgressBar
|
||||
android:id="@+id/progress_bar"
|
||||
style="?android:attr/progressBarStyleSmall"
|
||||
android:layout_width="16dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_marginStart="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/tv_update_status"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:lines="2"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Small" />
|
||||
android:layout_marginStart="8dp"
|
||||
android:textAppearance="@style/TextAppearance.Material3.LabelSmall"
|
||||
android:visibility="gone" />
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<androidx.appcompat.widget.SwitchCompat
|
||||
android:id="@+id/chk_enable"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
app:theme="@style/BrandedSwitch" />
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/layout_last_updated"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:layout_marginTop="@dimen/padding_spacing_dp8"
|
||||
android:paddingStart="@dimen/padding_spacing_dp8"
|
||||
android:paddingEnd="@dimen/padding_spacing_dp8">
|
||||
|
||||
<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:layout_marginStart="@dimen/padding_spacing_dp8"
|
||||
android:textAppearance="@style/TextAppearance.AppCompat.Caption"
|
||||
android:visibility="gone"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
</com.google.android.material.card.MaterialCardView>
|
||||
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Button xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
<com.google.android.material.button.MaterialButton xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
style="@style/Widget.AppCompat.Button.Borderless"
|
||||
style="@style/Widget.Material3.Button.TextButton"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:onClick="onModeHelpClicked"
|
||||
android:text="@string/title_mode_help"
|
||||
android:textAlignment="textStart"
|
||||
android:textStyle="italic"
|
||||
android:textColor="?attr/colorPrimary"
|
||||
app:iconTint="?attr/colorPrimary"
|
||||
tools:ignore="UsingOnClickInXml" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 10 KiB |
@@ -1,29 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="color_fab_active">#90CAF9</color>
|
||||
<color name="color_fab_inactive">#646464</color>
|
||||
<color name="divider_color_light">#424242</color>
|
||||
<color name="colorPing">#90CAF9</color>
|
||||
<color name="colorPingGood">#86D993</color>
|
||||
<color name="colorPingMedium">#F1C76A</color>
|
||||
<color name="status_connected">#66BB6A</color>
|
||||
<color name="colorPingRed">#FFB4AB</color>
|
||||
<color name="color_fab_active">@color/md_theme_primary</color>
|
||||
<color name="color_fab_inactive">@color/md_theme_secondaryContainer</color>
|
||||
<color name="divider_color_light">@color/md_theme_outlineVariant</color>
|
||||
<color name="server_list_divider">#2649454F</color>
|
||||
|
||||
<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>
|
||||
<!-- M3 Dark scheme — Purple/Violet tonal palette -->
|
||||
<color name="md_theme_primary">#D0BCFF</color>
|
||||
<color name="md_theme_onPrimary">#381E72</color>
|
||||
<color name="md_theme_primaryContainer">#4F378B</color>
|
||||
<color name="md_theme_onPrimaryContainer">#EADDFF</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>
|
||||
<color name="md_theme_secondary">#CCC2DC</color>
|
||||
<color name="md_theme_onSecondary">#332D41</color>
|
||||
<color name="md_theme_secondaryContainer">#4A4458</color>
|
||||
<color name="md_theme_onSecondaryContainer">#E8DEF8</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">#BBDEFB</color>
|
||||
<color name="md_theme_tertiary">#EFB8C8</color>
|
||||
<color name="md_theme_onTertiary">#492532</color>
|
||||
<color name="md_theme_tertiaryContainer">#633B48</color>
|
||||
<color name="md_theme_onTertiaryContainer">#FFD8E4</color>
|
||||
|
||||
<!-- Error colors -->
|
||||
<color name="md_theme_error">#FFB4AB</color>
|
||||
<color name="md_theme_errorContainer">#93000A</color>
|
||||
<color name="md_theme_onError">#690005</color>
|
||||
<color name="md_theme_onErrorContainer">#FFDAD6</color>
|
||||
<color name="md_theme_error">#F2B8B5</color>
|
||||
<color name="md_theme_errorContainer">#8C1D18</color>
|
||||
<color name="md_theme_onError">#601410</color>
|
||||
<color name="md_theme_onErrorContainer">#F9DEDC</color>
|
||||
|
||||
<!-- Background colors -->
|
||||
<color name="md_theme_background">#1C1B1F</color>
|
||||
@@ -35,15 +42,22 @@
|
||||
<color name="md_theme_surfaceVariant">#49454F</color>
|
||||
<color name="md_theme_onSurfaceVariant">#CAC4D0</color>
|
||||
<color name="md_theme_inverseSurface">#E6E1E5</color>
|
||||
<color name="md_theme_inverseOnSurface">#1C1B1F</color>
|
||||
<color name="md_theme_inverseOnSurface">#313033</color>
|
||||
|
||||
<!-- Surface containers — M3 dark elevation tones -->
|
||||
<color name="md_theme_surfaceContainerLowest">#0F0D13</color>
|
||||
<color name="md_theme_surfaceContainerLow">#1D1B20</color>
|
||||
<color name="md_theme_surfaceContainer">#211F26</color>
|
||||
<color name="md_theme_surfaceContainerHigh">#2B2930</color>
|
||||
<color name="md_theme_surfaceContainerHighest">#36343B</color>
|
||||
|
||||
<!-- Outline colors -->
|
||||
<color name="md_theme_outline">#938F99</color>
|
||||
<color name="md_theme_outlineVariant">#49454F</color>
|
||||
|
||||
<!-- Other colors -->
|
||||
<color name="md_theme_inversePrimary">#000000</color>
|
||||
<color name="md_theme_inversePrimary">#6750A4</color>
|
||||
<color name="md_theme_shadow">#000000</color>
|
||||
<color name="md_theme_surfaceTint">#C0C0C0</color>
|
||||
<color name="md_theme_surfaceTint">#D0BCFF</color>
|
||||
<color name="md_theme_scrim">#000000</color>
|
||||
</resources>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Night theme: inherit the shared AppThemeBase and only override night-specific items -->
|
||||
<!-- Night theme: inherit shared base, only override night-specific items -->
|
||||
<style name="AppThemeDayNight" parent="AppThemeBase">
|
||||
<!-- Night mode specific overrides -->
|
||||
<item name="android:windowLightStatusBar">false</item>
|
||||
<item name="android:windowLightNavigationBar" tools:targetApi="27">false</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8" ?>
|
||||
<resources>
|
||||
<string name="app_widget_name">v2rayNG</string>
|
||||
<string name="app_tile_name">v2rayNG</string>
|
||||
<string name="app_tile_first_use">Первое использование этой функции, пожалуйста, используйте приложение, чтобы добавить сервер</string>
|
||||
<string
|
||||
name="app_tile_first_use"
|
||||
>Первое использование этой функции, пожалуйста, используйте приложение, чтобы добавить сервер</string>
|
||||
<string name="navigation_drawer_open">Открыть панель навигации</string>
|
||||
<string name="navigation_drawer_close">Закрыть панель навигации</string>
|
||||
<string name="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">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="action_stop_service">Остановить службу</string>
|
||||
<string name="migration_fail">Перенос данных не выполнен!</string>
|
||||
<string name="pull_down_to_refresh">Потяните вниз для обновления!</string>
|
||||
@@ -15,7 +21,9 @@
|
||||
<!-- Notifications -->
|
||||
<string name="notification_action_stop_v2ray">Остановить</string>
|
||||
<string name="toast_permission_denied">Разрешение не получено</string>
|
||||
<string name="toast_permission_denied_notification">Разрешение на отображение уведомлений не получено</string>
|
||||
<string
|
||||
name="toast_permission_denied_notification"
|
||||
>Разрешение на отображение уведомлений не получено</string>
|
||||
<string name="notification_action_more">Ещё…</string>
|
||||
<string name="toast_services_start">Запуск служб</string>
|
||||
<string name="toast_services_stop">Остановка служб</string>
|
||||
@@ -29,19 +37,41 @@
|
||||
<string name="menu_item_edit_config">Изменить профиль</string>
|
||||
<string name="menu_item_del_config">Удалить профиль</string>
|
||||
<string name="menu_item_import_config_qrcode">Импорт из QR-кода</string>
|
||||
<string name="menu_item_import_config_clipboard">Импорт из буфера обмена</string>
|
||||
<string
|
||||
name="menu_item_import_config_clipboard"
|
||||
>Импорт из буфера обмена</string>
|
||||
<string name="menu_item_import_config_local">Импорт из файла</string>
|
||||
<string name="menu_item_import_config_policy_group">Добавить группу политик</string>
|
||||
<string name="menu_item_import_config_manually_vmess">Ручной ввод VMess</string>
|
||||
<string name="menu_item_import_config_manually_vless">Ручной ввод VLESS</string>
|
||||
<string name="menu_item_import_config_manually_ss">Ручной ввод Shadowsocks</string>
|
||||
<string name="menu_item_import_config_manually_socks">Ручной ввод SOCKS</string>
|
||||
<string name="menu_item_import_config_manually_http">Ручной ввод HTTP</string>
|
||||
<string name="menu_item_import_config_manually_trojan">Ручной ввод Trojan</string>
|
||||
<string name="menu_item_import_config_manually_wireguard">Ручной ввод WireGuard</string>
|
||||
<string name="menu_item_import_config_manually_hysteria2">Ручной ввод Hysteria2</string>
|
||||
<string
|
||||
name="menu_item_import_config_policy_group"
|
||||
>Добавить группу политик</string>
|
||||
<string
|
||||
name="menu_item_import_config_manually_vmess"
|
||||
>Ручной ввод VMess</string>
|
||||
<string
|
||||
name="menu_item_import_config_manually_vless"
|
||||
>Ручной ввод VLESS</string>
|
||||
<string
|
||||
name="menu_item_import_config_manually_ss"
|
||||
>Ручной ввод Shadowsocks</string>
|
||||
<string
|
||||
name="menu_item_import_config_manually_socks"
|
||||
>Ручной ввод SOCKS</string>
|
||||
<string
|
||||
name="menu_item_import_config_manually_http"
|
||||
>Ручной ввод HTTP</string>
|
||||
<string
|
||||
name="menu_item_import_config_manually_trojan"
|
||||
>Ручной ввод Trojan</string>
|
||||
<string
|
||||
name="menu_item_import_config_manually_wireguard"
|
||||
>Ручной ввод WireGuard</string>
|
||||
<string
|
||||
name="menu_item_import_config_manually_hysteria2"
|
||||
>Ручной ввод Hysteria2</string>
|
||||
<string name="del_config_comfirm">Подтверждаете удаление?</string>
|
||||
<string name="del_invalid_config_comfirm">Выполните проверку перед удалением! Подтверждаете удаление?</string>
|
||||
<string
|
||||
name="del_invalid_config_comfirm"
|
||||
>Выполните проверку перед удалением! Подтверждаете удаление?</string>
|
||||
<string name="server_lab_remarks">Название</string>
|
||||
<string name="server_lab_address">Адрес</string>
|
||||
<string name="server_lab_port">Порт</string>
|
||||
@@ -82,45 +112,73 @@
|
||||
<string name="server_lab_encryption">Шифрование</string>
|
||||
<string name="server_lab_flow">Поток</string>
|
||||
<string name="server_lab_public_key">Открытый ключ</string>
|
||||
<string name="server_lab_preshared_key">Дополнительный ключ шифрования (необязательно)</string>
|
||||
<string
|
||||
name="server_lab_preshared_key"
|
||||
>Дополнительный ключ шифрования (необязательно)</string>
|
||||
<string name="server_lab_short_id">ShortID</string>
|
||||
<string name="server_lab_spider_x">SpiderX</string>
|
||||
<string name="server_lab_mldsa65_verify">mldsa65Verify</string>
|
||||
<string name="server_lab_secret_key">Закрытый ключ</string>
|
||||
<string name="server_lab_reserved">Reserved (необязательно, через запятую)</string>
|
||||
<string name="server_lab_local_address">Локальный адрес (необязательно, IPv4/IPv6 через запятую)</string>
|
||||
<string name="server_lab_local_mtu">MTU (необязательно, по умолчанию 1420)</string>
|
||||
<string
|
||||
name="server_lab_reserved"
|
||||
>Reserved (необязательно, через запятую)</string>
|
||||
<string
|
||||
name="server_lab_local_address"
|
||||
>Локальный адрес (необязательно, IPv4/IPv6 через запятую)</string>
|
||||
<string
|
||||
name="server_lab_local_mtu"
|
||||
>MTU (необязательно, по умолчанию 1420)</string>
|
||||
<string name="toast_success">Успешно</string>
|
||||
<string name="toast_failure">Ошибка</string>
|
||||
<string name="toast_none_data">Ничего нет</string>
|
||||
<string name="toast_incorrect_protocol">Неправильный протокол</string>
|
||||
<string name="toast_decoding_failed">Невозможно декодировать</string>
|
||||
<string name="title_file_chooser">Выберите профиль</string>
|
||||
<string name="toast_require_file_manager">Установите файловый менеджер</string>
|
||||
<string
|
||||
name="toast_require_file_manager"
|
||||
>Установите файловый менеджер</string>
|
||||
<string name="server_customize_config">Изменить профиль</string>
|
||||
<string name="toast_config_file_invalid">Неправильный профиль</string>
|
||||
<string name="server_lab_content">Данные</string>
|
||||
<string name="toast_none_data_clipboard">В буфере обмена нет данных</string>
|
||||
<string name="toast_invalid_url">Неправильный URL</string>
|
||||
<string name="toast_insecure_url_protocol">Не используйте небезопасный HTTP-протокол в адресе подписки</string>
|
||||
<string name="server_lab_need_inbound">Убедитесь, что входящий порт соответствует настройкам</string>
|
||||
<string
|
||||
name="toast_insecure_url_protocol"
|
||||
>Не используйте небезопасный HTTP-протокол в адресе подписки</string>
|
||||
<string
|
||||
name="server_lab_need_inbound"
|
||||
>Убедитесь, что входящий порт соответствует настройкам</string>
|
||||
<string name="toast_malformed_josn">Профиль повреждён</string>
|
||||
<string name="server_lab_request_host6">Узел (SNI) (необязательно)</string>
|
||||
<string name="toast_action_not_allowed">Это действие запрещено</string>
|
||||
<string name="server_obfs_password">Пароль obfs</string>
|
||||
<string name="server_lab_port_hop">Смена портов (переопределяет порт)</string>
|
||||
<string
|
||||
name="server_lab_port_hop"
|
||||
>Смена портов (переопределяет порт)</string>
|
||||
<string name="server_lab_port_hop_interval">Интервал смены портов</string>
|
||||
<string name="server_lab_bandwidth_down">Входящая пропускная способность (допускаются: k/m/g/t)</string>
|
||||
<string name="server_lab_bandwidth_up">Исходящая пропускная способность (допускаются: k/m/g/t)</string>
|
||||
<string
|
||||
name="server_lab_bandwidth_down"
|
||||
>Входящая пропускная способность (допускаются: k/m/g/t)</string>
|
||||
<string
|
||||
name="server_lab_bandwidth_up"
|
||||
>Исходящая пропускная способность (допускаются: k/m/g/t)</string>
|
||||
<string name="server_lab_xhttp_mode">Режим XHTTP</string>
|
||||
<string name="server_lab_xhttp_extra">Необработанный JSON XHTTP Extra, формат: { XHTTPObject }</string>
|
||||
<string name="server_lab_final_mask">finalMask raw JSON, format: { FinalMaskObject }</string>
|
||||
<string
|
||||
name="server_lab_xhttp_extra"
|
||||
>Необработанный JSON XHTTP Extra, формат: { XHTTPObject }</string>
|
||||
<string
|
||||
name="server_lab_final_mask"
|
||||
>finalMask raw JSON, format: { FinalMaskObject }</string>
|
||||
<string name="server_lab_ech_config_list">EchConfigList</string>
|
||||
<string name="server_lab_ech_force_query">EchForceQuery</string>
|
||||
<string name="server_lab_pinned_ca256">Отпечаток сертификата (SHA-256)</string>
|
||||
<string
|
||||
name="server_lab_pinned_ca256"
|
||||
>Отпечаток сертификата (SHA-256)</string>
|
||||
|
||||
<!-- UserAssetActivity -->
|
||||
<string name="toast_asset_copy_failed">Невозможно скопировать файл, используйте файловый менеджер</string>
|
||||
<string
|
||||
name="toast_asset_copy_failed"
|
||||
>Невозможно скопировать файл, используйте файловый менеджер</string>
|
||||
<string name="menu_item_add_asset">Добавить ресурс</string>
|
||||
<string name="menu_item_add_file">Добавить файлы</string>
|
||||
<string name="menu_item_add_url">Добавить URL</string>
|
||||
@@ -130,7 +188,9 @@
|
||||
<string name="title_user_asset_add_url">Добавить URL ресурса</string>
|
||||
<string name="msg_file_not_found">Файл не найден</string>
|
||||
<string name="msg_remark_is_duplicate">Название уже существует</string>
|
||||
<string name="asset_geo_files_sources">Источник геофайлов (необязательно)</string>
|
||||
<string
|
||||
name="asset_geo_files_sources"
|
||||
>Источник геофайлов (необязательно)</string>
|
||||
|
||||
<!-- PerAppProxyActivity -->
|
||||
<string name="msg_dialog_progress">Загрузка…</string>
|
||||
@@ -143,28 +203,61 @@
|
||||
<string name="msg_downloading_content">Загрузка данных</string>
|
||||
<string name="menu_item_export_proxy_app">Экспорт в буфер обмена</string>
|
||||
<string name="menu_item_import_proxy_app">Импорт из буфера обмена</string>
|
||||
<string name="per_app_proxy_settings">Выбор приложений</string>
|
||||
<string name="per_app_proxy_settings_enable">Выбор приложений</string>
|
||||
<string name="per_app_proxy_settings">Раздельное туннелирование</string>
|
||||
<string
|
||||
name="per_app_proxy_settings_enable"
|
||||
>Раздельное туннелирование</string>
|
||||
|
||||
<!-- Preferences -->
|
||||
<string name="title_settings">Настройки</string>
|
||||
<string name="title_advanced">Расширенные настройки</string>
|
||||
<string name="title_core_settings">Настройки ядра</string>
|
||||
<string name="title_vpn_settings">Настройки VPN</string>
|
||||
<string name="title_pref_per_app_proxy">Прокси для выбранных приложений</string>
|
||||
<string name="summary_pref_per_app_proxy">Основной: выбранное приложение соединяется через прокси, не выбранное — напрямую;\nРежим обхода: выбранное приложение соединяется напрямую, не выбранное — через прокси.\nЕсть возможность автоматического выбора проксируемых приложений в меню.</string>
|
||||
<string name="title_pref_per_app_proxy">Раздельное туннелирование</string>
|
||||
<string
|
||||
name="summary_pref_per_app_proxy"
|
||||
>Основной: выбранное приложение соединяется через прокси, не выбранное — напрямую;\nРежим обхода: выбранное приложение соединяется напрямую, не выбранное — через прокси.\nЕсть возможность автоматического выбора проксируемых приложений в меню.</string>
|
||||
<string name="title_pref_is_booted">Автоподключение при запуске</string>
|
||||
<string name="summary_pref_is_booted">Автоматически подключаться к выбранному серверу при запуске приложения (может оказаться неудачным)</string>
|
||||
<string
|
||||
name="summary_pref_is_booted"
|
||||
>Автоматически подключаться к выбранному серверу при запуске приложения (может оказаться неудачным)</string>
|
||||
|
||||
<string name="title_pref_auto_sort_after_test">Автосортировка профилей</string>
|
||||
<string name="summary_pref_auto_sort_after_test">Автоматическая сортировка профилей после проверки (результаты проверки могут быть неточными)</string>
|
||||
<string
|
||||
name="title_pref_auto_sort_after_test"
|
||||
>Автосортировка профилей</string>
|
||||
<string
|
||||
name="summary_pref_auto_sort_after_test"
|
||||
>Автоматическая сортировка профилей после проверки (результаты проверки могут быть неточными)</string>
|
||||
|
||||
<string
|
||||
name="title_pref_show_copy_button"
|
||||
>Показывать кнопку копирования</string>
|
||||
<string
|
||||
name="summary_pref_show_copy_button"
|
||||
>Показывать кнопку для копирования конфигурации сервера в буфер обмена</string>
|
||||
<string
|
||||
name="title_pref_show_server_ip"
|
||||
>Показывать IP / хост сервера</string>
|
||||
<string
|
||||
name="summary_pref_show_server_ip"
|
||||
>Отображать IP-адрес или хост сервера под названием</string>
|
||||
|
||||
<string name="title_mux_settings">Настройки мультиплексирования</string>
|
||||
<string name="title_pref_mux_enabled">Использовать мультиплексирование</string>
|
||||
<string name="summary_pref_mux_enabled">Быстрее, но это может привести к нестабильному соединению.\nНиже можно настроить обработку TCP, UDP и QUIC.</string>
|
||||
<string name="title_pref_mux_concurency">TCP-соединения (диапазон от 1 до 1024)</string>
|
||||
<string name="title_pref_mux_xudp_concurency">XUDP-соединения (диапазон от 1 до 1024)</string>
|
||||
<string name="title_pref_mux_xudp_quic">Обработка QUIC в мультиплексном туннеле</string>
|
||||
<string
|
||||
name="title_pref_mux_enabled"
|
||||
>Использовать мультиплексирование</string>
|
||||
<string
|
||||
name="summary_pref_mux_enabled"
|
||||
>Быстрее, но это может привести к нестабильному соединению.\nНиже можно настроить обработку TCP, UDP и QUIC.</string>
|
||||
<string
|
||||
name="title_pref_mux_concurency"
|
||||
>TCP-соединения (диапазон от 1 до 1024)</string>
|
||||
<string
|
||||
name="title_pref_mux_xudp_concurency"
|
||||
>XUDP-соединения (диапазон от 1 до 1024)</string>
|
||||
<string
|
||||
name="title_pref_mux_xudp_quic"
|
||||
>Обработка QUIC в мультиплексном туннеле</string>
|
||||
<string-array name="mux_xudp_quic_entries">
|
||||
<item>Отклонять</item>
|
||||
<item>Разрешать</item>
|
||||
@@ -172,49 +265,87 @@
|
||||
</string-array>
|
||||
|
||||
<string name="title_pref_speed_enabled">Показывать скорость</string>
|
||||
<string name="summary_pref_speed_enabled">Показывать текущую скорость в уведомлении.\nЗначок будет меняться в зависимости от использования.</string>
|
||||
<string
|
||||
name="summary_pref_speed_enabled"
|
||||
>Показывать текущую скорость в уведомлении.\nЗначок будет меняться в зависимости от использования.</string>
|
||||
|
||||
<string name="title_pref_sniffing_enabled">Анализировать пакеты</string>
|
||||
<string name="summary_pref_sniffing_enabled">Пытаться определять доменные имена в пакетах (по умолчанию включено)</string>
|
||||
<string name="title_pref_route_only_enabled">Домен только для маршрутизации</string>
|
||||
<string name="summary_pref_route_only_enabled">Использовать доменное имя только для маршрутизации и сохранять целевой адрес в виде IP.</string>
|
||||
<string
|
||||
name="summary_pref_sniffing_enabled"
|
||||
>Пытаться определять доменные имена в пакетах (по умолчанию включено)</string>
|
||||
<string
|
||||
name="title_pref_route_only_enabled"
|
||||
>Домен только для маршрутизации</string>
|
||||
<string
|
||||
name="summary_pref_route_only_enabled"
|
||||
>Использовать доменное имя только для маршрутизации и сохранять целевой адрес в виде IP.</string>
|
||||
|
||||
<string name="title_pref_local_dns_enabled">Использовать локальную DNS</string>
|
||||
<string name="summary_pref_local_dns_enabled">Обслуживание выполняется DNS-модулем ядра (в настройках маршрутизации рекомендуется выбрать режим «Все, кроме LAN и Китая»)</string>
|
||||
<string
|
||||
name="title_pref_local_dns_enabled"
|
||||
>Использовать локальную DNS</string>
|
||||
<string
|
||||
name="summary_pref_local_dns_enabled"
|
||||
>Обслуживание выполняется DNS-модулем ядра (в настройках маршрутизации рекомендуется выбрать режим «Все, кроме LAN и Китая»)</string>
|
||||
|
||||
<string name="title_pref_fake_dns_enabled">Использовать поддельную DNS</string>
|
||||
<string name="summary_pref_fake_dns_enabled">Локальная DNS возвращает поддельные IP-адреса (быстрее, но может не работать с некоторыми приложениями)</string>
|
||||
<string
|
||||
name="title_pref_fake_dns_enabled"
|
||||
>Использовать поддельную DNS</string>
|
||||
<string
|
||||
name="summary_pref_fake_dns_enabled"
|
||||
>Локальная DNS возвращает поддельные IP-адреса (быстрее, но может не работать с некоторыми приложениями)</string>
|
||||
|
||||
<string name="title_pref_prefer_ipv6">Предпочитать IPv6</string>
|
||||
<string name="summary_pref_prefer_ipv6">Использовать маршрутизацию IPv6 предпочитать IPv6-адреса</string>
|
||||
<string
|
||||
name="summary_pref_prefer_ipv6"
|
||||
>Использовать маршрутизацию IPv6 предпочитать IPv6-адреса</string>
|
||||
|
||||
<string name="title_pref_remote_dns">Удалённая DNS (UDP/TCP/HTTPS/QUIC) (необязательно)</string>
|
||||
<string
|
||||
name="title_pref_remote_dns"
|
||||
>Удалённая DNS (UDP/TCP/HTTPS/QUIC) (необязательно)</string>
|
||||
<string name="summary_pref_remote_dns">DNS</string>
|
||||
|
||||
<string name="title_pref_vpn_dns">VPN DNS (только IPv4/v6)</string>
|
||||
<string name="title_pref_vpn_bypass_lan">VPN обходит LAN</string>
|
||||
|
||||
<string name="title_pref_vpn_interface_address">Адрес интерфейса VPN</string>
|
||||
<string
|
||||
name="title_pref_vpn_interface_address"
|
||||
>Адрес интерфейса VPN</string>
|
||||
<string name="title_pref_vpn_mtu">VPN MTU (по умолчанию 1500)</string>
|
||||
|
||||
<string name="title_pref_domestic_dns">Внутренняя DNS (необязательно)</string>
|
||||
<string
|
||||
name="title_pref_domestic_dns"
|
||||
>Внутренняя DNS (необязательно)</string>
|
||||
<string name="summary_pref_domestic_dns">DNS</string>
|
||||
|
||||
<string name="title_pref_dns_hosts">Узлы DNS (формат: домен:адрес,…)</string>
|
||||
<string
|
||||
name="title_pref_dns_hosts"
|
||||
>Узлы DNS (формат: домен:адрес,…)</string>
|
||||
<string name="summary_pref_dns_hosts">домен:адрес,…</string>
|
||||
|
||||
<string name="title_pref_delay_test_url">Сервис проверки задержки</string>
|
||||
<string name="summary_pref_delay_test_url">URL</string>
|
||||
|
||||
<string name="title_pref_ip_api_url">Сервис проверки текущего соединения</string>
|
||||
<string
|
||||
name="title_pref_ip_api_url"
|
||||
>Сервис проверки текущего соединения</string>
|
||||
<string name="summary_pref_ip_api_url">URL</string>
|
||||
|
||||
<string name="title_pref_proxy_sharing_enabled">Разрешать соединения из LAN</string>
|
||||
<string name="summary_pref_proxy_sharing_enabled">Другие устройства могут подключаться, используя ваш IP-адрес, чтобы использовать локальный прокси. Используйте только в доверенной сети, чтобы избежать несанкционированного подключения.</string>
|
||||
<string name="toast_warning_pref_proxysharing_short">Доступ из LAN разрешён, убедитесь, что вы находитесь в доверенной сети</string>
|
||||
<string
|
||||
name="title_pref_proxy_sharing_enabled"
|
||||
>Разрешать соединения из LAN</string>
|
||||
<string
|
||||
name="summary_pref_proxy_sharing_enabled"
|
||||
>Другие устройства могут подключаться, используя ваш IP-адрес, чтобы использовать локальный прокси. Используйте только в доверенной сети, чтобы избежать несанкционированного подключения.</string>
|
||||
<string
|
||||
name="toast_warning_pref_proxysharing_short"
|
||||
>Доступ из LAN разрешён, убедитесь, что вы находитесь в доверенной сети</string>
|
||||
|
||||
<string name="title_pref_allow_insecure">Разрешать небезопасные соединения</string>
|
||||
<string name="summary_pref_allow_insecure">Для TLS по умолчанию разрешены небезопасные соединения</string>
|
||||
<string
|
||||
name="title_pref_allow_insecure"
|
||||
>Разрешать небезопасные соединения</string>
|
||||
<string
|
||||
name="summary_pref_allow_insecure"
|
||||
>Для TLS по умолчанию разрешены небезопасные соединения</string>
|
||||
|
||||
<string name="title_pref_socks_port">Порт локального прокси</string>
|
||||
<string name="summary_pref_socks_port">Порт локального прокси</string>
|
||||
@@ -222,25 +353,50 @@
|
||||
<string name="title_pref_local_dns_port">Порт локальной DNS</string>
|
||||
<string name="summary_pref_local_dns_port">Порт локальной DNS</string>
|
||||
|
||||
<string name="title_pref_confirm_remove">Подтверждать удаление профиля</string>
|
||||
<string name="summary_pref_confirm_remove">Обязательное подтверждение удаления профиля</string>
|
||||
<string
|
||||
name="title_pref_confirm_remove"
|
||||
>Подтверждать удаление профиля</string>
|
||||
<string
|
||||
name="summary_pref_confirm_remove"
|
||||
>Обязательное подтверждение удаления профиля</string>
|
||||
|
||||
<string name="title_pref_start_scan_immediate">Сканировать при запуске</string>
|
||||
<string name="summary_pref_start_scan_immediate">Начинать сканирование сразу при запуске приложения или запускать функцию сканирования камерой или из изображения через панель инструментов</string>
|
||||
<string
|
||||
name="title_pref_start_scan_immediate"
|
||||
>Сканировать при запуске</string>
|
||||
<string
|
||||
name="summary_pref_start_scan_immediate"
|
||||
>Начинать сканирование сразу при запуске приложения или запускать функцию сканирования камерой или из изображения через панель инструментов</string>
|
||||
|
||||
<string name="title_pref_append_http_proxy">Дополнительный HTTP-прокси</string>
|
||||
<string name="summary_pref_append_http_proxy">HTTP-прокси будет использоваться напрямую (из браузера и других поддерживающих приложений), минуя виртуальный сетевой адаптер (Android 10+)</string>
|
||||
<string
|
||||
name="title_pref_append_http_proxy"
|
||||
>Дополнительный HTTP-прокси</string>
|
||||
<string
|
||||
name="summary_pref_append_http_proxy"
|
||||
>HTTP-прокси будет использоваться напрямую (из браузера и других поддерживающих приложений), минуя виртуальный сетевой адаптер (Android 10+)</string>
|
||||
|
||||
<string name="title_pref_double_column_display">Профили в два столбца</string>
|
||||
<string name="summary_pref_double_column_display">Список профилей отображается двумя столбцами, что позволяет показать больше информации на экране. Требуется перезапуск приложения.</string>
|
||||
<string
|
||||
name="title_pref_double_column_display"
|
||||
>Профили в два столбца</string>
|
||||
<string
|
||||
name="summary_pref_double_column_display"
|
||||
>Список профилей отображается двумя столбцами, что позволяет показать больше информации на экране. Требуется перезапуск приложения.</string>
|
||||
|
||||
<string name="title_pref_group_all_display">Общая вкладка групп</string>
|
||||
<string name="summary_pref_group_all_display">Показывать дополнительную вкладку со всеми профилями групп</string>
|
||||
<string
|
||||
name="summary_pref_group_all_display"
|
||||
>Показывать дополнительную вкладку со всеми профилями групп</string>
|
||||
|
||||
<!-- AboutActivity -->
|
||||
<string name="title_pref_feedback">Обратная связь</string>
|
||||
<string name="summary_pref_feedback">Предложить улучшение или сообщить об ошибке на GitHub</string>
|
||||
<string name="summary_pref_tg_group">Присоединиться к группе в Telegram</string>
|
||||
<string name="toast_tg_app_not_found">Приложение Telegram не найдено</string>
|
||||
<string
|
||||
name="summary_pref_feedback"
|
||||
>Предложить улучшение или сообщить об ошибке на GitHub</string>
|
||||
<string
|
||||
name="summary_pref_tg_group"
|
||||
>Присоединиться к группе в Telegram</string>
|
||||
<string
|
||||
name="toast_tg_app_not_found"
|
||||
>Приложение Telegram не найдено</string>
|
||||
<string name="title_privacy_policy">Политика конфиденциальности</string>
|
||||
<string name="title_about">О приложении</string>
|
||||
<string name="title_source_code">Исходный код</string>
|
||||
@@ -249,28 +405,57 @@
|
||||
|
||||
<string name="title_pref_promotion">Содействие</string>
|
||||
|
||||
<string name="title_pref_auto_update_subscription">Автоматически обновлять подписки</string>
|
||||
<string name="summary_pref_auto_update_subscription">Автоматическое обновление подписок в фоновом режиме с указанным интервалом. В зависимости от устройства эта функция может работать не всегда.</string>
|
||||
<string name="title_pref_auto_update_interval">Интервал автообновления (минут, не менее 15)</string>
|
||||
<string
|
||||
name="title_pref_auto_update_subscription"
|
||||
>Автоматически обновлять подписки</string>
|
||||
<string
|
||||
name="summary_pref_auto_update_subscription"
|
||||
>Автоматическое обновление подписок в фоновом режиме с указанным интервалом. В зависимости от устройства эта функция может работать не всегда.</string>
|
||||
<string
|
||||
name="title_pref_auto_update_interval"
|
||||
>Интервал автообновления (минут, не менее 15)</string>
|
||||
|
||||
<string name="title_core_loglevel">Подробность ведения журнала</string>
|
||||
<string name="title_outbound_domain_resolve_method">Предопределение исходящего домена</string>
|
||||
<string
|
||||
name="title_outbound_domain_resolve_method"
|
||||
>Предопределение исходящего домена</string>
|
||||
<string name="title_mode">Режим</string>
|
||||
<string name="title_mode_help">Нажмите для получения дополнительной информации</string>
|
||||
<string
|
||||
name="title_mode_help"
|
||||
>Нажмите для получения дополнительной информации</string>
|
||||
<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>
|
||||
<string name="title_pref_hev_tunnel_rw_timeout">Ожидание чтения/записи HevTun (секунд, по умолчанию TCP,UDP 300,60)</string>
|
||||
<string
|
||||
name="summary_pref_use_hev_tunnel"
|
||||
>Если включено, TUN будет использовать hev-socks5-tunnel; иначе будет использован xray-core</string>
|
||||
<string
|
||||
name="title_pref_hev_tunnel_loglevel"
|
||||
>Подробность журнала HevTun</string>
|
||||
<string
|
||||
name="title_pref_hev_tunnel_rw_timeout"
|
||||
>Ожидание чтения/записи HevTun (секунд, по умолчанию TCP,UDP 300,60)</string>
|
||||
|
||||
<string name="title_logcat">Журнал</string>
|
||||
<string name="logcat_copy">Копировать</string>
|
||||
<string name="logcat_clear">Очистить</string>
|
||||
<string name="title_service_restart">Перезапуск службы</string>
|
||||
<string name="title_del_all_config">Удалить профили</string>
|
||||
<string name="title_del_duplicate_config">Удалить дубликаты профилей</string>
|
||||
<string
|
||||
name="title_del_duplicate_config"
|
||||
>Удалить дубликаты профилей</string>
|
||||
<string name="title_del_invalid_config">Удалить нерабочие профили</string>
|
||||
<string name="title_export_all">Экспорт профилей в буфер обмена</string>
|
||||
<string name="title_sub_setting">Группы</string>
|
||||
@@ -280,26 +465,44 @@
|
||||
<string name="sub_setting_filter">Название фильтра</string>
|
||||
<string name="sub_setting_enable">Использовать обновление</string>
|
||||
<string name="sub_auto_update">Использовать автообновление</string>
|
||||
<string name="sub_allow_insecure_url">Разрешать незащищённые HTTP-адреса</string>
|
||||
<string
|
||||
name="sub_allow_insecure_url"
|
||||
>Разрешать незащищённые HTTP-адреса</string>
|
||||
<string name="sub_setting_pre_profile">Предыдущий профиль прокси</string>
|
||||
<string name="sub_setting_next_profile">Следующий профиль прокси</string>
|
||||
<string name="sub_setting_pre_profile_tip">Профиль должен быть уникальным</string>
|
||||
<string
|
||||
name="sub_setting_pre_profile_tip"
|
||||
>Профиль должен быть уникальным</string>
|
||||
<string name="title_sub_update">Обновить подписку</string>
|
||||
<string name="title_ping_all_server">Проверить профили</string>
|
||||
<string name="title_real_ping_all_server">Проверить задержку профилей</string>
|
||||
<string
|
||||
name="title_real_ping_all_server"
|
||||
>Проверить задержку профилей</string>
|
||||
<string name="title_user_asset_setting">Файлы ресурсов</string>
|
||||
<string name="title_sort_by_test_results">Сортировать по результатам теста</string>
|
||||
<string
|
||||
name="title_sort_by_test_results"
|
||||
>Сортировать по результатам теста</string>
|
||||
<string name="title_filter_config">Фильтр профилей</string>
|
||||
<string name="filter_config_all">Все</string>
|
||||
<string name="title_del_duplicate_config_count">Удалено дубликатов профилей: %d</string>
|
||||
<string
|
||||
name="title_del_duplicate_config_count"
|
||||
>Удалено дубликатов профилей: %d</string>
|
||||
<string name="title_del_config_count">Удалено профилей: %d</string>
|
||||
<string name="title_import_config_count">Импортировано профилей: %d</string>
|
||||
<string name="title_export_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>
|
||||
<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>
|
||||
<string name="toast_fragment_not_available">Фрагмент недоступен</string>
|
||||
<string name="title_locate_selected_config">Найти выбранный профиль</string>
|
||||
|
||||
@@ -309,17 +512,33 @@
|
||||
<!-- RoutingSettingActivity -->
|
||||
<string name="routing_settings_domain_strategy">Доменная стратегия</string>
|
||||
<string name="routing_settings_title">Маршрутизация</string>
|
||||
<string name="routing_settings_tips">Введите требуемые домены/IP через запятую</string>
|
||||
<string
|
||||
name="routing_settings_tips"
|
||||
>Введите требуемые домены/IP через запятую</string>
|
||||
<string name="routing_settings_save">Сохранить</string>
|
||||
<string name="routing_settings_delete">Очистить</string>
|
||||
<string name="routing_settings_rule_title">Настройка правил маршрутизации</string>
|
||||
<string
|
||||
name="routing_settings_rule_title"
|
||||
>Настройка правил маршрутизации</string>
|
||||
<string name="routing_settings_add_rule">Добавить правило</string>
|
||||
<string name="routing_settings_import_predefined_rulesets">Импорт набора правил</string>
|
||||
<string name="routing_settings_import_rulesets_tip">Существующие правила будут удалены. Продолжить?</string>
|
||||
<string name="routing_settings_import_rulesets_from_clipboard">Импорт правил из буфера обмена</string>
|
||||
<string name="routing_settings_import_rulesets_from_qrcode">Импорт правил из QR-кода</string>
|
||||
<string name="routing_settings_export_rulesets_to_clipboard">Экспорт правил в буфер обмена</string>
|
||||
<string name="routing_settings_locked">Постоянное (сохранится при импорте правил)</string>
|
||||
<string
|
||||
name="routing_settings_import_predefined_rulesets"
|
||||
>Импорт набора правил</string>
|
||||
<string
|
||||
name="routing_settings_import_rulesets_tip"
|
||||
>Существующие правила будут удалены. Продолжить?</string>
|
||||
<string
|
||||
name="routing_settings_import_rulesets_from_clipboard"
|
||||
>Импорт правил из буфера обмена</string>
|
||||
<string
|
||||
name="routing_settings_import_rulesets_from_qrcode"
|
||||
>Импорт правил из QR-кода</string>
|
||||
<string
|
||||
name="routing_settings_export_rulesets_to_clipboard"
|
||||
>Экспорт правил в буфер обмена</string>
|
||||
<string
|
||||
name="routing_settings_locked"
|
||||
>Постоянное (сохранится при импорте правил)</string>
|
||||
<string name="routing_settings_domain">Домен</string>
|
||||
<string name="routing_settings_ip">IP</string>
|
||||
<string name="routing_settings_port">Порт</string>
|
||||
@@ -332,44 +551,71 @@
|
||||
<string name="connection_test_pending">Проверить соединение</string>
|
||||
<string name="connection_test_testing">Проверка…</string>
|
||||
<string name="connection_test_testing_count">Проверка профилей (%d)</string>
|
||||
<string name="connection_test_available">Успешно: соединение заняло %d мс</string>
|
||||
<string name="connection_test_error">Сбой проверки интернет-соединения: %s</string>
|
||||
<string name="connection_test_available">%d мс</string>
|
||||
<string
|
||||
name="connection_test_error"
|
||||
>Сбой проверки интернет-соединения: %s</string>
|
||||
<string name="connection_test_fail">Интернет недоступен</string>
|
||||
<string name="connection_test_error_status_code">Код ошибки: #%d</string>
|
||||
<string name="connection_connected">Соединено, нажмите для проверки</string>
|
||||
<string name="connection_not_connected">Нет соединения</string>
|
||||
<string name="connection_runing_task_left">Запущено проверок: %s</string>
|
||||
<string name="connection_connected">нажмите для проверки</string>
|
||||
<string name="connection_not_connected">Ожидаем действий</string>
|
||||
<string name="connection_updating_profiles">Обновление профилей…</string>
|
||||
<string name="connection_runing_task_left">Проверено %s</string>
|
||||
|
||||
<string name="import_subscription_success">Подписка импортирована</string>
|
||||
<string name="import_subscription_failure">Невозможно импортировать подписку</string>
|
||||
<string
|
||||
name="import_subscription_failure"
|
||||
>Невозможно импортировать подписку</string>
|
||||
<string name="title_fragment_settings">Настройки фрагментирования</string>
|
||||
<string name="title_pref_fragment_packets">Фрагментирование пакетов</string>
|
||||
<string name="title_pref_fragment_length">Длина фрагмента (от - до)</string>
|
||||
<string name="title_pref_fragment_interval">Интервал фрагментов (от - до)</string>
|
||||
<string name="title_pref_fragment_enabled">Использовать фрагментирование</string>
|
||||
<string
|
||||
name="title_pref_fragment_interval"
|
||||
>Интервал фрагментов (от - до)</string>
|
||||
<string
|
||||
name="title_pref_fragment_enabled"
|
||||
>Использовать фрагментирование</string>
|
||||
|
||||
<string name="update_check_for_update">Проверить обновление</string>
|
||||
<string name="update_already_latest_version">Установлена последняя версия</string>
|
||||
<string
|
||||
name="update_already_latest_version"
|
||||
>Установлена последняя версия</string>
|
||||
<string name="update_new_version_found">Найдена новая версия: %s</string>
|
||||
<string name="update_now">Обновить</string>
|
||||
<string name="update_check_pre_release">Искать предварительный выпуск</string>
|
||||
<string
|
||||
name="update_check_pre_release"
|
||||
>Искать предварительный выпуск</string>
|
||||
<string name="update_checking_for_update">Проверка обновления…</string>
|
||||
|
||||
<string name="title_policy_group_type">Тип группы политик</string>
|
||||
<string name="title_policy_group_subscription_id">Из группы подписки</string>
|
||||
<string name="title_policy_group_subscription_filter">Название фильтра</string>
|
||||
<string
|
||||
name="title_policy_group_subscription_id"
|
||||
>Из группы подписки</string>
|
||||
<string
|
||||
name="title_policy_group_subscription_filter"
|
||||
>Название фильтра</string>
|
||||
|
||||
<!-- BackupActivity -->
|
||||
<string name="title_configuration_backup_restore">Резервное копирование</string>
|
||||
<string name="title_configuration_backup">Резервирование конфигурации</string>
|
||||
<string name="title_configuration_restore">Восстановление конфигурации</string>
|
||||
<string
|
||||
name="title_configuration_backup_restore"
|
||||
>Резервное копирование</string>
|
||||
<string
|
||||
name="title_configuration_backup"
|
||||
>Резервирование конфигурации</string>
|
||||
<string
|
||||
name="title_configuration_restore"
|
||||
>Восстановление конфигурации</string>
|
||||
<string name="title_configuration_share">Поделиться конфигурацией</string>
|
||||
<string name="title_webdav_config_setting">Настройки WebDAV</string>
|
||||
<string name="title_webdav_config_setting_unknown">Необходимо настроить WebDAV</string>
|
||||
<string
|
||||
name="title_webdav_config_setting_unknown"
|
||||
>Необходимо настроить WebDAV</string>
|
||||
<string name="title_webdav_url">URL сервера</string>
|
||||
<string name="title_webdav_user">Пользователь</string>
|
||||
<string name="title_webdav_pass">Пароль</string>
|
||||
<string name="title_webdav_remote_path">Удалённый путь (необязательно)</string>
|
||||
<string
|
||||
name="title_webdav_remote_path"
|
||||
>Удалённый путь (необязательно)</string>
|
||||
|
||||
<string-array name="share_method">
|
||||
<item>QR-код</item>
|
||||
|
||||
@@ -1,7 +1,3 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
|
||||
<style name="TabLayoutTextStyle" parent="TextAppearance.Design.Tab">
|
||||
<item name="textAllCaps">false</item>
|
||||
</style>
|
||||
</resources>
|
||||
</resources>
|
||||
|
||||
@@ -1,54 +1,66 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="colorPing">#1565C0</color>
|
||||
<color name="colorPingRed">#FF0099</color>
|
||||
<color name="colorConfigType">#1976D2</color>
|
||||
<color name="colorPing">#006494</color>
|
||||
<color name="colorPingGood">#2E7D32</color>
|
||||
<color name="colorPingMedium">#9A6700</color>
|
||||
<color name="status_connected">#4CAF50</color>
|
||||
<color name="colorPingRed">#BA1A1A</color>
|
||||
<color name="colorConfigType">#1565C0</color>
|
||||
<color name="colorWhite">#FFFFFF</color>
|
||||
<color name="color_fab_active">#1976D2</color>
|
||||
<color name="color_fab_inactive">#9C9C9C</color>
|
||||
<color name="divider_color_light">#E0E0E0</color>
|
||||
<color name="color_fab_active">@color/md_theme_primary</color>
|
||||
<color name="color_fab_inactive">@color/md_theme_secondaryContainer</color>
|
||||
<color name="divider_color_light">@color/md_theme_outlineVariant</color>
|
||||
<color name="server_list_divider">#22CAC4D0</color>
|
||||
<color name="colorIndicator">@color/md_theme_primary</color>
|
||||
|
||||
<color name="md_theme_primary">#000000</color>
|
||||
<!-- M3 Light scheme — Purple/Violet tonal palette -->
|
||||
<color name="md_theme_primary">#6750A4</color>
|
||||
<color name="md_theme_onPrimary">#FFFFFF</color>
|
||||
<color name="md_theme_primaryContainer">#E0E0E0</color>
|
||||
<color name="md_theme_onPrimaryContainer">#000000</color>
|
||||
<color name="md_theme_primaryContainer">#EADDFF</color>
|
||||
<color name="md_theme_onPrimaryContainer">#21005D</color>
|
||||
|
||||
<color name="md_theme_secondary">#1976D2</color>
|
||||
<color name="md_theme_secondary">#625B71</color>
|
||||
<color name="md_theme_onSecondary">#FFFFFF</color>
|
||||
<color name="md_theme_secondaryContainer">#FFE8D6</color>
|
||||
<color name="md_theme_onSecondaryContainer">#2B1700</color>
|
||||
<color name="md_theme_secondaryContainer">#E8DEF8</color>
|
||||
<color name="md_theme_onSecondaryContainer">#1D192B</color>
|
||||
|
||||
<color name="md_theme_tertiary">#1565C0</color>
|
||||
<color name="md_theme_tertiary">#7D5260</color>
|
||||
<color name="md_theme_onTertiary">#FFFFFF</color>
|
||||
<color name="md_theme_tertiaryContainer">#BBDEFB</color>
|
||||
<color name="md_theme_onTertiaryContainer">#00201A</color>
|
||||
<color name="md_theme_tertiaryContainer">#FFD8E4</color>
|
||||
<color name="md_theme_onTertiaryContainer">#31111D</color>
|
||||
|
||||
<!-- Error colors -->
|
||||
<color name="md_theme_error">#BA1A1A</color>
|
||||
<color name="md_theme_errorContainer">#FFDAD6</color>
|
||||
<color name="md_theme_error">#B3261E</color>
|
||||
<color name="md_theme_errorContainer">#F9DEDC</color>
|
||||
<color name="md_theme_onError">#FFFFFF</color>
|
||||
<color name="md_theme_onErrorContainer">#410002</color>
|
||||
<color name="md_theme_onErrorContainer">#410E0B</color>
|
||||
|
||||
<!-- Background colors -->
|
||||
<color name="md_theme_background">#FFFFFF</color>
|
||||
<color name="md_theme_background">#FFFBFE</color>
|
||||
<color name="md_theme_onBackground">#1C1B1F</color>
|
||||
|
||||
<!-- Surface colors -->
|
||||
<color name="md_theme_surface">#FFFFFF</color>
|
||||
<!-- Surface colors — M3 tonal surface hierarchy -->
|
||||
<color name="md_theme_surface">#FFFBFE</color>
|
||||
<color name="md_theme_onSurface">#1C1B1F</color>
|
||||
<color name="md_theme_surfaceVariant">#E7E0EC</color>
|
||||
<color name="md_theme_onSurfaceVariant">#49454F</color>
|
||||
<color name="md_theme_inverseSurface">#313033</color>
|
||||
<color name="md_theme_inverseOnSurface">#F4EFF4</color>
|
||||
|
||||
<!-- Surface containers — M3 elevation tones -->
|
||||
<color name="md_theme_surfaceContainerLowest">#FFFFFF</color>
|
||||
<color name="md_theme_surfaceContainerLow">#F7F2FA</color>
|
||||
<color name="md_theme_surfaceContainer">#F3EDF7</color>
|
||||
<color name="md_theme_surfaceContainerHigh">#ECE6F0</color>
|
||||
<color name="md_theme_surfaceContainerHighest">#E6E0E9</color>
|
||||
|
||||
<!-- Outline colors -->
|
||||
<color name="md_theme_outline">#79747E</color>
|
||||
<color name="md_theme_outlineVariant">#CAC4D0</color>
|
||||
|
||||
<!-- Other colors -->
|
||||
<color name="md_theme_inversePrimary">#C0C0C0</color>
|
||||
<color name="md_theme_inversePrimary">#D0BCFF</color>
|
||||
<color name="md_theme_shadow">#000000</color>
|
||||
<color name="md_theme_surfaceTint">#000000</color>
|
||||
<color name="md_theme_surfaceTint">#6750A4</color>
|
||||
<color name="md_theme_scrim">#000000</color>
|
||||
</resources>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<?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>
|
||||
@@ -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 — остальные приложения, например банки, будут идти напрямую через ваш домашний интернет.\n\nВ режиме обхода всё зеркально — выбранные приложения не используют VPN.</string>
|
||||
|
||||
<!-- Preferences -->
|
||||
<string name="title_settings">Settings</string>
|
||||
@@ -160,6 +161,11 @@
|
||||
<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_pref_show_server_ip">Show server IP / host</string>
|
||||
<string name="summary_pref_show_server_ip">Display the server IP address or host under the server name</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>
|
||||
@@ -266,11 +272,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>
|
||||
@@ -337,14 +348,15 @@
|
||||
|
||||
<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_updating_profiles">Updating profiles…</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,58 +1,65 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<!-- Common base theme: put all shared items here so day/night can reuse -->
|
||||
<!-- Common base theme -->
|
||||
<style name="AppThemeBase" parent="Theme.Material3.DayNight">
|
||||
<!-- Primary colors - main tone: black -->
|
||||
<!-- Primary -->
|
||||
<item name="colorPrimary">@color/md_theme_primary</item>
|
||||
<item name="colorOnPrimary">@color/md_theme_onPrimary</item>
|
||||
<item name="colorPrimaryContainer">@color/md_theme_primaryContainer</item>
|
||||
<item name="colorOnPrimaryContainer">@color/md_theme_onPrimaryContainer</item>
|
||||
|
||||
<!-- Secondary -->
|
||||
<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 -->
|
||||
<item name="colorTertiary">@color/md_theme_tertiary</item>
|
||||
<item name="colorOnTertiary">@color/md_theme_onTertiary</item>
|
||||
<item name="colorTertiaryContainer">@color/md_theme_tertiaryContainer</item>
|
||||
<item name="colorOnTertiaryContainer">@color/md_theme_onTertiaryContainer</item>
|
||||
|
||||
<!-- Error colors -->
|
||||
<!-- Error -->
|
||||
<item name="colorError">@color/md_theme_error</item>
|
||||
<item name="colorOnError">@color/md_theme_onError</item>
|
||||
<item name="colorErrorContainer">@color/md_theme_errorContainer</item>
|
||||
<item name="colorOnErrorContainer">@color/md_theme_onErrorContainer</item>
|
||||
|
||||
<!-- Surface colors -->
|
||||
<!-- Surface -->
|
||||
<item name="colorSurface">@color/md_theme_surface</item>
|
||||
<item name="colorOnSurface">@color/md_theme_onSurface</item>
|
||||
<item name="colorSurfaceVariant">@color/md_theme_surfaceVariant</item>
|
||||
<item name="colorOnSurfaceVariant">@color/md_theme_onSurfaceVariant</item>
|
||||
<item name="colorSurfaceInverse">@color/md_theme_inverseSurface</item>
|
||||
<item name="colorOnSurfaceInverse">@color/md_theme_inverseOnSurface</item>
|
||||
<item name="colorSurfaceContainer">@color/md_theme_surface</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/md_theme_surface</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/md_theme_surface</item>
|
||||
<item name="colorSurfaceContainerLow">@color/md_theme_surface</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/md_theme_surface</item>
|
||||
<item name="colorSurfaceContainer">@color/md_theme_surfaceContainer</item>
|
||||
<item name="colorSurfaceContainerHigh">@color/md_theme_surfaceContainerHigh</item>
|
||||
<item name="colorSurfaceContainerHighest">@color/md_theme_surfaceContainerHighest</item>
|
||||
<item name="colorSurfaceContainerLow">@color/md_theme_surfaceContainerLow</item>
|
||||
<item name="colorSurfaceContainerLowest">@color/md_theme_surfaceContainerLowest</item>
|
||||
|
||||
<!-- Background colors -->
|
||||
<!-- Background -->
|
||||
<item name="android:colorBackground">@color/md_theme_background</item>
|
||||
<item name="colorOnBackground">@color/md_theme_onBackground</item>
|
||||
|
||||
<!-- Outline colors -->
|
||||
<!-- Outline -->
|
||||
<item name="colorOutline">@color/md_theme_outline</item>
|
||||
<item name="colorOutlineVariant">@color/md_theme_outlineVariant</item>
|
||||
|
||||
<!-- Other colors -->
|
||||
<!-- Misc -->
|
||||
<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:navigationBarColor">@color/md_theme_surface</item>
|
||||
<!-- System bars — transparent so edge-to-edge works -->
|
||||
<item name="android:statusBarColor">@android:color/transparent</item>
|
||||
<item name="android:navigationBarColor">@android:color/transparent</item>
|
||||
|
||||
<!-- Typography: use Roboto (Google's Material font) everywhere -->
|
||||
<!-- Shape — M3 uses larger corner radii -->
|
||||
<item name="shapeAppearanceSmallComponent">@style/ShapeAppearance.App.SmallComponent</item>
|
||||
<item name="shapeAppearanceMediumComponent">@style/ShapeAppearance.App.MediumComponent</item>
|
||||
<item name="shapeAppearanceLargeComponent">@style/ShapeAppearance.App.LargeComponent</item>
|
||||
|
||||
<!-- Typography: Roboto -->
|
||||
<item name="android:fontFamily">sans-serif</item>
|
||||
<item name="fontFamily">sans-serif</item>
|
||||
<item name="android:editTextStyle">@style/RobotoEditTextStyle</item>
|
||||
@@ -60,7 +67,23 @@
|
||||
<item name="android:dialogTheme">@style/RobotoAlertDialogTheme</item>
|
||||
</style>
|
||||
|
||||
<style name="RobotoEditTextStyle" parent="Widget.AppCompat.EditText">
|
||||
<!-- M3 Shape tokens -->
|
||||
<style name="ShapeAppearance.App.SmallComponent" parent="ShapeAppearance.Material3.SmallComponent">
|
||||
<item name="cornerFamily">rounded</item>
|
||||
<item name="cornerSize">8dp</item>
|
||||
</style>
|
||||
|
||||
<style name="ShapeAppearance.App.MediumComponent" parent="ShapeAppearance.Material3.MediumComponent">
|
||||
<item name="cornerFamily">rounded</item>
|
||||
<item name="cornerSize">12dp</item>
|
||||
</style>
|
||||
|
||||
<style name="ShapeAppearance.App.LargeComponent" parent="ShapeAppearance.Material3.LargeComponent">
|
||||
<item name="cornerFamily">rounded</item>
|
||||
<item name="cornerSize">16dp</item>
|
||||
</style>
|
||||
|
||||
<style name="RobotoEditTextStyle" parent="Widget.Material3.TextInputEditText.OutlinedBox">
|
||||
<item name="android:fontFamily">sans-serif</item>
|
||||
<item name="android:textSize">16sp</item>
|
||||
</style>
|
||||
@@ -68,16 +91,15 @@
|
||||
<style name="RobotoAlertDialogTheme" parent="ThemeOverlay.Material3.MaterialAlertDialog">
|
||||
<item name="android:fontFamily">sans-serif</item>
|
||||
<item name="fontFamily">sans-serif</item>
|
||||
<item name="shapeAppearanceMediumComponent">@style/ShapeAppearance.App.MediumComponent</item>
|
||||
</style>
|
||||
|
||||
<!-- Day/Night theme: inherit common values and set light-mode-specific items -->
|
||||
<!-- Light -->
|
||||
<style name="AppThemeDayNight" parent="AppThemeBase">
|
||||
<!-- day/night-specific overrides (light/default) -->
|
||||
<item name="android:windowLightStatusBar">true</item>
|
||||
<item name="android:windowLightNavigationBar" tools:targetApi="27">true</item>
|
||||
</style>
|
||||
|
||||
<!-- Theme without ActionBar -->
|
||||
<style name="AppThemeDayNight.NoActionBar" parent="AppThemeDayNight">
|
||||
<item name="windowActionBar">false</item>
|
||||
<item name="windowNoTitle">true</item>
|
||||
@@ -89,8 +111,40 @@
|
||||
<item name="android:windowIsTranslucent">true</item>
|
||||
</style>
|
||||
|
||||
<!-- Switch uses primary color -->
|
||||
<style name="BrandedSwitch" parent="AppThemeDayNight">
|
||||
<item name="colorPrimary">@color/color_fab_active</item>
|
||||
<item name="colorPrimary">@color/md_theme_primary</item>
|
||||
</style>
|
||||
|
||||
<!-- Rounded-square FAB shape overlay (16dp corners) -->
|
||||
<style name="ShapeAppearance.App.RoundedSquare" parent="ShapeAppearance.Material3.SmallComponent">
|
||||
<item name="cornerFamily">rounded</item>
|
||||
<item name="cornerSize">16dp</item>
|
||||
</style>
|
||||
|
||||
<!-- Square icon button: no insets, no minHeight, no text -->
|
||||
<style name="Widget.ActionSquareButton" parent="Widget.Material3.Button">
|
||||
<item name="android:minWidth">0dp</item>
|
||||
<item name="android:minHeight">0dp</item>
|
||||
<item name="android:insetTop">0dp</item>
|
||||
<item name="android:insetBottom">0dp</item>
|
||||
<item name="android:insetLeft">0dp</item>
|
||||
<item name="android:insetRight">0dp</item>
|
||||
<item name="android:paddingStart">0dp</item>
|
||||
<item name="android:paddingEnd">0dp</item>
|
||||
<item name="android:paddingTop">0dp</item>
|
||||
<item name="android:paddingBottom">0dp</item>
|
||||
<item name="iconGravity">textStart</item>
|
||||
<item name="iconPadding">0dp</item>
|
||||
<item name="android:text"></item>
|
||||
<item name="shapeAppearance">@style/ShapeAppearance.App.RoundedSquare</item>
|
||||
<item name="elevation">0dp</item>
|
||||
<item name="android:stateListAnimator">@null</item>
|
||||
</style>
|
||||
|
||||
<!-- Tab label -->
|
||||
<style name="TabLayoutTextStyle" parent="TextAppearance.Material3.LabelLarge">
|
||||
<item name="android:textSize">14sp</item>
|
||||
</style>
|
||||
|
||||
</resources>
|
||||
|
||||
@@ -28,6 +28,18 @@
|
||||
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" />
|
||||
|
||||
<CheckBoxPreference
|
||||
android:key="pref_show_server_ip"
|
||||
android:defaultValue="false"
|
||||
android:summary="@string/summary_pref_show_server_ip"
|
||||
android:title="@string/title_pref_show_server_ip" />
|
||||
|
||||
<ListPreference
|
||||
android:defaultValue="auto"
|
||||
android:entries="@array/language_select"
|
||||
@@ -44,6 +56,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">
|
||||
@@ -292,4 +316,4 @@
|
||||
|
||||
</PreferenceCategory>
|
||||
|
||||
</PreferenceScreen>
|
||||
</PreferenceScreen>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name" translatable="false">olcNG</string>
|
||||
<string name="app_name" translatable="false">olcng</string>
|
||||
</resources>
|
||||
@@ -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" }
|
||||
|
||||
@@ -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")
|
||||
}
|
||||