Skip to content

Commit 9b6d124

Browse files
committed
feat: support v2rayNG install-config intent
1 parent c0a61f7 commit 9b6d124

5 files changed

Lines changed: 170 additions & 3 deletions

File tree

app/src/main/AndroidManifest.xml

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,22 @@
2626
android:theme="@style/AppTheme">
2727
<activity
2828
android:name=".MainActivity"
29-
android:exported="true">
29+
android:exported="true"
30+
android:launchMode="singleTop">
3031
<intent-filter>
3132
<action android:name="android.intent.action.MAIN" />
3233
<category android:name="android.intent.category.LAUNCHER" />
3334
</intent-filter>
35+
<intent-filter>
36+
<action android:name="android.intent.action.VIEW" />
37+
38+
<category android:name="android.intent.category.DEFAULT" />
39+
<category android:name="android.intent.category.BROWSABLE" />
40+
41+
<data
42+
android:host="install-config"
43+
android:scheme="v2rayng" />
44+
</intent-filter>
3445
</activity>
3546

3647
<activity-alias

app/src/main/kotlin/app/MainActivity.kt

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package app
22

33
import android.Manifest
4+
import android.content.Intent
45
import android.content.pm.PackageManager
56
import android.os.Build
67
import android.os.Bundle
@@ -11,11 +12,19 @@ import androidx.activity.result.contract.ActivityResultContracts
1112
import androidx.compose.foundation.layout.WindowInsets
1213
import androidx.compose.foundation.layout.asPaddingValues
1314
import androidx.compose.foundation.layout.safeDrawing
15+
import com.journeyapps.barcodescanner.ScanContract
16+
import data.AndroidAppStateStore
1417
import engine.vpn.AndroidVpnPermissionRequester
1518
import features.logs.AndroidLogFileCreator
16-
import features.resources.runtime.AndroidResourceFilePicker
1719
import features.proxy.server.qr.AndroidQrCodeScanRequester
18-
import com.journeyapps.barcodescanner.ScanContract
20+
import features.resources.runtime.AndroidResourceFilePicker
21+
import features.subscription.SubscriptionFetchUseCase
22+
import features.subscription.SubscriptionInstallConfigUseCase
23+
import features.subscription.isV2rayNgInstallConfigUri
24+
import features.subscription.toV2rayNgInstallConfigOrNull
25+
import features.subscription.usecase.subscriptionUpdateMessage
26+
import kotlinx.coroutines.launch
27+
import ui.feedback.AndroidToastTipNotifier
1928

