Skip to content

Commit 4b6d6ff

Browse files
committed
feat: 添加【frp 配置读写接口】
1 parent 408369d commit 4b6d6ff

9 files changed

Lines changed: 309 additions & 7 deletions

File tree

‎README.md‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,36 @@ base64 -w 0 keystore.jks > keystore.jks.base64
4747
armeabi-v7a 和 x86_64 架构的设备仍然使用 linux 类型的 frp 内核,可能会存在 DNS 解析失败的问题,建议在配置文件使用 `dnsServer` 指定 DNS 服务器
4848

4949
### 开机自启与后台保活
50-
App 按照原生 Android 规范设计,然而部分国产系统拥有更严格的后台管控,请手动在系统设置内打开相应开关。例如 ColorOS 16 退到后台会断开连接,在【应用设置->耗电管理->完全允许后台行为】之后恢复正常
50+
App 按照原生 Android 规范设计,然而部分国产系统拥有更严格的后台管控,请手动在系统设置内打开相应开关。例如 ColorOS 16 退到后台会断开连接,在【应用设置->耗电管理->完全允许后台行为】之后恢复正常
51+
52+
### BroadcastReceiver 使用示例
53+
需在设置中打开「在收到广播时启动/关闭」对应开关:
54+
55+
```shell
56+
# 启动所有已开启自启动的配置
57+
adb shell am broadcast -a io.github.acedroidx.frp.START io.github.acedroidx.frp
58+
59+
# 停止所有已开启自启动的配置
60+
adb shell am broadcast -a io.github.acedroidx.frp.STOP io.github.acedroidx.frp
61+
62+
# 仅操作指定配置(带参数示例)
63+
adb shell am broadcast -a io.github.acedroidx.frp.START -e TYPE frpc -e NAME example.toml io.github.acedroidx.frp
64+
adb shell am broadcast -a io.github.acedroidx.frp.STOP -e TYPE frpc -e NAME example.toml io.github.acedroidx.frp
65+
```
66+
67+
### ContentProvider 配置访问示例
68+
使用前请在「设置 -> frp 配置读写接口」开启读/写开关,注意可能的配置密码泄露等安全风险。
69+
70+
```shell
71+
# 列出全部配置(需要开启“允许读取”)
72+
adb shell content query --uri content://io.github.acedroidx.frp.config
73+
74+
# 读取单个配置(需要开启“允许读取”)
75+
adb shell content read --uri content://io.github.acedroidx.frp.config/frpc/example.toml
76+
77+
# 写入单个配置(需要开启“允许写入”)
78+
# 将本地 example.toml 覆盖写入设备上的配置文件
79+
adb shell content write --uri content://io.github.acedroidx.frp.config/frpc/example.toml < example.toml
80+
```
81+
82+
- 应用内快速验证:在主页配置列表长按“编辑”按钮,会用第三方应用通过 ContentProvider 打开该配置文件,同样需要先在设置中开启读/写开关。

‎README_en.md‎

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,4 +47,36 @@ Starting from v1.3.0, devices with the arm64-v8a architecture use the android ty
4747
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.
4848

4949
### Start at Boot and Background Keep-Alive
50-
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.
50+
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.
51+
52+
### BroadcastReceiver usage example
53+
Make sure to enable the corresponding "Startup at Broadcast" / "Stop at Broadcast" switches in Settings:
54+
55+
```shell
56+
# Start all configs with Auto-start enabled
57+
adb shell am broadcast -a io.github.acedroidx.frp.START io.github.acedroidx.frp
58+
59+
# Stop all configs with Auto-start enabled
60+
adb shell am broadcast -a io.github.acedroidx.frp.STOP io.github.acedroidx.frp
61+
62+
# Operate on a specific config only (examples with extras)
63+
adb shell am broadcast -a io.github.acedroidx.frp.START -e TYPE frpc -e NAME example.toml io.github.acedroidx.frp
64+
adb shell am broadcast -a io.github.acedroidx.frp.STOP -e TYPE frpc -e NAME example.toml io.github.acedroidx.frp
65+
```
66+
67+
### ContentProvider config access example
68+
Before using, turn on the "frp Config I/O" read/write switches in Settings and be aware of the possible disclosure of config passwords.
69+
70+
```shell
71+
# List all configs (requires "Allow read")
72+
adb shell content query --uri content://io.github.acedroidx.frp.config
73+
74+
# Read a single config (requires "Allow read")
75+
adb shell content read --uri content://io.github.acedroidx.frp.config/frpc/example.toml
76+
77+
# Write a single config (requires "Allow write")
78+
# Overwrite the device config with local example.toml
79+
adb shell content write --uri content://io.github.acedroidx.frp.config/frpc/example.toml < example.toml
80+
```
81+
82+
- 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/AndroidManifest.xml‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,12 @@
117117
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
118118
android:value="true" />
119119
</service>
120+
121+
<provider
122+
android:name=".FrpConfigProvider"
123+
android:authorities="io.github.acedroidx.frp.config"
124+
android:exported="true"
125+
android:grantUriPermissions="true" />
120126
</application>
121127

