Fix space-to-space switching, hide space rooms at Home, harden ntfy reconnect
Sonar / Sonar Quality Checks (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

Space rail: selecting a space while another is selected kept serving the old
filter (unkeyed remember in SpaceFiltersPresenter) — key it on the space id.

Home: track child lists of all spaces (not just the selected one) and hide
space-owned rooms at Home, Discord-style. Filter chips still search everything;
the owned set is persisted so Home is clean on cold start. Voice rooms now
classify in every space without visiting it first.

ntfy: reconnect immediately when the default network returns instead of
waiting out up to 5 minutes of backoff, and surface the socket state
(Connected/Reconnecting) in the foreground-service notification.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-29 20:08:54 -07:00
parent e7c9ac86d4
commit ccc2847418
5 changed files with 181 additions and 34 deletions
@@ -35,6 +35,7 @@ import io.element.android.features.home.impl.filters.into
import io.element.android.features.home.impl.search.RoomListSearchEvent
import io.element.android.features.home.impl.search.RoomListSearchState
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.blapAvailableFilters
import io.element.android.features.home.impl.spacefilters.into
import io.element.android.features.home.impl.spacefilters.selectedFilter
import io.element.android.features.invite.api.SeenInvitesStore
@@ -119,30 +120,63 @@ class RoomListPresenter(
sharedPreferences.getStringSet(BLAP_VOICE_ROOMS_KEY, null).orEmpty().map { RoomId(it) }.toSet()
)
}
// Blap: the selected space's true child list — used for voice classification AND
// unioned into the room filter (the SDK's descendants list omits DM portals, which
// left bridge spaces like mautrix-telegram's looking empty).
var blapSpaceChildIds by remember { mutableStateOf(emptySet<RoomId>()) }
LaunchedEffect(selectedSpaceId) {
blapSpaceChildIds = emptySet()
if (selectedSpaceId == null) return@LaunchedEffect
val spaceRoomList = client.spaceService.spaceRoomList(selectedSpaceId)
try {
spaceRoomList.loadAllIncrementally(this)
spaceRoomList.spaceRoomsFlow.collect { spaceRooms ->
blapSpaceChildIds = spaceRooms.map { it.roomId }.toSet()
val voiceIds = spaceRooms.filter { it.roomType.isBlapVoiceRoom() }.map { it.roomId }
if (voiceIds.isNotEmpty() && !blapVoiceRoomIds.containsAll(voiceIds)) {
blapVoiceRoomIds = blapVoiceRoomIds + voiceIds
sharedPreferences.edit {
putStringSet(BLAP_VOICE_ROOMS_KEY, blapVoiceRoomIds.map { it.value }.toSet())
// Blap: live child lists for EVERY top-level space, not just the selected one. Needed to
// (a) union real children into the selected space's filter (the SDK's descendants list
// omits DM portals, which left bridge spaces like mautrix-telegram's looking empty),
// (b) classify voice rooms without having to visit each space first, and
// (c) hide space-owned rooms from Home (Discord semantics: channels live in servers).
val spaceFilters = spaceFiltersState.blapAvailableFilters
val spaceIds = spaceFilters.map { it.spaceRoom.roomId }
var blapSpaceChildren by remember { mutableStateOf(emptyMap<RoomId, Set<RoomId>>()) }
LaunchedEffect(spaceIds) {
for (spaceId in spaceIds) {
launch {
val spaceRoomList = client.spaceService.spaceRoomList(spaceId)
try {
spaceRoomList.loadAllIncrementally(this)
spaceRoomList.spaceRoomsFlow.collect { spaceRooms ->
blapSpaceChildren = blapSpaceChildren + (spaceId to spaceRooms.map { it.roomId }.toSet())
val voiceIds = spaceRooms.filter { it.roomType.isBlapVoiceRoom() }.map { it.roomId }
if (voiceIds.isNotEmpty() && !blapVoiceRoomIds.containsAll(voiceIds)) {
blapVoiceRoomIds = blapVoiceRoomIds + voiceIds
sharedPreferences.edit {
putStringSet(BLAP_VOICE_ROOMS_KEY, blapVoiceRoomIds.map { it.value }.toSet())
}
}
}
} finally {
spaceRoomList.destroy()
}
}
} finally {
spaceRoomList.destroy()
}
}
val blapSpaceChildIds = blapSpaceChildren[selectedSpaceId].orEmpty()
// Blap: union of everything owned by any space (real children + SDK descendants),
// persisted so Home is clean right away on cold start. Grows immediately as spaces
// report in; replaced exactly once every space has reported (so rooms removed from
// a space eventually un-hide).
var blapSpaceOwnedIds by remember {
mutableStateOf(
sharedPreferences.getStringSet(BLAP_SPACE_OWNED_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 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()) }
}
}
// Home shows only rooms that don't belong to a space. Filter chips (Unreads, People,
// Favorites…) deliberately search everything, and inside a space the space filter rules.
val blapHiddenAtHome = if (selectedSpaceId == null && !filtersState.hasAnyFilterSelected) {
blapSpaceOwnedIds
} else {
emptySet()
}
// Blap: per-space section collapse state, persisted like desktop's localStorage key.
// At Home the channel sections start collapsed (DMs stay in reach on a phone);
// inside a space they start expanded. User toggles win once persisted.
@@ -270,6 +304,7 @@ class RoomListPresenter(
blapManualOrder,
blapCallUsers,
blapReorderMode,
blapHiddenAtHome,
)
return RoomListState(
@@ -328,6 +363,7 @@ 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"
}
@Composable
@@ -340,6 +376,7 @@ class RoomListPresenter(
blapManualOrder: List<RoomId>,
blapCallUsers: Map<UserId, MatrixUser>,
blapReorderMode: Boolean,
blapHiddenAtHome: Set<RoomId>,
): RoomListContentState {
val roomSummaries by produceState(initialValue = AsyncData.Loading()) {
roomListDataSource.roomSummariesFlow.collect { value = AsyncData.Success(it) }
@@ -371,7 +408,9 @@ class RoomListPresenter(
showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
batteryOptimizationState = batteryOptimizationPresenter.present(),
summaries = roomSummaries.dataOrNull().orEmpty().toImmutableList(),
summaries = roomSummaries.dataOrNull().orEmpty()
.filter { it.roomId !in blapHiddenAtHome }
.toImmutableList(),
seenRoomInvites = seenRoomInvites.toImmutableSet(),
blapVoiceRoomIds = blapVoiceRoomIds.toImmutableSet(),
blapCollapsedSections = blapCollapsedSections.toImmutableSet(),
@@ -87,11 +87,15 @@ class SpaceFiltersPresenter(
)
}
is SelectionMode.Selected -> {
var selectedFilter by remember { mutableStateOf(mode.filter) }
// Blap: keyed on the space id — switching straight from one selected space to
// another stays in this branch, and an unkeyed remember would keep serving the
// previous space's filter (rail taps looked dead until going through Home).
val selectedRoomId = mode.filter.spaceRoom.roomId
var selectedFilter by remember(selectedRoomId) { mutableStateOf(mode.filter) }
// Makes sure the selectedFilter stays in sync with the available filters
LaunchedEffect(availableFilters) {
LaunchedEffect(availableFilters, selectedRoomId) {
val upToDateFilter = availableFilters
.firstOrNull { it.spaceRoom.roomId == mode.filter.spaceRoom.roomId }
.firstOrNull { it.spaceRoom.roomId == selectedRoomId }
if (upToDateFilter == null) {
selectionMode = SelectionMode.Unselected
} else {
@@ -0,0 +1,63 @@
/*
* 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.roomlist
import android.content.SharedPreferences
/** Minimal in-memory SharedPreferences for JVM unit tests. */
class InMemorySharedPreferences : SharedPreferences {
private val values = mutableMapOf<String, Any?>()
override fun getAll(): Map<String, *> = values.toMap()
override fun getString(key: String, defValue: String?): String? = values[key] as? String ?: defValue
@Suppress("UNCHECKED_CAST")
override fun getStringSet(key: String, defValues: MutableSet<String>?): MutableSet<String>? =
(values[key] as? Set<String>)?.toMutableSet() ?: defValues
override fun getInt(key: String, defValue: Int): Int = values[key] as? Int ?: defValue
override fun getLong(key: String, defValue: Long): Long = values[key] as? Long ?: defValue
override fun getFloat(key: String, defValue: Float): Float = values[key] as? Float ?: defValue
override fun getBoolean(key: String, defValue: Boolean): Boolean = values[key] as? Boolean ?: defValue
override fun contains(key: String): Boolean = key in values
override fun edit(): SharedPreferences.Editor = object : SharedPreferences.Editor {
private val pending = mutableMapOf<String, Any?>()
private var clearFirst = false
override fun putString(key: String, value: String?) = apply { pending[key] = value }
override fun putStringSet(key: String, value: MutableSet<String>?) = apply { pending[key] = value?.toSet() }
override fun putInt(key: String, value: Int) = apply { pending[key] = value }
override fun putLong(key: String, value: Long) = apply { pending[key] = value }
override fun putFloat(key: String, value: Float) = apply { pending[key] = value }
override fun putBoolean(key: String, value: Boolean) = apply { pending[key] = value }
override fun remove(key: String) = apply { pending[key] = null }
override fun clear() = apply { clearFirst = true }
override fun commit(): Boolean {
apply()
return true
}
override fun apply() {
if (clearFirst) values.clear()
for ((key, value) in pending) {
if (value == null) values.remove(key) else values[key] = value
}
}
}
override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) = Unit
}
@@ -664,6 +664,7 @@ class RoomListPresenterTest {
announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
markRoomAsRead: MarkRoomAsRead? = null,
sharedPreferences: android.content.SharedPreferences = InMemorySharedPreferences(),
) = RoomListPresenter(
client = client,
leaveRoomPresenter = { leaveRoomState },
@@ -695,5 +696,6 @@ class RoomListPresenterTest {
announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
featureFlagService = featureFlagService,
sharedPreferences = sharedPreferences,
)
}
@@ -14,6 +14,8 @@ import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.net.ConnectivityManager
import android.net.Network
import android.os.Build
import android.os.IBinder
import android.util.Base64
@@ -35,10 +37,11 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@@ -76,6 +79,12 @@ class NtfySubscriberService : Service() {
private var connectionJob: Job? = null
private var currentWebSocket: WebSocket? = null
// Fired when the default network (re)appears, so the connect loop skips whatever is
// left of its backoff delay — a Doze exit or wifi↔cellular switch otherwise leaves
// push dead for up to MAX_BACKOFF_MS.
private val reconnectKick = Channel<Unit>(Channel.CONFLATED)
private var networkCallback: ConnectivityManager.NetworkCallback? = null
private val json = Json { ignoreUnknownKeys = true }
private val okHttpClient by lazy {
OkHttpClient.Builder()
@@ -90,6 +99,20 @@ class NtfySubscriberService : Service() {
super.onCreate()
bindings<NtfySubscriberServiceBindings>().inject(this)
serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
reconnectKick.trySend(Unit)
// A socket that survived the network change keeps working; a half-dead one
// gets torn down here so the loop reconnects immediately (since= resumes).
currentWebSocket?.close(NORMAL_CLOSURE, "network changed")
}
}
runCatching {
getSystemService<ConnectivityManager>()?.registerDefaultNetworkCallback(callback)
networkCallback = callback
}.onFailure {
Timber.tag(loggerTag.value).w("Could not register network callback: ${it.message}")
}
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -104,6 +127,9 @@ class NtfySubscriberService : Service() {
}
override fun onDestroy() {
networkCallback?.let { callback ->
runCatching { getSystemService<ConnectivityManager>()?.unregisterNetworkCallback(callback) }
}
currentWebSocket?.close(NORMAL_CLOSURE, "service destroyed")
serviceScope?.cancel()
super.onDestroy()
@@ -128,8 +154,10 @@ class NtfySubscriberService : Service() {
}
Timber.tag(loggerTag.value).d("Connecting to ntfy (${topics.size} topics)")
val hadMessages = runWebSocket(url)
updateNotification("Reconnecting…")
backoffMs = if (hadMessages) INITIAL_BACKOFF_MS else (backoffMs * 2).coerceAtMost(MAX_BACKOFF_MS)
delay(backoffMs)
// A network-available kick cuts the wait short so Doze exits reconnect instantly.
withTimeoutOrNull(backoffMs) { reconnectKick.receive() }
}
}
@@ -141,6 +169,7 @@ class NtfySubscriberService : Service() {
override fun onOpen(webSocket: WebSocket, response: Response) {
opened = true
Timber.tag(loggerTag.value).d("Connected")
updateNotification("Connected to ${ntfyStore.getServerUrl().removePrefix("https://").removePrefix("http://")}")
}
override fun onMessage(webSocket: WebSocket, text: String) {
@@ -203,6 +232,24 @@ class NtfySubscriberService : Service() {
}
}
private fun buildNotification(contentText: String): Notification {
return NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_blap_push)
.setContentTitle("Blap")
.setContentText(contentText)
.setOngoing(true)
.setShowWhen(false)
.setOnlyAlertOnce(true)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
}
/** Surfaces the socket state in the (minimized) FGS notification — the only way to see
* on-device whether push is actually connected without pulling logs. */
private fun updateNotification(contentText: String) {
getSystemService<NotificationManager>()?.notify(NOTIFICATION_ID, buildNotification(contentText))
}
private fun startAsForeground() {
val channel = NotificationChannel(
CHANNEL_ID,
@@ -213,20 +260,12 @@ class NtfySubscriberService : Service() {
setShowBadge(false)
}
getSystemService<NotificationManager>()?.createNotificationChannel(channel)
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_blap_push)
.setContentTitle("Blap")
.setContentText("Listening for messages")
.setOngoing(true)
.setShowWhen(false)
.setPriority(NotificationCompat.PRIORITY_MIN)
.build()
val type = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
ServiceInfo.FOREGROUND_SERVICE_TYPE_REMOTE_MESSAGING
} else {
0
}
ServiceCompat.startForeground(this, NOTIFICATION_ID, notification, type)
ServiceCompat.startForeground(this, NOTIFICATION_ID, buildNotification("Connecting…"), type)
}
companion object {