This commit is contained in:
spaced4ndy
2026-08-12 18:31:55 +04:00
parent 247e327ebe
commit 261e6103f8
22 changed files with 598 additions and 102 deletions
@@ -45,15 +45,22 @@ import kotlin.collections.ArrayList
import kotlin.random.Random
import kotlin.time.*
// A directory search and a connection can overlap - tapping a result starts a connection while
// the search is still running - so the single progress slot records its owner: a late search
// result must not clear the spinner that now belongs to the connection.
enum class ConnectProgressOwner { Connect, DirectorySearch }
object ConnectProgressManager {
private val connectInProgress = mutableStateOf<String?>(null)
private val connectProgressByTimeout = mutableStateOf(false)
private var onCancel: (() -> Unit)? = null
private var owner: ConnectProgressOwner? = null
private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
fun startConnectProgress(text: String, onCancel: (() -> Unit)? = null) {
fun startConnectProgress(text: String, owner: ConnectProgressOwner = ConnectProgressOwner.Connect, onCancel: (() -> Unit)? = null) {
connectInProgress.value = text
this.owner = owner
this.onCancel = onCancel
coroutineScope.launch {
delay(1000)
@@ -61,15 +68,22 @@ object ConnectProgressManager {
}
}
fun stopConnectProgress() {
fun stopConnectProgress(owner: ConnectProgressOwner = ConnectProgressOwner.Connect) {
if (this.owner != null && this.owner != owner) return
connectInProgress.value = null
this.owner = null
onCancel = null
connectProgressByTimeout.value = false
}
// a user-initiated cancel, and the takeover in planAndConnect, cancel whatever is running
fun cancelConnectProgress() {
onCancel?.invoke()
stopConnectProgress()
val cancel = onCancel
owner = null
onCancel = null
connectInProgress.value = null
connectProgressByTimeout.value = false
cancel?.invoke()
}
val showConnectProgress: String? get() =
@@ -0,0 +1,66 @@
package chat.simplex.common.model
import kotlinx.datetime.Instant
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.*
// The directory's contact address, as published in docs/DIRECTORY.md. It must be the short
// link: only that form carries the address DR keys that service requests require, so the full
// links on the What's New cards cannot be substituted here.
const val DIRECTORY_SERVICE_LINK = "https://smp4.simplex.im/a#lXUjJW5vHYQzoLYgmi8GbxkGP41_kjefFvBrdwg-0Ok"
// A service request is a full DR handshake, so it is slower than a local API call; the user
// gets a cancellable spinner while it runs and a retry row if it times out.
const val DIRECTORY_SEARCH_TIMEOUT_SEC = 10.0
@Serializable
data class DirectoryPublicLink(
val connFullLink: String? = null,
val connShortLink: String? = null,
)
@Serializable
data class DirectoryEntryType(
val groupType: GroupType? = null,
val summary: GroupSummary,
)
@Serializable
data class DirectorySearchEntry(
val entryType: DirectoryEntryType,
val displayName: String,
val simplexName: String? = null,
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
}
data class DirectorySearchResults(
val entries: List<DirectorySearchEntry>,
// opaque: stored and echoed back on the next request, never inspected
val cursor: JsonObject?,
)
fun directorySearchRequest(text: String, cursor: JsonObject?): JsonObject = buildJsonObject {
put("type", JsonPrimitive("search"))
put("searchText", JsonPrimitive(text))
if (cursor != null) put("searchCursor", cursor)
}
// The response is a tagged object: searchResults or error. Anything else is treated as a failure
// rather than parsed leniently - it comes from outside the app.
fun parseDirectorySearchResponse(resp: JsonObject): DirectorySearchResults? =
when ((resp["type"] as? JsonPrimitive)?.contentOrNull) {
"searchResults" -> {
val entries = (resp["entries"] as? JsonArray)?.mapNotNull {
runCatching { json.decodeFromJsonElement<DirectorySearchEntry>(it) }.getOrNull()
} ?: emptyList()
DirectorySearchResults(entries, resp["searchCursor"] as? JsonObject)
}
else -> null
}
@@ -1531,6 +1531,17 @@ object ChatController {
return null
}
// Blocks until the directory replies or the timeout elapses, so callers must use
// withLongRunningApi, not the single-threaded withBGApi.
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 = sendCmdWithRetry(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")
return null
}
suspend fun apiConnectPlan(rh: Long?, connLink: String, resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, linkOwnerSig: LinkOwnerSig? = null, inProgress: MutableState<Boolean>): ConnectionPlanResult? {
val userId = kotlin.runCatching { currentUserId("apiConnectPlan") }.getOrElse { return null }
val r = sendCmdWithRetry(rh, CC.APIConnectPlan(userId, connLink, resolveMode, linkOwnerSig), inProgress = inProgress)
@@ -3879,6 +3890,7 @@ sealed class CC {
class ApiSetConnectionIncognito(val connId: Long, val incognito: Boolean): CC()
class ApiChangeConnectionUser(val connId: Long, val userId: Long): CC()
class APIConnectPlan(val userId: Long, val connLink: String, val resolveMode: PlanResolveMode = PlanResolveMode.PRMUnknown, val linkOwnerSig: LinkOwnerSig? = null): CC()
class APISendServiceRequest(val userId: Long, val target: String, val timeoutSec: Double?, val request: JsonObject): CC()
class APIPrepareContact(val userId: Long, val connLink: CreatedConnLink, val contactShortLinkData: ContactShortLinkData, val verifiedDomain: SimplexDomain? = null): CC()
class APIPrepareGroup(val userId: Long, val connLink: CreatedConnLink, val directLink: Boolean, val groupShortLinkData: GroupShortLinkData, val verifiedDomain: SimplexDomain? = null): CC()
class APIChangePreparedContactUser(val contactId: Long, val newUserId: Long): CC()
@@ -4092,6 +4104,10 @@ sealed class CC {
val sigStr = if (linkOwnerSig != null) " sig=${json.encodeToString(linkOwnerSig)}" else ""
"/_connect plan $userId $connLink$resolveStr$sigStr"
}
is APISendServiceRequest -> {
val timeoutStr = if (timeoutSec != null) " timeout=$timeoutSec" else ""
"/_service_request $userId $target$timeoutStr ${json.encodeToString(request)}"
}
is APIPrepareContact -> "/_prepare contact $userId ${connLink.cmdString}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(contactShortLinkData)}"
is APIPrepareGroup -> "/_prepare group $userId ${connLink.cmdString} direct=${onOff(directLink)}${verifiedDomain?.let { " ${it.cmdString}" } ?: ""} ${json.encodeToString(groupShortLinkData)}"
is APIChangePreparedContactUser -> "/_set contact user @$contactId $newUserId"
@@ -4278,6 +4294,7 @@ sealed class CC {
is ApiSetConnectionIncognito -> "apiSetConnectionIncognito"
is ApiChangeConnectionUser -> "apiChangeConnectionUser"
is APIConnectPlan -> "apiConnectPlan"
is APISendServiceRequest -> "apiSendServiceRequest"
is APIPrepareContact -> "apiPrepareContact"
is APIPrepareGroup -> "apiPrepareGroup"
is APIChangePreparedContactUser -> "apiChangePreparedContactUser"
@@ -6564,6 +6581,7 @@ sealed class CR {
@Serializable @SerialName("connectionIncognitoUpdated") class ConnectionIncognitoUpdated(val user: UserRef, val toConnection: PendingContactConnection): CR()
@Serializable @SerialName("connectionUserChanged") class ConnectionUserChanged(val user: UserRef, val fromConnection: PendingContactConnection, val toConnection: PendingContactConnection, val newUser: UserRef): CR()
@Serializable @SerialName("connectionPlan") class CRConnectionPlan(val user: UserRef, val connLink: CreatedConnLink, val planSimplexName: SimplexNameInfo? = null, val otherSimplexName: SimplexNameInfo? = null, val connectionPlan: ConnectionPlan): CR()
@Serializable @SerialName("serviceResponse") class CRServiceResponse(val user: UserRef, val responseData: JsonObject): CR()
@Serializable @SerialName("newPreparedChat") class NewPreparedChat(val user: UserRef, val chat: Chat): CR()
@Serializable @SerialName("contactUserChanged") class ContactUserChanged(val user: UserRef, val fromContact: Contact, val newUser: UserRef, val toContact: Contact): CR()
@Serializable @SerialName("groupUserChanged") class GroupUserChanged(val user: UserRef, val fromGroup: GroupInfo, val newUser: UserRef, val toGroup: GroupInfo): CR()