From ccc2847418ab989c68d9cbbf024e82a43e111f95 Mon Sep 17 00:00:00 2001 From: enki Date: Wed, 29 Jul 2026 20:08:54 -0700 Subject: [PATCH] Fix space-to-space switching, hide space rooms at Home, harden ntfy reconnect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../home/impl/roomlist/RoomListPresenter.kt | 79 ++++++++++++++----- .../spacefilters/SpaceFiltersPresenter.kt | 10 ++- .../roomlist/InMemorySharedPreferences.kt | 63 +++++++++++++++ .../impl/roomlist/RoomListPresenterTest.kt | 2 + .../ntfy/NtfySubscriberService.kt | 61 +++++++++++--- 5 files changed, 181 insertions(+), 34 deletions(-) create mode 100644 features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/InMemorySharedPreferences.kt diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt index f3686735e5..ec4d9711b0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenter.kt @@ -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()) } - 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>()) } + 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, blapCallUsers: Map, blapReorderMode: Boolean, + blapHiddenAtHome: Set, ): 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(), diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersPresenter.kt index efa1f702bc..556d44e6ad 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spacefilters/SpaceFiltersPresenter.kt @@ -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 { diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/InMemorySharedPreferences.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/InMemorySharedPreferences.kt new file mode 100644 index 0000000000..8c756a24cb --- /dev/null +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/InMemorySharedPreferences.kt @@ -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() + + override fun getAll(): Map = 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?): MutableSet? = + (values[key] as? Set)?.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() + private var clearFirst = false + + override fun putString(key: String, value: String?) = apply { pending[key] = value } + override fun putStringSet(key: String, value: MutableSet?) = 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 +} diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt index 64dfc46ee2..0d35d0cd3c 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListPresenterTest.kt @@ -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, ) } diff --git a/libraries/pushproviders/ntfy/src/main/kotlin/io/element/android/libraries/pushproviders/ntfy/NtfySubscriberService.kt b/libraries/pushproviders/ntfy/src/main/kotlin/io/element/android/libraries/pushproviders/ntfy/NtfySubscriberService.kt index 814c679d7a..7d230c58cf 100644 --- a/libraries/pushproviders/ntfy/src/main/kotlin/io/element/android/libraries/pushproviders/ntfy/NtfySubscriberService.kt +++ b/libraries/pushproviders/ntfy/src/main/kotlin/io/element/android/libraries/pushproviders/ntfy/NtfySubscriberService.kt @@ -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(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().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()?.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()?.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()?.notify(NOTIFICATION_ID, buildNotification(contentText)) + } + private fun startAsForeground() { val channel = NotificationChannel( CHANNEL_ID, @@ -213,20 +260,12 @@ class NtfySubscriberService : Service() { setShowBadge(false) } getSystemService()?.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 {