Rail shows only root spaces + in-app updater; bump to 1.0.6
Sonar / Sonar Quality Checks (push) Has been cancelled
Post-release / post-release (push) Has been cancelled
Code Quality Checks / Search for forbidden patterns (push) Has been cancelled
Code Quality Checks / Search for invalid screenshot files (push) Has been cancelled
Code Quality Checks / Search for invalid dependencies (push) Has been cancelled
Code Quality Checks / Konsist tests (push) Has been cancelled
Code Quality Checks / Compose tests (push) Has been cancelled
Code Quality Checks / Android lint check (push) Has been cancelled
Code Quality Checks / Detekt checks (push) Has been cancelled
Code Quality Checks / Ktlint checks (push) Has been cancelled
Code Quality Checks / Doc checks (push) Has been cancelled
Code Quality Checks / Check shell scripts (push) Has been cancelled
Code Quality Checks / Run zizmor (push) Has been cancelled
Create release App Bundle and APKs / Create App Bundle (Gplay) (push) Has been cancelled
Create release App Bundle and APKs / Create App Bundle Enterprise (push) Has been cancelled
Create release App Bundle and APKs / Create APKs (FDroid) (push) Has been cancelled
Test / Runs unit tests (push) Has been cancelled
Code Quality Checks / Project Check Suite (push) Has been cancelled
Sonar / Sonar Quality Checks (push) Has been cancelled
Post-release / post-release (push) Has been cancelled
Code Quality Checks / Search for forbidden patterns (push) Has been cancelled
Code Quality Checks / Search for invalid screenshot files (push) Has been cancelled
Code Quality Checks / Search for invalid dependencies (push) Has been cancelled
Code Quality Checks / Konsist tests (push) Has been cancelled
Code Quality Checks / Compose tests (push) Has been cancelled
Code Quality Checks / Android lint check (push) Has been cancelled
Code Quality Checks / Detekt checks (push) Has been cancelled
Code Quality Checks / Ktlint checks (push) Has been cancelled
Code Quality Checks / Doc checks (push) Has been cancelled
Code Quality Checks / Check shell scripts (push) Has been cancelled
Code Quality Checks / Run zizmor (push) Has been cancelled
Create release App Bundle and APKs / Create App Bundle (Gplay) (push) Has been cancelled
Create release App Bundle and APKs / Create App Bundle Enterprise (push) Has been cancelled
Create release App Bundle and APKs / Create APKs (FDroid) (push) Has been cancelled
Test / Runs unit tests (push) Has been cancelled
Code Quality Checks / Project Check Suite (push) Has been cancelled
Rail: desktop parity (element-web rootSpaces) — spaces nested inside another joined space no longer get a rail entry; their rooms stay reachable through the parent space. Nested set persisted for clean cold starts. Updater: new libraries/blapupdate module checks the git.utn.lol release feed (6h throttle), offers a banner at the top of the chat list, downloads the ABI-matched APK and hands it to the system installer. Per-version dismissal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -30,6 +30,7 @@ dependencies {
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.blapupdate)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.featureflag.api)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
|
||||
@@ -260,6 +260,7 @@ private fun HomeScaffold(
|
||||
) {
|
||||
BlapSpaceRail(
|
||||
spaceFiltersState = roomListState.spaceFiltersState,
|
||||
nestedSpaceIds = roomListState.blapNestedSpaceIds,
|
||||
)
|
||||
RoomListContentView(
|
||||
contentState = roomListState.contentState,
|
||||
|
||||
+5
-1
@@ -36,6 +36,7 @@ import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
|
||||
import io.element.android.features.home.impl.spacefilters.blapAvailableFilters
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.features.home.impl.spacefilters.blapOnSelect
|
||||
import io.element.android.features.home.impl.spacefilters.selectedFilter
|
||||
import io.element.android.libraries.designsystem.components.avatar.Avatar
|
||||
@@ -53,9 +54,12 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
fun BlapSpaceRail(
|
||||
spaceFiltersState: SpaceFiltersState,
|
||||
modifier: Modifier = Modifier,
|
||||
nestedSpaceIds: Set<RoomId> = emptySet(),
|
||||
) {
|
||||
if (spaceFiltersState is SpaceFiltersState.Disabled) return
|
||||
val filters = spaceFiltersState.blapAvailableFilters
|
||||
// Desktop parity: only root spaces get a rail entry — spaces nested inside another
|
||||
// joined space are reachable through their parent, not the rail.
|
||||
val filters = spaceFiltersState.blapAvailableFilters.filter { it.spaceRoom.roomId !in nestedSpaceIds }
|
||||
val selectedRoomId = spaceFiltersState.selectedFilter()?.spaceRoom?.roomId
|
||||
val onSelect = spaceFiltersState.blapOnSelect
|
||||
|
||||
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Blap
|
||||
*
|
||||
* 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.home.impl.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.home.impl.roomlist.BlapUpdateBannerState
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
|
||||
/**
|
||||
* Blap: in-app update banner at the top of the chat list. Tapping Install downloads the
|
||||
* APK and hands off to the system installer — no trip to the git releases page.
|
||||
*/
|
||||
@Composable
|
||||
fun BlapUpdateBanner(
|
||||
state: BlapUpdateBannerState,
|
||||
onInstallClick: () -> Unit,
|
||||
onDismissClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.background(ElementTheme.colors.bgSubtleSecondary)
|
||||
.padding(start = 16.dp, end = 8.dp, top = 8.dp, bottom = 8.dp),
|
||||
) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = when {
|
||||
state.downloading -> "Downloading Blap ${state.version}…"
|
||||
state.failed -> "Update ${state.version} download failed"
|
||||
else -> "Blap ${state.version} is available"
|
||||
},
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
if (!state.downloading) {
|
||||
Text(
|
||||
text = if (state.failed) "Check your connection and try again" else "Tap install — no browser needed",
|
||||
style = ElementTheme.typography.fontBodySmRegular,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (state.downloading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier
|
||||
.padding(end = 12.dp)
|
||||
.size(20.dp),
|
||||
strokeWidth = 2.dp,
|
||||
)
|
||||
} else {
|
||||
TextButton(
|
||||
text = "Later",
|
||||
onClick = onDismissClick,
|
||||
)
|
||||
TextButton(
|
||||
text = if (state.failed) "Retry" else "Install",
|
||||
onClick = onInstallClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -277,9 +277,18 @@ private fun RoomsViewList(
|
||||
)
|
||||
}
|
||||
}
|
||||
// Banner precedence (top-to-bottom): full-screen-intent > battery-optimization >
|
||||
// new-notification-sound > sound-unavailable. At most one renders at a time.
|
||||
// Banner precedence (top-to-bottom): app-update > full-screen-intent >
|
||||
// battery-optimization > new-notification-sound. At most one renders at a time.
|
||||
SecurityBannerState.None -> when {
|
||||
state.blapUpdateBanner != null -> {
|
||||
item(key = "blap_update_banner", contentType = "blap_update_banner") {
|
||||
BlapUpdateBanner(
|
||||
state = state.blapUpdateBanner,
|
||||
onInstallClick = { eventSink(RoomListEvent.BlapInstallUpdate) },
|
||||
onDismissClick = { eventSink(RoomListEvent.BlapDismissUpdate) },
|
||||
)
|
||||
}
|
||||
}
|
||||
state.fullScreenIntentPermissionsState.shouldDisplayBanner -> {
|
||||
item {
|
||||
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
|
||||
|
||||
+4
@@ -27,6 +27,10 @@ sealed interface RoomListEvent {
|
||||
// Blap: collapse/expand a room list section
|
||||
data class BlapToggleSection(val section: BlapSection) : RoomListEvent
|
||||
|
||||
// Blap: in-app update banner actions
|
||||
data object BlapInstallUpdate : RoomListEvent
|
||||
data object BlapDismissUpdate : RoomListEvent
|
||||
|
||||
// Blap: persist the new manual room order after a drag (full displayed order)
|
||||
data class BlapSetRoomOrder(val order: List<RoomId>) : RoomListEvent
|
||||
|
||||
|
||||
+73
-4
@@ -48,6 +48,9 @@ import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.blapupdate.BlapUpdateChecker
|
||||
import io.element.android.libraries.blapupdate.BlapUpdateInfo
|
||||
import io.element.android.libraries.blapupdate.BlapUpdateInstaller
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
|
||||
@@ -95,6 +98,8 @@ class RoomListPresenter(
|
||||
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val sharedPreferences: SharedPreferences,
|
||||
private val blapUpdateChecker: BlapUpdateChecker,
|
||||
private val blapUpdateInstaller: BlapUpdateInstaller,
|
||||
) : Presenter<RoomListState> {
|
||||
private val encryptionService = client.encryptionService
|
||||
|
||||
@@ -161,14 +166,31 @@ class RoomListPresenter(
|
||||
sharedPreferences.getStringSet(BLAP_SPACE_OWNED_KEY, null).orEmpty().map { RoomId(it) }.toSet()
|
||||
)
|
||||
}
|
||||
// Desktop parity (element-web rootSpaces): a space that is itself a child of another
|
||||
// joined space gets no rail entry of its own — only root spaces make the rail.
|
||||
// Persisted like the owned set so the rail is right on cold start.
|
||||
var blapNestedSpaceIds by remember {
|
||||
mutableStateOf(
|
||||
sharedPreferences.getStringSet(BLAP_NESTED_SPACES_KEY, null).orEmpty().map { RoomId(it) }.toSet()
|
||||
)
|
||||
}
|
||||
LaunchedEffect(blapSpaceChildren, spaceFilters) {
|
||||
if (spaceFilters.isEmpty()) return@LaunchedEffect
|
||||
val union = blapSpaceChildren.values.flatten().toSet() + spaceFilters.flatMap { it.descendants }
|
||||
val nested = buildSet {
|
||||
blapSpaceChildren.forEach { (parentId, children) -> addAll(children.filter { it != parentId }) }
|
||||
spaceFilters.forEach { filter -> addAll(filter.descendants.filter { it != filter.spaceRoom.roomId }) }
|
||||
}.intersect(spaceIds.toSet())
|
||||
val allReported = spaceIds.all { it in blapSpaceChildren }
|
||||
val newValue = if (allReported) union else blapSpaceOwnedIds + union
|
||||
if (newValue != blapSpaceOwnedIds) {
|
||||
blapSpaceOwnedIds = newValue
|
||||
sharedPreferences.edit { putStringSet(BLAP_SPACE_OWNED_KEY, newValue.map { it.value }.toSet()) }
|
||||
val newOwned = if (allReported) union else blapSpaceOwnedIds + union
|
||||
if (newOwned != blapSpaceOwnedIds) {
|
||||
blapSpaceOwnedIds = newOwned
|
||||
sharedPreferences.edit { putStringSet(BLAP_SPACE_OWNED_KEY, newOwned.map { it.value }.toSet()) }
|
||||
}
|
||||
val newNested = if (allReported) nested else blapNestedSpaceIds + nested
|
||||
if (newNested != blapNestedSpaceIds) {
|
||||
blapNestedSpaceIds = newNested
|
||||
sharedPreferences.edit { putStringSet(BLAP_NESTED_SPACES_KEY, newNested.map { it.value }.toSet()) }
|
||||
}
|
||||
}
|
||||
// Desktop parity (SpaceStore.showInHomeSpace): Home = orphaned rooms + ALL DMs +
|
||||
@@ -209,6 +231,21 @@ class RoomListPresenter(
|
||||
}
|
||||
}
|
||||
|
||||
// Blap: in-app update check — throttled, so most launches make no network call.
|
||||
var blapUpdateInfo by remember { mutableStateOf<BlapUpdateInfo?>(null) }
|
||||
var blapUpdateDownloading by remember { mutableStateOf(false) }
|
||||
var blapUpdateFailed by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
val now = System.currentTimeMillis()
|
||||
val lastCheck = sharedPreferences.getLong(BLAP_UPDATE_LAST_CHECK_KEY, 0L)
|
||||
if (now - lastCheck < BLAP_UPDATE_CHECK_INTERVAL_MS) return@LaunchedEffect
|
||||
val info = blapUpdateChecker.check()
|
||||
sharedPreferences.edit { putLong(BLAP_UPDATE_LAST_CHECK_KEY, now) }
|
||||
if (info != null && info.version != sharedPreferences.getString(BLAP_UPDATE_DISMISSED_KEY, null)) {
|
||||
blapUpdateInfo = info
|
||||
}
|
||||
}
|
||||
|
||||
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
|
||||
val showNewNotificationSoundBanner by remember {
|
||||
announcementService.announcementsToShowFlow().map { announcements ->
|
||||
@@ -275,6 +312,24 @@ class RoomListPresenter(
|
||||
is RoomListEvent.BlapSetReorderMode -> {
|
||||
blapReorderMode = event.enabled
|
||||
}
|
||||
RoomListEvent.BlapInstallUpdate -> {
|
||||
val info = blapUpdateInfo
|
||||
if (info != null && !blapUpdateDownloading) {
|
||||
coroutineScope.launch {
|
||||
blapUpdateDownloading = true
|
||||
blapUpdateFailed = false
|
||||
val result = blapUpdateInstaller.downloadAndInstall(info)
|
||||
blapUpdateDownloading = false
|
||||
blapUpdateFailed = result.isFailure
|
||||
}
|
||||
}
|
||||
}
|
||||
RoomListEvent.BlapDismissUpdate -> {
|
||||
blapUpdateInfo?.let { info ->
|
||||
sharedPreferences.edit { putString(BLAP_UPDATE_DISMISSED_KEY, info.version) }
|
||||
}
|
||||
blapUpdateInfo = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -304,6 +359,13 @@ class RoomListPresenter(
|
||||
blapCallUsers,
|
||||
blapReorderMode,
|
||||
blapHiddenAtHome,
|
||||
blapUpdateInfo?.let { info ->
|
||||
BlapUpdateBannerState(
|
||||
version = info.version,
|
||||
downloading = blapUpdateDownloading,
|
||||
failed = blapUpdateFailed,
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
return RoomListState(
|
||||
@@ -317,6 +379,7 @@ class RoomListPresenter(
|
||||
acceptDeclineInviteState = acceptDeclineInviteState,
|
||||
hideInvitesAvatars = hideInvitesAvatar,
|
||||
canReportRoom = canReportRoom,
|
||||
blapNestedSpaceIds = blapNestedSpaceIds.toImmutableSet(),
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
@@ -363,6 +426,10 @@ class RoomListPresenter(
|
||||
private companion object {
|
||||
const val BLAP_VOICE_ROOMS_KEY = "blap_voice_room_ids"
|
||||
const val BLAP_SPACE_OWNED_KEY = "blap_space_owned_room_ids"
|
||||
const val BLAP_NESTED_SPACES_KEY = "blap_nested_space_ids"
|
||||
const val BLAP_UPDATE_LAST_CHECK_KEY = "blap_update_last_check"
|
||||
const val BLAP_UPDATE_DISMISSED_KEY = "blap_update_dismissed_version"
|
||||
const val BLAP_UPDATE_CHECK_INTERVAL_MS = 6 * 60 * 60 * 1000L
|
||||
}
|
||||
|
||||
@Composable
|
||||
@@ -376,6 +443,7 @@ class RoomListPresenter(
|
||||
blapCallUsers: Map<UserId, MatrixUser>,
|
||||
blapReorderMode: Boolean,
|
||||
blapHiddenAtHome: Set<RoomId>,
|
||||
blapUpdateBanner: BlapUpdateBannerState?,
|
||||
): RoomListContentState {
|
||||
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
|
||||
roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
|
||||
@@ -421,6 +489,7 @@ class RoomListPresenter(
|
||||
blapManualOrder = blapManualOrder.toImmutableList(),
|
||||
blapCallUsers = blapCallUsers.toImmutableMap(),
|
||||
blapReorderMode = blapReorderMode,
|
||||
blapUpdateBanner = blapUpdateBanner,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -25,6 +25,7 @@ import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentMapOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
data class RoomListState(
|
||||
val contextMenu: ContextMenu,
|
||||
@@ -37,6 +38,9 @@ data class RoomListState(
|
||||
val acceptDeclineInviteState: AcceptDeclineInviteState,
|
||||
val hideInvitesAvatars: Boolean,
|
||||
val canReportRoom: Boolean,
|
||||
// Blap: spaces that are children of another joined space — desktop shows only root
|
||||
// spaces in its rail, so these get no rail entry (see element-web SpaceStore.rootSpaces).
|
||||
val blapNestedSpaceIds: ImmutableSet<RoomId> = persistentSetOf(),
|
||||
val eventSink: (RoomListEvent) -> Unit,
|
||||
) {
|
||||
val displayFilters = contentState is RoomListContentState.Rooms
|
||||
@@ -88,5 +92,14 @@ sealed interface RoomListContentState {
|
||||
val blapCallUsers: ImmutableMap<UserId, MatrixUser> = persistentMapOf(),
|
||||
// Blap: drag-to-arrange mode (whole rows become drag handles, Done exits)
|
||||
val blapReorderMode: Boolean = false,
|
||||
// Blap: in-app update banner (null = no update to offer)
|
||||
val blapUpdateBanner: BlapUpdateBannerState? = null,
|
||||
) : RoomListContentState
|
||||
}
|
||||
|
||||
// Blap: state of the in-app update banner at the top of the chat list.
|
||||
data class BlapUpdateBannerState(
|
||||
val version: String,
|
||||
val downloading: Boolean = false,
|
||||
val failed: Boolean = false,
|
||||
)
|
||||
|
||||
+6
@@ -33,6 +33,8 @@ import io.element.android.features.leaveroom.api.LeaveRoomState
|
||||
import io.element.android.features.preferences.impl.tasks.MarkRoomAsRead
|
||||
import io.element.android.features.rageshake.test.logs.FakeAnnouncementService
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.blapupdate.BlapUpdateChecker
|
||||
import io.element.android.libraries.blapupdate.BlapUpdateInstaller
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter
|
||||
@@ -665,6 +667,8 @@ class RoomListPresenterTest {
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
markRoomAsRead: MarkRoomAsRead? = null,
|
||||
sharedPreferences: android.content.SharedPreferences = InMemorySharedPreferences(),
|
||||
blapUpdateChecker: BlapUpdateChecker = BlapUpdateChecker { null },
|
||||
blapUpdateInstaller: BlapUpdateInstaller = BlapUpdateInstaller { Result.success(Unit) },
|
||||
) = RoomListPresenter(
|
||||
client = client,
|
||||
leaveRoomPresenter = { leaveRoomState },
|
||||
@@ -697,5 +701,7 @@ class RoomListPresenterTest {
|
||||
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
|
||||
featureFlagService = featureFlagService,
|
||||
sharedPreferences = sharedPreferences,
|
||||
blapUpdateChecker = blapUpdateChecker,
|
||||
blapUpdateInstaller = blapUpdateInstaller,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user