From 2137884bde04dff36f159df9d2112add6c500903 Mon Sep 17 00:00:00 2001 From: spacedandy-agents-simplex <342056592+spacedandy-agents-simplex@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:27:50 +0000 Subject: [PATCH] android, desktop: validate directory entries, add name results and quiet connection plans Port the iOS online-search model: a directory entry now carries a non-optional link that decoding accepts only for group and channel links (the core fetches an invitation link even in a store-only plan), plus planLocalChatId, NameSearchResult, DirectoryResult and OnlineSearchRows. apiConnectPlan gains onFailure so lookups can log instead of alerting, and the first-search alert gets its hint preference. Run: 003-kotlin-directory-search, slice 2 --- .../simplex/common/model/DirectorySearch.kt | 127 +++++++++++++++++- .../chat/simplex/common/model/SimpleXAPI.kt | 18 ++- .../views/chatlist/DirectorySearchView.kt | 3 + 3 files changed, 141 insertions(+), 7 deletions(-) create mode 100644 apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt index bf9675adc9..e7bd02eff8 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/DirectorySearch.kt @@ -1,7 +1,14 @@ package chat.simplex.common.model +import chat.simplex.common.views.chatlist.DirectoryFooter +import chat.simplex.common.views.newchat.ConnectTarget +import chat.simplex.common.views.newchat.strConnectTarget +import chat.simplex.res.MR +import dev.icerock.moko.resources.ImageResource import kotlinx.datetime.Instant +import kotlinx.serialization.SerializationException import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import kotlinx.serialization.json.* // The directory's address, as published in docs/DIRECTORY.md. It must be the short link form - @@ -28,14 +35,30 @@ data class DirectorySearchEntry( val entryType: DirectoryEntryType, val displayName: String, val simplexName: String? = null, - val groupLink: DirectoryPublicLink, + private val groupLink: DirectoryPublicLink, val shortDescr: String? = null, val image: String? = null, val activeAt: Instant? = null, val createdAt: Instant? = null, ) { - // the directory drops entries with no link, but the response is untrusted input - val connectLink: String? get() = groupLink.connShortLink ?: groupLink.connFullLink + // the link is stable and unique per entry, so it also de-duplicates across pages + val id: String get() = link + + // untrusted input: only group and channel links are kept, as the core fetches an invitation link even in a store-only plan + @Transient + val link: String = run { + val link = groupLink.connShortLink ?: groupLink.connFullLink + val linkType = if (link != null) simplexLinkType(link) else null + if (link == null || linkType == null || !(linkType == SimplexLinkType.group || linkType == SimplexLinkType.channel)) { + throw SerializationException("not a group or channel link") + } + link + } +} + +fun simplexLinkType(s: String): SimplexLinkType? { + val t = strConnectTarget(s) + return if (t is ConnectTarget.Link && t.text == s) t.linkType else null } data class DirectorySearchResults( @@ -62,3 +85,101 @@ fun parseDirectorySearchResponse(resp: JsonObject): DirectorySearchResults? = } else -> null } + +fun planLocalChatId(plan: ConnectionPlan): ChatId? = + when (plan) { + is ConnectionPlan.ContactAddress -> when (val contactAddressPlan = plan.contactAddressPlan) { + is ContactAddressPlan.Known -> contactAddressPlan.contact.id + is ContactAddressPlan.ConnectingProhibit -> contactAddressPlan.contact.id + is ContactAddressPlan.ContactViaAddress -> contactAddressPlan.contact.id + is ContactAddressPlan.Ok, ContactAddressPlan.OwnLink, ContactAddressPlan.ConnectingConfirmReconnect -> null + } + is ConnectionPlan.GroupLink -> when (val groupLinkPlan = plan.groupLinkPlan) { + is GroupLinkPlan.Known -> groupLinkPlan.groupInfo.id + is GroupLinkPlan.OwnLink -> groupLinkPlan.groupInfo.id + is GroupLinkPlan.ConnectingProhibit -> groupLinkPlan.groupInfo_?.id + is GroupLinkPlan.Ok, GroupLinkPlan.ConnectingConfirmReconnect, is GroupLinkPlan.NoRelays, is GroupLinkPlan.UpdateRequired -> null + } + is ConnectionPlan.InvitationLink -> when (val invitationLinkPlan = plan.invitationLinkPlan) { + is InvitationLinkPlan.Known -> invitationLinkPlan.contact.id + is InvitationLinkPlan.Connecting -> invitationLinkPlan.contact_?.id + is InvitationLinkPlan.Ok, InvitationLinkPlan.OwnLink -> null + } + is ConnectionPlan.Error -> null + } + +class NameSearchResult private constructor( + val target: String, + val simplexName: String, + val profileName: String?, + val descr: String?, + val image: String?, + val iconName: ImageResource, + val localChatId: ChatId?, + // the link the core resolved the name to, never a profile field the name's owner wrote + val resolvedLink: String?, +) { + val id: String get() = target + + companion object { + operator fun invoke(target: String, result: ConnectionPlanResult): NameSearchResult? { + val profile: NamedChat? + val iconName: ImageResource + val resolvedLink: String? + when (val connectionPlan = result.connectionPlan) { + is ConnectionPlan.ContactAddress -> { + profile = when (val contactAddressPlan = connectionPlan.contactAddressPlan) { + is ContactAddressPlan.Ok -> contactAddressPlan.contactSLinkData_?.profile + is ContactAddressPlan.ContactViaAddress -> contactAddressPlan.contact.profile + is ContactAddressPlan.ConnectingProhibit -> contactAddressPlan.contact.profile + is ContactAddressPlan.Known -> contactAddressPlan.contact.profile + ContactAddressPlan.ConnectingConfirmReconnect -> null + ContactAddressPlan.OwnLink -> return null + } + iconName = MR.images.ic_account_circle_filled + resolvedLink = null + } + is ConnectionPlan.GroupLink -> { + val groupProfile: GroupProfile? = when (val groupLinkPlan = connectionPlan.groupLinkPlan) { + is GroupLinkPlan.Ok -> groupLinkPlan.groupSLinkData_?.groupProfile + is GroupLinkPlan.NoRelays -> groupLinkPlan.groupSLinkData_?.groupProfile + is GroupLinkPlan.UpdateRequired -> groupLinkPlan.groupSLinkData_?.groupProfile + is GroupLinkPlan.Known -> groupLinkPlan.groupInfo.groupProfile + is GroupLinkPlan.OwnLink -> groupLinkPlan.groupInfo.groupProfile + is GroupLinkPlan.ConnectingProhibit -> groupLinkPlan.groupInfo_?.groupProfile + GroupLinkPlan.ConnectingConfirmReconnect -> null + } + profile = groupProfile + iconName = if (groupProfile?.isChannel == true) MR.images.ic_bigtop_updates_circle_filled else MR.images.ic_supervised_user_circle_filled + resolvedLink = result.connLink.connShortLink + } + is ConnectionPlan.InvitationLink, is ConnectionPlan.Error -> return null + } + return NameSearchResult( + target = target, + simplexName = result.planSimplexName?.shortStr ?: target, + profileName = profile?.displayName, + descr = profile?.shortDescr, + image = profile?.image, + iconName = iconName, + localChatId = planLocalChatId(result.connectionPlan), + resolvedLink = resolvedLink, + ) + } + } +} + +data class DirectoryResult( + val entry: DirectorySearchEntry, + val localChatId: ChatId?, +) { + val id: String get() = entry.id +} + +data class OnlineSearchRows( + val names: List, + val directory: List, + val footer: DirectoryFooter?, +) { + val isEmpty: Boolean get() = names.isEmpty() && directory.isEmpty() && footer == null +} diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt index c6cd7c2e2d..eced5e6cd5 100644 --- a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/model/SimpleXAPI.kt @@ -193,6 +193,7 @@ class AppPreferences { val showHiddenProfilesNotice = mkBoolPreference(SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE, true) val oneHandUICardShown = mkBoolPreference(SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN, false) val addressCreationCardShown = mkBoolPreference(SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN, false) + val directorySearchAlertShown = mkBoolPreference(SHARED_PREFS_DIRECTORY_SEARCH_ALERT_SHOWN, false) val supporterBannerShown = mkBoolPreference(SHARED_PREFS_SUPPORTER_BANNER_SHOWN, false) val supporterBannerTapped = mkBoolPreference(SHARED_PREFS_SUPPORTER_BANNER_TAPPED, false) val getStakeBannerTapped = mkBoolPreference(SHARED_PREFS_GET_STAKE_BANNER_TAPPED, false) @@ -279,6 +280,7 @@ class AppPreferences { hintPref(laNoticeShown, false), hintPref(oneHandUICardShown, false), hintPref(addressCreationCardShown, false), + hintPref(directorySearchAlertShown, false), hintPref(supporterBannerShown, false), hintPref(supporterBannerTapped, false), hintPref(getStakeBannerTapped, false), @@ -474,6 +476,7 @@ class AppPreferences { private const val SHARED_PREFS_SHOW_HIDDEN_PROFILES_NOTICE = "ShowHiddenProfilesNotice" private const val SHARED_PREFS_ONE_HAND_UI_CARD_SHOWN = "OneHandUICardShown" private const val SHARED_PREFS_ADDRESS_CREATION_CARD_SHOWN = "AddressCreationCardShown" + private const val SHARED_PREFS_DIRECTORY_SEARCH_ALERT_SHOWN = "DirectorySearchAlertShown" private const val SHARED_PREFS_SUPPORTER_BANNER_SHOWN = "SupporterBannerShown" private const val SHARED_PREFS_SUPPORTER_BANNER_TAPPED = "SupporterBannerTapped" private const val SHARED_PREFS_GET_STAKE_BANNER_TAPPED = "GetStakeBannerTapped" @@ -1637,24 +1640,29 @@ object ChatController { return null } - // Blocks for up to the timeout, so call it from withLongRunningApi, not withBGApi. Plain sendCmd + // Blocks for up to the timeout, unlike most API calls here. Plain sendCmd // on purpose: a timeout shows a retry row in the results, not the alert sendCmdWithRetry would pop. suspend fun apiSearchDirectory(rh: Long?, text: String, cursor: JsonObject?): DirectorySearchResults? { val userId = kotlin.runCatching { currentUserId("apiSearchDirectory") }.getOrElse { return null } val req = directorySearchRequest(text, cursor) val r = sendCmd(rh, CC.APISendServiceRequest(userId, DIRECTORY_SERVICE_LINK, DIRECTORY_SEARCH_TIMEOUT_SEC, req)) if (r is API.Result && r.res is CR.CRServiceResponse) return parseDirectorySearchResponse(r.res.responseData) - Log.e(TAG, "apiSearchDirectory error: $r") + Log.e(TAG, "apiSearchDirectory error: ${r.responseType} ${r.details}") return null } - suspend fun apiConnectPlan(rh: Long?, connLink: String, resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState): ConnectionPlanResult? { + suspend fun apiConnectPlan(rh: Long?, connLink: String, resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState, onFailure: ConnectPlanFailure = ConnectPlanFailure.Alert): ConnectionPlanResult? { val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null } val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, resolveMode, linkOwnerSig), inProgress = inProgress) if (r is API.Result && r.res is CR.CRConnectionPlan) return ConnectionPlanResult(r.res.connLink, r.res.planSimplexName, r.res.otherSimplexName, r.res.connectionPlan) // a PRMNever (typing) search that matches nothing locally is not an error to surface if (r is API.Error && r.err is ChatError.ChatErrorChat && r.err.errorType is ChatErrorType.NotResolvedLocally) return null - if (inProgress.value && r != null) apiConnectResponseAlert(r) + if (r != null) { + when (onFailure) { + ConnectPlanFailure.Alert -> if (inProgress.value) apiConnectResponseAlert(r) + ConnectPlanFailure.Log -> Log.d(TAG, "apiConnectPlan: ${r.responseType} ${r.details}") + } + } return null } @@ -7511,6 +7519,8 @@ enum class PlanResolveMode { } } +enum class ConnectPlanFailure { Alert, Log } + @Serializable sealed class ConnectionPlan { @Serializable @SerialName("invitationLink") class InvitationLink(val invitationLinkPlan: InvitationLinkPlan): ConnectionPlan() diff --git a/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt new file mode 100644 index 0000000000..e74c2615c4 --- /dev/null +++ b/apps/multiplatform/common/src/commonMain/kotlin/chat/simplex/common/views/chatlist/DirectorySearchView.kt @@ -0,0 +1,3 @@ +package chat.simplex.common.views.chatlist + +enum class DirectoryFooter { Retry, ShowMore }