2029
class MainActivity : ComponentActivity() {
2130
private val vpnPermissionRequester = AndroidVpnPermissionRequester {
@@ -45,6 +54,14 @@ class MainActivity : ComponentActivity() {
4554
getString(R.string.error_log_export_launcher_missing)
4655
},
4756
)
57+
private val tipNotifier by lazy { AndroidToastTipNotifier(this) }
58+
59+
private val subscriptionInstallConfigUseCase by lazy {
60+
SubscriptionInstallConfigUseCase(
61+
stateStore = AndroidAppStateStore.get(this),
62+
subscriptionFetchUseCase = SubscriptionFetchUseCase(),
63+
)
64+
}
4865

4966
private val notificationPermissionLauncher = registerForActivityResult(
5067
ActivityResultContracts.RequestPermission(),
@@ -97,6 +114,15 @@ class MainActivity : ComponentActivity() {
97114
}
98115
showAppContent()
99116
requestStartupPermissions()
117+
if (savedInstanceState == null) {
118+
handleExternalIntent(intent)
119+
}
120+
}
121+
122+
override fun onNewIntent(intent: Intent) {
123+
super.onNewIntent(intent)
124+
setIntent(intent)
125+
handleExternalIntent(intent)
100126
}
101127

102128
override fun onDestroy() {
@@ -134,4 +160,31 @@ class MainActivity : ComponentActivity() {
134160
)
135161
}
136162
}
163+
164+
private fun handleExternalIntent(intent: Intent?) {
165+
val data = intent?.data ?: return
166+
if (!data.isV2rayNgInstallConfigUri()) return
167+
val config = intent.toV2rayNgInstallConfigOrNull()
168+
if (config == null) {
169+
(application as AsteriskApplication).appScope.launch {
170+
tipNotifier.show(getString(R.string.subscription_install_config_invalid))
171+
}
172+
return
173+
}
174+
(application as AsteriskApplication).appScope.launch {
175+
runCatching {
176+
subscriptionInstallConfigUseCase.install(config)
177+
}.onSuccess { result ->
178+
tipNotifier.show(
179+
subscriptionUpdateMessage(
180+
result = result,
181+
successTemplate = getString(R.string.proxy_server_list_subscription_update_result),
182+
failedTemplate = getString(R.string.proxy_server_list_subscription_update_result_with_failed),
183+
),
184+
)
185+
}.onFailure { error ->
186+
tipNotifier.showError(error, getString(R.string.subscription_install_config_failed))
187+
}
188+
}
189+
}
137190
}
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
package features.subscription
2+
3+
import android.content.Intent
4+
import android.net.Uri
5+
import androidx.core.net.toUri
6+
import app.AppState
7+
import app.SubscriptionGroupState
8+
import data.AndroidAppStateStore
9+
import features.proxy.server.usecase.ProxyServerListSubscriptionUpdateResult
10+
import features.proxy.server.usecase.withUpdatedSubscriptionServers
11+
import features.subscription.usecase.toSubscriptionFetchOptions
12+
import features.subscription.usecase.updateSubscriptions
13+
14+
internal data class V2rayNgInstallConfig(
15+
val name: String,
16+
val url: String,
17+
)
18+
19+
internal class SubscriptionInstallConfigUseCase(
20+
private val stateStore: AndroidAppStateStore,
21+
private val subscriptionFetchUseCase: SubscriptionFetchUseCase,
22+
) {
23+
suspend fun install(config: V2rayNgInstallConfig): ProxyServerListSubscriptionUpdateResult {
24+
val group = stateStore.addSubscriptionGroup(config)
25+
val result = updateSubscriptions(
26+
groups = listOf(group),
27+
subscriptionFetchUseCase = subscriptionFetchUseCase,
28+
fetchOptions = { stateStore.state.value.toSubscriptionFetchOptions(it) },
29+
)
30+
if (result.updates.isNotEmpty()) {
31+
stateStore.update { state ->
32+
state.withUpdatedSubscriptionServers(
33+
updates = result.updates,
34+
updatedAtMillis = result.updatedAtMillis,
35+
)
36+
}
37+
}
38+
return result
39+
}
40+
}
41+
42+
internal fun Intent.toV2rayNgInstallConfigOrNull(): V2rayNgInstallConfig? {
43+
if (action != Intent.ACTION_VIEW) return null
44+
return data?.toV2rayNgInstallConfigOrNull()
45+
}
46+
47+
internal fun Uri.isV2rayNgInstallConfigUri(): Boolean {
48+
return scheme.equals(V2rayNgScheme, ignoreCase = true) &&
49+
isHierarchical &&
50+
host.equals(V2rayNgInstallConfigHost, ignoreCase = true)
51+
}
52+
53+
private fun Uri.toV2rayNgInstallConfigOrNull(): V2rayNgInstallConfig? {
54+
if (!isV2rayNgInstallConfigUri()) return null
55+
val name = getQueryParameter("name")?.trim().orEmpty()
56+
val url = getQueryParameter("url")?.trim().orEmpty()
57+
if (name.isBlank() || !url.isValidSubscriptionUrl()) return null
58+
return V2rayNgInstallConfig(
59+
name = name,
60+
url = url,
61+
)
62+
}
63+
64+
private fun AndroidAppStateStore.addSubscriptionGroup(config: V2rayNgInstallConfig): SubscriptionGroupState {
65+
var savedGroup: SubscriptionGroupState? = null
66+
update { state ->
67+
val group = state.newSubscriptionGroup(config)
68+
savedGroup = group
69+
state.copy(
70+
subscriptionGroups = state.subscriptionGroups + group,
71+
nextSubscriptionGroupId = state.nextSubscriptionGroupId + 1,
72+
)
73+
}
74+
return checkNotNull(savedGroup)
75+
}
76+
77+
private fun AppState.newSubscriptionGroup(config: V2rayNgInstallConfig): SubscriptionGroupState {
78+
return SubscriptionGroupState(
79+
id = nextSubscriptionGroupId,
80+
name = config.name,
81+
url = config.url,
82+
userAgent = DefaultSubscriptionUserAgent,
83+
updateInterval = "",
84+
updateViaProxy = false,
85+
enabled = true,
86+
)
87+
}
88+
89+
private fun String.isValidSubscriptionUrl(): Boolean {
90+
val uri = runCatching { toUri() }.getOrNull() ?: return false
91+
val scheme = uri.scheme?.lowercase() ?: return false
92+
return uri.isHierarchical &&
93+
scheme in SubscriptionUrlSchemes &&
94+
uri.host?.isNotBlank() == true
95+
}
96+
97+
private const val V2rayNgScheme = "v2rayng"
98+
private const val V2rayNgInstallConfigHost = "install-config"
99+
private val SubscriptionUrlSchemes = setOf("http", "https")

app/src/main/res/values-zh-rCN/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,4 +499,6 @@
499499
<string name="subscription_auto_update_interval">自动更新间隔(小时)</string>
500500
<string name="subscription_update_via_proxy">使用代理更新</string>
501501
<string name="subscription_update_via_proxy_summary">通过本地代理请求订阅地址</string>
502+
<string name="subscription_install_config_invalid">无效的 v2rayNG 订阅链接</string>
503+
<string name="subscription_install_config_failed">导入订阅失败</string>
502504
</resources>

app/src/main/res/values/strings.xml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -499,4 +499,6 @@
499499
<string name="subscription_auto_update_interval">Auto update interval (hours)</string>
500500
<string name="subscription_update_via_proxy">Update via proxy</string>
501501
<string name="subscription_update_via_proxy_summary">Use the local proxy for subscription requests</string>
502+
<string name="subscription_install_config_invalid">Invalid v2rayNG subscription link</string>
503+
<string name="subscription_install_config_failed">Failed to import subscription</string>
502504
</resources>

0 commit comments

Comments
 (0)