diff --git a/app/src/main/java/org/monogram/app/MainContent.kt b/app/src/main/java/org/monogram/app/MainContent.kt
index f9152c95..9bdd15a4 100644
--- a/app/src/main/java/org/monogram/app/MainContent.kt
+++ b/app/src/main/java/org/monogram/app/MainContent.kt
@@ -95,4 +95,472 @@ fun MainContent(root: RootComponent) {
LockScreen(root)
}
}
-}
\ No newline at end of file
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ProxyConfirmSheet(root: RootComponent) {
+ val proxyConfirmState by root.proxyToConfirm.collectAsState()
+ if (proxyConfirmState.server != null) {
+ ModalBottomSheet(
+ onDismissRequest = root::dismissProxyConfirm,
+ dragHandle = { BottomSheetDefaults.DragHandle() },
+ containerColor = MaterialTheme.colorScheme.background,
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 32.dp)
+ ) {
+ Text(
+ text = stringResource(R.string.proxy_details),
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ modifier = Modifier.padding(horizontal = 4.dp)
+ )
+
+ Text(
+ text = stringResource(R.string.proxy_add_connect),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ color = MaterialTheme.colorScheme.surfaceContainer,
+ shape = RoundedCornerShape(24.dp)
+ ) {
+ Column(
+ Modifier.padding(vertical = 8.dp),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ DetailRow(stringResource(R.string.proxy_server), proxyConfirmState.server!!)
+ DetailRow(stringResource(R.string.proxy_port), proxyConfirmState.port!!.toString())
+ val typeName = when (proxyConfirmState.type) {
+ is ProxyTypeModel.Mtproto -> "MTProto"
+ is ProxyTypeModel.Socks5 -> "SOCKS5"
+ is ProxyTypeModel.Http -> "HTTP"
+ else -> stringResource(R.string.proxy_unknown)
+ }
+ DetailRow(stringResource(R.string.proxy_type), typeName)
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ val vpnActive by root.vpnDetector.isVpnActive.collectAsState()
+ val vpnBlockActive = root.appPreferences.isVpnAutoDisableEnabled.collectAsState().value && vpnActive
+ if (vpnBlockActive) {
+ Text(
+ text = stringResource(R.string.proxy_saved_vpn_not_enabled),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.error,
+ modifier = Modifier.padding(horizontal = 4.dp, vertical = 8.dp)
+ )
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = root::dismissProxyConfirm,
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Text(stringResource(R.string.cancel), fontSize = 16.sp, fontWeight = FontWeight.Bold)
+ }
+
+ Button(
+ onClick = {
+ root.confirmProxy(
+ proxyConfirmState.server!!,
+ proxyConfirmState.port!!,
+ proxyConfirmState.type!!
+ )
+ },
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Text(
+ if (vpnBlockActive) stringResource(R.string.proxy_save_for_later) else stringResource(R.string.connect),
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
+ }
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+private fun ChatConfirmJoinSheet(root: RootComponent) {
+ val chatConfirmJoinState by root.chatToConfirmJoin.collectAsState()
+ if (chatConfirmJoinState.chat != null || chatConfirmJoinState.inviteLink != null) {
+ ModalBottomSheet(
+ onDismissRequest = root::dismissChatConfirmJoin,
+ dragHandle = { BottomSheetDefaults.DragHandle() },
+ containerColor = MaterialTheme.colorScheme.background,
+ shape = RoundedCornerShape(topStart = 24.dp, topEnd = 24.dp)
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp)
+ .padding(bottom = 32.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ val title =
+ chatConfirmJoinState.chat?.title ?: chatConfirmJoinState.inviteTitle ?: ""
+ val avatarPath =
+ chatConfirmJoinState.chat?.avatarPath ?: chatConfirmJoinState.inviteAvatarPath
+ val isChannel =
+ chatConfirmJoinState.chat?.isChannel ?: chatConfirmJoinState.inviteIsChannel
+ val memberCount =
+ chatConfirmJoinState.chat?.memberCount ?: chatConfirmJoinState.inviteMemberCount
+ val description = chatConfirmJoinState.fullInfo?.description
+ ?: chatConfirmJoinState.inviteDescription
+
+ AvatarTopAppBar(
+ path = avatarPath,
+ name = title,
+ size = 100.dp,
+ fontSize = 32,
+ videoPlayerPool = root.videoPlayerPool
+ )
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+
+ val channelStr = stringResource(R.string.chat_channel)
+ val groupStr = stringResource(R.string.chat_group)
+ val infoText = buildString {
+ if (isChannel) append(channelStr) else append(groupStr)
+ if (memberCount > 0) {
+ append(" • ")
+ append(pluralStringResource(R.plurals.members_count, memberCount, memberCount))
+ }
+ }
+
+ Text(
+ text = infoText,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+
+ description?.let { bio ->
+ Spacer(modifier = Modifier.height(12.dp))
+ Text(
+ text = bio,
+ style = MaterialTheme.typography.bodyMedium,
+ textAlign = TextAlign.Center,
+ maxLines = 3
+ )
+ }
+
+ Spacer(modifier = Modifier.height(32.dp))
+
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ OutlinedButton(
+ onClick = root::dismissChatConfirmJoin,
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Text(stringResource(R.string.cancel), fontSize = 16.sp, fontWeight = FontWeight.Bold)
+ }
+
+ Button(
+ onClick = {
+ val chatId = chatConfirmJoinState.chat?.id
+ val inviteLink = chatConfirmJoinState.inviteLink
+ if (chatId != null) {
+ root.confirmJoinChat(chatId)
+ } else if (inviteLink != null) {
+ root.confirmJoinInviteLink(inviteLink)
+ }
+ },
+ modifier = Modifier
+ .weight(1f)
+ .height(56.dp),
+ shape = RoundedCornerShape(16.dp)
+ ) {
+ Text(stringResource(R.string.chat_join), fontSize = 16.sp, fontWeight = FontWeight.Bold)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun DetailRow(label: String, value: String) {
+ Row(
+ Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 20.dp, vertical = 12.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ label,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ value,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface
+ )
+ }
+}
+
+private fun isSettingsSelected(stack: ChildStack<*, RootComponent.Child>): Boolean {
+ return when (stack.active.instance) {
+ is RootComponent.Child.SettingsChild,
+ is RootComponent.Child.EditProfileChild,
+ is RootComponent.Child.SessionsChild,
+ is RootComponent.Child.FoldersChild,
+ is RootComponent.Child.ChatSettingsChild,
+ is RootComponent.Child.DataStorageChild,
+ is RootComponent.Child.StorageUsageChild,
+ is RootComponent.Child.NetworkUsageChild,
+ is RootComponent.Child.PrivacyChild,
+ is RootComponent.Child.AdBlockChild,
+ is RootComponent.Child.PowerSavingChild,
+ is RootComponent.Child.NotificationsChild,
+ is RootComponent.Child.PremiumChild,
+ is RootComponent.Child.ProxyChild,
+ is RootComponent.Child.StickersChild,
+ is RootComponent.Child.AboutChild,
+ is RootComponent.Child.DebugChild -> true
+
+ else -> false
+ }
+}
+
+@OptIn(ExperimentalDecomposeApi::class)
+@Composable
+fun MobileLayout(root: RootComponent) {
+ val stack by root.childStack.subscribeAsState()
+ val coroutineScope = rememberCoroutineScope()
+ val dragOffsetX = remember { Animatable(0f) }
+ val previous = stack.items.dropLast(1).lastOrNull()?.instance
+ var swipeBackInProgress by remember { mutableStateOf(false) }
+ var widthPx by remember { mutableFloatStateOf(0f) }
+
+ if (dragOffsetX.value > 0 && previous != null) { // todo: isDragToBackEnabled
+ Box(modifier = Modifier.fillMaxSize()) {
+ RenderChild(previous)
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .background(
+ Color.Black.copy(
+ alpha = 0.3f * (1f - (dragOffsetX.value / widthPx).coerceIn(
+ 0f,
+ 1f
+ ))
+ )
+ )
+ )
+ }
+ }
+
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .onSizeChanged {
+ widthPx = it.width.toFloat()
+ }
+ .then(
+ if (stack.active.instance is RootComponent.Child.ChatDetailChild) {
+ Modifier.pointerInput(Unit) {
+ var isDragging = false
+ detectHorizontalDragGestures(
+ onDragStart = { offset ->
+ isDragging = offset.x > 48.dp.toPx()
+ },
+ onHorizontalDrag = { change, dragAmount ->
+ if (isDragging) {
+ change.consume()
+ coroutineScope.launch {
+ val newOffset = dragOffsetX.value + dragAmount
+ dragOffsetX.snapTo(newOffset.coerceAtLeast(0f))
+ }
+ }
+ },
+ onDragEnd = {
+ if (isDragging) {
+ coroutineScope.launch {
+ val width = size.width.toFloat()
+ if (dragOffsetX.value > width * 0.15f) {
+ swipeBackInProgress = true
+ dragOffsetX.animateTo(width, tween(200))
+ root.onBack()
+ dragOffsetX.snapTo(0f)
+ swipeBackInProgress = false
+ } else {
+ dragOffsetX.animateTo(0f, spring())
+ }
+ }
+ isDragging = false
+ }
+ },
+ onDragCancel = {
+ if (isDragging) {
+ coroutineScope.launch { dragOffsetX.animateTo(0f) }
+ isDragging = false
+ }
+ }
+ )
+ }
+ } else Modifier
+ )
+ .graphicsLayer {
+ translationX = dragOffsetX.value
+ shadowElevation = if (dragOffsetX.value > 0) 20f else 0f
+ }
+ ) {
+ Children(
+ stack = root.childStack,
+ animation = predictiveBackAnimation(
+ backHandler = root.backHandler,
+ onBack = root::onBack,
+ fallbackAnimation = if (!swipeBackInProgress) stackAnimation(slide() + fade()) else null
+ )
+ ) {
+ RenderChild(it.instance, isOverlay = false)
+ }
+ }
+}
+
+@Composable
+private fun TabletLayout(root: RootComponent, childStack: ChildStack<*, RootComponent.Child>) {
+ val activeChild = childStack.active.instance
+ val isSettings = isSettingsSelected(childStack)
+
+ val listChild = remember(childStack) {
+ val settingsChild = childStack.backStack.find { it.instance is RootComponent.Child.SettingsChild }?.instance
+ ?: (activeChild as? RootComponent.Child.SettingsChild)
+ val chatsChild = childStack.backStack.find { it.instance is RootComponent.Child.ChatsChild }?.instance
+ ?: (activeChild as? RootComponent.Child.ChatsChild)
+
+ if (isSettings && settingsChild != null) {
+ settingsChild
+ } else {
+ chatsChild
+ }
+ }
+
+ Row(Modifier.fillMaxSize()) {
+ // List Pane
+ Box(
+ modifier = Modifier
+ .width(350.dp)
+ .fillMaxHeight()
+ ) {
+ if (listChild != null) {
+ RenderChild(listChild)
+ }
+ }
+
+ HorizontalDivider(
+ modifier = Modifier
+ .fillMaxHeight()
+ .width(1.dp),
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+
+ // Detail Pane
+ Box(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight()
+ ) {
+ val isListOnly = activeChild == listChild
+
+ if (!isListOnly) {
+ RenderChild(activeChild)
+ } else {
+ Box(
+ modifier = Modifier.fillMaxSize(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = if (isSettings) stringResource(R.string.tablet_select_setting) else stringResource(R.string.tablet_select_chat),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun RenderChild(child: RootComponent.Child, isOverlay: Boolean = false) {
+ when (child) {
+ is RootComponent.Child.StartupChild -> StartupContent()
+ is RootComponent.Child.AuthChild -> AuthContent(child.component)
+ is RootComponent.Child.ChatsChild -> ChatListContent(child.component)
+ is RootComponent.Child.NewChatChild -> NewChatContent(child.component)
+ is RootComponent.Child.ChatDetailChild -> ChatContent(
+ component = child.component,
+ isOverlay = isOverlay,
+ )
+ is RootComponent.Child.SettingsChild -> SettingsContent(child.component)
+ is RootComponent.Child.EditProfileChild -> EditProfileContent(child.component)
+ is RootComponent.Child.SessionsChild -> SessionsContent(child.component)
+ is RootComponent.Child.FoldersChild -> FoldersContent(child.component)
+ is RootComponent.Child.ChatSettingsChild -> ChatSettingsContent(child.component)
+ is RootComponent.Child.DataStorageChild -> DataStorageContent(child.component)
+ is RootComponent.Child.StorageUsageChild -> StorageUsageContent(child.component)
+ is RootComponent.Child.NetworkUsageChild -> NetworkUsageContent(child.component)
+ is RootComponent.Child.ProfileChild -> ProfileContent(child.component)
+ is RootComponent.Child.PremiumChild -> PremiumContent(child.component)
+ is RootComponent.Child.PrivacyChild -> PrivacyContent(child.component)
+ is RootComponent.Child.AdBlockChild -> AdBlockContent(child.component)
+ is RootComponent.Child.PowerSavingChild -> PowerSavingContent(child.component)
+ is RootComponent.Child.NotificationsChild -> NotificationsContent(child.component)
+ is RootComponent.Child.ProxyChild -> ProxyContent(child.component)
+ is RootComponent.Child.ProfileLogsChild -> ProfileLogsContent(child.component)
+ is RootComponent.Child.AdminManageChild -> AdminManageContent(child.component)
+ is RootComponent.Child.ChatEditChild -> ChatEditContent(child.component)
+ is RootComponent.Child.MemberListChild -> MemberListContent(child.component)
+ is RootComponent.Child.ChatPermissionsChild -> ChatPermissionsContent(child.component)
+ is RootComponent.Child.PasscodeChild -> PasscodeContent(child.component)
+ is RootComponent.Child.StickersChild -> StickersContent(child.component)
+ is RootComponent.Child.AboutChild -> AboutContent(child.component)
+ is RootComponent.Child.DebugChild -> DebugContent(child.component)
+ is RootComponent.Child.WebViewChild -> InternalWebView(
+ url = child.component.url,
+ onDismiss = child.component::onDismiss
+ )
+ }
+}
diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml
index 4f6abffe..1ffcb86a 100644
--- a/app/src/main/res/values-ru/strings.xml
+++ b/app/src/main/res/values-ru/strings.xml
@@ -15,8 +15,13 @@
Порт
Тип
Неизвестно
+ Прокси сохранено, но не включено из-за активной VPN
+ Невозможно включить прокси: VPN активна
+ Прокси добавлен и включён
Отмена
Подключиться
+ Сохранить
+ Сохранить на потом
Присоединиться
diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml
index e2e904de..ccd99564 100644
--- a/app/src/main/res/values-sk/strings.xml
+++ b/app/src/main/res/values-sk/strings.xml
@@ -15,8 +15,13 @@
Port
Typ
Neznáme
+ Proxy uložené, ale neaktivované kvôli aktívnej VPN
+ Nemožno aktivovať proxy: VPN je aktívna
+ Proxy pridaný a aktivovaný
Zrušiť
Pripojiť
+ Uložiť
+ Uložiť na neskôr
Pripojiť sa
diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml
index 7284c6a6..0d177568 100644
--- a/app/src/main/res/values-uk/strings.xml
+++ b/app/src/main/res/values-uk/strings.xml
@@ -15,8 +15,13 @@
Порт
Тип
Невідомо
+ Проксі збережено, але не увімкнено через активну VPN
+ Неможливо увімкнути проксі: VPN активна
+ Проксі додано та увімкнено
Скасувати
Підключитися
+ Зберегти
+ Зберегти на потім
Приєднатися
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index f6126aee..06b83b51 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -15,8 +15,13 @@
端口
类型
未知
+ 代理已保存,但因 VPN 处于活动状态而未启用
+ 无法启用代理:VPN 处于活动状态
+ 代理已添加并启用
取消
连接
+ 保存
+ 稍后保存
加入
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index a3bf8336..d16ed322 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -15,8 +15,13 @@
Port
Type
Unknown
+ Proxy saved but not enabled due to active VPN
+ Cannot enable proxy: VPN is active
+ Proxy added and enabled
Cancel
Connect
+ Save
+ Save for Later
Join
diff --git a/data/src/main/java/org/monogram/data/di/dataModule.kt b/data/src/main/java/org/monogram/data/di/dataModule.kt
index 961f322d..a658637b 100644
--- a/data/src/main/java/org/monogram/data/di/dataModule.kt
+++ b/data/src/main/java/org/monogram/data/di/dataModule.kt
@@ -23,6 +23,7 @@ import org.monogram.data.gateway.TelegramGateway
import org.monogram.data.gateway.TelegramGatewayImpl
import org.monogram.data.gateway.UpdateDispatcher
import org.monogram.data.gateway.UpdateDispatcherImpl
+import org.monogram.domain.infra.VpnDetector as VpnDetectorInterface
import org.monogram.data.infra.*
import org.monogram.data.mapper.ChatMapper
import org.monogram.data.mapper.MessageMapper
@@ -181,7 +182,7 @@ val dataModule = module {
single {
TdChatRemoteSource(
gateway = get(),
- connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
+ connectivityManager = androidContext().getSystemService(ConnectivityManager::class.java),
)
}
@@ -215,7 +216,7 @@ val dataModule = module {
single {
MessageMapper(
- connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
+ connectivityManager = androidContext().getSystemService(ConnectivityManager::class.java),
gateway = get(),
userRepository = get(),
customEmojiPaths = get().customEmojiPaths,
@@ -227,6 +228,12 @@ val dataModule = module {
)
}
+ single {
+ VpnDetector(
+ connectivityManager = androidContext().getSystemService(ConnectivityManager::class.java)
+ )
+ }
+
single {
ConnectionManager(
chatRemoteSource = get(),
@@ -234,7 +241,8 @@ val dataModule = module {
updates = get(),
appPreferences = get(),
dispatchers = get(),
- connectivityManager = androidContext().getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager,
+ connectivityManager = androidContext().getSystemService(ConnectivityManager::class.java),
+ vpnDetector = get(),
scopeProvider = get()
)
}
diff --git a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt
index df929ed2..9f4dc4c6 100644
--- a/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt
+++ b/data/src/main/java/org/monogram/data/infra/ConnectionManager.kt
@@ -18,6 +18,7 @@ import org.monogram.core.ScopeProvider
import org.monogram.data.datasource.remote.ChatRemoteSource
import org.monogram.data.datasource.remote.ProxyRemoteDataSource
import org.monogram.data.gateway.UpdateDispatcher
+import org.monogram.domain.infra.VpnDetector
import org.monogram.domain.repository.AppPreferencesProvider
import org.monogram.domain.repository.ConnectionStatus
import kotlin.random.Random
@@ -29,16 +30,24 @@ class ConnectionManager(
private val appPreferences: AppPreferencesProvider,
private val dispatchers: DispatcherProvider,
private val connectivityManager: ConnectivityManager,
+ private val vpnDetector: VpnDetector,
scopeProvider: ScopeProvider
) {
private val TAG = "ConnectionManager"
private val scope = scopeProvider.appScope
+ private data class ProxyModeState(
+ val autoBest: Boolean,
+ val telega: Boolean,
+ val vpnBlocking: Boolean
+ )
+
private val _connectionStateFlow = MutableStateFlow(ConnectionStatus.Connecting)
val connectionStateFlow = _connectionStateFlow.asStateFlow()
private var retryJob: Job? = null
private var proxyModeWatcherJob: Job? = null
+ private var vpnMonitoringJob: Job? = null
private var autoBestJob: Job? = null
private var telegaSwitchJob: Job? = null
private var watchdogJob: Job? = null
@@ -67,6 +76,7 @@ class ConnectionManager(
registerNetworkCallback()
startWatchdog()
startProxyManagement()
+ startVpnMonitoring()
scope.launch(dispatchers.default) {
runReconnectAttempt("bootstrap", force = true)
@@ -74,6 +84,53 @@ class ConnectionManager(
}
}
+ private fun startVpnMonitoring() {
+ vpnMonitoringJob?.cancel()
+ vpnDetector.stopMonitoring()
+ vpnDetector.startMonitoring()
+
+ vpnMonitoringJob = scope.launch {
+ combine(
+ appPreferences.isVpnAutoDisableEnabled,
+ vpnDetector.isVpnActive
+ ) { enabled, vpnActive -> enabled to vpnActive }
+ .distinctUntilChanged()
+ .collect { (enabled, vpnActive) ->
+ if (!enabled) return@collect
+
+ if (vpnActive) {
+ val currentProxyId = appPreferences.enabledProxyId.value
+ if (currentProxyId != null) {
+ appPreferences.setSavedProxyBeforeVpn(currentProxyId)
+ Log.d(TAG, "VPN detected active, disabling in-app proxy (saved proxy: $currentProxyId)")
+ coRunCatching {
+ proxyRemoteSource.disableProxy()
+ appPreferences.setEnabledProxyId(null)
+ }.onFailure { Log.e(TAG, "Failed to disable proxy for VPN", it) }
+ }
+ autoBestJob?.cancel()
+ telegaSwitchJob?.cancel()
+ } else {
+ val savedProxyId = appPreferences.savedProxyBeforeVpn.value
+ if (savedProxyId != null) {
+ Log.d(TAG, "VPN disconnected, restoring proxy: $savedProxyId")
+ coRunCatching {
+ if (proxyRemoteSource.enableProxy(savedProxyId)) {
+ appPreferences.setEnabledProxyId(savedProxyId)
+ appPreferences.setSavedProxyBeforeVpn(null)
+ } else {
+ Log.w(TAG, "enableProxy returned false for saved proxy $savedProxyId, clearing and letting auto-best handle it")
+ appPreferences.setSavedProxyBeforeVpn(null)
+ }
+ }.onFailure { Log.e(TAG, "Failed to restore proxy after VPN", it) }
+ }
+ // Do NOT restart jobs here — startProxyManagement owns job lifecycle
+ // and will restart them reactively when VPN state changes.
+ }
+ }
+ }
+ }
+
private fun handleConnectionState(state: TdApi.ConnectionState, source: String) {
val status = when (state) {
is TdApi.ConnectionStateReady -> ConnectionStatus.Connected
@@ -215,16 +272,25 @@ class ConnectionManager(
combine(
appPreferences.isAutoBestProxyEnabled,
- appPreferences.isTelegaProxyEnabled
- ) { autoBest, telega -> autoBest to telega }
+ appPreferences.isTelegaProxyEnabled,
+ appPreferences.isVpnAutoDisableEnabled,
+ vpnDetector.isVpnActive
+ ) { autoBest, telega, vpnEnabled, vpnActive ->
+ ProxyModeState(autoBest, telega, vpnEnabled && vpnActive)
+ }
.distinctUntilChanged()
- .collect { (autoBest, telega) ->
+ .collect { modeState ->
autoBestJob?.cancel()
telegaSwitchJob?.cancel()
- if (telega) {
+ if (modeState.vpnBlocking) {
+ // VPN is active and auto-disable is enabled — do not start proxy jobs
+ return@collect
+ }
+
+ if (modeState.telega) {
telegaSwitchJob = launchTelegaSwitchLoop()
- } else if (autoBest) {
+ } else if (modeState.autoBest) {
autoBestJob = launchAutoBestLoop()
}
}
@@ -248,6 +314,11 @@ class ConnectionManager(
}
private suspend fun selectBestProxy(telegaOnly: Boolean = false) {
+ if (appPreferences.isVpnAutoDisableEnabled.value && vpnDetector.isVpnActive.value) {
+ Log.d(TAG, "Skipping proxy selection — VPN is active")
+ return
+ }
+
val allProxies = proxyRemoteSource.getProxies()
val proxies = if (telegaOnly) {
val telegaIds = getTelegaIdentifiers()
@@ -281,6 +352,10 @@ class ConnectionManager(
val currentEnabled = proxies.find { it.isEnabled }
if (best.first.id != currentEnabled?.id) {
Log.d(TAG, "Switching to better proxy: ${best.first.server}:${best.first.port} (ping: ${best.second}ms)")
+ if (appPreferences.isVpnAutoDisableEnabled.value && vpnDetector.isVpnActive.value) {
+ Log.d(TAG, "VPN became active during proxy selection, aborting")
+ return
+ }
if (proxyRemoteSource.enableProxy(best.first.id)) {
appPreferences.setEnabledProxyId(best.first.id)
}
@@ -353,6 +428,20 @@ class ConnectionManager(
}
}
+
+ fun close() {
+ vpnDetector.stopMonitoring()
+ vpnMonitoringJob?.cancel()
+ retryJob?.cancel()
+ proxyModeWatcherJob?.cancel()
+ autoBestJob?.cancel()
+ telegaSwitchJob?.cancel()
+ watchdogJob?.cancel()
+ networkCallback?.let {
+ runCatching { connectivityManager.unregisterNetworkCallback(it) }
+ networkCallback = null
+ }
+ }
private fun onNetworkChanged(reason: String) {
scope.launch(dispatchers.default) {
runReconnectAttempt("network_$reason", force = true)
diff --git a/data/src/main/java/org/monogram/data/infra/VpnDetector.kt b/data/src/main/java/org/monogram/data/infra/VpnDetector.kt
new file mode 100644
index 00000000..3f6a9b9d
--- /dev/null
+++ b/data/src/main/java/org/monogram/data/infra/VpnDetector.kt
@@ -0,0 +1,92 @@
+package org.monogram.data.infra
+
+import android.net.ConnectivityManager
+import android.net.Network
+import android.net.NetworkCapabilities
+import android.net.NetworkRequest
+import android.os.Build
+import android.os.SystemClock
+import android.util.Log
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.asStateFlow
+import org.monogram.domain.infra.VpnDetector as VpnDetectorInterface
+
+class VpnDetector(
+ private val connectivityManager: ConnectivityManager
+) : VpnDetectorInterface {
+ private val TAG = "VpnDetector"
+ private val _isVpnActive = MutableStateFlow(false)
+ override val isVpnActive: StateFlow = _isVpnActive.asStateFlow()
+
+ private var networkCallback: ConnectivityManager.NetworkCallback? = null
+ private var lastCheckAtElapsedMs = 0L
+ private val minCheckIntervalMs = 500L
+
+ override fun startMonitoring() {
+ if (networkCallback != null) return
+
+ checkVpnStatus()
+
+ val callback = object : ConnectivityManager.NetworkCallback() {
+ override fun onAvailable(network: Network) {
+ checkVpnStatus()
+ }
+
+ override fun onLost(network: Network) {
+ checkVpnStatus()
+ }
+
+ override fun onCapabilitiesChanged(
+ network: Network,
+ networkCapabilities: NetworkCapabilities
+ ) {
+ if (connectivityManager.activeNetwork == network) {
+ checkVpnStatus()
+ }
+ }
+ }
+
+ runCatching {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
+ connectivityManager.registerDefaultNetworkCallback(callback)
+ } else {
+ val request = NetworkRequest.Builder().build()
+ connectivityManager.registerNetworkCallback(request, callback)
+ }
+ networkCallback = callback
+ }.onFailure { throwable ->
+ Log.e(TAG, "Failed to register network callback -- VPN detection disabled", throwable)
+ }
+ }
+
+ override fun stopMonitoring() {
+ networkCallback?.let {
+ runCatching { connectivityManager.unregisterNetworkCallback(it) }
+ networkCallback = null
+ }
+ }
+
+ private fun checkVpnStatus() {
+ val now = SystemClock.elapsedRealtime()
+ if (now - lastCheckAtElapsedMs < minCheckIntervalMs) return
+ lastCheckAtElapsedMs = now
+
+ val isActive = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
+ val activeNetwork = connectivityManager.activeNetwork
+ if (activeNetwork != null) {
+ val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
+ capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
+ } else {
+ false
+ }
+ } else {
+ @Suppress("DEPRECATION")
+ connectivityManager.activeNetworkInfo?.type == ConnectivityManager.TYPE_VPN
+ }
+
+ if (_isVpnActive.value != isActive) {
+ _isVpnActive.value = isActive
+ }
+ }
+}
diff --git a/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt b/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt
index 95ec6f75..263ac630 100644
--- a/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt
+++ b/data/src/main/java/org/monogram/data/repository/ExternalProxyRepositoryImpl.kt
@@ -4,6 +4,7 @@ import org.monogram.data.core.coRunCatching
import org.monogram.domain.models.ProxyModel
import org.monogram.domain.models.ProxyTypeModel
import org.monogram.domain.repository.AppPreferencesProvider
+import org.monogram.domain.repository.EnableProxyResult
import org.monogram.domain.repository.ExternalProxyRepository
import kotlinx.coroutines.*
import androidx.core.net.toUri
@@ -69,11 +70,15 @@ class ExternalProxyRepositoryImpl(
proxy
}.getOrNull()
- override suspend fun enableProxy(proxyId: Int): Boolean = coRunCatching {
- remote.enableProxy(proxyId)
- appPreferences.setEnabledProxyId(proxyId)
- true
- }.getOrDefault(false)
+ override suspend fun enableProxy(proxyId: Int, enable: Boolean): EnableProxyResult = coRunCatching {
+ if (enable) {
+ remote.enableProxy(proxyId)
+ appPreferences.setEnabledProxyId(proxyId)
+ EnableProxyResult.Enabled
+ } else {
+ EnableProxyResult.Skipped
+ }
+ }.getOrDefault(EnableProxyResult.Error)
override suspend fun disableProxy(): Boolean = coRunCatching {
remote.disableProxy()
@@ -84,6 +89,7 @@ class ExternalProxyRepositoryImpl(
override suspend fun removeProxy(proxyId: Int): Boolean = coRunCatching {
remote.removeProxy(proxyId)
if (appPreferences.enabledProxyId.value == proxyId) appPreferences.setEnabledProxyId(null)
+ if (appPreferences.savedProxyBeforeVpn.value == proxyId) appPreferences.setSavedProxyBeforeVpn(null)
true
}.getOrDefault(false)
diff --git a/domain/src/main/java/org/monogram/domain/infra/VpnDetector.kt b/domain/src/main/java/org/monogram/domain/infra/VpnDetector.kt
new file mode 100644
index 00000000..f2539665
--- /dev/null
+++ b/domain/src/main/java/org/monogram/domain/infra/VpnDetector.kt
@@ -0,0 +1,9 @@
+package org.monogram.domain.infra
+
+import kotlinx.coroutines.flow.StateFlow
+
+interface VpnDetector {
+ val isVpnActive: StateFlow
+ fun startMonitoring()
+ fun stopMonitoring()
+}
diff --git a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt
index 8cea1ab6..b57dea5f 100644
--- a/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt
+++ b/domain/src/main/java/org/monogram/domain/repository/AppPreferencesProvider.kt
@@ -46,6 +46,8 @@ interface AppPreferencesProvider {
val telegaProxyUrls: StateFlow>
val preferIpv6: StateFlow
val userProxyBackups: StateFlow>
+ val isVpnAutoDisableEnabled: StateFlow
+ val savedProxyBeforeVpn: StateFlow
val isBiometricEnabled: StateFlow
val passcode: StateFlow
@@ -92,6 +94,8 @@ interface AppPreferencesProvider {
fun setTelegaProxyUrls(urls: Set)
fun setPreferIpv6(enabled: Boolean)
fun setUserProxyBackups(backups: Set)
+ fun setVpnAutoDisableEnabled(enabled: Boolean)
+ fun setSavedProxyBeforeVpn(proxyId: Int?)
fun setBiometricEnabled(enabled: Boolean)
fun setPasscode(passcode: String?)
diff --git a/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt b/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt
index 2711a13d..bf4a6adb 100644
--- a/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt
+++ b/domain/src/main/java/org/monogram/domain/repository/ExternalProxyRepository.kt
@@ -3,12 +3,18 @@ package org.monogram.domain.repository
import org.monogram.domain.models.ProxyModel
import org.monogram.domain.models.ProxyTypeModel
+sealed class EnableProxyResult {
+ object Enabled : EnableProxyResult()
+ object Skipped : EnableProxyResult()
+ object Error : EnableProxyResult()
+}
+
interface ExternalProxyRepository {
suspend fun fetchExternalProxies(): List
suspend fun getProxies(): List
suspend fun addProxy(server: String, port: Int, enable: Boolean, type: ProxyTypeModel): ProxyModel?
suspend fun editProxy(proxyId: Int, server: String, port: Int, enable: Boolean, type: ProxyTypeModel): ProxyModel?
- suspend fun enableProxy(proxyId: Int): Boolean
+ suspend fun enableProxy(proxyId: Int, enable: Boolean = true): EnableProxyResult
suspend fun disableProxy(): Boolean
suspend fun removeProxy(proxyId: Int): Boolean
suspend fun pingProxy(proxyId: Int): Long?
diff --git a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt
index 9e102672..d4b81a89 100644
--- a/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt
+++ b/presentation/src/main/java/org/monogram/presentation/core/util/AppPreferences.kt
@@ -346,6 +346,13 @@ class AppPreferences(
private val _userProxyBackups = MutableStateFlow(prefs.getStringSet(KEY_USER_PROXY_BACKUPS, emptySet()) ?: emptySet())
override val userProxyBackups: StateFlow> = _userProxyBackups
+ private val _isVpnAutoDisableEnabled = MutableStateFlow(prefs.getBoolean(KEY_VPN_AUTO_DISABLE_PROXY, false))
+ override val isVpnAutoDisableEnabled: StateFlow = _isVpnAutoDisableEnabled
+
+ private val _savedProxyBeforeVpn =
+ MutableStateFlow(if (prefs.contains(KEY_SAVED_PROXY_BEFORE_VPN)) prefs.getInt(KEY_SAVED_PROXY_BEFORE_VPN, 0) else null)
+ override val savedProxyBeforeVpn: StateFlow = _savedProxyBeforeVpn
+
private val _isBiometricEnabled = MutableStateFlow(securePrefs.getBoolean(KEY_BIOMETRIC_ENABLED, false))
override val isBiometricEnabled: StateFlow = _isBiometricEnabled
@@ -853,6 +860,20 @@ class AppPreferences(
_userProxyBackups.value = backups
}
+ override fun setVpnAutoDisableEnabled(enabled: Boolean) {
+ prefs.edit().putBoolean(KEY_VPN_AUTO_DISABLE_PROXY, enabled).apply()
+ _isVpnAutoDisableEnabled.value = enabled
+ }
+
+ override fun setSavedProxyBeforeVpn(proxyId: Int?) {
+ if (proxyId != null) {
+ prefs.edit().putInt(KEY_SAVED_PROXY_BEFORE_VPN, proxyId).apply()
+ } else {
+ prefs.edit().remove(KEY_SAVED_PROXY_BEFORE_VPN).apply()
+ }
+ _savedProxyBeforeVpn.value = proxyId
+ }
+
override fun setBiometricEnabled(enabled: Boolean) {
securePrefs.edit().putBoolean(KEY_BIOMETRIC_ENABLED, enabled).apply()
_isBiometricEnabled.value = enabled
@@ -967,6 +988,8 @@ class AppPreferences(
_telegaProxyUrls.value = emptySet()
_preferIpv6.value = false
_userProxyBackups.value = emptySet()
+ _isVpnAutoDisableEnabled.value = false
+ _savedProxyBeforeVpn.value = null
_isPermissionRequested.value = false
}
@@ -1082,6 +1105,8 @@ class AppPreferences(
private const val KEY_TELEGA_PROXY_URLS = "telega_proxy_urls"
private const val KEY_PREFER_IPV6 = "prefer_ipv6"
private const val KEY_USER_PROXY_BACKUPS = "user_proxy_backups"
+ private const val KEY_VPN_AUTO_DISABLE_PROXY = "vpn_auto_disable_proxy"
+ private const val KEY_SAVED_PROXY_BEFORE_VPN = "saved_proxy_before_vpn"
private const val KEY_BIOMETRIC_ENABLED = "biometric_enabled"
private const val KEY_PASSCODE = "passcode"
diff --git a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt
index 2446ffb1..f3e8bf8a 100644
--- a/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt
+++ b/presentation/src/main/java/org/monogram/presentation/di/AppContainer.kt
@@ -6,6 +6,7 @@ import org.monogram.core.DispatcherProvider
import org.monogram.core.Logger
import org.monogram.domain.managers.*
import org.monogram.domain.repository.*
+import org.monogram.domain.infra.VpnDetector
import org.monogram.presentation.core.util.AppPreferences
import org.monogram.presentation.core.util.IDownloadUtils
import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache
@@ -49,6 +50,7 @@ interface UtilsContainer {
val clipManager: ClipManager
val dispatcherProvider: DispatcherProvider
val logger: Logger
+ val vpnDetector: VpnDetector
fun messageDisplayer(): MessageDisplayer
fun externalNavigator(): ExternalNavigator
fun phoneManager(): PhoneManager
diff --git a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt
index 78222891..e8be3482 100644
--- a/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt
+++ b/presentation/src/main/java/org/monogram/presentation/di/KoinAppContainer.kt
@@ -7,6 +7,7 @@ import org.monogram.core.DispatcherProvider
import org.monogram.core.Logger
import org.monogram.domain.managers.*
import org.monogram.domain.repository.*
+import org.monogram.domain.infra.VpnDetector
import org.monogram.presentation.core.util.AppPreferences
import org.monogram.presentation.core.util.IDownloadUtils
import org.monogram.presentation.features.chats.currentChat.components.ExoPlayerCache
@@ -50,6 +51,7 @@ class KoinUtilsContainer(private val koin: Koin) : UtilsContainer {
override val clipManager: ClipManager by lazy { koin.get() }
override val dispatcherProvider: DispatcherProvider by lazy { koin.get() }
override val logger: Logger by lazy { koin.get() }
+ override val vpnDetector: VpnDetector by lazy { koin.get() }
override fun messageDisplayer(): MessageDisplayer = koin.get()
override fun externalNavigator(): ExternalNavigator = koin.get()
diff --git a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt
index 1b70eec0..92c1357a 100644
--- a/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt
+++ b/presentation/src/main/java/org/monogram/presentation/root/DefaultRootComponent.kt
@@ -15,6 +15,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
+import org.monogram.domain.infra.VpnDetector
import org.monogram.domain.managers.PhoneManager
import org.monogram.domain.models.MessageContent
import org.monogram.domain.models.ProxyTypeModel
@@ -69,9 +70,9 @@ class DefaultRootComponent(
private val updateRepository: UpdateRepository = container.repositories.updateRepository
private val userRepository: UserRepository = container.repositories.userRepository
private val cacheProvider: CacheProvider = container.cacheProvider
-
override val appPreferences: AppPreferences = container.preferences.appPreferences
override val videoPlayerPool: VideoPlayerPool = container.utils.videoPlayerPool
+ override val vpnDetector: VpnDetector = container.utils.vpnDetector
private val navigation = StackNavigation()
private val scope = componentScope
@@ -324,9 +325,14 @@ class DefaultRootComponent(
override fun confirmProxy(server: String, port: Int, type: ProxyTypeModel) {
scope.launch {
- externalProxyRepository.addProxy(server, port, true, type)
+ val isVpnActive = vpnDetector.isVpnActive.value
+ externalProxyRepository.addProxy(server, port, !isVpnActive, type)
dismissProxyConfirm()
- messageDisplayer.show("Proxy added and enabled")
+ if (isVpnActive) {
+ messageDisplayer.show(container.utils.stringProvider().getString("proxy_saved_vpn_not_enabled"))
+ } else {
+ messageDisplayer.show(container.utils.stringProvider().getString("proxy_added_and_enabled"))
+ }
}
}
diff --git a/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt b/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt
index 6b01b196..cd60821a 100644
--- a/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt
+++ b/presentation/src/main/java/org/monogram/presentation/root/RootComponent.kt
@@ -40,6 +40,7 @@ import org.monogram.presentation.settings.sessions.SessionsComponent
import org.monogram.presentation.settings.settings.SettingsComponent
import org.monogram.presentation.settings.stickers.StickersComponent
import org.monogram.presentation.settings.storage.StorageUsageComponent
+import org.monogram.domain.infra.VpnDetector
interface RootComponent {
val backHandler: BackHandler
@@ -51,6 +52,7 @@ interface RootComponent {
val isBiometricEnabled: StateFlow
val videoPlayerPool: VideoPlayerPool
val appPreferences: AppPreferences
+ val vpnDetector: VpnDetector
fun onBack()
fun handleLink(link: String)
diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt
index 1b953d9e..6a2f960b 100644
--- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt
+++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyComponent.kt
@@ -11,10 +11,12 @@ import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import org.json.JSONObject
+import org.monogram.domain.infra.VpnDetector
import org.monogram.domain.models.ProxyModel
import org.monogram.domain.models.ProxyTypeModel
import org.monogram.domain.repository.AppPreferencesProvider
import org.monogram.domain.repository.CacheProvider
+import org.monogram.domain.repository.EnableProxyResult
import org.monogram.domain.repository.ExternalProxyRepository
import org.monogram.presentation.core.util.componentScope
import org.monogram.presentation.root.AppComponentContext
@@ -41,6 +43,7 @@ interface ProxyComponent {
fun onAutoBestProxyToggled(enabled: Boolean)
fun onTelegaProxyToggled(enabled: Boolean)
fun onPreferIpv6Toggled(enabled: Boolean)
+ fun onVpnAutoDisableToggled(enabled: Boolean)
fun onFetchTelegaProxies()
fun onClearUnavailableProxies()
fun onRemoveAllProxies()
@@ -58,6 +61,7 @@ interface ProxyComponent {
val isAddingProxy: Boolean = false,
val isAutoBestProxyEnabled: Boolean = false,
val preferIpv6: Boolean = false,
+ val isVpnAutoDisableEnabled: Boolean = false,
val proxyToEdit: ProxyModel? = null,
val proxyToDelete: ProxyModel? = null,
val testPing: Long? = null,
@@ -75,9 +79,24 @@ class DefaultProxyComponent(
private val onBack: () -> Unit
) : ProxyComponent, AppComponentContext by context {
+ private data class ProxyPrefs(
+ val autoBest: Boolean,
+ val telega: Boolean,
+ val ipv6: Boolean,
+ val vpnAutoDisable: Boolean
+ )
+
private val appPreferences: AppPreferencesProvider = container.preferences.appPreferences
private val cacheProvider: CacheProvider = container.cacheProvider
private val externalProxyRepository: ExternalProxyRepository = container.repositories.externalProxyRepository
+ private val vpnDetector: VpnDetector = container.utils.vpnDetector
+
+ private val vpnBlockActive: Boolean
+ get() = appPreferences.isVpnAutoDisableEnabled.value && vpnDetector.isVpnActive.value
+
+ private fun showVpnBlockToast() {
+ _state.update { it.copy(toastMessage = container.utils.stringProvider().getString("proxy_saved_vpn_not_enabled")) }
+ }
private val _state = MutableValue(ProxyComponent.State())
override val state: Value = _state
@@ -93,18 +112,22 @@ class DefaultProxyComponent(
combine(
appPreferences.isAutoBestProxyEnabled,
appPreferences.isTelegaProxyEnabled,
- appPreferences.preferIpv6
- ) { autoBest, telega, ipv6 -> Triple(autoBest, telega, ipv6) }
+ appPreferences.preferIpv6,
+ appPreferences.isVpnAutoDisableEnabled
+ ) { autoBest, telega, ipv6, vpnAutoDisable ->
+ ProxyPrefs(autoBest, telega, ipv6, vpnAutoDisable)
+ }
.distinctUntilChanged()
- .onEach { (autoBest, telega, ipv6) ->
- if (telega && autoBest) {
+ .onEach { prefs ->
+ if (prefs.telega && prefs.autoBest) {
appPreferences.setAutoBestProxyEnabled(false)
}
_state.update {
it.copy(
- isAutoBestProxyEnabled = if (telega) false else autoBest,
- isTelegaProxyEnabled = telega,
- preferIpv6 = ipv6
+ isAutoBestProxyEnabled = if (prefs.telega) false else prefs.autoBest,
+ isTelegaProxyEnabled = prefs.telega,
+ preferIpv6 = prefs.ipv6,
+ isVpnAutoDisableEnabled = prefs.vpnAutoDisable
)
}
}.launchIn(scope)
@@ -309,9 +332,18 @@ class DefaultProxyComponent(
override fun onEnableProxy(proxyId: Int) {
scope.launch {
- if (externalProxyRepository.enableProxy(proxyId)) {
- refreshProxies(shouldPing = false)
- onPingProxy(proxyId)
+ when (externalProxyRepository.enableProxy(proxyId, !vpnBlockActive)) {
+ is EnableProxyResult.Enabled -> {
+ refreshProxies(shouldPing = false)
+ onPingProxy(proxyId)
+ }
+ is EnableProxyResult.Skipped -> {
+ refreshProxies(shouldPing = false)
+ showVpnBlockToast()
+ }
+ is EnableProxyResult.Error -> {
+ // Toast handled by caller or silent
+ }
}
}
}
@@ -388,12 +420,13 @@ class DefaultProxyComponent(
override fun onAddProxy(server: String, port: Int, type: ProxyTypeModel) {
scope.launch {
- val proxy = externalProxyRepository.addProxy(server, port, true, type)
+ val proxy = externalProxyRepository.addProxy(server, port, !vpnBlockActive, type)
if (proxy != null) {
addProxyToBackup(proxy)
_state.update { it.copy(isAddingProxy = false) }
refreshProxies(shouldPing = false)
onPingProxy(proxy.id)
+ if (vpnBlockActive) showVpnBlockToast()
}
}
}
@@ -401,12 +434,13 @@ class DefaultProxyComponent(
override fun onEditProxy(proxyId: Int, server: String, port: Int, type: ProxyTypeModel) {
scope.launch {
val oldProxy = (_state.value.proxies + _state.value.telegaProxies).find { it.id == proxyId }
- val proxy = externalProxyRepository.editProxy(proxyId, server, port, true, type)
+ val proxy = externalProxyRepository.editProxy(proxyId, server, port, !vpnBlockActive, type)
if (proxy != null) {
replaceProxyInBackup(oldProxy, proxy)
_state.update { it.copy(proxyToEdit = null) }
refreshProxies(shouldPing = false)
onPingProxy(proxy.id)
+ if (vpnBlockActive) showVpnBlockToast()
}
}
}
@@ -456,6 +490,10 @@ class DefaultProxyComponent(
externalProxyRepository.setPreferIpv6(enabled)
}
+ override fun onVpnAutoDisableToggled(enabled: Boolean) {
+ appPreferences.setVpnAutoDisableEnabled(enabled)
+ }
+
override fun onFetchTelegaProxies() {
if (_state.value.isFetchingExternal) return
diff --git a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt
index 56ce6a0e..d5568e84 100644
--- a/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt
+++ b/presentation/src/main/java/org/monogram/presentation/settings/proxy/ProxyContent.kt
@@ -137,6 +137,16 @@ fun ProxyContent(component: ProxyComponent) {
onCheckedChange = component::onPreferIpv6Toggled
)
+ SettingsSwitchTile(
+ icon = Icons.Rounded.Shield,
+ title = stringResource(R.string.disable_proxy_on_vpn_title),
+ subtitle = stringResource(R.string.disable_proxy_on_vpn_subtitle),
+ checked = state.isVpnAutoDisableEnabled,
+ iconColor = MaterialTheme.colorScheme.primary,
+ position = ItemPosition.BOTTOM,
+ onCheckedChange = component::onVpnAutoDisableToggled
+ )
+
val isDirect = state.proxies.none { it.isEnabled } && state.telegaProxies.none { it.isEnabled }
SettingsTile(
icon = Icons.Rounded.LinkOff,
diff --git a/presentation/src/main/res/values-ru-rRU/string.xml b/presentation/src/main/res/values-ru-rRU/string.xml
index 63acaae6..87839c68 100644
--- a/presentation/src/main/res/values-ru-rRU/string.xml
+++ b/presentation/src/main/res/values-ru-rRU/string.xml
@@ -468,6 +468,8 @@
Автоматически выбирать самый быстрый прокси
Предпочитать IPv6
Использовать IPv6 при наличии
+ Отключать прокси при VPN
+ Временно отключать прокси приложения при активной системной VPN
Отключить прокси
Прямое подключение
Переключиться на прямое соединение
diff --git a/presentation/src/main/res/values-sk/string.xml b/presentation/src/main/res/values-sk/string.xml
index bb0e5c29..b72e2122 100644
--- a/presentation/src/main/res/values-sk/string.xml
+++ b/presentation/src/main/res/values-sk/string.xml
@@ -493,6 +493,8 @@
Automaticky použiť najrýchlejšie proxy
Uprednostniť IPv6
Použiť IPv6, keď je dostupné
+ Zakázať proxy pri VPN
+ Dočasne zakázať proxy v aplikácii pri aktívnej systémovej VPN
Vypnúť proxy
Pripojené priamo
Prepnúť na priame pripojenie
diff --git a/presentation/src/main/res/values-uk/string.xml b/presentation/src/main/res/values-uk/string.xml
index d7c37908..9f47ace7 100644
--- a/presentation/src/main/res/values-uk/string.xml
+++ b/presentation/src/main/res/values-uk/string.xml
@@ -468,6 +468,8 @@
Автоматично вибирати найшвидший проксі
Віддавати перевагу IPv6
Використовувати IPv6 за наявності
+ Вимикати проксі при VPN
+ Тимчасово вимикати проксі додатку при активній системній VPN
Вимкнути проксі
Пряме підключення
Перемкнутися на пряме з\'єднання
diff --git a/presentation/src/main/res/values-zh-rCN/string.xml b/presentation/src/main/res/values-zh-rCN/string.xml
index 61a1e6da..020678bd 100644
--- a/presentation/src/main/res/values-zh-rCN/string.xml
+++ b/presentation/src/main/res/values-zh-rCN/string.xml
@@ -468,6 +468,8 @@
自动使用速度最快的代理
IPv6 优先
如果可用,优先使用 IPv6
+ VPN 连接时禁用代理
+ 系统 VPN 处于活动状态时,暂时禁用应用内代理
禁用代理
直接连接
切换到直接连接
diff --git a/presentation/src/main/res/values/string.xml b/presentation/src/main/res/values/string.xml
index cf71caef..a7f03ffc 100644
--- a/presentation/src/main/res/values/string.xml
+++ b/presentation/src/main/res/values/string.xml
@@ -482,6 +482,8 @@
Automatically use the fastest proxy
Prefer IPv6
Use IPv6 when available
+ Disable Proxy on VPN
+ Temporarily disable in-app proxy when system VPN is active
Disable Proxy
Connected directly
Switch to direct connection