├── app
├── .gitignore
├── src
│ ├── main
│ │ ├── res
│ │ │ ├── resources.properties
│ │ │ ├── mipmap-hdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_foreground.webp
│ │ │ ├── mipmap-mdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_foreground.webp
│ │ │ ├── mipmap-xhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_foreground.webp
│ │ │ ├── mipmap-xxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_foreground.webp
│ │ │ ├── mipmap-xxxhdpi
│ │ │ │ ├── ic_launcher.webp
│ │ │ │ ├── ic_launcher_round.webp
│ │ │ │ └── ic_launcher_foreground.webp
│ │ │ ├── values
│ │ │ │ ├── ic_launcher_background.xml
│ │ │ │ ├── themes.xml
│ │ │ │ └── strings.xml
│ │ │ ├── values-night
│ │ │ │ └── themes.xml
│ │ │ ├── mipmap-anydpi-v26
│ │ │ │ ├── ic_launcher.xml
│ │ │ │ └── ic_launcher_round.xml
│ │ │ ├── drawable
│ │ │ │ ├── baseline_add_24.xml
│ │ │ │ ├── ic_arrow_back_24dp.xml
│ │ │ │ ├── ic_pencil_24dp.xml
│ │ │ │ ├── ic_baseline_delete_24.xml
│ │ │ │ ├── ic_rename.xml
│ │ │ │ ├── ic_settings_24dp.xml
│ │ │ │ ├── help_24px.xml
│ │ │ │ └── ic_launcher_background.xml
│ │ │ ├── layout
│ │ │ │ ├── activity_tasker_config.xml
│ │ │ │ ├── activity_tasker_config_selector.xml
│ │ │ │ └── activity_tasker_config_stop.xml
│ │ │ ├── drawable-v24
│ │ │ │ └── ic_launcher_foreground.xml
│ │ │ └── values-zh
│ │ │ │ └── strings.xml
│ │ ├── ic_launcher-playstore.png
│ │ ├── java
│ │ │ └── io
│ │ │ │ └── github
│ │ │ │ └── acedroidx
│ │ │ │ └── frp
│ │ │ │ ├── IntentExtraKey.kt
│ │ │ │ ├── BroadcastConstant.kt
│ │ │ │ ├── ui
│ │ │ │ └── theme
│ │ │ │ │ ├── Color.kt
│ │ │ │ │ ├── Type.kt
│ │ │ │ │ └── Theme.kt
│ │ │ │ ├── ShellServiceAction.kt
│ │ │ │ ├── FrpConfig.kt
│ │ │ │ ├── FrpType.kt
│ │ │ │ ├── PreferencesKey.kt
│ │ │ │ ├── AutoStartHelper.kt
│ │ │ │ ├── ShellThread.kt
│ │ │ │ ├── FrpConfigTemplate.kt
│ │ │ │ ├── AutoStartBroadReceiver.kt
│ │ │ │ ├── FrpConfigProvider.kt
│ │ │ │ ├── FrpTileService.kt
│ │ │ │ ├── AboutActivity.kt
│ │ │ │ ├── tasker
│ │ │ │ ├── TaskerStartFrpAction.kt
│ │ │ │ └── TaskerStopFrpAction.kt
│ │ │ │ ├── ConfigActivity.kt
│ │ │ │ └── OnboardingActivity.kt
│ │ ├── assets
│ │ │ └── examples
│ │ │ │ ├── zh-cn
│ │ │ │ ├── frps
│ │ │ │ │ └── 1.frps.toml
│ │ │ │ └── frpc
│ │ │ │ │ ├── 1.ssh.toml
│ │ │ │ │ ├── 2.stcp.proxies.toml
│ │ │ │ │ ├── 3.stcp.visitors.toml
│ │ │ │ │ ├── 4.xtcp.proxies.toml
│ │ │ │ │ └── 5.xtcp.visitors.toml
│ │ │ │ └── en
│ │ │ │ ├── frps
│ │ │ │ └── 1.frps.toml
│ │ │ │ └── frpc
│ │ │ │ ├── 1.ssh.toml
│ │ │ │ ├── 2.stcp.proxies.toml
│ │ │ │ ├── 3.stcp.visitors.toml
│ │ │ │ ├── 4.xtcp.proxies.toml
│ │ │ │ └── 5.xtcp.visitors.toml
│ │ └── AndroidManifest.xml
│ ├── test
│ │ └── java
│ │ │ └── io
│ │ │ └── github
│ │ │ └── acedroidx
│ │ │ └── frp
│ │ │ └── ExampleUnitTest.kt
│ └── androidTest
│ │ └── java
│ │ └── io
│ │ └── github
│ │ └── acedroidx
│ │ └── frp
│ │ └── ExampleInstrumentedTest.kt
├── proguard-rules.pro
├── release
│ └── output-metadata.json
└── build.gradle.kts
├── icon.png
├── .gitattributes
├── image
├── image1.png
├── image2.png
├── image1_en.png
└── image2_en.png
├── keystore.example.properties
├── AGENTS.md
├── gradle
└── wrapper
│ ├── gradle-wrapper.jar
│ └── gradle-wrapper.properties
├── scripts
├── README.md
├── README_en.md
├── update_frp_binaries.ps1
└── update_frp_binaries.sh
├── gradle.properties
├── settings.gradle.kts
├── .gitignore
├── gradlew.bat
├── README.md
├── .github
└── workflows
│ └── android.yml
├── gradlew
├── README_en.md
└── LICENSE
/app/.gitignore:
--------------------------------------------------------------------------------
1 | /build
--------------------------------------------------------------------------------
/app/src/main/res/resources.properties:
--------------------------------------------------------------------------------
1 | unqualifiedResLocale=en-US
--------------------------------------------------------------------------------
/icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/icon.png
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | # Auto detect text files and perform LF normalization
2 | * text=auto
3 |
--------------------------------------------------------------------------------
/image/image1.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/image/image1.png
--------------------------------------------------------------------------------
/image/image2.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/image/image2.png
--------------------------------------------------------------------------------
/image/image1_en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/image/image1_en.png
--------------------------------------------------------------------------------
/image/image2_en.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/image/image2_en.png
--------------------------------------------------------------------------------
/keystore.example.properties:
--------------------------------------------------------------------------------
1 | storeFile=C:\\path\\to\\Keystore.jks
2 | storePassword=
3 | keyAlias=
4 | keyPassword=
--------------------------------------------------------------------------------
/AGENTS.md:
--------------------------------------------------------------------------------
1 | 编码规范:
2 | 1. 适当添加中文的代码注释,尤其是复杂逻辑部分,并保持注释更新
3 | 2. 遵循项目现有的代码风格和命名规范
4 | 3. 如果你不确定外部库的使用方式,请使用context7工具进行搜索
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.jar:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/gradle/wrapper/gradle-wrapper.jar
--------------------------------------------------------------------------------
/app/src/main/ic_launcher-playstore.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/ic_launcher-playstore.png
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/IntentExtraKey.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | object IntentExtraKey {
4 | const val FrpConfig = "FrpConfig"
5 | }
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/AceDroidX/frp-Android/HEAD/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
--------------------------------------------------------------------------------
/app/src/main/res/values/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | #FFFFFF
4 |
--------------------------------------------------------------------------------
/app/src/main/res/values-night/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/values/themes.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/assets/examples/zh-cn/frps/1.frps.toml:
--------------------------------------------------------------------------------
1 | # frps 配置示例
2 | # https://gofrp.org/zh-cn/docs/examples/ssh/
3 | bindPort = 7000
4 |
5 | [log]
6 | level = "debug"
7 | disablePrintColor = true
8 |
9 | # 推荐使用 token 进行客户端认证,防止未授权的frpc连接到frps
10 | # [auth]
11 | # token = ""
--------------------------------------------------------------------------------
/gradle/wrapper/gradle-wrapper.properties:
--------------------------------------------------------------------------------
1 | #Thu Mar 24 09:47:16 CST 2022
2 | distributionBase=GRADLE_USER_HOME
3 | distributionUrl=https\://mirrors.cloud.tencent.com/gradle/gradle-8.13-bin.zip
4 | distributionPath=wrapper/dists
5 | zipStorePath=wrapper/dists
6 | zipStoreBase=GRADLE_USER_HOME
7 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/BroadcastConstant.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | object BroadcastAction {
4 | const val START = "io.github.acedroidx.frp.START"
5 | const val STOP = "io.github.acedroidx.frp.STOP"
6 | }
7 |
8 | object BroadcastExtraKey {
9 | const val TYPE = "TYPE"
10 | const val NAME = "NAME"
11 | }
--------------------------------------------------------------------------------
/app/src/main/assets/examples/en/frps/1.frps.toml:
--------------------------------------------------------------------------------
1 | # frps configuration example
2 | # https://gofrp.org/zh-cn/docs/examples/ssh/
3 | bindPort = 7000
4 |
5 | [log]
6 | level = "debug"
7 | disablePrintColor = true
8 |
9 | # It is recommended to use token for client authentication to prevent unauthorized frpc connections to frps
10 | # [auth]
11 | # token = ""
12 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/baseline_add_24.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_arrow_back_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/ui/theme/Color.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp.ui.theme
2 |
3 | import androidx.compose.ui.graphics.Color
4 |
5 | val Purple80 = Color(0xFFD0BCFF)
6 | val PurpleGrey80 = Color(0xFFCCC2DC)
7 | val Pink80 = Color(0xFFEFB8C8)
8 |
9 | val Purple40 = Color(0xFF6650a4)
10 | val PurpleGrey40 = Color(0xFF625b71)
11 | val Pink40 = Color(0xFF7D5260)
--------------------------------------------------------------------------------
/app/src/main/assets/examples/zh-cn/frpc/1.ssh.toml:
--------------------------------------------------------------------------------
1 | # tcp 转发示例
2 | # https://gofrp.org/zh-cn/docs/examples/ssh/
3 | serverAddr = ""
4 | serverPort = 7000
5 | dnsServer = "114.114.114.114"
6 | loginFailExit = false
7 |
8 | [log]
9 | level = "debug"
10 | disablePrintColor = true
11 |
12 | [[proxies]]
13 | name = "ssh"
14 | type = "tcp"
15 | localIP = "127.0.0.1"
16 | localPort = 22
17 | remotePort = 6000
18 |
--------------------------------------------------------------------------------
/app/src/main/assets/examples/en/frpc/1.ssh.toml:
--------------------------------------------------------------------------------
1 | # TCP forwarding example
2 | # https://gofrp.org/zh-cn/docs/examples/ssh/
3 | serverAddr = ""
4 | serverPort = 7000
5 | dnsServer = "114.114.114.114"
6 | loginFailExit = false
7 |
8 | [log]
9 | level = "debug"
10 | disablePrintColor = true
11 |
12 | [[proxies]]
13 | name = "ssh"
14 | type = "tcp"
15 | localIP = "127.0.0.1"
16 | localPort = 22
17 | remotePort = 6000
18 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_pencil_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
--------------------------------------------------------------------------------
/app/src/main/assets/examples/zh-cn/frpc/2.stcp.proxies.toml:
--------------------------------------------------------------------------------
1 | # stcp 转发示例 - 被连接端
2 | # https://gofrp.org/zh-cn/docs/examples/stcp/
3 | serverAddr = ""
4 | serverPort = 7000
5 | dnsServer = "114.114.114.114"
6 | loginFailExit = false
7 |
8 | [log]
9 | level = "debug"
10 | disablePrintColor = true
11 |
12 | [[proxies]]
13 | name = "secret_ssh"
14 | type = "stcp"
15 | # 只有与此处设置的 secretKey 一致的用户才能访问此服务
16 | secretKey = "abcdefg"
17 | localIP = "127.0.0.1"
18 | localPort = 22
--------------------------------------------------------------------------------
/app/src/test/java/io/github/acedroidx/frp/ExampleUnitTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import org.junit.Test
4 |
5 | import org.junit.Assert.*
6 |
7 | /**
8 | * Example local unit test, which will execute on the development machine (host).
9 | *
10 | * See [testing documentation](http://d.android.com/tools/testing).
11 | */
12 | class ExampleUnitTest {
13 | @Test
14 | fun addition_isCorrect() {
15 | assertEquals(4, 2 + 2)
16 | }
17 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_baseline_delete_24.xml:
--------------------------------------------------------------------------------
1 |
7 |
10 |
11 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_rename.xml:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/ShellServiceAction.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | object ShellServiceAction {
4 | const val START = "io.github.acedroidx.frp.START"
5 | // const val AUTO_START = "io.github.acedroidx.frp.AUTO_START"
6 | const val STOP = "io.github.acedroidx.frp.STOP"
7 | const val STOP_ALL = "io.github.acedroidx.frp.STOP_ALL"
8 | // const val PAUSE = "io.github.acedroidx.frp.PAUSE"
9 | // const val CONTINUE = "io.github.acedroidx.frp.CONTINUE"
10 | }
--------------------------------------------------------------------------------
/app/src/main/assets/examples/en/frpc/2.stcp.proxies.toml:
--------------------------------------------------------------------------------
1 | # stcp forwarding example - server side
2 | # https://gofrp.org/zh-cn/docs/examples/stcp/
3 | serverAddr = ""
4 | serverPort = 7000
5 | dnsServer = "114.114.114.114"
6 | loginFailExit = false
7 |
8 | [log]
9 | level = "debug"
10 | disablePrintColor = true
11 |
12 | [[proxies]]
13 | name = "secret_ssh"
14 | type = "stcp"
15 | # Only users with a matching secretKey can access this service
16 | secretKey = "abcdefg"
17 | localIP = "127.0.0.1"
18 | localPort = 22
19 |
--------------------------------------------------------------------------------
/app/src/main/assets/examples/zh-cn/frpc/3.stcp.visitors.toml:
--------------------------------------------------------------------------------
1 | # stcp 转发示例 - 连接端
2 | # https://gofrp.org/zh-cn/docs/examples/stcp/
3 | serverAddr = ""
4 | serverPort = 7000
5 | dnsServer = "114.114.114.114"
6 | loginFailExit = false
7 |
8 | [log]
9 | level = "debug"
10 | disablePrintColor = true
11 |
12 | [[visitors]]
13 | name = "secret_ssh_visitor"
14 | type = "stcp"
15 | # 要访问的 stcp 代理的名字
16 | serverName = "secret_ssh"
17 | secretKey = "abcdefg"
18 | # 绑定本地端口以访问 SSH 服务
19 | bindAddr = "127.0.0.1"
20 | bindPort = 6000
21 |
--------------------------------------------------------------------------------
/app/src/main/assets/examples/zh-cn/frpc/4.xtcp.proxies.toml:
--------------------------------------------------------------------------------
1 | # xtcp 转发示例 - 被连接端
2 | # https://gofrp.org/zh-cn/docs/examples/xtcp/
3 | serverAddr = ""
4 | serverPort = 7000
5 | dnsServer = "114.114.114.114"
6 | loginFailExit = false
7 | # 如果默认的 STUN 服务器不可用,可以配置一个新的 STUN 服务器
8 | # natHoleStunServer = "xxx"
9 |
10 | [log]
11 | level = "debug"
12 | disablePrintColor = true
13 |
14 | [[proxies]]
15 | name = "p2p_ssh"
16 | type = "xtcp"
17 | # 只有共享密钥 (secretKey) 与服务器端一致的用户才能访问该服务
18 | secretKey = "abcdefg"
19 | localIP = "127.0.0.1"
20 | localPort = 22
21 |
--------------------------------------------------------------------------------
/app/src/main/assets/examples/en/frpc/3.stcp.visitors.toml:
--------------------------------------------------------------------------------
1 | # stcp forwarding example - client side
2 | # https://gofrp.org/zh-cn/docs/examples/stcp/
3 | serverAddr = ""
4 | serverPort = 7000
5 | dnsServer = "114.114.114.114"
6 | loginFailExit = false
7 |
8 | [log]
9 | level = "debug"
10 | disablePrintColor = true
11 |
12 | [[visitors]]
13 | name = "secret_ssh_visitor"
14 | type = "stcp"
15 | # Name of the stcp proxy to access
16 | serverName = "secret_ssh"
17 | secretKey = "abcdefg"
18 | # Local bind address/port to access the SSH service
19 | bindAddr = "127.0.0.1"
20 | bindPort = 6000
21 |
--------------------------------------------------------------------------------
/app/src/main/assets/examples/en/frpc/4.xtcp.proxies.toml:
--------------------------------------------------------------------------------
1 | # xtcp forwarding example - server side
2 | # https://gofrp.org/zh-cn/docs/examples/xtcp/
3 | serverAddr = ""
4 | serverPort = 7000
5 | dnsServer = "114.114.114.114"
6 | loginFailExit = false
7 | # If the default STUN server is unavailable, configure a new one
8 | # natHoleStunServer = "xxx"
9 |
10 | [log]
11 | level = "debug"
12 | disablePrintColor = true
13 |
14 | [[proxies]]
15 | name = "p2p_ssh"
16 | type = "xtcp"
17 | # Only users with the same shared secret (secretKey) as the server can access this service
18 | secretKey = "abcdefg"
19 | localIP = "127.0.0.1"
20 | localPort = 22
21 |
--------------------------------------------------------------------------------
/app/src/main/assets/examples/zh-cn/frpc/5.xtcp.visitors.toml:
--------------------------------------------------------------------------------
1 | # xtcp 转发示例 - 连接端
2 | # https://gofrp.org/zh-cn/docs/examples/xtcp/
3 | serverAddr = ""
4 | serverPort = 7000
5 | dnsServer = "114.114.114.114"
6 | loginFailExit = false
7 | # 如果默认的 STUN 服务器不可用,可以配置一个新的 STUN 服务器
8 | # natHoleStunServer = "xxx"
9 |
10 | [log]
11 | level = "debug"
12 | disablePrintColor = true
13 |
14 | [[visitors]]
15 | name = "p2p_ssh_visitor"
16 | type = "xtcp"
17 | # 要访问的 P2P 代理的名称
18 | serverName = "p2p_ssh"
19 | secretKey = "abcdefg"
20 | # 绑定本地端口以访问 SSH 服务
21 | bindAddr = "127.0.0.1"
22 | bindPort = 6000
23 | # 如果需要自动保持隧道打开,将其设置为 true
24 | # keepTunnelOpen = false
25 |
--------------------------------------------------------------------------------
/app/src/main/assets/examples/en/frpc/5.xtcp.visitors.toml:
--------------------------------------------------------------------------------
1 | # xtcp forwarding example - client side
2 | # https://gofrp.org/zh-cn/docs/examples/xtcp/
3 | serverAddr = ""
4 | serverPort = 7000
5 | dnsServer = "114.114.114.114"
6 | loginFailExit = false
7 | # If the default STUN server is unavailable, configure a new one
8 | # natHoleStunServer = "xxx"
9 |
10 | [log]
11 | level = "debug"
12 | disablePrintColor = true
13 |
14 | [[visitors]]
15 | name = "p2p_ssh_visitor"
16 | type = "xtcp"
17 | # Name of the P2P proxy to access
18 | serverName = "p2p_ssh"
19 | secretKey = "abcdefg"
20 | # Local bind address/port to access the SSH service
21 | bindAddr = "127.0.0.1"
22 | bindPort = 6000
23 | # Set to true to keep the tunnel open automatically if required
24 | # keepTunnelOpen = false
25 |
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_tasker_config.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
15 |
16 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/app/src/androidTest/java/io/github/acedroidx/frp/ExampleInstrumentedTest.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import androidx.test.platform.app.InstrumentationRegistry
4 | import androidx.test.ext.junit.runners.AndroidJUnit4
5 |
6 | import org.junit.Test
7 | import org.junit.runner.RunWith
8 |
9 | import org.junit.Assert.*
10 |
11 | /**
12 | * Instrumented test, which will execute on an Android device.
13 | *
14 | * See [testing documentation](http://d.android.com/tools/testing).
15 | */
16 | @RunWith(AndroidJUnit4::class)
17 | class ExampleInstrumentedTest {
18 | @Test
19 | fun useAppContext() {
20 | // Context of the app under test.
21 | val appContext = InstrumentationRegistry.getInstrumentation().targetContext
22 | assertEquals("io.github.acedroidx.frp", appContext.packageName)
23 | }
24 | }
--------------------------------------------------------------------------------
/app/proguard-rules.pro:
--------------------------------------------------------------------------------
1 | # Add project specific ProGuard rules here.
2 | # You can control the set of applied configuration files using the
3 | # proguardFiles setting in build.gradle.kts.
4 | #
5 | # For more details, see
6 | # http://developer.android.com/guide/developing/tools/proguard.html
7 |
8 | # If your project uses WebView with JS, uncomment the following
9 | # and specify the fully qualified class name to the JavaScript interface
10 | # class:
11 | #-keepclassmembers class fqcn.of.javascript.interface.for.webview {
12 | # public *;
13 | #}
14 |
15 | # Uncomment this to preserve the line number information for
16 | # debugging stack traces.
17 | #-keepattributes SourceFile,LineNumberTable
18 |
19 | # If you keep the line number information, uncomment this to
20 | # hide the original source file name.
21 | #-renamesourcefileattribute SourceFile
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/FrpConfig.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import android.content.Context
4 | import android.os.Parcelable
5 | import kotlinx.parcelize.Parcelize
6 | import java.io.File
7 |
8 | @Parcelize
9 | data class FrpConfig(
10 | val type: FrpType,
11 | val fileName: String,
12 | ) : Parcelable {
13 | override fun toString(): String {
14 | return "[$type]$fileName"
15 | }
16 |
17 | fun getDir(context: Context): File {
18 | return this.type.getDir(context)
19 | }
20 |
21 | fun getFile(context: Context): File {
22 | return File(this.getDir(context), this.fileName)
23 | }
24 |
25 | fun getLogDir(context: Context): File {
26 | return File(context.cacheDir, "logs/${this.type.typeName}")
27 | }
28 |
29 | fun getLogFile(context: Context): File {
30 | val logFileName = this.fileName.replace(".toml", ".log")
31 | return File(this.getLogDir(context), logFileName)
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/FrpType.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import android.content.Context
4 | import java.io.File
5 |
6 | enum class FrpType(val typeName: String) {
7 | FRPC("frpc"), FRPS("frps");
8 |
9 | fun getDir(context: Context): File {
10 | return File(context.filesDir, this.typeName)
11 | }
12 |
13 | fun getLibName(): String {
14 | return when (this) {
15 | FRPC -> BuildConfig.FrpcFileName
16 | FRPS -> BuildConfig.FrpsFileName
17 | }
18 | }
19 |
20 | fun getAutoStartPreferencesKey(): String {
21 | return when (this) {
22 | FRPC -> PreferencesKey.AUTO_START_FRPC_LIST
23 | FRPS -> PreferencesKey.AUTO_START_FRPS_LIST
24 | }
25 | }
26 |
27 | fun getConfigAssetsName(): String {
28 | return when (this) {
29 | FRPC -> BuildConfig.FrpcConfigFileName
30 | FRPS -> BuildConfig.FrpsConfigFileName
31 | }
32 | }
33 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/PreferencesKey.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | object PreferencesKey {
4 | const val AUTO_START = "auto_start"
5 | const val AUTO_START_FRPC_LIST = "auto_start_frpc_list"
6 | const val AUTO_START_FRPS_LIST = "auto_start_frps_list"
7 | const val AUTO_START_LAUNCH = "auto_start_launch"
8 | const val AUTO_START_BROADCAST = "auto_start_broadcast"
9 | const val AUTO_STOP_BROADCAST = "auto_stop_broadcast"
10 | const val AUTO_START_BROADCAST_EXTRA = "auto_start_broadcast_extra"
11 | const val FRP_VERSION = "frp_version"
12 | const val THEME_MODE = "theme_mode"
13 | const val ALLOW_TASKER = "allow_tasker"
14 | const val EXCLUDE_FROM_RECENTS = "exclude_from_recents"
15 | const val HIDE_SERVICE_TOAST = "hide_service_toast"
16 | const val ALLOW_CONFIG_READ = "allow_config_read"
17 | const val ALLOW_CONFIG_WRITE = "allow_config_write"
18 | const val QUICK_TILE_CONFIG_TYPE = "quick_tile_config_type"
19 | const val QUICK_TILE_CONFIG_NAME = "quick_tile_config_name"
20 | const val FIRST_LAUNCH_DONE = "first_launch_done"
21 | }
--------------------------------------------------------------------------------
/app/src/main/res/drawable-v24/ic_launcher_foreground.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
10 |
11 |
14 |
15 |
18 |
19 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/ui/theme/Type.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp.ui.theme
2 |
3 | import androidx.compose.material3.Typography
4 | import androidx.compose.ui.text.TextStyle
5 | import androidx.compose.ui.text.font.FontFamily
6 | import androidx.compose.ui.text.font.FontWeight
7 | import androidx.compose.ui.unit.sp
8 |
9 | // Set of Material typography styles to start with
10 | val Typography = Typography(
11 | bodyLarge = TextStyle(
12 | fontFamily = FontFamily.Default,
13 | fontWeight = FontWeight.Normal,
14 | fontSize = 16.sp,
15 | lineHeight = 24.sp,
16 | letterSpacing = 0.5.sp
17 | )
18 | /* Other default text styles to override
19 | titleLarge = TextStyle(
20 | fontFamily = FontFamily.Default,
21 | fontWeight = FontWeight.Normal,
22 | fontSize = 22.sp,
23 | lineHeight = 28.sp,
24 | letterSpacing = 0.sp
25 | ),
26 | labelSmall = TextStyle(
27 | fontFamily = FontFamily.Default,
28 | fontWeight = FontWeight.Medium,
29 | fontSize = 11.sp,
30 | lineHeight = 16.sp,
31 | letterSpacing = 0.5.sp
32 | )
33 | */
34 | )
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_settings_24dp.xml:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/scripts/README.md:
--------------------------------------------------------------------------------
1 | # 更新 frp 二进制文件
2 |
3 | 此脚本 `update_frp_binaries.sh` 会获取 [fatedier/frp](https://github.com/fatedier/frp) 的最新 release(或使用指定的 tag),并从预编译的压缩包中提取 `frpc` / `frps` 可执行文件,放置到 Android 项目的 `jniLibs` 目录中,支持以下架构映射:
4 |
5 | - `android_arm64` -> `app/src/main/jniLibs/arm64-v8a/`
6 | - `linux_amd64` -> `app/src/main/jniLibs/x86_64/`
7 | - `linux_arm` -> `app/src/main/jniLibs/armeabi-v7a/`
8 |
9 | 提取的文件会被复制为 `libfrpc.so` 与 `libfrps.so`,并保留可执行权限(即 `chmod +x`)。
10 |
11 | 先决条件
12 | - 系统需安装 `curl`、`jq`、`tar` 和 `bash`。
13 |
14 | Linux 使用示例
15 | ```
16 | # 下载最新 release 并更新 jniLibs
17 | ./scripts/update_frp_binaries.sh
18 |
19 | # 指定 release tag
20 | ./scripts/update_frp_binaries.sh --tag v0.65.0
21 |
22 | # 仅模拟运行(不会下载或写入文件):
23 | ./scripts/update_frp_binaries.sh --dry-run
24 |
25 | # 指定自定义目标基础目录
26 | ./scripts/update_frp_binaries.sh --dest app/src/main/jniLibs_custom
27 |
28 | # 使用 GitHub Token 提升 API 请求配额
29 | ./scripts/update_frp_binaries.sh --token
30 | ```
31 |
32 | Windows PowerShell 使用示例:
33 | ```
34 | # 下载最新 release 并更新 jniLibs
35 | pwsh ./scripts/update_frp_binaries.ps1
36 |
37 | # 指定 release tag
38 | pwsh ./scripts/update_frp_binaries.ps1 -Tag v0.65.0
39 |
40 | # 模拟运行(不会写入)
41 | pwsh ./scripts/update_frp_binaries.ps1 -DryRun
42 | ```
43 |
44 |
--------------------------------------------------------------------------------
/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 | # IDE (e.g. Android Studio) users:
3 | # Gradle settings configured through the IDE *will override*
4 | # any settings specified in this file.
5 | # For more details on how to configure your build environment visit
6 | # http://www.gradle.org/docs/current/userguide/build_environment.html
7 | # Specifies the JVM arguments used for the daemon process.
8 | # The setting is particularly useful for tweaking memory settings.
9 | org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
10 | # When configured, Gradle will run in incubating parallel mode.
11 | # This option should only be used with decoupled projects. More details, visit
12 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
13 | # org.gradle.parallel=true
14 | # AndroidX package structure to make it clearer which packages are bundled with the
15 | # Android operating system, and which are packaged with your app"s APK
16 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
17 | android.useAndroidX=true
18 | # Automatically convert third-party libraries to use AndroidX
19 | android.enableJetifier=true
20 | # Kotlin code style for this project: "official" or "obsolete":
21 | kotlin.code.style=official
22 | android.nonTransitiveRClass=false
23 | android.nonFinalResIds=false
24 | org.gradle.configuration-cache=true
--------------------------------------------------------------------------------
/app/src/main/res/drawable/help_24px.xml:
--------------------------------------------------------------------------------
1 |
8 |
11 |
12 |
--------------------------------------------------------------------------------
/scripts/README_en.md:
--------------------------------------------------------------------------------
1 | # Update frp Binaries
2 |
3 | This script `update_frp_binaries.sh` fetches the latest release of [fatedier/frp](https://github.com/fatedier/frp) and extracts prebuilt `frpc`/`frps` executables for the following architectures:
4 |
5 | - `android_arm64` -> `app/src/main/jniLibs/arm64-v8a/`
6 | - `linux_amd64` -> `app/src/main/jniLibs/x86_64/`
7 | - `linux_arm` -> `app/src/main/jniLibs/armeabi-v7a/`
8 |
9 | Files will be copied as `libfrpc.so` and `libfrps.so` in the target directories (executable bit preserved).
10 |
11 | Prerequisites
12 | - `curl`, `jq`, `tar` and `bash` must be available on your system.
13 |
14 | Linux Usage Examples:
15 | ```
16 | # Download latest release and place in jniLibs
17 | ./scripts/update_frp_binaries.sh
18 |
19 | # Specify release tag
20 | ./scripts/update_frp_binaries.sh --tag v0.65.0
21 |
22 | # Dry-run (safe):
23 | ./scripts/update_frp_binaries.sh --dry-run
24 |
25 | # Use a custom destination base directory
26 | ./scripts/update_frp_binaries.sh --dest app/src/main/jniLibs_custom
27 |
28 | # Use a GitHub token to increase API quota
29 | ./scripts/update_frp_binaries.sh --token
30 | ```
31 |
32 | Windows PowerShell Usage Examples:
33 | ```
34 | # Download latest release
35 | pwsh ./scripts/update_frp_binaries.ps1
36 |
37 | # Use a specific release tag
38 | pwsh ./scripts/update_frp_binaries.ps1 -Tag v0.65.0
39 |
40 | # Dry-run:
41 | pwsh ./scripts/update_frp_binaries.ps1 -DryRun
42 | ```
43 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/AutoStartHelper.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import android.content.Context
4 |
5 | object AutoStartHelper {
6 | fun loadAutoStartConfigs(
7 | context: Context, typeFilter: FrpType? = null, nameFilter: String? = null
8 | ): List {
9 | val preferences = context.getSharedPreferences("data", Context.MODE_PRIVATE)
10 | val result = mutableListOf()
11 |
12 | fun addConfigs(type: FrpType, key: String) {
13 | if (typeFilter != null && typeFilter != type) return
14 | val names = preferences.getStringSet(key, emptySet()) ?: emptySet()
15 | names.forEach { name ->
16 | if (nameFilter != null && nameFilter != name) return@forEach
17 | val config = FrpConfig(type, name)
18 | if (config.getFile(context).exists()) {
19 | result.add(config)
20 | }
21 | }
22 | }
23 |
24 | addConfigs(FrpType.FRPC, PreferencesKey.AUTO_START_FRPC_LIST)
25 | addConfigs(FrpType.FRPS, PreferencesKey.AUTO_START_FRPS_LIST)
26 |
27 | return result
28 | }
29 |
30 | fun parseType(typeValue: String?): FrpType? {
31 | return when (typeValue?.lowercase()) {
32 | FrpType.FRPC.typeName -> FrpType.FRPC
33 | FrpType.FRPS.typeName -> FrpType.FRPS
34 | else -> null
35 | }
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/settings.gradle.kts:
--------------------------------------------------------------------------------
1 | pluginManagement {
2 |
3 | /**
4 | * The pluginManagement.repositories block configures the
5 | * repositories Gradle uses to search or download the Gradle plugins and
6 | * their transitive dependencies. Gradle pre-configures support for remote
7 | * repositories such as JCenter, Maven Central, and Ivy. You can also use
8 | * local repositories or define your own remote repositories. Here we
9 | * define the Gradle Plugin Portal, Google's Maven repository,
10 | * and the Maven Central Repository as the repositories Gradle should use to look for its
11 | * dependencies.
12 | */
13 |
14 | repositories {
15 | gradlePluginPortal()
16 | google()
17 | mavenCentral()
18 | }
19 | }
20 | dependencyResolutionManagement {
21 |
22 | /**
23 | * The dependencyResolutionManagement.repositories
24 | * block is where you configure the repositories and dependencies used by
25 | * all modules in your project, such as libraries that you are using to
26 | * create your application. However, you should configure module-specific
27 | * dependencies in each module-level build.gradle file. For new projects,
28 | * Android Studio includes Google's Maven repository and the Maven Central
29 | * Repository by default, but it does not configure any dependencies (unless
30 | * you select a template that requires some).
31 | */
32 |
33 | repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
34 | repositories {
35 | google()
36 | mavenCentral()
37 | }
38 | }
39 |
40 | include(":app")
41 | rootProject.name = "frp"
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Built application files
2 | *.apk
3 | *.ap_
4 | *.aab
5 |
6 | # Files for the ART/Dalvik VM
7 | *.dex
8 |
9 | # Java class files
10 | *.class
11 |
12 | # Generated files
13 | bin/
14 | gen/
15 | out/
16 | # Uncomment the following line in case you need and you don't have the release build type files in your app
17 | # release/
18 |
19 | # Gradle files
20 | .gradle/
21 | build/
22 |
23 | # Local configuration file (sdk path, etc)
24 | local.properties
25 |
26 | # Proguard folder generated by Eclipse
27 | proguard/
28 |
29 | # Log Files
30 | *.log
31 |
32 | # Android Studio Navigation editor temp files
33 | .navigation/
34 |
35 | # Android Studio captures folder
36 | captures/
37 |
38 | # IntelliJ
39 | *.iml
40 | .idea/workspace.xml
41 | .idea/tasks.xml
42 | .idea/gradle.xml
43 | .idea/assetWizardSettings.xml
44 | .idea/dictionaries
45 | .idea/libraries
46 | # Android Studio 3 in .gitignore file.
47 | .idea/caches
48 | .idea/modules.xml
49 | # Comment next line if keeping position of elements in Navigation Editor is relevant for you
50 | .idea/navEditor.xml
51 |
52 | # Keystore files
53 | # Uncomment the following lines if you do not want to check your keystore files in.
54 | *.jks
55 | *.jks.base64
56 | *.keystore
57 |
58 | # External native build folder generated in Android Studio 2.2 and later
59 | .externalNativeBuild
60 |
61 | # Google Services (e.g. APIs or Firebase)
62 | # google-services.json
63 |
64 | # Freeline
65 | freeline.py
66 | freeline/
67 | freeline_project_description.json
68 |
69 | # fastlane
70 | fastlane/report.xml
71 | fastlane/Preview.html
72 | fastlane/screenshots
73 | fastlane/test_output
74 | fastlane/readme.md
75 |
76 | # Version control
77 | vcs.xml
78 |
79 | # lint
80 | lint/intermediates/
81 | lint/generated/
82 | lint/outputs/
83 | lint/tmp/
84 | # lint/reports/
85 |
86 | keystore.properties
87 | .idea
88 | .kotlin/
89 | .claude/
90 | *.tar.gz
91 | baselineProfiles/
92 | TASKER_PLUGIN_DEVELOPMENT_GUIDE.md
93 | app/src/main/jniLibs/
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/ShellThread.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import android.os.Build
4 | import java.io.File
5 | import java.io.InterruptedIOException
6 |
7 | class ShellThread(
8 | val command: List,
9 | val dir: File,
10 | val envp: Map = emptyMap(),
11 | val outputCallback: (text: String) -> Unit
12 | ) : Thread() {
13 | private lateinit var process: Process
14 |
15 | override fun run() {
16 | try {
17 | val processBuilder = ProcessBuilder(command)
18 | processBuilder.directory(dir)
19 | envp.forEach { (key, value) ->
20 | processBuilder.environment()[key] = value
21 | }
22 | processBuilder.redirectErrorStream(true) // 合并错误流
23 |
24 | process = processBuilder.start()
25 |
26 | // 处理输出流
27 | process.inputStream.bufferedReader().use { reader ->
28 | try {
29 | var line: String? = null
30 | while (!isInterrupted && reader.readLine().also { line = it } != null) {
31 | line?.let { outputCallback(it) }
32 | }
33 | } catch (e: InterruptedIOException) {
34 | // 线程被中断
35 | outputCallback("Thread interrupted: ${e.message}")
36 | }
37 | }
38 |
39 | // 等待进程结束并读取退出码
40 | val exitCode = process.waitFor()
41 | outputCallback("Process exited with code: $exitCode")
42 |
43 | } catch (e: Exception) {
44 | e.printStackTrace()
45 | outputCallback("Error: ${e.javaClass.simpleName} - ${e.message}")
46 | } finally {
47 | stopProcess()
48 | }
49 | }
50 |
51 | fun stopProcess() {
52 | try {
53 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
54 | process.destroyForcibly()
55 | } else {
56 | process.destroy()
57 | }
58 | } catch (e: Exception) {
59 | e.printStackTrace()
60 | outputCallback("Error stopping process: ${e.message}")
61 | }
62 | }
63 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/FrpConfigTemplate.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import android.content.Context
4 | import java.util.Locale
5 |
6 | /**
7 | * frp 配置模板元数据.
8 | */
9 | data class FrpConfigTemplate(
10 | val type: FrpType,
11 | val assetPath: String,
12 | val fileName: String,
13 | val displayName: String,
14 | )
15 |
16 | /**
17 | * 加载 frp 配置模板,按系统语言优先选择对应目录,缺失时回落到英文模板。
18 | */
19 | fun getFrpConfigTemplates(context: Context): Map> {
20 | val assets = context.assets
21 | val preferredLocales = buildList {
22 | val currentLocale = if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N) {
23 | context.resources.configuration.locales.get(0)
24 | } else {
25 | @Suppress("DEPRECATION")
26 | context.resources.configuration.locale
27 | }
28 | val languageTag = currentLocale.language.lowercase(Locale.ROOT)
29 | if (languageTag.startsWith("zh")) {
30 | add("zh-cn")
31 | }
32 | add("en")
33 | }
34 |
35 | val templates = mutableMapOf>()
36 | FrpType.values().forEach { type ->
37 | var found: List = emptyList()
38 | for (localeKey in preferredLocales) {
39 | val dir = "examples/$localeKey/${type.typeName}"
40 | val fileNames = assets.list(dir)?.sorted().orEmpty()
41 | if (fileNames.isNotEmpty()) {
42 | found = fileNames.map { fileName ->
43 | val assetPath = "$dir/$fileName"
44 | val firstLine = assets.open(assetPath).bufferedReader().use { reader ->
45 | reader.readLine()
46 | } ?: fileName
47 | val displayName = firstLine
48 | .removePrefix("#")
49 | .removePrefix("//")
50 | .trim()
51 | .ifEmpty { fileName }
52 | FrpConfigTemplate(type, assetPath, fileName, displayName)
53 | }
54 | break
55 | }
56 | }
57 | templates[type] = found
58 | }
59 | return templates
60 | }
61 |
--------------------------------------------------------------------------------
/app/release/output-metadata.json:
--------------------------------------------------------------------------------
1 | {
2 | "version": 3,
3 | "artifactType": {
4 | "type": "APK",
5 | "kind": "Directory"
6 | },
7 | "applicationId": "io.github.acedroidx.frp",
8 | "variantName": "release",
9 | "elements": [
10 | {
11 | "type": "UNIVERSAL",
12 | "filters": [],
13 | "attributes": [],
14 | "versionCode": 16,
15 | "versionName": "1.4.3",
16 | "outputFile": "frp-for-android_universal_1.4.3.apk"
17 | },
18 | {
19 | "type": "ONE_OF_MANY",
20 | "filters": [
21 | {
22 | "filterType": "ABI",
23 | "value": "armeabi-v7a"
24 | }
25 | ],
26 | "attributes": [],
27 | "versionCode": 16,
28 | "versionName": "1.4.3",
29 | "outputFile": "frp-for-android_armeabi-v7a_1.4.3.apk"
30 | },
31 | {
32 | "type": "ONE_OF_MANY",
33 | "filters": [
34 | {
35 | "filterType": "ABI",
36 | "value": "arm64-v8a"
37 | }
38 | ],
39 | "attributes": [],
40 | "versionCode": 16,
41 | "versionName": "1.4.3",
42 | "outputFile": "frp-for-android_arm64-v8a_1.4.3.apk"
43 | },
44 | {
45 | "type": "ONE_OF_MANY",
46 | "filters": [
47 | {
48 | "filterType": "ABI",
49 | "value": "x86_64"
50 | }
51 | ],
52 | "attributes": [],
53 | "versionCode": 16,
54 | "versionName": "1.4.3",
55 | "outputFile": "frp-for-android_x86_64_1.4.3.apk"
56 | }
57 | ],
58 | "elementType": "File",
59 | "baselineProfiles": [
60 | {
61 | "minApi": 28,
62 | "maxApi": 30,
63 | "baselineProfiles": [
64 | "baselineProfiles/1/frp-for-android_universal_1.4.3.dm",
65 | "baselineProfiles/1/frp-for-android_armeabi-v7a_1.4.3.dm",
66 | "baselineProfiles/1/frp-for-android_arm64-v8a_1.4.3.dm",
67 | "baselineProfiles/1/frp-for-android_x86_64_1.4.3.dm"
68 | ]
69 | },
70 | {
71 | "minApi": 31,
72 | "maxApi": 2147483647,
73 | "baselineProfiles": [
74 | "baselineProfiles/0/frp-for-android_universal_1.4.3.dm",
75 | "baselineProfiles/0/frp-for-android_armeabi-v7a_1.4.3.dm",
76 | "baselineProfiles/0/frp-for-android_arm64-v8a_1.4.3.dm",
77 | "baselineProfiles/0/frp-for-android_x86_64_1.4.3.dm"
78 | ]
79 | }
80 | ],
81 | "minSdkVersionForDexing": 23
82 | }
--------------------------------------------------------------------------------
/gradlew.bat:
--------------------------------------------------------------------------------
1 | @if "%DEBUG%" == "" @echo off
2 | @rem ##########################################################################
3 | @rem
4 | @rem Gradle startup script for Windows
5 | @rem
6 | @rem ##########################################################################
7 |
8 | @rem Set local scope for the variables with windows NT shell
9 | if "%OS%"=="Windows_NT" setlocal
10 |
11 | set DIRNAME=%~dp0
12 | if "%DIRNAME%" == "" set DIRNAME=.
13 | set APP_BASE_NAME=%~n0
14 | set APP_HOME=%DIRNAME%
15 |
16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
17 | set DEFAULT_JVM_OPTS=
18 |
19 | @rem Find java.exe
20 | if defined JAVA_HOME goto findJavaFromJavaHome
21 |
22 | set JAVA_EXE=java.exe
23 | %JAVA_EXE% -version >NUL 2>&1
24 | if "%ERRORLEVEL%" == "0" goto init
25 |
26 | echo.
27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
28 | echo.
29 | echo Please set the JAVA_HOME variable in your environment to match the
30 | echo location of your Java installation.
31 |
32 | goto fail
33 |
34 | :findJavaFromJavaHome
35 | set JAVA_HOME=%JAVA_HOME:"=%
36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
37 |
38 | if exist "%JAVA_EXE%" goto init
39 |
40 | echo.
41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
42 | echo.
43 | echo Please set the JAVA_HOME variable in your environment to match the
44 | echo location of your Java installation.
45 |
46 | goto fail
47 |
48 | :init
49 | @rem Get command-line arguments, handling Windows variants
50 |
51 | if not "%OS%" == "Windows_NT" goto win9xME_args
52 |
53 | :win9xME_args
54 | @rem Slurp the command line arguments.
55 | set CMD_LINE_ARGS=
56 | set _SKIP=2
57 |
58 | :win9xME_args_slurp
59 | if "x%~1" == "x" goto execute
60 |
61 | set CMD_LINE_ARGS=%*
62 |
63 | :execute
64 | @rem Setup the command line
65 |
66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
67 |
68 | @rem Execute Gradle
69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
70 |
71 | :end
72 | @rem End local scope for the variables with windows NT shell
73 | if "%ERRORLEVEL%"=="0" goto mainEnd
74 |
75 | :fail
76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
77 | rem the _cmd.exe /c_ return code!
78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
79 | exit /b 1
80 |
81 | :mainEnd
82 | if "%OS%"=="Windows_NT" endlocal
83 |
84 | :omega
85 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/AutoStartBroadReceiver.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import android.content.BroadcastReceiver
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.os.Build
7 |
8 | class AutoStartBroadReceiver : BroadcastReceiver() {
9 | override fun onReceive(context: Context, intent: Intent) {
10 | val preferences = context.getSharedPreferences("data", Context.MODE_PRIVATE)
11 | when (intent.action) {
12 | Intent.ACTION_BOOT_COMPLETED -> {
13 | val autoStartOnBoot = preferences.getBoolean(PreferencesKey.AUTO_START, false)
14 | if (!autoStartOnBoot) return
15 | val configList = AutoStartHelper.loadAutoStartConfigs(context)
16 | startShellService(context, configList, ShellServiceAction.START)
17 | }
18 |
19 | BroadcastAction.START, BroadcastAction.STOP -> {
20 | val enableBroadcast = when (intent.action) {
21 | BroadcastAction.START -> preferences.getBoolean(
22 | PreferencesKey.AUTO_START_BROADCAST, false
23 | )
24 |
25 | BroadcastAction.STOP -> preferences.getBoolean(
26 | PreferencesKey.AUTO_STOP_BROADCAST, false
27 | )
28 |
29 | else -> false
30 | }
31 | if (!enableBroadcast) return
32 |
33 | val allowExtra =
34 | preferences.getBoolean(PreferencesKey.AUTO_START_BROADCAST_EXTRA, false)
35 |
36 | val configList = if (allowExtra) {
37 | val type =
38 | AutoStartHelper.parseType(intent.getStringExtra(BroadcastExtraKey.TYPE))
39 | val name = intent.getStringExtra(BroadcastExtraKey.NAME)
40 |
41 | if (type != null && !name.isNullOrBlank()) {
42 | AutoStartHelper.loadAutoStartConfigs(
43 | context, typeFilter = type, nameFilter = name
44 | )
45 | } else {
46 | AutoStartHelper.loadAutoStartConfigs(context)
47 | }
48 | } else {
49 | AutoStartHelper.loadAutoStartConfigs(context)
50 | }
51 |
52 | val serviceAction = if (intent.action == BroadcastAction.START) {
53 | ShellServiceAction.START
54 | } else {
55 | ShellServiceAction.STOP
56 | }
57 |
58 | startShellService(context, configList, serviceAction)
59 | }
60 | }
61 | }
62 |
63 | private fun startShellService(
64 | context: Context, configList: List, action: String
65 | ) {
66 | if (configList.isEmpty()) return
67 |
68 | val mainIntent = Intent(context, ShellService::class.java)
69 | mainIntent.action = action
70 | mainIntent.putParcelableArrayListExtra(IntentExtraKey.FrpConfig, ArrayList(configList))
71 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
72 | context.startForegroundService(mainIntent)
73 | } else {
74 | context.startService(mainIntent)
75 | }
76 | }
77 | }
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_tasker_config_selector.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
16 |
17 |
23 |
24 |
30 |
31 |
38 |
39 |
45 |
46 |
47 |
53 |
54 |
60 |
61 |
69 |
70 |
75 |
76 |
83 |
84 |
90 |
91 |
92 |
93 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/ui/theme/Theme.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp.ui.theme
2 |
3 | import android.app.Activity
4 | import android.os.Build
5 | import androidx.compose.foundation.isSystemInDarkTheme
6 | import androidx.compose.material3.MaterialTheme
7 | import androidx.compose.material3.darkColorScheme
8 | import androidx.compose.material3.dynamicDarkColorScheme
9 | import androidx.compose.material3.dynamicLightColorScheme
10 | import androidx.compose.material3.lightColorScheme
11 | import androidx.compose.runtime.Composable
12 | import androidx.compose.runtime.SideEffect
13 | import androidx.compose.ui.platform.LocalContext
14 | import androidx.compose.ui.platform.LocalView
15 | import androidx.core.view.WindowCompat
16 |
17 | object ThemeModeKeys {
18 | const val DARK = "dark"
19 | const val LIGHT = "light"
20 | const val FOLLOW_SYSTEM = "system"
21 |
22 | fun normalize(value: String?, fallback: String = FOLLOW_SYSTEM): String {
23 | return when (value) {
24 | DARK, "深色", "Dark" -> DARK
25 | LIGHT, "浅色", "Light" -> LIGHT
26 | FOLLOW_SYSTEM, "跟随系统", "Follow system" -> FOLLOW_SYSTEM
27 | else -> fallback
28 | }
29 | }
30 | }
31 |
32 | private val DarkColorScheme = darkColorScheme(
33 | primary = Purple80, secondary = PurpleGrey80, tertiary = Pink80
34 | )
35 |
36 | private val LightColorScheme = lightColorScheme(
37 | primary = Purple40, secondary = PurpleGrey40, tertiary = Pink40
38 |
39 | /* Other default colors to override
40 | background = Color(0xFFFFFBFE),
41 | surface = Color(0xFFFFFBFE),
42 | onPrimary = Color.White,
43 | onSecondary = Color.White,
44 | onTertiary = Color.White,
45 | onBackground = Color(0xFF1C1B1F),
46 | onSurface = Color(0xFF1C1B1F),
47 | */
48 | )
49 |
50 | @Composable
51 | fun FrpTheme(
52 | darkTheme: Boolean = isSystemInDarkTheme(),
53 | // Dynamic color is available on Android 12+
54 | dynamicColor: Boolean = true,
55 | themeMode: String? = null, // accepts ThemeModeKeys or null
56 | content: @Composable () -> Unit
57 | ) {
58 | val context = LocalContext.current
59 | // 根据 themeMode 决定是否使用深色主题
60 | val useDarkTheme = when (themeMode) {
61 | ThemeModeKeys.DARK -> true
62 | ThemeModeKeys.LIGHT -> false
63 | ThemeModeKeys.FOLLOW_SYSTEM, null -> darkTheme
64 | else -> darkTheme
65 | }
66 |
67 | val colorScheme = when {
68 | dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
69 | if (useDarkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
70 | }
71 |
72 | useDarkTheme -> DarkColorScheme
73 | else -> LightColorScheme
74 | }
75 |
76 | // 动态设置状态栏文字颜色,跟随应用主题而非系统模式
77 | val view = LocalView.current
78 | if (!view.isInEditMode) {
79 | SideEffect {
80 | val window = (view.context as? Activity)?.window
81 | window?.let {
82 | WindowCompat.getInsetsController(it, view).apply {
83 | // useDarkTheme=true(深色主题) → 浅色文字图标
84 | // useDarkTheme=false(浅色主题) → 深色文字图标
85 | isAppearanceLightStatusBars = !useDarkTheme
86 | isAppearanceLightNavigationBars = !useDarkTheme
87 | }
88 | }
89 | }
90 | }
91 |
92 | MaterialTheme(
93 | colorScheme = colorScheme, typography = Typography, content = content
94 | )
95 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # frp-Android
2 | A frp client for Android
3 | 一个Android的frp客户端
4 |
5 | 简体中文 | [English](README_en.md)
6 |
7 |
8 |

9 |

10 |
11 |
12 | ## 编译方法
13 |
14 | 如果您想更换frp内核,可以通过Github Actions或通过Android Studio编译
15 |
16 | 下述密钥相关步骤可选,若跳过该步骤将会使用Android公开的默认调试密钥进行签名
17 |
18 | ### (推荐) 通过Github Actions编译
19 |
20 | 1. fork本项目
21 | 2. (可选) 将您的apk签名密钥文件转为base64,以下为Linux示例
22 | ```shell
23 | base64 -w 0 keystore.jks > keystore.jks.base64
24 | ```
25 | 3. (可选) 转到Github项目的此页面:Settings > Secrets and variables > Actions > Repository secrets
26 | 4. (可选) 添加以下四个环境变量:```KEY_ALIAS``` ```KEY_PASSWORD``` ```STORE_FILE``` ```STORE_PASSWORD```其中```STORE_FILE```的内容为步骤2的base64,其他环境变量内容请根据您的密钥文件自行填写
27 | 5. 在Actions页面的Android CI手动触发或Push提交自动触发编译,手动触发时可输入指定的frp内核版本号tag进行下载(如v0.65.0),留空和自动触发时下载最新版本
28 |
29 | ### 通过Android Studio编译
30 |
31 | 1. (可选) 在项目根目录创建apk签名密钥设置文件```keystore.properties```, 内容参考同级的```keystore.example.properties```
32 | 2. 参考[脚本说明](./scripts/README.md)运行`update_frp_binaries`脚本以获取最新的frp内核文件,或者手动下载并放置到相应目录下
33 | 3. 使用Android Studio进行编译打包
34 |
35 | ## 常见问题
36 | ### 项目的frp内核(libfrpc.so)是怎么来的?
37 | 直接从[frp的release](https://github.com/fatedier/frp/releases)里把对应ABI的Linux版本压缩包解压之后重命名frpc为libfrpc.so
38 | 项目不是在代码里调用so中的方法,而是把so作为一个可执行文件,然后通过shell去执行对应的命令
39 | 因为Golang的零依赖特性,所以可以直接在Android里通过shell运行可执行文件
40 |
41 | ### 连接重试
42 | 在 frpc 配置中添加 `loginFailExit = false` 可以设置第一次登陆失败后不退出,实现多次重试。
43 | 可以适用于如下情况:开机自启动时,网络还未准备好,frpc 开始连接但失败,若不设置该选项则 frpc 会直接退出
44 |
45 | ### DNS解析失败
46 | 从 v1.3.0 开始,arm64-v8a 架构的设备将改用 android 类型的 frp 内核以解决 DNS 解析失败的问题。
47 | armeabi-v7a 和 x86_64 架构的设备仍然使用 linux 类型的 frp 内核,可能会存在 DNS 解析失败的问题,建议在配置文件使用 `dnsServer` 指定 DNS 服务器
48 |
49 | ### 开机自启与后台保活
50 | App 按照原生 Android 规范设计,然而部分国产系统拥有更严格的后台管控,请手动在系统设置内打开相应开关。例如 ColorOS 16 退到后台会断开连接,在【应用设置->耗电管理->完全允许后台行为】之后恢复正常
51 |
52 | ### 能在应用内更换内核版本吗?能内置多个frp内核吗?
53 | 简单来说:不能,请你参考上面的编译方法自行更换内核并编译Apk
54 |
55 | 由于[Android 10+ 移除了应用主目录的执行权限](https://developer.android.com/about/versions/10/behavior-changes-10?hl=zh-cn#execute-permission),因此无法动态下载并运行新的frp内核文件,只能在安装包内置需要的内核版本。
56 |
57 | 用户的需求是不确定的,难以通过有限的内置版本满足所有用户,因此推荐用户自行编译以内置所需的内核版本。
58 |
59 | 当然也有其他的方案,例如
60 |
61 | - [NekoBoxForAndroid](https://github.com/MatsuriDayo/NekoBoxForAndroid)开发了插件系统,可以将二进制文件分离出来作为Apk插件安装
62 | - [termux](https://github.com/termux/termux-exec-package)通过一些技巧实现了在受限环境下执行二进制文件
63 |
64 | 但是这些方案都比较复杂,本人精力与能力有限,暂时无法实现
65 |
66 | ### BroadcastReceiver 使用示例
67 | 需在设置中打开「在收到广播时启动/关闭」对应开关:
68 |
69 | ```shell
70 | # 启动所有已开启自启动的配置
71 | adb shell am broadcast -a io.github.acedroidx.frp.START io.github.acedroidx.frp
72 |
73 | # 停止所有已开启自启动的配置
74 | adb shell am broadcast -a io.github.acedroidx.frp.STOP io.github.acedroidx.frp
75 |
76 | # 仅操作指定配置(带参数示例)
77 | adb shell am broadcast -a io.github.acedroidx.frp.START -e TYPE frpc -e NAME example.toml io.github.acedroidx.frp
78 | adb shell am broadcast -a io.github.acedroidx.frp.STOP -e TYPE frpc -e NAME example.toml io.github.acedroidx.frp
79 | ```
80 |
81 | ### ContentProvider 配置访问示例
82 | 使用前请在「设置 -> frp 配置读写接口」开启读/写开关,注意可能的配置密码泄露等安全风险。
83 |
84 | ```shell
85 | # 列出全部配置(需要开启“允许读取”)
86 | adb shell content query --uri content://io.github.acedroidx.frp.config
87 |
88 | # 读取单个配置(需要开启“允许读取”)
89 | adb shell content read --uri content://io.github.acedroidx.frp.config/frpc/example.toml
90 |
91 | # 写入单个配置(需要开启“允许写入”)
92 | # 将本地 example.toml 覆盖写入设备上的配置文件
93 | adb shell content write --uri content://io.github.acedroidx.frp.config/frpc/example.toml < example.toml
94 | ```
95 |
96 | - 应用内快速验证:在主页配置列表长按“编辑”按钮,会用第三方应用通过 ContentProvider 打开该配置文件,同样需要先在设置中开启读/写开关。
--------------------------------------------------------------------------------
/app/src/main/res/layout/activity_tasker_config_stop.xml:
--------------------------------------------------------------------------------
1 |
2 |
8 |
9 |
16 |
17 |
24 |
25 |
31 |
32 |
38 |
39 |
46 |
47 |
53 |
54 |
55 |
61 |
62 |
68 |
69 |
77 |
78 |
83 |
84 |
91 |
92 |
98 |
99 |
100 |
101 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/FrpConfigProvider.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import android.content.ContentProvider
4 | import android.content.ContentValues
5 | import android.content.UriMatcher
6 | import android.database.Cursor
7 | import android.database.MatrixCursor
8 | import android.net.Uri
9 | import android.os.ParcelFileDescriptor
10 | import java.io.File
11 | import java.io.FileNotFoundException
12 |
13 | class FrpConfigProvider : ContentProvider() {
14 | companion object {
15 | const val AUTHORITY = "io.github.acedroidx.frp.config"
16 | private const val CODE_CONFIGS = 1
17 | private const val CODE_CONFIG_ITEM = 2
18 |
19 | private val uriMatcher = UriMatcher(UriMatcher.NO_MATCH).apply {
20 | // 根路径列出全部配置:content://io.github.acedroidx.frp.config
21 | addURI(AUTHORITY, null, CODE_CONFIGS)
22 | // 单个配置:content://io.github.acedroidx.frp.config/{type}/{name}
23 | addURI(AUTHORITY, "*/*", CODE_CONFIG_ITEM)
24 | }
25 | }
26 |
27 | override fun onCreate(): Boolean = true
28 |
29 | override fun query(
30 | uri: Uri,
31 | projection: Array?,
32 | selection: String?,
33 | selectionArgs: Array?,
34 | sortOrder: String?
35 | ): Cursor? {
36 | val match = uriMatcher.match(uri)
37 | val context = context ?: return null
38 | val prefs = context.getSharedPreferences("data", android.content.Context.MODE_PRIVATE)
39 |
40 | // 仅在允许读取时暴露内容,避免敏感配置泄漏
41 | if (!prefs.getBoolean(PreferencesKey.ALLOW_CONFIG_READ, false)) {
42 | throw SecurityException("Config read not allowed")
43 | }
44 |
45 | val cursor = MatrixCursor(arrayOf("_id", "type", "name"))
46 | when (match) {
47 | CODE_CONFIGS -> {
48 | var id = 0L
49 | FrpType.entries.forEach { type ->
50 | val names = type.getDir(context).list()?.toList() ?: emptyList()
51 | names.forEach { name ->
52 | cursor.addRow(arrayOf(id++, type.typeName, name))
53 | }
54 | }
55 | }
56 |
57 | CODE_CONFIG_ITEM -> {
58 | val type = uri.pathSegments.getOrNull(0)
59 | val name = uri.pathSegments.getOrNull(1)
60 | if (type.isNullOrBlank() || name.isNullOrBlank()) {
61 | return cursor
62 | }
63 | val frpType = AutoStartHelper.parseType(type)
64 | if (frpType != null) {
65 | val file = File(frpType.getDir(context), name)
66 | if (file.exists()) {
67 | cursor.addRow(arrayOf(0L, frpType.typeName, name))
68 | }
69 | }
70 | }
71 |
72 | else -> throw FileNotFoundException("Unknown URI: $uri")
73 | }
74 | return cursor
75 | }
76 |
77 | override fun getType(uri: Uri): String? {
78 | return when (uriMatcher.match(uri)) {
79 | CODE_CONFIGS -> "vnd.android.cursor.dir/vnd.io.github.acedroidx.frp.config"
80 | CODE_CONFIG_ITEM -> "text/plain"
81 | else -> null
82 | }
83 | }
84 |
85 | override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor? {
86 | val context = context ?: throw FileNotFoundException("No context")
87 | val prefs = context.getSharedPreferences("data", android.content.Context.MODE_PRIVATE)
88 | val match = uriMatcher.match(uri)
89 | if (match != CODE_CONFIG_ITEM) {
90 | throw FileNotFoundException("Unsupported uri: $uri")
91 | }
92 |
93 | val type = uri.pathSegments.getOrNull(0)
94 | val name = uri.pathSegments.getOrNull(1)
95 | val frpType =
96 | AutoStartHelper.parseType(type) ?: throw FileNotFoundException("Invalid type: $type")
97 | if (name.isNullOrBlank()) throw FileNotFoundException("Invalid name")
98 |
99 | val modeBits = ParcelFileDescriptor.parseMode(mode)
100 | val isWrite = mode.contains("w") || mode.contains("a") || mode.contains("t")
101 | // 根据模式与开关判断是否允许读/写
102 | if (isWrite && !prefs.getBoolean(PreferencesKey.ALLOW_CONFIG_WRITE, false)) {
103 | throw SecurityException("Config write not allowed")
104 | }
105 | if (!isWrite && !prefs.getBoolean(PreferencesKey.ALLOW_CONFIG_READ, false)) {
106 | throw SecurityException("Config read not allowed")
107 | }
108 |
109 | val dir = frpType.getDir(context)
110 | if (!dir.exists()) {
111 | dir.mkdirs()
112 | }
113 | val file = File(dir, name)
114 | if (!isWrite && !file.exists()) {
115 | throw FileNotFoundException("File not found")
116 | }
117 | return ParcelFileDescriptor.open(file, modeBits)
118 | }
119 |
120 | override fun insert(uri: Uri, values: ContentValues?): Uri? {
121 | throw UnsupportedOperationException("Insert not supported")
122 | }
123 |
124 | override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int {
125 | throw UnsupportedOperationException("Delete not supported")
126 | }
127 |
128 | override fun update(
129 | uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?
130 | ): Int {
131 | throw UnsupportedOperationException("Update not supported")
132 | }
133 | }
134 |
--------------------------------------------------------------------------------
/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
4 |
5 |
6 |
7 |
10 |
11 |
12 |
13 |
14 |
15 |
24 |
27 |
28 |
29 |
30 |
31 |
32 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
58 |
59 |
64 |
67 |
68 |
69 |
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 |
95 |
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
109 |
110 |
111 |
112 |
113 |
116 |
119 |
120 |
121 |
126 |
127 |
128 |
--------------------------------------------------------------------------------
/.github/workflows/android.yml:
--------------------------------------------------------------------------------
1 | name: Android CI
2 |
3 | on:
4 | push:
5 | workflow_dispatch:
6 | inputs:
7 | tag:
8 | description: 'frp binaries release tag (optional)'
9 | required: false
10 |
11 | jobs:
12 | build:
13 |
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - uses: actions/checkout@v4
18 | - name: set up JDK 17
19 | uses: actions/setup-java@v4
20 | with:
21 | java-version: '17'
22 | distribution: 'temurin'
23 | cache: gradle
24 | - name: Setup Android SDK
25 | uses: android-actions/setup-android@v3
26 | with:
27 | packages: 'build-tools;36.1.0'
28 | - name: Add build-tools to PATH
29 | run: echo "$ANDROID_HOME/build-tools/36.1.0" >> "$GITHUB_PATH"
30 |
31 | # https://docs.github.com/en/actions/security-for-github-actions/security-guides/using-secrets-in-github-actions#storing-base64-binary-blobs-as-secrets
32 | - name: Retrieve the secret and decode it to a file
33 | env:
34 | STORE_FILE: ${{ secrets.STORE_FILE }}
35 | if: env.STORE_FILE != ''
36 | run: |
37 | echo $STORE_FILE | base64 --decode > keystore.jks
38 | - name: Retrieve the key rotation files and decode them to files
39 | id: key-rotation-files
40 | env:
41 | NEW_STORE_FILE: ${{ secrets.NEW_STORE_FILE }}
42 | LINEAGE: ${{ secrets.LINEAGE }}
43 | if: env.NEW_STORE_FILE != '' && env.LINEAGE != ''
44 | run: |
45 | echo $NEW_STORE_FILE | base64 --decode > new_keystore.jks
46 | echo $LINEAGE | base64 --decode > lineage
47 |
48 | - name: Generate blank keystore.properties to bypass gradle check
49 | run: touch keystore.properties
50 |
51 | - name: Grant execute permission for gradlew
52 | run: chmod +x gradlew
53 | - name: Install runtime dependencies
54 | run: |
55 | # Ensure jq and tar are available for the frp download script
56 | sudo apt-get update && sudo apt-get install -y jq tar
57 | - name: Grant execute permission for update script
58 | run: chmod +x scripts/update_frp_binaries.sh
59 | - name: Update frp binaries
60 | run: ./scripts/update_frp_binaries.sh ${{ github.event_name == 'workflow_dispatch' && github.event.inputs.tag != '' && format('--tag {0}', github.event.inputs.tag) || '' }}
61 | - name: Build with Gradle
62 | run: ./gradlew assembleRelease
63 | env:
64 | KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
65 | KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
66 | STORE_FILE: ${{ secrets.STORE_FILE }}
67 | STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
68 |
69 | - name: Sign APKs with apksigner (key rotation)
70 | if: steps.key-rotation-files.outcome == 'success'
71 | env:
72 | KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
73 | KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
74 | STORE_PASSWORD: ${{ secrets.STORE_PASSWORD }}
75 | run: |
76 | set -euo pipefail
77 | echo "Signing apks in app/build/outputs/apk/release/ with key rotation"
78 | ls -la app/build/outputs/apk/release/
79 | # Sanity checks:
80 | if [ ! -f keystore.jks ]; then
81 | echo "keystore.jks not found" >&2
82 | exit 1
83 | fi
84 | if [ ! -f new_keystore.jks ]; then
85 | echo "new_keystore.jks not found" >&2
86 | exit 1
87 | fi
88 | if [ ! -f lineage ]; then
89 | echo "lineage file not found" >&2
90 | exit 1
91 | fi
92 |
93 | # Print apksigner version and path so debugging is easier
94 | apksigner --version || echo "apksigner didn't output a version"
95 |
96 | # Make for-loop not expand unmatched globs to literal value (bash only)
97 | shopt -s nullglob
98 | for apk in app/build/outputs/apk/release/*.apk; do
99 | if [ -z "$apk" ]; then
100 | echo "No APKs found to sign, skipping"
101 | break
102 | fi
103 | echo "Signing $apk"
104 | apksigner sign \
105 | --ks keystore.jks \
106 | --ks-key-alias "$KEY_ALIAS" \
107 | --ks-pass env:STORE_PASSWORD \
108 | --key-pass env:KEY_PASSWORD \
109 | --next-signer \
110 | --ks new_keystore.jks \
111 | --ks-key-alias "$KEY_ALIAS" \
112 | --ks-pass env:STORE_PASSWORD \
113 | --key-pass env:KEY_PASSWORD \
114 | --lineage lineage \
115 | "$apk"
116 | done
117 |
118 | - name: Upload arm64-v8a APK
119 | uses: actions/upload-artifact@v4
120 | with:
121 | name: frp-Android-arm64-v8a
122 | path: app/build/outputs/apk/release/*arm64-v8a*.apk
123 |
124 | - name: Upload armeabi-v7a APK
125 | uses: actions/upload-artifact@v4
126 | with:
127 | name: frp-Android-armeabi-v7a
128 | path: app/build/outputs/apk/release/*armeabi-v7a*.apk
129 |
130 | - name: Upload x86_64 APK
131 | uses: actions/upload-artifact@v4
132 | with:
133 | name: frp-Android-x86_64
134 | path: app/build/outputs/apk/release/*x86_64*.apk
135 |
136 | - name: Upload universal APK
137 | uses: actions/upload-artifact@v4
138 | with:
139 | name: frp-Android-universal
140 | path: app/build/outputs/apk/release/*universal*.apk
141 |
142 | - name: Create Release
143 | if: startsWith(github.ref, 'refs/tags/')
144 | uses: softprops/action-gh-release@v2
145 | with:
146 | files: app/build/outputs/apk/release/*.apk
147 | generate_release_notes: true
--------------------------------------------------------------------------------
/gradlew:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 |
3 | ##############################################################################
4 | ##
5 | ## Gradle start up script for UN*X
6 | ##
7 | ##############################################################################
8 |
9 | # Attempt to set APP_HOME
10 | # Resolve links: $0 may be a link
11 | PRG="$0"
12 | # Need this for relative symlinks.
13 | while [ -h "$PRG" ] ; do
14 | ls=`ls -ld "$PRG"`
15 | link=`expr "$ls" : '.*-> \(.*\)$'`
16 | if expr "$link" : '/.*' > /dev/null; then
17 | PRG="$link"
18 | else
19 | PRG=`dirname "$PRG"`"/$link"
20 | fi
21 | done
22 | SAVED="`pwd`"
23 | cd "`dirname \"$PRG\"`/" >/dev/null
24 | APP_HOME="`pwd -P`"
25 | cd "$SAVED" >/dev/null
26 |
27 | APP_NAME="Gradle"
28 | APP_BASE_NAME=`basename "$0"`
29 |
30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
31 | DEFAULT_JVM_OPTS=""
32 |
33 | # Use the maximum available, or set MAX_FD != -1 to use that value.
34 | MAX_FD="maximum"
35 |
36 | warn () {
37 | echo "$*"
38 | }
39 |
40 | die () {
41 | echo
42 | echo "$*"
43 | echo
44 | exit 1
45 | }
46 |
47 | # OS specific support (must be 'true' or 'false').
48 | cygwin=false
49 | msys=false
50 | darwin=false
51 | nonstop=false
52 | case "`uname`" in
53 | CYGWIN* )
54 | cygwin=true
55 | ;;
56 | Darwin* )
57 | darwin=true
58 | ;;
59 | MINGW* )
60 | msys=true
61 | ;;
62 | NONSTOP* )
63 | nonstop=true
64 | ;;
65 | esac
66 |
67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
68 |
69 | # Determine the Java command to use to start the JVM.
70 | if [ -n "$JAVA_HOME" ] ; then
71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
72 | # IBM's JDK on AIX uses strange locations for the executables
73 | JAVACMD="$JAVA_HOME/jre/sh/java"
74 | else
75 | JAVACMD="$JAVA_HOME/bin/java"
76 | fi
77 | if [ ! -x "$JAVACMD" ] ; then
78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
79 |
80 | Please set the JAVA_HOME variable in your environment to match the
81 | location of your Java installation."
82 | fi
83 | else
84 | JAVACMD="java"
85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
86 |
87 | Please set the JAVA_HOME variable in your environment to match the
88 | location of your Java installation."
89 | fi
90 |
91 | # Increase the maximum file descriptors if we can.
92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
93 | MAX_FD_LIMIT=`ulimit -H -n`
94 | if [ $? -eq 0 ] ; then
95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
96 | MAX_FD="$MAX_FD_LIMIT"
97 | fi
98 | ulimit -n $MAX_FD
99 | if [ $? -ne 0 ] ; then
100 | warn "Could not set maximum file descriptor limit: $MAX_FD"
101 | fi
102 | else
103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
104 | fi
105 | fi
106 |
107 | # For Darwin, add options to specify how the application appears in the dock
108 | if $darwin; then
109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
110 | fi
111 |
112 | # For Cygwin, switch paths to Windows format before running java
113 | if $cygwin ; then
114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"`
115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
116 | JAVACMD=`cygpath --unix "$JAVACMD"`
117 |
118 | # We build the pattern for arguments to be converted via cygpath
119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
120 | SEP=""
121 | for dir in $ROOTDIRSRAW ; do
122 | ROOTDIRS="$ROOTDIRS$SEP$dir"
123 | SEP="|"
124 | done
125 | OURCYGPATTERN="(^($ROOTDIRS))"
126 | # Add a user-defined pattern to the cygpath arguments
127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then
128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
129 | fi
130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh
131 | i=0
132 | for arg in "$@" ; do
133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
135 |
136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
138 | else
139 | eval `echo args$i`="\"$arg\""
140 | fi
141 | i=$((i+1))
142 | done
143 | case $i in
144 | (0) set -- ;;
145 | (1) set -- "$args0" ;;
146 | (2) set -- "$args0" "$args1" ;;
147 | (3) set -- "$args0" "$args1" "$args2" ;;
148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;;
149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
154 | esac
155 | fi
156 |
157 | # Escape application args
158 | save () {
159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
160 | echo " "
161 | }
162 | APP_ARGS=$(save "$@")
163 |
164 | # Collect all arguments for the java command, following the shell quoting and substitution rules
165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
166 |
167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong
168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then
169 | cd "$(dirname "$0")"
170 | fi
171 |
172 | exec "$JAVACMD" "$@"
173 |
--------------------------------------------------------------------------------
/app/src/main/res/drawable/ic_launcher_background.xml:
--------------------------------------------------------------------------------
1 |
2 |
7 |
10 |
15 |
20 |
25 |
30 |
35 |
40 |
45 |
50 |
55 |
60 |
65 |
70 |
75 |
80 |
85 |
90 |
95 |
100 |
105 |
110 |
115 |
120 |
125 |
130 |
135 |
140 |
145 |
150 |
155 |
160 |
165 |
170 |
171 |
--------------------------------------------------------------------------------
/README_en.md:
--------------------------------------------------------------------------------
1 | # frp-Android
2 | A frp client for Android
3 | 一个Android的frp客户端
4 |
5 | [简体中文](README.md) | English
6 |
7 |
8 |

9 |

10 |
11 |
12 | ## Compilation Methods
13 |
14 | If you wish to customize the frp kernel, you can compile it via Github Actions or through Android Studio.
15 |
16 | The key-related steps below are optional; if you skip them, the app will be signed with Android's public default debug key.
17 |
18 | ### (Recommended) Compiling via Github Actions
19 |
20 | 1. Fork this project.
21 | 2. (Optional) Convert your APK signing key file to base64; here's a Linux example:
22 | ```shell
23 | base64 -w 0 keystore.jks > keystore.jks.base64
24 | ```
25 | 3. (Optional) Navigate to this page of the Github project: Settings > Secrets and variables > Actions > Repository secrets.
26 | 4. (Optional) Add the following four environment variables:
27 | ```KEY_ALIAS``` ```KEY_PASSWORD``` ```STORE_FILE``` ```STORE_PASSWORD```
28 | The content for ```STORE_FILE``` should be the base64 from step 2, while you should fill in the other environment variables according to your key file.
29 | 5. A push commit will automatically trigger compilation, or you can manually trigger it on the Actions page. When triggering manually, you may enter a specific frp kernel version tag to download (for example, v0.65.0); leave it blank or trigger automatically to download the latest version.
30 |
31 | ### Compiling via Android Studio
32 |
33 | 1. (Optional) Create an APK signing key configuration file named ```keystore.properties``` at the root directory of the project, referencing the existing ```keystore.example.properties``` file at the same level.
34 | 2. Refer to the [script instructions](./scripts/README.md) to run the `update_frp_binaries` script to obtain the latest frp kernel files, or manually download and place them in the appropriate directories.
35 | 3. Compile and package using Android Studio.
36 |
37 | ## FAQs
38 | ### Where does the frp kernel (libfrpc.so) of the project come from?
39 | It is obtained directly by extracting the corresponding ABI Linux version archive from [frp's release](https://github.com/fatedier/frp/releases), renaming frpc to libfrpc.so.
40 | The project does not invoke methods from the so file within its code but treats the so as an executable file, executing the corresponding command through shell.
41 | Due to Golang's zero-dependency characteristic, the executable file can be run directly through shell in Android.
42 |
43 | ### Connection Retry
44 | Add `loginFailExit = false` to the frpc configuration to prevent exiting after the first login failure, enabling multiple retry attempts.
45 | This is useful in scenarios such as auto-start on boot, where the network may not be ready when frpc starts to connect and fails. Without this option, frpc will exit immediately after a failed attempt.
46 |
47 | ### DNS Resolution Failure
48 | Starting from v1.3.0, devices with the arm64-v8a architecture use the android type frp kernel to solve DNS resolution issues.
49 | Devices with armeabi-v7a and x86_64 architectures still use the linux type frp kernel, which may have DNS resolution problems. It is recommended to specify a DNS server using the `dnsServer` option in the configuration file.
50 |
51 | ### Start at Boot and Background Keep-Alive
52 | The app is designed according to the native Android specification. However, some custom Android systems have stricter background management. Please manually enable the relevant options in the system settings. For example, on ColorOS 16, the connection may be disconnected when the app is sent to the background. After enabling [App Settings -> Power Management -> Fully Allow Background Activity], it will work normally.
53 |
54 | ### Can I change the kernel version in the app? Can multiple frp kernels be bundled?
55 | Simply put: no. Please follow the compilation methods above to bundle your required kernel and build the APK.
56 |
57 | Since [Android 10+ removed execute permissions from the app's main directory](https://developer.android.com/about/versions/10/behavior-changes-10#execute-permission), it is impossible to dynamically download and run a new frp kernel file; only the needed kernel version packaged inside the APK can be executed.
58 |
59 | User needs vary and cannot be covered by a few bundled versions, so it is recommended that you compile your own build with the kernel version you need.
60 |
61 | There are other approaches, for example:
62 |
63 | - [NekoBoxForAndroid](https://github.com/MatsuriDayo/NekoBoxForAndroid) developed a plugin system to separate binaries into an APK plugin.
64 | - [termux](https://github.com/termux/termux-exec-package) uses certain techniques to execute binaries in restricted environments.
65 |
66 | However, these solutions are relatively complex, and with limited time and capability, they are not implemented here.
67 |
68 | ### BroadcastReceiver usage example
69 | Make sure to enable the corresponding "Startup at Broadcast" / "Stop at Broadcast" switches in Settings:
70 |
71 | ```shell
72 | # Start all configs with Auto-start enabled
73 | adb shell am broadcast -a io.github.acedroidx.frp.START io.github.acedroidx.frp
74 |
75 | # Stop all configs with Auto-start enabled
76 | adb shell am broadcast -a io.github.acedroidx.frp.STOP io.github.acedroidx.frp
77 |
78 | # Operate on a specific config only (examples with extras)
79 | adb shell am broadcast -a io.github.acedroidx.frp.START -e TYPE frpc -e NAME example.toml io.github.acedroidx.frp
80 | adb shell am broadcast -a io.github.acedroidx.frp.STOP -e TYPE frpc -e NAME example.toml io.github.acedroidx.frp
81 | ```
82 |
83 | ### ContentProvider config access example
84 | Before using, turn on the "frp Config I/O" read/write switches in Settings and be aware of the possible disclosure of config passwords.
85 |
86 | ```shell
87 | # List all configs (requires "Allow read")
88 | adb shell content query --uri content://io.github.acedroidx.frp.config
89 |
90 | # Read a single config (requires "Allow read")
91 | adb shell content read --uri content://io.github.acedroidx.frp.config/frpc/example.toml
92 |
93 | # Write a single config (requires "Allow write")
94 | # Overwrite the device config with local example.toml
95 | adb shell content write --uri content://io.github.acedroidx.frp.config/frpc/example.toml < example.toml
96 | ```
97 |
98 | - In-app quick validation: long-press the config edit button on the main list to open the config with a third-party app via ContentProvider (requires read/write switches enabled).
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/FrpTileService.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import android.annotation.SuppressLint
4 | import android.app.PendingIntent
5 | import android.content.ComponentName
6 | import android.content.Intent
7 | import android.content.ServiceConnection
8 | import android.graphics.drawable.Icon
9 | import android.os.Build
10 | import android.os.IBinder
11 | import android.service.quicksettings.Tile
12 | import android.service.quicksettings.TileService
13 | import android.util.Log
14 | import android.widget.Toast
15 | import androidx.annotation.RequiresApi
16 |
17 | @RequiresApi(Build.VERSION_CODES.N)
18 | class FrpTileService : TileService() {
19 |
20 | private var mService: ShellService? = null
21 | private var mBound: Boolean = false
22 |
23 | private val connection = object : ServiceConnection {
24 | override fun onServiceConnected(className: ComponentName, service: IBinder) {
25 | val binder = service as ShellService.LocalBinder
26 | mService = binder.getService()
27 | mBound = true
28 | updateTileState()
29 | }
30 |
31 | override fun onServiceDisconnected(arg0: ComponentName) {
32 | mService = null
33 | mBound = false
34 | }
35 | }
36 |
37 | override fun onStartListening() {
38 | super.onStartListening()
39 | // 绑定服务以获取运行状态
40 | val intent = Intent(this, ShellService::class.java)
41 | bindService(intent, connection, BIND_AUTO_CREATE)
42 | updateTileState()
43 | }
44 |
45 | override fun onStopListening() {
46 | super.onStopListening()
47 | if (mBound) {
48 | unbindService(connection)
49 | mBound = false
50 | }
51 | }
52 |
53 | override fun onClick() {
54 | super.onClick()
55 |
56 | val config = getSelectedConfig()
57 | if (config == null) {
58 | // 没有配置,打开设置页面让用户选择
59 | Toast.makeText(this, R.string.quick_tile_not_configured_toast, Toast.LENGTH_LONG).show()
60 | val intent = Intent(this, SettingsActivity::class.java).apply {
61 | addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
62 | }
63 | startActivityAndCollapseCompat(intent)
64 | return
65 | }
66 |
67 | val isRunning = isConfigRunning(config)
68 |
69 | if (isRunning) {
70 | // 停止配置
71 | val intent = Intent(this, ShellService::class.java).apply {
72 | action = ShellServiceAction.STOP
73 | putExtra(IntentExtraKey.FrpConfig, arrayListOf(config))
74 | }
75 | startService(intent)
76 | } else {
77 | // 启动配置
78 | val intent = Intent(this, ShellService::class.java).apply {
79 | action = ShellServiceAction.START
80 | putExtra(IntentExtraKey.FrpConfig, arrayListOf(config))
81 | }
82 | startService(intent)
83 | }
84 |
85 | // 立即更新面板上的文字和状态,避免需要下拉刷新
86 | qsTile?.let { tile ->
87 | val nowRunning = !isRunning
88 | tile.state = if (nowRunning) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
89 | tile.label = config.fileName.removeSuffix(".toml")
90 | tile.icon = Icon.createWithResource(this, R.drawable.ic_launcher_foreground)
91 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
92 | tile.subtitle = if (nowRunning) {
93 | getString(R.string.quick_tile_running)
94 | } else {
95 | getString(R.string.quick_tile_stopped)
96 | }
97 | }
98 | tile.updateTile()
99 | }
100 | }
101 |
102 | @SuppressLint("StartActivityAndCollapseDeprecated")
103 | private fun startActivityAndCollapseCompat(intent: Intent) {
104 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
105 | val pendingIntent = PendingIntent.getActivity(
106 | this, 0, intent,
107 | PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
108 | )
109 | startActivityAndCollapse(pendingIntent)
110 | } else {
111 | @Suppress("DEPRECATION")
112 | startActivityAndCollapse(intent)
113 | }
114 | }
115 |
116 | private fun updateTileState() {
117 | val tile = qsTile ?: return
118 | val config = getSelectedConfig()
119 |
120 | if (config == null) {
121 | tile.state = Tile.STATE_INACTIVE
122 | tile.label = getString(R.string.quick_tile_not_configured)
123 | tile.icon = Icon.createWithResource(this, R.drawable.ic_launcher_foreground)
124 | } else {
125 | val isRunning = isConfigRunning(config)
126 | tile.state = if (isRunning) Tile.STATE_ACTIVE else Tile.STATE_INACTIVE
127 | tile.label = config.fileName.removeSuffix(".toml")
128 | tile.icon = Icon.createWithResource(this, R.drawable.ic_launcher_foreground)
129 |
130 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
131 | tile.subtitle = if (isRunning) {
132 | getString(R.string.quick_tile_running)
133 | } else {
134 | getString(R.string.quick_tile_stopped)
135 | }
136 | }
137 | }
138 |
139 | tile.updateTile()
140 | }
141 |
142 | private fun getSelectedConfig(): FrpConfig? {
143 | val preferences = getSharedPreferences("data", MODE_PRIVATE)
144 | val configType = preferences.getString(PreferencesKey.QUICK_TILE_CONFIG_TYPE, null)
145 | val configName = preferences.getString(PreferencesKey.QUICK_TILE_CONFIG_NAME, null)
146 |
147 | if (configType == null || configName == null) {
148 | return null
149 | }
150 |
151 | val type = try {
152 | FrpType.valueOf(configType)
153 | } catch (_: IllegalArgumentException) {
154 | Log.e("FrpTileService", "Invalid config type: $configType")
155 | return null
156 | }
157 |
158 | // 检查配置文件是否存在
159 | val config = FrpConfig(type, configName)
160 | val file = config.getFile(this)
161 | if (!file.exists()) {
162 | Log.w("FrpTileService", "Config file does not exist: $configName")
163 | return null
164 | }
165 |
166 | return config
167 | }
168 |
169 | private fun isConfigRunning(config: FrpConfig): Boolean {
170 | return mService?.processThreads?.value?.containsKey(config) == true
171 | }
172 | }
173 |
--------------------------------------------------------------------------------
/app/build.gradle.kts:
--------------------------------------------------------------------------------
1 | import com.android.build.api.dsl.ApkSigningConfig
2 | import org.jetbrains.kotlin.gradle.dsl.JvmTarget
3 | import java.io.FileInputStream
4 | import java.util.Properties
5 |
6 | plugins {
7 | id("com.android.application")
8 | id("org.jetbrains.kotlin.android")
9 | id("org.jetbrains.kotlin.plugin.compose")
10 | id("org.jetbrains.kotlin.plugin.parcelize")
11 | }
12 |
13 | val keystorePropertiesFile = rootProject.file("keystore.properties")
14 | val keystoreProperties = Properties()
15 | if (keystorePropertiesFile.exists()) {
16 | keystoreProperties.load(FileInputStream(keystorePropertiesFile))
17 | }
18 |
19 | val fileSigningAvailable = keystorePropertiesFile.exists() && listOf(
20 | "keyAlias", "keyPassword", "storeFile", "storePassword"
21 | ).all { !keystoreProperties.getProperty(it).isNullOrBlank() }
22 |
23 | val envSigningAvailable = listOf("KEY_ALIAS", "KEY_PASSWORD", "STORE_FILE", "STORE_PASSWORD").all {
24 | !System.getenv(it).isNullOrBlank()
25 | }
26 |
27 | android {
28 | androidResources {
29 | generateLocaleConfig = true
30 | }
31 |
32 | buildFeatures {
33 | buildConfig = true
34 | compose = true
35 | }
36 |
37 | lateinit var releaseSigning: ApkSigningConfig
38 |
39 | signingConfigs {
40 | val aceSigning = when {
41 | fileSigningAvailable -> {
42 | create("AceKeystore") {
43 | keyAlias = keystoreProperties.getProperty("keyAlias")
44 | keyPassword = keystoreProperties.getProperty("keyPassword")
45 | storeFile = file(keystoreProperties.getProperty("storeFile"))
46 | storePassword = keystoreProperties.getProperty("storePassword")
47 | }
48 | }
49 |
50 | envSigningAvailable -> {
51 | create("AceKeystore") {
52 | keyAlias = System.getenv("KEY_ALIAS")
53 | keyPassword = System.getenv("KEY_PASSWORD")
54 | storeFile = if (System.getenv("STORE_FILE")?.isNotBlank() == true) {
55 | // CI 跑脚本会生成 keystore.jks
56 | file("../keystore.jks")
57 | } else {
58 | file(System.getenv("STORE_FILE"))
59 | }
60 | storePassword = System.getenv("STORE_PASSWORD")
61 | }
62 | }
63 |
64 | else -> null
65 | }
66 |
67 | // 没有提供签名信息时,回退到 Android 默认的 debug 签名,保证本地/CI 可编译
68 | releaseSigning = aceSigning ?: getByName("debug")
69 | }
70 |
71 | defaultConfig {
72 | applicationId = "io.github.acedroidx.frp"
73 | minSdk = 23
74 | targetSdk = 36
75 | compileSdk = 36
76 | versionCode = 20
77 | versionName = "1.3.0"
78 |
79 | testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
80 |
81 | signingConfig = releaseSigning
82 |
83 | buildConfigField("String", "FrpcFileName", "\"libfrpc.so\"")
84 | buildConfigField("String", "FrpsFileName", "\"libfrps.so\"")
85 | buildConfigField("String", "FrpcConfigFileName", "\"frpc.toml\"")
86 | buildConfigField("String", "FrpsConfigFileName", "\"frps.toml\"")
87 | }
88 |
89 | buildTypes {
90 | getByName("release") {
91 | isMinifyEnabled = false
92 | isShrinkResources = false
93 | proguardFiles(
94 | // Includes the default ProGuard rules files that are packaged with
95 | // the Android Gradle plugin. To learn more, go to the section about
96 | // R8 configuration files.
97 | getDefaultProguardFile("proguard-android-optimize.txt"),
98 | // Includes a local, custom Proguard rules file
99 | "proguard-rules.pro"
100 | )
101 | signingConfig = releaseSigning
102 | }
103 | getByName("debug") {
104 | signingConfig = releaseSigning
105 | }
106 | }
107 | compileOptions {
108 | sourceCompatibility = JavaVersion.VERSION_17
109 | targetCompatibility = JavaVersion.VERSION_17
110 | }
111 | kotlin {
112 | compilerOptions {
113 | jvmTarget = JvmTarget.JVM_17
114 | }
115 | }
116 | packaging {
117 | jniLibs {
118 | useLegacyPackaging = true
119 | }
120 | }
121 | splits {
122 | abi {
123 | isEnable = true
124 | reset()
125 | include("arm64-v8a", "x86_64", "armeabi-v7a")
126 | isUniversalApk = true
127 | }
128 | }
129 | namespace = "io.github.acedroidx.frp"
130 |
131 | applicationVariants.all {
132 | outputs.all {
133 | val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
134 | val abiFilter = output.filters.find { it.filterType == "ABI" }
135 | val abi = abiFilter?.identifier ?: "universal"
136 | val versionName = defaultConfig.versionName
137 | output.outputFileName = "frp_${abi}_${versionName}.apk"
138 | }
139 | }
140 | }
141 |
142 | dependencies {
143 | implementation("org.jetbrains.kotlin:kotlin-stdlib:2.2.21")
144 | implementation("androidx.core:core-ktx:1.17.0")
145 | implementation("androidx.appcompat:appcompat:1.7.1")
146 | implementation("com.google.android.material:material:1.13.0")
147 | implementation("androidx.constraintlayout:constraintlayout:2.2.1")
148 |
149 | implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.10.0")
150 | implementation("androidx.lifecycle:lifecycle-service:2.10.0")
151 | implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
152 |
153 | val composeBom = platform("androidx.compose:compose-bom:2025.12.00")
154 | implementation(composeBom)
155 | androidTestImplementation(composeBom)
156 | implementation("androidx.compose.material3:material3")
157 | // Android Studio Preview support
158 | implementation("androidx.compose.ui:ui-tooling-preview")
159 | debugImplementation("androidx.compose.ui:ui-tooling")
160 | // UI Tests
161 | androidTestImplementation("androidx.compose.ui:ui-test-junit4")
162 | debugImplementation("androidx.compose.ui:ui-test-manifest")
163 | // Optional - Integration with activities
164 | implementation("androidx.activity:activity-compose")
165 |
166 | // Tasker Plugin Library
167 | implementation("com.joaomgcd:taskerpluginlibrary:0.4.10")
168 |
169 | testImplementation("junit:junit:4.13.2")
170 | androidTestImplementation("androidx.test.ext:junit:1.3.0")
171 | androidTestImplementation("androidx.test.espresso:espresso-core:3.7.0")
172 | }
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/AboutActivity.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import android.content.SharedPreferences
4 | import android.os.Bundle
5 | import androidx.activity.ComponentActivity
6 | import androidx.activity.compose.setContent
7 | import androidx.activity.enableEdgeToEdge
8 | import androidx.compose.foundation.gestures.Orientation
9 | import androidx.compose.foundation.gestures.rememberScrollableState
10 | import androidx.compose.foundation.gestures.scrollable
11 | import androidx.compose.foundation.layout.Arrangement
12 | import androidx.compose.foundation.layout.Box
13 | import androidx.compose.foundation.layout.Column
14 | import androidx.compose.foundation.layout.padding
15 | import androidx.compose.foundation.rememberScrollState
16 | import androidx.compose.foundation.verticalScroll
17 | import androidx.compose.material3.ExperimentalMaterial3Api
18 | import androidx.compose.material3.Icon
19 | import androidx.compose.material3.IconButton
20 | import androidx.compose.material3.MaterialTheme
21 | import androidx.compose.material3.Scaffold
22 | import androidx.compose.material3.Text
23 | import androidx.compose.material3.TopAppBar
24 | import androidx.compose.runtime.Composable
25 | import androidx.compose.runtime.getValue
26 | import androidx.compose.ui.Modifier
27 | import androidx.compose.ui.platform.LocalUriHandler
28 | import androidx.compose.ui.res.painterResource
29 | import androidx.compose.ui.res.stringResource
30 | import androidx.compose.ui.text.LinkAnnotation
31 | import androidx.compose.ui.text.SpanStyle
32 | import androidx.compose.ui.text.TextLinkStyles
33 | import androidx.compose.ui.text.buildAnnotatedString
34 | import androidx.compose.ui.text.withLink
35 | import androidx.compose.ui.tooling.preview.Preview
36 | import androidx.compose.ui.unit.dp
37 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
38 | import io.github.acedroidx.frp.ui.theme.FrpTheme
39 | import io.github.acedroidx.frp.ui.theme.ThemeModeKeys
40 | import kotlinx.coroutines.flow.MutableStateFlow
41 | import androidx.compose.runtime.collectAsState
42 |
43 | class AboutActivity : ComponentActivity() {
44 | private val frpVersion = MutableStateFlow("")
45 | private val themeMode = MutableStateFlow("")
46 | private lateinit var preferences: SharedPreferences
47 |
48 | @OptIn(ExperimentalMaterial3Api::class)
49 | override fun onCreate(savedInstanceState: Bundle?) {
50 | super.onCreate(savedInstanceState)
51 |
52 | preferences = getSharedPreferences("data", MODE_PRIVATE)
53 | val loadingText = getString(R.string.loading)
54 | frpVersion.value =
55 | preferences.getString(PreferencesKey.FRP_VERSION, loadingText) ?: loadingText
56 | val rawTheme = preferences.getString(PreferencesKey.THEME_MODE, ThemeModeKeys.FOLLOW_SYSTEM)
57 | themeMode.value = ThemeModeKeys.normalize(rawTheme)
58 |
59 | enableEdgeToEdge()
60 | setContent {
61 | val currentTheme by themeMode.collectAsStateWithLifecycle(themeMode.collectAsState().value.ifEmpty { ThemeModeKeys.FOLLOW_SYSTEM })
62 | FrpTheme(themeMode = currentTheme) {
63 | val frpVersion by frpVersion.collectAsStateWithLifecycle(frpVersion.collectAsState().value.ifEmpty { loadingText })
64 | Scaffold(topBar = {
65 | TopAppBar(title = {
66 | Text(
67 | stringResource(
68 | R.string.frp_for_android_version,
69 | BuildConfig.VERSION_NAME,
70 | frpVersion
71 | )
72 | )
73 | }, navigationIcon = {
74 | IconButton(onClick = { finish() }) {
75 | Icon(
76 | painter = painterResource(id = R.drawable.ic_arrow_back_24dp),
77 | contentDescription = stringResource(R.string.content_desc_back)
78 | )
79 | }
80 | })
81 | }) { contentPadding ->
82 | // Screen content
83 | Box(
84 | modifier = Modifier
85 | .padding(contentPadding)
86 | .verticalScroll(rememberScrollState())
87 | .scrollable(
88 | orientation = Orientation.Vertical,
89 | state = rememberScrollableState { delta -> 0f })
90 | ) {
91 | MainContent()
92 | }
93 | }
94 | }
95 | }
96 | }
97 |
98 | @Preview(showBackground = true)
99 | @Composable
100 | fun MainContent() {
101 | val uriHandler = LocalUriHandler.current
102 | Column(
103 | verticalArrangement = Arrangement.spacedBy(16.dp), modifier = Modifier.padding(16.dp)
104 | ) {
105 | Text(
106 | stringResource(R.string.about_app_author),
107 | style = MaterialTheme.typography.titleMedium
108 | )
109 | Text(buildAnnotatedString {
110 | val link = LinkAnnotation.Url(
111 | "https://github.com/AceDroidX/frp-Android",
112 | TextLinkStyles(SpanStyle(color = MaterialTheme.colorScheme.primary))
113 | ) {
114 | val url = (it as LinkAnnotation.Url).url
115 | uriHandler.openUri(url)
116 | }
117 | withLink(link) { append("https://github.com/AceDroidX/frp-Android") }
118 | })
119 | Text(
120 | stringResource(R.string.about_contributors),
121 | style = MaterialTheme.typography.titleMedium
122 | )
123 | Text(
124 | stringResource(R.string.about_contributor_ahsaboy),
125 | style = MaterialTheme.typography.titleMedium
126 | )
127 | Text(buildAnnotatedString {
128 | val link = LinkAnnotation.Url(
129 | "https://github.com/ahsaboy",
130 | TextLinkStyles(SpanStyle(color = MaterialTheme.colorScheme.primary))
131 | ) {
132 | val url = (it as LinkAnnotation.Url).url
133 | uriHandler.openUri(url)
134 | }
135 | withLink(link) { append("https://github.com/ahsaboy") }
136 | })
137 | Text(
138 | stringResource(R.string.about_contributor_z156854666),
139 | style = MaterialTheme.typography.titleMedium
140 | )
141 | Text(buildAnnotatedString {
142 | val link = LinkAnnotation.Url(
143 | "https://github.com/z156854666",
144 | TextLinkStyles(SpanStyle(color = MaterialTheme.colorScheme.primary))
145 | ) {
146 | val url = (it as LinkAnnotation.Url).url
147 | uriHandler.openUri(url)
148 | }
149 | withLink(link) { append("https://github.com/z156854666") }
150 | })
151 |
152 | Text(
153 | stringResource(R.string.about_frp_author),
154 | style = MaterialTheme.typography.titleMedium
155 | )
156 | Text(buildAnnotatedString {
157 | val link = LinkAnnotation.Url(
158 | "https://github.com/fatedier/frp",
159 | TextLinkStyles(SpanStyle(color = MaterialTheme.colorScheme.primary))
160 | ) {
161 | val url = (it as LinkAnnotation.Url).url
162 | uriHandler.openUri(url)
163 | }
164 | withLink(link) { append("https://github.com/fatedier/frp") }
165 | })
166 | }
167 | }
168 | }
169 |
--------------------------------------------------------------------------------
/app/src/main/res/values-zh/strings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 | frp for Android - %1$s/%2$s
4 | 加载中…
5 | 返回
6 | 设置
7 | 重命名图标
8 | 帮助
9 | 自启动开关
10 | frp 自启动设置
11 | 开机自启动
12 | 在打开应用时启动
13 | 在收到广播时启动
14 | 在收到广播时关闭
15 | 允许带参数的广播
16 | 下列选项将作用于已在配置中开启【自启动开关】的配置文件
17 | 开启后,当应用收到启动广播(动作:%1$s)时,会自动启动所有在各自配置中已开启“自启动开关”的配置。\n\n示例:\nadb shell am broadcast -a %1$s io.github.acedroidx.frp
18 | 开启后,当应用收到停止广播(动作:%1$s)时,会自动停止所有在各自配置中已开启“自启动开关”的配置。\n\n示例:\nadb shell am broadcast -a %1$s io.github.acedroidx.frp
19 | 开启后,广播可携带参数 %2$s(frpc 或 frps)和 %3$s(配置文件名)来仅对指定配置执行启动或停止;否则广播会按默认行为对所有已开启自启动的配置生效。\n\n示例:\n\n# 启动 frpc 类型、配置名为 example.toml 的配置\nadb shell am broadcast -a %1$s -e %2$s frpc -e %3$s example.toml io.github.acedroidx.frp\n\n# 启动 frps 类型、配置名为 server.toml 的配置\nadb shell am broadcast -a %1$s -e %2$s frps -e %3$s server.toml io.github.acedroidx.frp\n\n# 停止 frpc 类型、配置名为 example.toml 的配置\nadb shell am broadcast -a %4$s -e %2$s frpc -e %3$s example.toml io.github.acedroidx.frp\n\n# 停止 frps 类型、配置名为 server.toml 的配置\nadb shell am broadcast -a %4$s -e %2$s frps -e %3$s server.toml io.github.acedroidx.frp
20 | 添加配置
21 | 关于
22 | Frp日志
23 | 清除
24 | 无日志
25 | 保存
26 | 不保存
27 | 删除
28 | frp后台服务
29 | frp后台服务通知
30 | 已启动frp服务
31 | 已关闭frp服务
32 | 停止
33 | frp后台服务
34 | 已启动%d个frp配置
35 | 复制
36 | 已复制
37 | 没有配置,点击按钮去添加一个!
38 | 重命名
39 | 确认
40 | 取消
41 | 选择要创建的frp类型
42 | 选择模板创建配置
43 | 暂无可用模板,请检查 assets/examples 目录
44 | 文件:%1$s
45 | 类型:%1$s
46 | Tasker frp 动作
47 | 此操作将启动指定的 frp 配置。 使用方法:在 Tasker 中设置以下变量: • %frpType- 设置为 \"frpc\" 或 \"frps\" • %configFileName- 配置文件的名称
48 | 选择frp配置
49 | 选择frp类型:
50 | 选择配置文件
51 | 没有找到配置文件,请先在应用里创建一个配置。
52 | 停止frp配置
53 | 停止所有运行中的配置
54 | 需要通知权限
55 | 应用需要通知权限来显示后台服务状态。请授予权限以确保功能正常运行。
56 | frp快捷开关
57 | 快捷开关配置
58 | 未选择
59 | 未配置
60 | 运行中
61 | 已停止
62 | 未选择快捷开关配置,请前往“设置-快捷开关配置”中选择需要启动的frp配置
63 | frp 配置读写接口
64 | 允许其他应用读取配置
65 | 允许其他应用写入配置
66 | 通过 ContentProvider 提供只读访问。仅在信任请求方时开启,配置文件可能包含敏感凭据。
67 | 允许其他应用通过 ContentProvider 修改配置。请谨慎开启,错误写入可能导致 frp 连接异常。
68 | 使用前请在「设置 -> frp 配置读写接口」开启读/写开关,注意可能的配置密码泄露等安全风险。\n\n使用示例:\n\n# 列出全部配置(需要开启“允许读取”)\nadb shell content query --uri content://io.github.acedroidx.frp.config\n\n# 读取单个配置(需要开启“允许读取”)\nadb shell content read --uri content://io.github.acedroidx.frp.config/frpc/example.toml\n\n# 写入单个配置(需要开启“允许写入”)\n# 将本地 example.toml 覆盖写入设备上的配置文件\nadb shell content write --uri content://io.github.acedroidx.frp.config/frpc/example.toml < example.toml
69 | App 作者 (AceDroidX):
70 | 非常感谢以下作者对 App 开发所做的贡献:
71 | ahsaboy: 添加应用图标、快捷开关、Tasker集成并优化了诸多功能
72 | z156854666: 新增同时启动多个frpc功能
73 | frp 作者 (fatedier):
74 | 设置
75 | 主题模式
76 | 深色
77 | 浅色
78 | 跟随系统
79 | 允许 Tasker 调用
80 | 最近任务中排除
81 | 隐藏服务启停提示
82 | 通知权限未授予,后台运行通知将无法显示
83 | 去设置
84 | %1$s:%2$s
85 | frpc
86 | frps
87 | 运行中
88 | 编辑
89 | 删除配置
90 | 日志
91 | 确认删除
92 | 确认删除 %1$s 吗?
93 | 已停止所有配置
94 | frp 配置为空
95 | 配置文件不存在
96 | frp 正在运行
97 | 没有可用应用打开该配置
98 | 首次使用引导
99 | 为确保 frp 后台稳定运行,请完成以下设置。
100 | 授予通知权限
101 | 通知权限用于将后台服务提升为前台服务,避免被系统杀死。\n\n如果觉得该通知打扰了您,可以在系统设置中关闭frp后台服务的通知而不关闭整个应用的通知权限。
102 | 尚未授予通知权限
103 | 去授予通知权限
104 | 关闭电池优化
105 | 将应用加入电池优化白名单,减少后台被清理的概率。
106 | 电池优化可能限制后台运行
107 | 申请忽略电池优化
108 | 已完成
109 | 完成
110 | 重新打开首次使用引导
111 | 配置与设置备份
112 | 导入备份
113 | 导出备份
114 | 备份导出成功
115 | 备份导出失败:%1$s
116 | 备份导入成功
117 | 备份导入失败:%1$s
118 |
--------------------------------------------------------------------------------
/scripts/update_frp_binaries.ps1:
--------------------------------------------------------------------------------
1 | <#
2 | .SYNOPSIS
3 | Download and extract frp release assets then place frpc/frps into Android jniLibs directories.
4 |
5 | .DESCRIPTION
6 | This script downloads the latest frp release (or a specified tag), extracts the architecture-specific
7 | assets, and copies frpc and frps to the appropriate jniLibs directories, renaming them to
8 | libfrpc.so and libfrps.so respectively.
9 |
10 | .PARAMETER Tag
11 | Specific release tag to fetch. If omitted, this script uses the latest release.
12 |
13 | .PARAMETER DestBase
14 | Destination base directory for jniLibs (default: app/src/main/jniLibs).
15 |
16 | .PARAMETER DryRun
17 | If specified, the script will print actions but will not download or write files.
18 |
19 | .PARAMETER Token
20 | GitHub token (optional) for increased API rate limits.
21 |
22 | .EXAMPLE
23 | # Dry-run
24 | pwsh ./scripts/update_frp_binaries.ps1 -DryRun
25 |
26 | # Download latest release and update jniLibs
27 | pwsh ./scripts/update_frp_binaries.ps1
28 |
29 | # Use specific tag
30 | pwsh ./scripts/update_frp_binaries.ps1 -Tag v0.65.0
31 | #>
32 |
33 | param(
34 | [string]$Tag = '',
35 | [string]$DestBase = 'app/src/main/jniLibs',
36 | [switch]$DryRun,
37 | [string]$Token = ''
38 | )
39 |
40 | $REPO_OWNER = 'fatedier'
41 | $REPO_NAME = 'frp'
42 | $API_BASE = "https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/releases"
43 |
44 | function Log($message) { Write-Host "[INFO] $message" }
45 | function Err($message) { Write-Host "[ERROR] $message" -ForegroundColor Red }
46 |
47 | # Arch mapping
48 | $ARCH_MAP = @{
49 | 'arm64-v8a' = 'android_arm64'
50 | 'x86_64' = 'linux_amd64'
51 | 'armeabi-v7a' = 'linux_arm'
52 | }
53 |
54 | if (-not (Test-Path $DestBase)) {
55 | if (-not $DryRun) {
56 | New-Item -ItemType Directory -Path $DestBase -Force | Out-Null
57 | } else {
58 | Log "DRY RUN: would create $DestBase"
59 | }
60 | }
61 |
62 | # Prepare API URL
63 | if ($Tag -ne '') {
64 | $ApiUrl = "$API_BASE/tags/$Tag"
65 | } else {
66 | $ApiUrl = "$API_BASE/latest"
67 | }
68 |
69 | Log "Fetching release info from GitHub: $ApiUrl"
70 |
71 | $headers = @{ 'Accept' = 'application/vnd.github.v3+json' }
72 | if ($Token -ne '') { $headers['Authorization'] = "token $Token" }
73 |
74 | if ($DryRun) { Log "DRY RUN: will not perform downloads or write files. Showing intended behavior..." }
75 |
76 | # Try to fetch JSON
77 | try {
78 | $release = Invoke-RestMethod -Uri $ApiUrl -Headers $headers -UseBasicParsing
79 | }
80 | catch {
81 | Err "Failed to fetch release info: $_"
82 | exit 1
83 | }
84 |
85 | # Work dir
86 | $TempBase = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "frp-update-$([System.Guid]::NewGuid().ToString())")
87 | if (-not $DryRun) { New-Item -ItemType Directory -Path $TempBase -Force | Out-Null }
88 | Log "Temporary work dir: $TempBase"
89 |
90 | function Cleanup {
91 | if (Test-Path $TempBase -PathType Container) {
92 | try { Remove-Item -Recurse -Force -Path $TempBase -ErrorAction SilentlyContinue } catch { }
93 | }
94 | }
95 |
96 | # Ensure cleanup on exit
97 | Register-EngineEvent PowerShell.Exiting -Action { Cleanup } | Out-Null
98 |
99 | function Find-AssetUrl([string]$pattern) {
100 | # Try regex match: frp_.*_${pattern}.*(tar.gz|zip)$
101 | $regex = [regex]"frp_.*_${pattern}.*(tar\.gz|zip)$"
102 | $asset = $release.assets | Where-Object { $regex.IsMatch($_.name) } | Select-Object -First 1
103 | if (-not $asset) {
104 | # fallback contains pattern
105 | $asset = $release.assets | Where-Object { $_.name -like "*$pattern*" } | Select-Object -First 1
106 | }
107 | return $asset
108 | }
109 |
110 | function Extract-Archive([string]$archive, [string]$destination) {
111 | New-Item -ItemType Directory -Path $destination -Force | Out-Null
112 |
113 | if ($archive -match '\.zip$') {
114 | # Expand-Archive works for zip on PowerShell
115 | try {
116 | Expand-Archive -LiteralPath $archive -DestinationPath $destination -Force
117 | return $true
118 | } catch {
119 | Err "Failed to expand zip: $_"
120 | return $false
121 | }
122 | }
123 | elseif ($archive -match '\.tar.gz$' -or $archive -match '\.tgz$') {
124 | # Prefer tar command if available
125 | $tarCmd = Get-Command tar -ErrorAction SilentlyContinue
126 | if ($tarCmd) {
127 | $psi = New-Object System.Diagnostics.ProcessStartInfo
128 | $psi.FileName = $tarCmd.Source
129 | $psi.Arguments = "-xzf `"$archive`" -C `"$destination`""
130 | $psi.RedirectStandardError = $true
131 | $psi.RedirectStandardOutput = $true
132 | $psi.UseShellExecute = $false
133 | $proc = [System.Diagnostics.Process]::Start($psi)
134 | $proc.WaitForExit()
135 | if ($proc.ExitCode -ne 0) {
136 | Err "tar failed with exit code $($proc.ExitCode): $($proc.StandardError.ReadToEnd())"
137 | return $false
138 | }
139 | return $true
140 | } else {
141 | Err "tar command not found; cannot extract tar.gz on this platform"
142 | return $false
143 | }
144 | }
145 | else {
146 | Err "Unsupported archive format: $archive"
147 | return $false
148 | }
149 | }
150 |
151 | function Ensure-Executable($file) {
152 | # On *nix, ensure executable bit if chmod exists
153 | $chmodCmd = Get-Command chmod -ErrorAction SilentlyContinue
154 | if ($chmodCmd -and (Test-Path $file)) {
155 | & $chmodCmd +x $file
156 | }
157 | }
158 |
159 | function Process-Asset([string]$pattern, [string]$abiDir) {
160 | $asset = Find-AssetUrl -pattern $pattern
161 | if (-not $asset) {
162 | Err "Cannot find asset matching pattern '$pattern' for ABI $abiDir. Skipping..."
163 | return $false
164 | }
165 |
166 | Log "Found asset for ${abiDir}: $($asset.name) - $($asset.browser_download_url)"
167 |
168 | $filename = [System.IO.Path]::Combine($TempBase, $asset.name)
169 |
170 | if ($DryRun) {
171 | Log "DRY RUN: Would download $($asset.browser_download_url) to $filename"
172 | } else {
173 | try {
174 | Log "Downloading $($asset.browser_download_url) to $filename"
175 | Invoke-WebRequest -Uri $asset.browser_download_url -Headers $headers -OutFile $filename -UseBasicParsing
176 | } catch {
177 | Err "Download failed: $_"
178 | return $false
179 | }
180 | }
181 |
182 | $extractDir = [System.IO.Path]::Combine($TempBase, "extract_$abiDir")
183 |
184 | if ($DryRun) {
185 | Log "DRY RUN: Would extract $filename to $extractDir and copy binaries to $DestBase/$abiDir"
186 | return $true
187 | }
188 |
189 | if (-not (Extract-Archive -archive $filename -destination $extractDir)) {
190 | Err "Failed to extract $filename"
191 | return $false
192 | }
193 |
194 | # Find frpc and frps
195 | $frpc = Get-ChildItem -Path $extractDir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq 'frpc' -or $_.Name -eq 'frp' } | Select-Object -First 1
196 | $frps = Get-ChildItem -Path $extractDir -Recurse -File -ErrorAction SilentlyContinue | Where-Object { $_.Name -eq 'frps' -or $_.Name -eq 'frp' } | Select-Object -First 1
197 |
198 | if (-not $frpc -or -not $frps) {
199 | Err "frpc or frps not found in archive $($asset.name). Listing files for debugging:"
200 | Get-ChildItem -Path $extractDir -Recurse -File | Select-Object -First 200 | ForEach-Object { Write-Host " $_.FullName" }
201 | return $false
202 | }
203 |
204 | $destDir = Join-Path $DestBase $abiDir
205 | if (-not (Test-Path $destDir)) { New-Item -ItemType Directory -Path $destDir -Force | Out-Null }
206 |
207 | $outFrpc = Join-Path $destDir 'libfrpc.so'
208 | $outFrps = Join-Path $destDir 'libfrps.so'
209 |
210 | try {
211 | Copy-Item -Path $frpc.FullName -Destination $outFrpc -Force
212 | Ensure-Executable $outFrpc
213 | Copy-Item -Path $frps.FullName -Destination $outFrps -Force
214 | Ensure-Executable $outFrps
215 | } catch {
216 | Err "Failed to copy binaries: $_"
217 | return $false
218 | }
219 |
220 | Log "Updated jniLibs for ${abiDir}:"
221 | Get-ChildItem -Path $destDir -Force | ForEach-Object { Write-Host " $_" }
222 | return $true
223 | }
224 |
225 | # Process each arch
226 | foreach ($kv in $ARCH_MAP.GetEnumerator()) {
227 | $abi = $kv.Key
228 | $mapping = $kv.Value
229 | $patterns = @()
230 | if ($mapping -eq 'linux_arm') {
231 | # 先尝试 linux_arm_hf,再回退到 linux_arm
232 | $patterns = @('linux_arm_hf','linux_arm')
233 | }
234 | elseif ($mapping -eq 'android_arm64') {
235 | # 旧版本缺少 android_arm64 资产时,回退使用 linux_arm64
236 | $patterns = @('android_arm64','linux_arm64')
237 | }
238 | else {
239 | $patterns = @($mapping)
240 | }
241 |
242 | $succeeded = $false
243 | foreach ($p in $patterns) {
244 | if (Process-Asset -pattern $p -abiDir $abi) { $succeeded = $true; break }
245 | }
246 | if (-not $succeeded) { Err "Failed to process asset for ABI $abi" }
247 | }
248 |
249 | Log "All done."
250 | if ($DryRun) { Log "DRY RUN complete. No files were written." }
251 |
252 | # Cleanup
253 | Cleanup
254 |
255 | exit 0
256 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/tasker/TaskerStartFrpAction.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp.tasker
2 |
3 | import android.app.Activity
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.os.Bundle
7 | import android.view.View
8 | import android.widget.AdapterView
9 | import android.widget.ArrayAdapter
10 | import android.widget.Button
11 | import android.widget.RadioGroup
12 | import android.widget.Spinner
13 | import android.widget.TextView
14 | import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerActionNoOutput
15 | import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig
16 | import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelperNoOutput
17 | import com.joaomgcd.taskerpluginlibrary.input.TaskerInput
18 | import com.joaomgcd.taskerpluginlibrary.input.TaskerInputField
19 | import com.joaomgcd.taskerpluginlibrary.input.TaskerInputRoot
20 | import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult
21 | import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultError
22 | import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess
23 | import io.github.acedroidx.frp.FrpConfig
24 | import io.github.acedroidx.frp.FrpType
25 | import io.github.acedroidx.frp.IntentExtraKey
26 | import io.github.acedroidx.frp.PreferencesKey
27 | import io.github.acedroidx.frp.R
28 | import io.github.acedroidx.frp.ShellService
29 | import io.github.acedroidx.frp.ShellServiceAction
30 |
31 | /**
32 | * Input class for Tasker - defines which FRP configuration to start
33 | */
34 | @TaskerInputRoot
35 | class StartFrpInput @JvmOverloads constructor(
36 | @field:TaskerInputField("frpType") var frpType: String? = null,
37 | @field:TaskerInputField("configFileName") var configFileName: String? = null
38 | )
39 |
40 | /**
41 | * Config Helper - manages the configuration UI and validation
42 | */
43 | class StartFrpHelper(config: TaskerPluginConfig) :
44 | TaskerPluginConfigHelperNoOutput(config) {
45 |
46 | override val runnerClass: Class get() = StartFrpRunner::class.java
47 | override val inputClass: Class get() = StartFrpInput::class.java
48 |
49 | override fun addToStringBlurb(input: TaskerInput, blurbBuilder: StringBuilder) {
50 | val frpType = input.regular.frpType ?: "frpc"
51 | val fileName = input.regular.configFileName ?: "default"
52 | blurbBuilder.append("\nStart [$frpType] $fileName")
53 | }
54 | }
55 |
56 | /**
57 | * Config Activity - the UI that appears when configuring the Tasker action
58 | */
59 | class ActivityConfigStartFrp : Activity(), TaskerPluginConfig {
60 | override val context: Context get() = applicationContext
61 |
62 | private val taskerHelper by lazy { StartFrpHelper(this) }
63 |
64 | private lateinit var radioGroupType: RadioGroup
65 | private lateinit var spinnerConfig: Spinner
66 | private lateinit var textViewNoConfig: TextView
67 | private lateinit var buttonSave: Button
68 | private lateinit var buttonCancel: Button
69 |
70 | private var selectedFrpType: FrpType = FrpType.FRPC
71 | private var configFiles: List = emptyList()
72 | private var selectedConfigFile: String? = null
73 |
74 | override fun assignFromInput(input: TaskerInput) {
75 | // Load previously saved configuration
76 | val frpType = input.regular.frpType
77 | val configFileName = input.regular.configFileName
78 |
79 | if (!frpType.isNullOrBlank() && frpType != "%frpType") {
80 | selectedFrpType = when (frpType.lowercase()) {
81 | "frps" -> FrpType.FRPS
82 | else -> FrpType.FRPC
83 | }
84 | }
85 |
86 | if (!configFileName.isNullOrBlank() && configFileName != "%configFileName") {
87 | selectedConfigFile = configFileName
88 | }
89 | }
90 |
91 | override val inputForTasker: TaskerInput
92 | get() {
93 | val frpType = selectedFrpType.typeName
94 | val configFileName = selectedConfigFile ?: ""
95 | return TaskerInput(StartFrpInput(frpType, configFileName))
96 | }
97 |
98 | override fun onCreate(savedInstanceState: Bundle?) {
99 | super.onCreate(savedInstanceState)
100 | setContentView(R.layout.activity_tasker_config_selector)
101 |
102 | // Initialize views
103 | radioGroupType = findViewById(R.id.radioGroupFrpType)
104 | spinnerConfig = findViewById(R.id.spinnerConfig)
105 | textViewNoConfig = findViewById(R.id.textViewNoConfig)
106 | buttonSave = findViewById(R.id.buttonSave)
107 | buttonCancel = findViewById(R.id.buttonCancel)
108 |
109 | // Load input from Tasker if editing existing action
110 | taskerHelper.onCreate()
111 |
112 | // Set up radio group listener
113 | radioGroupType.setOnCheckedChangeListener { _, checkedId ->
114 | selectedFrpType = if (checkedId == R.id.radioFrps) {
115 | FrpType.FRPS
116 | } else {
117 | FrpType.FRPC
118 | }
119 | loadConfigFiles()
120 | }
121 |
122 | // Set initial selection based on loaded data
123 | if (selectedFrpType == FrpType.FRPS) {
124 | radioGroupType.check(R.id.radioFrps)
125 | } else {
126 | radioGroupType.check(R.id.radioFrpc)
127 | }
128 |
129 | // Load config files
130 | loadConfigFiles()
131 |
132 | // Set up buttons
133 | buttonSave.setOnClickListener {
134 | if (selectedConfigFile != null) {
135 | taskerHelper.finishForTasker()
136 | }
137 | }
138 |
139 | buttonCancel.setOnClickListener {
140 | finish()
141 | }
142 | }
143 |
144 | private fun loadConfigFiles() {
145 | val dir = selectedFrpType.getDir(this)
146 | configFiles = dir.list()?.toList()?.sorted() ?: emptyList()
147 |
148 | if (configFiles.isEmpty()) {
149 | spinnerConfig.visibility = View.GONE
150 | textViewNoConfig.visibility = View.VISIBLE
151 | buttonSave.isEnabled = false
152 | } else {
153 | spinnerConfig.visibility = View.VISIBLE
154 | textViewNoConfig.visibility = View.GONE
155 | buttonSave.isEnabled = true
156 |
157 | val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, configFiles)
158 | adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
159 | spinnerConfig.adapter = adapter
160 |
161 | // Set selected item if we have one
162 | if (selectedConfigFile != null && configFiles.contains(selectedConfigFile)) {
163 | val index = configFiles.indexOf(selectedConfigFile)
164 | spinnerConfig.setSelection(index)
165 | }
166 |
167 | spinnerConfig.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
168 | override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
169 | selectedConfigFile = configFiles[position]
170 | }
171 |
172 | override fun onNothingSelected(parent: AdapterView<*>?) {
173 | selectedConfigFile = null
174 | }
175 | }
176 | }
177 | }
178 | }
179 |
180 | /**
181 | * Runner - executes the actual action when Tasker calls it
182 | */
183 | class StartFrpRunner : TaskerPluginRunnerActionNoOutput() {
184 | override fun run(context: Context, input: TaskerInput): TaskerPluginResult {
185 | // Check if Tasker is allowed
186 | val preferences = context.getSharedPreferences("data", Context.MODE_PRIVATE)
187 | val allowTasker = preferences.getBoolean(PreferencesKey.ALLOW_TASKER, false)
188 |
189 | if (!allowTasker) {
190 | return TaskerPluginResultError(
191 | Exception("Tasker integration is disabled. Please enable it in Settings.")
192 | )
193 | }
194 |
195 | val frpTypeStr = input.regular.frpType
196 | val configFileName = input.regular.configFileName
197 |
198 | if (frpTypeStr.isNullOrBlank() || configFileName.isNullOrBlank()) {
199 | return TaskerPluginResultError(
200 | Exception("Invalid configuration: frpType and configFileName must be provided")
201 | )
202 | }
203 |
204 | // Parse FRP type
205 | val frpType = when (frpTypeStr.lowercase()) {
206 | "frpc" -> FrpType.FRPC
207 | "frps" -> FrpType.FRPS
208 | else -> return TaskerPluginResultError(
209 | Exception("Invalid frpType: $frpTypeStr. Must be 'frpc' or 'frps'")
210 | )
211 | }
212 |
213 | // Create FrpConfig
214 | val config = FrpConfig(frpType, configFileName)
215 |
216 | // Check if config file exists
217 | val configFile = config.getFile(context)
218 | if (!configFile.exists()) {
219 | return TaskerPluginResultError(
220 | Exception("Configuration file not found: ${configFile.absolutePath}")
221 | )
222 | }
223 |
224 | // Start the FRP service
225 | val intent = Intent(context, ShellService::class.java).apply {
226 | action = ShellServiceAction.START
227 | putExtra(IntentExtraKey.FrpConfig, arrayListOf(config))
228 | }
229 |
230 | try {
231 | context.startService(intent)
232 | return TaskerPluginResultSucess()
233 | } catch (e: Exception) {
234 | return TaskerPluginResultError(e)
235 | }
236 | }
237 | }
238 |
--------------------------------------------------------------------------------
/scripts/update_frp_binaries.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | # update_frp_binaries.sh
3 | # Downloads the latest frp release (or a specified tag), extracts required three arch tarballs,
4 | # and places frps & frpc executables into the jniLibs directories with names libfrps.so and libfrpc.so
5 | # Usage:
6 | # ./scripts/update_frp_binaries.sh [--tag ] [--dest ] [--dry-run] [--token ]
7 |
8 | set -euo pipefail
9 |
10 | REPO_OWNER="fatedier"
11 | REPO_NAME="frp"
12 | API_BASE="https://api.github.com/repos/${REPO_OWNER}/${REPO_NAME}/releases"
13 |
14 | # Default dest base directory
15 | DEST_BASE_DEFAULT="app/src/main/jniLibs"
16 | TAG=""
17 | DEST_BASE="${DEST_BASE_DEFAULT}"
18 | DRY_RUN=0
19 | GITHUB_TOKEN=""
20 |
21 | # Helper function
22 | err() { echo "[ERROR] $*" >&2; }
23 | log() { echo "[INFO] $*"; }
24 | usage() {
25 | cat <] [--dest ] [--dry-run] [--token ]
27 |
28 | Downloads the latest frp release (or provided tag), extracts three asset tarballs and
29 | copies the frpc and frps binaries into Android JNI libs directories as libfrpc.so and libfrps.so
30 | Mapping (default):
31 | frp_*_android_arm64.tar.gz -> ${DEST_BASE}/arm64-v8a
32 | frp_*_linux_amd64.tar.gz -> ${DEST_BASE}/x86_64
33 | frp_*_linux_arm.tar.gz -> ${DEST_BASE}/armeabi-v7a
34 |
35 | Options:
36 | --tag : Release tag (default: latest)
37 | --dest : Base destination directory (default: ${DEST_BASE_DEFAULT})
38 | --dry-run : Show actions but don't download or write files
39 | --token : (Optional) GitHub token to increase rate limit
40 | EOF
41 | }
42 |
43 | # Parse args
44 | while [[ $# -gt 0 ]]; do
45 | case "$1" in
46 | --tag) TAG="$2"; shift 2;;
47 | --dest) DEST_BASE="$2"; shift 2;;
48 | --dry-run) DRY_RUN=1; shift;;
49 | --token) GITHUB_TOKEN="$2"; shift 2;;
50 | -h|--help) usage; exit 0;;
51 | *) err "Unknown arg: $1"; usage; exit 1;;
52 | esac
53 | done
54 |
55 | # Check required tools
56 | for cmd in curl jq tar mkdir mktemp grep; do
57 | if ! command -v "$cmd" >/dev/null 2>&1; then
58 | err "Required command '$cmd' not found. Please install it."; exit 2
59 | fi
60 | done
61 |
62 | # Prepare request URL
63 | if [[ -n "$TAG" ]]; then
64 | API_URL="${API_BASE}/tags/${TAG}"
65 | else
66 | API_URL="${API_BASE}/latest"
67 | fi
68 |
69 | log "Fetching release info from GitHub: ${API_URL}"
70 |
71 | AUTH_ARGS=()
72 | if [[ -n "$GITHUB_TOKEN" ]]; then
73 | AUTH_ARGS=( -H "Authorization: token ${GITHUB_TOKEN}" )
74 | fi
75 |
76 | if [[ $DRY_RUN -eq 1 ]]; then
77 | log "DRY RUN: will not perform downloads or write files. Showing intended behavior..."
78 | fi
79 |
80 | # Get release JSON
81 | # Fetch release JSON (fail hard if GitHub returns an error)
82 | if ! release_json=$(curl -sSL --fail "${API_URL}" -H "Accept: application/vnd.github.v3+json" "${AUTH_ARGS[@]}" ); then
83 | err "Failed to fetch release info from GitHub (${API_URL})"; exit 3
84 | fi
85 | if [[ -z "${release_json}" || "${release_json}" == "null" ]]; then
86 | err "Release info is empty or null from GitHub"; exit 3
87 | fi
88 |
89 | # Architecture mapping
90 | declare -A ARCH_MAP
91 | ARCH_MAP["arm64-v8a"]="android_arm64"
92 | ARCH_MAP["x86_64"]="linux_amd64"
93 | ARCH_MAP["armeabi-v7a"]="linux_arm"
94 |
95 | # Make DEST_BASE if not exists
96 | if [[ $DRY_RUN -eq 0 && ! -d ${DEST_BASE} ]]; then
97 | log "Creating dest base ${DEST_BASE}"
98 | mkdir -p "${DEST_BASE}"
99 | fi
100 |
101 | TMP_BASE=$(mktemp -d)
102 | trap '[[ -d "$TMP_BASE" ]] && rm -rf "$TMP_BASE"' EXIT
103 |
104 | log "Temporary work dir: ${TMP_BASE}"
105 |
106 | # Function to find an asset for a given pattern
107 | find_asset_url() {
108 | local pattern="$1" # pattern to match like 'linux_amd64' or 'android_arm64'
109 | # Build a regex: frp_.*_${pattern}.*.(tar.gz|zip)
110 | local regex="frp_.*_${pattern}.*\\.(tar\\.gz|zip)$"
111 |
112 | # Use jq to find browser_download_url matching regex
113 | local url
114 | url=$(jq -r --arg re "${regex}" '.assets[] | select(.name | test($re)) | .browser_download_url' <<<"${release_json}" | head -n 1)
115 | if [[ -z "$url" || "$url" == "null" ]]; then
116 | # fallback: contains substring
117 | url=$(jq -r --arg s "${pattern}" '.assets[] | select(.name | contains($s)) | .browser_download_url' <<<"${release_json}" | head -n 1)
118 | fi
119 | printf '%s' "$url"
120 | }
121 |
122 | # Function to process an asset: download, extract frpc / frps, copy to dest with rename
123 | process_asset() {
124 | local pattern="$1" # e.g. linux_amd64
125 | local abi_dir="$2" # e.g. arm64-v8a
126 |
127 | local asset_url
128 | asset_url=$(find_asset_url "$pattern")
129 | if [[ -z "$asset_url" || "$asset_url" == "null" ]]; then
130 | err "Cannot find asset for pattern '${pattern}' (abi ${abi_dir}) in release. Skipping..."
131 | # 1 = asset not found (non-fatal when trying alternate candidates)
132 | return 1
133 | fi
134 |
135 | log "Found asset for ${abi_dir}: ${asset_url}"
136 |
137 | local filename
138 | filename="${TMP_BASE}/$(basename "${asset_url}")"
139 |
140 | if [[ $DRY_RUN -eq 1 ]]; then
141 | log "DRY RUN: would download ${asset_url} to ${filename}"
142 | else
143 | log "Downloading ${asset_url}..."
144 | if ! curl -sSL --fail -o "${filename}" "${asset_url}"; then
145 | err "Download failed for ${asset_url}";
146 | # 2 = download failed (fatal)
147 | return 2
148 | fi
149 | fi
150 |
151 | local extract_dir="${TMP_BASE}/extract_${abi_dir}"
152 | mkdir -p "${extract_dir}"
153 |
154 | # List contents and try to find frpc & frps paths
155 | if [[ $DRY_RUN -eq 1 ]]; then
156 | # When doing a dry-run, show the asset name and URL we would download.
157 | local asset_name
158 | asset_name=$(jq -r --arg s "${pattern}" '.assets[] | select(.name | test($s)) | .name' <<<"${release_json}" | head -n 1)
159 | log "DRY RUN: Would download asset: ${asset_name}"
160 | log "DRY RUN: Asset URL: ${asset_url}"
161 | log "DRY RUN: Would extract frpc & frps and place into ${DEST_BASE}/${abi_dir} as libfrpc.so and libfrps.so"
162 | return 0
163 | else
164 | # Get file list
165 | if ! file_list=$(tar -tzf "${filename}"); then
166 | err "Failed to list archive ${filename}";
167 | # 3 = invalid archive or listing failed (fatal)
168 | return 3
169 | fi
170 |
171 | frpc_path=$(grep -E '/frpc$|(^|/)frpc$' <<<"${file_list}" | head -n1 || true)
172 | frps_path=$(grep -E '/frps$|(^|/)frps$' <<<"${file_list}" | head -n1 || true)
173 |
174 | if [[ -z "$frpc_path" || -z "$frps_path" ]]; then
175 | # Maybe frpc is named differently (e.g. frp or frp_client?). Search more broadly
176 | frpc_path=$(grep -E '(^|/)frpc$|(^|/)frp$' <<<"${file_list}" | head -n1 || true)
177 | frps_path=$(grep -E '(^|/)frps$|(^|/)frp' <<<"${file_list}" | head -n1 || true)
178 | fi
179 |
180 | if [[ -z "$frpc_path" || -z "$frps_path" ]]; then
181 | err "frpc or frps not found in ${filename}. Listing files and skipping"
182 | echo "$file_list" | sed -n '1,100p'
183 | return 1
184 | fi
185 |
186 | log "Extracting $frpc_path and $frps_path to ${extract_dir}"
187 | if ! tar -xzf "${filename}" -C "${extract_dir}" "$frpc_path" "$frps_path"; then
188 | err "Extraction failed for ${filename}";
189 | # 4 = extraction failed (fatal)
190 | return 4
191 | fi
192 |
193 | # The extracted files will be at ${extract_dir}/$frpc_path, get their basenames
194 | frpc_basename=$(basename "$frpc_path")
195 | frps_basename=$(basename "$frps_path")
196 |
197 | src_frpc="${extract_dir}/${frpc_path}"
198 | src_frps="${extract_dir}/${frps_path}"
199 |
200 | if [[ ! -f "$src_frpc" ]]; then
201 | # If extraction put file without path
202 | src_frpc=$(find "${extract_dir}" -type f -name "$frpc_basename" | head -n1 || true)
203 | fi
204 | if [[ ! -f "$src_frps" ]]; then
205 | src_frps=$(find "${extract_dir}" -type f -name "$frps_basename" | head -n1 || true)
206 | fi
207 |
208 | if [[ -z "$src_frpc" || -z "$src_frps" ]]; then
209 | err "Failed to locate extracted frpc/frps files. Please inspect ${extract_dir}"
210 | ls -l "${extract_dir}" || true
211 | # 5 = missing expected binaries in archive (fatal)
212 | return 5
213 | fi
214 |
215 | dest_dir="${DEST_BASE}/${abi_dir}"
216 | if [[ ! -d "${dest_dir}" ]]; then
217 | log "Creating dest dir ${dest_dir}"
218 | mkdir -p "${dest_dir}"
219 | fi
220 |
221 | # Write to the dest with required names
222 | out_frpc="${dest_dir}/libfrpc.so"
223 | out_frps="${dest_dir}/libfrps.so"
224 |
225 | log "Copying ${src_frpc} -> ${out_frpc}"
226 | cp -f "${src_frpc}" "${out_frpc}"
227 | chmod +x "${out_frpc}"
228 |
229 | log "Copying ${src_frps} -> ${out_frps}"
230 | cp -f "${src_frps}" "${out_frps}"
231 | chmod +x "${out_frps}"
232 |
233 | log "Updated jniLibs for ${abi_dir}:"
234 | ls -l "${dest_dir}"
235 | fi
236 |
237 | return 0
238 | }
239 |
240 | # Process each ARCH
241 | for abi in "${!ARCH_MAP[@]}"; do
242 | mapping=${ARCH_MAP[$abi]}
243 | # For linux arm, accept both linux_arm and linux_arm_hf (hf = hardware float) in matching
244 | if [[ "$mapping" == "linux_arm" ]]; then
245 | # 优先尝试 linux_arm_hf,其次退回 linux_arm
246 | pattern_candidates=("linux_arm_hf" "linux_arm")
247 | elif [[ "$mapping" == "android_arm64" ]]; then
248 | # 旧版本缺少 android_arm64 资产时,回退到 linux_arm64
249 | pattern_candidates=("android_arm64" "linux_arm64")
250 | else
251 | pattern_candidates=("${mapping}")
252 | fi
253 |
254 | downloaded=1
255 | for candidate in "${pattern_candidates[@]}"; do
256 | if process_asset "$candidate" "$abi"; then
257 | downloaded=0
258 | break
259 | else
260 | rc=$?
261 | # If asset wasn't found for this candidate, try the next candidate (rc == 1)
262 | if [[ $rc -eq 1 ]]; then
263 | log "Asset not found for candidate '${candidate}', trying next candidate if available..."
264 | continue
265 | else
266 | err "Fatal error processing asset for abi ${abi} (candidate: ${candidate}), exit code ${rc}. Exiting immediately."
267 | exit $rc
268 | fi
269 | fi
270 | done
271 |
272 | if [[ $downloaded -ne 0 ]]; then
273 | err "No suitable asset found for abi ${abi}. Exiting with code 6."
274 | exit 6
275 | fi
276 | done
277 |
278 | log "All done."
279 |
280 | if [[ $DRY_RUN -eq 1 ]]; then
281 | log "DRY RUN complete. No files were written."
282 | fi
283 |
284 | exit 0
285 |
--------------------------------------------------------------------------------
/app/src/main/res/values/strings.xml:
--------------------------------------------------------------------------------
1 |
2 | frp
3 | frp for Android
4 | frp for Android - %1$s/%2$s
5 | Loading…
6 | Back
7 | Settings
8 | Rename icon
9 | Help
10 | Auto-start Switch
11 | frp Auto-start Settings
12 | Startup at Boot
13 | Startup at App Launch
14 | Startup at Broadcast
15 | Stop at Broadcast
16 | Allow Broadcast with Extra
17 | The options below apply only to configurations whose "Auto-start Switch" is enabled in their own config.
18 | When enabled, receiving the start broadcast (action: %1$s) will automatically start every configuration whose "Auto-start Switch" is on.\n\nExample:\nadb shell am broadcast -a %1$s io.github.acedroidx.frp
19 | When enabled, receiving the stop broadcast (action: %1$s) will automatically stop every configuration whose "Auto-start Switch" is on.\n\nExample:\nadb shell am broadcast -a %1$s io.github.acedroidx.frp
20 | When enabled, the broadcast may carry %2$s (frpc or frps) and %3$s (config file name) to start or stop only the specified config; otherwise it affects all configs with auto-start enabled.\n\nExamples:\n\n# Start frpc config named example.toml\nadb shell am broadcast -a %1$s -e %2$s frpc -e %3$s example.toml io.github.acedroidx.frp\n\n# Start frps config named server.toml\nadb shell am broadcast -a %1$s -e %2$s frps -e %3$s server.toml io.github.acedroidx.frp\n\n# Stop frpc config named example.toml\nadb shell am broadcast -a %4$s -e %2$s frpc -e %3$s example.toml io.github.acedroidx.frp\n\n# Stop frps config named server.toml\nadb shell am broadcast -a %4$s -e %2$s frps -e %3$s server.toml io.github.acedroidx.frp
21 | Add Config
22 | About
23 | frp Log
24 | Clear
25 | No Log
26 | Save
27 | Don\'t Save
28 | Delete
29 | frp Background Service
30 | frp Background Service Notification
31 | frp Service Started
32 | frp Service Stopped
33 | frp Background Service
34 | %d frp Config Started
35 | Stop
36 | Copy
37 | Copied
38 | No Config. Click button to add one!
39 | Rename
40 | Confirm
41 | Dismiss
42 | Select the type of frp to create
43 | Choose a template to create
44 | No templates found. Please check assets/examples.
45 | File: %1$s
46 | Type: %1$s
47 | Tasker frp Action
48 | This action will start the specified frp configuration. To use: Set variables in Tasker: • %frpType - \"frpc\" or \"frps\" • %configFileName - name of config file
49 | Select frp Configuration
50 | Select frp Type:
51 | Select Configuration File:
52 | No configuration files found. Please create a configuration first in the main app.
53 | Stop frp Configuration
54 | Stop All Running Configs
55 | Notification Permission Required
56 | This app needs notification permission to show background service status. Please grant the permission to ensure proper functionality.
57 | frp Toggle
58 | Quick Tile Config
59 | Not Selected
60 | Not Configured
61 | Running
62 | Stopped
63 | No Quick Tile Config selected. Please go to \"Settings - Quick Tile Config\" to select the frp configuration you want to start.
64 | frp Config I/O
65 | Allow other apps to read configs
66 | Allow other apps to write configs
67 | Expose config files via ContentProvider for read-only access. Enable only if you trust the requesting app; configs may contain sensitive tokens.
68 | Allow other apps to modify config files via ContentProvider. Use with caution; unsafe writes may break frp connectivity.
69 | Before using, turn on the "frp Config I/O" read/write switches in Settings and be aware of the possible disclosure of config passwords.\n\nExamples:\n\n# List all configs (requires "Allow read")\nadb shell content query --uri content://io.github.acedroidx.frp.config\n\n# Read a single config (requires "Allow read")\nadb shell content read --uri content://io.github.acedroidx.frp.config/frpc/example.toml\n\n# Write a single config (requires "Allow write")\n# Overwrite the device config with local example.toml\nadb shell content write --uri content://io.github.acedroidx.frp.config/frpc/example.toml < example.toml
70 | App author (AceDroidX):
71 | Special thanks to the following contributors:
72 | ahsaboy: added app icon, quick toggle, Tasker integration, and many optimizations
73 | z156854666: added support for starting multiple frpc instances simultaneously
74 | frp author (fatedier):
75 | Settings
76 | Theme mode
77 | Dark
78 | Light
79 | Follow system
80 | Allow Tasker
81 | Exclude from recents
82 | Hide service start/stop toast
83 | Notification permission is not granted, background notifications will not be shown.
84 | Open settings
85 | %1$s: %2$s
86 | frpc
87 | frps
88 | Running
89 | Edit
90 | Delete config
91 | Log
92 | Confirm delete
93 | Delete %1$s?
94 | All configurations stopped
95 | frp config is null
96 | Config file does not exist
97 | frp is already running
98 | No app available to open this config
99 | Welcome
100 | To keep frp stable in the background, please complete these steps.
101 | Grant notification permission
102 | Notification permission is used to promote the background service to a foreground service to avoid being killed by the system.\n\nIf you find the notification bothersome, you can disable the frp background service notification in system settings without turning off the app notification permission.
103 | Notification permission not granted
104 | Grant notification permission
105 | Disable battery optimizations
106 | Allow frp to ignore battery optimizations so the service is less likely to be killed.
107 | Battery optimizations may stop background service
108 | Request optimization exemption
109 | Done
110 | Continue
111 | Reopen welcome onboarding
112 | Backup & Restore
113 | Import Backup
114 | Export Backup
115 | Backup exported
116 | Backup export failed: %1$s
117 | Backup imported
118 | Backup import failed: %1$s
119 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/ConfigActivity.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import android.content.SharedPreferences
4 | import android.os.Build
5 | import android.os.Bundle
6 | import android.util.Log
7 | import android.widget.Toast
8 | import androidx.activity.ComponentActivity
9 | import androidx.activity.compose.setContent
10 | import androidx.activity.enableEdgeToEdge
11 | import androidx.compose.foundation.layout.Arrangement
12 | import androidx.compose.foundation.layout.Column
13 | import androidx.compose.foundation.layout.Row
14 | import androidx.compose.foundation.layout.fillMaxSize
15 | import androidx.compose.foundation.layout.fillMaxWidth
16 | import androidx.compose.foundation.layout.imePadding
17 | import androidx.compose.foundation.layout.PaddingValues
18 | import androidx.compose.foundation.layout.padding
19 | import androidx.compose.material3.AlertDialog
20 | import androidx.compose.material3.Button
21 | import androidx.compose.material3.ExperimentalMaterial3Api
22 | import androidx.compose.material3.Icon
23 | import androidx.compose.material3.IconButton
24 | import androidx.compose.material3.MaterialTheme
25 | import androidx.compose.material3.Scaffold
26 | import androidx.compose.material3.Switch
27 | import androidx.compose.material3.Text
28 | import androidx.compose.material3.TextButton
29 | import androidx.compose.material3.TextField
30 | import androidx.compose.material3.TopAppBar
31 | import androidx.compose.runtime.Composable
32 | import androidx.compose.runtime.LaunchedEffect
33 | import androidx.compose.runtime.getValue
34 | import androidx.compose.runtime.mutableStateOf
35 | import androidx.compose.runtime.remember
36 | import androidx.compose.runtime.setValue
37 | import androidx.compose.ui.Alignment
38 | import androidx.compose.ui.Modifier
39 | import androidx.compose.ui.focus.FocusRequester
40 | import androidx.compose.ui.focus.focusRequester
41 | import androidx.compose.ui.res.painterResource
42 | import androidx.compose.ui.res.stringResource
43 | import androidx.compose.ui.text.font.FontFamily
44 | import androidx.compose.ui.tooling.preview.Preview
45 | import androidx.compose.ui.unit.dp
46 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
47 | import io.github.acedroidx.frp.ui.theme.FrpTheme
48 | import io.github.acedroidx.frp.ui.theme.ThemeModeKeys
49 | import kotlinx.coroutines.flow.MutableStateFlow
50 | import java.io.File
51 | import androidx.compose.runtime.collectAsState
52 | import androidx.core.content.edit
53 |
54 | class ConfigActivity : ComponentActivity() {
55 | private val configEditText = MutableStateFlow("")
56 | private val isAutoStart = MutableStateFlow(false)
57 | private val frpVersion = MutableStateFlow("")
58 | private val themeMode = MutableStateFlow("")
59 | private lateinit var configFile: File
60 | private lateinit var autoStartPreferencesKey: String
61 | private lateinit var preferences: SharedPreferences
62 |
63 | @OptIn(ExperimentalMaterial3Api::class)
64 | override fun onCreate(savedInstanceState: Bundle?) {
65 | super.onCreate(savedInstanceState)
66 |
67 | val frpConfig = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
68 | intent?.extras?.getParcelable(IntentExtraKey.FrpConfig, FrpConfig::class.java)
69 | } else {
70 | @Suppress("DEPRECATION") intent?.extras?.getParcelable(IntentExtraKey.FrpConfig)
71 | }
72 | if (frpConfig == null) {
73 | Log.e("adx", "frp config is null")
74 | Toast.makeText(this, getString(R.string.toast_frp_config_null), Toast.LENGTH_SHORT)
75 | .show()
76 | setResult(RESULT_CANCELED)
77 | finish()
78 | return
79 | }
80 | configFile = frpConfig.getFile(this)
81 | autoStartPreferencesKey = frpConfig.type.getAutoStartPreferencesKey()
82 | preferences = getSharedPreferences("data", MODE_PRIVATE)
83 | val loadingText = getString(R.string.loading)
84 | frpVersion.value =
85 | preferences.getString(PreferencesKey.FRP_VERSION, loadingText) ?: loadingText
86 | val rawTheme = preferences.getString(PreferencesKey.THEME_MODE, ThemeModeKeys.FOLLOW_SYSTEM)
87 | themeMode.value = ThemeModeKeys.normalize(rawTheme)
88 | readConfig()
89 | readIsAutoStart()
90 |
91 | enableEdgeToEdge()
92 | setContent {
93 | val currentTheme by themeMode.collectAsStateWithLifecycle(themeMode.collectAsState().value.ifEmpty { ThemeModeKeys.FOLLOW_SYSTEM })
94 | FrpTheme(themeMode = currentTheme) {
95 | val frpVersion by frpVersion.collectAsStateWithLifecycle(frpVersion.collectAsState().value.ifEmpty { loadingText })
96 | Scaffold(topBar = {
97 | TopAppBar(title = {
98 | Text(
99 | stringResource(
100 | R.string.frp_for_android_version,
101 | BuildConfig.VERSION_NAME,
102 | frpVersion
103 | )
104 | )
105 | }, navigationIcon = {
106 | IconButton(onClick = { closeActivity() }) {
107 | Icon(
108 | painter = painterResource(id = R.drawable.ic_arrow_back_24dp),
109 | contentDescription = stringResource(R.string.content_desc_back)
110 | )
111 | }
112 | })
113 | }) { contentPadding ->
114 | // Screen content
115 | MainContent(contentPadding)
116 | }
117 | }
118 | }
119 | }
120 |
121 | @Preview(showBackground = true)
122 | @Composable
123 | fun MainContent(contentPadding: PaddingValues = PaddingValues(0.dp)) {
124 | val openDialog = remember { mutableStateOf(false) }
125 | val focusRequester = remember { FocusRequester() }
126 |
127 | LaunchedEffect(Unit) {
128 | focusRequester.requestFocus()
129 | }
130 |
131 | Column(
132 | modifier = Modifier
133 | .fillMaxSize()
134 | .padding(contentPadding)
135 | .imePadding()
136 | ) {
137 | Row(
138 | verticalAlignment = Alignment.CenterVertically,
139 | horizontalArrangement = Arrangement.spacedBy(16.dp),
140 | modifier = Modifier
141 | .fillMaxWidth()
142 | .padding(12.dp)
143 | ) {
144 | Button(onClick = { saveConfig(); closeActivity() }) {
145 | Text(stringResource(R.string.saveConfigButton))
146 | }
147 | Button(onClick = { closeActivity() }) {
148 | Text(stringResource(R.string.dontSaveConfigButton))
149 | }
150 | Button(onClick = { openDialog.value = true }) {
151 | Text(stringResource(R.string.rename))
152 | }
153 | }
154 | Row(
155 | verticalAlignment = Alignment.CenterVertically,
156 | horizontalArrangement = Arrangement.spacedBy(16.dp),
157 | modifier = Modifier
158 | .fillMaxWidth()
159 | .padding(horizontal = 12.dp)
160 | ) {
161 | Text(stringResource(R.string.auto_start_switch))
162 | Switch(
163 | checked = isAutoStart.collectAsStateWithLifecycle(false).value,
164 | onCheckedChange = { setAutoStart(it) })
165 | }
166 | TextField(
167 | value = configEditText.collectAsStateWithLifecycle("").value,
168 | onValueChange = { configEditText.value = it },
169 | textStyle = MaterialTheme.typography.bodyMedium.merge(fontFamily = FontFamily.Monospace),
170 | modifier = Modifier
171 | .fillMaxWidth()
172 | .weight(1f)
173 | .padding(horizontal = 12.dp)
174 | .focusRequester(focusRequester)
175 | )
176 | }
177 | if (openDialog.value) {
178 | RenameDialog(configFile.name.removeSuffix(".toml")) { openDialog.value = false }
179 | }
180 | }
181 |
182 | @Composable
183 | fun RenameDialog(
184 | originName: String,
185 | onClose: () -> Unit,
186 | ) {
187 | var text by remember { mutableStateOf(originName) }
188 | AlertDialog(title = {
189 | Text(stringResource(R.string.rename))
190 | }, icon = {
191 | Icon(
192 | painterResource(id = R.drawable.ic_rename),
193 | contentDescription = stringResource(R.string.content_desc_rename_icon)
194 | )
195 | }, text = {
196 | TextField(text, onValueChange = { text = it })
197 | }, onDismissRequest = {
198 | onClose()
199 | }, confirmButton = {
200 | TextButton(onClick = {
201 | renameConfig("$text.toml")
202 | onClose()
203 | }) {
204 | Text(stringResource(R.string.confirm))
205 | }
206 | }, dismissButton = {
207 | TextButton(onClick = {
208 | onClose()
209 | }) {
210 | Text(stringResource(R.string.dismiss))
211 | }
212 | })
213 | }
214 |
215 | fun readConfig() {
216 | if (configFile.exists()) {
217 | val mReader = configFile.bufferedReader()
218 | val mRespBuff = StringBuffer()
219 | val buff = CharArray(1024)
220 | var ch = 0
221 | while (mReader.read(buff).also { ch = it } != -1) {
222 | mRespBuff.append(buff, 0, ch)
223 | }
224 | mReader.close()
225 | configEditText.value = mRespBuff.toString()
226 | } else {
227 | Log.e("adx", "config file is not exist")
228 | Toast.makeText(this, getString(R.string.toast_config_not_exist), Toast.LENGTH_SHORT)
229 | .show()
230 | }
231 | }
232 |
233 | fun saveConfig() {
234 | configFile.writeText(configEditText.value)
235 | }
236 |
237 | fun renameConfig(newName: String) {
238 | val originAutoStart = isAutoStart.value
239 | setAutoStart(false)
240 | val newFile = File(configFile.parent, newName)
241 | configFile.renameTo(newFile)
242 | configFile = newFile
243 | setAutoStart(originAutoStart)
244 | }
245 |
246 | fun readIsAutoStart() {
247 | isAutoStart.value =
248 | preferences.getStringSet(autoStartPreferencesKey, emptySet())?.contains(configFile.name)
249 | ?: false
250 | }
251 |
252 | fun setAutoStart(value: Boolean) {
253 | preferences.edit {
254 | val set = preferences.getStringSet(autoStartPreferencesKey, emptySet())?.toMutableSet()
255 | if (value) {
256 | set?.add(configFile.name)
257 | } else {
258 | set?.remove(configFile.name)
259 | }
260 | putStringSet(autoStartPreferencesKey, set)
261 | }
262 | isAutoStart.value = value
263 | }
264 |
265 | fun closeActivity() {
266 | setResult(RESULT_OK)
267 | finish()
268 | }
269 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 |
5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6 |
7 | 1. Definitions.
8 |
9 | "License" shall mean the terms and conditions for use, reproduction,
10 | and distribution as defined by Sections 1 through 9 of this document.
11 |
12 | "Licensor" shall mean the copyright owner or entity authorized by
13 | the copyright owner that is granting the License.
14 |
15 | "Legal Entity" shall mean the union of the acting entity and all
16 | other entities that control, are controlled by, or are under common
17 | control with that entity. For the purposes of this definition,
18 | "control" means (i) the power, direct or indirect, to cause the
19 | direction or management of such entity, whether by contract or
20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
21 | outstanding shares, or (iii) beneficial ownership of such entity.
22 |
23 | "You" (or "Your") shall mean an individual or Legal Entity
24 | exercising permissions granted by this License.
25 |
26 | "Source" form shall mean the preferred form for making modifications,
27 | including but not limited to software source code, documentation
28 | source, and configuration files.
29 |
30 | "Object" form shall mean any form resulting from mechanical
31 | transformation or translation of a Source form, including but
32 | not limited to compiled object code, generated documentation,
33 | and conversions to other media types.
34 |
35 | "Work" shall mean the work of authorship, whether in Source or
36 | Object form, made available under the License, as indicated by a
37 | copyright notice that is included in or attached to the work
38 | (an example is provided in the Appendix below).
39 |
40 | "Derivative Works" shall mean any work, whether in Source or Object
41 | form, that is based on (or derived from) the Work and for which the
42 | editorial revisions, annotations, elaborations, or other modifications
43 | represent, as a whole, an original work of authorship. For the purposes
44 | of this License, Derivative Works shall not include works that remain
45 | separable from, or merely link (or bind by name) to the interfaces of,
46 | the Work and Derivative Works thereof.
47 |
48 | "Contribution" shall mean any work of authorship, including
49 | the original version of the Work and any modifications or additions
50 | to that Work or Derivative Works thereof, that is intentionally
51 | submitted to Licensor for inclusion in the Work by the copyright owner
52 | or by an individual or Legal Entity authorized to submit on behalf of
53 | the copyright owner. For the purposes of this definition, "submitted"
54 | means any form of electronic, verbal, or written communication sent
55 | to the Licensor or its representatives, including but not limited to
56 | communication on electronic mailing lists, source code control systems,
57 | and issue tracking systems that are managed by, or on behalf of, the
58 | Licensor for the purpose of discussing and improving the Work, but
59 | excluding communication that is conspicuously marked or otherwise
60 | designated in writing by the copyright owner as "Not a Contribution."
61 |
62 | "Contributor" shall mean Licensor and any individual or Legal Entity
63 | on behalf of whom a Contribution has been received by Licensor and
64 | subsequently incorporated within the Work.
65 |
66 | 2. Grant of Copyright License. Subject to the terms and conditions of
67 | this License, each Contributor hereby grants to You a perpetual,
68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69 | copyright license to reproduce, prepare Derivative Works of,
70 | publicly display, publicly perform, sublicense, and distribute the
71 | Work and such Derivative Works in Source or Object form.
72 |
73 | 3. Grant of Patent License. Subject to the terms and conditions of
74 | this License, each Contributor hereby grants to You a perpetual,
75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76 | (except as stated in this section) patent license to make, have made,
77 | use, offer to sell, sell, import, and otherwise transfer the Work,
78 | where such license applies only to those patent claims licensable
79 | by such Contributor that are necessarily infringed by their
80 | Contribution(s) alone or by combination of their Contribution(s)
81 | with the Work to which such Contribution(s) was submitted. If You
82 | institute patent litigation against any entity (including a
83 | cross-claim or counterclaim in a lawsuit) alleging that the Work
84 | or a Contribution incorporated within the Work constitutes direct
85 | or contributory patent infringement, then any patent licenses
86 | granted to You under this License for that Work shall terminate
87 | as of the date such litigation is filed.
88 |
89 | 4. Redistribution. You may reproduce and distribute copies of the
90 | Work or Derivative Works thereof in any medium, with or without
91 | modifications, and in Source or Object form, provided that You
92 | meet the following conditions:
93 |
94 | (a) You must give any other recipients of the Work or
95 | Derivative Works a copy of this License; and
96 |
97 | (b) You must cause any modified files to carry prominent notices
98 | stating that You changed the files; and
99 |
100 | (c) You must retain, in the Source form of any Derivative Works
101 | that You distribute, all copyright, patent, trademark, and
102 | attribution notices from the Source form of the Work,
103 | excluding those notices that do not pertain to any part of
104 | the Derivative Works; and
105 |
106 | (d) If the Work includes a "NOTICE" text file as part of its
107 | distribution, then any Derivative Works that You distribute must
108 | include a readable copy of the attribution notices contained
109 | within such NOTICE file, excluding those notices that do not
110 | pertain to any part of the Derivative Works, in at least one
111 | of the following places: within a NOTICE text file distributed
112 | as part of the Derivative Works; within the Source form or
113 | documentation, if provided along with the Derivative Works; or,
114 | within a display generated by the Derivative Works, if and
115 | wherever such third-party notices normally appear. The contents
116 | of the NOTICE file are for informational purposes only and
117 | do not modify the License. You may add Your own attribution
118 | notices within Derivative Works that You distribute, alongside
119 | or as an addendum to the NOTICE text from the Work, provided
120 | that such additional attribution notices cannot be construed
121 | as modifying the License.
122 |
123 | You may add Your own copyright statement to Your modifications and
124 | may provide additional or different license terms and conditions
125 | for use, reproduction, or distribution of Your modifications, or
126 | for any such Derivative Works as a whole, provided Your use,
127 | reproduction, and distribution of the Work otherwise complies with
128 | the conditions stated in this License.
129 |
130 | 5. Submission of Contributions. Unless You explicitly state otherwise,
131 | any Contribution intentionally submitted for inclusion in the Work
132 | by You to the Licensor shall be under the terms and conditions of
133 | this License, without any additional terms or conditions.
134 | Notwithstanding the above, nothing herein shall supersede or modify
135 | the terms of any separate license agreement you may have executed
136 | with Licensor regarding such Contributions.
137 |
138 | 6. Trademarks. This License does not grant permission to use the trade
139 | names, trademarks, service marks, or product names of the Licensor,
140 | except as required for reasonable and customary use in describing the
141 | origin of the Work and reproducing the content of the NOTICE file.
142 |
143 | 7. Disclaimer of Warranty. Unless required by applicable law or
144 | agreed to in writing, Licensor provides the Work (and each
145 | Contributor provides its Contributions) on an "AS IS" BASIS,
146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147 | implied, including, without limitation, any warranties or conditions
148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149 | PARTICULAR PURPOSE. You are solely responsible for determining the
150 | appropriateness of using or redistributing the Work and assume any
151 | risks associated with Your exercise of permissions under this License.
152 |
153 | 8. Limitation of Liability. In no event and under no legal theory,
154 | whether in tort (including negligence), contract, or otherwise,
155 | unless required by applicable law (such as deliberate and grossly
156 | negligent acts) or agreed to in writing, shall any Contributor be
157 | liable to You for damages, including any direct, indirect, special,
158 | incidental, or consequential damages of any character arising as a
159 | result of this License or out of the use or inability to use the
160 | Work (including but not limited to damages for loss of goodwill,
161 | work stoppage, computer failure or malfunction, or any and all
162 | other commercial damages or losses), even if such Contributor
163 | has been advised of the possibility of such damages.
164 |
165 | 9. Accepting Warranty or Additional Liability. While redistributing
166 | the Work or Derivative Works thereof, You may choose to offer,
167 | and charge a fee for, acceptance of support, warranty, indemnity,
168 | or other liability obligations and/or rights consistent with this
169 | License. However, in accepting such obligations, You may act only
170 | on Your own behalf and on Your sole responsibility, not on behalf
171 | of any other Contributor, and only if You agree to indemnify,
172 | defend, and hold each Contributor harmless for any liability
173 | incurred by, or claims asserted against, such Contributor by reason
174 | of your accepting any such warranty or additional liability.
175 |
176 | END OF TERMS AND CONDITIONS
177 |
178 | APPENDIX: How to apply the Apache License to your work.
179 |
180 | To apply the Apache License to your work, attach the following
181 | boilerplate notice, with the fields enclosed by brackets "[]"
182 | replaced with your own identifying information. (Don't include
183 | the brackets!) The text should be enclosed in the appropriate
184 | comment syntax for the file format. We also recommend that a
185 | file or class name and description of purpose be included on the
186 | same "printed page" as the copyright notice for easier
187 | identification within third-party archives.
188 |
189 | Copyright [yyyy] [name of copyright owner]
190 |
191 | Licensed under the Apache License, Version 2.0 (the "License");
192 | you may not use this file except in compliance with the License.
193 | You may obtain a copy of the License at
194 |
195 | http://www.apache.org/licenses/LICENSE-2.0
196 |
197 | Unless required by applicable law or agreed to in writing, software
198 | distributed under the License is distributed on an "AS IS" BASIS,
199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200 | See the License for the specific language governing permissions and
201 | limitations under the License.
202 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/tasker/TaskerStopFrpAction.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp.tasker
2 |
3 | import android.app.Activity
4 | import android.content.Context
5 | import android.content.Intent
6 | import android.os.Build
7 | import android.os.Bundle
8 | import android.view.View
9 | import android.widget.AdapterView
10 | import android.widget.ArrayAdapter
11 | import android.widget.Button
12 | import android.widget.CheckBox
13 | import android.widget.RadioGroup
14 | import android.widget.Spinner
15 | import android.widget.TextView
16 | import com.joaomgcd.taskerpluginlibrary.action.TaskerPluginRunnerActionNoOutput
17 | import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfig
18 | import com.joaomgcd.taskerpluginlibrary.config.TaskerPluginConfigHelperNoOutput
19 | import com.joaomgcd.taskerpluginlibrary.input.TaskerInput
20 | import com.joaomgcd.taskerpluginlibrary.input.TaskerInputField
21 | import com.joaomgcd.taskerpluginlibrary.input.TaskerInputRoot
22 | import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResult
23 | import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultError
24 | import com.joaomgcd.taskerpluginlibrary.runner.TaskerPluginResultSucess
25 | import io.github.acedroidx.frp.FrpConfig
26 | import io.github.acedroidx.frp.FrpType
27 | import io.github.acedroidx.frp.IntentExtraKey
28 | import io.github.acedroidx.frp.PreferencesKey
29 | import io.github.acedroidx.frp.R
30 | import io.github.acedroidx.frp.ShellService
31 | import io.github.acedroidx.frp.ShellServiceAction
32 |
33 | /**
34 | * Input class for Tasker - defines which FRP configuration to stop
35 | * stopAll=true means stop all running configs, otherwise stop specific config
36 | */
37 | @TaskerInputRoot
38 | class StopFrpInput @JvmOverloads constructor(
39 | @field:TaskerInputField("stopAll") var stopAll: Boolean = false,
40 | @field:TaskerInputField("frpType") var frpType: String? = null,
41 | @field:TaskerInputField("configFileName") var configFileName: String? = null
42 | )
43 |
44 | /**
45 | * Config Helper - manages the configuration UI and validation
46 | */
47 | class StopFrpHelper(config: TaskerPluginConfig) :
48 | TaskerPluginConfigHelperNoOutput(config) {
49 |
50 | override val runnerClass: Class get() = StopFrpRunner::class.java
51 | override val inputClass: Class get() = StopFrpInput::class.java
52 |
53 | override fun addToStringBlurb(input: TaskerInput, blurbBuilder: StringBuilder) {
54 | if (input.regular.stopAll) {
55 | blurbBuilder.append("Stop all FRP configs")
56 | } else {
57 | val frpType = input.regular.frpType ?: "frpc"
58 | val fileName = input.regular.configFileName ?: "default"
59 | blurbBuilder.append("Stop [$frpType] $fileName")
60 | }
61 | }
62 | }
63 |
64 | /**
65 | * Config Activity - the UI that appears when configuring the Tasker action
66 | */
67 | class ActivityConfigStopFrp : Activity(), TaskerPluginConfig {
68 | override val context: Context get() = applicationContext
69 |
70 | private val taskerHelper by lazy { StopFrpHelper(this) }
71 |
72 | private lateinit var checkboxStopAll: CheckBox
73 | private lateinit var radioGroupType: RadioGroup
74 | private lateinit var spinnerConfig: Spinner
75 | private lateinit var textViewNoConfig: TextView
76 | private lateinit var buttonSave: Button
77 | private lateinit var buttonCancel: Button
78 |
79 | private var stopAll: Boolean = false
80 | private var selectedFrpType: FrpType = FrpType.FRPC
81 | private var configFiles: List = emptyList()
82 | private var selectedConfigFile: String? = null
83 |
84 | override fun assignFromInput(input: TaskerInput) {
85 | // Load previously saved configuration
86 | stopAll = input.regular.stopAll
87 |
88 | val frpType = input.regular.frpType
89 | val configFileName = input.regular.configFileName
90 |
91 | if (!frpType.isNullOrBlank() && frpType != "%frpType") {
92 | selectedFrpType = when (frpType.lowercase()) {
93 | "frps" -> FrpType.FRPS
94 | else -> FrpType.FRPC
95 | }
96 | }
97 |
98 | if (!configFileName.isNullOrBlank() && configFileName != "%configFileName") {
99 | selectedConfigFile = configFileName
100 | }
101 | }
102 |
103 | override val inputForTasker: TaskerInput
104 | get() {
105 | return if (stopAll) {
106 | TaskerInput(StopFrpInput(stopAll = true))
107 | } else {
108 | val frpType = selectedFrpType.typeName
109 | val configFileName = selectedConfigFile ?: ""
110 | TaskerInput(StopFrpInput(stopAll = false, frpType = frpType, configFileName = configFileName))
111 | }
112 | }
113 |
114 | override fun onCreate(savedInstanceState: Bundle?) {
115 | super.onCreate(savedInstanceState)
116 | setContentView(R.layout.activity_tasker_config_stop)
117 |
118 | // Initialize views
119 | checkboxStopAll = findViewById(R.id.checkboxStopAll)
120 | radioGroupType = findViewById(R.id.radioGroupFrpType)
121 | spinnerConfig = findViewById(R.id.spinnerConfig)
122 | textViewNoConfig = findViewById(R.id.textViewNoConfig)
123 | buttonSave = findViewById(R.id.buttonSave)
124 | buttonCancel = findViewById(R.id.buttonCancel)
125 |
126 | // Load input from Tasker if editing existing action
127 | taskerHelper.onCreate()
128 |
129 | // Set initial checkbox state
130 | checkboxStopAll.isChecked = stopAll
131 |
132 | // Set up checkbox listener
133 | checkboxStopAll.setOnCheckedChangeListener { _, isChecked ->
134 | stopAll = isChecked
135 | if (isChecked) {
136 | // Hide specific config selection when "Stop All" is checked
137 | radioGroupType.visibility = View.GONE
138 | spinnerConfig.visibility = View.GONE
139 | textViewNoConfig.visibility = View.GONE
140 | buttonSave.isEnabled = true
141 | } else {
142 | // Show specific config selection
143 | radioGroupType.visibility = View.VISIBLE
144 | loadConfigFiles()
145 | }
146 | }
147 |
148 | // Set up radio group listener
149 | radioGroupType.setOnCheckedChangeListener { _, checkedId ->
150 | selectedFrpType = if (checkedId == R.id.radioFrps) {
151 | FrpType.FRPS
152 | } else {
153 | FrpType.FRPC
154 | }
155 | loadConfigFiles()
156 | }
157 |
158 | // Set initial selection based on loaded data
159 | if (selectedFrpType == FrpType.FRPS) {
160 | radioGroupType.check(R.id.radioFrps)
161 | } else {
162 | radioGroupType.check(R.id.radioFrpc)
163 | }
164 |
165 | // Load config files initially if not stopping all
166 | if (!stopAll) {
167 | loadConfigFiles()
168 | } else {
169 | radioGroupType.visibility = View.GONE
170 | spinnerConfig.visibility = View.GONE
171 | textViewNoConfig.visibility = View.GONE
172 | }
173 |
174 | // Set up buttons
175 | buttonSave.setOnClickListener {
176 | if (stopAll || selectedConfigFile != null) {
177 | taskerHelper.finishForTasker()
178 | }
179 | }
180 |
181 | buttonCancel.setOnClickListener {
182 | finish()
183 | }
184 | }
185 |
186 | private fun loadConfigFiles() {
187 | val dir = selectedFrpType.getDir(this)
188 | configFiles = dir.list()?.toList()?.sorted() ?: emptyList()
189 |
190 | if (configFiles.isEmpty()) {
191 | spinnerConfig.visibility = View.GONE
192 | textViewNoConfig.visibility = View.VISIBLE
193 | buttonSave.isEnabled = false
194 | } else {
195 | spinnerConfig.visibility = View.VISIBLE
196 | textViewNoConfig.visibility = View.GONE
197 | buttonSave.isEnabled = true
198 |
199 | val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, configFiles)
200 | adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
201 | spinnerConfig.adapter = adapter
202 |
203 | // Set selected item if we have one
204 | if (selectedConfigFile != null && configFiles.contains(selectedConfigFile)) {
205 | val index = configFiles.indexOf(selectedConfigFile)
206 | spinnerConfig.setSelection(index)
207 | }
208 |
209 | spinnerConfig.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
210 | override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
211 | selectedConfigFile = configFiles[position]
212 | }
213 |
214 | override fun onNothingSelected(parent: AdapterView<*>?) {
215 | selectedConfigFile = null
216 | }
217 | }
218 | }
219 | }
220 | }
221 |
222 | /**
223 | * Runner - executes the actual action when Tasker calls it
224 | */
225 | class StopFrpRunner : TaskerPluginRunnerActionNoOutput() {
226 | override fun run(context: Context, input: TaskerInput): TaskerPluginResult {
227 | // Check if Tasker is allowed
228 | val preferences = context.getSharedPreferences("data", Context.MODE_PRIVATE)
229 | val allowTasker = preferences.getBoolean(PreferencesKey.ALLOW_TASKER, false)
230 |
231 | if (!allowTasker) {
232 | return TaskerPluginResultError(
233 | Exception("Tasker integration is disabled. Please enable it in Settings.")
234 | )
235 | }
236 |
237 | // If stopAll is true, stop all running configs
238 | if (input.regular.stopAll) {
239 | val intent = Intent(context, ShellService::class.java).apply {
240 | action = ShellServiceAction.STOP_ALL
241 | }
242 |
243 | try {
244 | context.startService(intent)
245 | return TaskerPluginResultSucess()
246 | } catch (e: Exception) {
247 | return TaskerPluginResultError(e)
248 | }
249 | }
250 |
251 | // Otherwise, stop specific config
252 | val frpTypeStr = input.regular.frpType
253 | val configFileName = input.regular.configFileName
254 |
255 | if (frpTypeStr.isNullOrBlank() || configFileName.isNullOrBlank()) {
256 | return TaskerPluginResultError(
257 | Exception("Invalid configuration: frpType and configFileName must be provided when stopAll is false")
258 | )
259 | }
260 |
261 | // Parse FRP type
262 | val frpType = when (frpTypeStr.lowercase()) {
263 | "frpc" -> FrpType.FRPC
264 | "frps" -> FrpType.FRPS
265 | else -> return TaskerPluginResultError(
266 | Exception("Invalid frpType: $frpTypeStr. Must be 'frpc' or 'frps'")
267 | )
268 | }
269 |
270 | // Create FrpConfig
271 | val config = FrpConfig(frpType, configFileName)
272 |
273 | // Check if config file exists
274 | val configFile = config.getFile(context)
275 | if (!configFile.exists()) {
276 | return TaskerPluginResultError(
277 | Exception("Configuration file not found: ${configFile.absolutePath}")
278 | )
279 | }
280 |
281 | // Stop the FRP service
282 | val intent = Intent(context, ShellService::class.java).apply {
283 | action = ShellServiceAction.STOP
284 | putExtra(IntentExtraKey.FrpConfig, arrayListOf(config))
285 | }
286 |
287 | try {
288 | context.startService(intent)
289 | return TaskerPluginResultSucess()
290 | } catch (e: Exception) {
291 | return TaskerPluginResultError(e)
292 | }
293 | }
294 | }
295 |
--------------------------------------------------------------------------------
/app/src/main/java/io/github/acedroidx/frp/OnboardingActivity.kt:
--------------------------------------------------------------------------------
1 | package io.github.acedroidx.frp
2 |
3 | import android.Manifest
4 | import android.content.Intent
5 | import android.content.SharedPreferences
6 | import android.content.ActivityNotFoundException
7 | import android.net.Uri
8 | import android.os.Build
9 | import android.os.Bundle
10 | import android.os.PowerManager
11 | import android.provider.Settings
12 | import androidx.activity.ComponentActivity
13 | import androidx.activity.compose.setContent
14 | import androidx.activity.enableEdgeToEdge
15 | import androidx.activity.result.contract.ActivityResultContracts
16 | import androidx.compose.foundation.layout.Arrangement
17 | import androidx.compose.foundation.layout.Column
18 | import androidx.compose.foundation.layout.PaddingValues
19 | import androidx.compose.foundation.layout.Row
20 | import androidx.compose.foundation.layout.Spacer
21 | import androidx.compose.foundation.layout.fillMaxWidth
22 | import androidx.compose.foundation.layout.height
23 | import androidx.compose.foundation.layout.padding
24 | import androidx.compose.foundation.rememberScrollState
25 | import androidx.compose.foundation.shape.RoundedCornerShape
26 | import androidx.compose.foundation.verticalScroll
27 | import androidx.compose.material3.Button
28 | import androidx.compose.material3.Card
29 | import androidx.compose.material3.CardDefaults
30 | import androidx.compose.material3.ExperimentalMaterial3Api
31 | import androidx.compose.material3.Icon
32 | import androidx.compose.material3.IconButton
33 | import androidx.compose.material3.MaterialTheme
34 | import androidx.compose.material3.Scaffold
35 | import androidx.compose.material3.Text
36 | import androidx.compose.material3.TextButton
37 | import androidx.compose.material3.TopAppBar
38 | import androidx.compose.runtime.Composable
39 | import androidx.compose.runtime.getValue
40 | import androidx.compose.runtime.remember
41 | import androidx.compose.ui.Modifier
42 | import androidx.compose.ui.res.painterResource
43 | import androidx.compose.ui.res.stringResource
44 | import androidx.compose.ui.text.style.TextAlign
45 | import androidx.compose.ui.tooling.preview.Preview
46 | import androidx.compose.ui.unit.dp
47 | import androidx.core.content.ContextCompat
48 | import androidx.core.content.edit
49 | import androidx.lifecycle.compose.collectAsStateWithLifecycle
50 | import io.github.acedroidx.frp.ui.theme.FrpTheme
51 | import io.github.acedroidx.frp.ui.theme.ThemeModeKeys
52 | import kotlinx.coroutines.flow.MutableStateFlow
53 | import androidx.compose.runtime.collectAsState
54 | import android.util.Log
55 |
56 | class OnboardingActivity : ComponentActivity() {
57 | private val themeMode = MutableStateFlow(ThemeModeKeys.FOLLOW_SYSTEM)
58 | private val notificationPermissionGranted = MutableStateFlow(true)
59 | private val ignoringBatteryOptimizations = MutableStateFlow(false)
60 |
61 | private lateinit var preferences: SharedPreferences
62 |
63 | // 通知权限请求启动器
64 | private val notificationPermissionLauncher = registerForActivityResult(
65 | ActivityResultContracts.RequestPermission()
66 | ) { granted ->
67 | notificationPermissionGranted.value = granted
68 | }
69 |
70 | // 电池优化豁免申请
71 | private val batteryOptimizationLauncher = registerForActivityResult(
72 | ActivityResultContracts.StartActivityForResult()
73 | ) {
74 | // 回到应用后更新当前状态,避免用户手动取消时状态错误
75 | updateBatteryOptimizationStatus()
76 | }
77 |
78 | @OptIn(ExperimentalMaterial3Api::class)
79 | override fun onCreate(savedInstanceState: Bundle?) {
80 | super.onCreate(savedInstanceState)
81 | preferences = getSharedPreferences("data", MODE_PRIVATE)
82 |
83 | val rawTheme = preferences.getString(PreferencesKey.THEME_MODE, ThemeModeKeys.FOLLOW_SYSTEM)
84 | themeMode.value = ThemeModeKeys.normalize(rawTheme)
85 |
86 | updateNotificationPermissionStatus()
87 | updateBatteryOptimizationStatus()
88 |
89 | enableEdgeToEdge()
90 | setContent {
91 | val currentTheme by themeMode.collectAsStateWithLifecycle(themeMode.collectAsState().value)
92 | val notificationGranted by notificationPermissionGranted.collectAsStateWithLifecycle(
93 | true
94 | )
95 | val batteryIgnored by ignoringBatteryOptimizations.collectAsStateWithLifecycle(false)
96 |
97 | FrpTheme(themeMode = currentTheme) {
98 | Scaffold(topBar = {
99 | TopAppBar(
100 | title = { Text(text = stringResource(R.string.onboarding_title)) },
101 | navigationIcon = {
102 | IconButton(onClick = { finish() }) {
103 | Icon(
104 | painter = painterResource(id = R.drawable.ic_arrow_back_24dp),
105 | contentDescription = stringResource(R.string.content_desc_back)
106 | )
107 | }
108 | })
109 | }) { contentPadding ->
110 | OnboardingContent(
111 | contentPadding = contentPadding,
112 | notificationGranted = notificationGranted,
113 | batteryOptimizationIgnored = batteryIgnored,
114 | onRequestNotificationPermission = { requestNotificationPermission() },
115 | onRequestBatteryOptimization = { requestIgnoreBatteryOptimization() },
116 | onContinue = { finishOnboarding() })
117 | }
118 | }
119 | }
120 | }
121 |
122 | private fun updateNotificationPermissionStatus() {
123 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
124 | val granted = ContextCompat.checkSelfPermission(
125 | this, Manifest.permission.POST_NOTIFICATIONS
126 | ) == android.content.pm.PackageManager.PERMISSION_GRANTED
127 | notificationPermissionGranted.value = granted
128 | } else {
129 | // Android 13 以下无需动态申请通知权限
130 | notificationPermissionGranted.value = true
131 | }
132 | }
133 |
134 | private fun updateBatteryOptimizationStatus() {
135 | val powerManager = getSystemService(POWER_SERVICE) as PowerManager
136 | ignoringBatteryOptimizations.value =
137 | powerManager.isIgnoringBatteryOptimizations(packageName)
138 | }
139 |
140 | private fun requestNotificationPermission() {
141 | if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
142 | notificationPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
143 | }
144 | }
145 |
146 | private fun requestIgnoreBatteryOptimization() {
147 | // 用户同意后系统会将应用加入白名单,拒绝则保持原状态
148 | try {
149 | val intent = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply {
150 | data = Uri.parse("package:$packageName")
151 | }
152 | batteryOptimizationLauncher.launch(intent)
153 | } catch (e: ActivityNotFoundException) {
154 | // 某些设备可能缺少该入口,记录日志便于排查
155 | Log.w("Onboarding", "Battery optimization request activity not found: ${e.message}")
156 | }
157 | }
158 |
159 | private fun finishOnboarding() {
160 | preferences.edit {
161 | putBoolean(PreferencesKey.FIRST_LAUNCH_DONE, true)
162 | }
163 | // 返回主界面,避免叠加过多的 Activity
164 | startActivity(
165 | Intent(this, MainActivity::class.java).apply {
166 | addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_NEW_TASK)
167 | })
168 | finish()
169 | }
170 |
171 | @Composable
172 | private fun StatusText(active: Boolean, inactiveText: String) {
173 | val status = if (active) stringResource(R.string.onboarding_status_done) else inactiveText
174 | val color =
175 | if (active) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error
176 | Text(text = status, color = color, style = MaterialTheme.typography.bodyMedium)
177 | }
178 |
179 | @Composable
180 | private fun OnboardingCard(
181 | title: String,
182 | description: String,
183 | status: @Composable () -> Unit,
184 | actionLabel: String,
185 | onAction: () -> Unit,
186 | enabled: Boolean = true
187 | ) {
188 | Card(
189 | modifier = Modifier
190 | .fillMaxWidth()
191 | .padding(vertical = 8.dp),
192 | colors = CardDefaults.cardColors(
193 | containerColor = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f)
194 | ),
195 | shape = RoundedCornerShape(12.dp)
196 | ) {
197 | Column(
198 | modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)
199 | ) {
200 | Text(text = title, style = MaterialTheme.typography.titleMedium)
201 | Text(
202 | text = description,
203 | style = MaterialTheme.typography.bodyMedium,
204 | color = MaterialTheme.colorScheme.onSurfaceVariant
205 | )
206 | status()
207 | Row(
208 | modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End
209 | ) {
210 | TextButton(onClick = onAction, enabled = enabled) {
211 | Text(actionLabel)
212 | }
213 | }
214 | }
215 | }
216 | }
217 |
218 | @Composable
219 | fun OnboardingContent(
220 | contentPadding: PaddingValues,
221 | notificationGranted: Boolean,
222 | batteryOptimizationIgnored: Boolean,
223 | onRequestNotificationPermission: () -> Unit,
224 | onRequestBatteryOptimization: () -> Unit,
225 | onContinue: () -> Unit
226 | ) {
227 | val scrollState = rememberScrollState()
228 | val showNotificationAction = remember(notificationGranted) { !notificationGranted }
229 | val showBatteryAction = remember(batteryOptimizationIgnored) { !batteryOptimizationIgnored }
230 |
231 | Column(
232 | modifier = Modifier
233 | .padding(contentPadding)
234 | .padding(16.dp)
235 | .verticalScroll(scrollState),
236 | verticalArrangement = Arrangement.spacedBy(12.dp)
237 | ) {
238 | Text(
239 | text = stringResource(R.string.onboarding_subtitle),
240 | style = MaterialTheme.typography.bodyLarge,
241 | modifier = Modifier.fillMaxWidth(),
242 | textAlign = TextAlign.Start
243 | )
244 |
245 | OnboardingCard(
246 | title = stringResource(R.string.onboarding_notification_title),
247 | description = stringResource(R.string.onboarding_notification_desc),
248 | status = {
249 | StatusText(
250 | active = notificationGranted,
251 | inactiveText = stringResource(R.string.onboarding_notification_status_missing)
252 | )
253 | },
254 | actionLabel = stringResource(R.string.onboarding_notification_action),
255 | onAction = onRequestNotificationPermission,
256 | enabled = showNotificationAction
257 | )
258 |
259 | OnboardingCard(
260 | title = stringResource(R.string.onboarding_battery_title),
261 | description = stringResource(R.string.onboarding_battery_desc),
262 | status = {
263 | StatusText(
264 | active = batteryOptimizationIgnored,
265 | inactiveText = stringResource(R.string.onboarding_battery_status_missing)
266 | )
267 | },
268 | actionLabel = stringResource(R.string.onboarding_battery_action),
269 | onAction = onRequestBatteryOptimization,
270 | enabled = showBatteryAction
271 | )
272 |
273 | Spacer(modifier = Modifier.height(12.dp))
274 |
275 | Button(
276 | onClick = {
277 | onContinue()
278 | }, modifier = Modifier.fillMaxWidth()
279 | ) {
280 | Text(stringResource(R.string.onboarding_continue))
281 | }
282 | }
283 | }
284 |
285 | @Preview(showBackground = true)
286 | @Composable
287 | fun OnboardingPreview() {
288 | FrpTheme(themeMode = ThemeModeKeys.LIGHT) {
289 | OnboardingContent(
290 | contentPadding = PaddingValues(0.dp),
291 | notificationGranted = false,
292 | batteryOptimizationIgnored = false,
293 | onRequestNotificationPermission = {},
294 | onRequestBatteryOptimization = {},
295 | onContinue = {})
296 | }
297 | }
298 | }
299 |
--------------------------------------------------------------------------------