Make Vulkan a *not required* feature (#6961)

* Make Vulkan a *not required* feature

* Add `DeviceHasVulkanSupport` helper to check this on runtime

* When trying to open a screen with a map in it, check if vulkan is supported and display a new error dialog in that case

* Fix random lint issue

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2026-06-04 15:08:19 +02:00
committed by GitHub
parent 27cdc0fe7d
commit a91e1fa2ae
9 changed files with 132 additions and 18 deletions
@@ -75,10 +75,10 @@ internal fun RoomSummaryRow(
room: RoomListRoomSummary,
hideInviteAvatars: Boolean,
isInviteSeen: Boolean,
showUnreadCount: Boolean = false,
onClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvent) -> Unit,
modifier: Modifier = Modifier,
showUnreadCount: Boolean = false,
eventSink: (RoomListEvent) -> Unit,
) {
Box(modifier = modifier) {
when (room.displayType) {
@@ -0,0 +1,30 @@
/*
* 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.features.location.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun RenderingMapsNotSupportedDialog(onSubmit: () -> Unit) {
ErrorDialog(
title = stringResource(CommonStrings.vulkan_not_supported_dialog_title_android),
content = stringResource(CommonStrings.vulkan_not_supported_dialog_content_android),
onSubmit = onSubmit,
)
}
@PreviewsDayNight
@Composable
internal fun RenderingMapsNotSupportedDialogPreview() = ElementPreview {
RenderingMapsNotSupportedDialog(onSubmit = {})
}
@@ -5,13 +5,21 @@
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
~ Please see LICENSE files in the repository root for full details.
-->
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools" >
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />
<!-- Declare Vulkan as an optional feature to prevent installation issues on devices that do not support it. -->
<uses-feature
android:name="android.hardware.vulkan.version"
android:required="false"
tools:replace="android:required" />
<application>
<service
android:name=".live.service.LiveLocationSharingService"
@@ -11,6 +11,9 @@ package io.element.android.features.messages.impl
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.lifecycle.subscribe
@@ -29,6 +32,7 @@ import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.features.forward.api.ForwardEntryPoint
import io.element.android.features.knockrequests.api.list.KnockRequestsListEntryPoint
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.RenderingMapsNotSupportedDialog
import io.element.android.features.location.api.ShareLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
@@ -53,6 +57,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.duration
import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.features.poll.api.create.CreatePollMode
import io.element.android.libraries.androidutils.system.DeviceHasVulkanSupport
import io.element.android.libraries.architecture.BackstackWithOverlayBox
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.callback
@@ -121,6 +126,7 @@ class MessagesFlowNode(
private val knockRequestsListEntryPoint: KnockRequestsListEntryPoint,
private val dateFormatter: DateFormatter,
private val coroutineDispatchers: CoroutineDispatchers,
private val hasVulkanSupport: DeviceHasVulkanSupport,
) : BaseFlowNode<MessagesFlowNode.NavTarget>(
backstack = BackStack(
initialElement = plugins.filterIsInstance<MessagesEntryPoint.Params>().first().initialTarget.toNavTarget(),
@@ -188,6 +194,8 @@ class MessagesFlowNode(
private val callback: MessagesEntryPoint.Callback = callback()
private var displayVulkanNotSupportedError by mutableStateOf(false)
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
@@ -267,7 +275,11 @@ class MessagesFlowNode(
}
override fun navigateToSendLocation() {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Live))
if (hasVulkanSupport()) {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Live))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToCreatePoll() {
@@ -279,7 +291,11 @@ class MessagesFlowNode(
}
override fun navigateToCurrentLiveLocation() {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
if (hasVulkanSupport()) {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
@@ -506,7 +522,11 @@ class MessagesFlowNode(
}
override fun navigateToSendLocation() {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Thread(navTarget.threadRootId)))
if (hasVulkanSupport()) {
backstack.push(NavTarget.SendLocation(Timeline.Mode.Thread(navTarget.threadRootId)))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToCreatePoll() {
@@ -518,7 +538,11 @@ class MessagesFlowNode(
}
override fun navigateToCurrentLiveLocation() {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
if (hasVulkanSupport()) {
backstack.push(NavTarget.LocationViewer(ShowLocationMode.Live(senderId = sessionId)))
} else {
displayVulkanNotSupportedError = true
}
}
override fun navigateToRoomCall(roomId: RoomId, isAudioCall: Boolean) {
@@ -607,18 +631,23 @@ class MessagesFlowNode(
)
}
is TimelineItemLocationContent -> {
val mode = when (event.content.mode) {
is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live(event.senderId)
is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static(
location = event.content.mode.location,
senderName = event.safeSenderName,
senderId = event.senderId,
senderAvatarUrl = event.senderAvatar.url,
timestamp = event.sentTimeMillis,
assetType = event.content.assetType,
)
if (hasVulkanSupport()) {
val mode = when (event.content.mode) {
is TimelineItemLocationContent.Mode.Live -> ShowLocationMode.Live(event.senderId)
is TimelineItemLocationContent.Mode.Static -> ShowLocationMode.Static(
location = event.content.mode.location,
senderName = event.safeSenderName,
senderId = event.senderId,
senderAvatarUrl = event.senderAvatar.url,
timestamp = event.sentTimeMillis,
assetType = event.content.assetType,
)
}
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() }
} else {
displayVulkanNotSupportedError = true
null
}
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() }
}
else -> null
}
@@ -691,6 +720,11 @@ class MessagesFlowNode(
@Composable
override fun View(modifier: Modifier) {
mentionSpanTheme.updateStyles()
if (displayVulkanNotSupportedError) {
RenderingMapsNotSupportedDialog { displayVulkanNotSupportedError = false }
}
CompositionLocalProvider(
LocalMentionSpanUpdater provides mentionSpanUpdater
) {
@@ -23,6 +23,7 @@ import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.messages.impl.pinned.banner.createPinnedEventsTimelineProvider
import io.element.android.features.messages.impl.timeline.createTimelineController
import io.element.android.features.poll.test.create.FakeCreatePollEntryPoint
import io.element.android.libraries.androidutils.system.DeviceHasVulkanSupport
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -42,6 +43,7 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.node.TestParentNode
import io.element.android.tests.testutils.testCoroutineDispatchers
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -85,6 +87,7 @@ class DefaultMessagesEntryPointTest {
knockRequestsListEntryPoint = FakeKnockRequestsListEntryPoint(),
dateFormatter = FakeDateFormatter(),
coroutineDispatchers = testCoroutineDispatchers(),
hasVulkanSupport = DeviceHasVulkanSupport(mockk(relaxed = true))
)
}
val callback = object : MessagesEntryPoint.Callback {
@@ -0,0 +1,31 @@
/*
* 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.system
import android.content.Context
import dev.zacsweers.metro.Inject
import io.element.android.libraries.di.annotations.ApplicationContext
private const val VULKAN_VERSION_1_0 = 0x400003
/**
* Checks if the device supports Vulkan 1.0.
*
* This is needed for the location screens that contain maps using MapLibre UI components.
*
* Needed until https://github.com/maplibre/maplibre-native/issues/3079 is resolved and we can automatically choose between OpenGL and Vulkan renderers,
* or no devices support OpenGL anymore.
*/
@Inject
class DeviceHasVulkanSupport(
@ApplicationContext private val context: Context,
) {
operator fun invoke(): Boolean {
return context.packageManager.hasSystemFeature("android.hardware.vulkan.version", VULKAN_VERSION_1_0)
}
}
@@ -546,4 +546,6 @@ Are you sure you want to continue?"</string>
<string name="timeline_decryption_failure_historical_event_user_not_joined">"You don\'t have access to this message"</string>
<string name="timeline_decryption_failure_unable_to_decrypt">"Unable to decrypt message"</string>
<string name="timeline_decryption_failure_withheld_unverified">"This message was blocked either because you did not verify your device or because the sender needs to verify your digital identity."</string>
<string name="vulkan_not_supported_dialog_content_android">"Your device is too old, a device with Android 8 or newer is required."</string>
<string name="vulkan_not_supported_dialog_title_android">"Rendering maps is not supported"</string>
</resources>
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4f64455657f0f8a5ac7eec71ee206721a79b25dacf470d105a0e6497e570204a
size 23954
@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:43bd5f04f170372b120fa2223c632d6a969c45b6d4fe61b161a7109318f9941f
size 22658