ProTUN's packet capture support in "Debug tools"

This commit is contained in:
Mateusz Markowicz
2026-02-16 16:18:48 +01:00
committed by MargeBot
parent 4547196238
commit 3c8aefc7be
11 changed files with 368 additions and 44 deletions

View File

@@ -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,

View File

@@ -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") }
}
}

View File

@@ -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 = {},
)
}
}

View File

@@ -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<Unit>(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<Event>(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?,
)

View File

@@ -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<DebugToolsViewModel>()
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 -> {

View File

@@ -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 <reified T> 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)
}

View File

@@ -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(

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<PacketCaptureInfo?> = 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")

View File

@@ -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 <https://www.gnu.org/licenses/>.
*/
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<Boolean> = preferences.observe<Boolean>(KEY_IS_ACTIVE).mapNotNull { it ?: isActive }
var maxBytes: Long by long(key = KEY_MAX_BYTES, default = DEFAULT_MAX_BYTES)
val maxBytesFlow: Flow<Long> = preferences.observe<Long>(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
}
}

View File

@@ -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

View File

@@ -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"