diff --git a/app/src/main/kotlin/com/wire/android/di/AppModule.kt b/app/src/main/kotlin/com/wire/android/di/AppModule.kt
index 35515e1221..0059fee6ed 100644
--- a/app/src/main/kotlin/com/wire/android/di/AppModule.kt
+++ b/app/src/main/kotlin/com/wire/android/di/AppModule.kt
@@ -31,6 +31,7 @@ import com.wire.android.feature.analytics.AnonymousAnalyticsManagerImpl
import com.wire.android.mapper.MessageResourceProvider
import com.wire.android.ui.analytics.AnalyticsConfiguration
import com.wire.android.ui.debug.securityproviders.AppPathsProvider
+import com.wire.android.ui.debug.securityproviders.NetworkDiagnosticsProvider
import com.wire.android.ui.home.conversations.MessageSharedState
import com.wire.android.ui.home.messagecomposer.location.LocationPickerParameters
import com.wire.android.util.CurrentTimeProvider
@@ -142,4 +143,8 @@ object AppModule {
context = context,
currentAccount = currentAccount
)
+
+ @Provides
+ fun provideNetworkDiagnosticsProvider(@ApplicationContext context: Context): NetworkDiagnosticsProvider =
+ NetworkDiagnosticsProvider(context = context)
}
diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/NetworkDiagnosticsProvider.kt b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/NetworkDiagnosticsProvider.kt
new file mode 100644
index 0000000000..44d06c1664
--- /dev/null
+++ b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/NetworkDiagnosticsProvider.kt
@@ -0,0 +1,116 @@
+/*
+ * Wire
+ * Copyright (C) 2026 Wire Swiss GmbH
+ *
+ * This program 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.
+ *
+ * This program 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 this program. If not, see http://www.gnu.org/licenses/.
+ */
+package com.wire.android.ui.debug.securityproviders
+
+import android.content.Context
+import android.net.ConnectivityManager
+import android.net.NetworkCapabilities
+import androidx.core.net.toUri
+import com.wire.android.appLogger
+import java.io.IOException
+import java.net.Inet6Address
+import java.net.InetAddress
+
+/**
+ * Reports how the device is currently reaching the backend: whether the active network is a VPN and which
+ * addresses the backend host resolves to through that very network, which is what a split tunnel or a
+ * misbehaving DNS would show up in.
+ *
+ * Resolution hits the network, so this must be called off the main thread.
+ */
+class NetworkDiagnosticsProvider(
+ private val context: Context,
+) {
+ operator fun invoke(apiUrl: String): NetworkDiagnostics {
+ val host = apiUrl.toUri().host.orEmpty()
+ val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
+ val activeNetwork = connectivityManager?.activeNetwork
+ ?: return NetworkDiagnostics(
+ isVpn = false,
+ networkTypes = emptyList(),
+ backendHost = host,
+ addresses = AddressResolution.NoActiveNetwork,
+ )
+
+ val capabilities = connectivityManager.getNetworkCapabilities(activeNetwork)
+ val networkTypes = NETWORK_TYPE_NAMES.filter { (transport, _) -> capabilities?.hasTransport(transport) == true }
+ .values
+ .toList()
+ val isVpn = capabilities?.hasTransport(NetworkCapabilities.TRANSPORT_VPN) == true
+
+ val addresses = when {
+ host.isEmpty() -> AddressResolution.Failed
+ else -> runCatching { activeNetwork.getAllByName(host) }
+ .fold(
+ onSuccess = { resolved -> AddressResolution.Resolved(resolved.mapNotNull { it.toResolvedAddress() }) },
+ onFailure = { error ->
+ if (error is IOException) {
+ appLogger.w("Could not resolve backend host through the active network", error)
+ AddressResolution.Failed
+ } else {
+ throw error
+ }
+ }
+ )
+ }
+
+ return NetworkDiagnostics(
+ isVpn = isVpn,
+ networkTypes = networkTypes,
+ backendHost = host,
+ addresses = addresses,
+ )
+ }
+
+ private companion object {
+ val NETWORK_TYPE_NAMES = linkedMapOf(
+ NetworkCapabilities.TRANSPORT_WIFI to "WIFI",
+ NetworkCapabilities.TRANSPORT_CELLULAR to "CELLULAR",
+ NetworkCapabilities.TRANSPORT_ETHERNET to "ETHERNET",
+ NetworkCapabilities.TRANSPORT_BLUETOOTH to "BLUETOOTH",
+ NetworkCapabilities.TRANSPORT_WIFI_AWARE to "WIFI_AWARE",
+ )
+ }
+}
+
+data class NetworkDiagnostics(
+ val isVpn: Boolean,
+ val networkTypes: List,
+ val backendHost: String,
+ val addresses: AddressResolution,
+)
+
+sealed interface AddressResolution {
+ data class Resolved(val addresses: List) : AddressResolution
+ data object NoActiveNetwork : AddressResolution
+ data object Failed : AddressResolution
+}
+
+data class ResolvedAddress(
+ val address: String,
+ val version: IpVersion,
+)
+
+enum class IpVersion { V4, V6 }
+
+private fun InetAddress.toResolvedAddress(): ResolvedAddress? = hostAddress?.let { address ->
+ ResolvedAddress(
+ address = address,
+ version = if (this is Inet6Address) IpVersion.V6 else IpVersion.V4,
+ )
+}
diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersScreen.kt b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersScreen.kt
index 8a490c2520..8c0039d207 100644
--- a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersScreen.kt
+++ b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersScreen.kt
@@ -17,6 +17,7 @@
*/
package com.wire.android.ui.debug.securityproviders
+import androidx.annotation.StringRes
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
@@ -81,7 +82,65 @@ fun SecurityProvidersScreen(
state.appPaths.forEach { entry ->
SettingsItem(title = stringResource(entry.labelRes), text = entry.path)
}
+
+ state.network?.let { network ->
+ SectionHeader(stringResource(R.string.debug_settings_network))
+ NetworkSection(network)
+ }
}
}
)
}
+
+@Composable
+private fun NetworkSection(network: NetworkDiagnostics) {
+ val unknown = stringResource(R.string.debug_settings_network_unknown)
+
+ SettingsItem(
+ title = stringResource(R.string.debug_settings_network_vpn),
+ text = stringResource(
+ if (network.isVpn) R.string.debug_settings_network_vpn_active else R.string.debug_settings_network_vpn_inactive
+ ),
+ )
+ SettingsItem(
+ title = stringResource(R.string.debug_settings_network_type),
+ text = network.networkTypes.joinToString().ifEmpty { unknown },
+ )
+ SettingsItem(
+ title = stringResource(R.string.debug_settings_network_backend_host),
+ text = network.backendHost.ifEmpty { unknown },
+ )
+
+ val addressesLabel = stringResource(R.string.debug_settings_network_resolved_addresses)
+ when (val addresses = network.addresses) {
+ is AddressResolution.Resolved -> if (addresses.addresses.isEmpty()) {
+ SettingsItem(title = addressesLabel, text = unknown)
+ } else {
+ addresses.addresses.forEach { resolved ->
+ SettingsItem(
+ title = stringResource(
+ R.string.debug_settings_network_resolved_address,
+ stringResource(resolved.version.labelRes())
+ ),
+ text = resolved.address,
+ )
+ }
+ }
+
+ AddressResolution.NoActiveNetwork -> SettingsItem(
+ title = addressesLabel,
+ text = stringResource(R.string.debug_settings_network_no_active_network),
+ )
+
+ AddressResolution.Failed -> SettingsItem(
+ title = addressesLabel,
+ text = stringResource(R.string.debug_settings_network_resolution_failed),
+ )
+ }
+}
+
+@StringRes
+private fun IpVersion.labelRes(): Int = when (this) {
+ IpVersion.V4 -> R.string.debug_settings_network_ip_v4
+ IpVersion.V6 -> R.string.debug_settings_network_ip_v6
+}
diff --git a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersViewModel.kt b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersViewModel.kt
index 7a3d5011ac..4eb8057e38 100644
--- a/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersViewModel.kt
+++ b/app/src/main/kotlin/com/wire/android/ui/debug/securityproviders/SecurityProvidersViewModel.kt
@@ -19,15 +19,24 @@ package com.wire.android.ui.debug.securityproviders
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
+import com.wire.android.appLogger
+import com.wire.android.util.dispatchers.DispatcherProvider
+import com.wire.kalium.logic.feature.user.SelfServerConfigUseCase
+import com.wire.kalium.network.NetworkStateObserver
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
+import kotlinx.coroutines.flow.flowOn
+import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
-import java.security.Provider
class SecurityProvidersViewModel @Inject constructor(
private val appPathsProvider: AppPathsProvider,
+ private val networkDiagnosticsProvider: NetworkDiagnosticsProvider,
+ private val networkStateObserver: NetworkStateObserver,
+ private val selfServerConfig: SelfServerConfigUseCase,
+ private val dispatchers: DispatcherProvider,
) : ViewModel() {
private val _state = MutableStateFlow(SecurityProvidersViewState())
@@ -37,18 +46,30 @@ class SecurityProvidersViewModel @Inject constructor(
viewModelScope.launch {
_state.update { current -> current.copy(appPaths = appPathsProvider()) }
}
+ viewModelScope.launch {
+ observeNetworkDiagnostics()
+ }
}
-}
-/**
- * `Provider.getVersionStr()` needs API 28 and `Provider.getVersion()` is deprecated, so read the version
- * straight out of the provider's own property map, where it is registered under this key.
- */
-private const val PROVIDER_VERSION_PROPERTY = "Provider.id version"
+ private suspend fun observeNetworkDiagnostics() {
+ val apiUrl = apiUrl() ?: return
+ networkStateObserver.observeCurrentNetwork()
+ .map { networkDiagnosticsProvider(apiUrl) }
+ .flowOn(dispatchers.io())
+ .collect { diagnostics -> _state.update { current -> current.copy(network = diagnostics) } }
+ }
-private fun Provider.versionString(): String = getProperty(PROVIDER_VERSION_PROPERTY).orEmpty()
+ private suspend fun apiUrl(): String? = when (val result = selfServerConfig()) {
+ is SelfServerConfigUseCase.Result.Success -> result.serverLinks.links.api
+ is SelfServerConfigUseCase.Result.Failure -> {
+ appLogger.w("Could not read the server config, skipping network diagnostics")
+ null
+ }
+ }
+}
data class SecurityProvidersViewState(
val appPaths: List = emptyList(),
+ val network: NetworkDiagnostics? = null,
val providers: List? = null,
)
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 05fe6a5372..911c2b20ca 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1796,6 +1796,19 @@ In group conversations, the group admin can overwrite this setting.
No backup dir
External files dir
%1$d entries
+ Network
+ VPN
+ Active
+ Not active
+ Network type
+ Backend host
+ Resolved addresses
+ Resolved address (%1$s)
+ IPv4
+ IPv6
+ No active network
+ Could not be resolved
+ Unknown
Step %1$d of 4
diff --git a/app/stability/app-devDebug.stability b/app/stability/app-devDebug.stability
index 876954f978..ae577be8ab 100644
--- a/app/stability/app-devDebug.stability
+++ b/app/stability/app-devDebug.stability
@@ -3941,6 +3941,13 @@ public fun com.wire.android.ui.debug.securityProvidersViewModel(): com.wire.andr
restartable: true
params:
+@Composable
+private fun com.wire.android.ui.debug.securityproviders.NetworkSection(network: com.wire.android.ui.debug.securityproviders.NetworkDiagnostics): kotlin.Unit
+ skippable: false
+ restartable: true
+ params:
+ - network: RUNTIME (requires runtime check)
+
@Composable
public fun com.wire.android.ui.debug.securityproviders.SecurityProvidersScreen(navigator: com.wire.android.navigation.Navigator, modifier: androidx.compose.ui.Modifier, viewModel: com.wire.android.ui.debug.securityproviders.SecurityProvidersViewModel): kotlin.Unit
skippable: false
@@ -6007,7 +6014,7 @@ public fun com.wire.android.ui.home.conversations.mediaGalleryViewModel(): com.w
params:
@Composable
-public fun com.wire.android.ui.home.conversations.mention.MemberItemToMention(avatarData: com.wire.android.model.UserAvatarData, name: kotlin.String, label: kotlin.String, membership: com.wire.android.ui.home.conversationslist.model.Membership, searchQuery: kotlin.String, clickable: com.wire.android.model.Clickable, modifier: androidx.compose.ui.Modifier): kotlin.Unit
+public fun com.wire.android.ui.home.conversations.mention.MemberItemToMention(avatarData: com.wire.android.model.UserAvatarData, name: kotlin.String, label: kotlin.String, membership: com.wire.android.ui.home.conversationslist.model.Membership, searchQuery: kotlin.String, clickable: com.wire.android.model.Clickable, modifier: androidx.compose.ui.Modifier, backgroundColor: androidx.compose.ui.graphics.Color): kotlin.Unit
skippable: true
restartable: true
params:
@@ -6018,6 +6025,7 @@ public fun com.wire.android.ui.home.conversations.mention.MemberItemToMention(av
- searchQuery: STABLE (String is immutable)
- clickable: STABLE (class with no mutable properties)
- modifier: STABLE (marked @Stable or @Immutable)
+ - backgroundColor: STABLE (marked @Stable or @Immutable)
@Composable
public fun com.wire.android.ui.home.conversations.messageAttachmentsViewModel(): com.wire.android.ui.home.conversations.attachment.MessageAttachmentsViewModel