mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-20 14:32:35 +00:00
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:
+124
-3
@@ -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
|
||||
}
|
||||
|
||||
+14
-4
@@ -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()
|
||||
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
package chat.simplex.common.views.chatlist
|
||||
|
||||
enum class DirectoryFooter { Retry, ShowMore }
|
||||
Reference in New Issue
Block a user