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