Skip to content

Commit a8a212c

Browse files
committed
feat: add VPN auto-disable for proxy settings
Automatically disable proxy when VPN is active and restore it when VPN disconnects. - VpnDetector using NetworkCallback API for real-time VPN detection - Proxy saved to SharedPreferences before VPN disables it, restored on disconnect - UI toggle in proxy settings screen to enable/disable the feature - VPN warning in proxy confirmation dialog when connecting via proxy link - Proxy blocked from manual enable when VPN is active - Translations for EN, RU, UK, ZH, SK - Clean architecture: VpnDetector interface in domain module - Type-safe getSystemService calls, persistent proxy storage, ProxyPrefs data class
1 parent ee69822 commit a8a212c

File tree

24 files changed

+313
-18
lines changed

24 files changed

+313
-18
lines changed

app/src/main/java/org/monogram/app/MainContent.kt

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -199,7 +199,19 @@ private fun ProxyConfirmSheet(root: RootComponent) {
199199
}
200200
}
201201

202-
Spacer(modifier = Modifier.height(24.dp))
202+
Spacer(modifier = Modifier.height(8.dp))
203+
204+
val vpnActive by root.vpnDetector.isVpnActive.collectAsState()
205+
if (vpnActive) {
206+
Text(
207+
text = stringResource(R.string.proxy_saved_vpn_active_dialog),
208+
style = MaterialTheme.typography.bodyMedium,
209+
color = MaterialTheme.colorScheme.error,
210+
modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)
211+
)
212+
}
213+
214+
Spacer(modifier = Modifier.height(16.dp))
203215

