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