Merge branch 'develop' into feature/bma/focussedButton

This commit is contained in:
Benoit Marty
2026-07-10 14:49:49 +02:00
committed by GitHub
162 changed files with 1409 additions and 492 deletions
@@ -7,4 +7,6 @@
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.VIBRATE" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.ACCESS_LOCAL_NETWORK" />
</manifest>
@@ -0,0 +1,25 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.network
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.net.InetAddress
interface DnsResolver {
suspend fun resolve(host: String): List<InetAddress>
}
@ContributesBinding(AppScope::class)
class DefaultDnsResolver : DnsResolver {
override suspend fun resolve(host: String): List<InetAddress> = withContext(Dispatchers.IO) {
InetAddress.getAllByName(host).toList()
}
}
@@ -0,0 +1,75 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.network
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.data.tryOrNull
import java.net.Inet4Address
import java.net.Inet6Address
import java.net.InetAddress
import java.net.URI
sealed interface LocalNetworkClassification {
data object PublicIp : LocalNetworkClassification
data object LocalIp : LocalNetworkClassification
data object Unresolvable : LocalNetworkClassification
}
interface LocalNetworkAddressClassifier {
/**
* Classify [url] as pointing to a local network, a public host, or unresolvable.
*/
suspend fun classify(url: String): LocalNetworkClassification
}
@ContributesBinding(AppScope::class)
class DefaultLocalNetworkAddressClassifier(
private val dnsResolver: DnsResolver,
) : LocalNetworkAddressClassifier {
override suspend fun classify(url: String): LocalNetworkClassification {
val host = extractHost(url) ?: return LocalNetworkClassification.Unresolvable
// `.local` domains are always link-local by definition
if (host.endsWith(".local", ignoreCase = true)) return LocalNetworkClassification.LocalIp
val resolved = tryOrNull { dnsResolver.resolve(host) }
if (resolved.isNullOrEmpty()) return LocalNetworkClassification.Unresolvable
return if (resolved.any { it.isLocalRange() }) {
LocalNetworkClassification.LocalIp
} else {
LocalNetworkClassification.PublicIp
}
}
private fun extractHost(url: String): String? {
return tryOrNull {
val uri = URI(url)
uri.host?.takeIf { it.isNotBlank() }
}
}
private fun InetAddress.isLocalRange(): Boolean {
if (isLoopbackAddress || isLinkLocalAddress || isSiteLocalAddress) return true
// Cases not covered by jvm
// 1. IPV4 100.64.0.0/10
if (this is Inet4Address) {
val bytes = address
val b0 = bytes[0].toInt() and 0xff
val b1 = bytes[1].toInt() and 0xff
if (b0 == 100 && b1 in 64..127) return true
}
// 2. IPV6 fc00::/7
if (this is Inet6Address) {
val firstByte = address[0].toInt() and 0xff
if (firstByte and 0xfe == 0xfc) return true
}
return false
}
}
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.network
import io.element.android.libraries.core.data.tryOrNull
import java.net.InetAddress
import java.net.UnknownHostException
class FakeDnsResolver(
private val results: Map<String, List<InetAddress>> = emptyMap(),
private val throwOnUnknown: Boolean = true,
) : DnsResolver {
override suspend fun resolve(host: String): List<InetAddress> {
// Mimic production DefaultDnsResolver: InetAddress resolves IP literals synchronously
// (no DNS lookup), so tests can pass literals through without registering them in [results].
if (looksLikeIpLiteral(host)) {
tryOrNull { InetAddress.getAllByName(host).toList() }?.let { return it }
}
return results[host]
?: if (throwOnUnknown) throw UnknownHostException(host) else emptyList()
}
private fun looksLikeIpLiteral(host: String): Boolean {
if (':' in host) return true
val parts = host.split('.')
return parts.size == 4 && parts.all { it.toIntOrNull() != null }
}
}
@@ -0,0 +1,124 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.androidutils.network
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.net.InetAddress
class LocalNetworkAddressClassifierTest {
private fun classifier(
resolver: DnsResolver = FakeDnsResolver(),
) = DefaultLocalNetworkAddressClassifier(resolver)
@Test
fun `IPv4 loopback literal is LocalIp`() = runTest {
assertThat(classifier().classify("https://127.0.0.1:8008")).isEqualTo(LocalNetworkClassification.LocalIp)
}
@Test
fun `IPv4 RFC1918 10 literal is LocalIp`() = runTest {
assertThat(classifier().classify("https://10.0.0.5")).isEqualTo(LocalNetworkClassification.LocalIp)
}
@Test
fun `IPv4 RFC1918 172_16 literal is LocalIp`() = runTest {
assertThat(classifier().classify("https://172.20.1.2")).isEqualTo(LocalNetworkClassification.LocalIp)
}
@Test
fun `IPv4 RFC1918 192_168 literal is LocalIp`() = runTest {
assertThat(classifier().classify("https://192.168.1.10")).isEqualTo(LocalNetworkClassification.LocalIp)
}
@Test
fun `IPv4 link local literal is LocalIp`() = runTest {
assertThat(classifier().classify("https://169.254.10.20")).isEqualTo(LocalNetworkClassification.LocalIp)
}
@Test
fun `IPv4 CGNAT 100_64_0_0_10 literal is LocalIp`() = runTest {
assertThat(classifier().classify("https://100.100.0.1")).isEqualTo(LocalNetworkClassification.LocalIp)
}
@Test
fun `IPv4 public literal is PublicIp`() = runTest {
assertThat(classifier().classify("https://8.8.8.8")).isEqualTo(LocalNetworkClassification.PublicIp)
}
@Test
fun `IPv6 loopback literal is LocalIp`() = runTest {
assertThat(classifier().classify("https://[::1]")).isEqualTo(LocalNetworkClassification.LocalIp)
}
@Test
fun `IPv6 link local literal is LocalIp`() = runTest {
assertThat(classifier().classify("https://[fe80::1]")).isEqualTo(LocalNetworkClassification.LocalIp)
}
@Test
fun `IPv6 unique local literal is LocalIp`() = runTest {
assertThat(classifier().classify("https://[fc00::1]")).isEqualTo(LocalNetworkClassification.LocalIp)
}
@Test
fun `IPv6 public literal is PublicIp`() = runTest {
assertThat(classifier().classify("https://[2606:4700:4700::1111]")).isEqualTo(LocalNetworkClassification.PublicIp)
}
@Test
fun `dot local mDNS name is LocalIp without DNS`() = runTest {
assertThat(classifier().classify("https://matrix.local"))
.isEqualTo(LocalNetworkClassification.LocalIp)
}
@Test
fun `hostname resolving to public IP is PublicIp`() = runTest {
val resolver = FakeDnsResolver(
results = mapOf("matrix.org" to listOf(InetAddress.getByName("8.8.8.8")))
)
assertThat(classifier(resolver).classify("https://matrix.org"))
.isEqualTo(LocalNetworkClassification.PublicIp)
}
@Test
fun `hostname resolving to private IP is LocalIp`() = runTest {
val resolver = FakeDnsResolver(
results = mapOf("matrix.corp.internal" to listOf(InetAddress.getByName("10.0.0.5")))
)
assertThat(classifier(resolver).classify("https://matrix.corp.internal"))
.isEqualTo(LocalNetworkClassification.LocalIp)
}
@Test
fun `hostname with mixed public and private addresses is LocalIp`() = runTest {
val resolver = FakeDnsResolver(
results = mapOf(
"matrix.example" to listOf(
InetAddress.getByName("8.8.8.8"),
InetAddress.getByName("10.0.0.5"),
)
)
)
assertThat(classifier(resolver).classify("https://matrix.example"))
.isEqualTo(LocalNetworkClassification.LocalIp)
}
@Test
fun `unresolvable hostname is Unresolvable`() = runTest {
val resolver = FakeDnsResolver(throwOnUnknown = true)
assertThat(classifier(resolver).classify("https://nonexistent.example"))
.isEqualTo(LocalNetworkClassification.Unresolvable)
}
@Test
fun `malformed URL is Unresolvable`() = runTest {
assertThat(classifier().classify("not a url")).isEqualTo(LocalNetworkClassification.Unresolvable)
}
}
@@ -56,6 +56,9 @@ import java.util.Optional
interface MatrixClient {
val sessionId: SessionId
val deviceId: DeviceId
/** Base URL of the homeserver this client is connected to. */
val homeserverUrl: String
val sessionPaths: SessionPaths
val userProfile: StateFlow<MatrixUser>
val roomListService: RoomListService
@@ -158,6 +158,7 @@ class RustMatrixClient(
) : MatrixClient {
override val sessionId: UserId = UserId(innerClient.userId())
override val deviceId: DeviceId = DeviceId(innerClient.deviceId())
override val homeserverUrl: String = innerClient.homeserver()
override val sessionCoroutineScope = appCoroutineScope.childScope(dispatchers.main, "Session-$sessionId")
private val sessionDispatcher = dispatchers.io.limitedParallelism(64)
@@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes
import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSession
import io.element.android.libraries.matrix.test.A_DEVICE_ID
import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
@@ -41,6 +42,7 @@ import uniffi.matrix_sdk_base.MediaRetentionPolicy
class FakeFfiClient(
private val userId: String = A_USER_ID.value,
private val deviceId: String = A_DEVICE_ID.value,
private val homeserver: String = A_HOMESERVER_URL,
private val notificationClient: NotificationClient = FakeFfiNotificationClient(),
private val notificationSettings: NotificationSettings = FakeFfiNotificationSettings(),
private val encryption: Encryption = FakeFfiEncryption(),
@@ -56,6 +58,7 @@ class FakeFfiClient(
) : Client(NoHandle) {
override fun userId(): String = userId
override fun deviceId(): String = deviceId
override fun homeserver(): String = homeserver
override suspend fun notificationClient(processSetup: NotificationProcessSetup) = notificationClient
override suspend fun getNotificationSettings(): NotificationSettings = notificationSettings
override fun encryption(): Encryption = encryption
@@ -77,6 +77,7 @@ class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID,
override val sessionPaths: SessionPaths = SessionPaths(fileDirectory = File("files"), cacheDirectory = File("cache")),
override val deviceId: DeviceId = A_DEVICE_ID,
override val homeserverUrl: String = A_HOMESERVER_URL,
override val sessionCoroutineScope: CoroutineScope = TestScope(),
private val userDisplayName: String? = A_USER_NAME,
private val userAvatarUrl: String? = AN_AVATAR_URL,
@@ -9,7 +9,23 @@
package io.element.android.libraries.permissions.api
sealed interface PermissionsEvent {
/**
* Request the runtime permission. If the app has already recorded a soft denial for this
* permission, this event is intercepted to raise [PermissionsState.showDialog] instead of
* hitting the OS — the intent is for the caller to render `PermissionsView` to prompt the
* user to open system settings.
*
* If the caller renders its own "already denied" UI and wants the OS prompt regardless,
* use [ForceRequestPermissions].
*/
data object RequestPermissions : PermissionsEvent
/**
* Same as [RequestPermissions] but bypasses the soft-denial intercept — always launches
* the OS permission dialog. The OS may still auto-deny for permanently-denied permissions.
*/
data object ForceRequestPermissions : PermissionsEvent
data object CloseDialog : PermissionsEvent
data object OpenSystemSettingAndCloseDialog : PermissionsEvent
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.permissions.api.localnetwork
interface LocalNetworkPermissionAdvisor {
/**
* Returns true when the app should request the ACCESS_LOCAL_NETWORK permission before making
* network requests to [homeserverUrl].
*/
suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean
}
@@ -0,0 +1,20 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.permissions.api.localnetwork
/**
* Which rationale dialog (if any) should be rendered on top of the caller's UI.
*
* [Rationale] is shown before the runtime prompt so the user knows why the permission is needed.
* [Settings] is shown when the OS says a rationale can no longer be shown (permanently denied).
*/
enum class LocalNetworkPermissionDialog {
None,
Rationale,
Settings,
}
@@ -0,0 +1,37 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.permissions.api.localnetwork
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun LocalNetworkPermissionDialogView(
dialog: LocalNetworkPermissionDialog,
onSubmit: () -> Unit,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
) {
val submitTextRes = when (dialog) {
LocalNetworkPermissionDialog.None -> return
LocalNetworkPermissionDialog.Rationale -> CommonStrings.dialog_allow_access
LocalNetworkPermissionDialog.Settings -> CommonStrings.action_open_settings
}
ConfirmationDialog(
title = stringResource(CommonStrings.screen_local_network_opt_in_title),
content = stringResource(CommonStrings.screen_local_network_opt_in_subtitle),
submitText = stringResource(submitTextRes),
cancelText = stringResource(CommonStrings.action_not_now),
onSubmitClick = onSubmit,
onDismiss = onDismiss,
modifier = modifier,
)
}
@@ -112,6 +112,9 @@ class DefaultPermissionsPresenter(
permissionState.launchPermissionRequest()
}
}
PermissionsEvent.ForceRequestPermissions -> {
permissionState.launchPermissionRequest()
}
PermissionsEvent.OpenSystemSettingAndCloseDialog -> {
permissionActions.openSettings(permission)
showDialog.value = false
@@ -0,0 +1,36 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.permissions.impl.localnetwork
import android.Manifest
import android.os.Build
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.network.LocalNetworkAddressClassifier
import io.element.android.libraries.androidutils.network.LocalNetworkClassification
import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
@ContributesBinding(AppScope::class)
class DefaultLocalNetworkPermissionAdvisor(
private val classifier: LocalNetworkAddressClassifier,
private val permissionStateProvider: PermissionStateProvider,
private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider,
) : LocalNetworkPermissionAdvisor {
override suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean {
if (!buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.CINNAMON_BUN)) return false
if (permissionStateProvider.isPermissionGranted(Manifest.permission.ACCESS_LOCAL_NETWORK)) return false
return when (classifier.classify(homeserverUrl)) {
LocalNetworkClassification.LocalIp -> true
LocalNetworkClassification.PublicIp,
// Unresolvable: we can't confirm the host is local, so we don't prompt, acceptable trade-off vs. over-prompting.
LocalNetworkClassification.Unresolvable -> false
}
}
}
@@ -0,0 +1,79 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.permissions.impl.localnetwork
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.androidutils.network.LocalNetworkAddressClassifier
import io.element.android.libraries.androidutils.network.LocalNetworkClassification
import io.element.android.libraries.permissions.test.FakePermissionStateProvider
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
private const val LOCAL_URL = "https://matrix.corp.internal"
private const val PUBLIC_URL = "https://matrix.org"
class DefaultLocalNetworkPermissionAdvisorTest {
private fun advisor(
sdkInt: Int,
permissionGranted: Boolean,
classification: LocalNetworkClassification,
) = DefaultLocalNetworkPermissionAdvisor(
classifier = FakeLocalNetworkAddressClassifier(classification),
permissionStateProvider = FakePermissionStateProvider(permissionGranted = permissionGranted),
buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(sdkInt),
)
@Test
fun `returns false when SDK below 37`() = runTest {
val result = advisor(sdkInt = 36, permissionGranted = false, classification = LocalNetworkClassification.LocalIp)
.shouldRequestPermissionFor(LOCAL_URL)
assertThat(result).isFalse()
}
@Test
fun `returns false when permission already granted`() = runTest {
val result = advisor(sdkInt = 37, permissionGranted = true, classification = LocalNetworkClassification.LocalIp)
.shouldRequestPermissionFor(LOCAL_URL)
assertThat(result).isFalse()
}
@Test
fun `returns false when URL classified as public IP`() = runTest {
val result = advisor(sdkInt = 37, permissionGranted = false, classification = LocalNetworkClassification.PublicIp)
.shouldRequestPermissionFor(PUBLIC_URL)
assertThat(result).isFalse()
}
@Test
fun `returns true when SDK 37, permission missing, URL is local`() = runTest {
val result = advisor(sdkInt = 37, permissionGranted = false, classification = LocalNetworkClassification.LocalIp)
.shouldRequestPermissionFor(LOCAL_URL)
assertThat(result).isTrue()
}
@Test
fun `returns false when SDK 37, permission missing, URL unresolvable`() = runTest {
val result = advisor(sdkInt = 37, permissionGranted = false, classification = LocalNetworkClassification.Unresolvable)
.shouldRequestPermissionFor(LOCAL_URL)
assertThat(result).isFalse()
}
@Test
fun `returns true when SDK above 37, permission missing, URL is local`() = runTest {
val result = advisor(sdkInt = 38, permissionGranted = false, classification = LocalNetworkClassification.LocalIp)
.shouldRequestPermissionFor(LOCAL_URL)
assertThat(result).isTrue()
}
}
private class FakeLocalNetworkAddressClassifier(
private val classification: LocalNetworkClassification,
) : LocalNetworkAddressClassifier {
override suspend fun classify(url: String): LocalNetworkClassification = classification
}
@@ -0,0 +1,16 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.permissions.test
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionAdvisor
class FakeLocalNetworkPermissionAdvisor(
private val shouldPrompt: Boolean = false,
) : LocalNetworkPermissionAdvisor {
override suspend fun shouldRequestPermissionFor(homeserverUrl: String): Boolean = shouldPrompt
}
@@ -21,6 +21,7 @@ class FakePermissionsPresenter(
private fun handleEvent(event: PermissionsEvent) {
when (event) {
PermissionsEvent.RequestPermissions -> state.value = state.value.copy(showDialog = true, permissionAlreadyAsked = true)
PermissionsEvent.ForceRequestPermissions -> state.value = state.value.copy(permissionAlreadyAsked = true)
PermissionsEvent.CloseDialog -> state.value = state.value.copy(showDialog = false)
PermissionsEvent.OpenSystemSettingAndCloseDialog -> state.value = state.value.copy(showDialog = false)
}
@@ -8,4 +8,6 @@
<resources>
<string name="action_mark_as_read">"Mark as read"</string>
<string name="a11y_jump_to_unread_messages">"Jump to first unread message"</string>
<string name="screen_local_network_opt_in_title">Allow access to local network</string>
<string name="screen_local_network_opt_in_subtitle">Your homeserver is on your local network. To connect, Element needs permission to reach other devices on this network.</string>
</resources>