From 9fc7808ea99f9cfa8cba97195adcdda6f094eb80 Mon Sep 17 00:00:00 2001 From: Morgan Pretty Date: Mon, 31 Aug 2026 15:46:58 +1000 Subject: [PATCH] Fix: a community learned from another device kept its token as the title A community that syncs to a linked device showed its raw room token as the conversation title, permanently. The device that joined showed the name. Joining fetches the room outright and stores its details before writing the community into config (OpenGroupManager.add). A linked device only ever receives that config, so it never runs that fetch -- and add() cannot help it, because the config arriving is how it learned about the community, so the already-joined short-circuit is always taken. That leaves the poller, which asks for pollInfo/{infoUpdates} and therefore gets the room's details ONLY if the room's counter differs from the number we send. With nothing cached we send 0, and a room whose metadata has not been edited since it was created still has a counter of 0 -- setting a name at creation does not bump it, so this is not limited to freshly minted test rooms. The server answers "no change", we store a record with no details, we keep sending 0, and the title never changes. The blank name then falls back to the room token, which RecipientNames marks as a last resort. So while we have no usable name for a room, ask for the room outright instead of polling it. The condition is "no name", not "no row" and not "no details object", because neither of those can express this: patchRoomInfo is an unconditional INSERT OR REPLACE, so a row exists after the first poll regardless, and RoomInfo.details is non-nullable with an empty default, so a response carrying no details deserialises to a present-but-blank object rather than to null. A row check would fire once and then go quiet; a null-details check would never fire at all. Keying on the name also matches the render exactly -- a blank name is precisely the state that shows the token -- so devices already holding a nameless record are repaired rather than left as they are. Cost is one extra request per poll cycle for a room that has never yielded a name, including the edge case of a room genuinely named "". A room with a name makes the request once and then never again. --- .../pollers/OpenGroupPoller.kt | 64 ++++++++++++++++--- 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt index 187adf92c2..5345dd6adb 100644 --- a/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt +++ b/app/src/main/java/org/session/libsession/messaging/sending_receiving/pollers/OpenGroupPoller.kt @@ -22,6 +22,7 @@ import org.session.libsession.messaging.open_groups.api.CommunityApiExecutor import org.session.libsession.messaging.open_groups.api.CommunityApiRequest import org.session.libsession.messaging.open_groups.api.GetCapsApi import org.session.libsession.messaging.open_groups.api.GetDirectMessagesApi +import org.session.libsession.messaging.open_groups.api.GetRoomDetailsApi import org.session.libsession.messaging.open_groups.api.GetRoomMessagesApi import org.session.libsession.messaging.open_groups.api.PollRoomApi import org.session.libsession.messaging.open_groups.api.execute @@ -52,6 +53,7 @@ class OpenGroupPoller @AssistedInject constructor( private val getRoomMessagesFactory: GetRoomMessagesApi.Factory, private val getDirectMessageFactory: GetDirectMessagesApi.Factory, private val pollRoomInfoFactory: PollRoomApi.Factory, + private val getRoomDetailsFactory: GetRoomDetailsApi.Factory, private val messageDataProvider: MessageDataProvider, private val getCapsApi: Provider, networkConnectivity: NetworkConnectivity, @@ -120,22 +122,64 @@ class OpenGroupPoller @AssistedInject constructor( val infoUpdates = latestRoomPollInfo?.details?.infoUpdates ?: 0 val lastMessageServerId = storage.getLastMessageServerID(room, server) - // Poll room info + // Poll room info. + // + // While we have no details for this room, ask for the room outright instead of polling it. + // `pollInfo/{infoUpdates}` returns the details ONLY if the room's counter differs from the + // number we send, and with nothing cached we send 0 — so a room whose metadata has not been + // edited since it was created (its counter is still 0, even if it was created WITH a name) + // answers "no change" and never tells us its name. The poll then writes a row without + // details, we keep sending 0, and the conversation shows the raw room token for ever. + // + // That is reachable whenever a client holds a community it did not join itself: joining + // fetches the room explicitly (see OpenGroupManager.add), but a community arriving by + // config sync from another device never goes through that path. + // + // Keyed on there being no usable NAME, rather than on the row or the details object being + // absent — neither of those can express this. `patchRoomInfo` is an unconditional + // INSERT OR REPLACE, so a row exists after the first poll whether details came back or not; + // and `RoomInfo.details` is non-nullable with an empty default, so a response carrying no + // details deserialises to a present-but-blank object rather than to null. A row check would + // fire once and then go quiet, and a `details == null` check would never fire at all. + // + // Asking "do we have a name" is also what the caller actually needs: the conversation title + // is `roomInfo?.details?.name ?: room`, so a blank name is exactly the state that shows the + // raw token. This repairs devices already holding a nameless row, and stops for good once a + // name arrives. + val haveRoomDetails = latestRoomPollInfo != null && + latestRoomPollInfo.details.name.isNotBlank() + allTasks += "polling room info" to async { - val roomInfo = communityApiExecutor.execute( - CommunityApiRequest( - serverBaseUrl = server, - serverPubKey = serverKey, - api = pollRoomInfoFactory.create( - room = room, - infoUpdates = infoUpdates + val roomInfoJson = if (haveRoomDetails) { + json.encodeToString( + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = server, + serverPubKey = serverKey, + api = pollRoomInfoFactory.create( + room = room, + infoUpdates = infoUpdates + ) + ) ) ) - ) + } else { + json.encodeToString( + OpenGroupApi.RoomInfo( + communityApiExecutor.execute( + CommunityApiRequest( + serverBaseUrl = server, + serverPubKey = serverKey, + api = getRoomDetailsFactory.create(room) + ) + ) + ) + ) + } handleRoomPollInfo( address = address, - pollInfoJsonText = json.encodeToString(roomInfo) + pollInfoJsonText = roomInfoJson ) }