Repository: SaeedDev94/Xray Branch: master Commit: 69bf223e8c24 Files: 221 Total size: 320.1 KB Directory structure: gitextract_0tifmtqk/ ├── .github/ │ └── workflows/ │ └── release.yml ├── .gitignore ├── .gitmodules ├── Dockerfile ├── LICENSE ├── README.md ├── XrayCore/ │ ├── go.mod │ ├── go.sum │ ├── lib/ │ │ ├── core.go │ │ ├── env.go │ │ ├── error.go │ │ └── test.go │ └── main.go ├── app/ │ ├── .gitignore │ ├── build.gradle.kts │ ├── libs/ │ │ └── .gitignore │ ├── src/ │ │ └── main/ │ │ ├── AndroidManifest.xml │ │ ├── assets/ │ │ │ └── .gitignore │ │ ├── java/ │ │ │ └── io/ │ │ │ └── github/ │ │ │ └── saeeddev94/ │ │ │ └── xray/ │ │ │ ├── Settings.kt │ │ │ ├── Xray.kt │ │ │ ├── activity/ │ │ │ │ ├── AppsRoutingActivity.kt │ │ │ │ ├── AssetsActivity.kt │ │ │ │ ├── ConfigsActivity.kt │ │ │ │ ├── LinksActivity.kt │ │ │ │ ├── LinksManagerActivity.kt │ │ │ │ ├── LogsActivity.kt │ │ │ │ ├── MainActivity.kt │ │ │ │ ├── ProfileActivity.kt │ │ │ │ ├── ScannerActivity.kt │ │ │ │ └── SettingsActivity.kt │ │ │ ├── adapter/ │ │ │ │ ├── AppsRoutingAdapter.kt │ │ │ │ ├── ConfigAdapter.kt │ │ │ │ ├── LinkAdapter.kt │ │ │ │ ├── ProfileAdapter.kt │ │ │ │ └── SettingAdapter.kt │ │ │ ├── component/ │ │ │ │ └── EmptySubmitSearchView.kt │ │ │ ├── database/ │ │ │ │ ├── Config.kt │ │ │ │ ├── ConfigDao.kt │ │ │ │ ├── Link.kt │ │ │ │ ├── LinkDao.kt │ │ │ │ ├── Profile.kt │ │ │ │ ├── ProfileDao.kt │ │ │ │ └── XrayDatabase.kt │ │ │ ├── dto/ │ │ │ │ ├── AppList.kt │ │ │ │ ├── ProfileList.kt │ │ │ │ └── XrayConfig.kt │ │ │ ├── fragment/ │ │ │ │ └── LinkFormFragment.kt │ │ │ ├── helper/ │ │ │ │ ├── ConfigHelper.kt │ │ │ │ ├── DownloadHelper.kt │ │ │ │ ├── FileHelper.kt │ │ │ │ ├── HttpHelper.kt │ │ │ │ ├── IntentHelper.kt │ │ │ │ ├── JsonHelper.kt │ │ │ │ ├── LinkHelper.kt │ │ │ │ ├── NetworkStateHelper.kt │ │ │ │ ├── ProfileTouchHelper.kt │ │ │ │ └── TransparentProxyHelper.kt │ │ │ ├── receiver/ │ │ │ │ ├── BootReceiver.kt │ │ │ │ └── VpnActionReceiver.kt │ │ │ ├── repository/ │ │ │ │ ├── ConfigRepository.kt │ │ │ │ ├── LinkRepository.kt │ │ │ │ └── ProfileRepository.kt │ │ │ ├── service/ │ │ │ │ ├── TProxyService.kt │ │ │ │ └── VpnTileService.kt │ │ │ └── viewmodel/ │ │ │ ├── ConfigViewModel.kt │ │ │ ├── LinkViewModel.kt │ │ │ └── ProfileViewModel.kt │ │ ├── jni/ │ │ │ ├── Android.mk │ │ │ └── Application.mk │ │ └── res/ │ │ ├── drawable/ │ │ │ ├── baseline_adb.xml │ │ │ ├── baseline_add.xml │ │ │ ├── baseline_alt_route.xml │ │ │ ├── baseline_config.xml │ │ │ ├── baseline_content_copy.xml │ │ │ ├── baseline_delete.xml │ │ │ ├── baseline_done.xml │ │ │ ├── baseline_download.xml │ │ │ ├── baseline_edit.xml │ │ │ ├── baseline_file_open.xml │ │ │ ├── baseline_folder_open.xml │ │ │ ├── baseline_link.xml │ │ │ ├── baseline_refresh.xml │ │ │ ├── baseline_settings.xml │ │ │ ├── baseline_vpn_key.xml │ │ │ ├── baseline_vpn_lock.xml │ │ │ └── ic_xray.xml │ │ ├── drawable-v26/ │ │ │ └── ic_launcher.xml │ │ ├── layout/ │ │ │ ├── activity_apps_routing.xml │ │ │ ├── activity_assets.xml │ │ │ ├── activity_configs.xml │ │ │ ├── activity_links.xml │ │ │ ├── activity_logs.xml │ │ │ ├── activity_main.xml │ │ │ ├── activity_profile.xml │ │ │ ├── activity_scanner.xml │ │ │ ├── activity_settings.xml │ │ │ ├── item_recycler_exclude.xml │ │ │ ├── item_recycler_main.xml │ │ │ ├── layout_link_form.xml │ │ │ ├── layout_link_item.xml │ │ │ ├── layout_tun_routes.xml │ │ │ ├── loading_dialog.xml │ │ │ ├── tab_advanced_settings.xml │ │ │ ├── tab_basic_settings.xml │ │ │ └── tab_config.xml │ │ ├── menu/ │ │ │ ├── menu_apps_routing.xml │ │ │ ├── menu_configs.xml │ │ │ ├── menu_drawer.xml │ │ │ ├── menu_links.xml │ │ │ ├── menu_logs.xml │ │ │ ├── menu_main.xml │ │ │ ├── menu_profile.xml │ │ │ └── menu_settings.xml │ │ ├── values/ │ │ │ ├── array.xml │ │ │ ├── colors.xml │ │ │ ├── strings.xml │ │ │ └── themes.xml │ │ └── xml/ │ │ └── network_security_config.xml │ └── versionCode.txt ├── build-xray.sh ├── build.gradle.kts ├── buildGo.sh ├── buildXrayCore.sh ├── buildXrayHelper.sh ├── gradle/ │ ├── libs.versions.toml │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat ├── metadata/ │ └── en-US/ │ ├── changelogs/ │ │ ├── 1004.txt │ │ ├── 1014.txt │ │ ├── 1024.txt │ │ ├── 1034.txt │ │ ├── 104.txt │ │ ├── 1044.txt │ │ ├── 1054.txt │ │ ├── 1064.txt │ │ ├── 114.txt │ │ ├── 124.txt │ │ ├── 134.txt │ │ ├── 144.txt │ │ ├── 154.txt │ │ ├── 164.txt │ │ ├── 174.txt │ │ ├── 184.txt │ │ ├── 194.txt │ │ ├── 204.txt │ │ ├── 214.txt │ │ ├── 224.txt │ │ ├── 234.txt │ │ ├── 244.txt │ │ ├── 254.txt │ │ ├── 264.txt │ │ ├── 274.txt │ │ ├── 284.txt │ │ ├── 294.txt │ │ ├── 304.txt │ │ ├── 314.txt │ │ ├── 324.txt │ │ ├── 334.txt │ │ ├── 344.txt │ │ ├── 354.txt │ │ ├── 364.txt │ │ ├── 374.txt │ │ ├── 384.txt │ │ ├── 394.txt │ │ ├── 404.txt │ │ ├── 414.txt │ │ ├── 424.txt │ │ ├── 504.txt │ │ ├── 514.txt │ │ ├── 524.txt │ │ ├── 534.txt │ │ ├── 54.txt │ │ ├── 544.txt │ │ ├── 604.txt │ │ ├── 614.txt │ │ ├── 624.txt │ │ ├── 634.txt │ │ ├── 64.txt │ │ ├── 644.txt │ │ ├── 654.txt │ │ ├── 664.txt │ │ ├── 674.txt │ │ ├── 684.txt │ │ ├── 694.txt │ │ ├── 704.txt │ │ ├── 714.txt │ │ ├── 724.txt │ │ ├── 734.txt │ │ ├── 74.txt │ │ ├── 744.txt │ │ ├── 754.txt │ │ ├── 764.txt │ │ ├── 774.txt │ │ ├── 784.txt │ │ ├── 794.txt │ │ ├── 804.txt │ │ ├── 814.txt │ │ ├── 824.txt │ │ ├── 834.txt │ │ ├── 844.txt │ │ ├── 854.txt │ │ ├── 864.txt │ │ ├── 874.txt │ │ ├── 884.txt │ │ ├── 894.txt │ │ ├── 904.txt │ │ ├── 914.txt │ │ ├── 924.txt │ │ ├── 934.txt │ │ ├── 944.txt │ │ ├── 954.txt │ │ ├── 964.txt │ │ ├── 974.txt │ │ ├── 984.txt │ │ └── 994.txt │ ├── full_description.txt │ └── short_description.txt └── settings.gradle.kts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/release.yml ================================================ name: Release CI on: push: tags: - '*' jobs: build-arm32: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Build arm32 docker run: docker build -t arm32 -f Dockerfile . - name: Compile arm32 build run: | docker run --rm \ -v /opt/dist:/opt/dist \ -e DIST_DIR='/opt/dist' \ -e RELEASE_TAG="$GITHUB_REF_NAME" \ -e NATIVE_ARCH="arm" \ -e ABI_ID=1 \ -e ABI_TARGET="armeabi-v7a" \ -e KS_FILE="${{ secrets.KS_FILE }}" \ -e KS_PASSWORD="${{ secrets.KS_PASSWORD }}" \ -e KEY_ALIAS="${{ secrets.KEY_ALIAS }}" \ -e KEY_PASSWORD="${{ secrets.KEY_PASSWORD }}" \ arm32 - name: Upload arm32 artifact uses: actions/upload-artifact@v4 with: name: arm32-build path: /opt/dist build-arm64: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Build arm64 docker run: docker build -t arm64 -f Dockerfile . - name: Compile arm64 build run: | docker run --rm \ -v /opt/dist:/opt/dist \ -e DIST_DIR='/opt/dist' \ -e RELEASE_TAG="$GITHUB_REF_NAME" \ -e NATIVE_ARCH="arm64" \ -e ABI_ID=2 \ -e ABI_TARGET="arm64-v8a" \ -e KS_FILE="${{ secrets.KS_FILE }}" \ -e KS_PASSWORD="${{ secrets.KS_PASSWORD }}" \ -e KEY_ALIAS="${{ secrets.KEY_ALIAS }}" \ -e KEY_PASSWORD="${{ secrets.KEY_PASSWORD }}" \ arm64 - name: Upload arm64 artifact uses: actions/upload-artifact@v4 with: name: arm64-build path: /opt/dist build-x86: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Build x86 docker run: docker build -t x86 -f Dockerfile . - name: Compile x86 build run: | docker run --rm \ -v /opt/dist:/opt/dist \ -e DIST_DIR='/opt/dist' \ -e RELEASE_TAG="$GITHUB_REF_NAME" \ -e NATIVE_ARCH="386" \ -e ABI_ID=3 \ -e ABI_TARGET="x86" \ -e KS_FILE="${{ secrets.KS_FILE }}" \ -e KS_PASSWORD="${{ secrets.KS_PASSWORD }}" \ -e KEY_ALIAS="${{ secrets.KEY_ALIAS }}" \ -e KEY_PASSWORD="${{ secrets.KEY_PASSWORD }}" \ x86 - name: Upload x86 artifact uses: actions/upload-artifact@v4 with: name: x86-build path: /opt/dist build-amd64: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Build amd64 docker run: docker build -t amd64 -f Dockerfile . - name: Compile amd64 build run: | docker run --rm \ -v /opt/dist:/opt/dist \ -e DIST_DIR='/opt/dist' \ -e RELEASE_TAG="$GITHUB_REF_NAME" \ -e NATIVE_ARCH="amd64" \ -e ABI_ID=4 \ -e ABI_TARGET="x86_64" \ -e KS_FILE="${{ secrets.KS_FILE }}" \ -e KS_PASSWORD="${{ secrets.KS_PASSWORD }}" \ -e KEY_ALIAS="${{ secrets.KEY_ALIAS }}" \ -e KEY_PASSWORD="${{ secrets.KEY_PASSWORD }}" \ amd64 - name: Upload amd64 artifact uses: actions/upload-artifact@v4 with: name: amd64-build path: /opt/dist publish: runs-on: ubuntu-latest needs: - build-arm32 - build-arm64 - build-x86 - build-amd64 permissions: contents: write steps: - name: Checkout code uses: actions/checkout@v4 - name: Download arm32 artifact uses: actions/download-artifact@v4 with: name: arm32-build path: dist - name: Download arm64 artifact uses: actions/download-artifact@v4 with: name: arm64-build path: dist - name: Download x86 artifact uses: actions/download-artifact@v4 with: name: x86-build path: dist - name: Download amd64 artifact uses: actions/download-artifact@v4 with: name: amd64-build path: dist - name: Set VERSION_CODE run: | ALL_VARIANTS=4 VERSION_CODE=$(cat "$GITHUB_WORKSPACE/app/versionCode.txt") ((VERSION_CODE += ALL_VARIANTS)) echo "VERSION_CODE=$VERSION_CODE" >> $GITHUB_ENV - name: Publish release uses: softprops/action-gh-release@v1 with: tag_name: ${{ github.ref_name }} name: ${{ github.ref_name }} prerelease: false draft: false files: "dist/*.apk" body_path: ${{ github.workspace }}/metadata/en-US/changelogs/${{ env.VERSION_CODE }}.txt ================================================ FILE: .gitignore ================================================ *.iml .idea .kotlin .gradle /local.properties /.idea/caches /.idea/libraries /.idea/modules.xml /.idea/workspace.xml /.idea/navEditor.xml /.idea/assetWizardSettings.xml .DS_Store /build /captures .externalNativeBuild .cxx local.properties ================================================ FILE: .gitmodules ================================================ [submodule "app/src/main/jni/hev-socks5-tunnel"] path = app/src/main/jni/hev-socks5-tunnel url = https://github.com/heiher/hev-socks5-tunnel.git [submodule "XrayCore/libXray"] path = XrayCore/libXray url = https://github.com/XTLS/libXray.git [submodule "XrayCore/Xray-core"] path = XrayCore/Xray-core url = https://github.com/XTLS/Xray-core.git [submodule "XrayHelper"] path = XrayHelper url = https://github.com/SaeedDev94/XrayHelper.git ================================================ FILE: Dockerfile ================================================ FROM debian:trixie ENV LANG=C.UTF-8 \ DEBIAN_FRONTEND=noninteractive COPY build-xray.sh /build-xray.sh ENTRYPOINT ["/build-xray.sh"] ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2024 SaeedDev94 Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # Xray App Cover This is a simple GUI client for XTLS/Xray-core # Screenshots MainActivityAssetsActivitySettingsActivity: Basic TabSettingsActivity: Advanced Tab # APK variants guide - arm32 => versionCode + 1 - arm64 => versionCode + 2 - x86 => versionCode + 3 - amd64 => versionCode + 4 # Download [![Release CI](https://github.com/SaeedDev94/Xray/actions/workflows/release.yml/badge.svg)](https://github.com/SaeedDev94/Xray/actions) Get it on GitHub Get it on F-Droid ================================================ FILE: XrayCore/go.mod ================================================ module XrayCore go 1.26.2 replace github.com/xtls/xray-core => ./Xray-core replace github.com/xtls/libxray => ./libXray require ( github.com/xtls/libxray v0.0.0-00010101000000-000000000000 github.com/xtls/xray-core v1.260327.0 ) require ( github.com/andybalholm/brotli v1.0.6 // indirect github.com/apernet/quic-go v0.59.1-0.20260330051153-c402ee641eb6 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 // indirect github.com/google/btree v1.1.2 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/juju/ratelimit v1.0.2 // indirect github.com/klauspost/compress v1.17.4 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/miekg/dns v1.1.72 // indirect github.com/pelletier/go-toml v1.9.5 // indirect github.com/pires/go-proxyproto v0.12.0 // indirect github.com/quic-go/qpack v0.6.0 // indirect github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af // indirect github.com/sagernet/sing v0.5.1 // indirect github.com/sagernet/sing-shadowsocks v0.2.7 // indirect github.com/vishvananda/netlink v1.3.1 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f // indirect go4.org/netipx v0.0.0-20231129151722-fdeea329fbba // indirect golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 // indirect golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b // indirect golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.12.0 // indirect golang.org/x/tools v0.44.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb // indirect golang.zx2c4.com/wireguard/windows v0.6.1 // indirect google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d // indirect google.golang.org/grpc v1.80.0 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 // indirect lukechampine.com/blake3 v1.4.1 // indirect ) ================================================ FILE: XrayCore/go.sum ================================================ github.com/andybalholm/brotli v1.0.6 h1:Yf9fFpf49Zrxb9NlQaluyE92/+X7UVHlhMNJN2sxfOI= github.com/andybalholm/brotli v1.0.6/go.mod h1:fO7iG3H7G2nSZ7m0zPUDn85XEX2GTukHGRSepvi9Eig= github.com/apernet/quic-go v0.59.1-0.20260330051153-c402ee641eb6 h1:cbF95uMsQwCwAzH2i8+2lNO2TReoELLuqeeMfyBjFbY= github.com/apernet/quic-go v0.59.1-0.20260330051153-c402ee641eb6/go.mod h1:Npbg8qBtAZlsAB3FWmqwlVh5jtVG6a4DlYsOylUpvzA= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344 h1:Arcl6UOIS/kgO2nW3A65HN+7CMjSDP/gofXL4CZt1V4= github.com/ghodss/yaml v1.0.1-0.20220118164431-d8423dcdf344/go.mod h1:GIjDIg/heH5DOkXY3YJ/wNhfHsQHoXGjl8G8amsYQ1I= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/golang/mock v1.7.0-rc.1 h1:YojYx61/OLFsiv6Rw1Z96LpldJIy31o+UHmwAUMJ6/U= github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/btree v1.1.2 h1:xf4v41cLI2Z6FxbKm+8Bu+m8ifhj15JuZ9sa0jZCMUU= github.com/google/btree v1.1.2/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/juju/ratelimit v1.0.2 h1:sRxmtRiajbvrcLQT7S+JbqU0ntsb9W2yhSdNN8tWfaI= github.com/juju/ratelimit v1.0.2/go.mod h1:qapgC/Gy+xNh9UxzV13HGGl/6UXNN+ct+vwSgWNm/qk= github.com/klauspost/compress v1.17.4 h1:Ej5ixsIri7BrIjBkRZLTo6ghwrEtHFk7ijlczPW4fZ4= github.com/klauspost/compress v1.17.4/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= github.com/pires/go-proxyproto v0.12.0 h1:TTCxD66dU898tahivkqc3hoceZp7P44FnorWyo9d5vM= github.com/pires/go-proxyproto v0.12.0/go.mod h1:qUvfqUMEoX7T8g0q7TQLDnhMjdTrxnG0hvpMn+7ePNI= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8= github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af h1:er2acxbi3N1nvEq6HXHUAR1nTWEJmQfqiGR8EVT9rfs= github.com/refraction-networking/utls v1.8.3-0.20260301010127-aa6edf4b11af/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/sagernet/sing v0.5.1 h1:mhL/MZVq0TjuvHcpYcFtmSD1BFOxZ/+8ofbNZcg1k1Y= github.com/sagernet/sing v0.5.1/go.mod h1:ARkL0gM13/Iv5VCZmci/NuoOlePoIsW0m7BWfln/Hak= github.com/sagernet/sing-shadowsocks v0.2.7 h1:zaopR1tbHEw5Nk6FAkM05wCslV6ahVegEZaKMv9ipx8= github.com/sagernet/sing-shadowsocks v0.2.7/go.mod h1:0rIKJZBR65Qi0zwdKezt4s57y/Tl1ofkaq6NlkzVuyE= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f h1:iy2JRioxmUpoJ3SzbFPyTxHZMbR/rSHP7dOOgYaq1O8= github.com/xtls/reality v0.0.0-20260322125925-9234c772ba8f/go.mod h1:DsJblcWDGt76+FVqBVwbwRhxyyNJsGV48gJLch0OOWI= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba h1:0b9z3AuHCjxk0x/opv64kcgZLBseWJUpBw5I82+2U4M= go4.org/netipx v0.0.0-20231129151722-fdeea329fbba/go.mod h1:PLyyIXexvUFg3Owu6p/WfdlivPbZJsZdgWZlrGope/Y= golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842 h1:vr/HnozRka3pE4EsMEg1lgkXJkTFJCVUX+S/ZT6wYzM= golang.org/x/exp v0.0.0-20240506185415-9bf2ced13842/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b h1:Qt2eaXcZ8x20iAcoZ6AceeMMtnjuPHvC51KRCH1DKSQ= golang.org/x/mobile v0.0.0-20260410095206-2cfb76559b7b/go.mod h1:5Fu78lew5ucMXt8w2KYcwvxu2rkC/liHzUvaoiI+H/M= golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= golang.zx2c4.com/wireguard/windows v0.6.1 h1:XMaKojH1Hs/raMrmnir4n35nTvzvWj7NmSYzHn2F4qU= golang.zx2c4.com/wireguard/windows v0.6.1/go.mod h1:04aqInu5GYuTFvMuDw/rKBAF7mHrltW/3rekpfbbZDM= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478 h1:aLsVTW0lZ8+IY5u/ERjZSCvAmhuR7slKzyha3YikDNA= google.golang.org/genproto v0.0.0-20260414002931-afd174a4e478/go.mod h1:YJAzKjfHIUHb9T+bfu8L7mthAp7VVXQBUs1PLdBWS7M= google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d h1:wT2n40TBqFY6wiwazVK9/iTWbsQrgk5ZfCSVFLO9LQA= google.golang.org/genproto/googleapis/rpc v0.0.0-20260406210006-6f92a3bedf2d/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0 h1:Lk6hARj5UPY47dBep70OD/TIMwikJ5fGUGX0Rm3Xigk= gvisor.dev/gvisor v0.0.0-20260122175437-89a5d21be8f0/go.mod h1:QkHjoMIBaYtpVufgwv3keYAbln78mBoCuShZrPrer1Q= lukechampine.com/blake3 v1.4.1 h1:I3Smz7gso8w4/TunLKec6K2fn+kyKtDxr/xcQEN84Wg= lukechampine.com/blake3 v1.4.1/go.mod h1:QFosUxmjB8mnrWFSNwKmvxHpfY72bmD2tQ0kBMM3kwo= ================================================ FILE: XrayCore/lib/core.go ================================================ package lib import ( "github.com/xtls/xray-core/common/cmdarg" "github.com/xtls/xray-core/core" _ "github.com/xtls/xray-core/main/distro/all" ) var coreServer *core.Instance func Server(config string) (*core.Instance, error) { file := cmdarg.Arg{config} json, err := core.LoadConfig("json", file) if err != nil { return nil, err } server, err := core.New(json) if err != nil { return nil, err } return server, nil } func Start(dir string, config string) (err error) { SetEnv(dir) coreServer, err = Server(config) if err != nil { return } if err = coreServer.Start(); err != nil { return } return nil } func Stop() error { if coreServer != nil { err := coreServer.Close() coreServer = nil if err != nil { return err } } return nil } func Version() string { return core.Version() } ================================================ FILE: XrayCore/lib/env.go ================================================ package lib import "os" func SetEnv(dir string) { os.Setenv("xray.location.asset", dir) } ================================================ FILE: XrayCore/lib/error.go ================================================ package lib func WrapError(err error) string { if err != nil { return err.Error() } return "" } ================================================ FILE: XrayCore/lib/test.go ================================================ package lib func Test(dir string, config string) error { SetEnv(dir) _, err := Server(config) if err != nil { return err } return nil } ================================================ FILE: XrayCore/main.go ================================================ package XrayCore import ( "github.com/xtls/xray-core/infra/conf" "github.com/xtls/libxray/nodep" "github.com/xtls/libxray/share" "XrayCore/lib" ) func Test(dir string, config string) string { err := lib.Test(dir, config) return lib.WrapError(err) } func Start(dir string, config string) string { err := lib.Start(dir, config) return lib.WrapError(err) } func Stop() string { err := lib.Stop() return lib.WrapError(err) } func Version() string { return lib.Version() } func Json(link string) string { var response nodep.CallResponse[*conf.Config] xrayJson, err := share.ConvertShareLinksToXrayJson(link) return response.EncodeToBase64(xrayJson, err) } ================================================ FILE: app/.gitignore ================================================ /build /release ================================================ FILE: app/build.gradle.kts ================================================ plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.parcelize) alias(libs.plugins.google.ksp) } val abiId: String by project val abiTarget: String by project fun calcVersionCode(): Int { val versionCodeFile = file("versionCode.txt") val versionCode = versionCodeFile.readText().trim().toInt() return versionCode + abiId.toInt() } android { namespace = "io.github.saeeddev94.xray" compileSdk = 36 defaultConfig { applicationId = "io.github.saeeddev94.xray" minSdk = 26 targetSdk = 36 versionCode = calcVersionCode() versionName = "12.2.0" } buildFeatures { buildConfig = true viewBinding = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_21 targetCompatibility = JavaVersion.VERSION_21 } externalNativeBuild { ndkVersion = "28.2.13676358" ndkBuild { path = file("src/main/jni/Android.mk") } } splits { abi { isEnable = true isUniversalApk = false reset() //noinspection ChromeOsAbiSupport include(*abiTarget.split(",").toTypedArray()) } } dependenciesInfo { includeInApk = false includeInBundle = false } signingConfigs { create("release") { storeFile = file("/tmp/xray.jks") storePassword = System.getenv("KS_PASSWORD") keyAlias = System.getenv("KEY_ALIAS") keyPassword = System.getenv("KEY_PASSWORD") } } buildTypes { release { signingConfig = signingConfigs.getByName("release") } } } kotlin { compilerOptions { languageVersion = org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_3 jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_21 } } dependencies { implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.aar", "*.jar")))) implementation(libs.androidx.activity.ktx) implementation(libs.androidx.core.ktx) implementation(libs.androidx.appcompat) implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.room.ktx) implementation(libs.androidx.room.runtime) ksp(libs.androidx.room.compiler) implementation(libs.blacksquircle.ui.editorkit) implementation(libs.blacksquircle.ui.language.json) implementation(libs.google.material) implementation(libs.topjohnwu.libsu.core) implementation(libs.yuriy.budiyev.code.scanner) } ================================================ FILE: app/libs/.gitignore ================================================ *.aar *.jar ================================================ FILE: app/src/main/AndroidManifest.xml ================================================ ================================================ FILE: app/src/main/assets/.gitignore ================================================ xrayhelper ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/Settings.kt ================================================ package io.github.saeeddev94.xray import android.content.Context import androidx.core.content.edit import java.io.File class Settings(private val context: Context) { private val sharedPreferences = context.getSharedPreferences("settings", Context.MODE_PRIVATE) /** Active Link ID */ var selectedLink: Long get() = sharedPreferences.getLong("selectedLink", 0L) set(value) = sharedPreferences.edit { putLong("selectedLink", value) } /** Active Profile ID */ var selectedProfile: Long get() = sharedPreferences.getLong("selectedProfile", 0L) set(value) = sharedPreferences.edit { putLong("selectedProfile", value) } /** The time of last refresh */ var lastRefreshLinks: Long get() = sharedPreferences.getLong("lastRefreshLinks", 0L) set(value) = sharedPreferences.edit { putLong("lastRefreshLinks", value) } /** XrayHelper Version Code */ var xrayHelperVersionCode: Int get() = sharedPreferences.getInt("xrayHelperVersionCode", 0) set(value) = sharedPreferences.edit { putInt("xrayHelperVersionCode", value) } /** * Apps Routing * Mode: true -> exclude, false -> include * Default: exclude */ var appsRoutingMode: Boolean get() = sharedPreferences.getBoolean("appsRoutingMode", true) set(value) = sharedPreferences.edit { putBoolean("appsRoutingMode", value) } var appsRouting: String get() = sharedPreferences.getString("excludedApps", "")!! set(value) = sharedPreferences.edit { putString("excludedApps", value) } /** Tun Routes */ var tunRoutes: Set get() = sharedPreferences.getStringSet( "tunRoutes", context.resources.getStringArray(R.array.publicIpAddresses).toSet() )!! set(value) = sharedPreferences.edit { putStringSet("tunRoutes", value) } /** Basic */ var socksAddress: String get() = sharedPreferences.getString("socksAddress", "127.0.0.1")!! set(value) = sharedPreferences.edit { putString("socksAddress", value) } var socksPort: String get() = sharedPreferences.getString("socksPort", "10808")!! set(value) = sharedPreferences.edit { putString("socksPort", value) } var socksUsername: String get() = sharedPreferences.getString("socksUsername", "")!! set(value) = sharedPreferences.edit { putString("socksUsername", value) } var socksPassword: String get() = sharedPreferences.getString("socksPassword", "")!! set(value) = sharedPreferences.edit { putString("socksPassword", value) } var geoIpAddress: String get() = sharedPreferences.getString( "geoIpAddress", "https://github.com/v2fly/geoip/releases/latest/download/geoip.dat" )!! set(value) = sharedPreferences.edit { putString("geoIpAddress", value) } var geoSiteAddress: String get() = sharedPreferences.getString( "geoSiteAddress", "https://github.com/v2fly/domain-list-community/releases/latest/download/dlc.dat" )!! set(value) = sharedPreferences.edit { putString("geoSiteAddress", value) } var pingAddress: String get() = sharedPreferences.getString("pingAddress", "https://www.google.com")!! set(value) = sharedPreferences.edit { putString("pingAddress", value) } var pingTimeout: Int get() = sharedPreferences.getInt("pingTimeout", 5) set(value) = sharedPreferences.edit { putInt("pingTimeout", value) } var refreshLinksInterval: Int get() = sharedPreferences.getInt("refreshLinksInterval", 60) set(value) = sharedPreferences.edit { putInt("refreshLinksInterval", value) } var bypassLan: Boolean get() = sharedPreferences.getBoolean("bypassLan", true) set(value) = sharedPreferences.edit { putBoolean("bypassLan", value) } var enableIpV6: Boolean get() = sharedPreferences.getBoolean("enableIpV6", true) set(value) = sharedPreferences.edit { putBoolean("enableIpV6", value) } var socksUdp: Boolean get() = sharedPreferences.getBoolean("socksUdp", true) set(value) = sharedPreferences.edit { putBoolean("socksUdp", value) } var tun2socks: Boolean get() = sharedPreferences.getBoolean("tun2socks", true) set(value) = sharedPreferences.edit { putBoolean("tun2socks", value) } var bootAutoStart: Boolean get() = sharedPreferences.getBoolean("bootAutoStart", false) set(value) = sharedPreferences.edit { putBoolean("bootAutoStart", value) } var refreshLinksOnOpen: Boolean get() = sharedPreferences.getBoolean("refreshLinksOnOpen", false) set(value) = sharedPreferences.edit { putBoolean("refreshLinksOnOpen", value) } /** Advanced */ var primaryDns: String get() = sharedPreferences.getString("primaryDns", "1.1.1.1")!! set(value) = sharedPreferences.edit { putString("primaryDns", value) } var secondaryDns: String get() = sharedPreferences.getString("secondaryDns", "1.0.0.1")!! set(value) = sharedPreferences.edit { putString("secondaryDns", value) } var primaryDnsV6: String get() = sharedPreferences.getString("primaryDnsV6", "2606:4700:4700::1111")!! set(value) = sharedPreferences.edit { putString("primaryDnsV6", value) } var secondaryDnsV6: String get() = sharedPreferences.getString("secondaryDnsV6", "2606:4700:4700::1001")!! set(value) = sharedPreferences.edit { putString("secondaryDnsV6", value) } var tunName: String get() = sharedPreferences.getString("tunName", "tun0")!! set(value) = sharedPreferences.edit { putString("tunName", value) } var tunMtu: Int get() = sharedPreferences.getInt("tunMtu", 8500) set(value) = sharedPreferences.edit { putInt("tunMtu", value) } var tunAddress: String get() = sharedPreferences.getString("tunAddress", "10.10.10.10")!! set(value) = sharedPreferences.edit { putString("tunAddress", value) } var tunPrefix: Int get() = sharedPreferences.getInt("tunPrefix", 32) set(value) = sharedPreferences.edit { putInt("tunPrefix", value) } var tunAddressV6: String get() = sharedPreferences.getString("tunAddressV6", "fc00::1")!! set(value) = sharedPreferences.edit { putString("tunAddressV6", value) } var tunPrefixV6: Int get() = sharedPreferences.getInt("tunPrefixV6", 128) set(value) = sharedPreferences.edit { putInt("tunPrefixV6", value) } var hotspotInterface get() = sharedPreferences.getString("hotspotInterface", "wlan2")!! set(value) = sharedPreferences.edit { putString("hotspotInterface", value) } var tetheringInterface get() = sharedPreferences.getString("tetheringInterface", "rndis0")!! set(value) = sharedPreferences.edit { putString("tetheringInterface", value) } var tproxyAddress: String get() = sharedPreferences.getString("tproxyAddress", "127.0.0.1")!! set(value) = sharedPreferences.edit { putString("tproxyAddress", value) } var tproxyPort: Int get() = sharedPreferences.getInt("tproxyPort", 10888) set(value) = sharedPreferences.edit { putInt("tproxyPort", value) } var tproxyBypassWiFi: Set get() = sharedPreferences.getStringSet("tproxyBypassWiFi", mutableSetOf())!! set(value) = sharedPreferences.edit { putStringSet("tproxyBypassWiFi", value) } var tproxyAutoConnect: Boolean get() = sharedPreferences.getBoolean("tproxyAutoConnect", false) set(value) = sharedPreferences.edit { putBoolean("tproxyAutoConnect", value) } var tproxyHotspot: Boolean get() = sharedPreferences.getBoolean("tproxyHotspot", false) set(value) = sharedPreferences.edit { putBoolean("tproxyHotspot", value) } var tproxyTethering: Boolean get() = sharedPreferences.getBoolean("tproxyTethering", false) set(value) = sharedPreferences.edit { putBoolean("tproxyTethering", value) } var transparentProxy: Boolean get() = sharedPreferences.getBoolean("transparentProxy", false) set(value) = sharedPreferences.edit { putBoolean("transparentProxy", value) } fun baseDir(): File = context.filesDir fun xrayCoreFile(): File = File(baseDir(), "xray") fun xrayHelperFile(): File = File(baseDir(), "xrayhelper") fun testConfig(): File = File(baseDir(), "test.json") fun xrayConfig(): File = File(baseDir(), "config.json") fun tun2socksConfig(): File = File(baseDir(), "tun2socks.yml") fun xrayHelperConfig(): File = File(baseDir(), "config.yml") fun xrayCorePid(): File = File(baseDir(), "core.pid") fun networkMonitorPid(): File = File(baseDir(), "monitor.pid") fun networkMonitorScript(): File = File(baseDir(), "monitor.sh") fun xrayCoreLogs(): File = File(baseDir(), "error.log") } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/Xray.kt ================================================ package io.github.saeeddev94.xray import android.app.Application import io.github.saeeddev94.xray.database.XrayDatabase import io.github.saeeddev94.xray.repository.ConfigRepository import io.github.saeeddev94.xray.repository.LinkRepository import io.github.saeeddev94.xray.repository.ProfileRepository class Xray : Application() { private val xrayDatabase by lazy { XrayDatabase.ref(this) } val configRepository by lazy { ConfigRepository(xrayDatabase.configDao()) } val linkRepository by lazy { LinkRepository(xrayDatabase.linkDao()) } val profileRepository by lazy { ProfileRepository(xrayDatabase.profileDao()) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/activity/AppsRoutingActivity.kt ================================================ package io.github.saeeddev94.xray.activity import android.Manifest import android.annotation.SuppressLint import android.content.pm.PackageManager import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.ImageView import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.widget.SearchView import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.adapter.AppsRoutingAdapter import io.github.saeeddev94.xray.databinding.ActivityAppsRoutingBinding import io.github.saeeddev94.xray.dto.AppList import io.github.saeeddev94.xray.helper.TransparentProxyHelper import io.github.saeeddev94.xray.service.TProxyService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class AppsRoutingActivity : AppCompatActivity() { private val settings by lazy { Settings(applicationContext) } private val transparentProxyHelper by lazy { TransparentProxyHelper(this, settings) } private lateinit var binding: ActivityAppsRoutingBinding private lateinit var appsList: RecyclerView private lateinit var appsRoutingAdapter: AppsRoutingAdapter private lateinit var apps: ArrayList private lateinit var filtered: MutableList private lateinit var appsRouting: MutableSet private lateinit var menu: Menu private var appsRoutingMode: Boolean = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) title = "" binding = ActivityAppsRoutingBinding.inflate(layoutInflater) appsRoutingMode = settings.appsRoutingMode setContentView(binding.root) setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) binding.search.focusable = View.NOT_FOCUSABLE binding.search.setOnQueryTextListener(object : SearchView.OnQueryTextListener { override fun onQueryTextChange(newText: String?): Boolean { search(newText) return false } override fun onQueryTextSubmit(query: String?): Boolean { binding.search.clearFocus() search(query) return false } }) binding.search.findViewById(androidx.appcompat.R.id.search_close_btn) ?.setOnClickListener { binding.search.setQuery("", false) binding.search.clearFocus() } getApps() } override fun onCreateOptionsMenu(menu: Menu): Boolean { this.menu = menu menuInflater.inflate(R.menu.menu_apps_routing, menu) handleMode() return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.appsRoutingSave -> saveAppsRouting() R.id.appsRoutingExcludeMode -> setMode(false) R.id.appsRoutingIncludeMode -> setMode(true) else -> finish() } return true } private fun setMode(appsRoutingMode: Boolean) { this.appsRoutingMode = appsRoutingMode handleMode().also { message -> Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).show() } } private fun handleMode(): String { val excludeItem = menu.findItem(R.id.appsRoutingExcludeMode) val includeItem = menu.findItem(R.id.appsRoutingIncludeMode) return when (this.appsRoutingMode) { true -> { excludeItem.isVisible = true includeItem.isVisible = false getString(R.string.appsRoutingExcludeMode) } false -> { excludeItem.isVisible = false includeItem.isVisible = true getString(R.string.appsRoutingIncludeMode) } } } @SuppressLint("NotifyDataSetChanged") private fun search(query: String?) { val keyword = query?.trim()?.lowercase() ?: "" if (keyword.isEmpty()) { if (apps.size > filtered.size) { filtered.clear() filtered.addAll(apps.toMutableList()) appsRoutingAdapter.notifyDataSetChanged() } return } val list = ArrayList() apps.forEach { if (it.appName.lowercase().contains(keyword) || it.packageName.contains(keyword)) { list.add(it) } } filtered.clear() filtered.addAll(list.toMutableList()) appsRoutingAdapter.notifyDataSetChanged() } private fun getApps() { lifecycleScope.launch { val selected = ArrayList() val unselected = ArrayList() packageManager.getInstalledPackages(PackageManager.GET_PERMISSIONS).forEach { val permissions = it.requestedPermissions if ( permissions == null || !permissions.contains(Manifest.permission.INTERNET) ) return@forEach val appIcon = it.applicationInfo!!.loadIcon(packageManager) val appName = it.applicationInfo!!.loadLabel(packageManager).toString() val packageName = it.packageName val app = AppList(appIcon, appName, packageName) val isSelected = settings.appsRouting.contains(packageName) if (isSelected) selected.add(app) else unselected.add(app) } withContext(Dispatchers.Main) { apps = ArrayList(selected + unselected) filtered = apps.toMutableList() appsRouting = settings.appsRouting.split("\n").toMutableSet() appsList = binding.appsList appsRoutingAdapter = AppsRoutingAdapter( this@AppsRoutingActivity, filtered, appsRouting ) appsList.adapter = appsRoutingAdapter appsList.layoutManager = LinearLayoutManager(applicationContext) } } } private fun saveAppsRouting() { val appsRoutingMode = this.appsRoutingMode val appsRouting = this.appsRouting.joinToString("\n") lifecycleScope.launch { val tproxySettingsChanged = settings.appsRoutingMode != appsRoutingMode || settings.appsRouting != appsRouting val stopService = tproxySettingsChanged && settings.xrayCorePid().exists() if (tproxySettingsChanged) transparentProxyHelper.kill() withContext(Dispatchers.Main) { binding.search.clearFocus() settings.appsRoutingMode = appsRoutingMode settings.appsRouting = appsRouting if (stopService) TProxyService.stop(this@AppsRoutingActivity) finish() } } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/activity/AssetsActivity.kt ================================================ package io.github.saeeddev94.xray.activity import android.annotation.SuppressLint import android.net.Uri import android.os.Bundle import android.view.View import android.widget.LinearLayout import android.widget.ProgressBar import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.appcompat.app.AppCompatActivity import androidx.core.view.isVisible import androidx.lifecycle.lifecycleScope import com.topjohnwu.superuser.Shell import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.databinding.ActivityAssetsBinding import io.github.saeeddev94.xray.helper.DownloadHelper import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.text.SimpleDateFormat import java.util.Date import kotlin.text.toRegex class AssetsActivity : AppCompatActivity() { private lateinit var binding: ActivityAssetsBinding private var downloading: Boolean = false private val settings by lazy { Settings(applicationContext) } private val geoIpLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { writeToFile(it, geoIpFile()) } private val geoSiteLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { writeToFile(it, geoSiteFile()) } private val xrayCoreLauncher = registerForActivityResult(ActivityResultContracts.GetContent()) { val file = settings.xrayCoreFile() writeToFile(it, file) { makeExeFile(file) } } private fun geoIpFile(): File = File(applicationContext.filesDir, "geoip.dat") private fun geoSiteFile(): File = File(applicationContext.filesDir, "geosite.dat") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val mimeType = "application/octet-stream" title = getString(R.string.assets) binding = ActivityAssetsBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) setAssetStatus() // GeoIP binding.geoIpDownload.setOnClickListener { download(settings.geoIpAddress, geoIpFile(), binding.geoIpSetup, binding.geoIpProgress) } binding.geoIpFile.setOnClickListener { geoIpLauncher.launch(mimeType) } binding.geoIpDelete.setOnClickListener { delete(geoIpFile()) } // GeoSite binding.geoSiteDownload.setOnClickListener { download( settings.geoSiteAddress, geoSiteFile(), binding.geoSiteSetup, binding.geoSiteProgress ) } binding.geoSiteFile.setOnClickListener { geoSiteLauncher.launch(mimeType) } binding.geoSiteDelete.setOnClickListener { delete(geoSiteFile()) } // XTLS/Xray-core binding.xrayCoreFile.setOnClickListener { runAsRoot { xrayCoreLauncher.launch(mimeType) } } binding.xrayCoreDelete.setOnClickListener { delete(settings.xrayCoreFile()) } } @SuppressLint("SimpleDateFormat") private fun getFileDate(file: File): String { return if (file.exists()) { val date = Date(file.lastModified()) SimpleDateFormat("yyyy/MM/dd HH:mm:ss").format(date) } else { getString(R.string.noValue) } } private fun getXrayCoreVersion(file: File): String { return getExeVersion(file, "${file.absolutePath} version") } private fun getExeVersion(file: File, cmd: String): String { val exists = file.exists() val invalid = { delete(file) "Invalid" } return if (exists) { val result = Shell.cmd(cmd).exec() if (result.isSuccess) { val txt = result.out.first() val match = "Xray (.*?) ".toRegex().find(txt) match?.groups?.get(1)?.value ?: invalid() } else invalid() } else getString(R.string.noValue) } private fun setAssetStatus() { val geoIp = geoIpFile() val geoIpExists = geoIp.exists() binding.geoIpDate.text = getFileDate(geoIp) binding.geoIpSetup.visibility = if (geoIpExists) View.GONE else View.VISIBLE binding.geoIpInstalled.visibility = if (geoIpExists) View.VISIBLE else View.GONE binding.geoIpProgress.visibility = View.GONE val geoSite = geoSiteFile() val geoSiteExists = geoSite.exists() binding.geoSiteDate.text = getFileDate(geoSite) binding.geoSiteSetup.visibility = if (geoSiteExists) View.GONE else View.VISIBLE binding.geoSiteInstalled.visibility = if (geoSiteExists) View.VISIBLE else View.GONE binding.geoSiteProgress.visibility = View.GONE val xrayCore = settings.xrayCoreFile() val xrayCoreExists = xrayCore.exists() binding.xrayCoreVersion.text = getXrayCoreVersion(xrayCore) binding.xrayCoreSetup.isVisible = !xrayCoreExists binding.xrayCoreInstalled.isVisible = xrayCoreExists } private fun download(url: String, file: File, setup: LinearLayout, progressBar: ProgressBar) { if (downloading) { Toast.makeText( applicationContext, "Another download is running, please wait", Toast.LENGTH_SHORT ).show() return } setup.visibility = View.GONE progressBar.visibility = View.VISIBLE progressBar.progress = 0 downloading = true DownloadHelper(lifecycleScope, url, file, object : DownloadHelper.DownloadListener { override fun onProgress(progress: Int) { progressBar.progress = progress } override fun onError(exception: Exception) { downloading = false Toast.makeText(applicationContext, exception.message, Toast.LENGTH_SHORT).show() setAssetStatus() } override fun onComplete() { downloading = false setAssetStatus() } }).start() } private fun writeToFile(uri: Uri?, file: File, cb: (() -> Unit)? = null) { if (uri == null) return lifecycleScope.launch { contentResolver.openInputStream(uri).use { input -> FileOutputStream(file).use { output -> input?.copyTo(output) } } if (cb != null) cb() withContext(Dispatchers.Main) { setAssetStatus() } } } private fun makeExeFile(file: File) { Shell.cmd("chown root:root ${file.absolutePath}").exec() Shell.cmd("chmod +x ${file.absolutePath}").exec() } private fun delete(file: File) { lifecycleScope.launch { file.delete() withContext(Dispatchers.Main) { setAssetStatus() } } } private fun runAsRoot(cb: () -> Unit) { val result = Shell.cmd("whoami").exec() if (result.isSuccess && result.out.first() == "root") { cb() return } Toast.makeText(this, "Root Required", Toast.LENGTH_SHORT).show() } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/activity/ConfigsActivity.kt ================================================ package io.github.saeeddev94.xray.activity import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.RadioButton import android.widget.RadioGroup import android.widget.Toast import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.blacksquircle.ui.editorkit.plugin.autoindent.autoIndentation import com.blacksquircle.ui.editorkit.plugin.base.PluginSupplier import com.blacksquircle.ui.editorkit.plugin.delimiters.highlightDelimiters import com.blacksquircle.ui.editorkit.plugin.linenumbers.lineNumbers import com.blacksquircle.ui.editorkit.widget.TextProcessor import com.blacksquircle.ui.language.json.JsonLanguage import com.google.android.material.radiobutton.MaterialRadioButton import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.adapter.ConfigAdapter import io.github.saeeddev94.xray.database.Config import io.github.saeeddev94.xray.databinding.ActivityConfigsBinding import io.github.saeeddev94.xray.viewmodel.ConfigViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject import kotlin.getValue import kotlin.reflect.cast class ConfigsActivity : AppCompatActivity() { private lateinit var binding: ActivityConfigsBinding private lateinit var config: Config private lateinit var adapter: ConfigAdapter private val configViewModel: ConfigViewModel by viewModels() private val radioGroup = mutableMapOf() private val configEditor = mutableMapOf() private val indentSpaces = 4 override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) title = getString(R.string.configs) binding = ActivityConfigsBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) lifecycleScope.launch { config = configViewModel.get() withContext(Dispatchers.Main) { val context = this@ConfigsActivity val tabs = listOf("log", "dns", "inbounds", "outbounds", "routing") adapter = ConfigAdapter(context, tabs) { tab, view -> setup(tab, view) } binding.viewPager.adapter = adapter binding.tabLayout.setupWithViewPager(binding.viewPager) } } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_configs, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.saveConfigs -> saveConfigs() else -> finish() } return true } private fun setup(tab: String, view: View) { val mode = getMode(tab) val config = getConfig(tab) val modeRadioGroup = view.findViewById(R.id.modeRadioGroup) modeRadioGroup.removeAllViews() Config.Mode.entries.forEach { val radio = MaterialRadioButton(this) radio.text = it.name radio.tag = it modeRadioGroup.addView(radio) if (it == mode) modeRadioGroup.check(radio.id) } radioGroup.put(tab, modeRadioGroup) val editor = view.findViewById(R.id.config) val pluginSupplier = PluginSupplier.create { lineNumbers { lineNumbers = true highlightCurrentLine = true } highlightDelimiters() autoIndentation { autoIndentLines = true autoCloseBrackets = true autoCloseQuotes = true } } editor.language = JsonLanguage() editor.setTextContent(config) editor.plugins(pluginSupplier) configEditor.put(tab, editor) } private fun getConfig(tab: String): String { val editor = configEditor[tab] val default = "" return if (editor == null) { when (tab) { "log" -> config.log "dns" -> config.dns "inbounds" -> config.inbounds "outbounds" -> config.outbounds "routing" -> config.routing else -> default } } else getViewConfig(tab, default) } private fun getMode(tab: String): Config.Mode { val group = configEditor[tab] val default = Config.Mode.Disable return if (group == null) { when (tab) { "log" -> config.logMode "dns" -> config.dnsMode "inbounds" -> config.inboundsMode "outbounds" -> config.outboundsMode "routing" -> config.routingMode else -> default } } else getViewMode(tab, default) } private fun getViewConfig(tab: String, default: String): String { val editor = configEditor[tab] if (editor == null) return default return editor.text.toString() } private fun getViewMode(tab: String, default: Config.Mode): Config.Mode { val group = radioGroup[tab] if (group == null) return default val modeRadioButton = group.findViewById( group.checkedRadioButtonId ) return Config.Mode::class.cast(modeRadioButton.tag) } private fun formatConfig(tab: String, default: String): String { val json = getViewConfig(tab, default) if (arrayOf("inbounds", "outbounds").contains(tab)) return JSONArray(json).toString(indentSpaces) return JSONObject(json).toString(indentSpaces) } private fun saveConfigs() { runCatching { config.log = formatConfig("log", config.log) config.dns = formatConfig("dns", config.dns) config.inbounds = formatConfig("inbounds", config.inbounds) config.outbounds = formatConfig("outbounds", config.outbounds) config.routing = formatConfig("routing", config.routing) config.logMode = getViewMode("log", config.logMode) config.dnsMode = getViewMode("dns", config.dnsMode) config.inboundsMode = getViewMode("inbounds", config.inboundsMode) config.outboundsMode = getViewMode("outbounds", config.outboundsMode) config.routingMode = getViewMode("routing", config.routingMode) config }.onSuccess { configViewModel.update(it) finish() }.onFailure { Toast.makeText( this, "Invalid config", Toast.LENGTH_SHORT ).show() } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/activity/LinksActivity.kt ================================================ package io.github.saeeddev94.xray.activity import android.os.Bundle import android.view.Menu import android.view.MenuItem import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.adapter.LinkAdapter import io.github.saeeddev94.xray.database.Link import io.github.saeeddev94.xray.databinding.ActivityLinksBinding import io.github.saeeddev94.xray.viewmodel.LinkViewModel import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch class LinksActivity : AppCompatActivity() { private val linkViewModel: LinkViewModel by viewModels() private val adapter by lazy { LinkAdapter() } private val linksRecyclerView by lazy { findViewById(R.id.linksRecyclerView) } private var links: MutableList = mutableListOf() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) title = getString(R.string.links) val binding = ActivityLinksBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) adapter.onEditClick = { link -> openLink(link) } adapter.onDeleteClick = { link -> deleteLink(link) } linksRecyclerView.layoutManager = LinearLayoutManager(this) linksRecyclerView.itemAnimator = DefaultItemAnimator() linksRecyclerView.adapter = adapter lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { linkViewModel.links.collectLatest { links = it.toMutableList() adapter.submitList(it) } } } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_links, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.refreshLinks -> refreshLinks() R.id.newLink -> openLink() else -> finish() } return true } private fun refreshLinks() { val intent = LinksManagerActivity.refreshLinks(applicationContext) startActivity(intent) } private fun openLink(link: Link = Link()) { val intent = LinksManagerActivity.openLink(applicationContext, link) startActivity(intent) } private fun deleteLink(link: Link) { val intent = LinksManagerActivity.deleteLink(applicationContext, link) startActivity(intent) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/activity/LinksManagerActivity.kt ================================================ package io.github.saeeddev94.xray.activity import android.app.Dialog import android.content.Context import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.widget.LinearLayout import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.database.Link import io.github.saeeddev94.xray.database.Profile import io.github.saeeddev94.xray.fragment.LinkFormFragment import io.github.saeeddev94.xray.helper.HttpHelper import io.github.saeeddev94.xray.helper.IntentHelper import io.github.saeeddev94.xray.helper.LinkHelper import io.github.saeeddev94.xray.service.TProxyService import io.github.saeeddev94.xray.viewmodel.LinkViewModel import io.github.saeeddev94.xray.viewmodel.ProfileViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.json.JSONArray import org.json.JSONObject import kotlin.reflect.cast class LinksManagerActivity : AppCompatActivity() { companion object { private const val LINK_REF = "ref" private const val DELETE_ACTION = "delete" fun refreshLinks(context: Context): Intent { return Intent(context, LinksManagerActivity::class.java) } fun openLink(context: Context, link: Link = Link()): Intent { return Intent(context, LinksManagerActivity::class.java).apply { putExtra(LINK_REF, link) } } fun deleteLink(context: Context, link: Link): Intent { return Intent(context, LinksManagerActivity::class.java).apply { putExtra(LINK_REF, link) putExtra(DELETE_ACTION, true) } } } private val settings by lazy { Settings(applicationContext) } private val linkViewModel: LinkViewModel by viewModels() private val profileViewModel: ProfileViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val link: Link? = IntentHelper.getParcelable(intent, LINK_REF, Link::class.java) val deleteAction = intent.getBooleanExtra(DELETE_ACTION, false) if (link == null) { refreshLinks() return } if (deleteAction) { deleteLink(link) return } LinkFormFragment(link) { if (link.id == 0L) { linkViewModel.insert(link) } else { linkViewModel.update(link) } setResult(RESULT_OK) finish() }.show(supportFragmentManager, null) } private fun loadingDialog(): Dialog { val dialogView = LayoutInflater.from(this).inflate( R.layout.loading_dialog, LinearLayout(this) ) return MaterialAlertDialogBuilder(this) .setView(dialogView) .setCancelable(false) .create() } private fun refreshLinks() { val loadingDialog = loadingDialog() loadingDialog.show() lifecycleScope.launch { val links = linkViewModel.activeLinks() links.forEach { link -> val profiles = profileViewModel.linkProfiles(link.id) runCatching { val content = HttpHelper.get(link.address, link.userAgent).trim() val newProfiles = if (link.type == Link.Type.Json) { jsonProfiles(link, content) } else { subscriptionProfiles(link, content) } if (newProfiles.isNotEmpty()) { val linkProfiles = profiles.filter { it.linkId == link.id } manageProfiles(link, linkProfiles, newProfiles) } } } withContext(Dispatchers.Main) { settings.lastRefreshLinks = System.currentTimeMillis() TProxyService.newConfig(applicationContext) loadingDialog.dismiss() finish() } } } private fun jsonProfiles(link: Link, value: String): List { val list = arrayListOf() val configs = runCatching { JSONArray(value) }.getOrNull() ?: JSONArray() for (i in 0 until configs.length()) { runCatching { JSONObject::class.cast(configs[i]) }.getOrNull()?.let { configuration -> val label = if (configuration.has("remarks")) { val remarks = configuration.getString("remarks") configuration.remove("remarks") remarks } else { LinkHelper.REMARK_DEFAULT } val json = configuration.toString(2) val profile = Profile().apply { linkId = link.id name = label config = json } list.add(profile) } } return list.reversed().toList() } private fun subscriptionProfiles(link: Link, value: String): List { val decoded = runCatching { LinkHelper.tryDecodeBase64(value).trim() }.getOrNull() ?: "" return decoded.split("\n") .reversed() .map { LinkHelper(settings, it) } .filter { it.isValid() } .map { linkHelper -> val profile = Profile() profile.linkId = link.id profile.config = linkHelper.json() profile.name = linkHelper.remark() profile } } private suspend fun manageProfiles( link: Link, linkProfiles: List, newProfiles: List ) { if (newProfiles.size >= linkProfiles.size) { newProfiles.forEachIndexed { index, newProfile -> if (index >= linkProfiles.size) { newProfile.linkId = link.id insertProfile(newProfile) } else { val linkProfile = linkProfiles[index] updateProfile(linkProfile, newProfile) } } return } linkProfiles.forEachIndexed { index, linkProfile -> if (index >= newProfiles.size) { deleteProfile(linkProfile) } else { val newProfile = newProfiles[index] updateProfile(linkProfile, newProfile) } } } private suspend fun insertProfile(newProfile: Profile) { profileViewModel.create(newProfile) } private suspend fun updateProfile(linkProfile: Profile, newProfile: Profile) { linkProfile.name = newProfile.name linkProfile.config = newProfile.config profileViewModel.update(linkProfile) } private suspend fun deleteProfile(linkProfile: Profile) { profileViewModel.remove(linkProfile) withContext(Dispatchers.Main) { val selectedProfile = settings.selectedProfile if (selectedProfile == linkProfile.id) { settings.selectedProfile = 0L } } } private fun deleteLink(link: Link) { lifecycleScope.launch { profileViewModel.linkProfiles(link.id) .forEach { linkProfile -> deleteProfile(linkProfile) } linkViewModel.delete(link) withContext(Dispatchers.Main) { finish() } } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/activity/LogsActivity.kt ================================================ package io.github.saeeddev94.xray.activity import android.annotation.SuppressLint import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.os.Bundle import android.util.Log import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import io.github.saeeddev94.xray.BuildConfig import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.databinding.ActivityLogsBinding import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.BufferedReader import java.io.IOException import java.io.InputStreamReader import java.nio.charset.StandardCharsets class LogsActivity : AppCompatActivity() { private lateinit var binding: ActivityLogsBinding private val settings by lazy { Settings(applicationContext) } companion object { private const val MAX_BUFFERED_LINES = (1 shl 14) - 1 } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) title = getString(R.string.logs) binding = ActivityLogsBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) lifecycleScope.launch(Dispatchers.IO) { streamingLog() } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_logs, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.deleteLogs -> flush() R.id.copyLogs -> copyToClipboard(binding.logsTextView.text.toString()) else -> finish() } return true } private fun flush() { lifecycleScope.launch(Dispatchers.IO) { val command = if (settings.transparentProxy) { listOf("echo", "''", ">", settings.xrayCoreLogs().absolutePath) } else { listOf("logcat", "-c") } val process = ProcessBuilder(command).start() process.waitFor() withContext(Dispatchers.Main) { binding.logsTextView.text = "" } } } private fun copyToClipboard(text: String) { if (text.isBlank()) return try { val clipData = ClipData.newPlainText(null, text) val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager clipboardManager.setPrimaryClip(clipData) Toast.makeText(applicationContext, "Logs copied", Toast.LENGTH_SHORT).show() } catch (error: Exception) { error.printStackTrace() } } @SuppressLint("SetTextI18n") private suspend fun streamingLog() = withContext(Dispatchers.IO) { val cmd = if (settings.transparentProxy) { listOf("tail", "-f", settings.xrayCoreLogs().absolutePath) } else { listOf("logcat", "-v", "time", "-s", "GoLog,${BuildConfig.APPLICATION_ID}") } val builder = ProcessBuilder(cmd) builder.environment()["LC_ALL"] = "C" var process: Process? = null try { process = try { builder.start() } catch (e: IOException) { Log.e(packageName, Log.getStackTraceString(e)) return@withContext } val stdout = BufferedReader( InputStreamReader(process!!.inputStream, StandardCharsets.UTF_8) ) val bufferedLogLines = arrayListOf() var timeLastNotify = System.nanoTime() // The timeout is initially small so that the view gets populated immediately. var timeout = 1000000000L / 2 while (true) { val line = stdout.readLine() ?: break bufferedLogLines.add(line) val timeNow = System.nanoTime() if ( bufferedLogLines.size < MAX_BUFFERED_LINES && (timeNow - timeLastNotify) < timeout && stdout.ready() ) continue // Increase the timeout after the initial view has something in it. timeout = 1000000000L * 5 / 2 timeLastNotify = timeNow withContext(Dispatchers.Main) { val contentHeight = binding.logsTextView.height val scrollViewHeight = binding.logsScrollView.height val isScrolledToBottomAlready = (binding.logsScrollView.scrollY + scrollViewHeight) >= contentHeight * 0.95 binding.logsTextView.text = binding.logsTextView.text.toString() + bufferedLogLines.joinToString( separator = "\n", postfix = "\n" ) bufferedLogLines.clear() if (isScrolledToBottomAlready) { binding.logsScrollView.post { binding.logsScrollView.fullScroll(View.FOCUS_DOWN) } } } } } finally { process?.destroy() } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/activity/MainActivity.kt ================================================ package io.github.saeeddev94.xray.activity import XrayCore.XrayCore import android.content.BroadcastReceiver import android.content.ClipboardManager import android.content.Context import android.content.Intent import android.content.IntentFilter import android.content.res.ColorStateList import android.net.VpnService import android.os.Build import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts.RequestPermission import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult import androidx.activity.viewModels import androidx.appcompat.app.ActionBarDrawerToggle import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.core.view.GravityCompat import androidx.core.view.isVisible import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope import androidx.lifecycle.repeatOnLifecycle import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.navigation.NavigationView import com.google.android.material.tabs.TabLayout import io.github.saeeddev94.xray.BuildConfig import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.adapter.ProfileAdapter import io.github.saeeddev94.xray.database.Link import io.github.saeeddev94.xray.databinding.ActivityMainBinding import io.github.saeeddev94.xray.dto.ProfileList import io.github.saeeddev94.xray.helper.HttpHelper import io.github.saeeddev94.xray.helper.LinkHelper import io.github.saeeddev94.xray.helper.ProfileTouchHelper import io.github.saeeddev94.xray.helper.TransparentProxyHelper import io.github.saeeddev94.xray.service.TProxyService import io.github.saeeddev94.xray.viewmodel.LinkViewModel import io.github.saeeddev94.xray.viewmodel.ProfileViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.net.URI class MainActivity : AppCompatActivity(), NavigationView.OnNavigationItemSelectedListener { private val clipboardManager by lazy { getSystemService(ClipboardManager::class.java) } private val settings by lazy { Settings(applicationContext) } private val transparentProxyHelper by lazy { TransparentProxyHelper(this, settings) } private val linkViewModel: LinkViewModel by viewModels() private val profileViewModel: ProfileViewModel by viewModels() private var isRunning: Boolean = false private lateinit var binding: ActivityMainBinding private lateinit var profileAdapter: ProfileAdapter private lateinit var tabs: List private val profilesRecyclerView by lazy { findViewById(R.id.profilesRecyclerView) } private val profiles = arrayListOf() private var cameraPermission = registerForActivityResult(RequestPermission()) { if (!it) return@registerForActivityResult scannerLauncher.launch( Intent(applicationContext, ScannerActivity::class.java) ) } private val notificationPermission = registerForActivityResult(RequestPermission()) { onToggleButtonClick() } private val linksManager = registerForActivityResult(StartActivityForResult()) { if (it.resultCode != RESULT_OK) return@registerForActivityResult refreshLinks() } private var scannerLauncher = registerForActivityResult(StartActivityForResult()) { val link = it.data?.getStringExtra("link") if (it.resultCode != RESULT_OK || link == null) return@registerForActivityResult this@MainActivity.processLink(link) } private val vpnLauncher = registerForActivityResult(StartActivityForResult()) { if (it.resultCode != RESULT_OK) return@registerForActivityResult toggleVpnService() } private val vpnServiceEventReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (context == null || intent == null) return when (intent.action) { TProxyService.START_VPN_SERVICE_ACTION_NAME -> vpnStartStatus() TProxyService.STOP_VPN_SERVICE_ACTION_NAME -> vpnStopStatus() TProxyService.STATUS_VPN_SERVICE_ACTION_NAME -> { intent.getBooleanExtra("isRunning", false).let { isRunning -> if (isRunning) vpnStartStatus() else vpnStopStatus() } } } } } private val linksTabListener = object : TabLayout.OnTabSelectedListener { override fun onTabSelected(tab: TabLayout.Tab?) { if (tab == null) return settings.selectedLink = tab.tag.toString().toLong() profileViewModel.next(settings.selectedLink) } override fun onTabUnselected(tab: TabLayout.Tab?) { } override fun onTabReselected(tab: TabLayout.Tab?) { } } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityMainBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) binding.toggleButton.setOnClickListener { onToggleButtonClick() } binding.pingBox.setOnClickListener { ping() } binding.navView.menu.findItem(R.id.appVersion).title = BuildConfig.VERSION_NAME binding.navView.menu.findItem(R.id.xrayVersion).title = XrayCore.version() binding.navView.setNavigationItemSelectedListener(this) ActionBarDrawerToggle( this, binding.drawerLayout, binding.toolbar, R.string.drawerOpen, R.string.drawerClose ).also { binding.drawerLayout.addDrawerListener(it) it.syncState() } profileAdapter = ProfileAdapter( lifecycleScope, settings, profileViewModel, profiles, { index, profile -> profileSelect(index, profile) }, { profile -> profileEdit(profile) }, { profile -> profileDelete(profile) }, ) profilesRecyclerView.adapter = profileAdapter profilesRecyclerView.layoutManager = LinearLayoutManager(applicationContext) ItemTouchHelper(ProfileTouchHelper(profileAdapter)).also { it.attachToRecyclerView(profilesRecyclerView) } lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { linkViewModel.tabs.collectLatest { onNewTabs(it) } } } lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { profileViewModel.filtered.collectLatest { onNewProfiles(it) } } } lifecycleScope.launch { lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { profileViewModel.profiles.collectLatest { val tabs = if (::tabs.isInitialized) tabs else linkViewModel.activeLinks() val list = tabsList(tabs) val index = tabsIndex(list) profileViewModel.next(list[index].id) } } } intent?.data?.let { deepLink -> val pathSegments = deepLink.pathSegments if (pathSegments.isNotEmpty()) processLink(pathSegments[0]) } } override fun onResume() { super.onResume() lifecycleScope.launch { if (settings.transparentProxy) transparentProxyHelper.install() } } override fun onStart() { super.onStart() IntentFilter().also { it.addAction(TProxyService.START_VPN_SERVICE_ACTION_NAME) it.addAction(TProxyService.STOP_VPN_SERVICE_ACTION_NAME) it.addAction(TProxyService.STATUS_VPN_SERVICE_ACTION_NAME) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { registerReceiver(vpnServiceEventReceiver, it, RECEIVER_NOT_EXPORTED) } else { @Suppress("UnspecifiedRegisterReceiverFlag") registerReceiver(vpnServiceEventReceiver, it) } } Intent(this, TProxyService::class.java).also { it.action = TProxyService.STATUS_VPN_SERVICE_ACTION_NAME startService(it) } if (settings.refreshLinksOnOpen) { val interval = (settings.refreshLinksInterval * 60 * 1000).toLong() val diff = System.currentTimeMillis() - settings.lastRefreshLinks if (diff >= interval) refreshLinks() } } override fun onStop() { super.onStop() unregisterReceiver(vpnServiceEventReceiver) } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_main, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.refreshLinks -> refreshLinks() R.id.newProfile -> startActivity(ProfileActivity.getIntent(applicationContext)) R.id.scanQrCode -> cameraPermission.launch(android.Manifest.permission.CAMERA) R.id.fromClipboard -> { runCatching { clipboardManager.primaryClip!!.getItemAt(0).text.toString().trim() }.getOrNull()?.let { processLink(it) } } } return true } override fun onNavigationItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.assets -> Intent(applicationContext, AssetsActivity::class.java) R.id.links -> Intent(applicationContext, LinksActivity::class.java) R.id.logs -> Intent(applicationContext, LogsActivity::class.java) R.id.appsRouting -> Intent(applicationContext, AppsRoutingActivity::class.java) R.id.configs -> Intent(applicationContext, ConfigsActivity::class.java) R.id.settings -> Intent(applicationContext, SettingsActivity::class.java) else -> null }?.let { startActivity(it) } binding.drawerLayout.closeDrawer(GravityCompat.START) return true } private fun tabsList(list: List): List { tabs = list return listOf(Link(name = "All")) + tabs } private fun tabsIndex(list: List): Int { return list.indexOfFirst { it.id == settings.selectedLink }.takeIf { it != -1 } ?: 0 } private fun onNewTabs(value: List) { binding.linksTab.removeOnTabSelectedListener(linksTabListener) binding.linksTab.removeAllTabs() binding.linksTab.isVisible = !value.isEmpty() val list = tabsList(value) val index = tabsIndex(list) list.forEach { val tab = binding.linksTab.newTab() tab.tag = it.id tab.text = it.name binding.linksTab.addTab(tab) } binding.linksTab.selectTab(binding.linksTab.getTabAt(index)) binding.linksTab.addOnTabSelectedListener(linksTabListener) } private fun onNewProfiles(value: List) { profiles.clear() profiles.addAll(ArrayList(value)) @Suppress("NotifyDataSetChanged") profileAdapter.notifyDataSetChanged() } private fun vpnStartStatus() { isRunning = true binding.toggleButton.text = getString(R.string.vpnStop) binding.toggleButton.backgroundTintList = ColorStateList.valueOf( ContextCompat.getColor(this, R.color.primaryColor) ) binding.pingResult.text = getString(R.string.pingConnected) } private fun vpnStopStatus() { isRunning = false binding.toggleButton.text = getString(R.string.vpnStart) binding.toggleButton.backgroundTintList = ColorStateList.valueOf( ContextCompat.getColor(this, R.color.btnColor) ) binding.pingResult.text = getString(R.string.pingNotConnected) } private fun onToggleButtonClick() { if (!settings.tun2socks || settings.transparentProxy) { toggleVpnService() return } if (!hasPostNotification()) return VpnService.prepare(this).also { if (it == null) { toggleVpnService() return } vpnLauncher.launch(it) } } private fun toggleVpnService() { if (isRunning) { TProxyService.stop(applicationContext) return } TProxyService.start(applicationContext, false) } private fun profileSelect(index: Int, profile: ProfileList) { val selectedProfile = settings.selectedProfile lifecycleScope.launch { val ref = if (selectedProfile > 0L) profileViewModel.find(selectedProfile) else null withContext(Dispatchers.Main) { if (selectedProfile == profile.id) return@withContext settings.selectedProfile = profile.id profileAdapter.notifyItemChanged(index) if (isRunning) TProxyService.newConfig(applicationContext) if (ref == null || ref.id == profile.id) return@withContext profiles.indexOfFirst { it.id == ref.id }.let { if (it != -1) profileAdapter.notifyItemChanged(it) } } } } private fun profileEdit(profile: ProfileList) { if (isRunning && settings.selectedProfile == profile.id) return startActivity(ProfileActivity.getIntent(applicationContext, profile.id)) } private fun profileDelete(profile: ProfileList) { if (isRunning && settings.selectedProfile == profile.id) return MaterialAlertDialogBuilder(this) .setTitle("Delete Profile#${profile.index + 1} ?") .setMessage("\"${profile.name}\" will delete forever !!") .setNegativeButton("No", null) .setPositiveButton("Yes") { _, _ -> lifecycleScope.launch { val ref = profileViewModel.find(profile.id) val id = ref.id profileViewModel.remove(ref) withContext(Dispatchers.Main) { val selectedProfile = settings.selectedProfile if (selectedProfile == id) { settings.selectedProfile = 0L } } } }.show() } private fun processLink(link: String) { val uri = runCatching { URI(link) }.getOrNull() ?: return if (uri.scheme == "http") { Toast.makeText( applicationContext, getString(R.string.forbiddenHttp), Toast.LENGTH_SHORT ).show() return } if (uri.scheme == "https") { openLink(uri) return } val linkHelper = LinkHelper(settings, link) if (!linkHelper.isValid()) { Toast.makeText( applicationContext, getString(R.string.invalidLink), Toast.LENGTH_SHORT ).show() return } val json = linkHelper.json() val name = linkHelper.remark() startActivity(ProfileActivity.getIntent(applicationContext, name = name, config = json)) } private fun refreshLinks() { startActivity(LinksManagerActivity.refreshLinks(applicationContext)) } private fun openLink(uri: URI) { val link = Link() link.name = LinkHelper.remark(uri, LinkHelper.LINK_DEFAULT) link.address = uri.toString() val intent = LinksManagerActivity.openLink(applicationContext, link) linksManager.launch(intent) } private fun ping() { if (!isRunning) return binding.pingResult.text = getString(R.string.pingTesting) HttpHelper(lifecycleScope, settings).measureDelay(!settings.transparentProxy) { binding.pingResult.text = it } } private fun hasPostNotification(): Boolean { val sharedPref = getSharedPreferences("app", MODE_PRIVATE) val key = "request_notification_permission" val askedBefore = sharedPref.getBoolean(key, false) if (askedBefore) return true if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { sharedPref.edit { putBoolean(key, true) } notificationPermission.launch(android.Manifest.permission.POST_NOTIFICATIONS) return false } return true } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/activity/ProfileActivity.kt ================================================ package io.github.saeeddev94.xray.activity import XrayCore.XrayCore import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.Menu import android.view.MenuItem import androidx.activity.viewModels import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.blacksquircle.ui.editorkit.plugin.autoindent.autoIndentation import com.blacksquircle.ui.editorkit.plugin.base.PluginSupplier import com.blacksquircle.ui.editorkit.plugin.delimiters.highlightDelimiters import com.blacksquircle.ui.editorkit.plugin.linenumbers.lineNumbers import com.blacksquircle.ui.language.json.JsonLanguage import com.google.android.material.dialog.MaterialAlertDialogBuilder import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.database.Config import io.github.saeeddev94.xray.database.Profile import io.github.saeeddev94.xray.databinding.ActivityProfileBinding import io.github.saeeddev94.xray.helper.ConfigHelper import io.github.saeeddev94.xray.helper.FileHelper import io.github.saeeddev94.xray.viewmodel.ConfigViewModel import io.github.saeeddev94.xray.viewmodel.ProfileViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.BufferedReader import java.io.InputStreamReader class ProfileActivity : AppCompatActivity() { companion object { private const val PROFILE_ID = "id" private const val PROFILE_NAME = "name" private const val PROFILE_CONFIG = "config" fun getIntent( context: Context, id: Long = 0L, name: String = "", config: String = "" ) = Intent(context, ProfileActivity::class.java).also { it.putExtra(PROFILE_ID, id) if (name.isNotEmpty()) it.putExtra(PROFILE_NAME, name) if (config.isNotEmpty()) it.putExtra( PROFILE_CONFIG, config.replace("\\/", "/") ) } } private val settings by lazy { Settings(applicationContext) } private val configViewModel: ConfigViewModel by viewModels() private val profileViewModel: ProfileViewModel by viewModels() private lateinit var binding: ActivityProfileBinding private lateinit var config: Config private lateinit var profile: Profile private var id: Long = 0L override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) id = intent.getLongExtra(PROFILE_ID, 0L) title = if (isNew()) getString(R.string.newProfile) else getString(R.string.editProfile) binding = ActivityProfileBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) lifecycleScope.launch { val config = configViewModel.get() withContext(Dispatchers.Main) { this@ProfileActivity.config = config } } val jsonUri = intent.data if (Intent.ACTION_VIEW == intent.action && jsonUri != null) { val profile = Profile() profile.config = readJsonFile(jsonUri) resolved(profile) } else if (isNew()) { val profile = Profile() profile.name = intent.getStringExtra(PROFILE_NAME) ?: "" profile.config = intent.getStringExtra(PROFILE_CONFIG) ?: "" resolved(profile) } else { lifecycleScope.launch { val profile = profileViewModel.find(id) withContext(Dispatchers.Main) { resolved(profile) } } } } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_profile, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.saveProfile -> save() else -> finish() } return true } private fun isNew() = id == 0L private fun readJsonFile(uri: Uri): String { val content = StringBuilder() try { contentResolver.openInputStream(uri)?.use { input -> BufferedReader(InputStreamReader(input)).forEachLine { content.append("$it\n") } } } catch (error: Exception) { error.printStackTrace() } return content.toString() } private fun resolved(value: Profile) { profile = value binding.profileName.setText(profile.name) val editor = binding.profileConfig val pluginSupplier = PluginSupplier.create { lineNumbers { lineNumbers = true highlightCurrentLine = true } highlightDelimiters() autoIndentation { autoIndentLines = true autoCloseBrackets = true autoCloseQuotes = true } } editor.language = JsonLanguage() editor.setTextContent(profile.config) editor.plugins(pluginSupplier) } private fun save(check: Boolean = true) { profile.name = binding.profileName.text.toString() profile.config = binding.profileConfig.text.toString() lifecycleScope.launch { val configHelper = runCatching { ConfigHelper(settings, config, profile.config) } val error = if (configHelper.isSuccess) { isValid(configHelper.getOrNull().toString()) } else { configHelper.exceptionOrNull()?.message ?: getString(R.string.invalidProfile) } if (check && error.isNotEmpty()) { withContext(Dispatchers.Main) { showError(error) } return@launch } if (profile.id == 0L) { profileViewModel.create(profile) } else { profileViewModel.update(profile) } withContext(Dispatchers.Main) { finish() } } } private suspend fun isValid(json: String): String { return withContext(Dispatchers.IO) { val pwd = filesDir.absolutePath val testConfig = settings.testConfig() FileHelper.createOrUpdate(testConfig, json) XrayCore.test(pwd, testConfig.absolutePath) } } private fun showError(message: String) { MaterialAlertDialogBuilder(this) .setTitle(getString(R.string.invalidProfile)) .setMessage(message) .setNegativeButton(getString(R.string.cancel)) { _, _ -> } .setPositiveButton(getString(R.string.ignore)) { _, _ -> save(false) } .show() } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/activity/ScannerActivity.kt ================================================ package io.github.saeeddev94.xray.activity import android.content.Intent import android.os.Bundle import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import com.budiyev.android.codescanner.AutoFocusMode import com.budiyev.android.codescanner.CodeScanner import com.budiyev.android.codescanner.CodeScannerView import com.budiyev.android.codescanner.DecodeCallback import com.budiyev.android.codescanner.ErrorCallback import com.budiyev.android.codescanner.ScanMode import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.databinding.ActivityScannerBinding class ScannerActivity : AppCompatActivity() { private lateinit var binding: ActivityScannerBinding private lateinit var codeScanner: CodeScanner override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) binding = ActivityScannerBinding.inflate(layoutInflater) setContentView(binding.root) val scannerView = findViewById(R.id.scannerView) codeScanner = CodeScanner(this, scannerView) codeScanner.camera = CodeScanner.CAMERA_BACK codeScanner.formats = CodeScanner.ALL_FORMATS codeScanner.autoFocusMode = AutoFocusMode.SAFE codeScanner.scanMode = ScanMode.SINGLE codeScanner.isAutoFocusEnabled = true codeScanner.isFlashEnabled = false codeScanner.decodeCallback = DecodeCallback { runOnUiThread { val intent = Intent().also { intent -> intent.putExtra("link", it.text) } setResult(RESULT_OK, intent) finish() } } codeScanner.errorCallback = ErrorCallback { runOnUiThread { Toast.makeText( this, "Camera initialization error: ${it.message}", Toast.LENGTH_LONG ).show() } } scannerView.setOnClickListener { codeScanner.startPreview() } } override fun onPause() { codeScanner.releaseResources() super.onPause() } override fun onResume() { super.onResume() codeScanner.startPreview() } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/activity/SettingsActivity.kt ================================================ package io.github.saeeddev94.xray.activity import android.annotation.SuppressLint import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View import android.widget.EditText import android.widget.LinearLayout import android.widget.Toast import androidx.appcompat.app.AppCompatActivity import androidx.lifecycle.lifecycleScope import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.materialswitch.MaterialSwitch import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.adapter.SettingAdapter import io.github.saeeddev94.xray.databinding.ActivitySettingsBinding import io.github.saeeddev94.xray.helper.TransparentProxyHelper import io.github.saeeddev94.xray.service.TProxyService import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class SettingsActivity : AppCompatActivity() { private val settings by lazy { Settings(applicationContext) } private val transparentProxyHelper by lazy { TransparentProxyHelper(this, settings) } private lateinit var binding: ActivitySettingsBinding private lateinit var adapter: SettingAdapter private lateinit var basic: View private lateinit var advanced: View override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) title = getString(R.string.settings) binding = ActivitySettingsBinding.inflate(layoutInflater) setContentView(binding.root) setSupportActionBar(binding.toolbar) supportActionBar?.setDisplayHomeAsUpEnabled(true) val tabs = listOf("Basic", "Advanced") val layouts = listOf(R.layout.tab_basic_settings, R.layout.tab_advanced_settings) adapter = SettingAdapter(this, tabs, layouts, object : SettingAdapter.ViewsReady { override fun rendered(views: List) { basic = views[0] advanced = views[1] setupBasic() setupAdvanced() } }) binding.viewPager.adapter = adapter binding.tabLayout.setupWithViewPager(binding.viewPager) } override fun onCreateOptionsMenu(menu: Menu): Boolean { menuInflater.inflate(R.menu.menu_settings, menu) return true } override fun onOptionsItemSelected(item: MenuItem): Boolean { when (item.itemId) { R.id.saveSettings -> applySettings() else -> finish() } return true } @SuppressLint("SetTextI18n") private fun setupBasic() { basic.findViewById(R.id.socksAddress).setText(settings.socksAddress) basic.findViewById(R.id.socksPort).setText(settings.socksPort) basic.findViewById(R.id.socksUsername).setText(settings.socksUsername) basic.findViewById(R.id.socksPassword).setText(settings.socksPassword) basic.findViewById(R.id.geoIpAddress).setText(settings.geoIpAddress) basic.findViewById(R.id.geoSiteAddress).setText(settings.geoSiteAddress) basic.findViewById(R.id.pingAddress).setText(settings.pingAddress) basic.findViewById(R.id.pingTimeout).setText(settings.pingTimeout.toString()) basic.findViewById(R.id.refreshLinksInterval) .setText(settings.refreshLinksInterval.toString()) basic.findViewById(R.id.bypassLan).isChecked = settings.bypassLan basic.findViewById(R.id.enableIpV6).isChecked = settings.enableIpV6 basic.findViewById(R.id.socksUdp).isChecked = settings.socksUdp basic.findViewById(R.id.tun2socks).isChecked = settings.tun2socks basic.findViewById(R.id.bootAutoStart).isChecked = settings.bootAutoStart basic.findViewById(R.id.refreshLinksOnOpen).isChecked = settings.refreshLinksOnOpen } @SuppressLint("SetTextI18n") private fun setupAdvanced() { advanced.findViewById(R.id.primaryDns).setText(settings.primaryDns) advanced.findViewById(R.id.secondaryDns).setText(settings.secondaryDns) advanced.findViewById(R.id.primaryDnsV6).setText(settings.primaryDnsV6) advanced.findViewById(R.id.secondaryDnsV6).setText(settings.secondaryDnsV6) advanced.findViewById(R.id.tunName).setText(settings.tunName) advanced.findViewById(R.id.tunMtu).setText(settings.tunMtu.toString()) advanced.findViewById(R.id.tunAddress).setText(settings.tunAddress) advanced.findViewById(R.id.tunPrefix).setText(settings.tunPrefix.toString()) advanced.findViewById(R.id.tunAddressV6).setText(settings.tunAddressV6) advanced.findViewById(R.id.tunPrefixV6).setText(settings.tunPrefixV6.toString()) advanced.findViewById(R.id.hotspotInterface).setText(settings.hotspotInterface) advanced.findViewById(R.id.tetheringInterface).setText(settings.tetheringInterface) advanced.findViewById(R.id.tproxyAddress).setText(settings.tproxyAddress) advanced.findViewById(R.id.tproxyPort).setText(settings.tproxyPort.toString()) advanced.findViewById(R.id.tproxyBypassWiFi).setText(settings.tproxyBypassWiFi.joinToString(", ")) advanced.findViewById(R.id.tproxyAutoConnect).isChecked = settings.tproxyAutoConnect advanced.findViewById(R.id.tproxyHotspot).isChecked = settings.tproxyHotspot advanced.findViewById(R.id.tproxyTethering).isChecked = settings.tproxyTethering advanced.findViewById(R.id.transparentProxy).isChecked = settings.transparentProxy advanced.findViewById(R.id.tunRoutes).setOnClickListener { tunRoutes() } } private fun tunRoutes() { val tunRoutes = resources.getStringArray(R.array.publicIpAddresses).toSet() val layout = layoutInflater.inflate(R.layout.layout_tun_routes, null) val editText = layout.findViewById(R.id.tunRoutesEditText) val getValue = { editText.text.toString().lines().map { it.trim() }.filter { it.isNotBlank() }.toSet() } editText.setText(settings.tunRoutes.joinToString("\n")) MaterialAlertDialogBuilder(this) .setTitle(R.string.tunRoutes) .setView(layout) .setPositiveButton("Save") { _, _ -> settings.tunRoutes = getValue() } .setNeutralButton("Reset") { _, _ -> settings.tunRoutes = tunRoutes } .setNegativeButton("Close", null) .show() } private fun applySettings() { val transparentProxy = advanced.findViewById(R.id.transparentProxy).isChecked if (transparentProxy && !settings.xrayCoreFile().exists()) { Toast.makeText( applicationContext, "Install the assets", Toast.LENGTH_SHORT ).show() return } saveSettings() } private fun saveSettings() { /** Basic */ settings.socksAddress = basic.findViewById(R.id.socksAddress).text.toString() settings.socksPort = basic.findViewById(R.id.socksPort).text.toString() settings.socksUsername = basic.findViewById(R.id.socksUsername).text.toString() settings.socksPassword = basic.findViewById(R.id.socksPassword).text.toString() settings.geoIpAddress = basic.findViewById(R.id.geoIpAddress).text.toString() settings.geoSiteAddress = basic.findViewById(R.id.geoSiteAddress).text.toString() settings.pingAddress = basic.findViewById(R.id.pingAddress).text.toString() settings.pingTimeout = basic.findViewById(R.id.pingTimeout).text.toString().toInt() settings.refreshLinksInterval = basic.findViewById(R.id.refreshLinksInterval).text.toString().toInt() settings.bypassLan = basic.findViewById(R.id.bypassLan).isChecked val enableIpV6 = basic.findViewById(R.id.enableIpV6).isChecked settings.socksUdp = basic.findViewById(R.id.socksUdp).isChecked settings.tun2socks = basic.findViewById(R.id.tun2socks).isChecked settings.bootAutoStart = basic.findViewById(R.id.bootAutoStart).isChecked settings.refreshLinksOnOpen = basic.findViewById(R.id.refreshLinksOnOpen).isChecked /** Advanced */ settings.primaryDns = advanced.findViewById(R.id.primaryDns).text.toString() settings.secondaryDns = advanced.findViewById(R.id.secondaryDns).text.toString() settings.primaryDnsV6 = advanced.findViewById(R.id.primaryDnsV6).text.toString() settings.secondaryDnsV6 = advanced.findViewById(R.id.secondaryDnsV6).text.toString() settings.tunName = advanced.findViewById(R.id.tunName).text.toString() settings.tunMtu = advanced.findViewById(R.id.tunMtu).text.toString().toInt() settings.tunAddress = advanced.findViewById(R.id.tunAddress).text.toString() settings.tunPrefix = advanced.findViewById(R.id.tunPrefix).text.toString().toInt() settings.tunAddressV6 = advanced.findViewById(R.id.tunAddressV6).text.toString() settings.tunPrefixV6 = advanced.findViewById(R.id.tunPrefixV6).text.toString().toInt() val hotspotInterface = advanced.findViewById(R.id.hotspotInterface).text.toString() val tetheringInterface = advanced.findViewById(R.id.tetheringInterface).text.toString() val tproxyAddress = advanced.findViewById(R.id.tproxyAddress).text.toString() val tproxyPort = advanced.findViewById(R.id.tproxyPort).text.toString().toInt() val tproxyBypassWiFi = advanced.findViewById(R.id.tproxyBypassWiFi).text .toString() .split(",") .map { it.trim() } .filter { it.isNotBlank() } .toSet() val tproxyAutoConnect = advanced.findViewById(R.id.tproxyAutoConnect).isChecked val tproxyHotspot = advanced.findViewById(R.id.tproxyHotspot).isChecked val tproxyTethering = advanced.findViewById(R.id.tproxyTethering).isChecked val transparentProxy = advanced.findViewById(R.id.transparentProxy).isChecked lifecycleScope.launch { val tproxySettingsChanged = settings.enableIpV6 != enableIpV6 || settings.hotspotInterface != hotspotInterface || settings.tetheringInterface != tetheringInterface || settings.tproxyAddress != tproxyAddress || settings.tproxyPort != tproxyPort || settings.tproxyBypassWiFi != tproxyBypassWiFi || settings.tproxyAutoConnect != tproxyAutoConnect || settings.tproxyHotspot != tproxyHotspot || settings.tproxyTethering != tproxyTethering || settings.transparentProxy != transparentProxy val stopService = tproxySettingsChanged && settings.xrayCorePid().exists() if (tproxySettingsChanged) transparentProxyHelper.kill() withContext(Dispatchers.Main) { settings.enableIpV6 = enableIpV6 settings.hotspotInterface = hotspotInterface settings.tetheringInterface = tetheringInterface settings.tproxyAddress = tproxyAddress settings.tproxyPort = tproxyPort settings.tproxyBypassWiFi = tproxyBypassWiFi settings.tproxyAutoConnect = tproxyAutoConnect settings.tproxyHotspot = tproxyHotspot settings.tproxyTethering = tproxyTethering settings.transparentProxy = transparentProxy if (stopService) TProxyService.stop(this@SettingsActivity) finish() } } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/adapter/AppsRoutingAdapter.kt ================================================ package io.github.saeeddev94.xray.adapter import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ImageView import android.widget.LinearLayout import android.widget.TextView import androidx.recyclerview.widget.RecyclerView import com.google.android.material.checkbox.MaterialCheckBox import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.dto.AppList class AppsRoutingAdapter( private var context: Context, private var apps: MutableList, private var appsRouting: MutableSet, ) : RecyclerView.Adapter() { override fun onCreateViewHolder(container: ViewGroup, type: Int): ViewHolder { val linearLayout = LinearLayout(context) val item: View = LayoutInflater.from(context).inflate( R.layout.item_recycler_exclude, linearLayout, false ) return ViewHolder(item) } override fun getItemCount(): Int { return apps.size } override fun onBindViewHolder(holder: ViewHolder, index: Int) { val app = apps[index] val isSelected = appsRouting.contains(app.packageName) holder.appIcon.setImageDrawable(app.appIcon) holder.appName.text = app.appName holder.packageName.text = app.packageName holder.isSelected.isChecked = isSelected holder.appContainer.setOnClickListener { if (isSelected) { appsRouting.remove(app.packageName) } else { appsRouting.add(app.packageName) } notifyItemChanged(index) } } class ViewHolder(item: View) : RecyclerView.ViewHolder(item) { var appContainer: LinearLayout = item.findViewById(R.id.appContainer) var appIcon: ImageView = item.findViewById(R.id.appIcon) var appName: TextView = item.findViewById(R.id.appName) var packageName: TextView = item.findViewById(R.id.packageName) var isSelected: MaterialCheckBox = item.findViewById(R.id.isSelected) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/adapter/ConfigAdapter.kt ================================================ package io.github.saeeddev94.xray.adapter import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.viewpager.widget.PagerAdapter import io.github.saeeddev94.xray.R class ConfigAdapter( private var context: Context, private var tabs: List, private var setup: (tab: String, view: View) -> Unit, ) : PagerAdapter() { override fun instantiateItem(container: ViewGroup, position: Int): Any { val view = LayoutInflater.from(context).inflate(R.layout.tab_config, container, false) container.addView(view) setup(tabs[position], view) return view } override fun getCount(): Int { return tabs.size } override fun getPageTitle(position: Int): CharSequence { return tabs[position] } override fun isViewFromObject(view: View, `object`: Any): Boolean { return view == `object` } override fun destroyItem(container: ViewGroup, position: Int, `object`: Any) { container.removeView(`object` as View) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/adapter/LinkAdapter.kt ================================================ package io.github.saeeddev94.xray.adapter import android.content.res.ColorStateList import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import android.widget.TextView import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.ListAdapter import androidx.recyclerview.widget.RecyclerView import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.database.Link class LinkAdapter : ListAdapter(diffCallback) { var onEditClick: (link: Link) -> Unit = {} var onDeleteClick: (link: Link) -> Unit = {} override fun onCreateViewHolder(parent: ViewGroup, type: Int) = LinkHolder( LayoutInflater.from(parent.context).inflate( R.layout.layout_link_item, parent, false ) ) override fun onBindViewHolder(holder: LinkHolder, position: Int) { holder.bind(position) } inner class LinkHolder(view: View) : RecyclerView.ViewHolder(view) { private val card = view.findViewById(R.id.linkCard) private val name = view.findViewById(R.id.linkName) private val type = view.findViewById(R.id.linkType) private val edit = view.findViewById(R.id.linkEdit) private val delete = view.findViewById(R.id.linkDelete) fun bind(index: Int) { val link = getItem(index) val color = if (link.isActive) R.color.btnColor else R.color.btnColorDisabled card.backgroundTintList = ColorStateList.valueOf( ContextCompat.getColor(card.context, color) ) name.text = link.name type.text = link.type.name edit.setOnClickListener { onEditClick(link) } delete.setOnClickListener { onDeleteClick(link) } } } companion object { private val diffCallback = object : DiffUtil.ItemCallback() { override fun areItemsTheSame(oldItem: Link, newItem: Link): Boolean = oldItem.id == newItem.id override fun areContentsTheSame(oldItem: Link, newItem: Link): Boolean = oldItem == newItem } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/adapter/ProfileAdapter.kt ================================================ package io.github.saeeddev94.xray.adapter import android.content.res.ColorStateList import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import android.widget.TextView import androidx.cardview.widget.CardView import androidx.core.content.ContextCompat import androidx.recyclerview.widget.RecyclerView import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.dto.ProfileList import io.github.saeeddev94.xray.helper.ProfileTouchHelper import io.github.saeeddev94.xray.viewmodel.ProfileViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch class ProfileAdapter( private val scope: CoroutineScope, private val settings: Settings, private val profileViewModel: ProfileViewModel, private val profiles: ArrayList, private val profileSelect: (index: Int, profile: ProfileList) -> Unit, private val profileEdit: (profile: ProfileList) -> Unit, private val profileDelete: (profile: ProfileList) -> Unit, ) : RecyclerView.Adapter(), ProfileTouchHelper.ProfileTouchCallback { override fun onCreateViewHolder(container: ViewGroup, type: Int): ViewHolder { val linearLayout = LinearLayout(container.context) val item: View = LayoutInflater.from(container.context).inflate( R.layout.item_recycler_main, linearLayout, false ) return ViewHolder(item) } override fun getItemCount(): Int { return profiles.size } override fun onBindViewHolder(holder: ViewHolder, index: Int) { val profile = profiles[index] val color = if (settings.selectedProfile == profile.id) R.color.primaryColor else R.color.btnColor holder.activeIndicator.backgroundTintList = ColorStateList.valueOf( ContextCompat.getColor(holder.profileCard.context, color) ) holder.profileName.text = profile.name holder.profileCard.setOnClickListener { profileSelect(index, profile) } holder.profileEdit.setOnClickListener { profileEdit(profile) } holder.profileDelete.setOnClickListener { profileDelete(profile) } } override fun onItemMoved(fromPosition: Int, toPosition: Int): Boolean { profiles.add(toPosition, profiles.removeAt(fromPosition)) notifyItemMoved(fromPosition, toPosition) if (toPosition > fromPosition) { notifyItemRangeChanged(fromPosition, toPosition - fromPosition + 1) } else { notifyItemRangeChanged(toPosition, fromPosition - toPosition + 1) } return true } override fun onItemMoveCompleted(startPosition: Int, endPosition: Int) { val isMoveUp = startPosition > endPosition val start = if (isMoveUp) profiles[endPosition + 1] else profiles[endPosition - 1] val end = profiles[endPosition] scope.launch { if (isMoveUp) profileViewModel.moveUp(start.index, end.index, end.id) else profileViewModel.moveDown(start.index, end.index, end.id) } } class ViewHolder(item: View) : RecyclerView.ViewHolder(item) { var activeIndicator: LinearLayout = item.findViewById(R.id.activeIndicator) var profileCard: CardView = item.findViewById(R.id.profileCard) var profileName: TextView = item.findViewById(R.id.profileName) var profileEdit: LinearLayout = item.findViewById(R.id.profileEdit) var profileDelete: LinearLayout = item.findViewById(R.id.profileDelete) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/adapter/SettingAdapter.kt ================================================ package io.github.saeeddev94.xray.adapter import android.content.Context import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.viewpager.widget.PagerAdapter class SettingAdapter( private var context: Context, private var tabs: List, private var layouts: List, private var callback: ViewsReady, ) : PagerAdapter() { private val views: MutableList = mutableListOf() override fun instantiateItem(container: ViewGroup, position: Int): Any { val layoutInflater = context.getSystemService(Context.LAYOUT_INFLATER_SERVICE) as LayoutInflater val view = layoutInflater.inflate(layouts[position], container, false) container.addView(view) views.add(view) if (views.size == tabs.size) { callback.rendered(views) } return view } override fun getCount(): Int { return tabs.size } override fun getPageTitle(position: Int): CharSequence { return tabs[position] } override fun isViewFromObject(view: View, `object`: Any): Boolean { return view == `object` } interface ViewsReady { fun rendered(views: List) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/component/EmptySubmitSearchView.kt ================================================ package io.github.saeeddev94.xray.component import android.annotation.SuppressLint import android.content.Context import android.util.AttributeSet import androidx.appcompat.widget.SearchView class EmptySubmitSearchView : SearchView { constructor(context: Context) : super(context) constructor(context: Context, attrs: AttributeSet) : super(context, attrs) constructor(context: Context, attrs: AttributeSet, defStyleAttr: Int) : super( context, attrs, defStyleAttr ) @SuppressLint("RestrictedApi") override fun setOnQueryTextListener(listener: OnQueryTextListener?) { super.setOnQueryTextListener(listener) val searchAutoComplete = this.findViewById(androidx.appcompat.R.id.search_src_text) searchAutoComplete.setOnEditorActionListener { _, _, _ -> listener?.onQueryTextSubmit(query.toString()) return@setOnEditorActionListener true } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/database/Config.kt ================================================ package io.github.saeeddev94.xray.database import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverter import kotlinx.parcelize.Parcelize @Parcelize @Entity(tableName = "configs") data class Config( @PrimaryKey @ColumnInfo(name = "id") var id: Int = 1, @ColumnInfo(name = "log") var log: String = "{}", @ColumnInfo(name = "dns") var dns: String = "{}", @ColumnInfo(name = "inbounds") var inbounds: String = "[]", @ColumnInfo(name = "outbounds") var outbounds: String = "[]", @ColumnInfo(name = "routing") var routing: String = "{}", @ColumnInfo(name = "log_mode") var logMode: Mode = Mode.Disable, @ColumnInfo(name = "dns_mode") var dnsMode: Mode = Mode.Disable, @ColumnInfo(name = "inbounds_mode") var inboundsMode: Mode = Mode.Disable, @ColumnInfo(name = "outbounds_mode") var outboundsMode: Mode = Mode.Disable, @ColumnInfo(name = "routing_mode") var routingMode: Mode = Mode.Disable, ) : Parcelable { enum class Mode(val value: Int) { Disable(0), Replace(1), Merge(2); class Convertor { @TypeConverter fun fromMode(mode: Mode): Int = mode.value @TypeConverter fun toMode(value: Int): Mode = entries[value] } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/database/ConfigDao.kt ================================================ package io.github.saeeddev94.xray.database import androidx.room.Dao import androidx.room.Insert import androidx.room.Query import androidx.room.Update @Dao interface ConfigDao { @Query("SELECT * FROM configs WHERE id = 1") suspend fun get(): Config? @Insert suspend fun insert(config: Config) @Update suspend fun update(config: Config) } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/database/Link.kt ================================================ package io.github.saeeddev94.xray.database import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import androidx.room.TypeConverter import kotlinx.parcelize.Parcelize @Parcelize @Entity(tableName = "links") data class Link( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") var id: Long = 0L, @ColumnInfo(name = "name") var name: String = "", @ColumnInfo(name = "address") var address: String = "", @ColumnInfo(name = "type") var type: Type = Type.Json, @ColumnInfo(name = "is_active") var isActive: Boolean = false, @ColumnInfo(name = "user_agent") var userAgent: String? = null, ) : Parcelable { enum class Type(val value: Int) { Json(0), Subscription(1); class Convertor { @TypeConverter fun fromType(type: Type): Int = type.value @TypeConverter fun toType(value: Int): Type = entries[value] } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/database/LinkDao.kt ================================================ package io.github.saeeddev94.xray.database import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Update import kotlinx.coroutines.flow.Flow @Dao interface LinkDao { @Query("SELECT * FROM links ORDER BY id ASC") fun all(): Flow> @Query("SELECT * FROM links WHERE is_active = 1 ORDER BY id ASC") fun tabs(): Flow> @Query("SELECT * FROM links WHERE is_active = 1 ORDER BY id ASC") suspend fun activeLinks(): List @Insert suspend fun insert(link: Link): Long @Update suspend fun update(link: Link) @Delete suspend fun delete(link: Link) } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/database/Profile.kt ================================================ package io.github.saeeddev94.xray.database import android.os.Parcelable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey import androidx.room.Index import androidx.room.PrimaryKey import kotlinx.parcelize.Parcelize @Parcelize @Entity( tableName = "profiles", foreignKeys = [ ForeignKey( entity = Link::class, parentColumns = ["id"], childColumns = ["link_id"], onUpdate = ForeignKey.CASCADE, onDelete = ForeignKey.CASCADE, ), ], indices = [ Index( name = "profiles_link_id_foreign", value = ["link_id"] ), ], ) data class Profile( @PrimaryKey(autoGenerate = true) @ColumnInfo(name = "id") var id: Long = 0L, @ColumnInfo(name = "link_id") var linkId: Long? = null, @ColumnInfo(name = "index") var index: Int = -1, @ColumnInfo(name = "name") var name: String = "", @ColumnInfo(name = "config") var config: String = "", ) : Parcelable ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/database/ProfileDao.kt ================================================ package io.github.saeeddev94.xray.database import androidx.room.Dao import androidx.room.Delete import androidx.room.Insert import androidx.room.Query import androidx.room.Transaction import androidx.room.Update import io.github.saeeddev94.xray.dto.ProfileList import kotlinx.coroutines.flow.Flow @Dao interface ProfileDao { @Query( "SELECT `profiles`.`id`, `profiles`.`index`, `profiles`.`name`, `profiles`.`link_id` AS `link`" + " FROM `profiles`" + " LEFT JOIN `links` ON `profiles`.`link_id` = `links`.`id`" + " WHERE `links`.`is_active` IS NULL OR `links`.`is_active` = 1" + " ORDER BY `profiles`.`index` ASC" ) fun all(): Flow> @Query("SELECT * FROM profiles WHERE link_id = :linkId ORDER BY `index` DESC") suspend fun linkProfiles(linkId: Long): List @Query("SELECT * FROM profiles WHERE `id` = :id") suspend fun find(id: Long): Profile @Insert suspend fun insert(profile: Profile): Long @Update suspend fun update(profile: Profile) @Delete suspend fun delete(profile: Profile) @Query("UPDATE profiles SET `index` = :index WHERE `id` = :id") suspend fun updateIndex(index: Int, id: Long) @Query("UPDATE profiles SET `index` = `index` + 1") suspend fun fixInsertIndex() @Query("UPDATE profiles SET `index` = `index` - 1 WHERE `index` > :index") suspend fun fixDeleteIndex(index: Int) @Query( "UPDATE profiles" + " SET `index` = `index` + 1" + " WHERE `index` >= :start" + " AND `index` < :end" + " AND `id` NOT IN (:exclude)" ) suspend fun fixMoveUpIndex(start: Int, end: Int, exclude: Long) @Query( "UPDATE profiles" + " SET `index` = `index` - 1" + " WHERE `index` > :start" + " AND `index` <= :end" + " AND `id` NOT IN (:exclude)" ) suspend fun fixMoveDownIndex(start: Int, end: Int, exclude: Long) @Transaction suspend fun create(profile: Profile) { insert(profile) fixInsertIndex() } @Transaction suspend fun remove(profile: Profile) { delete(profile) fixDeleteIndex(profile.index) } @Transaction suspend fun moveUp(start: Int, end: Int, exclude: Long) { updateIndex(start, exclude) fixMoveUpIndex(start, end, exclude) } @Transaction suspend fun moveDown(start: Int, end: Int, exclude: Long) { updateIndex(start, exclude) fixMoveDownIndex(end, start, exclude) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/database/XrayDatabase.kt ================================================ package io.github.saeeddev94.xray.database import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverters import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase @Database( entities = [ Config::class, Link::class, Profile::class, ], version = 4, exportSchema = false, ) @TypeConverters( Link.Type.Convertor::class, Config.Mode.Convertor::class, ) abstract class XrayDatabase : RoomDatabase() { abstract fun configDao(): ConfigDao abstract fun linkDao(): LinkDao abstract fun profileDao(): ProfileDao companion object { private val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(db: SupportSQLiteDatabase) { // create links table db.execSQL(""" CREATE TABLE links ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT NOT NULL, type INTEGER NOT NULL, is_active INTEGER NOT NULL ) """) // add link_id to profiles table db.execSQL("ALTER TABLE profiles ADD COLUMN link_id INTEGER") // create profiles_new table similar to profiles but with new column (link_id) db.execSQL(""" CREATE TABLE profiles_new ( id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, link_id INTEGER, "index" INTEGER NOT NULL, name TEXT NOT NULL, config TEXT NOT NULL, FOREIGN KEY (link_id) REFERENCES links(id) ON UPDATE CASCADE ON DELETE CASCADE ) """) // create index for link_id db.execSQL("CREATE INDEX profiles_link_id_foreign ON profiles_new(link_id)") // importing data from profile to profiles_new db.execSQL(""" INSERT INTO profiles_new (id, link_id, "index", name, config) SELECT id, link_id, "index", name, config FROM profiles """) // drop profiles table db.execSQL("DROP TABLE profiles") // rename profiles_new to profiles db.execSQL("ALTER TABLE profiles_new RENAME TO profiles") } } private val MIGRATION_2_3 = object : Migration(2, 3) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL("ALTER TABLE links ADD COLUMN user_agent TEXT") } } private val MIGRATION_3_4 = object : Migration(3, 4) { override fun migrate(db: SupportSQLiteDatabase) { // create config table db.execSQL(""" CREATE TABLE configs ( id INTEGER PRIMARY KEY NOT NULL, log TEXT NOT NULL, dns TEXT NOT NULL, inbounds TEXT NOT NULL, outbounds TEXT NOT NULL, routing TEXT NOT NULL, log_mode INTEGER NOT NULL, dns_mode INTEGER NOT NULL, inbounds_mode INTEGER NOT NULL, outbounds_mode INTEGER NOT NULL, routing_mode INTEGER NOT NULL ) """) } } @Volatile private var db: XrayDatabase? = null fun ref(context: Context): XrayDatabase { if (db == null) { synchronized(this) { if (db == null) { val migrations = arrayOf( MIGRATION_1_2, MIGRATION_2_3, MIGRATION_3_4, ) db = Room.databaseBuilder( context.applicationContext, XrayDatabase::class.java, "xray" ).addMigrations(*migrations).build() } } } return db!! } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/dto/AppList.kt ================================================ package io.github.saeeddev94.xray.dto import android.graphics.drawable.Drawable data class AppList( var appIcon: Drawable, var appName: String, var packageName: String, ) ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/dto/ProfileList.kt ================================================ package io.github.saeeddev94.xray.dto data class ProfileList( var id: Long, var index: Int, var name: String, var link: Long?, ) ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/dto/XrayConfig.kt ================================================ package io.github.saeeddev94.xray.dto data class XrayConfig( val dir: String, val file: String, ) ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/fragment/LinkFormFragment.kt ================================================ package io.github.saeeddev94.xray.fragment import android.app.Dialog import android.content.DialogInterface import android.os.Bundle import android.widget.EditText import android.widget.LinearLayout import android.widget.RadioButton import android.widget.RadioGroup import android.widget.Toast import androidx.fragment.app.DialogFragment import androidx.fragment.app.FragmentActivity import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.materialswitch.MaterialSwitch import com.google.android.material.radiobutton.MaterialRadioButton import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.database.Link import java.net.URI import kotlin.reflect.cast class LinkFormFragment( private val link: Link, private val onConfirm: () -> Unit, ) : DialogFragment() { override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { return openLink(requireActivity()) } override fun onDismiss(dialog: DialogInterface) { super.onDismiss(dialog) requireActivity().finish() } private fun openLink(context: FragmentActivity): Dialog = MaterialAlertDialogBuilder(context).apply { val layout = context.layoutInflater.inflate( R.layout.layout_link_form, LinearLayout(context) ) val typeRadioGroup = layout.findViewById(R.id.typeRadioGroup) val nameEditText = layout.findViewById(R.id.nameEditText) val addressEditText = layout.findViewById(R.id.addressEditText) val userAgentEditText = layout.findViewById(R.id.userAgentEditText) val isActiveSwitch = layout.findViewById(R.id.isActiveSwitch) Link.Type.entries.forEach { val radio = MaterialRadioButton(context) radio.text = it.name radio.tag = it typeRadioGroup.addView(radio) if (it == link.type) typeRadioGroup.check(radio.id) } nameEditText.setText(link.name) addressEditText.setText(link.address) userAgentEditText.setText(link.userAgent) isActiveSwitch.isChecked = if (link.id == 0L) { true } else { link.isActive } setView(layout) setTitle( if (link.id == 0L) context.getString(R.string.newLink) else context.getString(R.string.editLink) ) setPositiveButton( if (link.id == 0L) context.getString(R.string.createLink) else context.getString(R.string.updateLink) ) { _, _ -> val address = addressEditText.text.toString() val typeRadioButton = typeRadioGroup.findViewById( typeRadioGroup.checkedRadioButtonId ) val uri = runCatching { URI(address) }.getOrNull() val invalidLink = context.getString(R.string.invalidLink) val onlyHttps = context.getString(R.string.onlyHttps) if (uri == null) { Toast.makeText(context, invalidLink, Toast.LENGTH_SHORT).show() return@setPositiveButton } if (uri.scheme != "https") { Toast.makeText(context, onlyHttps, Toast.LENGTH_SHORT).show() return@setPositiveButton } link.type = Link.Type::class.cast(typeRadioButton.tag) link.name = nameEditText.text.toString() link.address = address link.userAgent = userAgentEditText.text.toString().ifBlank { null } link.isActive = isActiveSwitch.isChecked onConfirm() } setNegativeButton(context.getString(R.string.closeLink)) { _, _ -> } }.create() } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/helper/ConfigHelper.kt ================================================ package io.github.saeeddev94.xray.helper import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.database.Config import org.json.JSONObject class ConfigHelper( settings: Settings, config: Config, base: String, ) { private val base: JSONObject = JsonHelper.makeObject(base) init { process("log", config.log, config.logMode) process("dns", config.dns, config.dnsMode) process("inbounds", config.inbounds, config.inboundsMode) process("outbounds", config.outbounds, config.outboundsMode) process("routing", config.routing, config.routingMode) if (settings.tproxyHotspot || settings.tproxyTethering) sharedInbounds() } override fun toString(): String { return base.toString(4) } private fun process(key: String, config: String, mode: Config.Mode) { if (mode == Config.Mode.Disable) return if (arrayOf("inbounds", "outbounds").contains(key)) { processArray(key, config, mode) return } processObject(key, config, mode) } private fun processObject(key: String, config: String, mode: Config.Mode) { val oldValue = JsonHelper.getObject(base, key) val newValue = JsonHelper.makeObject(config) val final = if (mode == Config.Mode.Replace) newValue else JsonHelper.mergeObjects(oldValue, newValue) base.put(key, final) } private fun processArray(key: String, config: String, mode: Config.Mode) { val oldValue = JsonHelper.getArray(base, key) val newValue = JsonHelper.makeArray(config) val final = if (mode == Config.Mode.Replace) newValue else JsonHelper.mergeArrays(oldValue, newValue, "protocol") base.put(key, final) } private fun sharedInbounds() { val key = "inbounds" val inbounds = JsonHelper.getArray(base, key) for (i in 0 until inbounds.length()) { val inbound = inbounds[i] if (inbound is JSONObject && inbound.has("listen")) { inbound.remove("listen") inbounds.put(i, inbound) } } base.put(key, inbounds) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/helper/DownloadHelper.kt ================================================ package io.github.saeeddev94.xray.helper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.net.HttpURLConnection import java.net.URL class DownloadHelper( private val scope: CoroutineScope, private val url: String, private val file: File, private val callback: DownloadListener, ) { fun start() { scope.launch(Dispatchers.IO) { var input: InputStream? = null var output: OutputStream? = null var connection: HttpURLConnection? = null try { connection = URL(url).openConnection() as HttpURLConnection connection.connect() if (connection.responseCode != HttpURLConnection.HTTP_OK) { throw Exception("Expected HTTP ${HttpURLConnection.HTTP_OK} but received HTTP ${connection.responseCode}") } input = connection.inputStream output = FileOutputStream(file) val fileLength = connection.contentLength val data = ByteArray(4096) var total: Long = 0 var count: Int while (input.read(data).also { count = it } != -1) { total += count.toLong() if (fileLength > 0) { val progress = (total * 100 / fileLength).toInt() withContext(Dispatchers.Main) { callback.onProgress(progress) } } output.write(data, 0, count) } withContext(Dispatchers.Main) { callback.onComplete() } } catch (exception: Exception) { withContext(Dispatchers.Main) { callback.onError(exception) } } finally { try { output?.close() input?.close() } catch (_: IOException) { } connection?.disconnect() } } } interface DownloadListener { fun onProgress(progress: Int) fun onError(exception: Exception) fun onComplete() } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/helper/FileHelper.kt ================================================ package io.github.saeeddev94.xray.helper import java.io.File class FileHelper { companion object { fun createOrUpdate(file: File, content: String) { val fileContent = if (file.exists()) file.bufferedReader().use { it.readText() } else "" if (content != fileContent) file.writeText(content) } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/helper/HttpHelper.kt ================================================ package io.github.saeeddev94.xray.helper import io.github.saeeddev94.xray.BuildConfig import io.github.saeeddev94.xray.Settings import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.net.Authenticator import java.net.HttpURLConnection import java.net.InetSocketAddress import java.net.PasswordAuthentication import java.net.Proxy import java.net.URL class HttpHelper( val scope: CoroutineScope, val settings: Settings, ) { companion object { private fun getConnection( link: String, method: String = "GET", proxy: Proxy? = null, timeout: Int = 5000, userAgent: String? = null, ): HttpURLConnection { val url = URL(link) val connection = if (proxy == null) { url.openConnection() as HttpURLConnection } else { url.openConnection(proxy) as HttpURLConnection } connection.requestMethod = method connection.connectTimeout = timeout connection.readTimeout = timeout userAgent?.let { connection.setRequestProperty("User-Agent", it) } connection.setRequestProperty("Connection", "close") return connection } suspend fun get(link: String, userAgent: String? = null): String { return withContext(Dispatchers.IO) { val defaultUserAgent = "${BuildConfig.APPLICATION_ID}/${BuildConfig.VERSION_NAME}" val connection = getConnection(link, userAgent = userAgent ?: defaultUserAgent) var responseCode = 0 val responseBody = try { connection.connect() responseCode = connection.responseCode connection.inputStream.bufferedReader().use { it.readText() } } catch (_: Exception) { null } finally { connection.disconnect() } if (responseCode != HttpURLConnection.HTTP_OK || responseBody == null) { throw Exception("HTTP Error: $responseCode") } responseBody } } } fun measureDelay(proxy: Boolean, callback: (result: String) -> Unit) { scope.launch(Dispatchers.IO) { val start = System.currentTimeMillis() val connection = getConnection(proxy) var result = "HTTP {status}, {delay} ms" result = try { setSocksAuth(getSocksAuth()) val responseCode = connection.responseCode result.replace("{status}", "$responseCode") } catch (error: Exception) { error.message ?: "Http delay measure failed" } finally { connection.disconnect() setSocksAuth(null) } val delay = System.currentTimeMillis() - start withContext(Dispatchers.Main) { callback(result.replace("{delay}", "$delay")) } } } private suspend fun getConnection(withProxy: Boolean): HttpURLConnection { return withContext(Dispatchers.IO) { val link = settings.pingAddress val method = "HEAD" val address = InetSocketAddress(settings.socksAddress, settings.socksPort.toInt()) val proxy = if (withProxy) Proxy(Proxy.Type.SOCKS, address) else null val timeout = settings.pingTimeout * 1000 getConnection(link, method, proxy, timeout) } } private fun getSocksAuth(): Authenticator? { if ( settings.socksUsername.trim().isEmpty() || settings.socksPassword.trim().isEmpty() ) return null return object : Authenticator() { override fun getPasswordAuthentication(): PasswordAuthentication { return PasswordAuthentication( settings.socksUsername, settings.socksPassword.toCharArray() ) } } } private fun setSocksAuth(auth: Authenticator?) { Authenticator.setDefault(auth) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/helper/IntentHelper.kt ================================================ package io.github.saeeddev94.xray.helper import android.content.Intent import android.os.Build class IntentHelper { companion object { fun getParcelable(intent: Intent, name: String, clazz: Class): T? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { intent.getParcelableExtra(name, clazz) } else { @Suppress("deprecation") intent.getParcelableExtra(name) } } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/helper/JsonHelper.kt ================================================ package io.github.saeeddev94.xray.helper import org.json.JSONArray import org.json.JSONObject class JsonHelper { companion object { fun makeObject(value: String) = JSONObject(value) fun makeArray(value: String) = JSONArray(value) fun getObject(value: JSONObject, key: String) = value.optJSONObject(key) ?: JSONObject() fun getArray(value: JSONObject, key: String) = value.optJSONArray(key) ?: JSONArray() fun mergeObjects(obj1: JSONObject, obj2: JSONObject): JSONObject { val result = JSONObject(obj1.toString()) for (key in obj2.keys()) { val value2 = obj2[key] if (result.has(key)) { val value1 = result[key] when { value1 is JSONObject && value2 is JSONObject -> { result.put(key, mergeObjects(value1, value2)) } value1 is JSONArray && value2 is JSONArray -> { result.put(key, mergeArrays(value1, value2)) } else -> result.put(key, value2) } } else result.put(key, value2) } return result } fun mergeArrays(arr1: JSONArray, arr2: JSONArray, mergeKey: String = ""): JSONArray { val result = JSONArray() for (i in 0 until arr1.length()) result.put(arr1[i]) for (i in 0 until arr2.length()) { val value2 = arr2[i] if (value2 is JSONObject && value2.has(mergeKey)) { val keyValue = value2[mergeKey] var merged = false for (j in 0 until result.length()) { val value1 = result[j] if (value1 is JSONObject && value1.has(mergeKey) && value1[mergeKey] == keyValue) { result.put(j, mergeObjects(value1, value2)) merged = true break } } if (!merged) result.put(value2) } else result.put(value2) } return result } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/helper/LinkHelper.kt ================================================ package io.github.saeeddev94.xray.helper import XrayCore.XrayCore import android.util.Base64 import io.github.saeeddev94.xray.Settings import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import java.net.URI class LinkHelper( private val settings: Settings, link: String ) { private val success: Boolean private val outbound: JSONObject? private var remark: String = REMARK_DEFAULT init { val base64: String = XrayCore.json(link) val decoded = tryDecodeBase64(base64) val response = try { JSONObject(decoded) } catch (_: JSONException) { JSONObject() } val data = response.optJSONObject("data") ?: JSONObject() val outbounds = data.optJSONArray("outbounds") ?: JSONArray() success = response.optBoolean("success", false) outbound = if (outbounds.length() > 0) outbounds[0] as JSONObject else null } companion object { const val REMARK_DEFAULT = "New Profile" const val LINK_DEFAULT = "New Link" fun remark(uri: URI, default: String = ""): String { val name = uri.fragment ?: "" return name.ifEmpty { default } } fun tryDecodeBase64(value: String): String { return runCatching { val byteArray = Base64.decode(value, Base64.DEFAULT) String(byteArray) }.getOrNull() ?: value } } fun isValid(): Boolean { return success && outbound != null } fun json(): String { return config().toString(2) + "\n" } fun remark(): String { return remark } private fun log(): JSONObject { val log = JSONObject() log.put("loglevel", "warning") return log } private fun dns(): JSONObject { val dns = JSONObject() val servers = JSONArray() servers.put(settings.primaryDns) servers.put(settings.secondaryDns) dns.put("servers", servers) return dns } private fun inbounds(): JSONArray { val inbounds = JSONArray() val sniffing = JSONObject() sniffing.put("enabled", true) val sniffingDestOverride = JSONArray() sniffingDestOverride.put("http") sniffingDestOverride.put("tls") sniffingDestOverride.put("quic") sniffing.put("destOverride", sniffingDestOverride) val tproxy = JSONObject() tproxy.put("listen", settings.tproxyAddress) tproxy.put("port", settings.tproxyPort) tproxy.put("protocol", "dokodemo-door") val tproxySettings = JSONObject() tproxySettings.put("network", "tcp,udp") tproxySettings.put("followRedirect", true) val tproxySockopt = JSONObject() tproxySockopt.put("tproxy", "tproxy") val tproxyStreamSettings = JSONObject() tproxyStreamSettings.put("sockopt", tproxySockopt) tproxy.put("settings", tproxySettings) tproxy.put("sniffing", sniffing) tproxy.put("streamSettings", tproxyStreamSettings) tproxy.put("tag", "all-in") val socks = JSONObject() socks.put("listen", settings.socksAddress) socks.put("port", settings.socksPort.toInt()) socks.put("protocol", "socks") val socksSettings = JSONObject() socksSettings.put("udp", true) if ( settings.socksUsername.trim().isNotEmpty() && settings.socksPassword.trim().isNotEmpty() ) { val account = JSONObject() account.put("user", settings.socksUsername) account.put("pass", settings.socksPassword) val accounts = JSONArray() accounts.put(account) socksSettings.put("auth", "password") socksSettings.put("accounts", accounts) } socks.put("settings", socksSettings) socks.put("sniffing", sniffing) socks.put("tag", "socks") if (settings.transparentProxy) inbounds.put(tproxy) else inbounds.put(socks) return inbounds } private fun outbounds(): JSONArray { val outbounds = JSONArray() val proxy = JSONObject(outbound!!.toString()) if (proxy.has("sendThrough")) { remark = proxy.optString("sendThrough", REMARK_DEFAULT) proxy.remove("sendThrough") } proxy.put("tag", "proxy") val direct = JSONObject() direct.put("protocol", "freedom") direct.put("tag", "direct") val block = JSONObject() block.put("protocol", "blackhole") block.put("tag", "block") val dns = JSONObject() dns.put("protocol", "dns") dns.put("tag", "dns-out") outbounds.put(proxy) outbounds.put(direct) outbounds.put(block) if (settings.transparentProxy) outbounds.put(dns) return outbounds } private fun routing(): JSONObject { val routing = JSONObject() routing.put("domainStrategy", "IPIfNonMatch") val rules = JSONArray() val proxyDns = JSONObject() if (settings.transparentProxy) { val inboundTag = JSONArray() inboundTag.put("all-in") proxyDns.put("network", "udp") proxyDns.put("port", 53) proxyDns.put("inboundTag", inboundTag) proxyDns.put("outboundTag", "dns-out") } else { val proxyDnsIp = JSONArray() proxyDnsIp.put(settings.primaryDns) proxyDnsIp.put(settings.secondaryDns) proxyDns.put("ip", proxyDnsIp) proxyDns.put("port", 53) proxyDns.put("outboundTag", "proxy") } val directPrivate = JSONObject() val directPrivateIp = JSONArray() directPrivateIp.put("geoip:private") directPrivate.put("ip", directPrivateIp) directPrivate.put("outboundTag", "direct") rules.put(proxyDns) rules.put(directPrivate) routing.put("rules", rules) return routing } private fun config(): JSONObject { val config = JSONObject() config.put("log", log()) config.put("dns", dns()) config.put("inbounds", inbounds()) config.put("outbounds", outbounds()) config.put("routing", routing()) return config } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/helper/NetworkStateHelper.kt ================================================ package io.github.saeeddev94.xray.helper import com.topjohnwu.superuser.Shell import io.github.saeeddev94.xray.service.TProxyService import java.io.File class NetworkStateHelper() { fun monitor(script: File, pid: File) { if (!script.exists()) makeScript(script) val file = "/data/misc/net/rt_tables" val cmd = "nohup inotifyd ${script.absolutePath} $file:w" + " > /dev/null 2>&1 & echo $! > ${pid.absolutePath}" Shell.cmd(cmd).exec() } fun getState(): NetworkState { return NetworkState(getWifi(), getData()) } fun isOnline(state: NetworkState): Boolean { return state.wifi != null || state.data } private fun makeScript(file: File) { val pkg = TProxyService.PKG_NAME val action = TProxyService.NETWORK_UPDATE_SERVICE_ACTION_NAME val content = arrayListOf( "#!/bin/sh", "", "am start-foreground-service -n $pkg/.service.TProxyService -a $action", "", ).joinToString("\n") FileHelper.createOrUpdate(file, content) Shell.cmd("chown root:root ${file.absolutePath}").exec() Shell.cmd("chmod +x ${file.absolutePath}").exec() } private fun getWifi(): String? { val cmd = "dumpsys wifi" + " | grep 'mWifiInfo SSID'" + " | awk -F 'SSID: ' '{print $2}'" + " | awk -F ',' '{print $1}'" + " | head -n 1" val result = Shell.cmd(cmd).exec() if (!result.isSuccess || result.out.isEmpty()) return null val ssid = result.out.first() if (ssid == "") return null return ssid.trim('"') } private fun getData(): Boolean { val cmd = "settings get global mobile_data" + " && settings get global mobile_data1" + " && settings get global mobile_data2" val result = Shell.cmd(cmd).exec() if (!result.isSuccess || result.out.isEmpty()) return false return result.out.contains("1") } data class NetworkState( val wifi: String?, val data: Boolean, ) } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/helper/ProfileTouchHelper.kt ================================================ package io.github.saeeddev94.xray.helper import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView class ProfileTouchHelper(private var adapter: ProfileTouchCallback) : ItemTouchHelper.Callback() { private var startPosition: Int = -1 override fun getMovementFlags( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ): Int = makeMovementFlags(ItemTouchHelper.UP or ItemTouchHelper.DOWN, 0) override fun onMove( recyclerView: RecyclerView, source: RecyclerView.ViewHolder, target: RecyclerView.ViewHolder ): Boolean = adapter.onItemMoved(source.absoluteAdapterPosition, target.absoluteAdapterPosition) override fun onSwiped( viewHolder: RecyclerView.ViewHolder, direction: Int ) { } override fun onSelectedChanged( viewHolder: RecyclerView.ViewHolder?, actionState: Int ) { if (actionState != ItemTouchHelper.ACTION_STATE_DRAG) return startPosition = viewHolder!!.absoluteAdapterPosition } override fun clearView( recyclerView: RecyclerView, viewHolder: RecyclerView.ViewHolder ) { val endPosition = viewHolder.absoluteAdapterPosition adapter.onItemMoveCompleted(startPosition, endPosition) } interface ProfileTouchCallback { fun onItemMoved(fromPosition: Int, toPosition: Int): Boolean fun onItemMoveCompleted(startPosition: Int, endPosition: Int) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/helper/TransparentProxyHelper.kt ================================================ package io.github.saeeddev94.xray.helper import android.content.Context import com.topjohnwu.superuser.Shell import io.github.saeeddev94.xray.BuildConfig import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.service.TProxyService import java.io.FileOutputStream class TransparentProxyHelper( private val context: Context, private val settings: Settings, ) { private val networkStateHelper by lazy { NetworkStateHelper() } fun isRunning(): Boolean = settings.xrayCorePid().exists() fun startService() { makeConfig() Shell.cmd("${cmd()} service start").exec() } fun stopService() { Shell.cmd("${cmd()} service stop").exec() } fun enableProxy() { Shell.cmd("${cmd()} proxy enable").exec() } fun disableProxy() { Shell.cmd("${cmd()} proxy disable").exec() } fun refreshProxy() { Shell.cmd("${cmd()} proxy refresh").exec() } fun kill() { if (settings.networkMonitorPid().exists()) { val path = settings.networkMonitorPid().absolutePath Shell.cmd("kill $(cat $path) && rm $path").exec() } if (settings.xrayCorePid().exists()) { disableProxy() stopService() } } fun monitorNetwork() { val script = settings.networkMonitorScript() val pid = settings.networkMonitorPid() if (!settings.tproxyAutoConnect || !settings.transparentProxy || pid.exists()) return networkStateHelper.monitor(script, pid) } fun networkState(): NetworkStateHelper.NetworkState { return networkStateHelper.getState() } fun bypassWiFi(state: NetworkStateHelper.NetworkState): Boolean { val tproxyBypassWiFi = settings.tproxyBypassWiFi val ssid = state.wifi ?: "" return tproxyBypassWiFi.isNotEmpty() && tproxyBypassWiFi.contains(ssid) } fun networkUpdate(value: NetworkStateHelper.NetworkState? = null) { if (!settings.tproxyAutoConnect) return val state = value ?: networkState() val isOnline = networkStateHelper.isOnline(state) val isRunning = settings.xrayCorePid().exists() if (!isOnline || bypassWiFi(state)) { TProxyService.stop(context) return } if (!isRunning) { TProxyService.start(context, false) return } refreshProxy() } fun install() { val xrayHelper = settings.xrayHelperFile() val appVersionCode = BuildConfig.VERSION_CODE val xrayHelperVersionCode = settings.xrayHelperVersionCode if (xrayHelper.exists() && xrayHelperVersionCode == appVersionCode) return if (xrayHelper.exists()) xrayHelper.delete() context.assets.open(xrayHelper.name).use { input -> FileOutputStream(xrayHelper).use { output -> input.copyTo(output) } } Shell.cmd("chown root:root ${xrayHelper.absolutePath}").exec() Shell.cmd("chmod +x ${xrayHelper.absolutePath}").exec() settings.xrayHelperVersionCode = appVersionCode } private fun cmd(): String { return arrayListOf( settings.xrayHelperFile().absolutePath, "-c", settings.xrayHelperConfig().absolutePath, ).joinToString(" ") } private fun makeConfig() { val yml = arrayListOf( "xrayHelper:", " coreType: xray", " corePath: ${settings.xrayCoreFile().absolutePath}", " coreConfig: ${settings.xrayConfig().absolutePath}", " dataDir: ${settings.baseDir().absolutePath}", " runDir: ${settings.baseDir().absolutePath}", "proxy:", " method: tproxy", " tproxyPort: ${settings.tproxyPort}", " enableIPv6: ${settings.enableIpV6}", " mode: ${if (settings.appsRoutingMode) "blacklist" else "whitelist"}", ) val appsList = settings.appsRouting.split("\n") .map { it.trim() } .filter { it.trim().isNotBlank() } if (appsList.isNotEmpty()) { yml.add(" pkgList:") appsList.forEach { yml.add(" - $it") } } val includedInterfaces = arrayListOf() val excludedInterfaces = arrayListOf() if (settings.tproxyHotspot) includedInterfaces.add(settings.hotspotInterface) else excludedInterfaces.add(settings.hotspotInterface) if (settings.tproxyTethering) includedInterfaces.add(settings.tetheringInterface) else excludedInterfaces.add(settings.tetheringInterface) if (includedInterfaces.isNotEmpty()) { yml.add(" apList:") includedInterfaces.forEach { yml.add(" - $it") } } if (excludedInterfaces.isNotEmpty()) { yml.add(" ignoreList:") excludedInterfaces.forEach { yml.add(" - $it") } } yml.add("") FileHelper.createOrUpdate( settings.xrayHelperConfig(), yml.joinToString("\n") ) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/receiver/BootReceiver.kt ================================================ package io.github.saeeddev94.xray.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.os.SystemClock import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.helper.TransparentProxyHelper import io.github.saeeddev94.xray.service.TProxyService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class BootReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { val systemUpTime = SystemClock.elapsedRealtime() val twoMinutes = 2L * 60L * 1000L val isAppLaunch = systemUpTime > twoMinutes if ( context == null || intent == null || intent.action != Intent.ACTION_BOOT_COMPLETED || isAppLaunch ) return val settings = Settings(context) val xrayCorePid = settings.xrayCorePid() val networkMonitorPid = settings.networkMonitorPid() if (xrayCorePid.exists()) xrayCorePid.delete() if (networkMonitorPid.exists()) networkMonitorPid.delete() if (!settings.bootAutoStart) { TProxyService.stop(context) return } if (settings.transparentProxy) { val pendingResult = goAsync() val transparentProxyHelper = TransparentProxyHelper(context, settings) CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { val state = transparentProxyHelper.networkState() val bypassWiFi = transparentProxyHelper.bypassWiFi(state) transparentProxyHelper.monitorNetwork() withContext(Dispatchers.Main) { if (bypassWiFi) TProxyService.stop(context) else TProxyService.start(context, false) pendingResult.finish() } } return } TProxyService.start(context, settings.tun2socks) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/receiver/VpnActionReceiver.kt ================================================ package io.github.saeeddev94.xray.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.service.TProxyService import io.github.saeeddev94.xray.service.VpnTileService class VpnActionReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (context == null || intent == null) return val allowed = listOf( TProxyService.START_VPN_SERVICE_ACTION_NAME, TProxyService.STOP_VPN_SERVICE_ACTION_NAME, TProxyService.NEW_CONFIG_SERVICE_ACTION_NAME, ) val action = intent.action ?: "" val label = intent.getStringExtra("profile") ?: context.getString(R.string.appName) if (!allowed.contains(action)) return Intent(context, VpnTileService::class.java).also { it.putExtra("action", action) it.putExtra("label", label) context.startService(it) } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/repository/ConfigRepository.kt ================================================ package io.github.saeeddev94.xray.repository import io.github.saeeddev94.xray.database.Config import io.github.saeeddev94.xray.database.ConfigDao class ConfigRepository(private val configDao: ConfigDao) { suspend fun get(): Config { val config = configDao.get() if (config != null) return config return Config().also { configDao.insert(it) } } suspend fun update(config: Config) { configDao.update(config) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/repository/LinkRepository.kt ================================================ package io.github.saeeddev94.xray.repository import io.github.saeeddev94.xray.database.Link import io.github.saeeddev94.xray.database.LinkDao class LinkRepository(private val linkDao: LinkDao) { val all = linkDao.all() val tabs = linkDao.tabs() suspend fun activeLinks(): List { return linkDao.activeLinks() } suspend fun insert(link: Link) { linkDao.insert(link) } suspend fun update(link: Link) { linkDao.update(link) } suspend fun delete(link: Link) { linkDao.delete(link) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/repository/ProfileRepository.kt ================================================ package io.github.saeeddev94.xray.repository import io.github.saeeddev94.xray.database.Profile import io.github.saeeddev94.xray.database.ProfileDao class ProfileRepository(private val profileDao: ProfileDao) { val all = profileDao.all() suspend fun linkProfiles(linkId: Long): List { return profileDao.linkProfiles(linkId) } suspend fun find(id: Long): Profile { return profileDao.find(id) } suspend fun update(profile: Profile) { profileDao.update(profile) } suspend fun create(profile: Profile) { profileDao.create(profile) } suspend fun remove(profile: Profile) { profileDao.remove(profile) } suspend fun updateIndex(index: Int, id: Long) { profileDao.updateIndex(index, id) } suspend fun moveUp(start: Int, end: Int, exclude: Long) { profileDao.moveUp(start, end, exclude) } suspend fun moveDown(start: Int, end: Int, exclude: Long) { profileDao.moveDown(start, end, exclude) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/service/TProxyService.kt ================================================ package io.github.saeeddev94.xray.service import XrayCore.XrayCore import android.annotation.SuppressLint import android.app.Notification import android.app.NotificationChannel import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent import android.net.ConnectivityManager import android.net.Network import android.net.NetworkCapabilities import android.net.NetworkRequest import android.net.VpnService import android.os.Build import android.os.Handler import android.os.Looper import android.os.ParcelFileDescriptor import android.util.Log import android.widget.Toast import androidx.core.app.NotificationCompat import io.github.saeeddev94.xray.BuildConfig import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings import io.github.saeeddev94.xray.Xray import io.github.saeeddev94.xray.activity.MainActivity import io.github.saeeddev94.xray.database.Config import io.github.saeeddev94.xray.database.Profile import io.github.saeeddev94.xray.dto.XrayConfig import io.github.saeeddev94.xray.helper.ConfigHelper import io.github.saeeddev94.xray.helper.FileHelper import io.github.saeeddev94.xray.helper.TransparentProxyHelper import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import java.io.File import kotlin.reflect.cast @SuppressLint("VpnServicePolicy") class TProxyService : VpnService() { companion object { init { System.loadLibrary("hev-socks5-tunnel") } const val PKG_NAME = BuildConfig.APPLICATION_ID const val STATUS_VPN_SERVICE_ACTION_NAME = "$PKG_NAME.VpnStatus" const val STOP_VPN_SERVICE_ACTION_NAME = "$PKG_NAME.VpnStop" const val START_VPN_SERVICE_ACTION_NAME = "$PKG_NAME.VpnStart" const val NEW_CONFIG_SERVICE_ACTION_NAME = "$PKG_NAME.NewConfig" const val NETWORK_UPDATE_SERVICE_ACTION_NAME = "$PKG_NAME.NetworkUpdate" private const val VPN_SERVICE_NOTIFICATION_ID = 1 private const val OPEN_MAIN_ACTIVITY_ACTION_ID = 2 private const val STOP_VPN_SERVICE_ACTION_ID = 3 fun status(context: Context) = startCommand(context, STATUS_VPN_SERVICE_ACTION_NAME) fun stop(context: Context) = startCommand(context, STOP_VPN_SERVICE_ACTION_NAME) fun newConfig(context: Context) = startCommand(context, NEW_CONFIG_SERVICE_ACTION_NAME) fun start(context: Context, check: Boolean) { if (check && prepare(context) != null) { Log.e( "TProxyService", "Can't start: VpnService#prepare(): needs user permission" ) return } startCommand(context, START_VPN_SERVICE_ACTION_NAME, true) } private fun startCommand(context: Context, name: String, foreground: Boolean = false) { Intent(context, TProxyService::class.java).also { it.action = name if (foreground) { context.startForegroundService(it) } else { context.startService(it) } } } } private val notificationManager by lazy { getSystemService(NotificationManager::class.java) } private val connectivityManager by lazy { getSystemService(ConnectivityManager::class.java) } private val settings by lazy { Settings(applicationContext) } private val transparentProxyHelper by lazy { TransparentProxyHelper(this, settings) } private val configRepository by lazy { Xray::class.cast(application).configRepository } private val profileRepository by lazy { Xray::class.cast(application).profileRepository } private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private var isRunning: Boolean = false private var tunDevice: ParcelFileDescriptor? = null private var cellularCallback: ConnectivityManager.NetworkCallback? = null private var toast: Toast? = null private external fun TProxyStartService(configPath: String, fd: Int) private external fun TProxyStopService() private external fun TProxyGetStats(): LongArray override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { scope.launch { when (intent?.action) { START_VPN_SERVICE_ACTION_NAME -> start(getProfile(), globalConfigs()) NEW_CONFIG_SERVICE_ACTION_NAME -> newConfig(getProfile(), globalConfigs()) STOP_VPN_SERVICE_ACTION_NAME -> stopVPN() STATUS_VPN_SERVICE_ACTION_NAME -> broadcastStatus() NETWORK_UPDATE_SERVICE_ACTION_NAME -> transparentProxyHelper.networkUpdate() } } return START_STICKY } override fun onRevoke() { stopVPN() } override fun onDestroy() { scope.cancel() cellularCallback?.let { connectivityManager.unregisterNetworkCallback(it) } cellularCallback = null toast = null super.onDestroy() } private fun configName(profile: Profile?): String = profile?.name ?: settings.tunName private fun getIsRunning(): Boolean { return if (settings.transparentProxy) { transparentProxyHelper.isRunning() } else { isRunning } } private suspend fun getProfile(): Profile? { return if (settings.selectedProfile == 0L) { null } else { profileRepository.find(settings.selectedProfile) } } private suspend fun globalConfigs(): Config { return configRepository.get() } private fun getConfig(profile: Profile, globalConfigs: Config): XrayConfig? { val dir: File = applicationContext.filesDir val config: File = settings.xrayConfig() val configHelper = runCatching { ConfigHelper(settings, globalConfigs, profile.config) } val error: String = if (configHelper.isSuccess) { FileHelper.createOrUpdate(config, configHelper.getOrNull().toString()) XrayCore.test(dir.absolutePath, config.absolutePath) } else { configHelper.exceptionOrNull()?.message ?: getString(R.string.invalidProfile) } if (error.isNotEmpty()) { showToast(error) return null } return XrayConfig(dir.absolutePath, config.absolutePath) } private fun start(profile: Profile?, globalConfigs: Config) { if (profile == null) return getConfig(profile, globalConfigs)?.let { startXray(it) startVPN(profile) } } private fun newConfig(profile: Profile?, globalConfigs: Config) { if (!getIsRunning() || profile == null) return stopXray() getConfig(profile, globalConfigs).also { if (it == null) stopVPN() else startXray(it) }?.let { val name = configName(profile) val notification = createNotification(name) showToast(name) broadcastStart(NEW_CONFIG_SERVICE_ACTION_NAME, name) notificationManager.notify(VPN_SERVICE_NOTIFICATION_ID, notification) } } private fun startXray(config: XrayConfig) { if (settings.transparentProxy) transparentProxyHelper.startService() else XrayCore.start(config.dir, config.file) } private fun stopXray() { if (settings.transparentProxy) transparentProxyHelper.stopService() else XrayCore.stop() } private fun startVPN(profile: Profile?) { if (settings.transparentProxy) { transparentProxyHelper.enableProxy() transparentProxyHelper.monitorNetwork() } else if (settings.tun2socks) { /** Create Tun */ val tun = Builder() val tunName = getString(R.string.appName) /** Basic tun config */ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) tun.setMetered(false) tun.setMtu(settings.tunMtu) tun.setSession(tunName) /** IPv4 */ tun.addAddress(settings.tunAddress, settings.tunPrefix) tun.addDnsServer(settings.primaryDns) tun.addDnsServer(settings.secondaryDns) /** IPv6 */ if (settings.enableIpV6) { tun.addAddress(settings.tunAddressV6, settings.tunPrefixV6) tun.addDnsServer(settings.primaryDnsV6) tun.addDnsServer(settings.secondaryDnsV6) tun.addRoute("::", 0) } /** Bypass LAN (IPv4) */ if (settings.bypassLan) { settings.tunRoutes.forEach { val address = it.split('/') tun.addRoute(address[0], address[1].toInt()) } } else { tun.addRoute("0.0.0.0", 0) } /** Apps Routing */ if (settings.appsRoutingMode) tun.addDisallowedApplication(applicationContext.packageName) settings.appsRouting.split("\n").forEach { val packageName = it.trim() if (packageName.isBlank()) return@forEach if (settings.appsRoutingMode) tun.addDisallowedApplication(packageName) else tun.addAllowedApplication(packageName) } /** Build tun device */ tunDevice = tun.establish() /** Check tun device */ if (tunDevice == null) { Log.e("TProxyService", "tun#establish failed") return } /** Create, Update tun2socks config */ val tun2socksConfig = arrayListOf( "tunnel:", " name: $tunName", " mtu: ${settings.tunMtu}", "socks5:", " address: ${settings.socksAddress}", " port: ${settings.socksPort}", ) if ( settings.socksUsername.trim().isNotEmpty() && settings.socksPassword.trim().isNotEmpty() ) { tun2socksConfig.add(" username: ${settings.socksUsername}") tun2socksConfig.add(" password: ${settings.socksPassword}") } tun2socksConfig.add(if (settings.socksUdp) " udp: udp" else " udp: tcp") tun2socksConfig.add("") FileHelper.createOrUpdate( settings.tun2socksConfig(), tun2socksConfig.joinToString("\n") ) /** Start tun2socks */ TProxyStartService(settings.tun2socksConfig().absolutePath, tunDevice!!.fd) } /** Service Notification */ val name = configName(profile) startForeground(VPN_SERVICE_NOTIFICATION_ID, createNotification(name)) /** Listen for cellular changes */ if (cellularCallback == null) { val request = NetworkRequest.Builder() .addTransportType(NetworkCapabilities.TRANSPORT_CELLULAR) .build() cellularCallback = object : ConnectivityManager.NetworkCallback() { override fun onAvailable(network: Network) { this@TProxyService.transparentProxyHelper.networkUpdate() } } connectivityManager.registerNetworkCallback(request, cellularCallback!!) } /** Broadcast start event */ showToast("Start VPN") isRunning = true broadcastStart(START_VPN_SERVICE_ACTION_NAME, name) } private fun stopVPN() { if (settings.transparentProxy) { transparentProxyHelper.disableProxy() } else { TProxyStopService() runCatching { tunDevice?.close() } tunDevice = null isRunning = false } stopXray() stopForeground(STOP_FOREGROUND_REMOVE) showToast("Stop VPN") broadcastStop() stopSelf() } private fun broadcastStart(action: String, configName: String) { Intent(action).also { it.`package` = BuildConfig.APPLICATION_ID it.putExtra("profile", configName) sendBroadcast(it) } } private fun broadcastStop() { Intent(STOP_VPN_SERVICE_ACTION_NAME).also { it.`package` = BuildConfig.APPLICATION_ID sendBroadcast(it) } } private fun broadcastStatus() { Intent(STATUS_VPN_SERVICE_ACTION_NAME).also { it.`package` = BuildConfig.APPLICATION_ID it.putExtra("isRunning", getIsRunning()) sendBroadcast(it) } } private fun createNotification(name: String): Notification { val pendingActivity = PendingIntent.getActivity( applicationContext, OPEN_MAIN_ACTIVITY_ACTION_ID, Intent(applicationContext, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE ) val pendingStop = PendingIntent.getService( applicationContext, STOP_VPN_SERVICE_ACTION_ID, Intent(applicationContext, TProxyService::class.java).also { it.action = STOP_VPN_SERVICE_ACTION_NAME }, PendingIntent.FLAG_IMMUTABLE ) return NotificationCompat .Builder(applicationContext, createNotificationChannel()) .setSmallIcon(R.drawable.baseline_vpn_lock) .setContentTitle(name) .setContentIntent(pendingActivity) .addAction(0, getString(R.string.vpnStop), pendingStop) .setPriority(NotificationCompat.PRIORITY_MAX) .setOngoing(true) .build() } private fun createNotificationChannel(): String { val id = "XrayVpnServiceNotification" val name = "Xray VPN Service" val channel = NotificationChannel(id, name, NotificationManager.IMPORTANCE_LOW) notificationManager.createNotificationChannel(channel) return id } private fun showToast(message: String) { Handler(Looper.getMainLooper()).post { toast?.cancel() toast = Toast.makeText(applicationContext, message, Toast.LENGTH_SHORT).also { it.show() } } } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/service/VpnTileService.kt ================================================ package io.github.saeeddev94.xray.service import android.content.ComponentName import android.content.Intent import android.content.SharedPreferences import android.graphics.drawable.Icon import android.service.quicksettings.Tile import android.service.quicksettings.TileService import androidx.core.content.edit import io.github.saeeddev94.xray.R import io.github.saeeddev94.xray.Settings class VpnTileService : TileService() { private val settings by lazy { Settings(applicationContext) } override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { requestListeningState(this, ComponentName(this, VpnTileService::class.java)) val action = intent?.getStringExtra("action") ?: "" val label = intent?.getStringExtra("label") ?: "" val sharedPref = sharedPref() sharedPref.edit { putString("action", action) putString("label", label) } handleUpdate(action, label) return START_STICKY } override fun onStartListening() { super.onStartListening() handleUpdate() } override fun onClick() { super.onClick() val proxy = !settings.tun2socks || settings.transparentProxy when (qsTile?.state) { Tile.STATE_INACTIVE -> { TProxyService.start(applicationContext, !proxy) } Tile.STATE_ACTIVE -> TProxyService.stop(applicationContext) } } private fun handleUpdate(newAction: String? = null, newLabel: String? = null) { val sharedPref = sharedPref() val action = newAction ?: sharedPref.getString("action", "")!! val label = newLabel ?: sharedPref.getString("label", "")!! if (action.isNotEmpty() && label.isNotEmpty()) { when (action) { TProxyService.START_VPN_SERVICE_ACTION_NAME, TProxyService.NEW_CONFIG_SERVICE_ACTION_NAME -> updateTile(Tile.STATE_ACTIVE, label) TProxyService.STOP_VPN_SERVICE_ACTION_NAME -> updateTile(Tile.STATE_INACTIVE, label) } } } private fun updateTile(newState: Int, newLabel: String) { val tile = qsTile ?: return tile.apply { state = newState label = newLabel icon = Icon.createWithResource(applicationContext, R.drawable.vpn_key) updateTile() } } private fun sharedPref(): SharedPreferences { return getSharedPreferences("vpn_tile", MODE_PRIVATE) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/viewmodel/ConfigViewModel.kt ================================================ package io.github.saeeddev94.xray.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import io.github.saeeddev94.xray.Xray import io.github.saeeddev94.xray.database.Config import kotlinx.coroutines.launch class ConfigViewModel(application: Application) : AndroidViewModel(application) { private val configRepository by lazy { getApplication().configRepository } suspend fun get() = configRepository.get() fun update(config: Config) = viewModelScope.launch { configRepository.update(config) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/viewmodel/LinkViewModel.kt ================================================ package io.github.saeeddev94.xray.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import io.github.saeeddev94.xray.Xray import io.github.saeeddev94.xray.database.Link import kotlinx.coroutines.launch class LinkViewModel(application: Application) : AndroidViewModel(application) { private val linkRepository by lazy { getApplication().linkRepository } val tabs = linkRepository.tabs val links = linkRepository.all suspend fun activeLinks(): List { return linkRepository.activeLinks() } fun insert(link: Link) = viewModelScope.launch { linkRepository.insert(link) } fun update(link: Link) = viewModelScope.launch { linkRepository.update(link) } fun delete(link: Link) = viewModelScope.launch { linkRepository.delete(link) } } ================================================ FILE: app/src/main/java/io/github/saeeddev94/xray/viewmodel/ProfileViewModel.kt ================================================ package io.github.saeeddev94.xray.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import io.github.saeeddev94.xray.Xray import io.github.saeeddev94.xray.database.Profile import io.github.saeeddev94.xray.dto.ProfileList import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch class ProfileViewModel(application: Application) : AndroidViewModel(application) { private val profileRepository by lazy { getApplication().profileRepository } val profiles = profileRepository.all.flowOn(Dispatchers.IO).stateIn( viewModelScope, SharingStarted.Eagerly, listOf(), ) val filtered = MutableSharedFlow>() fun next(link: Long) = viewModelScope.launch { val all = profiles.value fixIndex(all) val list = all.filter { link == 0L || link == it.link } filtered.emit(list) } suspend fun linkProfiles(linkId: Long): List { return profileRepository.linkProfiles(linkId) } suspend fun find(id: Long): Profile { return profileRepository.find(id) } suspend fun create(profile: Profile) { return profileRepository.create(profile) } suspend fun update(profile: Profile) { profileRepository.update(profile) } suspend fun remove(profile: Profile) { profileRepository.remove(profile) } suspend fun moveUp(start: Int, end: Int, exclude: Long) { profileRepository.moveUp(start, end, exclude) } suspend fun moveDown(start: Int, end: Int, exclude: Long) { profileRepository.moveDown(start, end, exclude) } private fun fixIndex(list: List) = viewModelScope.launch { list.forEachIndexed { index, profile -> if (profile.index == index) return@forEachIndexed profileRepository.updateIndex(index, profile.id) } } } ================================================ FILE: app/src/main/jni/Android.mk ================================================ # Copyright (C) 2023 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # include $(call all-subdir-makefiles) ================================================ FILE: app/src/main/jni/Application.mk ================================================ # Copyright (C) 2023 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # APP_OPTIM := release APP_PLATFORM := android-26 APP_ABI := armeabi-v7a arm64-v8a x86 x86_64 APP_CFLAGS := -O3 -DPKGNAME=io/github/saeeddev94/xray/service APP_CPPFLAGS := -O3 -std=c++11 NDK_TOOLCHAIN_VERSION := clang LOCAL_LDFLAGS += -Wl,--build-id=none -Wl,--hash-style=gnu ================================================ FILE: app/src/main/res/drawable/baseline_adb.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_add.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_alt_route.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_config.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_content_copy.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_delete.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_done.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_download.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_edit.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_file_open.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_folder_open.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_link.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_refresh.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_settings.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_vpn_key.xml ================================================ ================================================ FILE: app/src/main/res/drawable/baseline_vpn_lock.xml ================================================ ================================================ FILE: app/src/main/res/drawable/ic_xray.xml ================================================ ================================================ FILE: app/src/main/res/drawable-v26/ic_launcher.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_apps_routing.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_assets.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_configs.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_links.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_logs.xml ================================================ ================================================ FILE: app/src/main/res/layout/activity_main.xml ================================================