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
This commit is contained in:
spacedandy-agents-simplex
2026-10-14 13:27:50 +00:00
parent 79b1439913
commit 2137884bde
3 changed files with 141 additions and 7 deletions
@@ -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<NameSearchResult>,
val directory: List<DirectoryResult>,
val footer: DirectoryFooter?,
) {
val isEmpty: Boolean get() = names.isEmpty() && directory.isEmpty() && footer == null
}
@@ -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<Boolean>): ConnectionPlanResult? {
suspend fun apiConnectPlan(rh: Long?, connLink: String, resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>, 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()
@@ -0,0 +1,3 @@
package chat.simplex.common.views.chatlist
enum class DirectoryFooter { Retry, ShowMore }