// SPDX-License-Identifier: MIT // Copyright (c) 2026 Thibault Ducray // // This file is part of MyPwdTool's open-source sync/encryption core — see // LICENSE-SYNC-CRYPTO.md at the repo root and https://mypwdtool.com/open-source/. // The rest of this application is proprietary and NOT covered by this license. package fr.tducray.mypwdtool.relay import fr.tducray.mypwdtool.appsecrets.RelayMasterKey import kotlinx.coroutines.suspendCancellableCoroutine import java.util.concurrent.atomic.AtomicReference import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.put import java.net.HttpURLConnection import java.net.URI import java.net.URLEncoder /** * Port of `RelayAPIClient` in `Sync/RelayAPIClient.swift`. Built on `java.net.HttpURLConnection` * rather than Ktor or `java.net.http.HttpClient` — the guiding principle for this whole client * (agreed 2026-07-24) is staying as close to plain JVM APIs as possible, and unlike * `java.net.http` (desktop-only — moved off it 2026-09, see below), `HttpURLConnection` is part * of the JDK on every JVM-based target this module ships to, Android included: Windows, Linux, * and Android are all "just a JVM" from this code's point of view, so the less this relies on * KMP abstractions over per-platform APIs, the more of it runs completely unmodified everywhere. * This file lives in jvmMain by Gradle source-set naming convention only — it's shared into * androidMain via the jvmMain->androidMain dependency edge in build.gradle.kts, not * Android-specific despite the folder name. */ class RelayAPIClient( baseUrl: String, private val platform: String, private val clientVersion: String, ) { private val baseUrl: String = baseUrl.trimEnd('/') private val json = Json { ignoreUnknownKeys = true } private class RequestSpec( val method: String, val url: String, val headers: Map = emptyMap(), val body: String? = null, val timeoutMs: Int = 30_000, ) // MARK: - GET /registration/challenge suspend fun fetchChallenge(): RegistrationChallenge { val request = RequestSpec("GET", "$baseUrl/registration/challenge", headers = mapOf("Accept" to "application/json")) return json.decodeFromString(RegistrationChallenge.serializer(), performRequest(request)) } // MARK: - POST /devices /** * Registers a new device. Fetches a challenge nonce first (§4.1), computes the two-level * HMAC response (§3.3) via [ChallengeResponse], then calls `POST /devices` (§4.2). */ suspend fun registerDevice(name: String): RegisterResponse { val challenge = fetchChallenge() val challengeResponse = ChallengeResponse.compute( masterKeyB64Url = RelayMasterKey.base64Url, nonce = challenge.nonce, version = challenge.version, serverDate = challenge.serverDate, platform = platform, clientVersion = clientVersion, ) val body = buildJsonObject { put("device_name", name) put("platform", platform) put("client_version", clientVersion) put("challenge_nonce", challenge.nonce) put("challenge_response", challengeResponse) } val request = RequestSpec( "POST", "$baseUrl/devices", headers = mapOf("Content-Type" to "application/json", "Accept" to "application/json"), body = body.toString(), ) return json.decodeFromString(RegisterResponse.serializer(), performRequest(request)) } // MARK: - DELETE /devices suspend fun deregisterDevice(sendToken: String) { val request = RequestSpec("DELETE", "$baseUrl/devices", headers = mapOf("Authorization" to "Bearer $sendToken")) performRequest(request) } // MARK: - PATCH /devices /** Updates the relay's own `device_name` record for this device (§4.4) — stored * server-side for diagnostics only, distinct from the `sender_device_name` field gossiped * peer-to-peer in the encrypted heartbeat/upsert payloads (see SyncManager's * `renameDevice`), which is what other paired devices actually display. */ suspend fun renameDevice(sendToken: String, name: String) { val body = buildJsonObject { put("device_name", name) } val request = RequestSpec( "PATCH", "$baseUrl/devices", headers = mapOf("Content-Type" to "application/json", "Authorization" to "Bearer $sendToken"), body = body.toString(), ) performRequest(request) } // MARK: - POST /messages suspend fun sendMessage(msg: OutboundRelayMessage, sendToken: String): SendResponse { val body = buildJsonObject { put("message_id", msg.messageId) put("stream_id", msg.streamId) put("sender_device_id", msg.senderDeviceId) putJsonArrayOfStrings("recipient_inbox_ids", msg.recipientInboxIds) put("ciphertext", msg.ciphertext) put("cipher_version", msg.cipherVersion) msg.encryptedIdentifier?.let { put("encrypted_identifier", it) } msg.ttlSeconds?.let { put("ttl_seconds", it) } } val request = RequestSpec( "POST", "$baseUrl/messages", headers = mapOf("Content-Type" to "application/json", "Authorization" to "Bearer $sendToken"), body = body.toString(), ) return json.decodeFromString(SendResponse.serializer(), performRequest(request)) } // MARK: - GET /messages suspend fun pollMessages( recvToken: String, inboxId: String, after: String?, limit: Int, waitMs: Int, ): PollResponse { val query = buildString { append("inbox_id=").append(urlEncode(inboxId)) append("&limit=").append(limit) append("&wait_ms=").append(waitMs) if (after != null) append("&after=").append(urlEncode(after)) } // Long-poll timeout: waitMs/1000 + 15 seconds, matching the Swift client. val request = RequestSpec( "GET", "$baseUrl/messages?$query", headers = mapOf("Authorization" to "Bearer $recvToken"), timeoutMs = waitMs + 15_000, ) return json.decodeFromString(PollResponse.serializer(), performRequest(request)) } // MARK: - POST /messages/ack suspend fun ackMessages(recvToken: String, inboxId: String, messageIds: List) { val body = buildJsonObject { put("inbox_id", inboxId) putJsonArrayOfStrings("message_ids", messageIds) } val request = RequestSpec( "POST", "$baseUrl/messages/ack", headers = mapOf("Content-Type" to "application/json", "Authorization" to "Bearer $recvToken"), body = body.toString(), ) performRequest(request) } // MARK: - GET /limits suspend fun fetchLimits(): ServerLimits { val request = RequestSpec("GET", "$baseUrl/limits", headers = mapOf("Accept" to "application/json")) return json.decodeFromString(ServerLimits.serializer(), performRequest(request)) } // MARK: - GET /health suspend fun healthCheck(): Boolean { val request = RequestSpec("GET", "$baseUrl/health", timeoutMs = 10_000) return try { performRequest(request) true } catch (e: RelayError.ServerError) { false } } // MARK: - Internal request performer private suspend fun performRequest(spec: RequestSpec): String = suspendCancellableCoroutine { cont -> // A blocking HttpURLConnection read ignores coroutine cancellation, so a long poll would // keep its socket open for up to its full hold after the poll loop was cancelled (e.g. // on app backgrounding). Disconnecting on cancel unblocks the read immediately. val current = AtomicReference(null) cont.invokeOnCancellation { current.get()?.disconnect() } Thread { cont.resumeWith(runCatching { performBlockingRequest(spec) { current.set(it) } }) }.apply { isDaemon = true }.start() } private fun performBlockingRequest(spec: RequestSpec, onConnection: (HttpURLConnection) -> Unit): String { val connection = try { (URI.create(spec.url).toURL().openConnection() as HttpURLConnection).apply { requestMethod = spec.method connectTimeout = spec.timeoutMs readTimeout = spec.timeoutMs spec.headers.forEach { (k, v) -> setRequestProperty(k, v) } if (spec.body != null) { doOutput = true outputStream.use { it.write(spec.body.toByteArray(Charsets.UTF_8)) } } } } catch (e: Exception) { throw RelayError.NetworkError(e) } onConnection(connection) val statusCode = try { connection.responseCode } catch (e: Exception) { throw RelayError.NetworkError(e) } fun readBody(stream: java.io.InputStream?): String = stream?.bufferedReader(Charsets.UTF_8)?.use { it.readText() } ?: "" return when (statusCode) { in 200..299 -> readBody(connection.inputStream) 401 -> { val responseBody = readBody(connection.errorStream) val code = try { json.decodeFromString(RelayApiErrorEnvelope.serializer(), responseBody).error.code } catch (e: Exception) { null } throw when (code) { "token_expired" -> RelayError.TokenExpired "challenge_failed", "challenge_required" -> RelayError.ChallengeFailed else -> RelayError.Unauthorized } } 404 -> throw RelayError.NotFound 429 -> { val retryAfter = connection.getHeaderField("Retry-After")?.toIntOrNull() ?: 60 throw RelayError.RateLimited(retryAfterSeconds = retryAfter) } else -> throw RelayError.ServerError(statusCode, readBody(connection.errorStream)) } } private fun urlEncode(s: String): String = URLEncoder.encode(s, Charsets.UTF_8.name()).replace("+", "%20") } private fun kotlinx.serialization.json.JsonObjectBuilder.putJsonArrayOfStrings(key: String, values: List) { put(key, kotlinx.serialization.json.JsonArray(values.map { JsonPrimitive(it) })) }