Compare commits

..

34 Commits

Author SHA1 Message Date
chen08209
23e3baf534 Optimize ip detection
Support android vpn ipv6 inbound switch

Support log export

Optimize more details
2024-09-26 14:27:52 +08:00
chen08209
d522a64604 Fix android system dns issues
Optimize dns default option

Fix some issues
2024-09-20 11:21:25 +08:00
chen08209
3902ea0064 Update readme 2024-09-18 00:42:20 +08:00
chen08209
5566f2b54f Update README.md 2 2024-09-18 00:22:31 +08:00
chen08209
511b7c521a Update README.md 2 2024-09-18 00:20:00 +08:00
chen08209
fb01d87371 Update README.md 2024-09-18 00:17:12 +08:00
chen08209
043648f998 Fix build error2 2024-09-17 23:06:32 +08:00
chen08209
3eb26e8061 Fix build error 2024-09-17 22:42:10 +08:00
chen08209
5d6bd6466f Support desktop hotkey
Support android ipv6 inbound

Support android system dns

fix some bugs
2024-09-17 21:42:13 +08:00
chen08209
4e766d9407 Fix delete profile error 2024-09-09 09:49:22 +08:00
chen08209
80f8aa22ee Fix submit error 2 2024-09-08 21:38:58 +08:00
chen08209
97714e8b25 Fix submit error 2024-09-08 21:22:02 +08:00
chen08209
50bf4170d9 Optimize DNS strategy
Fix the problem that the tray is not displayed in some cases

Optimize tray

Update core

Fix some error
2024-09-08 20:58:02 +08:00
chen08209
79efa67df3 Fix tun update issues 2024-09-02 16:42:47 +08:00
chen08209
ac397393a0 Add DNS override
Fixed some bugs
Optimize more detail
2024-09-02 16:09:51 +08:00
chen08209
b685165230 Add Hosts override 2024-09-02 16:09:27 +08:00
chen08209
402221aaa2 fix android tip error
fix windows auto launch error
2024-08-26 20:35:38 +08:00
chen08209
f6d9ed11d9 Fix windows tray issues
Optimize windows logic
2024-08-25 23:40:13 +08:00
chen08209
c38a671d57 Optimize app logic
Support windows administrator auto launch

Support android close vpn
2024-08-22 19:56:19 +08:00
chen08209
75af47aead Change flutter version 2024-08-15 14:34:02 +08:00
chen08209
8dafe3b0ec Support profiles sort
Support windows country flags display

Optimize proxies page and profiles page columns
2024-08-15 14:18:33 +08:00
chen08209
813198a21d Update flutter version 2024-08-11 17:45:57 +08:00
chen08209
68dd262fef Update version 2024-08-11 17:09:31 +08:00
chen08209
5ef020db73 Update timeout time 2024-08-11 17:08:51 +08:00
chen08209
e3c9035903 Update access control page
Fix bug
2024-08-11 17:08:46 +08:00
chen08209
7fc54c5295 Optimize provider page
Optimize delay test

Support local backup and recovery
2024-08-05 18:17:05 +08:00
chen08209
00a78b5fb4 Fix android tile service issues 2024-08-01 23:51:28 +08:00
chen08209
8cdaf30de0 Fix linux core build error 2024-07-31 21:24:31 +08:00
chen08209
f39b9cf933 Add proxy-only traffic statistics
Update core

Optimize more details
2024-07-31 21:05:16 +08:00
chen08209
9df1ff46c2 Merge pull request #140 from txyyh/main
添加自建 F-Droid 仓库相关 workflow
2024-07-29 16:48:04 +08:00
txyyh
fcbbbdc698 Rename readme fingerprint 2024-07-29 16:45:12 +08:00
txyyh
3ba8355772 Rename workflow deploy repo name 2024-07-29 16:42:25 +08:00
txyyh
f6b97f82ae Add download guide to README 2024-07-29 16:39:52 +08:00
txyyh
13ac20f273 Add push release files to fdroid-repo 2024-07-29 16:39:52 +08:00
434 changed files with 80476 additions and 120567 deletions

View File