204216
Row(
205217
modifier = Modifier.fillMaxWidth(),

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
<string name="proxy_port">Порт</string>
1616
<string name="proxy_type">Тип</string>
1717
<string name="proxy_unknown">Неизвестно</string>
18+
<string name="proxy_saved_vpn_active_dialog">Прокси сохранено, но не включено из-за активной VPN</string>
19+
<string name="proxy_enable_failed_vpn_active">Невозможно включить прокси: VPN активна</string>
20+
<string name="proxy_added_and_enabled">Прокси добавлен и включён</string>
1821
<string name="cancel">Отмена</string>
1922
<string name="connect">Подключиться</string>
2023

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
<string name="proxy_port">Port</string>
1616
<string name="proxy_type">Typ</string>
1717
<string name="proxy_unknown">Neznáme</string>
18+
<string name="proxy_saved_vpn_active_dialog">Proxy uložené, ale neaktivované kvôli aktívnej VPN</string>
19+
<string name="proxy_enable_failed_vpn_active">Nemožno aktivovať proxy: VPN je aktívna</string>
20+
<string name="proxy_added_and_enabled">Proxy pridaný a aktivovaný</string>
1821
<string name="cancel">Zrušiť</string>
1922
<string name="connect">Pripojiť</string>
2023

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
<string name="proxy_port">Порт</string>
1616
<string name="proxy_type">Тип</string>
1717
<string name="proxy_unknown">Невідомо</string>
18+
<string name="proxy_saved_vpn_active_dialog">Проксі збережено, але не увімкнено через активну VPN</string>
19+
<string name="proxy_enable_failed_vpn_active">Неможливо увімкнути проксі: VPN активна</string>
20+
<string name="proxy_added_and_enabled">Проксі додано та увімкнено</string>
1821
<string name="cancel">Скасувати</string>
1922
<string name="connect">Підключитися</string>
2023

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
<string name="proxy_port">端口</string>
1616
<string name="proxy_type">类型</string>
1717
<string name="proxy_unknown">未知</string>
18+
<string name="proxy_saved_vpn_active_dialog">代理已保存,但因 VPN 处于活动状态而未启用</string>
19+
<string name="proxy_enable_failed_vpn_active">无法启用代理:VPN 处于活动状态</string>
20+
<string name="proxy_added_and_enabled">代理已添加并启用</string>
1821
<string name="cancel">取消</string>
1922
<string name="connect">连接</string>
2023

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@
1515
<string name="proxy_port">Port</string>
1616
<string name="proxy_type">Type</string>
1717
<string name="proxy_unknown">Unknown</string>
18+
<string name="proxy_saved_vpn_active_dialog">Proxy saved but not enabled due to active VPN</string>
19+
<string name="proxy_enable_failed_vpn_active">Cannot enable proxy: VPN is active</string>
20+
<string name="proxy_added_and_enabled">Proxy added and enabled</string>
1821
<string name="cancel">Cancel</string>
1922
<string name="connect">Connect</string>
2023

data/src/main/java/org/monogram/data/di/dataModule.kt

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import org.monogram.data.gateway.TelegramGateway
2222
import org.monogram.data.gateway.TelegramGatewayImpl
2323
import org.monogram.data.gateway.UpdateDispatcher
2424
import org.monogram.data.gateway.UpdateDispatcherImpl
25+
import org.monogram.domain.infra.VpnDetector as VpnDetectorInterface
2526
import org.monogram.data.infra.*
2627
import org.monogram.data.mapper.ChatMapper
2728
import org.monogram.data.mapper.MessageMapper
@@ -178,7 +179,7 @@ val dataModule = module {
178179
single<ChatRemoteSource> {
179180
TdChatRemoteSource(
180181
gateway = get(),
181-
connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
182+
connectivityManager = androidContext().getSystemService(ConnectivityManager::class.java),
182183
)
183184
}
184185

@@ -212,7 +213,7 @@ val dataModule = module {
212213

213214
single {
214215
MessageMapper(
215-
connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
216+
connectivityManager = androidContext().getSystemService(ConnectivityManager::class.java),
216217
gateway = get(),
217218
userRepository = get(),
218219
customEmojiPaths = get<FileUpdateHandler>().customEmojiPaths,
@@ -224,14 +225,22 @@ val dataModule = module {
224225
)
225226
}
226227

228+
single<VpnDetectorInterface> {
229+
VpnDetector(
230+
connectivityManager = androidContext().getSystemService(ConnectivityManager::class.java),
231+
dispatchers = get()
232+
)
233+
}
234+
227235
single {
228236
ConnectionManager(
229237
chatRemoteSource = get(),
230238
proxyRemoteSource = get(),
231239
updates = get(),
232240
appPreferences = get(),
233241
dispatchers = get(),
234-
connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
242+
connectivityManager = androidContext().getSystemService(ConnectivityManager::class.java),
243+
vpnDetector = get(),
235244
scopeProvider = get()
236245
)
237246
}
Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,33 @@
11
package org.monogram.data.infra
22

33
import android.content.Context
4+
import android.util.Log
45
import org.monogram.domain.repository.StringProvider
56

67
class AndroidStringProvider(private val context: Context) : StringProvider {
8+
private val TAG = "StringProvider"
9+
710
override fun getString(resName: String): String {
811
val resId = context.resources.getIdentifier(resName, "string", context.packageName)
9-
return if (resId != 0) context.getString(resId) else resName
12+
return if (resId != 0) context.getString(resId) else {
13+
Log.w(TAG, "String resource not found: $resName")
14+
resName
15+
}
1016
}
1117

1218
override fun getString(resName: String, vararg formatArgs: Any): String {
1319
val resId = context.resources.getIdentifier(resName, "string", context.packageName)
14-
return if (resId != 0) context.getString(resId, *formatArgs) else resName
20+
return if (resId != 0) context.getString(resId, *formatArgs) else {
21+
Log.w(TAG, "String resource not found: $resName")
22+
resName
23+
}
1524
}
1625

1726
override fun getQuantityString(resName: String, quantity: Int, vararg formatArgs: Any): String {
1827
val resId = context.resources.getIdentifier(resName, "plurals", context.packageName)
19-
return if (resId != 0) context.resources.getQuantityString(resId, quantity, *formatArgs) else resName
28+
return if (resId != 0) context.resources.getQuantityString(resId, quantity, *formatArgs) else {
29+
Log.w(TAG, "Plural resource not found: $resName")
30+
resName
31+
}
2032
}
2133
}

data/src/main/java/org/monogram/data/infra/ConnectionManager.kt

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import org.monogram.core.ScopeProvider
1818
import org.monogram.data.datasource.remote.ChatRemoteSource
1919
import org.monogram.data.datasource.remote.ProxyRemoteDataSource
2020
import org.monogram.data.gateway.UpdateDispatcher
21+
import org.monogram.domain.infra.VpnDetector
2122
import org.monogram.domain.repository.AppPreferencesProvider
2223
import org.monogram.domain.repository.ConnectionStatus
2324
import kotlin.random.Random
@@ -29,6 +30,7 @@ class ConnectionManager(
2930
private val appPreferences: AppPreferencesProvider,
3031
private val dispatchers: DispatcherProvider,
3132
private val connectivityManager: ConnectivityManager,
33+
private val vpnDetector: VpnDetector,
3234
scopeProvider: ScopeProvider
3335
) {
3436
private val TAG = "ConnectionManager"
@@ -67,13 +69,64 @@ class ConnectionManager(
6769
registerNetworkCallback()
6870
startWatchdog()
6971
startProxyManagement()
72+
startVpnMonitoring()
7073

7174
scope.launch(dispatchers.default) {
7275
runReconnectAttempt("bootstrap", force = true)
7376
syncConnectionStateFromTdlib("bootstrap")
7477
}
7578
}
7679

80+
private fun startVpnMonitoring() {
81+
vpnDetector.startMonitoring()
82+
83+
scope.launch {
84+
combine(
85+
appPreferences.isVpnAutoDisableEnabled,
86+
vpnDetector.isVpnActive
87+
) { enabled, vpnActive -> enabled to vpnActive }
88+
.distinctUntilChanged()
89+
.collect { (enabled, vpnActive) ->
90+
if (!enabled) return@collect
91+
92+
if (vpnActive) {
93+
val currentProxyId = appPreferences.enabledProxyId.value
94+
if (currentProxyId != null) {
95+
appPreferences.setSavedProxyBeforeVpn(currentProxyId)
96+
Log.d(TAG, "VPN detected active, disabling in-app proxy (saved proxy: $currentProxyId)")
97+
coRunCatching {
98+
proxyRemoteSource.disableProxy()
99+
appPreferences.setEnabledProxyId(null)
100+
}.onFailure { Log.e(TAG, "Failed to disable proxy for VPN", it) }
101+
}
102+
autoBestJob?.cancel()
103+
telegaSwitchJob?.cancel()
104+
} else {
105+
val savedProxyId = appPreferences.savedProxyBeforeVpn.value
106+
if (savedProxyId != null) {
107+
Log.d(TAG, "VPN disconnected, restoring proxy: $savedProxyId")
108+
coRunCatching {
109+
if (proxyRemoteSource.enableProxy(savedProxyId)) {
110+
appPreferences.setEnabledProxyId(savedProxyId)
111+
appPreferences.setSavedProxyBeforeVpn(null)
112+
} else {
113+
Log.w(TAG, "enableProxy returned false for saved proxy $savedProxyId, keeping it for retry")
114+
}
115+
}.onFailure { Log.e(TAG, "Failed to restore proxy after VPN", it) }
116+
}
117+
118+
if (appPreferences.isTelegaProxyEnabled.value) {
119+
telegaSwitchJob?.cancel()
120+
telegaSwitchJob = launchTelegaSwitchLoop()
121+
} else if (appPreferences.isAutoBestProxyEnabled.value) {
122+
autoBestJob?.cancel()
123+
autoBestJob = launchAutoBestLoop()
124+
}
125+
}
126+
}
127+
}
128+
}
129+
77130
private fun handleConnectionState(state: TdApi.ConnectionState, source: String) {
78131
val status = when (state) {
79132
is TdApi.ConnectionStateReady -> ConnectionStatus.Connected
@@ -248,6 +301,11 @@ class ConnectionManager(
248301
}
249302

250303
private suspend fun selectBestProxy(telegaOnly: Boolean = false) {
304+
if (appPreferences.isVpnAutoDisableEnabled.value && vpnDetector.isVpnActive.value) {
305+
Log.d(TAG, "Skipping proxy selection — VPN is active")
306+
return
307+
}
308+
251309
val allProxies = proxyRemoteSource.getProxies()
252310
val proxies = if (telegaOnly) {
253311
val telegaIds = getTelegaIdentifiers()
@@ -370,4 +428,17 @@ class ConnectionManager(
370428
connectivityManager.activeNetworkInfo?.isConnected == true
371429
}
372430
}
431+
432+
fun stop() {
433+
vpnDetector.stopMonitoring()
434+
retryJob?.cancel()
435+
proxyModeWatcherJob?.cancel()
436+
autoBestJob?.cancel()
437+
telegaSwitchJob?.cancel()
438+
watchdogJob?.cancel()
439+
networkCallback?.let {
440+
runCatching { connectivityManager.unregisterNetworkCallback(it) }
441+
networkCallback = null
442+
}
443+
}
373444
}
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package org.monogram.data.infra
2+
3+
import android.net.ConnectivityManager
4+
import android.net.Network
5+
import android.net.NetworkCapabilities
6+
import android.os.Build
7+
import android.util.Log
8+
import kotlinx.coroutines.flow.MutableStateFlow
9+
import kotlinx.coroutines.flow.StateFlow
10+
import kotlinx.coroutines.flow.asStateFlow
11+
import org.monogram.core.DispatcherProvider
12+
import org.monogram.domain.infra.VpnDetector as VpnDetectorInterface
13+
14+
class VpnDetector(
15+
private val connectivityManager: ConnectivityManager,
16+
private val dispatchers: DispatcherProvider
17+
) : VpnDetectorInterface {
18+
private val _isVpnActive = MutableStateFlow(false)
19+
override val isVpnActive: StateFlow<Boolean> = _isVpnActive.asStateFlow()
20+
21+
private var networkCallback: ConnectivityManager.NetworkCallback? = null
22+
23+
override fun startMonitoring() {
24+
checkVpnStatus()
25+
26+
val callback = object : ConnectivityManager.NetworkCallback() {
27+
override fun onAvailable(network: Network) {
28+
checkVpnStatus()
29+
}
30+
31+
override fun onLost(network: Network) {
32+
checkVpnStatus()
33+
}
34+
35+
override fun onCapabilitiesChanged(
36+
network: Network,
37+
networkCapabilities: NetworkCapabilities
38+
) {
39+
if (connectivityManager.activeNetwork == network) {
40+
checkVpnStatus()
41+
}
42+
}
43+
}
44+
45+
runCatching {
46+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
47+
connectivityManager.registerDefaultNetworkCallback(callback)
48+
} else {
49+
val request = android.net.NetworkRequest.Builder().build()
50+
connectivityManager.registerNetworkCallback(request, callback)
51+
}
52+
networkCallback = callback
53+
}.onFailure { throwable ->
54+
Log.e("VpnDetector", "Failed to register network callback -- VPN detection disabled", throwable)
55+
}
56+
}
57+
58+
override fun stopMonitoring() {
59+
networkCallback?.let {
60+
runCatching { connectivityManager.unregisterNetworkCallback(it) }
61+
networkCallback = null
62+
}
63+
}
64+
65+
private fun checkVpnStatus() {
66+
val isActive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
67+
val activeNetwork = connectivityManager.activeNetwork
68+
if (activeNetwork != null) {
69+
val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
70+
capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
71+
} else {
72+
false
73+
}
74+
} else {
75+
@Suppress("DEPRECATION")
76+
connectivityManager.activeNetworkInfo?.type == ConnectivityManager.TYPE_VPN
77+
}
78+
79+
if (_isVpnActive.value != isActive) {
80+
_isVpnActive.value = isActive
81+
}
82+
}
83+
}

0 commit comments

Comments
 (0)