122128
</manifest>
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
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<out String>?,
32+
selection: String?,
33+
selectionArgs: Array<out String>?,
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<out String>?): Int {
125+
throw UnsupportedOperationException("Delete not supported")
126+
}
127+
128+
override fun update(
129+
uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?
130+
): Int {
131+
throw UnsupportedOperationException("Update not supported")
132+
}
133+
}

‎app/src/main/java/io/github/acedroidx/frp/MainActivity.kt‎

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,20 @@ import android.content.ComponentName
66
import android.content.Intent
77
import android.content.ServiceConnection
88
import android.content.SharedPreferences
9+
import android.net.Uri
910
import android.os.Build
1011
import android.os.Bundle
1112
import android.os.IBinder
1213
import android.util.Log
14+
import android.widget.Toast
1315
import androidx.activity.ComponentActivity
1416
import androidx.activity.compose.setContent
1517
import androidx.activity.enableEdgeToEdge
1618
import androidx.activity.result.contract.ActivityResultContracts
1719
import androidx.compose.material3.SnackbarHost
1820
import androidx.compose.material3.SnackbarHostState
1921
import androidx.core.content.edit
22+
import androidx.compose.foundation.combinedClickable
2023
import androidx.compose.foundation.gestures.Orientation
2124
import androidx.compose.foundation.gestures.rememberScrollableState
2225
import androidx.compose.foundation.gestures.scrollable
@@ -360,11 +363,31 @@ class MainActivity : ComponentActivity() {
360363
color = MaterialTheme.colorScheme.onSurfaceVariant,
361364
modifier = Modifier.padding(horizontal = 8.dp)
362365
)
363-
IconButton(
364-
onClick = { startConfigActivity(config) },
365-
enabled = !isRunning,
366-
modifier = Modifier.size(28.dp)
367-
) {
366+
Box(
367+
modifier = Modifier
368+
.size(28.dp)
369+
.combinedClickable(
370+
enabled = !isRunning,
371+
onClick = { startConfigActivity(config) },
372+
onLongClick = {
373+
// 通过 ContentProvider 让外部应用读写配置
374+
val uri =
375+
Uri.parse("content://${FrpConfigProvider.AUTHORITY}/${config.type.typeName}/${config.fileName}")
376+
val viewIntent = Intent(Intent.ACTION_VIEW).apply {
377+
setDataAndType(uri, "text/plain")
378+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
379+
addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION)
380+
}
381+
try {
382+
startActivity(viewIntent)
383+
} catch (e: Exception) {
384+
Toast.makeText(
385+
this@MainActivity,
386+
getString(R.string.toast_no_viewer_for_config),
387+
Toast.LENGTH_SHORT
388+
).show()
389+
}
390+
})) {
368391
Icon(
369392
painter = painterResource(id = R.drawable.ic_pencil_24dp),
370393
contentDescription = stringResource(R.string.content_desc_edit),

‎app/src/main/java/io/github/acedroidx/frp/PreferencesKey.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ object PreferencesKey {
1313
const val ALLOW_TASKER = "allow_tasker"
1414
const val EXCLUDE_FROM_RECENTS = "exclude_from_recents"
1515
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"
1618
const val QUICK_TILE_CONFIG_TYPE = "quick_tile_config_type"
1719
const val QUICK_TILE_CONFIG_NAME = "quick_tile_config_name"
1820
const val FIRST_LAUNCH_DONE = "first_launch_done"

‎app/src/main/java/io/github/acedroidx/frp/SettingsActivity.kt‎

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ class SettingsActivity : ComponentActivity() {
6060
private val allowTasker = MutableStateFlow(true)
6161
private val excludeFromRecents = MutableStateFlow(false)
6262
private val hideServiceToast = MutableStateFlow(false)
63+
private val allowConfigRead = MutableStateFlow(false)
64+
private val allowConfigWrite = MutableStateFlow(false)
6365
private val quickTileConfig = MutableStateFlow<FrpConfig?>(null)
6466
private lateinit var preferences: SharedPreferences
6567

@@ -96,6 +98,10 @@ class SettingsActivity : ComponentActivity() {
9698
PreferencesKey.HIDE_SERVICE_TOAST, false
9799
)
98100

101+
// 读取配置读写接口开关,默认关闭
102+
allowConfigRead.value = preferences.getBoolean(PreferencesKey.ALLOW_CONFIG_READ, false)
103+
allowConfigWrite.value = preferences.getBoolean(PreferencesKey.ALLOW_CONFIG_WRITE, false)
104+
99105
// 加载配置列表
100106
loadConfigList()
101107

@@ -138,10 +144,13 @@ class SettingsActivity : ComponentActivity() {
138144
val isTaskerAllowed by allowTasker.collectAsStateWithLifecycle(true)
139145
val isExcludeFromRecents by excludeFromRecents.collectAsStateWithLifecycle(false)
140146
val isHideServiceToast by hideServiceToast.collectAsStateWithLifecycle(false)
147+
val isConfigReadAllowed by allowConfigRead.collectAsStateWithLifecycle(false)
148+
val isConfigWriteAllowed by allowConfigWrite.collectAsStateWithLifecycle(false)
141149
val currentQuickTileConfig by quickTileConfig.collectAsStateWithLifecycle(null)
142150
val configs by allConfigs.collectAsStateWithLifecycle(emptyList())
143151

144152
var showAutoStartHelp by remember { mutableStateOf(false) }
153+
var showConfigIoHelp by remember { mutableStateOf(false) }
145154

146155
val themeOptions = listOf(
147156
ThemeModeKeys.DARK to stringResource(R.string.theme_mode_dark),
@@ -156,6 +165,13 @@ class SettingsActivity : ComponentActivity() {
156165
onDismiss = { showAutoStartHelp = false })
157166
}
158167

168+
if (showConfigIoHelp) {
169+
HelpDialog(
170+
title = stringResource(R.string.config_io_title),
171+
message = stringResource(R.string.config_io_help_message),
172+
onDismiss = { showConfigIoHelp = false })
173+
}
174+
159175
Column(
160176
modifier = Modifier
161177
.fillMaxWidth()
@@ -368,6 +384,50 @@ class SettingsActivity : ComponentActivity() {
368384
}
369385
}
370386

387+
// frp 配置读写接口
388+
Card(modifier = Modifier.fillMaxWidth()) {
389+
Column(modifier = Modifier.padding(8.dp)) {
390+
Row(
391+
modifier = Modifier.padding(horizontal = 16.dp),
392+
verticalAlignment = Alignment.CenterVertically,
393+
horizontalArrangement = Arrangement.SpaceBetween
394+
) {
395+
Text(
396+
text = stringResource(R.string.config_io_title),
397+
style = MaterialTheme.typography.titleMedium,
398+
color = MaterialTheme.colorScheme.onSurface
399+
)
400+
IconButton(onClick = { showConfigIoHelp = true }) {
401+
Icon(
402+
painter = painterResource(id = R.drawable.help_24px),
403+
contentDescription = stringResource(R.string.content_desc_help)
404+
)
405+
}
406+
}
407+
HorizontalDivider()
408+
409+
SettingItemWithSwitch(
410+
title = stringResource(R.string.config_read_title),
411+
checked = isConfigReadAllowed,
412+
onCheckedChange = { checked ->
413+
preferences.edit {
414+
putBoolean(PreferencesKey.ALLOW_CONFIG_READ, checked)
415+
}
416+
allowConfigRead.value = checked
417+
})
418+
419+
SettingItemWithSwitch(
420+
title = stringResource(R.string.config_write_title),
421+
checked = isConfigWriteAllowed,
422+
onCheckedChange = { checked ->
423+
preferences.edit {
424+
putBoolean(PreferencesKey.ALLOW_CONFIG_WRITE, checked)
425+
}
426+
allowConfigWrite.value = checked
427+
})
428+
}
429+
}
430+
371431
// 关于设置项
372432
// 关于设置项(Card)
373433
Card(modifier = Modifier.fillMaxWidth()) {

0 commit comments

Comments
 (0)