@@ -1,57 +0,0 @@
name: 问题反馈 / Bug report
title: "[BUG] "
description: 反馈你遇到的问题 / Report the issue you are experiencing
body:
- type: markdown
attributes:
value: |
## 在提交问题之前,请确认以下事项:
1. 请务必给issue填写一个简洁明了的标题以便他人快速检索
2. 请确保[已有的问题](https://github.com/chen08209/FlClash/issues?q=is%3Aissue) 中没有人提交过相似issue否则请在已有的issue下进行讨论
3. 请务必按照模板规范详细描述问题否则issue将会被直接关闭
## Before submitting the issue, please make sure of the following checklist:
1. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
2. Please make sure there is no similar issue in the [existing issues](https://github.com/chen08209/FlClash/issues?q=is%3Aissue), otherwise please discuss under the existing issue
3. Please describe the problem in detail according to the template specification, otherwise issue will be closed directly.
- type: textarea
id: description
attributes:
label: 问题描述 / Describe the bug
description: 详细清晰地描述你遇到的问题,并配合截图 / Describe the problem you encountered in detail and clearly, and provide screenshots
validations:
required: true
- type: textarea
attributes:
label: 软件版本 / Version
description: 请提供FlClash的具体版本 / Please provide the specific version of FlClash.
validations:
required: true
- type: textarea
attributes:
label: 复现步骤 / To Reproduce
description: 请提供复现问题的步骤 / Steps to reproduce the behavior
validations:
required: true
- type: dropdown
attributes:
label: 操作系统 / OS
options:
- Android
- Windows
- MacOS
- Linux
validations:
required: true
- type: input
attributes:
label: 操作系统版本 / OS Version
description: 请提供你的操作系统版本Linux请额外提供桌面环境及窗口系统 / Please provide your OS version, for Linux, please also provide the desktop environment and window system
validations:
required: true
- type: textarea
attributes:
label: 日志(勿上传日志文件,请粘贴日志内容) / Log (Do not upload the log file, paste the log content directly)
description: 请提供完整或相关部分的Debug日志请在“软件左侧菜单”->“设置”->“日志等级”调整到debug / Please provide a complete or relevant Debug log (please adjust it to debug in the left menu of software-> Settings-> Log Level)
validations:
required: true

View File

@@ -1,4 +0,0 @@
contact_links:
- name: 讨论交流 / Communication
url: https://t.me/+G-veVtwBOl4wODc1
about: 在 Telegram 群组中与其他用户讨论交流 / Communicate with other users in the Telegram group

View File

@@ -1,42 +0,0 @@
name: 功能请求 / Feature request
title: "[Feature] "
description: 提出你的功能请求 / Propose your feature request
body:
- type: markdown
attributes:
value: |
## 在提交问题之前,请确认以下事项:
1. 请务必给issue填写一个简洁明了的标题以便他人快速检索
2. 请确保[已有的问题](https://github.com/chen08209/FlClash/issues?q=is%3Aissue) 中没有人提交过相似issue否则请在已有的issue下进行讨论
3. 请务必按照模板规范详细描述问题否则issue将会被直接关闭
## Before submitting the issue, please make sure of the following checklist:
1. Please be sure to fill in a concise and clear title for the issue so that others can quickly search
2. Please make sure there is no similar issue in the [existing issues](https://github.com/chen08209/FlClash/issues?q=is%3Aissue), otherwise please discuss under the existing issue
3. Please describe the problem in detail according to the template specification, otherwise issue will be closed directly.
- type: textarea
id: description
attributes:
label: 功能描述 / Feature description
description: 详细清晰地描述你的功能请求 / A clear and concise description of what the feature is
validations:
required: true
- type: textarea
attributes:
label: 使用场景 / Use case
description: 请描述你的功能请求的使用场景 / Please describe the use case of your feature request
validations:
required: true
- type: checkboxes
id: os-labels
attributes:
label: 适用系统 / Target OS
description: 请选择该功能适用的操作系统(至少选择一个) / Please select the operating system(s) for this feature request (select at least one)
options:
- label: Android
- label: Windows
- label: MacOS
- label: Linux
validations:
required: true

View File

@@ -1,58 +0,0 @@
<div align=center>
[![Release Downloads](https://img.shields.io/github/downloads/chen08209/FlClash/vVERSION/total?style=flat-square&logo=github)](https://img.shields.io/github/downloads/chen08209/FlClash/vVERSION/)
</div>
**Download based on your OS:**
<div align=left>
<table>
<thead align=left>
<tr>
<th>OS</th>
<th>Download</th>
</tr>
</thead>
<tbody align=left>
<tr>
<td>Android</td>
<td>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-android-arm64-v8a.apk"><img src="https://img.shields.io/badge/APK-ARMv8-168039.svg?logo=android"></a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-android-armeabi-v7a.apk"><img src="https://img.shields.io/badge/APK-ARMv7-45bf55.svg?logo=android"></a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-android-x86_64.apk"><img src="https://img.shields.io/badge/APK-x64-96ed89.svg?logo=android"></a>
</td>
</tr>
<tr>
<td>Windows</td>
<td>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-windows-amd64-setup.exe"><img src="https://img.shields.io/badge/Setup-x64-2d7d9a.svg?logo=windows"></a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-windows-amd64.zip"><img src="https://img.shields.io/badge/Portable-x64-67b7d1.svg?logo=windows"></a>
</td>
</tr>
<tr>
<td>macOS</td>
<td>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-macos-arm64.dmg"><img src="https://img.shields.io/badge/DMG-Apple%20Silicon-%23000000.svg?logo=apple"></a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-macos-amd64.dmg"><img src="https://img.shields.io/badge/DMG-Intel%20X64-%2300A9E0.svg?logo=apple"></a><br>
</td>
</tr>
<tr>
<td>Linux</td>
<td>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.AppImage"><img src="https://img.shields.io/badge/AppImage-x64-f84e29.svg?logo=linux"> </a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.deb"><img src="https://img.shields.io/badge/DebPackage-x64-FF9966.svg?logo=debian"> </a><br>
<a href="https://github.com/chen08209/FlClash/releases/download/vVERSION/FlClash-VERSION-linux-amd64.deb"><img src="https://img.shields.io/badge/RpmPackage-x64-F1B42F.svg?logo=redhat"> </a>
</td>
</tr>
</tbody>
</table>
</div>
<div dir="ltr">
**List of all changes:** [ChangeLog](https://github.com/chen08209/FlClash/blob/main/CHANGELOG.md)
</div>

View File

@@ -1,260 +0,0 @@
name: build
on:
push:
tags:
- 'v*'
env:
IS_STABLE: ${{ !contains(github.ref, '-') }}
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- platform: android
os: ubuntu-latest
- platform: windows
os: Windows-2022
arch: amd64
- platform: linux
os: ubuntu-22.04
arch: amd64
- platform: macos
os: macos-13
arch: amd64
- platform: macos
os: macos-latest
arch: arm64
- platform: windows
os: windows-11-arm
arch: arm64
- platform: linux
os: ubuntu-24.04-arm
arch: arm64
steps:
- name: Setup rust
if: startsWith(matrix.os, 'windows-11-arm')
run: |
Invoke-WebRequest -Uri "https://win.rustup.rs/aarch64" -OutFile rustup-init.exe
.\rustup-init.exe -y --default-toolchain stable
$cargoPath = "$env:USERPROFILE\.cargo\bin"
Add-Content $env:GITHUB_PATH $cargoPath
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup Android Signing
if: startsWith(matrix.platform,'android')
run: |
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
echo "${{ secrets.SERVICE_JSON }}" | base64 --decode > android/app/google-services.json
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/local.properties
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/local.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/local.properties
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.24.0'
cache-dependency-path: |
core/go.sum
- name: Setup Flutter Master
if: startsWith(matrix.os, 'windows-11-arm') || startsWith(matrix.os, 'ubuntu-24.04-arm')
uses: subosito/flutter-action@v2
with:
channel: 'master'
cache: true
- name: Setup Flutter
if: ${{ !(startsWith(matrix.os, 'windows-11-arm') || startsWith(matrix.os, 'ubuntu-24.04-arm')) }}
uses: subosito/flutter-action@v2
with:
channel: 'stable'
cache: true
# flutter-version: 3.29.3
- name: Get Flutter Dependency
run: flutter pub get
- name: Setup
run: dart setup.dart ${{ matrix.platform }} ${{ matrix.arch && format('--arch {0}', matrix.arch) }} ${{ env.IS_STABLE == 'true' && '--env stable' || '' }}
- name: Upload
uses: actions/upload-artifact@v4
with:
name: artifact-${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) }}
path: ./dist
overwrite: true
changelog:
runs-on: ubuntu-latest
needs: [ build ]
steps:
- name: Checkout
uses: actions/checkout@v4
if: ${{ env.IS_STABLE == 'true' }}
with:
fetch-depth: 0
ref: refs/heads/main
- name: Generate
if: ${{ env.IS_STABLE == 'true' }}
run: |
tags=($(git tag --merged $(git rev-parse HEAD) --sort=-creatordate))
preTag=$(grep -oP '^## \K.*' CHANGELOG.md | head -n 1)
currentTag=""
for ((i = 0; i <= ${#tags[@]}; i++)); do
if (( i < ${#tags[@]} )); then
tag=${tags[$i]}
else
tag=""
fi
if [ -n "$currentTag" ]; then
if [ "$(echo -e "$currentTag\n$preTag" | sort -V | head -n 1)" == "$currentTag" ]; then
break
fi
fi
if [ -n "$currentTag" ]; then
echo "## $currentTag" >> NEW_CHANGELOG.md
echo "" >> NEW_CHANGELOG.md
if [ -n "$tag" ]; then
git log --pretty=format:"%B" "$tag..$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> NEW_CHANGELOG.md
else
git log --pretty=format:"%B" "$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> NEW_CHANGELOG.md
fi
echo "" >> NEW_CHANGELOG.md
fi
currentTag=$tag
done
cat CHANGELOG.md >> NEW_CHANGELOG.md
cat NEW_CHANGELOG.md > CHANGELOG.md
- name: Commit
if: ${{ env.IS_STABLE == 'true' }}
run: |
git add CHANGELOG.md
if ! git diff --cached --quiet; then
echo "Commit pushing"
git config --local user.email "chen08209@gmail.com"
git config --local user.name "chen08209"
git commit -m "Update changelog"
git push
if [ $? -eq 0 ]; then
echo "Push succeeded"
else
echo "Push failed"
exit 1
fi
fi
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
upload:
permissions: write-all
needs: [ build ]
runs-on: ubuntu-latest
services:
telegram-bot-api:
image: aiogram/telegram-bot-api:latest
env:
TELEGRAM_API_ID: ${{ secrets.TELEGRAM_API_ID }}
TELEGRAM_API_HASH: ${{ secrets.TELEGRAM_API_HASH }}
ports:
- 8081:8081
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download
uses: actions/download-artifact@v4
with:
path: ./dist/
pattern: artifact-*
merge-multiple: true
- name: Generate release.md
run: |
tags=($(git tag --merged $(git rev-parse HEAD) --sort=-creatordate))
preTag=$(curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" | grep -Po '"tag_name": "\K.*?(?=")' || echo "")
currentTag=""
for ((i = 0; i <= ${#tags[@]}; i++)); do
if (( i < ${#tags[@]} )); then
tag=${tags[$i]}
else
tag=""
fi
if [ -n "$currentTag" ]; then
if [ "$(echo -e "$currentTag\n$preTag" | sort -V | head -n 1)" == "$currentTag" ]; then
break
fi
fi
if [ -n "$currentTag" ]; then
if [ -n "$tag" ]; then
git log --pretty=format:"%B" "$tag..$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> release.md
else
git log --pretty=format:"%B" "$currentTag" | awk 'NF {print "- " $0} !NF {print ""}' >> release.md
fi
echo "" >> release.md
fi
currentTag=$tag
done
- name: Push to telegram
env:
TELEGRAM_BOT_TOKEN: ${{ secrets.TELEGRAM_BOT_TOKEN }}
TAG: ${{ github.ref_name }}
RUN_ID: ${{ github.run_id }}
run: |
python -m pip install --upgrade pip
pip install requests
python release_telegram.py
- name: Patch release.md
run: |
version=$(echo "${{ github.ref_name }}" | sed 's/^v//')
sed "s|VERSION|$version|g" ./.github/release_template.md >> release.md
- name: Generate sha256
if: env.IS_STABLE == 'true'
run: |
cd ./dist
for file in $(find . -type f -not -name "*.sha256"); do
sha256sum "$file" > "${file}.sha256"
done
- name: Release
if: ${{ env.IS_STABLE == 'true' }}
uses: softprops/action-gh-release@v2
with:
files: ./dist/*
body_path: './release.md'
- name: Create Fdroid Source Dir
if: ${{ env.IS_STABLE == 'true' }}
run: |
mkdir -p ./tmp
cp ./dist/*android-arm64-v8a* ./tmp/ || true
echo "Files copied successfully"
- name: Push to fdroid repo
if: ${{ env.IS_STABLE == 'true' }}
uses: cpina/github-action-push-to-another-repository@v1.7.2
env:
SSH_DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
with:
source-directory: ./tmp/
destination-github-username: chen08209
destination-repository-name: FlClash-fdroid-repo
user-name: 'github-actions[bot]'
user-email: 'github-actions[bot]@users.noreply.github.com'
target-branch: main
commit-message: Update from ${{ github.ref_name }}
target-directory: /tmp/

164
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,164 @@
name: build
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
include:
- platform: android
os: ubuntu-latest
- platform: windows
os: windows-latest
arch: amd64
- platform: linux
os: ubuntu-latest
arch: amd64
- platform: macos
os: macos-13
arch: amd64
- platform: macos
os: macos-latest
arch: arm64
steps:
- name: Setup Mingw64
if: startsWith(matrix.platform,'windows')
uses: msys2/setup-msys2@v2
with:
msystem: mingw64
install: mingw-w64-x86_64-gcc
update: true
- name: Set Mingw64 Env
if: startsWith(matrix.platform,'windows')
run: |
echo "${{ runner.temp }}\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append
- name: Check Matrix
run: |
echo "Running on ${{ matrix.os }}"
echo "Arch: ${{ runner.arch }}"
gcc --version
- name: Checkout
uses: actions/checkout@v4
with:
submodules: recursive
- name: Setup JAVA
if: startsWith(matrix.platform,'android')
uses: actions/setup-java@v4
with:
distribution: 'zulu'
java-version: 17
- name: Setup NDK
if: startsWith(matrix.platform,'android')
uses: nttld/setup-ndk@v1
id: setup-ndk
with:
ndk-version: r26b
add-to-path: true
link-to-sdk: true
- name: Setup Android Signing
if: startsWith(matrix.platform,'android')
run: |
echo "${{ secrets.KEYSTORE }}" | base64 --decode > android/app/keystore.jks
echo "keyAlias=${{ secrets.KEY_ALIAS }}" >> android/local.properties
echo "storePassword=${{ secrets.STORE_PASSWORD }}" >> android/local.properties
echo "keyPassword=${{ secrets.KEY_PASSWORD }}" >> android/local.properties
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version-file: 'core/go.mod'
cache-dependency-path: |
core/go.sum
- name: Setup Flutter
uses: subosito/flutter-action@v2
with:
flutter-version: 3.22.x
channel: 'stable'
cache: true
- name: Get Flutter Dependency
run: flutter pub get
- name: Setup
run: dart setup.dart ${{ matrix.platform }} ${{ matrix.arch && format('--arch {0}', matrix.arch) }}
- name: Upload
uses: actions/upload-artifact@v4
with:
name: artifact-${{ matrix.platform }}${{ matrix.arch && format('-{0}', matrix.arch) }}
path: ./dist
retention-days: 1
overwrite: true
upload-release:
if: ${{ !contains(github.ref, '+') }}
permissions: write-all
needs: [ build ]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Download
uses: actions/download-artifact@v4
with:
path: ./dist/
pattern: artifact-*
merge-multiple: true
- name: Pre Release
run: |
pip install gitchangelog pystache mustache markdown
pre=$(curl --silent "https://api.github.com/repos/chen08209/FlClash/releases/latest" | grep -Po '"tag_name": "\K.*?(?=")' || echo "")
if [ -z "pre" ]; then
echo "init" > release.md
else
current="${{ github.ref_name }}"
echo -e "\n\n<details markdown=1><summary>All changes from $current to the latest commit:</summary>\n\n" >> release.md
gitchangelog "${pre}.." >> release.md 2>&1 || echo "Error in gitchangelog"
echo -e "\n\n</details>" >> release.md
fi
- name: Release
uses: softprops/action-gh-release@v2
with:
files: ./dist/*
body_path: './release.md'
- name: Create Fdroid Source Dir
run: |
mkdir -p ./tmp
cp ./dist/*android-arm64-v8a* ./tmp/ || true
echo "Files copied successfully"
- name: Push to fdroid repo
uses: cpina/github-action-push-to-another-repository@v1.7.2
env:
SSH_DEPLOY_KEY: ${{ secrets.SSH_DEPLOY_KEY }}
with:
source-directory: ./tmp/
destination-github-username: chen08209
destination-repository-name: FlClash-fdroid-repo
user-name: 'github-actions[bot]'
user-email: 'github-actions[bot]@users.noreply.github.com'
target-branch: action-pr
commit-message: Update from ${{ github.ref_name }}
target-directory: /tmp/

2
.gitignore vendored
View File

@@ -5,11 +5,9 @@
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
migrate_working_dir/
# IntelliJ related

4
.gitmodules vendored
View File

@@ -1,10 +1,8 @@
[submodule "core/Clash.Meta"]
path = core/Clash.Meta
url = git@github.com:chen08209/Clash.Meta.git
branch = FlClash
branch = FlClash-Alpha
[submodule "plugins/flutter_distributor"]
path = plugins/flutter_distributor
url = git@github.com:chen08209/flutter_distributor.git
branch = FlClash

View File

@@ -1,918 +0,0 @@
## v0.8.87
- Optimize desktop view
- Optimize logs, requests, connection pages
- Optimize windows tray auto hide
- Optimize some details
- Update core
- Update changelog
## v0.8.86
- Fix windows tun issues
- Optimize android get system dns
- Optimize more details
- Update changelog
## v0.8.85
- Support override script
- Support proxies search
- Support svg display
- Optimize config persistence
- Add some scenes auto close connections
- Update core
- Optimize more details
## v0.8.84
- Fix windows service verify issues
- Update changelog
## v0.8.83
- Add windows server mode start process verify
- Add linux deb dependencies
- Add backup recovery strategy select
- Support custom text scaling
- Optimize the display of different text scale
- Optimize windows setup experience
- Optimize startTun performance
- Optimize android tv experience
- Optimize default option
- Optimize computed text size
- Optimize hyperOS freeform window
- Add developer mode
- Update core
- Optimize more details
- Add issues template
- Update changelog
## v0.8.82
- Optimize android vpn performance
- Add custom primary color and color scheme
- Add linux nad windows arm release
- Optimize requests and logs page
- Fix map input page delete issues
- Update changelog
## v0.8.81
- Add rule override
- Update core
- Optimize more details
- Update changelog
## v0.8.80
- Optimize dashboard performance
- Fix some issues
- Fix unselected proxy group delay issues
- Fix asn url issues
- Update changelog
## v0.8.79
- Fix tab delay view issues
- Fix tray action issues
- Fix get profile redirect client ua issues
- Fix proxy card delay view issues
- Add Russian, Japanese adaptation
- Fix some issues
- Update changelog
## v0.8.78
- Fix list form input view issues
- Fix traffic view issues
- Update changelog
## v0.8.77
- Optimize performance
- Update core
- Optimize core stability
- Fix linux tun authority check error
- Fix some issues
- Fix scroll physics error
- Update changelog
## v0.8.75
- Add windows storage corruption detection
- Fix core crash caused by windows resource manager restart
- Optimize logs, requests, access to pages
- Fix macos bypass domain issues
- Update changelog
## v0.8.74
- Fix some issues
- Update changelog
## v0.8.73
- Update popup menu
- Add file editor
- Fix android service issues
- Optimize desktop background performance
- Optimize android main process performance
- Optimize delay test
- Optimize vpn protect
- Update changelog
## v0.8.72
- Update core
- Fix some issues
- Update changelog
## v0.8.71
- Remake dashboard
- Optimize theme
- Optimize more details
- Update flutter version
- Update changelog
## v0.8.70
- Support better window position memory
- Add windows arm64 and linux arm64 build script
- Optimize some details
## v0.8.69
- Remake desktop
- Optimize change proxy
- Optimize network check
- Fix fallback issues
- Optimize lots of details
- Update change.yaml
- Fix android tile issues
- Fix windows tray issues
- Support setting bypassDomain
- Update flutter version
- Fix android service issues
- Fix macos dock exit button issues
- Add route address setting
- Optimize provider view
- Update changelog
- Update CHANGELOG.md
## v0.8.67
- Add android shortcuts
- Fix init params issues
- Fix dynamic color issues
- Optimize navigator animate
- Optimize window init
- Optimize fab
- Optimize save
## v0.8.66
- Fix the collapse issues
- Add fontFamily options
## v0.8.65
- Update core version
- Update flutter version
- Optimize ip check
- Optimize url-test
## v0.8.64
- Update release message
- Init auto gen changelog
- Fix windows tray issues
- Fix urltest issues
- Add auto changelog
- Fix windows admin auto launch issues
- Add android vpn options
- Support proxies icon configuration
- Optimize android immersion display
- Fix some issues
- Optimize ip detection
- Support android vpn ipv6 inbound switch
- Support log export
- Optimize more details
- Fix android system dns issues
- Optimize dns default option
- Fix some issues
- Update readme
## v0.8.60
- Fix build error2
- Fix build error
- Support desktop hotkey
- Support android ipv6 inbound
- Support android system dns
- fix some bugs
## v0.8.59
- Fix delete profile error
## v0.8.58
- Fix submit error 2
- Fix submit error
- Optimize DNS strategy
- Fix the problem that the tray is not displayed in some cases
- Optimize tray
- Update core
- Fix some error
## v0.8.57
- Fix tun update issues
- Add DNS override
- Fixed some bugs
- Optimize more detail
- Add Hosts override
## v0.8.56
- fix android tip error
- fix windows auto launch error
## v0.8.55
- Fix windows tray issues
- Optimize windows logic
- Optimize app logic
- Support windows administrator auto launch
- Support android close vpn
## v0.8.53
- Change flutter version
- Support profiles sort
- Support windows country flags display
- Optimize proxies page and profiles page columns
## v0.8.52
- Update flutter version
- Update version
- Update timeout time
- Update access control page
- Fix bug
## v0.8.51
- Optimize provider page
- Optimize delay test
- Support local backup and recovery
- Fix android tile service issues
## v0.8.49
- Fix linux core build error
- Add proxy-only traffic statistics
- Update core
- Optimize more details
- Merge pull request #140 from txyyh/main
- 添加自建 F-Droid 仓库相关 workflow
- Rename readme fingerprint
- Rename workflow deploy repo name
- Add download guide to README
- Add push release files to fdroid-repo
## v0.8.48
- Optimize proxies page
- Fix ua issues
- Optimize more details
## v0.8.47
- Fix windows build error
## v0.8.46
- Update app icon
- Fix desktop backup error
- Optimize request ua
- Change android icon
- Optimize dashboard
## v0.8.44
- Remove request validate certificate
- Sync core
## v0.8.43
- Fix windows error
## v0.8.42
- Fix setup.dart error
- Fix android system proxy not effective
- Add macos arm64
## v0.8.41
- Optimize proxies page
- Support mouse drag scroll
- Adjust desktop ui
- Revert "Fix android vpn issues"
- This reverts commit 891977408e6938e2acd74e9b9adb959c48c79988.
## v0.8.40
- Fix android vpn issues
- Fix android vpn issues
- Rollback partial modification
## v0.8.39
- Fix the problem that ui can't be synchronized when android vpn is occupied by an external
- Override default socksPort,port
## v0.8.38
- Fix fab issues
## v0.8.37
- Update version
- Fix the problem that vpn cannot be started in some cases
- Fix the problem that geodata url does not take effect
## v0.8.36
- Update ua
- Fix change outbound mode without check ip issues
- Separate android ui and vpn
- Fix url validate issues 2
- Add android hidden from the recent task
- Add geoip file
- Support modify geoData URL
## v0.8.35
- Fix url validate issues
- Fix check ip performance problem
- Optimize resources page
## v0.8.34
- Add ua selector
- Support modify test url
- Optimize android proxy
- Fix the error that async proxy provider could not selected the proxy
## v0.8.33
- Fix android proxy error
- Fix submit error
- Add windows tun
- Optimize android proxy
- Optimize change profile
- Update application ua
- Optimize delay test
## v0.8.32
- Fix android repeated request notification issues
## v0.8.31
- Fix memory overflow issues
## v0.8.30
- Optimize proxies expansion panel 2
- Fix android scan qrcode error
## v0.8.29
- Optimize proxies expansion panel
- Fix text error
## v0.8.28
- Optimize proxy
- Optimize delayed sorting performance
- Add expansion panel proxies page
- Support to adjust the proxy card size
- Support to adjust proxies columns number
- Fix autoRun show issues
- Fix Android 10 issues
- Optimize ip show
## v0.8.26
- Add intranet IP display
- Add connections page
- Add search in connections, requests
- Add keyword search in connections, requests, logs
- Add basic viewing editing capabilities
- Optimize update profile
## v0.8.25
- Update version
- Fix the problem of excessive memory usage in traffic usage.
- Add lightBlue theme color
- Fix start unable to update profile issues
- Fix flashback caused by process
## v0.8.23
- Add build version
- Optimize quick start
- Update system default option
## v0.8.22
- Update build.yml
- Fix android vpn close issues
- Add requests page
- Fix checkUpdate dark mode style error
- Fix quickStart error open app
- Add memory proxies tab index
- Support hidden group
- Optimize logs
- Fix externalController hot load error
## v0.8.21
- Add tcp concurrent switch
- Add system proxy switch
- Add geodata loader switch
- Add external controller switch
- Add auto gc on trim memory
- Fix android notification error
## v0.8.20
- Fix ipv6 error
- Fix android udp direct error
- Add ipv6 switch
- Add access all selected button
- Remove android low version splash
## v0.8.19
- Update version
- Add allowBypass
- Fix Android only pick .text file issues
## v0.8.18
- Fix search issues
## v0.8.17
- Fix LoadBalance, Relay load error
- Fix build.yml4
- Fix build.yml3
- Fix build.yml2
- Fix build.yml
- Add search function at access control
- Fix the issues with the profile add button to cover the edit button
- Adapt LoadBalance and Relay
- Add arm
- Fix android notification icon error
## v0.8.16
- Add one-click update all profiles
- Add expire show
## v0.8.15
- Temp remove tun mode
- Remove macos in workflow
- Change go version
## v0.8.14
- Update Version
- Fix tun unable to open
## v0.8.13
- Optimize delay test2
- Optimize delay test
- Add check ip
- add check ip request
## v0.8.12
- Fix the problem that the download of remote resources failed after GeodataMode was turned on, which caused the
application to flash back.
- Fix edit profile error
- Fix quickStart change proxy error
- Fix core version
## v0.8.10
- Fix core version
## v0.8.9
- Update file_picker
- Add resources page
- Optimize more detail
- Add access selected sorted
- Fix notification duplicate creation issue
- Fix AccessControl click issue
## v0.8.7
- Fix Workflow
- Fix Linux unable to open
- Update README.md 3
- Create LICENSE
- Update README.md 2
- Update README.md
- Optimize workFlow
## v0.8.6
- optimize checkUpdate
## v0.8.5
- Fix submit error
## v0.8.4
- add WebDAV
- add Auto check updates
- Optimize more details
- optimize delayTest
## v0.8.2
- upgrade flutter version
## v0.8.1
- Update kernel
- Add import profile via QR code image
## v0.8.0
- Add compatibility mode and adapt clash scheme.
## v0.7.14
- update Version
- Reconstruction application proxy logic
## v0.7.13
- Fix Tab destroy error
## v0.7.12
- Optimize repeat healthcheck
## v0.7.11
- Optimize Direct mode ui
## v0.7.10
- Optimize Healthcheck
- Remove proxies position animation, improve performance
- Add Telegram Link
- Update healthcheck policy
- New Check URLTest
- Fix the problem of invalid auto-selection
## v0.7.8
- New Async UpdateConfig
- add changeProfileDebounce
- Update Workflow
- Fix ChangeProfile block
- Fix Release Message Error
## v0.7.7
- Update Selector 2
## v0.7.6
- Update Version
- Fix Proxies Select Error
## v0.7.5
- Fix the problem that the proxy group is empty in global mode.
- Fix the problem that the proxy group is empty in global mode.
## v0.7.4
- Add ProxyProvider2
## v0.7.3
- Add ProxyProvider
- Update Version
- Update ProxyGroup Sort
- Fix Android quickStart VpnService some problems
## v0.7.1
- Update version
- Set Android notification low importance
- Fix the issue that VpnService can't be closed correctly in special cases
- Fix the problem that TileService is not destroyed correctly in some cases
- Adjust tab animation defaults
- Add Telegram in README_zh_CN.md
- Add Telegram
## v0.7.0
- update mobile_scanner
- Initial commit

View File

@@ -1,10 +0,0 @@
android_arm64:
dart ./setup.dart android --arch arm64
macos_arm64:
dart ./setup.dart macos --arch arm64
android_app:
dart ./setup.dart android
android_arm64_core:
dart ./setup.dart android --arch arm64 --out core
macos_arm64_core:
dart ./setup.dart macos --arch arm64 --out core

View File

@@ -34,29 +34,6 @@ on Mobile:
✨ Support subscription link, Dark mode
## Use
### Linux
⚠️ Make sure to install the following dependencies before using them
```bash
sudo apt-get install libayatana-appindicator3-dev
sudo apt-get install libkeybinder-3.0-dev
```
### Android
Support the following actions
```bash
com.follow.clash.action.START
com.follow.clash.action.STOP
com.follow.clash.action.TOGGLE
```
## Download
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
@@ -93,7 +70,7 @@ Support the following actions
3. Run build script
```bash
dart .\setup.dart windows --arch <arm64 | amd64>
dart .\setup.dart
```
- linux
@@ -103,7 +80,7 @@ Support the following actions
2. Run build script
```bash
dart .\setup.dart linux --arch <arm64 | amd64>
dart .\setup.dart
```
- macOS
@@ -113,7 +90,7 @@ Support the following actions
2. Run build script
```bash
dart .\setup.dart macos --arch <arm64 | amd64>
dart .\setup.dart
```
## Star

View File

@@ -10,6 +10,7 @@
[![Channel](https://img.shields.io/badge/Telegram-Channel-blue?style=flat-square&logo=telegram)](https://t.me/FlClash)
基于ClashMeta的多平台代理客户端简单易用开源无广告。
on Desktop:
@@ -34,29 +35,6 @@ on Mobile:
✨ 支持一键导入订阅, 深色模式
## Use
### Linux
⚠️ 使用前请确保安装以下依赖
```bash
sudo apt-get install libayatana-appindicator3-dev
sudo apt-get install libkeybinder-3.0-dev
```
### Android
支持下列操作
```bash
com.follow.clash.action.START
com.follow.clash.action.STOP
com.follow.clash.action.TOGGLE
```
## Download
<a href="https://chen08209.github.io/FlClash-fdroid-repo/repo?fingerprint=789D6D32668712EF7672F9E58DEEB15FBD6DCEEC5AE7A4371EA72F2AAE8A12FD"><img alt="Get it on F-Droid" src="snapshots/get-it-on-fdroid.svg" width="200px"/></a> <a href="https://github.com/chen08209/FlClash/releases"><img alt="Get it on GitHub" src="snapshots/get-it-on-github.svg" width="200px"/></a>
@@ -93,7 +71,7 @@ on Mobile:
3. 运行构建脚本
```bash
dart .\setup.dart windows --arch <arm64 | amd64>
dart .\setup.dart
```
- linux
@@ -103,7 +81,7 @@ on Mobile:
2. 运行构建脚本
```bash
dart .\setup.dart linux --arch <arm64 | amd64>
dart .\setup.dart
```
- macOS
@@ -113,7 +91,7 @@ on Mobile:
2. 运行构建脚本
```bash
dart .\setup.dart macos --arch <arm64 | amd64>
dart .\setup.dart
```
## Star History

View File

@@ -1,10 +1,29 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- lib/l10n/intl/**
errors:
invalid_annotation_target: ignore
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at
# https://dart-lang.github.io/linter/lints/index.html.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
prefer_single_quotes: true
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options

114
android/app/build.gradle Normal file
View File

@@ -0,0 +1,114 @@
import com.android.build.gradle.tasks.MergeSourceSetFolders
plugins {
id "com.android.application"
id "kotlin-android"
id "dev.flutter.flutter-gradle-plugin"
}
def localProperties = new Properties()
def localPropertiesFile = rootProject.file('local.properties')
if (localPropertiesFile.exists()) {
localPropertiesFile.withReader('UTF-8') { reader ->
localProperties.load(reader)
}
}
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
if (flutterVersionCode == null) {
flutterVersionCode = '1'
}
def flutterVersionName = localProperties.getProperty('flutter.versionName')
if (flutterVersionName == null) {
flutterVersionName = '1.0'
}
def defStoreFile = file("keystore.jks")
def defStorePassword = localProperties.getProperty('storePassword')
def defKeyAlias = localProperties.getProperty('keyAlias')
def defKeyPassword = localProperties.getProperty('keyPassword')
def isRelease = defStoreFile.exists() && defStorePassword != null && defKeyAlias != null && defKeyPassword != null
android {
namespace "com.follow.clash"
compileSdkVersion 34
ndkVersion "27.1.12297006"
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
sourceSets {
main.java.srcDirs += 'src/main/kotlin'
}
signingConfigs {
if (isRelease) {
release {
storeFile defStoreFile
storePassword defStorePassword
keyAlias defKeyAlias
keyPassword defKeyPassword
}
}
}
defaultConfig {
applicationId "com.follow.clash"
minSdkVersion 21
targetSdkVersion 34
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
}
buildTypes {
debug {
minifyEnabled false
applicationIdSuffix '.debug'
}
release {
if (isRelease) {
signingConfig signingConfigs.release
} else {
signingConfig signingConfigs.debug
}
proguardFiles getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
}
}
}
tasks.register('copyNativeLibs', Copy) {
delete('src/main/jniLibs')
from('../../libclash/android')
into('src/main/jniLibs')
}
tasks.withType(MergeSourceSetFolders).configureEach {
dependsOn copyNativeLibs
}
flutter {
source '../..'
}
dependencies {
implementation 'androidx.core:core-splashscreen:1.0.1'
implementation 'com.google.code.gson:gson:2.10'
implementation("com.android.tools.smali:smali-dexlib2:3.0.7") {
exclude group: "com.google.guava", module: "guava"
}
}
afterEvaluate {
assembleDebug.dependsOn copyNativeLibs
assembleRelease.dependsOn copyNativeLibs
}

View File

@@ -1,108 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import java.util.Properties
plugins {
id("com.android.application")
id("kotlin-android")
id("dev.flutter.flutter-gradle-plugin")
id("com.google.gms.google-services")
id("com.google.firebase.crashlytics")
}
val localPropertiesFile = rootProject.file("local.properties")
val localProperties = Properties().apply {
if (localPropertiesFile.exists()) {
localPropertiesFile.inputStream().use { load(it) }
}
}
val mStoreFile: File = file("keystore.jks")
val mStorePassword: String? = localProperties.getProperty("storePassword")
val mKeyAlias: String? = localProperties.getProperty("keyAlias")
val mKeyPassword: String? = localProperties.getProperty("keyPassword")
val isRelease =
mStoreFile.exists() && mStorePassword != null && mKeyAlias != null && mKeyPassword != null
android {
namespace = "com.follow.clash"
compileSdk = libs.versions.compileSdk.get().toInt()
ndkVersion = libs.versions.ndkVersion.get()
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
applicationId = "com.follow.clash"
minSdk = flutter.minSdkVersion
targetSdk = libs.versions.targetSdk.get().toInt()
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
if (isRelease) {
create("release") {
storeFile = mStoreFile
storePassword = mStorePassword
keyAlias = mKeyAlias
keyPassword = mKeyPassword
}
}
}
packaging {
jniLibs {
useLegacyPackaging = true
}
}
buildTypes {
debug {
isMinifyEnabled = false
applicationIdSuffix = ".debug"
}
release {
isMinifyEnabled = true
isShrinkResources = true
signingConfig = if (isRelease) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro"
)
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
flutter {
source = "../.."
}
dependencies {
implementation(project(":service"))
implementation(project(":common"))
implementation(libs.core.splashscreen)
implementation(libs.gson)
implementation(libs.smali.dexlib2) {
exclude(group = "com.google.guava", module = "guava")
}
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics.ndk)
implementation(libs.firebase.analytics)
}

View File

@@ -1,46 +0,0 @@
{
"project_info": {
"project_number": "000000000000",
"project_id": "dev"
},
"client": [
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "com.follow.clash"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
},
{
"client_info": {
"mobilesdk_app_id": "1:000000000000:android:0000000000000000",
"android_client_info": {
"package_name": "com.follow.clash.debug"
}
},
"oauth_client": [],
"api_key": [
{
"current_key": "0"
}
],
"services": {
"appinvite_service": {
"other_platform_oauth_client": []
}
}
}
]
}

View File

@@ -1,4 +1,2 @@
-keep class com.follow.clash.models.**{ *; }
-keep class com.follow.clash.service.models.**{ *; }
-keep class com.follow.clash.models.**{ *; }

View File

@@ -1,16 +1,13 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<manifest xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<application
android:icon="@mipmap/ic_launcher"
android:label="FlClash Debug"
tools:replace="android:label">
<application android:label="FlClash Debug" tools:replace="android:label">
<service
android:name=".TileService"
android:label="FlClash Debug"
tools:replace="android:label" />
android:name=".services.FlClashTileService"
android:label="FlClash Debug"
tools:replace="android:label">
</service>
</application>
</manifest>

View File

@@ -1,4 +1,3 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
@@ -8,44 +7,44 @@
<uses-feature
android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.software.leanback"
android:required="false" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE"
tools:ignore="SystemPermissionTypo" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<uses-permission
android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<application
android:name=".Application"
android:banner="@mipmap/ic_banner"
android:hardwareAccelerated="true"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:hardwareAccelerated="true"
android:label="FlClash">
<activity
android:name=".MainActivity"
android:name="com.follow.clash.MainActivity"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:exported="true"
android:hardwareAccelerated="true"
android:launchMode="singleTop"
android:theme="@style/LaunchTheme"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" />
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
<category android:name="android.intent.category.LEANBACK_LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
@@ -64,29 +63,29 @@
</intent-filter>
</activity>
<!-- <meta-data-->
<!-- android:name="io.flutter.embedding.android.EnableImpeller"-->
<!-- android:value="true" />-->
<activity
android:name=".TempActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@style/TransparentTheme">
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="${applicationId}.action.START" />
<action android:name="com.follow.clash.action.START" />
</intent-filter>
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="${applicationId}.action.STOP" />
</intent-filter>
<intent-filter>
<category android:name="android.intent.category.DEFAULT" />
<action android:name="${applicationId}.action.TOGGLE" />
<action android:name="com.follow.clash.action.STOP" />
</intent-filter>
</activity>
<service
android:name=".TileService"
android:name=".services.FlClashTileService"
android:exported="true"
android:icon="@drawable/ic"
android:foregroundServiceType="specialUse"
android:icon="@drawable/ic_stat_name"
android:label="FlClash"
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
<intent-filter>
@@ -97,16 +96,49 @@
android:value="true" />
</service>
<receiver
android:name=".BroadcastReceiver"
android:enabled="true"
<provider
android:name=".FilesProvider"
android:authorities="${applicationId}.files"
android:exported="true"
android:permission="${applicationId}.permission.RECEIVE_BROADCASTS">
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:process=":background">
<intent-filter>
<action android:name="${applicationId}.intent.action.SERVICE_CREATED" />
<action android:name="${applicationId}.intent.action.SERVICE_DESTROYED" />
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</receiver>
</provider>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<service
android:name=".services.FlClashVpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="vpn" />
</service>
<service
android:name=".services.FlClashService"
android:exported="false"
android:foregroundServiceType="specialUse">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="service" />
</service>
<meta-data
android:name="flutterEmbedding"

View File

@@ -1,13 +0,0 @@
package com.follow.clash
import android.app.Application
import android.content.Context
import com.follow.clash.common.GlobalState
class Application : Application() {
override fun attachBaseContext(base: Context?) {
super.attachBaseContext(base)
GlobalState.init(this)
}
}

View File

@@ -0,0 +1,10 @@
package com.follow.clash
import com.follow.clash.models.Props
import com.follow.clash.models.TunProps
interface BaseServiceInterface {
fun start(port: Int, props: Props?): TunProps?
fun stop()
fun startForeground(title: String, content: String)
}

View File

@@ -1,27 +0,0 @@
package com.follow.clash
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.action
import kotlinx.coroutines.launch
class BroadcastReceiver : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
when (intent?.action) {
BroadcastAction.SERVICE_CREATED.action -> {
GlobalState.log("Receiver service created")
GlobalState.launch {
State.handleStartServiceAction()
}
}
BroadcastAction.SERVICE_DESTROYED.action -> {
GlobalState.log("Receiver service destroyed")
State.handleStopServiceAction()
}
}
}
}

View File

@@ -1,121 +0,0 @@
package com.follow.clash
import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.os.Build
import android.os.Handler
import android.os.Looper
import androidx.core.graphics.drawable.toBitmap
import com.follow.clash.common.GlobalState
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.util.concurrent.TimeUnit
import kotlin.coroutines.resume
private const val ICON_TTL_DAYS = 1L
suspend fun PackageManager.getPackageIconPath(packageName: String): String =
withContext(Dispatchers.IO) {
val cacheDir = GlobalState.application.cacheDir
val iconDir = File(cacheDir, "icons").apply { mkdirs() }
return@withContext try {
val pkgInfo = getPackageInfo(packageName, 0)
val lastUpdateTime = pkgInfo.lastUpdateTime
val iconFile = File(iconDir, "${packageName}_${lastUpdateTime}.webp")
if (iconFile.exists() && !isExpired(iconFile)) {
return@withContext iconFile.absolutePath
}
iconDir.listFiles { f -> f.name.startsWith("${packageName}_") }?.forEach(File::delete)
val icon = getApplicationIcon(packageName)
saveDrawableToFile(icon, iconFile)
iconFile.absolutePath
} catch (_: Exception) {
val defaultIconFile = File(iconDir, "default_icon.webp")
if (!defaultIconFile.exists()) {
saveDrawableToFile(defaultActivityIcon, defaultIconFile)
}
defaultIconFile.absolutePath
}
}
private suspend fun saveDrawableToFile(drawable: Drawable, file: File) {
val bitmap = withContext(Dispatchers.Default) {
drawable.toBitmap(width = 128, height = 128)
}
try {
val format = when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> {
Bitmap.CompressFormat.WEBP_LOSSY
}
else -> {
Bitmap.CompressFormat.WEBP
}
}
FileOutputStream(file).use { fos ->
bitmap.compress(format, 90, fos)
}
} finally {
if (!bitmap.isRecycled) bitmap.recycle()
}
}
private fun isExpired(file: File): Boolean {
val now = System.currentTimeMillis()
val age = now - file.lastModified()
return age > TimeUnit.DAYS.toMillis(ICON_TTL_DAYS)
}
suspend fun <T> MethodChannel.awaitResult(
method: String, arguments: Any? = null
): T? = withContext(Dispatchers.Main) {
suspendCancellableCoroutine { continuation ->
invokeMethod(method, arguments, object : MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("UNCHECKED_CAST") continuation.resume(result as T?)
}
override fun error(code: String, message: String?, details: Any?) {
continuation.resume(null)
}
override fun notImplemented() {
continuation.resume(null)
}
})
}
}
inline fun <reified T : FlutterPlugin> FlutterEngine.plugin(): T? {
return plugins.get(T::class.java) as T?
}
fun <T> MethodChannel.invokeMethodOnMainThread(
method: String, arguments: Any? = null, callback: ((Result<T>) -> Unit)? = null
) {
Handler(Looper.getMainLooper()).post {
invokeMethod(method, arguments, object : MethodChannel.Result {
override fun success(result: Any?) {
@Suppress("UNCHECKED_CAST") callback?.invoke(Result.success(result as T))
}
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
val exception = Exception("MethodChannel error: $errorCode - $errorMessage")
callback?.invoke(Result.failure(exception))
}
override fun notImplemented() {
val exception = NotImplementedError("Method not implemented: $method")
callback?.invoke(Result.failure(exception))
}
})
}
}

View File

@@ -1,33 +1,35 @@
package com.follow.clash.service
package com.follow.clash
import android.database.Cursor
import android.database.MatrixCursor
import android.os.CancellationSignal
import android.os.ParcelFileDescriptor
import android.provider.DocumentsContract
import android.provider.DocumentsContract.Document
import android.provider.DocumentsContract.Root
import android.provider.DocumentsProvider
import java.io.File
import java.io.FileNotFoundException
class FilesProvider : DocumentsProvider() {
companion object {
private const val DEFAULT_ROOT_ID = "0"
private val DEFAULT_DOCUMENT_COLUMNS = arrayOf(
DocumentsContract.Document.COLUMN_DOCUMENT_ID,
DocumentsContract.Document.COLUMN_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.COLUMN_SIZE,
Document.COLUMN_DOCUMENT_ID,
Document.COLUMN_DISPLAY_NAME,
Document.COLUMN_MIME_TYPE,
Document.COLUMN_FLAGS,
Document.COLUMN_SIZE,
)
private val DEFAULT_ROOT_COLUMNS = arrayOf(
DocumentsContract.Root.COLUMN_ROOT_ID,
DocumentsContract.Root.COLUMN_FLAGS,
DocumentsContract.Root.COLUMN_ICON,
DocumentsContract.Root.COLUMN_TITLE,
DocumentsContract.Root.COLUMN_SUMMARY,
DocumentsContract.Root.COLUMN_DOCUMENT_ID
Root.COLUMN_ROOT_ID,
Root.COLUMN_FLAGS,
Root.COLUMN_ICON,
Root.COLUMN_TITLE,
Root.COLUMN_SUMMARY,
Root.COLUMN_DOCUMENT_ID
)
}
@@ -38,12 +40,12 @@ class FilesProvider : DocumentsProvider() {
override fun queryRoots(projection: Array<String>?): Cursor {
return MatrixCursor(projection ?: DEFAULT_ROOT_COLUMNS).apply {
newRow().apply {
add(DocumentsContract.Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
add(DocumentsContract.Root.COLUMN_FLAGS, DocumentsContract.Root.FLAG_LOCAL_ONLY)
add(DocumentsContract.Root.COLUMN_ICON, R.drawable.ic_service)
add(DocumentsContract.Root.COLUMN_TITLE, "FlClash")
add(DocumentsContract.Root.COLUMN_SUMMARY, "Data")
add(DocumentsContract.Root.COLUMN_DOCUMENT_ID, "/")
add(Root.COLUMN_ROOT_ID, DEFAULT_ROOT_ID)
add(Root.COLUMN_FLAGS, Root.FLAG_LOCAL_ONLY)
add(Root.COLUMN_ICON, R.mipmap.ic_launcher)
add(Root.COLUMN_TITLE, context!!.getString(R.string.fl_clash))
add(Root.COLUMN_SUMMARY, "Data")
add(Root.COLUMN_DOCUMENT_ID, "/")
}
}
}
@@ -85,20 +87,20 @@ class FilesProvider : DocumentsProvider() {
private fun includeFile(result: MatrixCursor, file: File) {
result.newRow().apply {
add(DocumentsContract.Document.COLUMN_DOCUMENT_ID, file.absolutePath)
add(DocumentsContract.Document.COLUMN_DISPLAY_NAME, file.name)
add(DocumentsContract.Document.COLUMN_SIZE, file.length())
add(Document.COLUMN_DOCUMENT_ID, file.absolutePath)
add(Document.COLUMN_DISPLAY_NAME, file.name)
add(Document.COLUMN_SIZE, file.length())
add(
DocumentsContract.Document.COLUMN_FLAGS,
DocumentsContract.Document.FLAG_SUPPORTS_WRITE or DocumentsContract.Document.FLAG_SUPPORTS_DELETE
Document.COLUMN_FLAGS,
Document.FLAG_SUPPORTS_WRITE or Document.FLAG_SUPPORTS_DELETE
)
add(DocumentsContract.Document.COLUMN_MIME_TYPE, getDocumentType(file))
add(Document.COLUMN_MIME_TYPE, getDocumentType(file))
}
}
private fun getDocumentType(file: File): String {
return if (file.isDirectory) {
DocumentsContract.Document.MIME_TYPE_DIR
Document.MIME_TYPE_DIR
} else {
"application/octet-stream"
}

View File

@@ -0,0 +1,70 @@
package com.follow.clash
import android.content.Context
import androidx.lifecycle.MutableLiveData
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ServicePlugin
import com.follow.clash.plugins.VpnPlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import java.util.concurrent.locks.ReentrantLock
import kotlin.concurrent.withLock
enum class RunState {
START,
PENDING,
STOP
}
object GlobalState {
private val lock = ReentrantLock()
val runLock = ReentrantLock()
val runState: MutableLiveData<RunState> = MutableLiveData<RunState>(RunState.STOP)
var flutterEngine: FlutterEngine? = null
private var serviceEngine: FlutterEngine? = null
fun getCurrentAppPlugin(): AppPlugin? {
val currentEngine = if (flutterEngine != null) flutterEngine else serviceEngine
return currentEngine?.plugins?.get(AppPlugin::class.java) as AppPlugin?
}
fun getCurrentTilePlugin(): TilePlugin? {
val currentEngine = if (flutterEngine != null) flutterEngine else serviceEngine
return currentEngine?.plugins?.get(TilePlugin::class.java) as TilePlugin?
}
fun getCurrentVPNPlugin(): VpnPlugin? {
return serviceEngine?.plugins?.get(VpnPlugin::class.java) as VpnPlugin?
}
fun destroyServiceEngine() {
serviceEngine?.destroy()
serviceEngine = null
}
fun initServiceEngine(context: Context) {
if (serviceEngine != null) return
lock.withLock {
destroyServiceEngine()
serviceEngine = FlutterEngine(context)
serviceEngine?.plugins?.add(VpnPlugin())
serviceEngine?.plugins?.add(AppPlugin())
serviceEngine?.plugins?.add(TilePlugin())
serviceEngine?.plugins?.add(ServicePlugin())
val vpnService = DartExecutor.DartEntrypoint(
FlutterInjector.instance().flutterLoader().findAppBundlePath(),
"vpnService"
)
serviceEngine?.dartExecutor?.executeDartEntrypoint(
vpnService,
)
}
}
}

View File

@@ -1,41 +1,27 @@
package com.follow.clash
import android.content.Intent
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import com.follow.clash.common.GlobalState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ServicePlugin
import com.follow.clash.plugins.VpnPlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class MainActivity : FlutterActivity(),
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
lifecycleScope.launch {
State.destroyServiceEngine()
}
}
class MainActivity : FlutterActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
flutterEngine.plugins.add(AppPlugin())
flutterEngine.plugins.add(VpnPlugin())
flutterEngine.plugins.add(ServicePlugin())
flutterEngine.plugins.add(TilePlugin())
State.flutterEngine = flutterEngine
GlobalState.flutterEngine = flutterEngine
}
override fun onDestroy() {
GlobalState.launch {
Service.setEventListener(null)
}
State.flutterEngine = null
GlobalState.flutterEngine = null
super.onDestroy()
}
}

View File

@@ -1,143 +0,0 @@
package com.follow.clash
import com.follow.clash.common.ServiceDelegate
import com.follow.clash.common.formatString
import com.follow.clash.common.intent
import com.follow.clash.service.ICallbackInterface
import com.follow.clash.service.IEventInterface
import com.follow.clash.service.IRemoteInterface
import com.follow.clash.service.IResultInterface
import com.follow.clash.service.RemoteService
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
object Service {
private val delegate by lazy {
ServiceDelegate<IRemoteInterface>(
RemoteService::class.intent, ::handleServiceDisconnected
) {
IRemoteInterface.Stub.asInterface(it)
}
}
var onServiceDisconnected: ((String) -> Unit)? = null
private fun handleServiceDisconnected(message: String) {
onServiceDisconnected?.let {
it(message)
}
}
fun bind() {
delegate.bind()
}
fun unbind() {
delegate.unbind()
}
suspend fun invokeAction(data: String, cb: (result: String) -> Unit): Result<Unit> {
val res = mutableListOf<ByteArray>()
return delegate.useService {
it.invokeAction(
data, object : ICallbackInterface.Stub() {
override fun onResult(result: ByteArray?, isSuccess: Boolean) {
res.add(result ?: byteArrayOf())
if (isSuccess) {
cb(res.formatString())
}
}
})
}
}
suspend fun setEventListener(
cb: ((result: String?) -> Unit)?
): Result<Unit> {
val results = HashMap<String, MutableList<ByteArray>>()
return delegate.useService {
it.setEventListener(
when (cb != null) {
true -> object : IEventInterface.Stub() {
override fun onEvent(
id: String, data: ByteArray?, isSuccess: Boolean
) {
if (results[id] == null) {
results[id] = mutableListOf()
}
results[id]?.add(data ?: byteArrayOf())
if (isSuccess) {
cb(results[id]?.formatString())
results.remove(id)
}
}
}
false -> null
}
)
}
}
suspend fun updateNotificationParams(
params: NotificationParams
): Result<Unit> {
return delegate.useService {
it.updateNotificationParams(params)
}
}
suspend fun setCrashlytics(
enable: Boolean
): Result<Unit> {
return delegate.useService {
it.setCrashlytics(enable)
}
}
private suspend fun awaitIResultInterface(
block: (IResultInterface) -> Unit
): Long = suspendCancellableCoroutine { continuation ->
val callback = object : IResultInterface.Stub() {
override fun onResult(time: Long) {
if (continuation.isActive) {
continuation.resume(time)
}
}
}
try {
block(callback)
} catch (e: Exception) {
if (continuation.isActive) {
continuation.resumeWithException(e)
}
}
}
suspend fun startService(options: VpnOptions, runTime: Long): Long {
return delegate.useService {
awaitIResultInterface { callback ->
it.startService(options, runTime, callback)
}
}.getOrNull() ?: 0L
}
suspend fun stopService(): Long {
return delegate.useService {
awaitIResultInterface { callback ->
it.stopService(callback)
}
}.getOrNull() ?: 0L
}
suspend fun getRunTime(): Long {
return delegate.useService {
it.runTime
}.getOrNull() ?: 0L
}
}

View File

@@ -1,161 +0,0 @@
package com.follow.clash
import com.follow.clash.common.GlobalState
import com.follow.clash.plugins.AppPlugin
import com.follow.clash.plugins.ServicePlugin
import com.follow.clash.plugins.TilePlugin
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.dart.DartExecutor
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
enum class RunState {
START, PENDING, STOP
}
object State {
val runLock = Mutex()
var runTime: Long = 0
val runStateFlow: MutableStateFlow<RunState> = MutableStateFlow(RunState.STOP)
var flutterEngine: FlutterEngine? = null
var serviceFlutterEngine: FlutterEngine? = null
val appPlugin: AppPlugin?
get() = flutterEngine?.plugin<AppPlugin>() ?: serviceFlutterEngine?.plugin<AppPlugin>()
val servicePlugin: ServicePlugin?
get() = flutterEngine?.plugin<ServicePlugin>()
?: serviceFlutterEngine?.plugin<ServicePlugin>()
val tilePlugin: TilePlugin?
get() = flutterEngine?.plugin<TilePlugin>() ?: serviceFlutterEngine?.plugin<TilePlugin>()
suspend fun handleToggleAction() {
var action: (suspend () -> Unit)?
runLock.withLock {
action = when (runStateFlow.value) {
RunState.PENDING -> null
RunState.START -> ::handleStopServiceAction
RunState.STOP -> ::handleStartServiceAction
}
}
action?.invoke()
}
suspend fun handleSyncState() {
runLock.withLock {
Service.bind()
runTime = Service.getRunTime()
val runState = when (runTime == 0L) {
true -> RunState.STOP
false -> RunState.START
}
runStateFlow.tryEmit(runState)
}
}
suspend fun handleStartServiceAction() {
tilePlugin?.handleStart()
if (flutterEngine != null) {
return
}
startServiceWithEngine()
}
fun handleStopServiceAction() {
tilePlugin?.handleStop()
if (flutterEngine != null || serviceFlutterEngine != null) {
return
}
handleStopService()
}
fun handleStartService() {
if (appPlugin != null) {
appPlugin?.requestNotificationsPermission {
startService()
}
return
}
startService()
}
suspend fun destroyServiceEngine() {
runLock.withLock {
withContext(Dispatchers.Main) {
runCatching {
serviceFlutterEngine?.destroy()
serviceFlutterEngine = null
}
}
}
}
suspend fun startServiceWithEngine() {
runLock.withLock {
if (serviceFlutterEngine != null || runStateFlow.value == RunState.PENDING || runStateFlow.value == RunState.START) {
return
}
withContext(Dispatchers.Main) {
serviceFlutterEngine = FlutterEngine(GlobalState.application)
serviceFlutterEngine?.plugins?.add(ServicePlugin())
serviceFlutterEngine?.plugins?.add(AppPlugin())
serviceFlutterEngine?.plugins?.add(TilePlugin())
val dartEntrypoint = DartExecutor.DartEntrypoint(
FlutterInjector.instance().flutterLoader().findAppBundlePath(), "_service"
)
serviceFlutterEngine?.dartExecutor?.executeDartEntrypoint(dartEntrypoint)
}
}
}
private fun startService() {
GlobalState.launch {
runLock.withLock {
if (runStateFlow.value == RunState.PENDING || runStateFlow.value == RunState.START) {
return@launch
}
runStateFlow.tryEmit(RunState.PENDING)
if (servicePlugin == null) {
return@launch
}
val options = servicePlugin?.handleGetVpnOptions()
if (options == null) {
return@launch
}
appPlugin?.prepare(options.enable) {
runTime = Service.startService(options, runTime)
runStateFlow.tryEmit(RunState.START)
}
}
}
}
fun handleStopService() {
GlobalState.launch {
runLock.withLock {
if (runStateFlow.value == RunState.PENDING || runStateFlow.value == RunState.STOP) {
return@launch
}
runStateFlow.tryEmit(RunState.PENDING)
runTime = Service.stopService()
runStateFlow.tryEmit(RunState.STOP)
}
destroyServiceEngine()
}
}
}

View File

@@ -2,34 +2,19 @@ package com.follow.clash
import android.app.Activity
import android.os.Bundle
import com.follow.clash.common.QuickAction
import com.follow.clash.common.action
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
class TempActivity : Activity(),
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
class TempActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
when (intent.action) {
QuickAction.START.action -> {
launch {
State.handleStartServiceAction()
}
"com.follow.clash.action.START" -> {
GlobalState.getCurrentTilePlugin()?.handleStart()
}
QuickAction.STOP.action -> {
State.handleStopServiceAction()
}
QuickAction.TOGGLE.action -> {
launch {
State.handleToggleAction()
}
"com.follow.clash.action.STOP" -> {
GlobalState.getCurrentTilePlugin()?.handleStop()
}
}
finish()
finishAndRemoveTask()
}
}

View File

@@ -1,62 +0,0 @@
package com.follow.clash
import android.annotation.SuppressLint
import android.os.Build
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.toPendingIntent
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
class TileService : TileService() {
private var scope: CoroutineScope? = null
private fun updateTile(runState: RunState) {
if (qsTile != null) {
qsTile.state = when (runState) {
RunState.START -> Tile.STATE_ACTIVE
RunState.PENDING -> Tile.STATE_UNAVAILABLE
RunState.STOP -> Tile.STATE_INACTIVE
}
qsTile.updateTile()
}
}
override fun onStartListening() {
super.onStartListening()
scope?.cancel()
scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
scope?.launch {
State.handleSyncState()
State.runStateFlow.collect {
updateTile(it)
}
}
}
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun handleToggle() {
val intent = QuickAction.TOGGLE.quickIntent
val pendingIntent = intent.toPendingIntent
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(pendingIntent)
} else {
@Suppress("DEPRECATION")
startActivityAndCollapse(intent)
}
}
override fun onClick() {
super.onClick()
handleToggle()
}
override fun onStopListening() {
scope?.cancel()
super.onStopListening()
}
}

View File

@@ -0,0 +1,69 @@
package com.follow.clash.extensions
import android.graphics.Bitmap
import android.graphics.drawable.Drawable
import android.net.ConnectivityManager
import android.net.Network
import android.system.OsConstants.IPPROTO_TCP
import android.system.OsConstants.IPPROTO_UDP
import android.util.Base64
import androidx.core.graphics.drawable.toBitmap
import com.follow.clash.models.Metadata
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.ByteArrayOutputStream
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
suspend fun Drawable.getBase64(): String {
val drawable = this
return withContext(Dispatchers.IO) {
val bitmap = drawable.toBitmap()
val byteArrayOutputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream)
Base64.encodeToString(byteArrayOutputStream.toByteArray(), Base64.NO_WRAP)
}
}
fun Metadata.getProtocol(): Int? {
if (network.startsWith("tcp")) return IPPROTO_TCP
if (network.startsWith("udp")) return IPPROTO_UDP
return null
}
fun ConnectivityManager.resolveDns(network: Network?): List<String> {
val properties = getLinkProperties(network) ?: return listOf()
return properties.dnsServers.map { it.asSocketAddressText(53) }
}
fun InetAddress.asSocketAddressText(port: Int): String {
return when (this) {
is Inet6Address ->
"[${numericToTextFormat(this.address)}]:$port"
is Inet4Address ->
"${this.hostAddress}:$port"
else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}")
}
}
private fun numericToTextFormat(src: ByteArray): String {
val sb = StringBuilder(39)
for (i in 0 until 8) {
sb.append(
Integer.toHexString(
src[i shl 1].toInt() shl 8 and 0xff00
or (src[(i shl 1) + 1].toInt() and 0xff)
)
)
if (i < 7) {
sb.append(":")
}
}
return sb.toString()
}

View File

@@ -3,7 +3,6 @@ package com.follow.clash.models
data class Package(
val packageName: String,
val label: String,
val system: Boolean,
val internet: Boolean,
val lastUpdateTime: Long,
val isSystem: Boolean,
val firstInstallTime: Long,
)

View File

@@ -0,0 +1,15 @@
package com.follow.clash.models
data class Process(
val id: Int,
val metadata: Metadata,
)
data class Metadata(
val network: String,
val sourceIP: String,
val sourcePort: Int,
val destinationIP: String,
val destinationPort: Int,
val host: String
)

View File

@@ -0,0 +1,30 @@
package com.follow.clash.models
enum class AccessControlMode {
acceptSelected,
rejectSelected,
}
data class AccessControl(
val mode: AccessControlMode,
val acceptList: List<String>,
val rejectList: List<String>,
)
data class Props(
val enable: Boolean?,
val accessControl: AccessControl?,
val allowBypass: Boolean?,
val systemProxy: Boolean?,
val ipv6: Boolean?,
)
data class TunProps(
val fd: Int,
val gateway: String,
val gateway6: String,
val portal: String,
val portal6: String,
val dns: String,
val dns6: String
)

View File

@@ -1,9 +0,0 @@
package com.follow.clash.models
data class AppState(
val crashlytics: Boolean = true,
val currentProfileName: String = "FlClash",
val stopText: String = "Stop",
val onlyStatisticsProxy: Boolean = false,
)

View File

@@ -3,6 +3,7 @@ package com.follow.clash.plugins
import android.Manifest
import android.app.Activity
import android.app.ActivityManager
import android.content.Context
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.ComponentInfo
@@ -13,16 +14,10 @@ import android.widget.Toast
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import androidx.core.content.ContextCompat.getSystemService
import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import com.android.tools.smali.dexlib2.dexbacked.DexBackedDexFile
import com.follow.clash.R
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.getPackageIconPath
import androidx.core.content.FileProvider
import com.follow.clash.GlobalState
import com.follow.clash.extensions.getBase64
import com.follow.clash.models.Package
import com.google.gson.Gson
import io.flutter.embedding.android.FlutterActivity
@@ -38,25 +33,23 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.io.File
import java.lang.ref.WeakReference
import java.util.zip.ZipFile
class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware {
companion object {
const val VPN_PERMISSION_REQUEST_CODE = 1001
const val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
}
private var activity: Activity? = null
private var activityRef: WeakReference<Activity>? = null
private var toast: Toast? = null
private lateinit var context: Context
private lateinit var channel: MethodChannel
private lateinit var scope: CoroutineScope
private var vpnPrepareCallback: (suspend () -> Unit)? = null
private var vpnCallBack: (() -> Unit)? = null
private var requestNotificationCallback: (() -> Unit)? = null
private val iconMap = mutableMapOf<String, String?>()
private val packages = mutableListOf<Package>()
@@ -115,24 +108,45 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
("(" + chinaAppPrefixList.joinToString("|").replace(".", "\\.") + ").*").toRegex()
}
val VPN_PERMISSION_REQUEST_CODE = 1001
val NOTIFICATION_PERMISSION_REQUEST_CODE = 1002
private var isBlockNotification: Boolean = false
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(Dispatchers.Default)
context = flutterPluginBinding.applicationContext;
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "app")
channel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
scope.cancel()
}
private fun tip(message: String?) {
if (GlobalState.flutterEngine == null) {
if (toast != null) {
toast!!.cancel()
}
toast = Toast.makeText(context, message, Toast.LENGTH_SHORT)
toast!!.show()
}
}
override fun onMethodCall(call: MethodCall, result: Result) {
when (call.method) {
"moveTaskToBack" -> {
activityRef?.get()?.moveTaskToBack(true)
result.success(true)
activity?.moveTaskToBack(true)
result.success(true);
}
"updateExcludeFromRecents" -> {
val value = call.argument<Boolean>("value")
updateExcludeFromRecents(value)
result.success(true)
}
"initShortcuts" -> {
initShortcuts(call.arguments as String)
result.success(true)
result.success(true);
}
"getPackages" -> {
@@ -148,7 +162,26 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
"getPackageIcon" -> {
handleGetPackageIcon(call, result)
scope.launch {
val packageName = call.argument<String>("packageName")
if (packageName == null) {
result.success(null)
return@launch
}
val packageIcon = getPackageIcon(packageName)
packageIcon.let {
if (it != null) {
result.success(it)
return@launch
}
if (iconMap["default"] == null) {
iconMap["default"] =
context.packageManager?.defaultActivityIcon?.getBase64()
}
result.success(iconMap["default"])
return@launch
}
}
}
"tip" -> {
@@ -157,53 +190,61 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
result.success(true)
}
"openFile" -> {
val path = call.argument<String>("path")!!
openFile(path)
result.success(true)
}
else -> {
result.notImplemented()
result.notImplemented();
}
}
}
private fun handleGetPackageIcon(call: MethodCall, result: Result) {
scope.launch {
val packageName = call.argument<String>("packageName")
if (packageName == null) {
result.success("")
return@launch
}
val path = GlobalState.application.packageManager.getPackageIconPath(packageName)
result.success(path)
}
}
private fun initShortcuts(label: String) {
val shortcut = with(ShortcutInfoCompat.Builder(GlobalState.application, "toggle")) {
setShortLabel(label)
setIcon(
IconCompat.createWithResource(
GlobalState.application,
R.mipmap.ic_launcher_round,
)
)
setIntent(QuickAction.TOGGLE.quickIntent)
build()
}
ShortcutManagerCompat.setDynamicShortcuts(
GlobalState.application, listOf(shortcut)
private fun openFile(path: String) {
val file = File(path)
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileProvider",
file
)
val intent = Intent(Intent.ACTION_VIEW).setDataAndType(
uri,
"text/plain"
)
val flags =
Intent.FLAG_GRANT_WRITE_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION
val resInfoList = context.packageManager.queryIntentActivities(
intent, PackageManager.MATCH_DEFAULT_ONLY
)
for (resolveInfo in resInfoList) {
val packageName = resolveInfo.activityInfo.packageName
context.grantUriPermission(
packageName,
uri,
flags
)
}
try {
activity?.startActivity(intent)
} catch (e: Exception) {
println(e)
}
}
private fun tip(message: String?) {
Toast.makeText(GlobalState.application, message, Toast.LENGTH_LONG).show()
}
@Suppress("DEPRECATION")
private fun updateExcludeFromRecents(value: Boolean?) {
val am = getSystemService(GlobalState.application, ActivityManager::class.java)
val am = getSystemService(context, ActivityManager::class.java)
val task = am?.appTasks?.firstOrNull {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
it.taskInfo.taskId == activityRef?.get()?.taskId
it.taskInfo.taskId == activity?.taskId
} else {
it.taskInfo.id == activityRef?.get()?.taskId
it.taskInfo.id == activity?.taskId
}
}
@@ -214,23 +255,36 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
}
private suspend fun getPackageIcon(packageName: String): String? {
val packageManager = context.packageManager
if (iconMap[packageName] == null) {
iconMap[packageName] = try {
packageManager?.getApplicationIcon(packageName)?.getBase64()
} catch (_: Exception) {
null
}
}
return iconMap[packageName]
}
private fun getPackages(): List<Package> {
val packageManager = GlobalState.application.packageManager
if (packages.isNotEmpty()) return packages
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA or PackageManager.GET_PERMISSIONS)
?.filter {
it.packageName != GlobalState.application.packageName && it.packageName != "android"
}?.map {
Package(
packageName = it.packageName,
label = it.applicationInfo?.loadLabel(packageManager).toString(),
system = (it.applicationInfo?.flags?.and(ApplicationInfo.FLAG_SYSTEM)) != 0,
lastUpdateTime = it.lastUpdateTime,
internet = it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
)
}?.let { packages.addAll(it) }
return packages
val packageManager = context.packageManager
if (packages.isNotEmpty()) return packages;
packageManager?.getInstalledPackages(PackageManager.GET_META_DATA)?.filter {
it.packageName != context.packageName
|| it.requestedPermissions?.contains(Manifest.permission.INTERNET) == true
|| it.packageName == "android"
}?.map {
Package(
packageName = it.packageName,
label = it.applicationInfo.loadLabel(packageManager).toString(),
isSystem = (it.applicationInfo.flags and ApplicationInfo.FLAG_SYSTEM) == 1,
firstInstallTime = it.firstInstallTime
)
}?.let { packages.addAll(it) }
return packages;
}
private suspend fun getPackagesToJson(): String {
@@ -247,66 +301,44 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}
}
fun requestNotificationsPermission(callBack: () -> Unit) {
requestNotificationCallback = callBack
fun requestVpnPermission(context: Context, callBack: () -> Unit) {
vpnCallBack = callBack
val intent = VpnService.prepare(context)
if (intent != null) {
activity?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
return;
}
vpnCallBack?.invoke()
}
fun requestNotificationsPermission(context: Context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
val permission = ContextCompat.checkSelfPermission(
GlobalState.application, Manifest.permission.POST_NOTIFICATIONS
context,
Manifest.permission.POST_NOTIFICATIONS
)
if (permission == PackageManager.PERMISSION_GRANTED || isBlockNotification) {
invokeRequestNotificationCallback()
return
}
activityRef?.get()?.let {
if (permission != PackageManager.PERMISSION_GRANTED) {
if (isBlockNotification) return
if (activity == null) return
ActivityCompat.requestPermissions(
it,
activity!!,
arrayOf(Manifest.permission.POST_NOTIFICATIONS),
NOTIFICATION_PERMISSION_REQUEST_CODE
)
return
}
return
} else {
invokeRequestNotificationCallback()
}
}
fun invokeRequestNotificationCallback() {
requestNotificationCallback?.invoke()
requestNotificationCallback = null
}
fun prepare(needPrepare: Boolean, callBack: (suspend () -> Unit)) {
vpnPrepareCallback = callBack
if (!needPrepare) {
invokeVpnPrepareCallback()
return
}
val intent = VpnService.prepare(GlobalState.application)
if (intent != null) {
activityRef?.get()?.startActivityForResult(intent, VPN_PERMISSION_REQUEST_CODE)
return
}
invokeVpnPrepareCallback()
}
fun invokeVpnPrepareCallback() {
GlobalState.launch {
vpnPrepareCallback?.invoke()
vpnPrepareCallback = null
}
}
@Suppress("DEPRECATION")
private fun isChinaPackage(packageName: String): Boolean {
val packageManager = GlobalState.application.packageManager ?: return false
val packageManager = context.packageManager ?: return false
skipPrefixList.forEach {
if (packageName == it || packageName.startsWith("$it.")) return false
}
val packageManagerFlags = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
PackageManager.MATCH_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
} else {
@Suppress("DEPRECATION")
PackageManager.GET_UNINSTALLED_PACKAGES or PackageManager.GET_ACTIVITIES or PackageManager.GET_SERVICES or PackageManager.GET_RECEIVERS or PackageManager.GET_PROVIDERS
}
if (packageName.matches(chinaAppRegex)) {
@@ -315,10 +347,11 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
try {
val packageInfo = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
packageManager.getPackageInfo(
packageName, PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong())
packageName,
PackageManager.PackageInfoFlags.of(packageManagerFlags.toLong())
)
} else {
packageManager.getPackageInfo(
@Suppress("DEPRECATION") packageManager.getPackageInfo(
packageName, packageManagerFlags
)
}
@@ -330,33 +363,31 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
}.forEach {
if (it.name.matches(chinaAppRegex)) return true
}
packageInfo.applicationInfo?.publicSourceDir?.let {
ZipFile(File(it)).use {
for (packageEntry in it.entries()) {
if (packageEntry.name.startsWith("firebase-")) return false
ZipFile(File(packageInfo.applicationInfo.publicSourceDir)).use {
for (packageEntry in it.entries()) {
if (packageEntry.name.startsWith("firebase-")) return false
}
for (packageEntry in it.entries()) {
if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith(
".dex"
))
) {
continue
}
for (packageEntry in it.entries()) {
if (!(packageEntry.name.startsWith("classes") && packageEntry.name.endsWith(
".dex"
))
) {
continue
}
if (packageEntry.size > 15000000) {
return true
}
val input = it.getInputStream(packageEntry).buffered()
val dexFile = try {
DexBackedDexFile.fromInputStream(null, input)
} catch (e: Exception) {
return false
}
for (clazz in dexFile.classes) {
val clazzName =
clazz.type.substring(1, clazz.type.length - 1).replace("/", ".")
.replace("$", ".")
if (clazzName.matches(chinaAppRegex)) return true
}
if (packageEntry.size > 15000000) {
return true
}
val input = it.getInputStream(packageEntry).buffered()
val dexFile = try {
DexBackedDexFile.fromInputStream(null, input)
} catch (e: Exception) {
return false
}
for (clazz in dexFile.classes) {
val clazzName =
clazz.type.substring(1, clazz.type.length - 1).replace("/", ".")
.replace("$", ".")
if (clazzName.matches(chinaAppRegex)) return true
}
}
}
@@ -366,53 +397,43 @@ class AppPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, ActivityAware
return false
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(Dispatchers.Default)
channel =
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/app")
channel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
channel.setMethodCallHandler(null)
scope.cancel()
}
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activityRef = WeakReference(binding.activity)
activity = binding.activity;
binding.addActivityResultListener(::onActivityResult)
binding.addRequestPermissionsResultListener(::onRequestPermissionsResultListener)
}
override fun onDetachedFromActivityForConfigChanges() {
activityRef = null
activity = null
}
override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) {
activityRef = WeakReference(binding.activity)
activity = binding.activity;
}
override fun onDetachedFromActivity() {
channel.invokeMethod("exit", null)
activityRef = null
activity = null
}
private fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean {
if (requestCode == VPN_PERMISSION_REQUEST_CODE) {
if (resultCode == FlutterActivity.RESULT_OK) {
invokeVpnPrepareCallback()
GlobalState.initServiceEngine(context)
vpnCallBack?.invoke()
}
}
return true
}
private fun onRequestPermissionsResultListener(
requestCode: Int, permissions: Array<String>, grantResults: IntArray
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
): Boolean {
if (requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) {
isBlockNotification = true
}
invokeRequestNotificationCallback()
return true
}
}

View File

@@ -1,34 +1,23 @@
package com.follow.clash.plugins
import com.follow.clash.RunState
import com.follow.clash.Service
import com.follow.clash.State
import com.follow.clash.awaitResult
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
import com.follow.clash.invokeMethodOnMainThread
import com.follow.clash.models.AppState
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import com.google.gson.Gson
import android.content.Context
import android.net.ConnectivityManager
import androidx.core.content.getSystemService
import com.follow.clash.GlobalState
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var flutterMethodChannel: MethodChannel
private lateinit var context: Context
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
flutterMethodChannel = MethodChannel(
flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/service"
)
context = flutterPluginBinding.applicationContext
flutterMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "service")
flutterMethodChannel.setMethodCallHandler(this)
}
@@ -38,31 +27,14 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) = when (call.method) {
"init" -> {
handleInit(call, result)
GlobalState.getCurrentAppPlugin()?.requestNotificationsPermission(context)
GlobalState.initServiceEngine(context)
result.success(true)
}
"shutdown" -> {
handleShutdown(result)
}
"invokeAction" -> {
handleInvokeAction(call, result)
}
"getRunTime" -> {
handleGetRunTime(result)
}
"syncState" -> {
handleSyncState(call, result)
}
"start" -> {
handleStart(result)
}
"stop" -> {
handleStop(result)
"destroy" -> {
handleDestroy()
result.success(true)
}
else -> {
@@ -70,91 +42,8 @@ class ServicePlugin : FlutterPlugin, MethodChannel.MethodCallHandler,
}
}
private fun handleInvokeAction(call: MethodCall, result: MethodChannel.Result) {
launch {
val data = call.arguments<String>()!!
Service.invokeAction(data) {
result.success(it)
}
}
}
private fun handleShutdown(result: MethodChannel.Result) {
Service.unbind()
result.success(true)
}
private fun handleStart(result: MethodChannel.Result) {
State.handleStartService()
result.success(true)
}
private fun handleStop(result: MethodChannel.Result) {
State.handleStopService()
result.success(true)
}
suspend fun handleGetVpnOptions(): VpnOptions? {
val res = flutterMethodChannel.awaitResult<String>("getVpnOptions", null)
return Gson().fromJson(res, VpnOptions::class.java)
}
val semaphore = Semaphore(10)
fun handleSendEvent(value: String?) {
launch(Dispatchers.Main) {
semaphore.withPermit {
flutterMethodChannel.invokeMethod("event", value)
}
}
}
private fun onServiceDisconnected(message: String) {
State.runStateFlow.tryEmit(RunState.STOP)
flutterMethodChannel.invokeMethodOnMainThread<Any>("crash", message)
}
private fun handleSyncState(call: MethodCall, result: MethodChannel.Result) {
val data = call.arguments<String>()!!
val params = Gson().fromJson(data, AppState::class.java)
GlobalState.setCrashlytics(params.crashlytics)
launch {
Service.updateNotificationParams(
NotificationParams(
title = params.currentProfileName,
stopText = params.stopText,
onlyStatisticsProxy = params.onlyStatisticsProxy
)
)
Service.setCrashlytics(params.crashlytics)
result.success("")
}
}
fun handleInit(call: MethodCall, result: MethodChannel.Result) {
Service.bind()
launch {
val needSetEventListener = call.arguments<Boolean>() ?: false
when (needSetEventListener) {
true -> Service.setEventListener {
handleSendEvent(it)
}
false -> Service.setEventListener(null)
}.onSuccess {
result.success("")
}.onFailure {
result.success(it.message)
}
}
Service.onServiceDisconnected = ::onServiceDisconnected
}
private fun handleGetRunTime(result: MethodChannel.Result) {
launch {
State.handleSyncState()
result.success(State.runTime)
}
private fun handleDestroy() {
GlobalState.getCurrentVPNPlugin()?.stop()
GlobalState.destroyServiceEngine()
}
}

View File

@@ -1,31 +1,36 @@
package com.follow.clash.plugins
import com.follow.clash.common.Components
import com.follow.clash.invokeMethodOnMainThread
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
class TilePlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
class TilePlugin(private val onStart: (() -> Unit)? = null, private val onStop: (() -> Unit)? = null) : FlutterPlugin,
MethodChannel.MethodCallHandler {
private lateinit var channel: MethodChannel
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel =
MethodChannel(flutterPluginBinding.binaryMessenger, "${Components.PACKAGE_NAME}/tile")
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "tile")
channel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
handleDetached()
channel.setMethodCallHandler(null)
}
fun handleStart() {
channel.invokeMethodOnMainThread<Any>("start", null)
onStart?.let { it() }
channel.invokeMethod("start", null)
}
fun handleStop() {
channel.invokeMethodOnMainThread<Any>("stop", null)
channel.invokeMethod("stop", null)
onStop?.let { it() }
}
private fun handleDetached() {
channel.invokeMethod("detached", null)
}

View File

@@ -0,0 +1,269 @@
package com.follow.clash.plugins
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkRequest
import android.os.Build
import android.os.IBinder
import androidx.core.content.getSystemService
import com.follow.clash.BaseServiceInterface
import com.follow.clash.GlobalState
import com.follow.clash.RunState
import com.follow.clash.extensions.getProtocol
import com.follow.clash.extensions.resolveDns
import com.follow.clash.models.Props
import com.follow.clash.models.TunProps
import com.follow.clash.services.FlClashService
import com.follow.clash.services.FlClashVpnService
import com.google.gson.Gson
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.net.InetSocketAddress
import kotlin.concurrent.withLock
import com.follow.clash.models.Process
class VpnPlugin : FlutterPlugin, MethodChannel.MethodCallHandler {
private lateinit var flutterMethodChannel: MethodChannel
private lateinit var context: Context
private var flClashService: BaseServiceInterface? = null
private var port: Int = 7890
private var props: Props? = null
private lateinit var scope: CoroutineScope
private val connectivity by lazy {
context.getSystemService<ConnectivityManager>()
}
private val connection = object : ServiceConnection {
override fun onServiceConnected(className: ComponentName, service: IBinder) {
flClashService = when (service) {
is FlClashVpnService.LocalBinder -> service.getService()
is FlClashService.LocalBinder -> service.getService()
else -> throw Exception("invalid binder")
}
start()
}
override fun onServiceDisconnected(arg: ComponentName) {
flClashService = null
}
}
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
scope = CoroutineScope(Dispatchers.Default)
context = flutterPluginBinding.applicationContext
scope.launch {
registerNetworkCallback()
}
flutterMethodChannel = MethodChannel(flutterPluginBinding.binaryMessenger, "vpn")
flutterMethodChannel.setMethodCallHandler(this)
}
override fun onDetachedFromEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
unRegisterNetworkCallback()
flutterMethodChannel.setMethodCallHandler(null)
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
"start" -> {
port = call.argument<Int>("port")!!
val args = call.argument<String>("args")
props =
if (args != null) Gson().fromJson(args, Props::class.java) else null
when (props?.enable == true) {
true -> handleStartVpn()
false -> start()
}
result.success(true)
}
"stop" -> {
stop()
result.success(true)
}
"setProtect" -> {
val fd = call.argument<Int>("fd")
if (fd != null) {
if (flClashService is FlClashVpnService) {
(flClashService as FlClashVpnService).protect(fd)
}
result.success(true)
} else {
result.success(false)
}
}
"startForeground" -> {
val title = call.argument<String>("title") as String
val content = call.argument<String>("content") as String
startForeground(title, content)
result.success(true)
}
"resolverProcess" -> {
val data = call.argument<String>("data")
val process =
if (data != null) Gson().fromJson(
data,
Process::class.java
) else null
val metadata = process?.metadata
if (metadata == null) {
result.success(null)
return
}
val protocol = metadata.getProtocol()
if (protocol == null) {
result.success(null)
return
}
scope.launch {
withContext(Dispatchers.Default) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
result.success(null)
return@withContext
}
val src = InetSocketAddress(metadata.sourceIP, metadata.sourcePort)
val dst = InetSocketAddress(
metadata.destinationIP.ifEmpty { metadata.host },
metadata.destinationPort
)
val uid = try {
connectivity?.getConnectionOwnerUid(protocol, src, dst)
} catch (_: Exception) {
null
}
if (uid == null || uid == -1) {
result.success(null)
return@withContext
}
val packages = context.packageManager?.getPackagesForUid(uid)
result.success(packages?.first())
}
}
}
else -> {
result.notImplemented()
}
}
}
private fun handleStartVpn() {
GlobalState.getCurrentAppPlugin()?.requestVpnPermission(context) {
start()
}
}
fun requestGc() {
flutterMethodChannel.invokeMethod("gc", null)
}
val networks = mutableSetOf<Network>()
fun onUpdateNetwork() {
val dns = networks.flatMap { network ->
connectivity?.resolveDns(network) ?: emptyList()
}
.toSet()
.joinToString(",")
scope.launch {
withContext(Dispatchers.Main) {
flutterMethodChannel.invokeMethod("dnsChanged", dns)
}
}
// if (flClashService is FlClashVpnService) {
// val network = networks.maxByOrNull { net ->
// connectivity?.getNetworkCapabilities(net)?.let { cap ->
// TRANSPORT_PRIORITY.indexOfFirst { cap.hasTransport(it) }
// } ?: -1
// }
// network?.let {
// (flClashService as FlClashVpnService).updateUnderlyingNetworks(arrayOf(network))
// }
// }
}
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networks.add(network)
onUpdateNetwork()
}
override fun onLost(network: Network) {
networks.remove(network)
onUpdateNetwork()
}
}
private val request = NetworkRequest.Builder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
}.build()
private fun registerNetworkCallback() {
networks.clear()
connectivity?.registerNetworkCallback(request, callback)
}
private fun unRegisterNetworkCallback() {
connectivity?.unregisterNetworkCallback(callback)
networks.clear()
onUpdateNetwork()
}
private fun startForeground(title: String, content: String) {
GlobalState.runLock.withLock {
if (GlobalState.runState.value != RunState.START) return
flClashService?.startForeground(title, content)
}
}
private fun start() {
if (flClashService == null) {
bindService()
return
}
GlobalState.runLock.withLock {
if (GlobalState.runState.value == RunState.START) return
GlobalState.runState.value = RunState.START
val tunProps = flClashService?.start(port, props)
flutterMethodChannel.invokeMethod(
"started",
Gson().toJson(tunProps, TunProps::class.java)
)
}
}
fun stop() {
GlobalState.runLock.withLock {
if (GlobalState.runState.value == RunState.STOP) return
GlobalState.runState.value = RunState.STOP
flClashService?.stop()
}
GlobalState.destroyServiceEngine()
}
private fun bindService() {
val intent = when (props?.enable == true) {
true -> Intent(context, FlClashVpnService::class.java)
false -> Intent(context, FlClashService::class.java)
}
context.bindService(intent, connection, Context.BIND_AUTO_CREATE)
}
}

View File

@@ -0,0 +1,103 @@
package com.follow.clash.services
import android.annotation.SuppressLint
import android.app.Notification.FOREGROUND_SERVICE_IMMEDIATE
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.os.Binder
import android.os.Build
import android.os.IBinder
import androidx.core.app.NotificationCompat
import com.follow.clash.BaseServiceInterface
import com.follow.clash.MainActivity
import com.follow.clash.models.Props
class FlClashService : Service(), BaseServiceInterface {
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): FlClashService = this@FlClashService
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun onUnbind(intent: Intent?): Boolean {
return super.onUnbind(intent)
}
private val CHANNEL = "FlClash"
private val notificationId: Int = 1
private val notificationBuilder: NotificationCompat.Builder by lazy {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
with(NotificationCompat.Builder(this, CHANNEL)) {
setSmallIcon(com.follow.clash.R.drawable.ic_stat_name)
setContentTitle("FlClash")
setContentIntent(pendingIntent)
setCategory(NotificationCompat.CATEGORY_SERVICE)
priority = NotificationCompat.PRIORITY_MIN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
}
setOngoing(true)
setShowWhen(false)
setOnlyAlertOnce(true)
setAutoCancel(true)
}
}
override fun start(port: Int, props: Props?) = null
override fun stop() {
stopSelf()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
@SuppressLint("ForegroundServiceType", "WrongConstant")
override fun startForeground(title: String, content: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
var channel = manager?.getNotificationChannel(CHANNEL)
if (channel == null) {
channel =
NotificationChannel(CHANNEL, "FlClash", NotificationManager.IMPORTANCE_LOW)
manager?.createNotificationChannel(channel)
}
}
val notification =
notificationBuilder.setContentTitle(title).setContentText(content).build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(notificationId, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(notificationId, notification)
}
}
}

View File

@@ -0,0 +1,89 @@
package com.follow.clash.services
import android.annotation.SuppressLint
import android.app.PendingIntent
import android.content.Intent
import android.os.Build
import android.os.IBinder
import android.service.quicksettings.Tile
import android.service.quicksettings.TileService
import androidx.annotation.RequiresApi
import androidx.lifecycle.Observer
import com.follow.clash.GlobalState
import com.follow.clash.RunState
import com.follow.clash.TempActivity
@RequiresApi(Build.VERSION_CODES.N)
class FlClashTileService : TileService() {
private val observer = Observer<RunState> { runState ->
updateTile(runState)
}
private fun updateTile(runState: RunState) {
if (qsTile != null) {
qsTile.state = when (runState) {
RunState.START -> Tile.STATE_ACTIVE
RunState.PENDING -> Tile.STATE_UNAVAILABLE
RunState.STOP -> Tile.STATE_INACTIVE
}
qsTile.updateTile()
}
}
override fun onStartListening() {
super.onStartListening()
GlobalState.runState.value?.let { updateTile(it) }
GlobalState.runState.observeForever(observer)
}
@SuppressLint("StartActivityAndCollapseDeprecated")
private fun activityTransfer() {
val intent = Intent(this, TempActivity::class.java)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startActivityAndCollapse(pendingIntent)
} else {
startActivityAndCollapse(intent)
}
}
override fun onClick() {
super.onClick()
activityTransfer()
if (GlobalState.runState.value == RunState.STOP) {
GlobalState.runState.value = RunState.PENDING
val tilePlugin = GlobalState.getCurrentTilePlugin()
if (tilePlugin != null) {
tilePlugin.handleStart()
} else {
GlobalState.initServiceEngine(applicationContext)
}
} else if (GlobalState.runState.value == RunState.START) {
GlobalState.runState.value = RunState.PENDING
GlobalState.getCurrentTilePlugin()?.handleStop()
}
}
override fun onDestroy() {
GlobalState.runState.removeObserver(observer)
super.onDestroy()
}
}

View File

@@ -0,0 +1,264 @@
package com.follow.clash.services
import android.annotation.SuppressLint
import android.app.Notification.FOREGROUND_SERVICE_IMMEDIATE
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Intent
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.net.Network
import android.net.ProxyInfo
import android.net.VpnService
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.Parcel
import android.os.RemoteException
import androidx.core.app.NotificationCompat
import com.follow.clash.BaseServiceInterface
import com.follow.clash.GlobalState
import com.follow.clash.MainActivity
import com.follow.clash.R
import com.follow.clash.TempActivity
import com.follow.clash.models.AccessControlMode
import com.follow.clash.models.Props
import com.follow.clash.models.TunProps
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class FlClashVpnService : VpnService(), BaseServiceInterface {
companion object {
private val passList = listOf(
"*zhihu.com",
"*zhimg.com",
"*jd.com",
"100ime-iat-api.xfyun.cn",
"*360buyimg.com",
"localhost",
"*.local",
"127.*",
"10.*",
"172.16.*",
"172.17.*",
"172.18.*",
"172.19.*",
"172.2*",
"172.30.*",
"172.31.*",
"192.168.*"
)
private const val TUN_MTU = 9000
private const val TUN_SUBNET_PREFIX = 30
private const val TUN_GATEWAY = "172.19.0.1"
private const val TUN_SUBNET_PREFIX6 = 126
private const val TUN_GATEWAY6 = "fdfe:dcba:9876::1"
private const val TUN_PORTAL = "172.19.0.2"
private const val TUN_PORTAL6 = "fdfe:dcba:9876::2"
private const val TUN_DNS = TUN_PORTAL
private const val TUN_DNS6 = TUN_PORTAL6
private const val NET_ANY = "0.0.0.0"
private const val NET_ANY6 = "::"
}
override fun onCreate() {
super.onCreate()
GlobalState.initServiceEngine(applicationContext)
}
override fun start(port: Int, props: Props?): TunProps {
return with(Builder()) {
addAddress(TUN_GATEWAY, TUN_SUBNET_PREFIX)
addRoute(NET_ANY, 0)
addDnsServer(TUN_DNS)
if (props?.ipv6 == true) {
try {
addAddress(TUN_GATEWAY6, TUN_SUBNET_PREFIX6)
addRoute(NET_ANY6, 0)
addDnsServer(TUN_DNS6)
} catch (_: Exception) {
}
}
setMtu(TUN_MTU)
props?.accessControl?.let { accessControl ->
when (accessControl.mode) {
AccessControlMode.acceptSelected -> {
(accessControl.acceptList + packageName).forEach {
addAllowedApplication(it)
}
}
AccessControlMode.rejectSelected -> {
(accessControl.rejectList - packageName).forEach {
addDisallowedApplication(it)
}
}
}
}
setSession("FlClash")
setBlocking(false)
if (Build.VERSION.SDK_INT >= 29) {
setMetered(false)
}
if (props?.allowBypass == true) {
allowBypass()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && props?.systemProxy == true) {
setHttpProxy(
ProxyInfo.buildDirectProxy(
"127.0.0.1",
port,
passList
)
)
}
TunProps(
fd = establish()?.detachFd()
?: throw NullPointerException("Establish VPN rejected by system"),
gateway = "$TUN_GATEWAY/$TUN_SUBNET_PREFIX",
gateway6 = if (props?.ipv6 == true) "$TUN_GATEWAY6/$TUN_SUBNET_PREFIX6" else "",
portal = TUN_PORTAL,
portal6 = if (props?.ipv6 == true) TUN_PORTAL6 else "",
dns = TUN_DNS,
dns6 = if (props?.ipv6 == true) TUN_DNS6 else ""
)
}
}
fun updateUnderlyingNetworks(networks: Array<Network>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
this.setUnderlyingNetworks(networks)
}
}
override fun stop() {
stopSelf()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
private val CHANNEL = "FlClash"
private val notificationId: Int = 1
private val notificationBuilder: NotificationCompat.Builder by lazy {
val intent = Intent(this, MainActivity::class.java)
val pendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this,
0,
intent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
val stopIntent = Intent(this, TempActivity::class.java)
stopIntent.action = "com.follow.clash.action.STOP"
stopIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
val stopPendingIntent = if (Build.VERSION.SDK_INT >= 31) {
PendingIntent.getActivity(
this,
0,
stopIntent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
} else {
PendingIntent.getActivity(
this,
0,
stopIntent,
PendingIntent.FLAG_UPDATE_CURRENT
)
}
with(NotificationCompat.Builder(this, CHANNEL)) {
setSmallIcon(R.drawable.ic_stat_name)
setContentTitle("FlClash")
setContentIntent(pendingIntent)
setCategory(NotificationCompat.CATEGORY_SERVICE)
priority = NotificationCompat.PRIORITY_MIN
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
}
setOngoing(true)
setShowWhen(false)
setOnlyAlertOnce(true)
setAutoCancel(true)
addAction(0, "Stop", stopPendingIntent);
}
}
@SuppressLint("ForegroundServiceType", "WrongConstant")
override fun startForeground(title: String, content: String) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
var channel = manager?.getNotificationChannel(CHANNEL)
if (channel == null) {
channel =
NotificationChannel(CHANNEL, "FlClash", NotificationManager.IMPORTANCE_LOW)
manager?.createNotificationChannel(channel)
}
}
val notification =
notificationBuilder.setContentTitle(title).setContentText(content).build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(notificationId, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(notificationId, notification)
}
}
override fun onTrimMemory(level: Int) {
super.onTrimMemory(level)
GlobalState.getCurrentVPNPlugin()?.requestGc()
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): FlClashVpnService = this@FlClashVpnService
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
try {
val isSuccess = super.onTransact(code, data, reply, flags)
if (!isSuccess) {
CoroutineScope(Dispatchers.Main).launch {
GlobalState.getCurrentTilePlugin()?.handleStop()
}
}
return isSuccess
} catch (e: RemoteException) {
throw e
}
}
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun onUnbind(intent: Intent?): Boolean {
return super.onUnbind(intent)
}
override fun onDestroy() {
stop()
super.onDestroy()
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 803 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="FlClash">FlClash</string>
<string name="fl_clash">FlClash</string>
</resources>

32
android/build.gradle Normal file
View File

@@ -0,0 +1,32 @@
buildscript {
ext.kotlin_version = "${kotlin_version}"
repositories {
google()
mavenCentral()
}
dependencies {
classpath "com.android.tools.build:gradle:$agp_version"
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
allprojects {
repositories {
google()
mavenCentral()
}
}
rootProject.buildDir = '../build'
subprojects {
project.buildDir = "${rootProject.buildDir}/${project.name}"
}
subprojects {
project.evaluationDependsOn(':app')
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@@ -1,34 +0,0 @@
buildscript {
dependencies {
classpath(libs.build.kotlin)
}
}
plugins {
id("com.android.library") apply false
}
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}

View File

@@ -1 +0,0 @@
/build

View File

@@ -1,45 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.follow.clash.common"
compileSdk = libs.versions.compileSdk.get().toInt()
defaultConfig {
minSdk = 21
consumerProguardFiles("consumer-rules.pro")
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.androidx.core)
implementation(libs.gson)
implementation(platform(libs.firebase.bom))
implementation(libs.firebase.crashlytics.ndk)
implementation(libs.firebase.analytics)
}

View File

@@ -1,9 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<permission
android:name="${applicationId}.permission.RECEIVE_BROADCASTS"
android:protectionLevel="signature" />
<uses-permission android:name="${applicationId}.permission.RECEIVE_BROADCASTS" />
</manifest>

View File

@@ -1,16 +0,0 @@
package com.follow.clash.common
import android.content.ComponentName
object Components {
const val PACKAGE_NAME = "com.follow.clash"
val MAIN_ACTIVITY =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.MainActivity")
val TEMP_ACTIVITY =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.TempActivity")
val BROADCAST_RECEIVER =
ComponentName(GlobalState.packageName, "${PACKAGE_NAME}.BroadcastReceiver")
}

View File

@@ -1,23 +0,0 @@
package com.follow.clash.common
import com.google.gson.annotations.SerializedName
enum class QuickAction {
STOP,
START,
TOGGLE,
}
enum class BroadcastAction {
SERVICE_CREATED,
SERVICE_DESTROYED,
}
enum class AccessControlMode {
@SerializedName("acceptSelected")
ACCEPT_SELECTED,
@SerializedName("rejectSelected")
REJECT_SELECTED,
}

View File

@@ -1,251 +0,0 @@
package com.follow.clash.common
import android.annotation.SuppressLint
import android.app.ActivityManager
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.BroadcastReceiver
import android.content.ComponentName
import android.content.Context
import android.content.Context.RECEIVER_NOT_EXPORTED
import android.content.Intent
import android.content.IntentFilter
import android.content.ServiceConnection
import android.content.pm.ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
import android.os.Build
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.os.RemoteException
import android.util.Log
import androidx.core.content.getSystemService
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.retryWhen
import kotlinx.coroutines.withContext
import java.nio.charset.Charset
import kotlin.reflect.KClass
//fun Context.startForegroundServiceCompat(intent: Intent?) {
// if (Build.VERSION.SDK_INT >= 26) {
// startForegroundService(intent)
// } else {
// startService(intent)
// }
//}
val KClass<*>.intent: Intent
get() = Intent(GlobalState.application, this.java)
fun Service.startForegroundCompat(id: Int, notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
startForeground(id, notification, FOREGROUND_SERVICE_TYPE_SPECIAL_USE)
} else {
startForeground(id, notification)
}
}
val ComponentName.intent: Intent
get() = Intent().apply {
setComponent(this@intent)
setPackage(GlobalState.packageName)
}
val QuickAction.action: String
get() = "${GlobalState.application.packageName}.action.${this.name}"
val QuickAction.quickIntent: Intent
get() = Components.TEMP_ACTIVITY.intent.apply {
action = this@quickIntent.action
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)
}
val BroadcastAction.action: String
get() = "${GlobalState.application.packageName}.intent.action.${this.name}"
val Context.processName: String?
get() {
val pid = android.os.Process.myPid()
val activityManager = getSystemService<ActivityManager>()
activityManager?.runningAppProcesses?.find { it.pid == pid }?.let {
return it.processName
}
return null
}
val BroadcastAction.quickIntent: Intent
get() = Components.BROADCAST_RECEIVER.intent.apply {
action = this@quickIntent.action
}
fun BroadcastAction.sendBroadcast() {
val intent = Intent().apply {
action = this@sendBroadcast.action
Log.d("[sendBroadcast]", "$action")
setPackage(GlobalState.packageName)
}
GlobalState.application.sendBroadcast(
intent, GlobalState.RECEIVE_BROADCASTS_PERMISSIONS
)
}
val Intent.toPendingIntent: PendingIntent
get() = PendingIntent.getActivity(
GlobalState.application,
0,
this,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT
)
fun Service.startForeground(notification: Notification) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(NotificationManager::class.java)
var channel = manager?.getNotificationChannel(GlobalState.NOTIFICATION_CHANNEL)
if (channel == null) {
channel = NotificationChannel(
GlobalState.NOTIFICATION_CHANNEL,
"SERVICE_CHANNEL",
NotificationManager.IMPORTANCE_LOW
)
manager?.createNotificationChannel(channel)
}
}
startForegroundCompat(GlobalState.NOTIFICATION_ID, notification)
}
@SuppressLint("UnspecifiedRegisterReceiverFlag")
fun Context.registerReceiverCompat(
receiver: BroadcastReceiver,
filter: IntentFilter,
) = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
registerReceiver(receiver, filter, RECEIVER_NOT_EXPORTED)
} else {
registerReceiver(receiver, filter)
}
fun Context.receiveBroadcastFlow(
configure: IntentFilter.() -> Unit,
): Flow<Intent> = callbackFlow {
val filter = IntentFilter().apply(configure)
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (context == null || intent == null) return
trySend(intent)
}
}
registerReceiverCompat(receiver, filter)
awaitClose { unregisterReceiver(receiver) }
}
inline fun <reified T : IBinder> Context.bindServiceFlow(
intent: Intent,
flags: Int = Context.BIND_AUTO_CREATE,
maxRetries: Int = 10,
retryDelayMillis: Long = 200L
): Flow<Pair<IBinder?, String>> = callbackFlow {
val connection = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName?, binder: IBinder?) {
if (binder != null) {
try {
@Suppress("UNCHECKED_CAST") val casted = binder as? T
if (casted != null) {
trySend(Pair(casted, ""))
} else {
trySend(Pair(null, "Binder is not of type ${T::class.java}"))
}
} catch (e: RemoteException) {
trySend(Pair(null, "Failed to link to death: ${e.message}"))
}
} else {
trySend(Pair(null, "Binder empty"))
}
}
override fun onServiceDisconnected(name: ComponentName?) {
trySend(Pair(null, "Service disconnected"))
}
}
val success = withContext(Dispatchers.Main) {
bindService(intent, connection, flags)
}
if (!success) {
throw IllegalStateException("bindService() failed, will retry")
}
awaitClose {
Handler(Looper.getMainLooper()).post {
unbindService(connection)
trySend(Pair(null, ""))
}
}
}.retryWhen { cause, attempt ->
if (attempt < maxRetries && cause is Exception) {
delay(retryDelayMillis)
true
} else {
false
}
}
val Long.formatBytes: String
get() {
val units = arrayOf("B", "KB", "MB", "GB", "TB")
var size = this.toDouble()
var unitIndex = 0
while (size >= 1024 && unitIndex < units.size - 1) {
size /= 1024
unitIndex++
}
return if (unitIndex == 0) {
"${size.toLong()}${units[unitIndex]}"
} else {
"%.1f${units[unitIndex]}".format(size)
}
}
fun String.chunkedForAidl(charset: Charset = Charsets.UTF_8): List<ByteArray> {
val allBytes = toByteArray(charset)
val total = allBytes.size
val maxBytes = when {
total <= 100 * 1024 -> total
total <= 1024 * 1024 -> 64 * 1024
total <= 10 * 1024 * 1024 -> 128 * 1024
else -> 256 * 1024
}
val result = mutableListOf<ByteArray>()
var index = 0
while (index < total) {
val end = minOf(index + maxBytes, total)
result.add(allBytes.copyOfRange(index, end))
index = end
}
return result
}
fun <T : List<ByteArray>> T.formatString(charset: Charset = Charsets.UTF_8): String {
val totalSize = this.sumOf { it.size }
val combined = ByteArray(totalSize)
var offset = 0
forEach { byteArray ->
byteArray.copyInto(combined, offset)
offset += byteArray.size
}
return String(combined, charset)
}

View File

@@ -1,47 +0,0 @@
package com.follow.clash.common
import android.app.Application
import android.util.Log
import com.google.firebase.FirebaseApp
import com.google.firebase.crashlytics.FirebaseCrashlytics
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
object GlobalState : CoroutineScope by CoroutineScope(Dispatchers.Default) {
const val NOTIFICATION_CHANNEL = "FlClash"
const val NOTIFICATION_ID = 1
val packageName: String
get() = application.packageName
val RECEIVE_BROADCASTS_PERMISSIONS: String
get() = "${packageName}.permission.RECEIVE_BROADCASTS"
private var _application: Application? = null
val application: Application
get() = _application!!
fun log(text: String) {
Log.d("[FlClash]", text)
}
fun init(application: Application) {
_application = application
}
fun setCrashlytics(enable: Boolean) {
_application?.let {
FirebaseApp.initializeApp(it)
FirebaseCrashlytics.getInstance().isCrashlyticsCollectionEnabled = enable
if (enable) {
log("init crashlytics ${it.processName}")
}
}
}
}

View File

@@ -1,75 +0,0 @@
package com.follow.clash.common
import android.content.Intent
import android.os.IBinder
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import java.util.concurrent.atomic.AtomicBoolean
class ServiceDelegate<T>(
private val intent: Intent,
private val onServiceDisconnected: ((String) -> Unit)? = null,
private val interfaceCreator: (IBinder) -> T,
) : CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
private val _bindingState = AtomicBoolean(false)
private var _serviceState = MutableStateFlow<Pair<T?, String>?>(null)
val serviceState: StateFlow<Pair<T?, String>?> = _serviceState
private var job: Job? = null
private fun handleBind(data: Pair<IBinder?, String>) {
data.first?.let {
_serviceState.value = Pair(interfaceCreator(it), data.second)
} ?: run {
_serviceState.value = Pair(null, data.second)
unbind()
onServiceDisconnected?.invoke(data.second)
_bindingState.set(false)
}
}
fun bind() {
if (_bindingState.compareAndSet(false, true)) {
job?.cancel()
job = null
_serviceState.value = null
job = launch {
runCatching {
GlobalState.application.bindServiceFlow<IBinder>(intent)
.collect { handleBind(it) }
}
}
}
}
suspend inline fun <R> useService(
timeoutMillis: Long = 5000, crossinline block: suspend (T) -> R
): Result<R> {
return runCatching {
withTimeout(timeoutMillis) {
val state = serviceState.filterNotNull().first()
state.first?.let {
block(it)
} ?: throw Exception(state.second)
}
}
}
fun unbind() {
if (_bindingState.compareAndSet(true, false)) {
job?.cancel()
job = null
_serviceState.value = null
}
}
}

View File

@@ -1,14 +0,0 @@
package com.follow.clash.common
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
fun tickerFlow(delayMillis: Long, initialDelayMillis: Long = delayMillis): Flow<Unit> = flow {
delay(initialDelayMillis)
while (true) {
emit(Unit)
delay(delayMillis)
}
}

View File

@@ -1 +0,0 @@
/build

View File

@@ -1,81 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
}
android {
namespace = "com.follow.clash.core"
compileSdk = libs.versions.compileSdk.get().toInt()
ndkVersion = libs.versions.ndkVersion.get()
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
}
sourceSets {
getByName("main") {
jniLibs.srcDirs("src/main/jniLibs")
}
}
externalNativeBuild {
cmake {
path("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(libs.annotation.jvm)
}
val copyNativeLibs by tasks.register<Copy>("copyNativeLibs") {
doFirst {
delete("src/main/jniLibs")
}
from("../../libclash/android")
into("src/main/jniLibs")
doLast {
val includesDir = file("src/main/jniLibs/includes")
val targetDir = file("src/main/cpp/includes")
if (includesDir.exists()) {
copy {
from(includesDir)
into(targetDir)
}
delete(includesDir)
}
}
}
afterEvaluate {
tasks.named("preBuild") {
dependsOn(copyNativeLibs)
}
}

View File

@@ -1,5 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
</manifest>

View File

@@ -1,51 +0,0 @@
cmake_minimum_required(VERSION 3.22.1)
project("core")
message("CMAKE_SOURCE_DIR ${CMAKE_SOURCE_DIR}")
message("CMAKE_BUILD_TYPE ${CMAKE_BUILD_TYPE}")
if (NOT "${CMAKE_BUILD_TYPE}" STREQUAL "Debug")
# set(CMAKE_BUILD_TYPE Release CACHE STRING "Build type" FORCE)
add_compile_options(-O3)
add_compile_options(-flto)
add_compile_options(-g0)
add_compile_options(-ffunction-sections -fdata-sections)
add_compile_options(-fno-exceptions -fno-rtti)
add_link_options(
-flto
-Wl,--gc-sections
-Wl,--strip-all
-Wl,--exclude-libs=ALL
)
add_compile_options(-fvisibility=hidden -fvisibility-inlines-hidden)
endif ()
set(LIB_CLASH_PATH "${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libclash.so")
message("LIB_CLASH_PATH ${LIB_CLASH_PATH}")
if (EXISTS ${LIB_CLASH_PATH})
message("Found libclash.so for ABI ${ANDROID_ABI}")
add_compile_definitions(LIBCLASH)
include_directories(${CMAKE_SOURCE_DIR}/../cpp/includes/${ANDROID_ABI})
link_directories(${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI})
add_library(${CMAKE_PROJECT_NAME} SHARED
jni_helper.cpp
core.cpp)
target_link_libraries(${CMAKE_PROJECT_NAME}
clash)
else ()
message("Not found libclash.so for ABI ${ANDROID_ABI}")
add_library(${CMAKE_PROJECT_NAME} SHARED
jni_helper.cpp
core.cpp)
target_link_libraries(${CMAKE_PROJECT_NAME})
endif ()

View File

@@ -1,194 +0,0 @@
#include <jni.h>
#ifdef LIBCLASH
#include "jni_helper.h"
#include "libclash.h"
#include "bride.h"
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
jstring stack, jstring address, jstring dns) {
const auto interface = new_global(cb);
startTUN(interface, fd, get_string(stack), get_string(address), get_string(dns));
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
stopTun();
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_forceGC(JNIEnv *env, jobject thiz) {
forceGC();
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns) {
updateDns(get_string(dns));
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
const auto interface = new_global(cb);
invokeAction(interface, get_string(data));
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_setEventListener(JNIEnv *env, jobject thiz, jobject cb) {
if (cb != nullptr) {
const auto interface = new_global(cb);
setEventListener(interface);
} else {
setEventListener(nullptr);
}
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return new_string(getTraffic(only_statistics_proxy));
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
return new_string(getTotalTraffic(only_statistics_proxy));
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
suspend(suspended);
}
static jmethodID m_tun_interface_protect;
static jmethodID m_tun_interface_resolve_process;
static jmethodID m_invoke_interface_result;
static void release_jni_object_impl(void *obj) {
ATTACH_JNI();
del_global(static_cast<jobject>(obj));
}
static void free_string_impl(char *str) {
free(str);
}
static void call_tun_interface_protect_impl(void *tun_interface, const int fd) {
ATTACH_JNI();
env->CallVoidMethod(static_cast<jobject>(tun_interface),
m_tun_interface_protect,
fd);
}
static char *
call_tun_interface_resolve_process_impl(void *tun_interface, const int protocol,
const char *source,
const char *target,
const int uid) {
ATTACH_JNI();
const auto packageName = reinterpret_cast<jstring>(env->CallObjectMethod(
static_cast<jobject>(tun_interface),
m_tun_interface_resolve_process,
protocol,
new_string(source),
new_string(target),
uid));
return get_string(packageName);
}
static void call_invoke_interface_result_impl(void *invoke_interface, const char *data) {
ATTACH_JNI();
env->CallVoidMethod(static_cast<jobject>(invoke_interface),
m_invoke_interface_result,
new_string(data));
}
extern "C"
JNIEXPORT jint JNICALL
JNI_OnLoad(JavaVM *vm, void *) {
JNIEnv *env = nullptr;
if (vm->GetEnv(reinterpret_cast<void **>(&env), JNI_VERSION_1_6) != JNI_OK) {
return JNI_ERR;
}
initialize_jni(vm, env);
const auto c_tun_interface = find_class("com/follow/clash/core/TunInterface");
const auto c_invoke_interface = find_class("com/follow/clash/core/InvokeInterface");
m_tun_interface_protect = find_method(c_tun_interface, "protect", "(I)V");
m_tun_interface_resolve_process = find_method(c_tun_interface, "resolverProcess",
"(ILjava/lang/String;Ljava/lang/String;I)Ljava/lang/String;");
m_invoke_interface_result = find_method(c_invoke_interface, "onResult",
"(Ljava/lang/String;)V");
protect_func = &call_tun_interface_protect_impl;
resolve_process_func = &call_tun_interface_resolve_process_impl;
result_func = &call_invoke_interface_result_impl;
release_object_func = &release_jni_object_impl;
free_string_func = &free_string_impl;
return JNI_VERSION_1_6;
}
#else
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_startTun(JNIEnv *env, jobject thiz, jint fd, jobject cb,
jstring stack, jstring address, jstring dns) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_stopTun(JNIEnv *env, jobject thiz) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_invokeAction(JNIEnv *env, jobject thiz, jstring data, jobject cb) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_forceGC(JNIEnv *env, jobject thiz) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_updateDNS(JNIEnv *env, jobject thiz, jstring dns) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_setEventListener(JNIEnv *env, jobject thiz, jobject cb) {
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
}
extern "C"
JNIEXPORT jstring JNICALL
Java_com_follow_clash_core_Core_getTotalTraffic(JNIEnv *env, jobject thiz,
const jboolean only_statistics_proxy) {
}
extern "C"
JNIEXPORT void JNICALL
Java_com_follow_clash_core_Core_suspended(JNIEnv *env, jobject thiz, jboolean suspended) {
}
#endif

View File

@@ -1,71 +0,0 @@
#include "jni_helper.h"
#include <cstdlib>
#include <malloc.h>
#include <cstring>
static JavaVM *global_vm;
static jclass c_string;
static jmethodID m_new_string;
static jmethodID m_get_bytes;
void initialize_jni(JavaVM *vm, JNIEnv *env) {
global_vm = vm;
c_string = reinterpret_cast<jclass>(new_global(find_class("java/lang/String")));
m_new_string = find_method(c_string, "<init>", "([B)V");
m_get_bytes = find_method(c_string, "getBytes", "()[B");
}
JavaVM *global_java_vm() {
return global_vm;
}
char *jni_get_string(JNIEnv *env, jstring str) {
const auto array = reinterpret_cast<jbyteArray>(env->CallObjectMethod(str, m_get_bytes));
const int length = env->GetArrayLength(array);
const auto content = static_cast<char *>(malloc(length + 1));
env->GetByteArrayRegion(array, 0, length, reinterpret_cast<jbyte *>(content));
content[length] = 0;
return content;
}
jstring jni_new_string(JNIEnv *env, const char *str) {
const auto length = static_cast<int>(strlen(str));
const auto array = env->NewByteArray(length);
env->SetByteArrayRegion(array, 0, length, reinterpret_cast<const jbyte *>(str));
return reinterpret_cast<jstring>(env->NewObject(c_string, m_new_string, array));
}
int jni_catch_exception(JNIEnv *env) {
const int result = env->ExceptionCheck();
if (result) {
env->ExceptionDescribe();
env->ExceptionClear();
}
return result;
}
void jni_attach_thread(scoped_jni *jni) {
JavaVM *vm = global_java_vm();
if (vm->GetEnv(reinterpret_cast<void **>(&jni->env), JNI_VERSION_1_6) == JNI_OK) {
jni->require_release = 0;
return;
}
if (vm->AttachCurrentThread(&jni->env, nullptr) != JNI_OK) {
abort();
}
jni->require_release = 1;
}
void jni_detach_thread(const scoped_jni *env) {
JavaVM *vm = global_java_vm();
if (env->require_release) {
vm->DetachCurrentThread();
}
}
void release_string(char **str) {
free(*str);
}

View File

@@ -1,36 +0,0 @@
#pragma once
#include <jni.h>
struct scoped_jni {
JNIEnv *env;
int require_release;
};
extern void initialize_jni(JavaVM *vm, JNIEnv *env);
extern jstring jni_new_string(JNIEnv *env, const char *str);
extern char *jni_get_string(JNIEnv *env, jstring str);
extern int jni_catch_exception(JNIEnv *env);
extern void jni_attach_thread(scoped_jni *jni);
extern void jni_detach_thread(const scoped_jni *env);
extern void release_string( char **str);
#define ATTACH_JNI() __attribute__((unused, cleanup(jni_detach_thread))) \
scoped_jni _jni{}; \
jni_attach_thread(&_jni); \
JNIEnv *env = _jni.env
#define scoped_string __attribute__((cleanup(release_string))) char*
#define find_class(name) env->FindClass(name)
#define find_method(cls, name, signature) env->GetMethodID(cls, name, signature)
#define new_global(obj) env->NewGlobalRef(obj)
#define del_global(obj) env->DeleteGlobalRef(obj)
#define get_string(jstr) jni_get_string(env, jstr)
#define new_string(cstr) jni_new_string(env, cstr)

View File

@@ -1,114 +0,0 @@
package com.follow.clash.core
import java.net.InetAddress
import java.net.InetSocketAddress
import java.net.URL
data object Core {
private external fun startTun(
fd: Int,
cb: TunInterface,
stack: String,
address: String,
dns: String,
)
external fun forceGC(
)
external fun updateDNS(
dns: String,
)
private fun parseInetSocketAddress(address: String): InetSocketAddress {
val url = URL("https://$address")
return InetSocketAddress(InetAddress.getByName(url.host), url.port)
}
fun startTun(
fd: Int,
protect: (Int) -> Boolean,
resolverProcess: (protocol: Int, source: InetSocketAddress, target: InetSocketAddress, uid: Int) -> String,
stack: String,
address: String,
dns: String,
) {
startTun(
fd,
object : TunInterface {
override fun protect(fd: Int) {
protect(fd)
}
override fun resolverProcess(
protocol: Int,
source: String,
target: String,
uid: Int
): String {
return resolverProcess(
protocol,
parseInetSocketAddress(source),
parseInetSocketAddress(target),
uid,
)
}
},
stack,
address,
dns
)
}
external fun suspended(
suspended: Boolean,
)
private external fun invokeAction(
data: String,
cb: InvokeInterface
)
fun invokeAction(
data: String,
cb: (result: String?) -> Unit
) {
invokeAction(
data,
object : InvokeInterface {
override fun onResult(result: String?) {
cb(result)
}
},
)
}
private external fun setEventListener(cb: InvokeInterface?)
fun callSetEventListener(
cb: ((result: String?) -> Unit)?
) {
when (cb != null) {
true -> setEventListener(
object : InvokeInterface {
override fun onResult(result: String?) {
cb(result)
}
},
)
false -> setEventListener(null)
}
}
external fun stopTun()
external fun getTraffic(onlyStatisticsProxy: Boolean): String
external fun getTotalTraffic(onlyStatisticsProxy: Boolean): String
init {
System.loadLibrary("core")
}
}

View File

@@ -1,8 +0,0 @@
package com.follow.clash.core
import androidx.annotation.Keep
@Keep
interface InvokeInterface {
fun onResult(result: String?)
}

View File

@@ -1,9 +0,0 @@
package com.follow.clash.core
import androidx.annotation.Keep
@Keep
interface TunInterface {
fun protect(fd: Int)
fun resolverProcess(protocol: Int, source: String, target: String, uid: Int): String
}

View File

@@ -1,3 +1,5 @@
org.gradle.jvmargs=-Xmx4G
android.useAndroidX=true
android.enableJetifier=true
kotlin_version=1.9.22
agp_version=8.2.1

View File

@@ -1,28 +0,0 @@
[versions]
#agp = "8.10.1"
firebaseBom = "34.2.0"
minSdk = "23"
targetSdk = "36"
compileSdk = "36"
ndkVersion = "28.0.13004108"
coreKtx = "1.17.0"
annotationJvm = "1.9.1"
coreSplashscreen = "1.0.1"
gson = "2.13.1"
kotlin = "2.2.10"
smaliDexlib2 = "3.0.9"
firebaseCrashlyticsKtx = "20.0.1"
firebaseCommonKtx = "22.0.0"
[libraries]
build-kotlin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" }
annotation-jvm = { module = "androidx.annotation:annotation-jvm", version.ref = "annotationJvm" }
core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
firebase-analytics = { module = "com.google.firebase:firebase-analytics" }
firebase-bom = { module = "com.google.firebase:firebase-bom", version.ref = "firebaseBom" }
firebase-crashlytics-ndk = { module = "com.google.firebase:firebase-crashlytics-ndk" }
gson = { module = "com.google.code.gson:gson", version.ref = "gson" }
smali-dexlib2 = { module = "com.android.tools.smali:smali-dexlib2", version.ref = "smaliDexlib2" }
firebase-crashlytics-ktx = { group = "com.google.firebase", name = "firebase-crashlytics-ktx", version.ref = "firebaseCrashlyticsKtx" }
firebase-common-ktx = { group = "com.google.firebase", name = "firebase-common-ktx", version.ref = "firebaseCommonKtx" }

View File

@@ -2,5 +2,5 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip

View File

@@ -1 +0,0 @@
/build

View File

@@ -1,48 +0,0 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
id("kotlin-parcelize")
}
android {
namespace = "com.follow.clash.service"
compileSdk = 36
defaultConfig {
minSdk = libs.versions.minSdk.get().toInt()
}
buildFeatures {
aidl = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
buildTypes {
release {
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(JvmTarget.JVM_17)
}
}
dependencies {
implementation(project(":core"))
implementation(project(":common"))
implementation(libs.gson)
implementation(libs.androidx.core)
}

View File

@@ -1,49 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<application>
<service
android:name=".VpnService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:permission="android.permission.BIND_VPN_SERVICE"
android:process=":remote">
<intent-filter>
<action android:name="android.net.VpnService" />
</intent-filter>
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="vpn" />
</service>
<service
android:name=".CommonService"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":remote">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="proxy" />
</service>
<service
android:name=".RemoteService"
android:enabled="true"
android:exported="false"
android:process=":remote" />
<provider
android:name=".FilesProvider"
android:authorities="${applicationId}.files"
android:exported="true"
android:grantUriPermissions="true"
android:permission="android.permission.MANAGE_DOCUMENTS"
android:process=":remote">
<intent-filter>
<action android:name="android.content.action.DOCUMENTS_PROVIDER" />
</intent-filter>
</provider>
</application>
</manifest>

View File

@@ -1,6 +0,0 @@
// ICallbackInterface.aidl
package com.follow.clash.service;
interface ICallbackInterface {
oneway void onResult(in byte[] data,in boolean isSuccess);
}

View File

@@ -1,6 +0,0 @@
// IEventInterface.aidl
package com.follow.clash.service;
interface IEventInterface {
oneway void onEvent(in String id, in byte[] data,in boolean isSuccess);
}

View File

@@ -1,18 +0,0 @@
// IRemoteInterface.aidl
package com.follow.clash.service;
import com.follow.clash.service.ICallbackInterface;
import com.follow.clash.service.IEventInterface;
import com.follow.clash.service.IResultInterface;
import com.follow.clash.service.models.VpnOptions;
import com.follow.clash.service.models.NotificationParams;
interface IRemoteInterface {
void invokeAction(in String data, in ICallbackInterface callback);
void updateNotificationParams(in NotificationParams params);
void startService(in VpnOptions options, in long runTime, in IResultInterface result);
void stopService(in IResultInterface result);
void setEventListener(in IEventInterface event);
void setCrashlytics(in boolean enable);
long getRunTime();
}

View File

@@ -1,6 +0,0 @@
// IResultInterface.aidl
package com.follow.clash.service;
interface IResultInterface {
oneway void onResult(in long runTime);
}

View File

@@ -1,4 +0,0 @@
//AccessControl.aidl
package com.follow.clash.service.models;
parcelable AccessControl;

View File

@@ -1,4 +0,0 @@
//NotificationParams.aidl
package com.follow.clash.service.models;
parcelable NotificationParams;

View File

@@ -1,6 +0,0 @@
//VpnOptions.aidl
package com.follow.clash.service.models;
import com.follow.clash.service.models.AccessControl;
parcelable VpnOptions;

View File

@@ -1,60 +0,0 @@
package com.follow.clash.service
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import com.follow.clash.core.Core
import com.follow.clash.service.modules.NetworkObserveModule
import com.follow.clash.service.modules.NotificationModule
import com.follow.clash.service.modules.SuspendModule
import com.follow.clash.service.modules.moduleLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
class CommonService : Service(), IBaseService,
CoroutineScope by CoroutineScope(Dispatchers.Default) {
private val self: CommonService
get() = this
private val loader = moduleLoader {
install(NetworkObserveModule(self))
install(NotificationModule(self))
install(SuspendModule(self))
}
override fun onCreate() {
super.onCreate()
handleCreate()
}
override fun onDestroy() {
handleDestroy()
super.onDestroy()
}
override fun onLowMemory() {
Core.forceGC()
super.onLowMemory()
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): CommonService = this@CommonService
}
override fun onBind(intent: Intent): IBinder {
return binder
}
override fun start() {
loader.load()
}
override fun stop() {
loader.cancel()
stopSelf()
}
}

View File

@@ -1,21 +0,0 @@
package com.follow.clash.service
import com.follow.clash.common.BroadcastAction
import com.follow.clash.common.GlobalState
import com.follow.clash.common.sendBroadcast
interface IBaseService {
fun handleCreate() {
GlobalState.log("Service create")
BroadcastAction.SERVICE_CREATED.sendBroadcast()
}
fun handleDestroy() {
GlobalState.log("Service destroy")
BroadcastAction.SERVICE_DESTROYED.sendBroadcast()
}
fun start()
fun stop()
}

View File

@@ -1,140 +0,0 @@
package com.follow.clash.service
import android.app.Service
import android.content.Intent
import android.os.IBinder
import com.follow.clash.common.GlobalState
import com.follow.clash.common.ServiceDelegate
import com.follow.clash.common.chunkedForAidl
import com.follow.clash.common.intent
import com.follow.clash.core.Core
import com.follow.clash.service.State.delegate
import com.follow.clash.service.State.intent
import com.follow.clash.service.State.runLock
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.withLock
import java.util.UUID
class RemoteService : Service(),
CoroutineScope by CoroutineScope(SupervisorJob() + Dispatchers.Default) {
private fun handleStopService(result: IResultInterface) {
launch {
runLock.withLock {
delegate?.useService { service ->
service.stop()
delegate?.unbind()
}
State.runTime = 0
result.onResult(0)
}
}
}
private fun handleServiceDisconnected(message: String) {
GlobalState.log("Background service disconnected: $message")
intent = null
delegate = null
}
private fun handleStartService(runTime: Long, result: IResultInterface) {
launch {
runLock.withLock {
val nextIntent = when (State.options?.enable == true) {
true -> VpnService::class.intent
false -> CommonService::class.intent
}
if (intent != nextIntent) {
delegate?.unbind()
delegate = ServiceDelegate(nextIntent, ::handleServiceDisconnected) { binder ->
when (binder) {
is VpnService.LocalBinder -> binder.getService()
is CommonService.LocalBinder -> binder.getService()
else -> throw IllegalArgumentException("Invalid binder type")
}
}
intent = nextIntent
delegate?.bind()
}
delegate?.useService { service ->
service.start()
}
State.runTime = when (runTime != 0L) {
true -> runTime
false -> System.currentTimeMillis()
}
result.onResult(State.runTime)
}
}
}
private val binder = object : IRemoteInterface.Stub() {
override fun invokeAction(data: String, callback: ICallbackInterface) {
Core.invokeAction(data) {
runCatching {
val chunks = it?.chunkedForAidl() ?: listOf()
val totalSize = chunks.size
chunks.forEachIndexed { index, chunk ->
callback.onResult(chunk, totalSize - 1 == index)
}
}
}
}
override fun updateNotificationParams(params: NotificationParams?) {
State.notificationParamsFlow.tryEmit(params)
}
override fun startService(
options: VpnOptions,
runtime: Long,
result: IResultInterface,
) {
State.options = options
handleStartService(runtime, result)
}
override fun stopService(result: IResultInterface) {
handleStopService(result)
}
override fun setEventListener(eventListener: IEventInterface?) {
GlobalState.log("RemoveEventListener ${eventListener == null}")
when (eventListener != null) {
true -> Core.callSetEventListener {
runCatching {
val id = UUID.randomUUID().toString()
val chunks = it?.chunkedForAidl() ?: listOf()
val totalSize = chunks.size
chunks.forEachIndexed { index, chunk ->
eventListener.onEvent(id, chunk, totalSize - 1 == index)
}
}
}
false -> Core.callSetEventListener(null)
}
}
override fun setCrashlytics(enable: Boolean) {
GlobalState.setCrashlytics(enable)
}
override fun getRunTime(): Long {
return State.runTime
}
}
override fun onBind(intent: Intent?): IBinder {
return binder
}
override fun onDestroy() {
GlobalState.log("Remote service destroy")
super.onDestroy()
}
}

View File

@@ -1,22 +0,0 @@
package com.follow.clash.service
import android.content.Intent
import com.follow.clash.common.ServiceDelegate
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.VpnOptions
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Mutex
object State {
var options: VpnOptions? = null
var notificationParamsFlow: MutableStateFlow<NotificationParams?> = MutableStateFlow(
NotificationParams()
)
val runLock = Mutex()
var runTime: Long = 0L
var delegate: ServiceDelegate<IBaseService>? = null
var intent: Intent? = null
}

View File

@@ -1,256 +0,0 @@
package com.follow.clash.service
import android.content.Intent
import android.net.ConnectivityManager
import android.net.ProxyInfo
import android.os.Binder
import android.os.Build
import android.os.IBinder
import android.os.Parcel
import android.os.RemoteException
import android.util.Log
import androidx.core.content.getSystemService
import com.follow.clash.common.AccessControlMode
import com.follow.clash.common.GlobalState
import com.follow.clash.core.Core
import com.follow.clash.service.models.VpnOptions
import com.follow.clash.service.models.getIpv4RouteAddress
import com.follow.clash.service.models.getIpv6RouteAddress
import com.follow.clash.service.models.toCIDR
import com.follow.clash.service.modules.NetworkObserveModule
import com.follow.clash.service.modules.NotificationModule
import com.follow.clash.service.modules.SuspendModule
import com.follow.clash.service.modules.moduleLoader
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import java.net.InetSocketAddress
import android.net.VpnService as SystemVpnService
class VpnService : SystemVpnService(), IBaseService,
CoroutineScope by CoroutineScope(Dispatchers.Default) {
private val self: VpnService
get() = this
private val loader = moduleLoader {
install(NetworkObserveModule(self))
install(NotificationModule(self))
install(SuspendModule(self))
}
override fun onCreate() {
super.onCreate()
handleCreate()
}
override fun onDestroy() {
handleDestroy()
super.onDestroy()
}
private val connectivity by lazy {
getSystemService<ConnectivityManager>()
}
private val uidPageNameMap = mutableMapOf<Int, String>()
private fun resolverProcess(
protocol: Int,
source: InetSocketAddress,
target: InetSocketAddress,
uid: Int,
): String {
val nextUid = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
connectivity?.getConnectionOwnerUid(protocol, source, target) ?: -1
} else {
uid
}
if (nextUid == -1) {
return ""
}
if (!uidPageNameMap.containsKey(nextUid)) {
uidPageNameMap[nextUid] = this.packageManager?.getPackagesForUid(nextUid)?.first() ?: ""
}
return uidPageNameMap[nextUid] ?: ""
}
val VpnOptions.address
get(): String = buildString {
append(IPV4_ADDRESS)
if (ipv6) {
append(",")
append(IPV6_ADDRESS)
}
}
val VpnOptions.dns
get(): String {
if (dnsHijacking) {
return NET_ANY
}
return buildString {
append(DNS)
if (ipv6) {
append(",")
append(DNS6)
}
}
}
override fun onLowMemory() {
Core.forceGC()
super.onLowMemory()
}
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun getService(): VpnService = this@VpnService
override fun onTransact(code: Int, data: Parcel, reply: Parcel?, flags: Int): Boolean {
try {
val isSuccess = super.onTransact(code, data, reply, flags)
if (!isSuccess) {
GlobalState.log("VpnService disconnected")
handleDestroy()
}
return isSuccess
} catch (e: RemoteException) {
GlobalState.log("VpnService onTransact $e")
return false
}
}
}
override fun onBind(intent: Intent): IBinder {
return binder
}
private fun handleStart(options: VpnOptions) {
val fd = with(Builder()) {
val cidr = IPV4_ADDRESS.toCIDR()
addAddress(cidr.address, cidr.prefixLength)
Log.d(
"addAddress", "address: ${cidr.address} prefixLength:${cidr.prefixLength}"
)
val routeAddress = options.getIpv4RouteAddress()
if (routeAddress.isNotEmpty()) {
try {
routeAddress.forEach { i ->
Log.d(
"addRoute4", "address: ${i.address} prefixLength:${i.prefixLength}"
)
addRoute(i.address, i.prefixLength)
}
} catch (_: Exception) {
addRoute(NET_ANY, 0)
}
} else {
addRoute(NET_ANY, 0)
}
if (options.ipv6) {
try {
val cidr = IPV6_ADDRESS.toCIDR()
Log.d(
"addAddress6", "address: ${cidr.address} prefixLength:${cidr.prefixLength}"
)
addAddress(cidr.address, cidr.prefixLength)
} catch (_: Exception) {
Log.d(
"addAddress6", "IPv6 is not supported."
)
}
try {
val routeAddress = options.getIpv6RouteAddress()
if (routeAddress.isNotEmpty()) {
try {
routeAddress.forEach { i ->
Log.d(
"addRoute6",
"address: ${i.address} prefixLength:${i.prefixLength}"
)
addRoute(i.address, i.prefixLength)
}
} catch (_: Exception) {
addRoute("::", 0)
}
} else {
addRoute(NET_ANY6, 0)
}
} catch (_: Exception) {
addRoute(NET_ANY6, 0)
}
}
addDnsServer(DNS)
if (options.ipv6) {
addDnsServer(DNS6)
}
setMtu(9000)
options.accessControl.let { accessControl ->
if (accessControl.enable) {
when (accessControl.mode) {
AccessControlMode.ACCEPT_SELECTED -> {
(accessControl.acceptList + packageName).forEach {
addAllowedApplication(it)
}
}
AccessControlMode.REJECT_SELECTED -> {
(accessControl.rejectList - packageName).forEach {
addDisallowedApplication(it)
}
}
}
}
}
setSession("FlClash")
setBlocking(false)
if (Build.VERSION.SDK_INT >= 29) {
setMetered(false)
}
if (options.allowBypass) {
allowBypass()
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && options.systemProxy) {
setHttpProxy(
ProxyInfo.buildDirectProxy(
"127.0.0.1", options.port, options.bypassDomain
)
)
}
establish()?.detachFd()
?: throw NullPointerException("Establish VPN rejected by system")
}
Core.startTun(
fd,
protect = this::protect,
resolverProcess = this::resolverProcess,
options.stack,
options.address,
options.dns
)
}
override fun start() {
loader.load()
State.options?.let {
handleStart(it)
}
}
override fun stop() {
loader.cancel()
Core.stopTun()
stopSelf()
}
companion object {
private const val IPV4_ADDRESS = "172.19.0.1/30"
private const val IPV6_ADDRESS = "fdfe:dcba:9876::1/126"
private const val DNS = "172.19.0.2"
private const val DNS6 = "fdfe:dcba:9876::2"
private const val NET_ANY = "0.0.0.0"
private const val NET_ANY6 = "::"
}
}

View File

@@ -1,11 +0,0 @@
package com.follow.clash.service.models
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class NotificationParams(
val title: String = "FlClash",
val stopText: String = "STOP",
val onlyStatisticsProxy: Boolean = false,
) : Parcelable

View File

@@ -1,23 +0,0 @@
package com.follow.clash.service.models
import com.follow.clash.common.formatBytes
import com.follow.clash.core.Core
import com.google.gson.Gson
data class Traffic(
val up: Long,
val down: Long,
)
val Traffic.speedText: String
get() = "${up.formatBytes}/s↑ ${down.formatBytes}/s↓"
fun Core.getSpeedTrafficText(onlyStatisticsProxy: Boolean): String {
try {
val res = getTraffic(onlyStatisticsProxy)
val traffic = Gson().fromJson(res, Traffic::class.java)
return traffic.speedText
} catch (_: Exception) {
return ""
}
}

View File

@@ -1,83 +0,0 @@
package com.follow.clash.service.models
import android.os.Parcelable
import com.follow.clash.common.AccessControlMode
import kotlinx.parcelize.Parcelize
import java.net.InetAddress
@Parcelize
data class AccessControl(
val enable: Boolean,
val mode: AccessControlMode,
val acceptList: List<String>,
val rejectList: List<String>,
) : Parcelable
@Parcelize
data class VpnOptions(
val enable: Boolean,
val port: Int,
val ipv6: Boolean,
val dnsHijacking: Boolean,
val accessControl: AccessControl,
val allowBypass: Boolean,
val systemProxy: Boolean,
val bypassDomain: List<String>,
val stack: String,
val routeAddress: List<String>,
) : Parcelable
data class CIDR(val address: InetAddress, val prefixLength: Int)
fun VpnOptions.getIpv4RouteAddress(): List<CIDR> {
return routeAddress.filter {
it.isIpv4()
}.map {
it.toCIDR()
}
}
fun VpnOptions.getIpv6RouteAddress(): List<CIDR> {
return routeAddress.filter {
it.isIpv6()
}.map {
it.toCIDR()
}
}
fun String.isIpv4(): Boolean {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
val address = InetAddress.getByName(parts[0])
return address.address.size == 4
}
fun String.isIpv6(): Boolean {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
val address = InetAddress.getByName(parts[0])
return address.address.size == 16
}
fun String.toCIDR(): CIDR {
val parts = split("/")
if (parts.size != 2) {
throw IllegalArgumentException("Invalid CIDR format")
}
val ipAddress = parts[0]
val prefixLength =
parts[1].toIntOrNull() ?: throw IllegalArgumentException("Invalid prefix length")
val address = InetAddress.getByName(ipAddress)
val maxPrefix = if (address.address.size == 4) 32 else 128
if (prefixLength < 0 || prefixLength > maxPrefix) {
throw IllegalArgumentException("Invalid prefix length for IP version")
}
return CIDR(address, prefixLength)
}

View File

@@ -1,19 +0,0 @@
package com.follow.clash.service.modules
abstract class Module {
private var isInstall: Boolean = false
protected abstract fun onInstall()
protected abstract fun onUninstall()
fun install() {
isInstall = true
onInstall()
}
fun uninstall() {
onUninstall()
isInstall = false
}
}

View File

@@ -1,51 +0,0 @@
package com.follow.clash.service.modules
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
interface ModuleLoaderScope {
fun <T : Module> install(module: T): T
}
interface ModuleLoader {
fun load()
fun cancel()
}
private val mutex = Mutex()
fun CoroutineScope.moduleLoader(block: suspend ModuleLoaderScope.() -> Unit): ModuleLoader {
val modules = mutableListOf<Module>()
var job: Job? = null
return object : ModuleLoader {
override fun load() {
job = launch(Dispatchers.IO) {
mutex.withLock {
val scope = object : ModuleLoaderScope {
override fun <T : Module> install(module: T): T {
modules.add(module)
module.install()
return module
}
}
scope.block()
}
}
}
override fun cancel() {
launch(Dispatchers.IO) {
job?.cancel()
mutex.withLock {
modules.asReversed().forEach { it.uninstall() }
modules.clear()
}
}
}
}
}

View File

@@ -1,149 +0,0 @@
package com.follow.clash.service.modules
import android.app.Service
import android.net.ConnectivityManager
import android.net.LinkProperties
import android.net.Network
import android.net.NetworkCapabilities
import android.net.NetworkCapabilities.TRANSPORT_SATELLITE
import android.net.NetworkCapabilities.TRANSPORT_USB
import android.net.NetworkRequest
import android.os.Build
import androidx.core.content.getSystemService
import com.follow.clash.core.Core
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.util.concurrent.ConcurrentHashMap
private data class NetworkInfo(
@Volatile var losingMs: Long = 0, @Volatile var dnsList: List<InetAddress> = emptyList()
) {
fun isAvailable(): Boolean = losingMs < System.currentTimeMillis()
}
class NetworkObserveModule(private val service: Service) : Module() {
private val networkInfos = ConcurrentHashMap<Network, NetworkInfo>()
private val connectivity by lazy {
service.getSystemService<ConnectivityManager>()
}
private var preDnsList = listOf<String>()
private val request = NetworkRequest.Builder().apply {
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
addCapability(NetworkCapabilities.NET_CAPABILITY_FOREGROUND)
}
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
}.build()
private val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
networkInfos[network] = NetworkInfo()
onUpdateNetwork()
super.onAvailable(network)
}
override fun onLosing(network: Network, maxMsToLive: Int) {
networkInfos[network]?.losingMs = System.currentTimeMillis() + maxMsToLive
onUpdateNetwork()
setUnderlyingNetworks(network)
super.onLosing(network, maxMsToLive)
}
override fun onLost(network: Network) {
networkInfos.remove(network)
onUpdateNetwork()
setUnderlyingNetworks(network)
super.onLost(network)
}
override fun onLinkPropertiesChanged(network: Network, linkProperties: LinkProperties) {
networkInfos[network]?.dnsList = linkProperties.dnsServers
onUpdateNetwork()
setUnderlyingNetworks(network)
super.onLinkPropertiesChanged(network, linkProperties)
}
}
override fun onInstall() {
onUpdateNetwork()
connectivity?.registerNetworkCallback(request, callback)
}
private fun networkToInt(entry: Map.Entry<Network, NetworkInfo>): Int {
val capabilities = connectivity?.getNetworkCapabilities(entry.key)
return when {
capabilities == null -> 100
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN) -> 90
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> 0
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET) -> 1
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && capabilities.hasTransport(
TRANSPORT_USB
) -> 2
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_BLUETOOTH) -> 3
capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> 4
Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM && capabilities.hasTransport(
TRANSPORT_SATELLITE
) -> 5
else -> 20
} + (if (entry.value.isAvailable()) 0 else 10)
}
fun onUpdateNetwork() {
val dnsList = (networkInfos.asSequence().minByOrNull { networkToInt(it) }?.value?.dnsList
?: emptyList()).map { x -> x.asSocketAddressText(53) }
if (dnsList == preDnsList) {
return
}
preDnsList = dnsList
Core.updateDNS(dnsList.toSet().joinToString(","))
}
fun setUnderlyingNetworks(network: Network) {
// if (service is VpnService && Build.VERSION.SDK_INT in 22..28) {
// service.setUnderlyingNetworks(arrayOf(network))
// }
}
override fun onUninstall() {
connectivity?.unregisterNetworkCallback(callback)
networkInfos.clear()
onUpdateNetwork()
}
}
fun InetAddress.asSocketAddressText(port: Int): String {
return when (this) {
is Inet6Address -> "[${numericToTextFormat(this)}]:$port"
is Inet4Address -> "${this.hostAddress}:$port"
else -> throw IllegalArgumentException("Unsupported Inet type ${this.javaClass}")
}
}
private fun numericToTextFormat(address: Inet6Address): String {
val src = address.address
val sb = StringBuilder(39)
for (i in 0 until 8) {
sb.append(
Integer.toHexString(
src[i shl 1].toInt() shl 8 and 0xff00 or (src[(i shl 1) + 1].toInt() and 0xff)
)
)
if (i < 7) {
sb.append(":")
}
}
if (address.scopeId > 0) {
sb.append("%")
sb.append(address.scopeId)
}
return sb.toString()
}

View File

@@ -1,124 +0,0 @@
package com.follow.clash.service.modules
import android.app.Notification.FOREGROUND_SERVICE_IMMEDIATE
import android.app.Service
import android.app.Service.STOP_FOREGROUND_REMOVE
import android.content.Intent
import android.os.Build
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.content.getSystemService
import com.follow.clash.common.Components
import com.follow.clash.common.GlobalState
import com.follow.clash.common.QuickAction
import com.follow.clash.common.quickIntent
import com.follow.clash.common.receiveBroadcastFlow
import com.follow.clash.common.startForeground
import com.follow.clash.common.tickerFlow
import com.follow.clash.common.toPendingIntent
import com.follow.clash.core.Core
import com.follow.clash.service.R
import com.follow.clash.service.State
import com.follow.clash.service.models.NotificationParams
import com.follow.clash.service.models.getSpeedTrafficText
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
data class ExtendedNotificationParams(
val title: String,
val stopText: String,
val onlyStatisticsProxy: Boolean,
val contentText: String,
)
val NotificationParams.extended: ExtendedNotificationParams
get() = ExtendedNotificationParams(
title, stopText, onlyStatisticsProxy, Core.getSpeedTrafficText(onlyStatisticsProxy)
)
class NotificationModule(private val service: Service) : Module() {
private val scope = CoroutineScope(Dispatchers.Default)
override fun onInstall() {
State.notificationParamsFlow.value?.let {
update(it.extended)
}
scope.launch {
val screenFlow = service.receiveBroadcastFlow {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}.map { intent ->
intent.action == Intent.ACTION_SCREEN_ON
}.onStart {
emit(isScreenOn())
}
combine(
tickerFlow(1000, 0), State.notificationParamsFlow, screenFlow
) { _, params, screenOn ->
params?.extended to screenOn
}.filter { (params, screenOn) -> params != null && screenOn }
.distinctUntilChanged { old, new -> old.first == new.first && old.second == new.second }
.collect { (params, _) ->
update(params!!)
}
}
}
private fun isScreenOn(): Boolean {
val pm = service.getSystemService<PowerManager>()
return when (pm != null) {
true -> pm.isInteractive
false -> true
}
}
private val notificationBuilder: NotificationCompat.Builder by lazy {
val intent = Intent().setComponent(Components.MAIN_ACTIVITY)
with(
NotificationCompat.Builder(
service, GlobalState.NOTIFICATION_CHANNEL
)
) {
setSmallIcon(R.drawable.ic)
setContentTitle("FlClash")
setContentIntent(intent.toPendingIntent)
setPriority(NotificationCompat.PRIORITY_HIGH)
setCategory(NotificationCompat.CATEGORY_SERVICE)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
foregroundServiceBehavior = FOREGROUND_SERVICE_IMMEDIATE
}
setOngoing(true)
setShowWhen(true)
setOnlyAlertOnce(true)
}
}
private fun update(params: ExtendedNotificationParams) {
service.startForeground(
with(notificationBuilder) {
setContentTitle(params.title)
setContentText(params.contentText)
clearActions()
addAction(
0, params.stopText, QuickAction.STOP.quickIntent.toPendingIntent
).build()
})
}
override fun onUninstall() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
service.stopForeground(STOP_FOREGROUND_REMOVE)
} else {
service.stopForeground(true)
}
scope.cancel()
}
}

View File

@@ -1,61 +0,0 @@
package com.follow.clash.service.modules
import android.app.Service
import android.content.Intent
import android.os.PowerManager
import androidx.core.content.getSystemService
import com.follow.clash.common.receiveBroadcastFlow
import com.follow.clash.core.Core
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.launch
class SuspendModule(private val service: Service) : Module() {
private val scope = CoroutineScope(Dispatchers.Default)
private fun isScreenOn(): Boolean {
val pm = service.getSystemService<PowerManager>()
return when (pm != null) {
true -> pm.isInteractive
false -> true
}
}
val isDeviceIdleMode: Boolean
get() {
return service.getSystemService<PowerManager>()?.isDeviceIdleMode ?: true
}
private fun onUpdate(isScreenOn: Boolean) {
if (isScreenOn) {
Core.suspended(false)
return
}
Core.suspended(isDeviceIdleMode)
}
override fun onInstall() {
scope.launch {
val screenFlow = service.receiveBroadcastFlow {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}.map { intent ->
intent.action == Intent.ACTION_SCREEN_ON
}.onStart {
emit(isScreenOn())
}
screenFlow.collect {
onUpdate(it)
}
}
}
override fun onUninstall() {
scope.cancel()
}
}

View File

@@ -1,17 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="240dp"
android:height="240dp"
android:viewportWidth="240"
android:viewportHeight="240"
tools:ignore="VectorRaster">
<path
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
android:fillColor="#FFFFFF"/>
<path
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:fillColor="#FFFFFF"/>
</vector>

View File

@@ -1,17 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:width="240dp"
android:height="240dp"
android:viewportWidth="240"
android:viewportHeight="240"
tools:ignore="VectorRaster">
<path
android:pathData="M48.1,80.89L168.44,11.41c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0l-120.34,69.48c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:fillColor="#6666FB"/>
<path
android:pathData="M78.98,134.37l60.18,-34.74c11.07,-6.39 25.23,-2.59 31.63,8.48h0c6.4,11.07 2.61,25.24 -8.47,31.64l-60.18,34.74c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64h0Z"
android:fillColor="#336AB6"/>
<path
android:pathData="M109.86,187.86h0c11.08,-6.4 25.24,-2.6 31.64,8.48 0,0 0,0 0,0h0c6.4,11.08 2.6,25.24 -8.48,31.64 0,0 0,0 0,0h0c-11.08,6.4 -25.24,2.6 -31.64,-8.48 0,0 0,0 0,0h0c-6.4,-11.08 -2.6,-25.24 8.48,-31.64 0,0 0,0 0,0Z"
android:fillColor="#5CA8E9"/>
</vector>

26
android/settings.gradle Normal file
View File

@@ -0,0 +1,26 @@
pluginManagement {
def flutterSdkPath = {
def properties = new Properties()
file("local.properties").withInputStream { properties.load(it) }
def flutterSdkPath = properties.getProperty("flutter.sdk")
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
return flutterSdkPath
}
settings.ext.flutterSdkPath = flutterSdkPath()
includeBuild("${settings.ext.flutterSdkPath}/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "$agp_version" apply false
id "org.jetbrains.kotlin.android" version "$kotlin_version" apply false
}
include ":app"

View File

@@ -1,31 +0,0 @@
pluginManagement {
val flutterSdkPath = run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "8.12.2" apply false
id("org.jetbrains.kotlin.android") version "2.2.10" apply false
id("com.google.gms.google-services") version ("4.3.15") apply false
id("com.google.firebase.crashlytics") version ("2.8.1") apply false
}
include(":app")
include(":core")
include(":service")
include(":common")

Some files were not shown because too many files have changed in this diff Show More