From 3c8aefc7bece5855327a9f2ef5fc5f82c0e592dd Mon Sep 17 00:00:00 2001 From: Mateusz Markowicz Date: Mon, 16 Feb 2026 16:18:48 +0100 Subject: [PATCH] ProTUN's packet capture support in "Debug tools" --- .../protonvpn/android/base/ui/VpnButton.kt | 2 + .../android/logging/LogFileForSharing.kt | 20 +--- .../redesign/settings/ui/DebugTools.kt | 110 +++++++++++++++++- .../settings/ui/DebugToolsViewModel.kt | 65 ++++++++++- .../redesign/settings/ui/SubSettings.kt | 33 ++++-- .../protonvpn/android/utils/AndroidUtils.kt | 15 +++ .../vpn/protun/ConnectionParamsProTun.kt | 4 +- .../android/vpn/protun/PacketCapture.kt | 77 ++++++++++++ .../android/vpn/protun/PacketCapturePrefs.kt | 52 +++++++++ .../android/vpn/protun/ProTunBackend.kt | 32 ++++- gradle/libs.versions.toml | 2 +- 11 files changed, 368 insertions(+), 44 deletions(-) create mode 100644 app/src/main/java/com/protonvpn/android/vpn/protun/PacketCapture.kt create mode 100644 app/src/main/java/com/protonvpn/android/vpn/protun/PacketCapturePrefs.kt diff --git a/app/src/main/java/com/protonvpn/android/base/ui/VpnButton.kt b/app/src/main/java/com/protonvpn/android/base/ui/VpnButton.kt index 9020a4b3b..061e17f4c 100644 --- a/app/src/main/java/com/protonvpn/android/base/ui/VpnButton.kt +++ b/app/src/main/java/com/protonvpn/android/base/ui/VpnButton.kt @@ -48,12 +48,14 @@ fun VpnSolidButton( enabled: Boolean = true, isExternalLink: Boolean = false, isLoading: Boolean = false, + colors: ButtonColors = ButtonDefaults.protonButtonColors(isLoading) ) { ProtonSolidButton( modifier = modifier.fillMaxWidth(), onClick = onClick, enabled = enabled, loading = isLoading, + colors = colors ) { VpnButtonContent( text = text, diff --git a/app/src/main/java/com/protonvpn/android/logging/LogFileForSharing.kt b/app/src/main/java/com/protonvpn/android/logging/LogFileForSharing.kt index f83d2e9c5..523f09896 100644 --- a/app/src/main/java/com/protonvpn/android/logging/LogFileForSharing.kt +++ b/app/src/main/java/com/protonvpn/android/logging/LogFileForSharing.kt @@ -19,15 +19,11 @@ package com.protonvpn.android.logging -import android.content.ClipData import android.content.Context import android.content.Intent -import android.net.Uri -import androidx.core.content.FileProvider -import com.protonvpn.android.BuildConfig +import com.protonvpn.android.utils.createSendFileIntent import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext -import java.io.File import javax.inject.Inject @Reusable @@ -36,18 +32,6 @@ class LogFileForSharing @Inject constructor( ) { suspend operator fun invoke(): Intent? { val file = ProtonLogger.getLogFileForSharing() - return file?.let { createIntent(it) } - } - - private fun createIntent(file: File): Intent { - val intent = Intent(Intent.ACTION_SEND) - intent.type = "*/*" - - val contentUri: Uri = - FileProvider.getUriForFile(appContext, "${BuildConfig.APPLICATION_ID}.fileprovider", file) - intent.putExtra(Intent.EXTRA_STREAM, contentUri) - intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION - intent.clipData = ClipData.newRawUri("", contentUri) - return Intent.createChooser(intent, "Share log") + return file?.let { appContext.createSendFileIntent(it, "Share log") } } } diff --git a/app/src/main/java/com/protonvpn/android/redesign/settings/ui/DebugTools.kt b/app/src/main/java/com/protonvpn/android/redesign/settings/ui/DebugTools.kt index a7d63ea2b..8fee6dff2 100644 --- a/app/src/main/java/com/protonvpn/android/redesign/settings/ui/DebugTools.kt +++ b/app/src/main/java/com/protonvpn/android/redesign/settings/ui/DebugTools.kt @@ -21,30 +21,44 @@ package com.protonvpn.android.redesign.settings.ui import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.protonvpn.android.base.ui.ProtonVpnPreview import com.protonvpn.android.base.ui.VpnSolidButton +import com.protonvpn.android.base.ui.protonButtonColors import com.protonvpn.android.redesign.base.ui.ProtonOutlinedTextField import com.protonvpn.android.redesign.base.ui.SettingsItem +import me.proton.core.compose.theme.ProtonTheme import me.proton.core.presentation.R as CoreR @Composable @Suppress("LongParameterList") fun DebugTools( + state: DebugToolsState, onConnectGuestHole: () -> Unit, onRefreshConfig: () -> Unit, - netzone: String, - country: String, setNetzone: (String) -> Unit, setCountry: (String) -> Unit, onClose: () -> Unit, + setPcapActive: (Boolean) -> Unit, + onSharePcapFile: () -> Unit, + onRemovePcapFile: () -> Unit, + onSetMaxPcapSizeMB: (Long) -> Unit, ) { SubSetting( title = "Debug tools", @@ -58,14 +72,14 @@ fun DebugTools( val paddingModifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp).fillMaxWidth() DebugTextInputRow( - value = netzone, + value = state.netzone, onValueChange = setNetzone, labelText = "x-pm-netzone", placeholderText = "IP address", modifier = paddingModifier, ) DebugTextInputRow( - value = country, + value = state.country, onValueChange = setCountry, labelText = "x-pm-country", placeholderText = "2-letter country code", @@ -76,6 +90,67 @@ fun DebugTools( text = "Refresh config and servers", modifier = paddingModifier, ) + + SettingsItem( + modifier = Modifier.clickable(onClick = onConnectGuestHole), + name = "Packet capture", + ) + + VpnSolidButton( + onClick = { setPcapActive(!state.isPacketCaptureActive) }, + text = if (state.isPacketCaptureActive) "Stop PCAP" else "Start PCAP", + colors = + if (state.isPacketCaptureActive) ButtonDefaults.protonButtonColors( + contentColor = ProtonTheme.colors.textNorm, + backgroundColor = ProtonTheme.colors.notificationError + ) else { + ButtonDefaults.protonButtonColors() + }, + modifier = paddingModifier, + ) + + if (!state.isPacketCaptureActive) { + DebugTextInputRow( + value = if (state.pcapMaxMBytes == 0L) "" else state.pcapMaxMBytes.toString(), + onValueChange = { newValue -> + // Filter to only allow digits (positive numbers) + val filtered = newValue.filter { it.isDigit() } + val sizeInMB = filtered.toLongOrNull() ?: 0L + onSetMaxPcapSizeMB(sizeInMB) + }, + labelText = "Max PCAP size (MB)", + placeholderText = state.pcapMaxMBytes.takeIf { it > 0 }?.toString() ?: "Unlimited", + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = paddingModifier, + ) + } + + state.existingPcapFileName?.let { fileName -> + Row( + modifier = paddingModifier, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = fileName, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Spacer(modifier = Modifier.width(8.dp)) + IconButton(onClick = onSharePcapFile) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_proton_arrow_up_from_square), + contentDescription = "Share", + ) + } + IconButton(onClick = onRemovePcapFile) { + Icon( + painter = painterResource(id = CoreR.drawable.ic_proton_trash), + contentDescription = "Remove", + ) + } + } + } } } @@ -86,6 +161,7 @@ private fun DebugTextInputRow( labelText: String, placeholderText: String, modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, ) { Row( modifier = modifier, @@ -98,6 +174,7 @@ private fun DebugTextInputRow( placeholderText = placeholderText, singleLine = true, modifier = Modifier.weight(1f), + keyboardOptions = keyboardOptions, trailingIcon = { Icon( painter = painterResource(id = CoreR.drawable.ic_proton_close), @@ -110,3 +187,28 @@ private fun DebugTextInputRow( ) } } + +@ProtonVpnPreview +@Composable +fun DebugToolsPreview() { + ProtonVpnPreview { + DebugTools( + state = DebugToolsState( + netzone = "netzone", + country = "country", + isPacketCaptureActive = true, + pcapMaxMBytes = 100, + existingPcapFileName = "protonvpn.pcap", + ), + onConnectGuestHole = {}, + onRefreshConfig = {}, + setNetzone = {}, + setCountry = {}, + onClose = {}, + setPcapActive = {}, + onSharePcapFile = {}, + onRemovePcapFile = {}, + onSetMaxPcapSizeMB = {}, + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/protonvpn/android/redesign/settings/ui/DebugToolsViewModel.kt b/app/src/main/java/com/protonvpn/android/redesign/settings/ui/DebugToolsViewModel.kt index 7c5a15ee9..987e31a4b 100644 --- a/app/src/main/java/com/protonvpn/android/redesign/settings/ui/DebugToolsViewModel.kt +++ b/app/src/main/java/com/protonvpn/android/redesign/settings/ui/DebugToolsViewModel.kt @@ -20,17 +20,23 @@ package com.protonvpn.android.redesign.settings.ui import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope import com.protonvpn.android.api.GuestHole import com.protonvpn.android.api.data.DebugApiPrefs -import com.protonvpn.android.promooffers.data.ApiNotificationManager import com.protonvpn.android.appconfig.AppConfig import com.protonvpn.android.auth.usecase.CurrentUser +import com.protonvpn.android.promooffers.data.ApiNotificationManager import com.protonvpn.android.ui.home.ServerListUpdater +import com.protonvpn.android.utils.ifOrNull +import com.protonvpn.android.vpn.protun.PacketCapture +import com.protonvpn.android.vpn.protun.PacketCapturePrefs import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.launch +import java.io.File import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -42,21 +48,38 @@ class DebugToolsViewModel @Inject constructor( private val appConfig: AppConfig, private val serverListUpdater: ServerListUpdater, private val apiNotificationManager: ApiNotificationManager, + private val packetCapture: PacketCapture, debugApiPrefsNullable: DebugApiPrefs?, + private val packetCapturePrefs: PacketCapturePrefs, ): ViewModel() { private val debugApiPrefs = requireNotNull(debugApiPrefsNullable) + private val updateStateTrigger = MutableSharedFlow(replay = 1).apply { + tryEmit(Unit) + } val state = combine( debugApiPrefs.netzoneFlow, debugApiPrefs.countryFlow, - ) { netzone, country -> + packetCapturePrefs.maxBytesFlow, + packetCapture.isCaptureActiveFlow, + updateStateTrigger + ) { netzone, country, pcapMaxBytes, isPacketCaptureActive, _ -> DebugToolsState( - netzone = netzone, - country = country, + netzone = netzone.orEmpty(), + country = country.orEmpty(), + isPacketCaptureActive = isPacketCaptureActive, + pcapMaxMBytes = pcapMaxBytes / (1024 * 1024), + existingPcapFileName = ifOrNull(!isPacketCaptureActive) { packetCapture.fileIfExists()?.name } ) } + sealed interface Event { + data class SendPcap(val file: File): Event + data object ShowNoPcapFound: Event + } + val events = MutableSharedFlow(extraBufferCapacity = 1) + fun connectGuestHole() { mainScope.launch { guestHole.onAlternativesUnblock { @@ -80,9 +103,39 @@ class DebugToolsViewModel @Inject constructor( fun setCountry(country: String) { debugApiPrefs.country = country.takeIf { it.isNotBlank() } } + + fun setPcapActive(active: Boolean) { + viewModelScope.launch { + packetCapture.setActive(active) + } + } + + fun setMaxPcapSizeMB(sizeInMBytes: Long) { + packetCapturePrefs.maxBytes = sizeInMBytes * 1024 * 1024 + } + + fun removePcapFile() { + viewModelScope.launch { + packetCapture.removeFileIfInactive() + updateStateTrigger.tryEmit(Unit) + } + } + + fun sharePcapFile() { + viewModelScope.launch { + val file = packetCapture.fileIfExists() + if (file != null) + events.emit(Event.SendPcap(file)) + else + events.emit(Event.ShowNoPcapFound) + } + } } data class DebugToolsState( - val netzone: String?, - val country: String?, + val netzone: String, + val country: String, + val isPacketCaptureActive: Boolean, + val pcapMaxMBytes: Long, + val existingPcapFileName: String?, ) diff --git a/app/src/main/java/com/protonvpn/android/redesign/settings/ui/SubSettings.kt b/app/src/main/java/com/protonvpn/android/redesign/settings/ui/SubSettings.kt index 88e0c7dc5..d5d1ce04e 100644 --- a/app/src/main/java/com/protonvpn/android/redesign/settings/ui/SubSettings.kt +++ b/app/src/main/java/com/protonvpn/android/redesign/settings/ui/SubSettings.kt @@ -21,6 +21,7 @@ package com.protonvpn.android.redesign.settings.ui import android.content.Intent import android.provider.Settings +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box @@ -76,6 +77,7 @@ import com.protonvpn.android.ui.settings.SettingsSplitTunnelAppsActivity import com.protonvpn.android.ui.settings.SettingsSplitTunnelIpsActivity import com.protonvpn.android.utils.Constants import com.protonvpn.android.utils.DebugUtils +import com.protonvpn.android.utils.createSendFileIntent import com.protonvpn.android.utils.openUrl import com.protonvpn.android.utils.openVpnSettings import com.protonvpn.android.vpn.ui.LocalVpnUiDelegate @@ -271,15 +273,28 @@ fun SubSettingsRoute( SubSettingsScreen.Type.DebugTools -> { val debugToolsViewModel = hiltViewModel() val state = debugToolsViewModel.state.collectAsStateWithLifecycle(initialValue = null).value - DebugTools( - onClose = onClose, - onConnectGuestHole = debugToolsViewModel::connectGuestHole, - onRefreshConfig = debugToolsViewModel::refreshConfig, - netzone = state?.netzone ?: "", - country = state?.country ?: "", - setNetzone = debugToolsViewModel::setNetzone, - setCountry = debugToolsViewModel::setCountry, - ) + debugToolsViewModel.events.collectAsEffect { event -> + when (event) { + is DebugToolsViewModel.Event.SendPcap -> + context.startActivity(context.createSendFileIntent(event.file, "Share pcap")) + DebugToolsViewModel.Event.ShowNoPcapFound -> + Toast.makeText(context, "No PCAP captured.", Toast.LENGTH_SHORT).show() + } + } + if (state != null) { + DebugTools( + state = state, + onClose = onClose, + onConnectGuestHole = debugToolsViewModel::connectGuestHole, + onRefreshConfig = debugToolsViewModel::refreshConfig, + setNetzone = debugToolsViewModel::setNetzone, + setCountry = debugToolsViewModel::setCountry, + setPcapActive = debugToolsViewModel::setPcapActive, + onRemovePcapFile = debugToolsViewModel::removePcapFile, + onSetMaxPcapSizeMB = debugToolsViewModel::setMaxPcapSizeMB, + onSharePcapFile = debugToolsViewModel::sharePcapFile, + ) + } } SubSettingsScreen.Type.NatType -> { diff --git a/app/src/main/java/com/protonvpn/android/utils/AndroidUtils.kt b/app/src/main/java/com/protonvpn/android/utils/AndroidUtils.kt index e0ee22001..c6633bd00 100644 --- a/app/src/main/java/com/protonvpn/android/utils/AndroidUtils.kt +++ b/app/src/main/java/com/protonvpn/android/utils/AndroidUtils.kt @@ -24,6 +24,7 @@ import android.app.Application import android.app.ApplicationExitInfo import android.content.ActivityNotFoundException import android.content.BroadcastReceiver +import android.content.ClipData import android.content.Context import android.content.Context.RECEIVER_NOT_EXPORTED import android.content.ContextWrapper @@ -52,9 +53,11 @@ import androidx.annotation.RequiresApi import androidx.browser.customtabs.CustomTabsClient import androidx.browser.customtabs.CustomTabsIntent import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider import androidx.core.graphics.BlendModeColorFilterCompat import androidx.core.graphics.BlendModeCompat import androidx.core.view.ViewCompat +import com.protonvpn.android.BuildConfig import com.protonvpn.android.R import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -376,3 +379,15 @@ fun BroadcastReceiver.launchAsyncReceive(scope: CoroutineScope, block: suspend ( inline fun ifOrNull(predicate: Boolean, block: () -> T): T? = if (predicate) block() else null + +fun Context.createSendFileIntent(file: File, title: String): Intent { + val intent = Intent(Intent.ACTION_SEND) + intent.type = "*/*" + + val contentUri: Uri = + FileProvider.getUriForFile(this, "${BuildConfig.APPLICATION_ID}.fileprovider", file) + intent.putExtra(Intent.EXTRA_STREAM, contentUri) + intent.flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + intent.clipData = ClipData.newRawUri("", contentUri) + return Intent.createChooser(intent, title) +} diff --git a/app/src/main/java/com/protonvpn/android/vpn/protun/ConnectionParamsProTun.kt b/app/src/main/java/com/protonvpn/android/vpn/protun/ConnectionParamsProTun.kt index 523e0f576..acbbb02d8 100644 --- a/app/src/main/java/com/protonvpn/android/vpn/protun/ConnectionParamsProTun.kt +++ b/app/src/main/java/com/protonvpn/android/vpn/protun/ConnectionParamsProTun.kt @@ -36,6 +36,7 @@ import me.proton.core.network.domain.session.SessionId import me.proton.vpn.sdk.api.InitialConfig import me.proton.vpn.sdk.api.InterfaceConfig import me.proton.vpn.sdk.api.IpNetworkPrefix +import me.proton.vpn.sdk.api.PacketCaptureInfo import me.proton.vpn.sdk.api.Peer import me.proton.vpn.sdk.api.SplitTunnelAppsConfig import me.proton.vpn.sdk.api.SplitTunnelMode @@ -68,6 +69,7 @@ class ConnectionParamsProTun( sessionId: SessionId?, certificateRepository: CertificateRepository, computeAllowedIPs: ComputeAllowedIPs, + pcapFile: PacketCaptureInfo?, ): InitialConfig { val allowedIps = if (connectIntent is AnyConnectIntent.GuestHole) { @@ -84,7 +86,7 @@ class ConnectionParamsProTun( ) val privateKey = certificateRepository.getX25519Key(sessionId) - return InitialConfig(iface, privateKey, peers) + return InitialConfig(iface, privateKey, peers, pcapFile) } fun splitTunnelAppsConfig( diff --git a/app/src/main/java/com/protonvpn/android/vpn/protun/PacketCapture.kt b/app/src/main/java/com/protonvpn/android/vpn/protun/PacketCapture.kt new file mode 100644 index 000000000..5be7b9dcf --- /dev/null +++ b/app/src/main/java/com/protonvpn/android/vpn/protun/PacketCapture.kt @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2026. Proton AG + * + * This file is part of ProtonVPN. + * + * ProtonVPN is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ProtonVPN is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ProtonVPN. If not, see . + */ + +package com.protonvpn.android.vpn.protun + +import android.content.Context +import com.protonvpn.android.concurrency.VpnDispatcherProvider +import com.protonvpn.android.utils.ifOrNull +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withContext +import me.proton.vpn.sdk.api.PacketCaptureFile +import me.proton.vpn.sdk.api.PacketCaptureInfo +import java.io.File +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PacketCapture @Inject constructor( + @ApplicationContext private val context: Context, + private val dispatcherProvider: VpnDispatcherProvider, + private val prefs: PacketCapturePrefs, +) { + val isCaptureActiveFlow = + prefs.isActiveFlow.filterNotNull() + + val activeFileFlow: Flow = combine( + prefs.isActiveFlow, + prefs.maxBytesFlow, + ) { active, maxBytes -> + ifOrNull(active) { + context.shareDir().mkdirs() + PacketCaptureInfo( + PacketCaptureFile.Path(context.packetCaptureFile(), append = false), + maxBytes.toULong() + ) + } + }.distinctUntilChanged() + + fun setActive(active: Boolean) { + prefs.isActive = active + } + + suspend fun fileIfExists() = withContext(dispatcherProvider.Io) { + context.packetCaptureFile().takeIf { it.exists() } + } + + suspend fun removeFileIfInactive() = withContext(dispatcherProvider.Io) { + val active = isCaptureActiveFlow.first() + if (!active) { + context.packetCaptureFile().takeIf { it.exists() }?.delete() + } + } +} + +private fun Context.shareDir() = File(cacheDir, "share") +private fun Context.packetCaptureFile() = File(shareDir(), "protonvpn.pcap") \ No newline at end of file diff --git a/app/src/main/java/com/protonvpn/android/vpn/protun/PacketCapturePrefs.kt b/app/src/main/java/com/protonvpn/android/vpn/protun/PacketCapturePrefs.kt new file mode 100644 index 000000000..507282f6e --- /dev/null +++ b/app/src/main/java/com/protonvpn/android/vpn/protun/PacketCapturePrefs.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2023 Proton AG + * + * This file is part of ProtonVPN. + * + * ProtonVPN is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * ProtonVPN is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with ProtonVPN. If not, see . + */ +package com.protonvpn.android.vpn.protun + +import android.content.SharedPreferences +import com.protonvpn.android.utils.SharedPreferencesProvider +import dagger.Reusable +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.mapNotNull +import me.proton.core.util.android.sharedpreferences.PreferencesProvider +import me.proton.core.util.android.sharedpreferences.boolean +import me.proton.core.util.android.sharedpreferences.long +import me.proton.core.util.android.sharedpreferences.observe +import javax.inject.Inject + +@Reusable +class PacketCapturePrefs @Inject constructor( + private val prefsProvider: SharedPreferencesProvider +) : PreferencesProvider { + + override val preferences: SharedPreferences + get() = prefsProvider.getPrefs(PREFS_NAME) + + var isActive: Boolean by boolean(key = KEY_IS_ACTIVE, default = false) + val isActiveFlow: Flow = preferences.observe(KEY_IS_ACTIVE).mapNotNull { it ?: isActive } + var maxBytes: Long by long(key = KEY_MAX_BYTES, default = DEFAULT_MAX_BYTES) + val maxBytesFlow: Flow = preferences.observe(key = KEY_MAX_BYTES).mapNotNull { it ?: maxBytes } + + companion object { + private const val PREFS_NAME = "PcapPrefs" + private const val KEY_IS_ACTIVE = "is_active" + private const val KEY_MAX_BYTES = "max_bytes" + + private const val DEFAULT_MAX_BYTES = 100L * 1024 * 1024 // 100 MB + } +} diff --git a/app/src/main/java/com/protonvpn/android/vpn/protun/ProTunBackend.kt b/app/src/main/java/com/protonvpn/android/vpn/protun/ProTunBackend.kt index 91d4554ec..2b17e8481 100644 --- a/app/src/main/java/com/protonvpn/android/vpn/protun/ProTunBackend.kt +++ b/app/src/main/java/com/protonvpn/android/vpn/protun/ProTunBackend.kt @@ -43,9 +43,13 @@ import com.protonvpn.android.vpn.VpnBackend import com.protonvpn.android.vpn.VpnState import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import me.proton.core.network.data.di.SharedOkHttpClient import me.proton.core.network.domain.NetworkManager import me.proton.vpn.sdk.api.ProtonVpnSdk @@ -70,11 +74,14 @@ class ProTunBackend @Inject constructor( @SharedOkHttpClient okHttp: OkHttpClient, private val sdk: ProtonVpnSdk, private val computeAllowedIPs: ComputeAllowedIPs, - private val preparePeers: PreparePeersForConnectionProTun + private val preparePeers: PreparePeersForConnectionProTun, + private val packetCapture: PacketCapture, ) : VpnBackend( settingsForConnection, certificateRepository, networkManager, networkCapabilitiesFlow, VpnProtocol.ProTun, mainScope, dispatcherProvider, localAgentUnreachableTracker, currentUser, getNetZone, foregroundActivityTracker, okHttp, shouldWaitForTunnelVerified = false ) { + private var observePcapJob: Job? = null + init { sdk.connectionManager.state.onEach { state -> ProtonLogger.logCustom( @@ -124,15 +131,30 @@ class ProTunBackend @Inject constructor( override suspend fun connect(connectionParams: ConnectionParams) { super.connect(connectionParams) - val wireGuardParams = connectionParams as ConnectionParamsProTun - val settings = settingsForConnection.getFor(wireGuardParams.connectIntent) + val protunParams = connectionParams as ConnectionParamsProTun + val settings = settingsForConnection.getFor(protunParams.connectIntent) val sessionId = currentUser.sessionId() - val config = wireGuardParams.getTunnelConfig( - context, settings, sessionId, certificateRepository, computeAllowedIPs) + val config = protunParams.getTunnelConfig( + context, + settings, + sessionId, + certificateRepository, + computeAllowedIPs, + packetCapture.activeFileFlow.first() + ) sdk.connectionManager.connect(config) + + observePcapJob = mainScope.launch { + packetCapture.activeFileFlow + .drop(1) + .collect { pcapFile -> sdk.connectionManager.setPacketCaptureEnabled(pcapFile) } + } } override suspend fun closeVpnTunnel(withStateChange: Boolean) { + observePcapJob?.cancel() + observePcapJob = null + sdk.connectionManager.disconnect() if (withStateChange) { // Set state to disabled right away to give app some time to close notification diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 52f233fc5..0f2836f93 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -70,7 +70,7 @@ proton-core-env-config-version = "1.3.1" proton-core-version = "36.3.2" proton-test-fusion-version = "0.9.97" proton-vpn-govpnlib-version = "0.1.78" -proton-vpn-internal-sdk-version = "0.1.24-1.0.28" +proton-vpn-internal-sdk-version = "0.1.29-1.0.29" proton-vpn-wireguard-version = "1.0.20230512.29" relex-circleindicator-version = "2.1.6" retrofit-version = "2.11.0"