diff --git a/build.gradle.kts b/build.gradle.kts
index 2643dd4473..70886af7ad 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,9 +1,7 @@
import com.unciv.build.BuildConfig.gdxVersion
+import com.unciv.build.BuildConfig.ktorVersion
import com.unciv.build.BuildConfig.roboVMVersion
-plugins {
- id("io.gitlab.arturbosch.detekt").version("1.23.0-RC3")
-}
// You'll still get kotlin-reflect-1.3.70.jar in your classpath, but will no longer be used
configurations.all { resolutionStrategy {
@@ -12,7 +10,6 @@ configurations.all { resolutionStrategy {
buildscript {
-
repositories {
// Chinese mirrors for quicker loading for chinese devs - uncomment if you're chinese
// maven{ url = uri("https://maven.aliyun.com/repository/central") }
@@ -31,6 +28,18 @@ buildscript {
}
}
+// Fixes the error "Please initialize at least one Kotlin target in 'Unciv (:)'"
+kotlin {
+ jvm()
+}
+
+// Plugins used for serialization of JSON for networking
+plugins {
+ id("io.gitlab.arturbosch.detekt").version("1.23.0-RC3")
+ kotlin("multiplatform") version "1.8.10"
+ kotlin("plugin.serialization") version "1.8.10"
+}
+
allprojects {
apply(plugin = "eclipse")
apply(plugin = "idea")
@@ -116,12 +125,26 @@ project(":ios") {
project(":core") {
apply(plugin = "kotlin")
+ // Serialization features (especially JSON)
+ apply(plugin = "kotlinx-serialization")
dependencies {
"implementation"("com.badlogicgames.gdx:gdx:$gdxVersion")
"implementation"("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
"implementation"("org.jetbrains.kotlin:kotlin-reflect:${com.unciv.build.BuildConfig.kotlinVersion}")
+ // Ktor core
+ "implementation"("io.ktor:ktor-client-core:$ktorVersion")
+ // CIO engine
+ "implementation"("io.ktor:ktor-client-cio:$ktorVersion")
+ // WebSocket support
+ "implementation"("io.ktor:ktor-client-websockets:$ktorVersion")
+ // Gzip transport encoding
+ "implementation"("io.ktor:ktor-client-encoding:$ktorVersion")
+ // Content negotiation
+ "implementation"("io.ktor:ktor-client-content-negotiation:$ktorVersion")
+ // JSON serialization and de-serialization
+ "implementation"("io.ktor:ktor-serialization-kotlinx-json:$ktorVersion")
}
diff --git a/buildSrc/src/main/kotlin/BuildConfig.kt b/buildSrc/src/main/kotlin/BuildConfig.kt
index 0bf6230fbc..d8b14d4660 100644
--- a/buildSrc/src/main/kotlin/BuildConfig.kt
+++ b/buildSrc/src/main/kotlin/BuildConfig.kt
@@ -8,5 +8,6 @@ object BuildConfig {
const val appVersion = "4.7.1"
const val gdxVersion = "1.11.0"
+ const val ktorVersion = "2.2.3"
const val roboVMVersion = "2.3.1"
}
diff --git a/core/src/com/unciv/logic/multiplayer/ApiVersion.kt b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt
new file mode 100644
index 0000000000..76bac2e388
--- /dev/null
+++ b/core/src/com/unciv/logic/multiplayer/ApiVersion.kt
@@ -0,0 +1,147 @@
+package com.unciv.logic.multiplayer
+
+import com.unciv.Constants
+import com.unciv.json.json
+import com.unciv.logic.multiplayer.ApiVersion.APIv0
+import com.unciv.logic.multiplayer.ApiVersion.APIv1
+import com.unciv.logic.multiplayer.ApiVersion.APIv2
+import com.unciv.logic.multiplayer.apiv2.DEFAULT_CONNECT_TIMEOUT
+import com.unciv.logic.multiplayer.apiv2.UncivNetworkException
+import com.unciv.logic.multiplayer.apiv2.VersionResponse
+import com.unciv.utils.Log
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.engine.cio.CIO
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.request.get
+import io.ktor.client.statement.bodyAsText
+import io.ktor.http.isSuccess
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.serialization.json.Json
+
+/**
+ * Enum determining the version of a remote server API implementation
+ *
+ * [APIv0] is used to reference DropBox. It doesn't support any further features.
+ * [APIv1] is used for the UncivServer built-in server implementation as well as
+ * for servers implementing this interface. Examples thereof include:
+ * - https://github.com/Mape6/Unciv_server (Python)
+ * - https://gitlab.com/azzurite/unciv-server (NodeJS)
+ * - https://github.com/oynqr/rust_unciv_server (Rust)
+ * - https://github.com/touhidurrr/UncivServer.xyz (NodeJS)
+ * This servers may or may not support authentication. The [ServerFeatureSet] may
+ * be used to inspect their functionality. [APIv2] is used to reference
+ * the heavily extended REST-like HTTP API in combination with a WebSocket
+ * functionality for communication. Examples thereof include:
+ * - https://github.com/hopfenspace/runciv
+ *
+ * A particular server may implement multiple interfaces simultaneously.
+ * There's a server version check in the constructor of [OnlineMultiplayer]
+ * which handles API auto-detection. The precedence of various APIs is
+ * determined by that function:
+ * @see [OnlineMultiplayer.determineServerAPI]
+ */
+enum class ApiVersion {
+ APIv0, APIv1, APIv2;
+
+ companion object {
+ /**
+ * Check the server version by connecting to [baseUrl] without side-effects
+ *
+ * This function doesn't make use of any currently used workers or high-level
+ * connection pools, but instead opens and closes the transports inside it.
+ *
+ * It will first check if the [baseUrl] equals the [Constants.dropboxMultiplayerServer]
+ * to check for [ApiVersion.APIv0]. Dropbox may be unavailable, but this is **not**
+ * checked here. It will then try to connect to ``/isalive`` of [baseUrl]. If a
+ * HTTP 200 response is received, it will try to decode the response body as JSON.
+ * On success (regardless of the content of the JSON), [ApiVersion.APIv1] has been
+ * detected. Otherwise, it will try ``/api/version`` to detect [ApiVersion.APIv2]
+ * and try to decode its response as JSON. If any of the network calls result in
+ * timeout, connection refused or any other networking error, [suppress] is checked.
+ * If set, throwing *any* errors is forbidden, so it returns null, otherwise the
+ * detected [ApiVersion] is returned or the exception is thrown.
+ *
+ * @throws UncivNetworkException: thrown for any kind of network error
+ * or de-serialization problems (ony when [suppress] is false)
+ */
+ suspend fun detect(baseUrl: String, suppress: Boolean = true, timeout: Long? = null): ApiVersion? {
+ if (baseUrl == Constants.dropboxMultiplayerServer) {
+ return APIv0
+ }
+ val fixedBaseUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
+
+ // This client instance should be used during the API detection
+ val client = HttpClient(CIO) {
+ install(ContentNegotiation) {
+ json(Json {
+ prettyPrint = true
+ isLenient = true
+ })
+ }
+ install(HttpTimeout) {
+ connectTimeoutMillis = timeout ?: DEFAULT_CONNECT_TIMEOUT
+ }
+ defaultRequest {
+ url(fixedBaseUrl)
+ }
+ }
+
+ // Try to connect to an APIv1 server at first
+ val response1 = try {
+ client.get("isalive")
+ } catch (e: Exception) {
+ Log.debug("Failed to fetch '/isalive' at %s: %s", fixedBaseUrl, e.localizedMessage)
+ if (!suppress) {
+ client.close()
+ throw UncivNetworkException(e)
+ }
+ null
+ }
+ if (response1?.status?.isSuccess() == true) {
+ // Some API implementations just return the text "true" on the `isalive` endpoint
+ if (response1.bodyAsText().startsWith("true")) {
+ Log.debug("Detected APIv1 at %s (no feature set)", fixedBaseUrl)
+ client.close()
+ return APIv1
+ }
+ try {
+ val serverFeatureSet: ServerFeatureSet = json().fromJson(ServerFeatureSet::class.java, response1.bodyAsText())
+ // val serverFeatureSet: ServerFeatureSet = response1.body()
+ Log.debug("Detected APIv1 at %s: %s", fixedBaseUrl, serverFeatureSet)
+ client.close()
+ return APIv1
+ } catch (e: Exception) {
+ Log.debug("Failed to de-serialize OK response body of '/isalive' at %s: %s", fixedBaseUrl, e.localizedMessage)
+ }
+ }
+
+ // Then try to connect to an APIv2 server
+ val response2 = try {
+ client.get("api/version")
+ } catch (e: Exception) {
+ Log.debug("Failed to fetch '/api/version' at %s: %s", fixedBaseUrl, e.localizedMessage)
+ if (!suppress) {
+ client.close()
+ throw UncivNetworkException(e)
+ }
+ null
+ }
+ if (response2?.status?.isSuccess() == true) {
+ try {
+ val serverVersion: VersionResponse = response2.body()
+ Log.debug("Detected APIv2 at %s: %s", fixedBaseUrl, serverVersion)
+ client.close()
+ return APIv2
+ } catch (e: Exception) {
+ Log.debug("Failed to de-serialize OK response body of '/api/version' at %s: %s", fixedBaseUrl, e.localizedMessage)
+ }
+ }
+
+ client.close()
+ return null
+ }
+ }
+}
diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt
new file mode 100644
index 0000000000..1c6784bd17
--- /dev/null
+++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2.kt
@@ -0,0 +1,618 @@
+package com.unciv.logic.multiplayer.apiv2
+
+import com.badlogic.gdx.utils.Disposable
+import com.unciv.UncivGame
+import com.unciv.logic.GameInfo
+import com.unciv.logic.event.Event
+import com.unciv.logic.event.EventBus
+import com.unciv.logic.multiplayer.ApiVersion
+import com.unciv.logic.multiplayer.storage.ApiV2FileStorageEmulator
+import com.unciv.logic.multiplayer.storage.ApiV2FileStorageWrapper
+import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException
+import com.unciv.utils.Concurrency
+import com.unciv.utils.Log
+import io.ktor.client.call.body
+import io.ktor.client.plugins.websocket.ClientWebSocketSession
+import io.ktor.client.request.get
+import io.ktor.http.isSuccess
+import io.ktor.websocket.Frame
+import io.ktor.websocket.FrameType
+import io.ktor.websocket.close
+import io.ktor.websocket.readText
+import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.channels.Channel
+import kotlinx.coroutines.channels.Channel.Factory.CONFLATED
+import kotlinx.coroutines.channels.ClosedReceiveChannelException
+import kotlinx.coroutines.channels.ClosedSendChannelException
+import kotlinx.coroutines.channels.ReceiveChannel
+import kotlinx.coroutines.channels.SendChannel
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.runBlocking
+import kotlinx.serialization.json.Json
+import java.time.Instant
+import java.util.Random
+import java.util.UUID
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Main class to interact with multiplayer servers implementing [ApiVersion.ApiV2]
+ */
+class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable {
+
+ /** Cache the result of the last server API compatibility check */
+ private var compatibilityCheck: Boolean? = null
+
+ /** Channel to send frames via WebSocket to the server, may be null
+ * for unsupported servers or unauthenticated/uninitialized clients */
+ private var sendChannel: SendChannel? = null
+
+ /** Info whether this class is fully initialized and ready to use */
+ private var initialized = false
+
+ /** Switch to enable auto-reconnect attempts for the WebSocket connection */
+ private var reconnectWebSocket = true
+
+ /** Timestamp of the last successful login */
+ private var lastSuccessfulAuthentication: AtomicReference = AtomicReference()
+
+ /** Cache for the game details to make certain lookups faster */
+ private val gameDetails: MutableMap = mutableMapOf()
+
+ /** List of channel that extend the usage of the [EventBus] system, see [getWebSocketEventChannel] */
+ private val eventChannelList = mutableListOf>()
+
+ /** Map of waiting receivers of pongs (answers to pings) via a channel that gets null
+ * or any thrown exception; access is synchronized on the [ApiV2] instance */
+ private val pongReceivers: MutableMap> = mutableMapOf()
+
+ /**
+ * Get a receiver channel for WebSocket [Event]s that is decoupled from the [EventBus] system
+ *
+ * All WebSocket events are sent to the [EventBus] as well as to all channels
+ * returned by this function, so it's possible to receive from any of these to
+ * get the event. It's better to cancel the [ReceiveChannel] after usage, but cleanup
+ * would also be carried out automatically asynchronously whenever events are sent.
+ * Note that only raw WebSocket messages are put here, i.e. no processed [GameInfo]
+ * or other large objects will be sent (the exception being [UpdateGameData], which
+ * may grow pretty big, as in up to 500 KiB as base64-encoded string data).
+ *
+ * Use the channel returned by this function if the GL render thread, which is used
+ * by the [EventBus] system, may not be available (e.g. in the Android turn checker).
+ */
+ fun getWebSocketEventChannel(): ReceiveChannel {
+ // We're using CONFLATED channels here to avoid usage of possibly huge amounts of memory
+ val c = Channel(capacity = CONFLATED)
+ eventChannelList.add(c as SendChannel)
+ return c
+ }
+
+ /**
+ * Initialize this class (performing actual networking connectivity)
+ *
+ * It's recommended to set the credentials correctly in the first run, if possible.
+ */
+ suspend fun initialize(credentials: Pair? = null) {
+ if (compatibilityCheck == null) {
+ isCompatible()
+ }
+ if (!isCompatible()) {
+ Log.error("Incompatible API detected at '$baseUrl'! Further APIv2 usage will most likely break!")
+ }
+
+ if (credentials != null) {
+ if (!auth.login(credentials.first, credentials.second, suppress = true)) {
+ Log.debug("Login failed using provided credentials (username '${credentials.first}')")
+ } else {
+ lastSuccessfulAuthentication.set(Instant.now())
+ Concurrency.run {
+ refreshGameDetails()
+ }
+ }
+ }
+ ApiV2FileStorageWrapper.storage = ApiV2FileStorageEmulator(this)
+ ApiV2FileStorageWrapper.api = this
+ initialized = true
+ }
+
+ // ---------------- LIFECYCLE FUNCTIONALITY ----------------
+
+ /**
+ * Determine if the user is authenticated by comparing timestamps
+ *
+ * This method is not reliable. The server might have configured another session timeout.
+ */
+ fun isAuthenticated(): Boolean {
+ return (lastSuccessfulAuthentication.get() != null && (lastSuccessfulAuthentication.get()!! + DEFAULT_SESSION_TIMEOUT) > Instant.now())
+ }
+
+ /**
+ * Determine if this class has been fully initialized
+ */
+ fun isInitialized(): Boolean {
+ return initialized
+ }
+
+ /**
+ * Dispose this class and its children and jobs
+ */
+ override fun dispose() {
+ disableReconnecting()
+ sendChannel?.close()
+ for (channel in eventChannelList) {
+ channel.close()
+ }
+ for (job in websocketJobs) {
+ job.cancel()
+ }
+ for (job in websocketJobs) {
+ runBlocking {
+ job.join()
+ }
+ }
+ client.cancel()
+ }
+
+ // ---------------- COMPATIBILITY FUNCTIONALITY ----------------
+
+ /**
+ * Determine if the remote server is compatible with this API implementation
+ *
+ * This currently only checks the endpoints /api/version and /api/v2/ws.
+ * If the first returns a valid [VersionResponse] and the second a valid
+ * [ApiErrorResponse] for being not authenticated, then the server API
+ * is most likely compatible. Otherwise, if 404 errors or other unexpected
+ * responses are retrieved in both cases, the API is surely incompatible.
+ *
+ * This method won't raise any exception other than network-related.
+ * It should be used to verify server URLs to determine the further handling.
+ *
+ * It caches its result once completed; set [update] for actually requesting.
+ */
+ suspend fun isCompatible(update: Boolean = false): Boolean {
+ if (compatibilityCheck != null && !update) {
+ return compatibilityCheck!!
+ }
+
+ val versionInfo = try {
+ val r = client.get("api/version")
+ if (!r.status.isSuccess()) {
+ false
+ } else {
+ val b: VersionResponse = r.body()
+ b.version == 2
+ }
+ } catch (e: IllegalArgumentException) {
+ false
+ } catch (e: Throwable) {
+ Log.error("Unexpected exception calling version endpoint for '$baseUrl': $e")
+ false
+ }
+
+ if (!versionInfo) {
+ compatibilityCheck = false
+ return false
+ }
+
+ val websocketSupport = try {
+ val r = client.get("api/v2/ws")
+ if (r.status.isSuccess()) {
+ Log.error("Websocket endpoint from '$baseUrl' accepted unauthenticated request")
+ false
+ } else {
+ val b: ApiErrorResponse = r.body()
+ b.statusCode == ApiStatusCode.Unauthenticated
+ }
+ } catch (e: IllegalArgumentException) {
+ false
+ } catch (e: Throwable) {
+ Log.error("Unexpected exception calling WebSocket endpoint for '$baseUrl': $e")
+ false
+ }
+
+ compatibilityCheck = websocketSupport
+ return websocketSupport
+ }
+
+ // ---------------- GAME-RELATED FUNCTIONALITY ----------------
+
+ /**
+ * Fetch server's details about a game based on its game ID
+ *
+ * @throws MultiplayerFileNotFoundException: if the [gameId] can't be resolved on the server
+ */
+ suspend fun getGameDetails(gameId: UUID): GameDetails {
+ val result = gameDetails[gameId]
+ if (result != null && result.refreshed + DEFAULT_CACHE_EXPIRY > Instant.now()) {
+ return result.to()
+ }
+ refreshGameDetails()
+ return gameDetails[gameId]?.to() ?: throw MultiplayerFileNotFoundException(null)
+ }
+
+ /**
+ * Refresh the cache of known multiplayer games, [gameDetails]
+ */
+ private suspend fun refreshGameDetails() {
+ val currentGames = game.list()!!
+ for (entry in gameDetails.keys) {
+ if (entry !in currentGames.map { it.gameUUID }) {
+ gameDetails.remove(entry)
+ }
+ }
+ for (g in currentGames) {
+ gameDetails[g.gameUUID] = TimedGameDetails(Instant.now(), g.gameUUID, g.chatRoomUUID, g.gameDataID, g.name)
+ }
+ }
+
+ // ---------------- WEBSOCKET FUNCTIONALITY ----------------
+
+ /**
+ * Send text as a [FrameType.TEXT] frame to the server via WebSocket (fire & forget)
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ @Suppress("Unused")
+ internal suspend fun sendText(text: String, suppress: Boolean = false): Boolean {
+ val channel = sendChannel
+ if (channel == null) {
+ Log.debug("No WebSocket connection, can't send text frame to server: '$text'")
+ if (suppress) {
+ return false
+ } else {
+ throw UncivNetworkException("WebSocket not connected", null)
+ }
+ }
+ try {
+ channel.send(Frame.Text(text))
+ } catch (e: Throwable) {
+ Log.debug("Sending text via WebSocket failed: %s\n%s", e.localizedMessage, e.stackTraceToString())
+ if (!suppress) {
+ throw UncivNetworkException(e)
+ } else {
+ return false
+ }
+ }
+ return true
+ }
+
+ /**
+ * Send a [FrameType.PING] frame to the server, without awaiting a response
+ *
+ * This operation might fail with some exception, e.g. network exceptions.
+ * Internally, a random byte array of [size] will be used for the ping. It
+ * returns true when sending worked as expected, false when there's no
+ * send channel available and an exception otherwise.
+ */
+ private suspend fun sendPing(size: Int = 0): Boolean {
+ val body = ByteArray(size)
+ Random().nextBytes(body)
+ return sendPing(body)
+ }
+
+ /**
+ * Send a [FrameType.PING] frame with the specified content to the server, without awaiting a response
+ *
+ * This operation might fail with some exception, e.g. network exceptions.
+ * It returns true when sending worked as expected, false when there's no
+ * send channel available and an exception otherwise.
+ */
+ private suspend fun sendPing(content: ByteArray): Boolean {
+ val channel = sendChannel
+ return if (channel == null) {
+ false
+ } else {
+ channel.send(Frame.Ping(content))
+ true
+ }
+ }
+
+ /**
+ * Send a [FrameType.PONG] frame with the specified content to the server
+ *
+ * This operation might fail with some exception, e.g. network exceptions.
+ * It returns true when sending worked as expected, false when there's no
+ * send channel available and an exception otherwise.
+ */
+ private suspend fun sendPong(content: ByteArray): Boolean {
+ val channel = sendChannel
+ return if (channel == null) {
+ false
+ } else {
+ channel.send(Frame.Pong(content))
+ true
+ }
+ }
+
+ /**
+ * Send a [FrameType.PING] and await the response of a [FrameType.PONG]
+ *
+ * The function returns the delay between Ping and Pong in milliseconds.
+ * Note that the function may never return if the Ping or Pong packets are lost on
+ * the way, unless [timeout] is set. It will then return `null` if the [timeout]
+ * of milliseconds was reached or the sending of the ping failed. Note that ensuring
+ * this limit is on a best effort basis and may not be reliable, since it uses
+ * [delay] internally to quit waiting for the result of the operation.
+ * This function may also throw arbitrary exceptions for network failures.
+ */
+ suspend fun awaitPing(size: Int = 2, timeout: Long? = null): Double? {
+ if (size < 2) {
+ throw IllegalArgumentException("Size too small to identify ping responses uniquely")
+ }
+ val body = ByteArray(size)
+ Random().nextBytes(body)
+
+ val key = body.toHex()
+ val channel = Channel(capacity = Channel.RENDEZVOUS)
+ synchronized(this) {
+ pongReceivers[key] = channel
+ }
+
+ var job: Job? = null
+ if (timeout != null) {
+ job = Concurrency.run {
+ delay(timeout)
+ channel.close()
+ }
+ }
+
+ try {
+ return kotlin.system.measureNanoTime {
+ if (!sendPing(body)) {
+ return null
+ }
+ val exception = runBlocking { channel.receive() }
+ job?.cancel()
+ channel.close()
+ if (exception != null) {
+ throw exception
+ }
+ }.toDouble() / 10e6
+ } catch (c: ClosedReceiveChannelException) {
+ return null
+ } finally {
+ synchronized(this) {
+ pongReceivers.remove(key)
+ }
+ }
+ }
+
+ /**
+ * Handler for incoming [FrameType.PONG] frames to make [awaitPing] work properly
+ */
+ private suspend fun onPong(content: ByteArray) {
+ val receiver = synchronized(this) { pongReceivers[content.toHex()] }
+ receiver?.send(null)
+ }
+
+ /**
+ * Handle a newly established WebSocket connection
+ */
+ private suspend fun handleWebSocket(session: ClientWebSocketSession) {
+ sendChannel?.close()
+ sendChannel = session.outgoing
+
+ websocketJobs.add(Concurrency.run {
+ val currentChannel = session.outgoing
+ while (sendChannel != null && currentChannel == sendChannel) {
+ try {
+ sendPing()
+ } catch (e: Exception) {
+ Log.debug("Failed to send WebSocket ping: %s", e.localizedMessage)
+ Concurrency.run {
+ if (reconnectWebSocket) {
+ websocket(::handleWebSocket)
+ }
+ }
+ }
+ delay(DEFAULT_WEBSOCKET_PING_FREQUENCY)
+ }
+ Log.debug("It looks like the WebSocket channel has been replaced")
+ })
+
+ try {
+ while (true) {
+ val incomingFrame = session.incoming.receive()
+ when (incomingFrame.frameType) {
+ FrameType.PING -> {
+ sendPong(incomingFrame.data)
+ }
+ FrameType.PONG -> {
+ onPong(incomingFrame.data)
+ }
+ FrameType.CLOSE -> {
+ throw ClosedReceiveChannelException("Received CLOSE frame via WebSocket")
+ }
+ FrameType.BINARY -> {
+ Log.debug("Received binary packet of size %s which can't be parsed at the moment", incomingFrame.data.size)
+ }
+ FrameType.TEXT -> {
+ try {
+ val text = (incomingFrame as Frame.Text).readText()
+ val msg = Json.decodeFromString(WebSocketMessageSerializer(), text)
+ Log.debug("Incoming WebSocket message ${msg::class.java.canonicalName}: $msg")
+ when (msg.type) {
+ WebSocketMessageType.InvalidMessage -> {
+ Log.debug("Received 'InvalidMessage' from WebSocket connection")
+ }
+ else -> {
+ // Casting any message but InvalidMessage to WebSocketMessageWithContent should work,
+ // otherwise the class hierarchy has been messed up somehow; all messages should have content
+ Concurrency.runOnGLThread {
+ EventBus.send((msg as WebSocketMessageWithContent).content)
+ }
+ for (c in eventChannelList) {
+ Concurrency.run {
+ try {
+ c.send((msg as WebSocketMessageWithContent).content)
+ } catch (closed: ClosedSendChannelException) {
+ delay(10)
+ eventChannelList.remove(c)
+ } catch (t: Throwable) {
+ Log.debug("Sending event %s to event channel %s failed: %s", (msg as WebSocketMessageWithContent).content, c, t)
+ delay(10)
+ eventChannelList.remove(c)
+ }
+ }
+ }
+ }
+ }
+ } catch (e: Throwable) {
+ Log.error("%s\n%s", e.localizedMessage, e.stackTraceToString())
+ }
+ }
+ }
+ }
+ } catch (e: ClosedReceiveChannelException) {
+ Log.debug("The WebSocket channel was closed: $e")
+ sendChannel?.close()
+ session.close()
+ session.flush()
+ Concurrency.run {
+ if (reconnectWebSocket) {
+ websocket(::handleWebSocket)
+ }
+ }
+ } catch (e: CancellationException) {
+ Log.debug("WebSocket coroutine was cancelled, closing connection: $e")
+ sendChannel?.close()
+ session.close()
+ session.flush()
+ } catch (e: Throwable) {
+ Log.error("Error while handling a WebSocket connection: %s\n%s", e.localizedMessage, e.stackTraceToString())
+ sendChannel?.close()
+ session.close()
+ session.flush()
+ Concurrency.run {
+ if (reconnectWebSocket) {
+ websocket(::handleWebSocket)
+ }
+ }
+ throw e
+ }
+ }
+
+ /**
+ * Ensure that the WebSocket is connected (send a PING and build a new connection on failure)
+ *
+ * Use [jobCallback] to receive the newly created job handling the WS connection.
+ * Note that this callback might not get called if no new WS connection was created.
+ * It returns the measured round trip time in milliseconds if everything was fine.
+ */
+ suspend fun ensureConnectedWebSocket(timeout: Long = DEFAULT_WEBSOCKET_PING_TIMEOUT, jobCallback: ((Job) -> Unit)? = null): Double? {
+ val pingMeasurement = try {
+ awaitPing(timeout = timeout)
+ } catch (e: Exception) {
+ Log.debug("Error %s while ensuring connected WebSocket: %s", e, e.localizedMessage)
+ null
+ }
+ if (pingMeasurement == null) {
+ websocket(::handleWebSocket, jobCallback)
+ }
+ return pingMeasurement
+ }
+
+ // ---------------- SESSION FUNCTIONALITY ----------------
+
+ /**
+ * Perform post-login hooks and updates
+ *
+ * 1. Create a new WebSocket connection after logging in (ignoring existing sockets)
+ * 2. Update the [UncivGame.Current.settings.multiplayer.userId]
+ * (this makes using APIv0/APIv1 games impossible if the user ID is not preserved!)
+ */
+ @Suppress("KDocUnresolvedReference")
+ override suspend fun afterLogin() {
+ enableReconnecting()
+ val me = account.get(cache = false, suppress = true)
+ if (me != null) {
+ Log.error(
+ "Updating user ID from %s to %s. This is no error. But you may need the old ID to be able to access your old multiplayer saves.",
+ UncivGame.Current.settings.multiplayer.userId,
+ me.uuid
+ )
+ UncivGame.Current.settings.multiplayer.userId = me.uuid.toString()
+ UncivGame.Current.settings.save()
+ ensureConnectedWebSocket()
+ }
+ super.afterLogin()
+ }
+
+ /**
+ * Perform the post-logout hook, cancelling all WebSocket jobs and event channels
+ */
+ override suspend fun afterLogout(success: Boolean) {
+ disableReconnecting()
+ sendChannel?.close()
+ if (success) {
+ for (channel in eventChannelList) {
+ channel.close()
+ }
+ for (job in websocketJobs) {
+ job.cancel()
+ }
+ }
+ super.afterLogout(success)
+ }
+
+ /**
+ * Refresh the currently used session by logging in with username and password stored in the game settings
+ *
+ * Any errors are suppressed. Differentiating invalid logins from network issues is therefore impossible.
+ *
+ * Set [ignoreLastCredentials] to refresh the session even if there was no last successful credentials.
+ */
+ suspend fun refreshSession(ignoreLastCredentials: Boolean = false): Boolean {
+ if (!ignoreLastCredentials) {
+ return false
+ }
+ val success = auth.login(
+ UncivGame.Current.settings.multiplayer.userName,
+ UncivGame.Current.settings.multiplayer.passwords[UncivGame.Current.onlineMultiplayer.multiplayerServer.serverUrl] ?: "",
+ suppress = true
+ )
+ if (success) {
+ lastSuccessfulAuthentication.set(Instant.now())
+ }
+ return success
+ }
+
+ /**
+ * Enable auto re-connect attempts for the WebSocket connection
+ */
+ fun enableReconnecting() {
+ reconnectWebSocket = true
+ }
+
+ /**
+ * Disable auto re-connect attempts for the WebSocket connection
+ */
+ fun disableReconnecting() {
+ reconnectWebSocket = false
+ }
+}
+
+/**
+ * Small struct to store the most relevant details about a game, useful for caching
+ *
+ * Note that those values may become invalid (especially the [dataID]), so use it only for
+ * caching for short durations. The [chatRoomUUID] may be valid longer (up to the game's lifetime).
+ */
+data class GameDetails(val gameUUID: UUID, val chatRoomUUID: UUID, val dataID: Long, val name: String)
+
+/**
+ * Holding the same values as [GameDetails], but with an instant determining the last refresh
+ */
+private data class TimedGameDetails(val refreshed: Instant, val gameUUID: UUID, val chatRoomUUID: UUID, val dataID: Long, val name: String) {
+ fun to() = GameDetails(gameUUID, chatRoomUUID, dataID, name)
+}
+
+/**
+ * Convert a byte array to a hex string
+ */
+private fun ByteArray.toHex(): String {
+ return this.joinToString("") { it.toUByte().toString(16).padStart(2, '0') }
+}
diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt
new file mode 100644
index 0000000000..398cb8c666
--- /dev/null
+++ b/core/src/com/unciv/logic/multiplayer/apiv2/ApiV2Wrapper.kt
@@ -0,0 +1,255 @@
+package com.unciv.logic.multiplayer.apiv2
+
+import com.unciv.UncivGame
+import com.unciv.logic.UncivShowableException
+import com.unciv.utils.Concurrency
+import com.unciv.utils.Log
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.engine.cio.CIO
+import io.ktor.client.plugins.HttpSend
+import io.ktor.client.plugins.HttpTimeout
+import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
+import io.ktor.client.plugins.defaultRequest
+import io.ktor.client.plugins.plugin
+import io.ktor.client.plugins.websocket.ClientWebSocketSession
+import io.ktor.client.plugins.websocket.WebSockets
+import io.ktor.client.plugins.websocket.cio.webSocketRawSession
+import io.ktor.client.request.get
+import io.ktor.http.DEFAULT_PORT
+import io.ktor.http.ParametersBuilder
+import io.ktor.http.URLBuilder
+import io.ktor.http.URLProtocol
+import io.ktor.http.Url
+import io.ktor.http.appendPathSegments
+import io.ktor.http.encodedPath
+import io.ktor.http.isSecure
+import io.ktor.http.userAgent
+import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter
+import io.ktor.serialization.kotlinx.json.json
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.coroutineScope
+import kotlinx.serialization.SerializationException
+import kotlinx.serialization.json.Json
+import java.util.concurrent.ConcurrentLinkedQueue
+
+/**
+ * API wrapper around the newly implemented REST API for multiplayer game handling
+ *
+ * Note that this class does not include the handling of messages via the
+ * WebSocket connection, but rather only the pure HTTP-based API.
+ * Almost any method may throw certain OS or network errors as well as the
+ * [ApiErrorResponse] for invalid requests (4xx) or server failures (5xx).
+ *
+ * This class should be considered implementation detail, since it just
+ * abstracts HTTP endpoint names from other modules in this package.
+ * Use the [ApiV2] class for public methods to interact with the server.
+ */
+open class ApiV2Wrapper(baseUrl: String) {
+ private val baseUrlImpl: String = if (baseUrl.endsWith("/")) baseUrl else ("$baseUrl/")
+ private val baseServer = URLBuilder(baseUrl).apply {
+ encodedPath = ""
+ encodedParameters = ParametersBuilder()
+ fragment = ""
+ }.toString()
+
+ // HTTP client to handle the server connections, logging, content parsing and cookies
+ internal val client = HttpClient(CIO) {
+ // Do not add install(HttpCookies) because it will break Cookie handling
+ install(ContentNegotiation) {
+ json(Json {
+ prettyPrint = true
+ isLenient = true
+ })
+ }
+ install(HttpTimeout) {
+ requestTimeoutMillis = DEFAULT_REQUEST_TIMEOUT
+ connectTimeoutMillis = DEFAULT_CONNECT_TIMEOUT
+ }
+ install(WebSockets) {
+ // Pings are configured manually to enable re-connecting automatically, don't use `pingInterval`
+ contentConverter = KotlinxWebsocketSerializationConverter(Json)
+ }
+ defaultRequest {
+ url(baseUrlImpl)
+ }
+ }
+
+ /** Helper that replaces library cookie storages to fix cookie serialization problems and keeps
+ * track of user-supplied credentials to be able to refresh expired sessions on the fly */
+ private val authHelper = AuthHelper()
+
+ /** Queue to keep references to all opened WebSocket handler jobs */
+ protected var websocketJobs = ConcurrentLinkedQueue()
+
+ init {
+ client.plugin(HttpSend).intercept { request ->
+ request.userAgent("Unciv/${UncivGame.VERSION.toNiceString()}-GNU-Terry-Pratchett")
+ val clientCall = try {
+ execute(request)
+ } catch (t: Throwable) {
+ Log.error("Failed to query API: %s %s\nURL: %s\nError %s:\n%s", request.method.value, request.url.encodedPath, request.url, t.localizedMessage, t.stackTraceToString())
+ throw t
+ }
+ Log.debug(
+ "'%s %s': %s (%d ms%s)",
+ request.method.value,
+ request.url.toString(),
+ clientCall.response.status,
+ clientCall.response.responseTime.timestamp - clientCall.response.requestTime.timestamp,
+ if (!request.url.protocol.isSecure()) ", insecure!" else ""
+ )
+ clientCall
+ }
+ }
+
+ /**
+ * Coroutine directly executed after every successful login to the server,
+ * which also refreshed the session cookie (i.e., not [AuthApi.loginOnly]).
+ * This coroutine should not raise any unhandled exceptions, because otherwise
+ * the login function will fail as well. If it requires longer operations,
+ * those operations should be detached from the current thread.
+ */
+ protected open suspend fun afterLogin() {}
+
+ /**
+ * Coroutine directly executed after every attempt to logout from the server.
+ * The parameter [success] determines whether logging out completed successfully,
+ * i.e. this coroutine will also be called in the case of an error.
+ * This coroutine should not raise any unhandled exceptions, because otherwise
+ * the login function will fail as well. If it requires longer operations,
+ * those operations should be detached from the current thread.
+ */
+ protected open suspend fun afterLogout(success: Boolean) {}
+
+ /**
+ * API for account management
+ */
+ val account = AccountsApi(client, authHelper)
+
+ /**
+ * API for authentication management
+ */
+ val auth = AuthApi(client, authHelper, ::afterLogin, ::afterLogout)
+
+ /**
+ * API for chat management
+ */
+ val chat = ChatApi(client, authHelper)
+
+ /**
+ * API for friendship management
+ */
+ val friend = FriendApi(client, authHelper)
+
+ /**
+ * API for game management
+ */
+ val game = GameApi(client, authHelper)
+
+ /**
+ * API for invite management
+ */
+ val invite = InviteApi(client, authHelper)
+
+ /**
+ * API for lobby management
+ */
+ val lobby = LobbyApi(client, authHelper)
+
+ /**
+ * Start a new WebSocket connection
+ *
+ * The parameter [handler] is a coroutine that will be fed the established
+ * [ClientWebSocketSession] on success at a later point. Note that this
+ * method does instantly return, detaching the creation of the WebSocket.
+ * The [handler] coroutine might not get called, if opening the WS fails.
+ * Use [jobCallback] to receive the newly created job handling the WS connection.
+ */
+ internal suspend fun websocket(handler: suspend (ClientWebSocketSession) -> Unit, jobCallback: ((Job) -> Unit)? = null): Boolean {
+ Log.debug("Starting a new WebSocket connection ...")
+
+ coroutineScope {
+ try {
+ val session = client.webSocketRawSession {
+ authHelper.add(this)
+ url {
+ protocol = if (Url(baseServer).protocol.isSecure()) URLProtocol.WSS else URLProtocol.WS
+ port = Url(baseServer).specifiedPort.takeUnless { it == DEFAULT_PORT } ?: protocol.defaultPort
+ appendPathSegments("api/v2/ws")
+ }
+ }
+ val job = Concurrency.runOnNonDaemonThreadPool {
+ handler(session)
+ }
+ websocketJobs.add(job)
+ Log.debug("A new WebSocket has been created, running in job $job")
+ if (jobCallback != null) {
+ jobCallback(job)
+ }
+ true
+ } catch (e: SerializationException) {
+ Log.debug("Failed to create a WebSocket: %s", e.localizedMessage)
+ return@coroutineScope false
+ } catch (e: Exception) {
+ Log.debug("Failed to establish WebSocket connection: %s", e.localizedMessage)
+ return@coroutineScope false
+ }
+ }
+
+ return true
+ }
+
+ /**
+ * Retrieve the currently available API version of the connected server
+ *
+ * Unlike other API endpoint implementations, this function does not handle
+ * any errors or retries on failure. You must wrap any call in a try-except
+ * clause expecting any type of error. The error may not be appropriate to
+ * be shown to end users, i.e. it's definitively no [UncivShowableException].
+ */
+ suspend fun version(): VersionResponse {
+ return client.get("api/version").body()
+ }
+
+}
+
+/**
+ * APIv2 exception class that is compatible with [UncivShowableException]
+ */
+class ApiException(val error: ApiErrorResponse) : UncivShowableException(lookupErrorMessage(error.statusCode))
+
+/**
+ * Convert an API status code to a string that can be translated and shown to users
+ */
+private fun lookupErrorMessage(statusCode: ApiStatusCode): String {
+ return when (statusCode) {
+ ApiStatusCode.Unauthenticated -> "You are not logged in. Please login first."
+ ApiStatusCode.NotFound -> "The operation couldn't be completed, since the resource was not found."
+ ApiStatusCode.InvalidContentType -> "The media content type was invalid. Please report this as a bug."
+ ApiStatusCode.InvalidJson -> "The server didn't understand the sent data. Please report this as a bug."
+ ApiStatusCode.PayloadOverflow -> "The amount of data sent to the server was too large. Please report this as a bug."
+ ApiStatusCode.LoginFailed -> "The login failed. Is the username and password correct?"
+ ApiStatusCode.UsernameAlreadyOccupied -> "The selected username is already taken. Please choose another name."
+ ApiStatusCode.InvalidPassword -> "This password is not valid. Please choose another password."
+ ApiStatusCode.EmptyJson -> "The server encountered an empty JSON problem. Please report this as a bug."
+ ApiStatusCode.InvalidUsername -> "The username is not valid. Please choose another one."
+ ApiStatusCode.InvalidDisplayName -> "The display name is not valid. Please choose another one."
+ ApiStatusCode.FriendshipAlreadyRequested -> "You have already requested friendship with this player. Please wait until the request is accepted."
+ ApiStatusCode.AlreadyFriends -> "You are already friends, you can't request it again."
+ ApiStatusCode.MissingPrivileges -> "You don't have the required privileges to perform this operation."
+ ApiStatusCode.InvalidMaxPlayersCount -> "The maximum number of players for this lobby is out of the supported range for this server. Please adjust the number. Two players should always work."
+ ApiStatusCode.AlreadyInALobby -> "You are already in another lobby. You need to close or leave the other lobby before."
+ ApiStatusCode.InvalidUuid -> "The operation could not be completed, since an invalid UUID was given. Please retry later or restart the game. If the problem persists, please report this as a bug."
+ ApiStatusCode.InvalidLobbyUuid -> "The lobby was not found. Maybe it has already been closed?"
+ ApiStatusCode.InvalidFriendUuid -> "You must be friends with the other player before this action can be completed. Try again later."
+ ApiStatusCode.GameNotFound -> "The game was not found on the server. Try again later. If the problem persists, the game was probably already removed from the server, sorry."
+ ApiStatusCode.InvalidMessage -> "This message could not be sent, since it was invalid. Remove any invalid characters and try again."
+ ApiStatusCode.WsNotConnected -> "The WebSocket is not available. Please restart the game and try again. If the problem persists, please report this as a bug."
+ ApiStatusCode.LobbyFull -> "The lobby is currently full. You can't join right now."
+ ApiStatusCode.InvalidPlayerUUID -> "The ID of the player was invalid. Does the player exist? Please try again. If the problem persists, please report this as a bug."
+ ApiStatusCode.InternalServerError -> "Internal server error. Please report this as a bug."
+ ApiStatusCode.DatabaseError -> "Internal server database error. Please report this as a bug."
+ ApiStatusCode.SessionError -> "Internal session error. Please report this as a bug."
+ }
+}
diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt b/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt
new file mode 100644
index 0000000000..1dd29e7132
--- /dev/null
+++ b/core/src/com/unciv/logic/multiplayer/apiv2/AuthHelper.kt
@@ -0,0 +1,64 @@
+package com.unciv.logic.multiplayer.apiv2
+
+import com.unciv.utils.Log
+import io.ktor.client.request.HttpRequestBuilder
+import io.ktor.client.request.header
+import io.ktor.http.CookieEncoding
+import io.ktor.http.HttpHeaders
+import io.ktor.http.encodeCookieValue
+import java.time.Instant
+import java.util.concurrent.atomic.AtomicReference
+
+/**
+ * Authentication helper which doesn't support multiple cookies, but just does the job correctly
+ *
+ * It also stores the username and password as well as the timestamp of the last successful login.
+ * Do not use HttpCookies since the url-encoded cookie values break the authentication flow.
+ */
+class AuthHelper {
+
+ /** Value of the last received session cookie (pair of cookie value and max age) */
+ private var cookie: AtomicReference?> = AtomicReference()
+
+ /** Credentials used during the last successful login */
+ internal var lastSuccessfulCredentials: AtomicReference?> = AtomicReference()
+
+ /** Timestamp of the last successful login */
+ private var lastSuccessfulAuthentication: AtomicReference = AtomicReference()
+
+ /**
+ * Set the session cookie, update the last refresh timestamp and the last successful credentials
+ */
+ internal fun setCookie(value: String, maxAge: Int? = null, credentials: Pair? = null) {
+ cookie.set(Pair(value, maxAge))
+ lastSuccessfulAuthentication.set(Instant.now())
+ lastSuccessfulCredentials.set(credentials)
+ }
+
+ /**
+ * Drop the session cookie and credentials, so that authenticating won't be possible until re-login
+ */
+ internal fun unset() {
+ cookie.set(null)
+ lastSuccessfulCredentials.set(null)
+ }
+
+ /**
+ * Add authentication to the request builder by adding the Cookie header
+ */
+ fun add(request: HttpRequestBuilder) {
+ val value = cookie.get()
+ if (value != null) {
+ if ((lastSuccessfulAuthentication.get()?.plusSeconds((value.second ?: 0).toLong()) ?: Instant.MIN) < Instant.now()) {
+ Log.debug("Session cookie might have already expired")
+ }
+ // Using the raw cookie encoding ensures that valid base64 characters are not re-url-encoded
+ request.header(HttpHeaders.Cookie, encodeCookieValue(
+ "$SESSION_COOKIE_NAME=${value.first}", encoding = CookieEncoding.RAW
+ ))
+ } else {
+ Log.debug("Session cookie is not available")
+ }
+ }
+
+}
diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt
new file mode 100644
index 0000000000..ddcab84794
--- /dev/null
+++ b/core/src/com/unciv/logic/multiplayer/apiv2/Conf.kt
@@ -0,0 +1,27 @@
+package com.unciv.logic.multiplayer.apiv2
+
+import java.time.Duration
+
+/** Name of the session cookie returned and expected by the server */
+internal const val SESSION_COOKIE_NAME = "id"
+
+/** Default value for max number of players in a lobby if no other value is set */
+internal const val DEFAULT_LOBBY_MAX_PLAYERS = 32
+
+/** Default ping frequency for outgoing WebSocket connection in seconds */
+internal const val DEFAULT_WEBSOCKET_PING_FREQUENCY = 15_000L
+
+/** Default session timeout expected from multiplayer servers (unreliable) */
+internal val DEFAULT_SESSION_TIMEOUT = Duration.ofSeconds(15 * 60)
+
+/** Default cache expiry timeout to indicate that certain data needs to be re-fetched */
+internal val DEFAULT_CACHE_EXPIRY = Duration.ofSeconds(30 * 60)
+
+/** Default timeout for a single request (miliseconds) */
+internal const val DEFAULT_REQUEST_TIMEOUT = 10_000L
+
+/** Default timeout for connecting to a remote server (miliseconds) */
+internal const val DEFAULT_CONNECT_TIMEOUT = 5_000L
+
+/** Default timeout for a single WebSocket PING-PONG roundtrip */
+internal const val DEFAULT_WEBSOCKET_PING_TIMEOUT = 10_000L
diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt
new file mode 100644
index 0000000000..be2cdd5866
--- /dev/null
+++ b/core/src/com/unciv/logic/multiplayer/apiv2/EndpointImplementations.kt
@@ -0,0 +1,1120 @@
+/**
+ * Collection of endpoint implementations
+ *
+ * Those classes are not meant to be used directly. Take a look at the Api class for common usage.
+ */
+
+package com.unciv.logic.multiplayer.apiv2
+
+import com.unciv.utils.Log
+import io.ktor.client.HttpClient
+import io.ktor.client.call.body
+import io.ktor.client.plugins.cookies.get
+import io.ktor.client.request.HttpRequestBuilder
+import io.ktor.client.request.request
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.HttpResponse
+import io.ktor.client.statement.bodyAsText
+import io.ktor.client.statement.request
+import io.ktor.http.ContentType
+import io.ktor.http.HttpMethod
+import io.ktor.http.HttpStatusCode
+import io.ktor.http.contentType
+import io.ktor.http.isSuccess
+import io.ktor.http.path
+import io.ktor.http.setCookie
+import io.ktor.util.network.UnresolvedAddressException
+import java.io.IOException
+import java.time.Instant
+import java.util.UUID
+
+/**
+ * List of HTTP status codes which are considered to [ApiErrorResponse]s by the specification
+ */
+internal val ERROR_CODES = listOf(HttpStatusCode.BadRequest, HttpStatusCode.InternalServerError)
+
+/**
+ * List of API status codes that should be re-executed after session refresh, if possible
+ */
+private val RETRY_CODES = listOf(ApiStatusCode.Unauthenticated)
+
+/**
+ * Default value for randomly generated passwords
+ */
+private const val DEFAULT_RANDOM_PASSWORD_LENGTH = 32
+
+/**
+ * Max age of a cached entry before it will be re-queried
+ */
+private const val MAX_CACHE_AGE_SECONDS = 60L
+
+/**
+ * Perform a HTTP request via [method] to [endpoint]
+ *
+ * Use [refine] to change the [HttpRequestBuilder] after it has been prepared with the method
+ * and path. Do not edit the cookie header or the request URL, since they might be overwritten.
+ * If [suppress] is set, it will return null instead of throwing any exceptions.
+ * This function retries failed requests after executing coroutine [retry] which will be passed
+ * the same arguments as the [request] coroutine, if it is set and the request failed due to
+ * network or defined API errors, see [RETRY_CODES]. It should return a [Boolean] which determines
+ * if the original request should be retried after finishing [retry]. For example, to silently
+ * repeat a request on such failure, use such coroutine: suspend { true }
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+private suspend fun request(
+ method: HttpMethod,
+ endpoint: String,
+ client: HttpClient,
+ authHelper: AuthHelper,
+ refine: ((HttpRequestBuilder) -> Unit)? = null,
+ suppress: Boolean = false,
+ retry: (suspend () -> Boolean)? = null
+): HttpResponse? {
+ val builder = HttpRequestBuilder()
+ builder.method = method
+ if (refine != null) {
+ refine(builder)
+ }
+ builder.url { path(endpoint) }
+ authHelper.add(builder)
+
+ // Perform the request, but handle network issues gracefully according to the specified exceptions
+ val response = try {
+ client.request(builder)
+ } catch (e: Throwable) {
+ when (e) {
+ // This workaround allows to catch multiple exception types at the same time
+ // See https://youtrack.jetbrains.com/issue/KT-7128 if you want this feature in Kotlin :)
+ is IOException, is UnresolvedAddressException -> {
+ val shouldRetry = if (retry != null) {
+ Log.debug("Calling retry coroutine %s for network error %s in '%s %s'", retry, e, method, endpoint)
+ retry()
+ } else {
+ false
+ }
+ return if (shouldRetry) {
+ Log.debug("Retrying after network error %s: %s (cause: %s)", e, e.message, e.cause)
+ request(method, endpoint, client, authHelper,
+ refine = refine,
+ suppress = suppress,
+ retry = null
+ )
+ } else if (suppress) {
+ Log.debug("Suppressed network error %s: %s (cause: %s)", e, e.message, e.cause)
+ null
+ } else {
+ Log.debug("Throwing network error %s: %s (cause: %s)", e, e.message, e.cause)
+ throw UncivNetworkException(e)
+ }
+ }
+ else -> throw e
+ }
+ }
+
+ // For HTTP errors defined in the API, throwing an ApiException would be the correct handling.
+ // Therefore, try to de-serialize the response as ApiErrorResponse first. If it happens to be
+ // an authentication failure, the request could be retried as well. Otherwise, throw the error.
+ if (response.status in ERROR_CODES) {
+ try {
+ val error: ApiErrorResponse = response.body()
+ // Now the API response can be checked for retry-able failures
+ if (error.statusCode in RETRY_CODES && retry != null) {
+ Log.debug("Calling retry coroutine %s for API response error %s in '%s %s'", retry, error, method, endpoint)
+ if (retry()) {
+ return request(method, endpoint, client, authHelper,
+ refine = refine,
+ suppress = suppress,
+ retry = null
+ )
+ }
+ }
+ if (suppress) {
+ Log.debug("Suppressing %s for call to '%s'", error, response.request.url)
+ return null
+ }
+ throw error.to()
+ } catch (e: IllegalArgumentException) { // de-serialization failed
+ Log.error("Invalid body for '%s %s' -> %s: %s: '%s'", method, response.request.url, response.status, e.message, response.bodyAsText())
+ val shouldRetry = if (retry != null) {
+ Log.debug("Calling retry coroutine %s for serialization error %s in '%s %s'", retry, e, method, endpoint)
+ retry()
+ } else {
+ false
+ }
+ return if (shouldRetry) {
+ request(method, endpoint, client, authHelper,
+ refine = refine,
+ suppress = suppress,
+ retry = null
+ )
+ } else if (suppress) {
+ Log.debug("Suppressed invalid API error response %s: %s (cause: %s)", e, e.message, e.cause)
+ null
+ } else {
+ Log.debug("Throwing network error instead of API error due to serialization failure %s: %s (cause: %s)", e, e.message, e.cause)
+ throw UncivNetworkException(e)
+ }
+ }
+ } else if (response.status.isSuccess()) {
+ return response
+ } else {
+ // Here, the server returned a non-success code which is not recognized,
+ // therefore it is considered a network error (even if was something like 404)
+ if (suppress) {
+ Log.debug("Suppressed unknown HTTP status code %s for '%s %s'", response.status, method, response.request.url)
+ return null
+ }
+ // If the server does not conform to the API, re-trying requests is useless
+ throw UncivNetworkException(IllegalArgumentException(response.status.toString()))
+ }
+}
+
+/**
+ * Get the default retry mechanism which tries to refresh the current session, if credentials are available
+ */
+private fun getDefaultRetry(client: HttpClient, authHelper: AuthHelper): (suspend () -> Boolean) {
+ val lastCredentials = authHelper.lastSuccessfulCredentials.get()
+ if (lastCredentials != null) {
+ return suspend {
+ val response = request(HttpMethod.Post, "api/v2/auth/login", client, authHelper, suppress = true, retry = null, refine = {b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(LoginRequest(lastCredentials.first, lastCredentials.second))
+ })
+ if (response != null && response.status.isSuccess()) {
+ val authCookie = response.setCookie()[SESSION_COOKIE_NAME]
+ Log.debug("Received new session cookie in retry handler: $authCookie")
+ if (authCookie != null) {
+ authHelper.setCookie(
+ authCookie.value,
+ authCookie.maxAge,
+ Pair(lastCredentials.first, lastCredentials.second)
+ )
+ true
+ } else {
+ false
+ }
+ } else {
+ false
+ }
+ }
+ } else {
+ return suspend { false }
+ }
+}
+
+/**
+ * Simple cache for GET queries to the API
+ */
+private object Cache {
+ private var responseCache: MutableMap> = mutableMapOf()
+
+ /**
+ * Clear the response cache
+ */
+ fun clear() {
+ responseCache.clear()
+ }
+
+ /**
+ * Wrapper around [request] to cache responses to GET queries up to [MAX_CACHE_AGE_SECONDS]
+ */
+ suspend fun get(
+ endpoint: String,
+ client: HttpClient,
+ authHelper: AuthHelper,
+ refine: ((HttpRequestBuilder) -> Unit)? = null,
+ suppress: Boolean = false,
+ cache: Boolean = true,
+ retry: (suspend () -> Boolean)? = null
+ ): HttpResponse? {
+ val result = responseCache[endpoint]
+ if (cache && result != null && result.first.plusSeconds(MAX_CACHE_AGE_SECONDS).isAfter(Instant.now())) {
+ return result.second
+ }
+ val response = request(HttpMethod.Get, endpoint, client, authHelper, refine, suppress, retry)
+ if (cache && response != null) {
+ responseCache[endpoint] = Pair(Instant.now(), response)
+ }
+ return response
+ }
+}
+
+/**
+ * API wrapper for account handling (do not use directly; use the Api class instead)
+ */
+class AccountsApi(private val client: HttpClient, private val authHelper: AuthHelper) {
+
+ /**
+ * Retrieve information about the currently logged in user
+ *
+ * Unset [cache] to avoid using the cache and update the data from the server.
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [AccountResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun get(cache: Boolean = true, suppress: Boolean = false): AccountResponse? {
+ return Cache.get(
+ "api/v2/accounts/me",
+ client, authHelper,
+ suppress = suppress,
+ cache = cache,
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ }
+
+ /**
+ * Retrieve details for an account by its [uuid] (always preferred to using usernames)
+ *
+ * Unset [cache] to avoid using the cache and update the data from the server.
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [AccountResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun lookup(uuid: UUID, cache: Boolean = true, suppress: Boolean = false): AccountResponse? {
+ return Cache.get(
+ "api/v2/accounts/$uuid",
+ client, authHelper,
+ suppress = suppress,
+ cache = cache,
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ }
+
+ /**
+ * Retrieve details for an account by its [username]
+ *
+ * Important note: Usernames can be changed, so don't assume they can be
+ * cached to do lookups for their display names or UUIDs later. Always convert usernames
+ * to UUIDs when handling any user interactions (e.g., inviting, sending messages, ...).
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [AccountResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun lookup(username: String, suppress: Boolean = false): AccountResponse? {
+ return request(
+ HttpMethod.Post, "api/v2/accounts/lookup",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper),
+ refine = { b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(LookupAccountUsernameRequest(username))
+ }
+ )?.body()
+ }
+
+ /**
+ * Set the [username] of the currently logged-in user
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun setUsername(username: String, suppress: Boolean = false): Boolean {
+ return update(UpdateAccountRequest(username, null), suppress)
+ }
+
+ /**
+ * Set the [displayName] of the currently logged-in user
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun setDisplayName(displayName: String, suppress: Boolean = false): Boolean {
+ return update(UpdateAccountRequest(null, displayName), suppress)
+ }
+
+ /**
+ * Update the currently logged in user information
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ private suspend fun update(r: UpdateAccountRequest, suppress: Boolean): Boolean {
+ val response = request(
+ HttpMethod.Put, "api/v2/accounts/me",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper),
+ refine = { b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(r)
+ }
+ )
+ return response?.status?.isSuccess() == true
+ }
+
+ /**
+ * Deletes the currently logged-in account (irreversible operation!)
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun delete(suppress: Boolean = false): Boolean {
+ val response = request(
+ HttpMethod.Delete, "api/v2/accounts/me",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )
+ return response?.status?.isSuccess() == true
+ }
+
+ /**
+ * Set [newPassword] for the currently logged-in account, provided the [oldPassword] was accepted as valid
+ *
+ * If not given, the [oldPassword] will be used from the login session cache, if available.
+ * However, if the [oldPassword] can't be determined, it will likely yield in a [ApiStatusCode.InvalidPassword].
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun setPassword(newPassword: String, oldPassword: String? = null, suppress: Boolean = false): Boolean {
+ var oldLocalPassword = oldPassword
+ val lastKnownPassword = authHelper.lastSuccessfulCredentials.get()?.second
+ if (oldLocalPassword == null && lastKnownPassword != null) {
+ oldLocalPassword = lastKnownPassword
+ }
+ if (oldLocalPassword == null) {
+ oldLocalPassword = "" // empty passwords will yield InvalidPassword, so this is fine here
+ }
+ val response = request(
+ HttpMethod.Post, "api/v2/accounts/setPassword",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper),
+ refine = { b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(SetPasswordRequest(oldLocalPassword, newPassword))
+ }
+ )
+ return if (response?.status?.isSuccess() == true) {
+ Log.debug("User's password has been changed successfully")
+ true
+ } else {
+ false
+ }
+ }
+
+ /**
+ * Register a new user account
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun register(username: String, displayName: String, password: String, suppress: Boolean = false): Boolean {
+ val response = request(
+ HttpMethod.Post, "api/v2/accounts/register",
+ client, authHelper,
+ suppress = suppress,
+ refine = { b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(AccountRegistrationRequest(username, displayName, password))
+ }
+ )
+ return if (response?.status?.isSuccess() == true) {
+ Log.debug("A new account for username '%s' has been created", username)
+ true
+ } else {
+ false
+ }
+ }
+
+}
+
+/**
+ * API wrapper for authentication handling (do not use directly; use the Api class instead)
+ */
+class AuthApi(private val client: HttpClient, private val authHelper: AuthHelper, private val afterLogin: suspend () -> Unit, private val afterLogout: suspend (Boolean) -> Unit) {
+
+ /**
+ * Try logging in with [username] and [password] for testing purposes, don't set the session cookie
+ *
+ * This method won't raise *any* exception, just return the boolean value if login worked.
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun loginOnly(username: String, password: String): Boolean {
+ val response = request(
+ HttpMethod.Post, "api/v2/auth/login",
+ client, authHelper,
+ suppress = true,
+ refine = { b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(LoginRequest(username, password))
+ }
+ )
+ return response?.status?.isSuccess() == true
+ }
+
+ /**
+ * Try logging in with [username] and [password] to get a new session
+ *
+ * This method will also implicitly set a cookie in the in-memory cookie storage to authenticate
+ * further API calls and cache the username and password to refresh expired sessions.
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun login(username: String, password: String, suppress: Boolean = false): Boolean {
+ val response = request(
+ HttpMethod.Post, "api/v2/auth/login",
+ client, authHelper,
+ suppress = suppress,
+ refine = { b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(LoginRequest(username, password))
+ },
+ retry = { Log.error("Failed to login. See previous debug logs for details."); false }
+ )
+ return if (response?.status?.isSuccess() == true) {
+ val authCookie = response.setCookie()[SESSION_COOKIE_NAME]
+ Log.debug("Received new session cookie: $authCookie")
+ if (authCookie != null) {
+ authHelper.setCookie(
+ authCookie.value,
+ authCookie.maxAge,
+ Pair(username, password)
+ )
+ afterLogin()
+ true
+ } else {
+ Log.error("No recognized, valid session cookie found in login response!")
+ false
+ }
+ } else {
+ false
+ }
+ }
+
+ /**
+ * Logs out the currently logged in user
+ *
+ * This method will also clear the cookie and credentials to avoid further authenticated API calls.
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun logout(suppress: Boolean = true): Boolean {
+ val response = try {
+ request(
+ HttpMethod.Get, "api/v2/auth/logout",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )
+ } catch (e: Throwable) {
+ authHelper.unset()
+ Cache.clear()
+ Log.debug("Logout failed due to %s (%s), dropped session anyways", e, e.message)
+ afterLogout(false)
+ return false
+ }
+ Cache.clear()
+ return if (response?.status?.isSuccess() == true) {
+ authHelper.unset()
+ Log.debug("Logged out successfully, dropped session")
+ afterLogout(true)
+ true
+ } else {
+ authHelper.unset()
+ Log.debug("Logout failed for some reason, dropped session anyways")
+ afterLogout(false)
+ false
+ }
+ }
+
+}
+
+/**
+ * API wrapper for chat room handling (do not use directly; use the Api class instead)
+ */
+class ChatApi(private val client: HttpClient, private val authHelper: AuthHelper) {
+
+ /**
+ * Retrieve all chats a user has access to
+ *
+ * In the response, you will find different room types / room categories.
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [GetAllChatsResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun list(suppress: Boolean = false): GetAllChatsResponse? {
+ return request(
+ HttpMethod.Get, "api/v2/chats",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ }
+
+ /**
+ * Retrieve the messages of a chatroom identified by [roomUUID]
+ *
+ * The [ChatMessage]s should be sorted by their timestamps, [ChatMessage.createdAt].
+ * The [ChatMessage.uuid] should be used to uniquely identify chat messages. This is
+ * needed as new messages may be delivered via WebSocket as well. [GetChatResponse.members]
+ * holds information about all members that are currently in the chat room (including yourself).
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [GetChatResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun get(roomUUID: UUID, suppress: Boolean = false): GetChatResponse? {
+ return request(
+ HttpMethod.Get, "api/v2/chats/$roomUUID",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ }
+
+ /**
+ * Send a message to a chat room
+ *
+ * The executing user must be a member of the chatroom and the message must not be empty.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun send(message: String, chatRoomUUID: UUID, suppress: Boolean = false): ChatMessage? {
+ val response = request(
+ HttpMethod.Post, "api/v2/chats/$chatRoomUUID",
+ client, authHelper,
+ suppress = suppress,
+ refine = { b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(SendMessageRequest(message))
+ },
+ retry = getDefaultRetry(client, authHelper)
+ )
+ return response?.body()
+ }
+
+}
+
+/**
+ * API wrapper for friend handling (do not use directly; use the Api class instead)
+ */
+class FriendApi(private val client: HttpClient, private val authHelper: AuthHelper) {
+
+ /**
+ * Retrieve a pair of the list of your established friendships and the list of your open friendship requests (incoming and outgoing)
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise a pair of lists or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun list(suppress: Boolean = false): Pair, List>? {
+ val body: GetFriendResponse? = request(
+ HttpMethod.Get, "api/v2/friends",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ return if (body != null) Pair(body.friends, body.friendRequests) else null
+ }
+
+ /**
+ * Retrieve a list of your established friendships
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise a list of [FriendResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun listFriends(suppress: Boolean = false): List? {
+ return list(suppress = suppress)?.first
+ }
+
+ /**
+ * Retrieve a list of your open friendship requests (incoming and outgoing)
+ *
+ * If you have a request with [FriendRequestResponse.from] equal to your username, it means
+ * you have requested a friendship, but the destination hasn't accepted yet. In the other
+ * case, if your username is in [FriendRequestResponse.to], you have received a friend request.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise a list of [FriendRequestResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun listRequests(suppress: Boolean = false): List? {
+ return list(suppress = suppress)?.second
+ }
+
+ /**
+ * Request friendship with another user
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun request(other: UUID, suppress: Boolean = false): Boolean {
+ val response = request(
+ HttpMethod.Post, "api/v2/friends",
+ client, authHelper,
+ suppress = suppress,
+ refine = { b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(CreateFriendRequest(other))
+ },
+ retry = getDefaultRetry(client, authHelper)
+ )
+ return response?.status?.isSuccess() == true
+ }
+
+ /**
+ * Accept a friend request identified by [friendRequestUUID]
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun accept(friendRequestUUID: UUID, suppress: Boolean = false): Boolean {
+ val response = request(
+ HttpMethod.Put, "api/v2/friends/$friendRequestUUID",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )
+ return response?.status?.isSuccess() == true
+ }
+
+ /**
+ * Don't want your friends anymore? Just delete them!
+ *
+ * This function accepts both friend UUIDs and friendship request UUIDs.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun delete(friendUUID: UUID, suppress: Boolean = false): Boolean {
+ val response = request(
+ HttpMethod.Delete, "api/v2/friends/$friendUUID",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )
+ return response?.status?.isSuccess() == true
+ }
+
+}
+
+/**
+ * API wrapper for game handling (do not use directly; use the Api class instead)
+ */
+class GameApi(private val client: HttpClient, private val authHelper: AuthHelper) {
+
+ /**
+ * Retrieves an overview of all open games of a player
+ *
+ * The response does not contain any full game state, but rather a
+ * shortened game state identified by its ID and state identifier.
+ * If the state ([GameOverviewResponse.gameDataID]) of a known game
+ * differs from the last known identifier, the server has a newer
+ * state of the game. The [GameOverviewResponse.lastActivity] field
+ * is a convenience attribute and shouldn't be used for update checks.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise list of [GameOverviewResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun list(suppress: Boolean = false): List? {
+ val body: GetGameOverviewResponse? = request(
+ HttpMethod.Get, "api/v2/games",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ return body?.games
+ }
+
+ /**
+ * Retrieves a single game identified by [gameUUID] which is currently open (actively played)
+ *
+ * Other than [list], this method's return value contains a full game state (on success).
+ * Set [cache] to false to avoid getting a cached result by this function. This
+ * is especially useful for receiving a new game on purpose / on request.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [GameStateResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun get(gameUUID: UUID, cache: Boolean = true, suppress: Boolean = false): GameStateResponse? {
+ return Cache.get(
+ "api/v2/games/$gameUUID",
+ client, authHelper,
+ suppress = suppress,
+ cache = cache,
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ }
+
+ /**
+ * Retrieves an overview of a single game of a player (or null if no such game is available)
+ *
+ * The response does not contain any full game state, but rather a
+ * shortened game state identified by its ID and state identifier.
+ * If the state ([GameOverviewResponse.gameDataID]) of a known game
+ * differs from the last known identifier, the server has a newer
+ * state of the game. The [GameOverviewResponse.lastActivity] field
+ * is a convenience attribute and shouldn't be used for update checks.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [GameOverviewResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun head(gameUUID: UUID, suppress: Boolean = false): GameOverviewResponse? {
+ val result = list(suppress = suppress)
+ return result?.filter { it.gameUUID == gameUUID }?.getOrNull(0)
+ }
+
+ /**
+ * Upload a new game state for an existing game identified by [gameUUID]
+ *
+ * If the game can't be updated (maybe it has been already completed
+ * or aborted), it will respond with a GameNotFound in [ApiErrorResponse].
+ * Use the [gameUUID] retrieved from the server in a previous API call.
+ *
+ * On success, returns the new game data ID that can be used to verify
+ * that the client and server use the same state (prevents re-querying).
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [Long] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun upload(gameUUID: UUID, gameData: String, suppress: Boolean = false): Long? {
+ val body: GameUploadResponse? = request(
+ HttpMethod.Put, "api/v2/games/$gameUUID",
+ client, authHelper,
+ suppress = suppress,
+ refine = { b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(GameUploadRequest(gameData))
+ },
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ if (body != null) {
+ Log.debug("The game with UUID $gameUUID has been uploaded, the new data ID is ${body.gameDataID}")
+ }
+ return body?.gameDataID
+ }
+
+}
+
+/**
+ * API wrapper for invite handling (do not use directly; use the Api class instead)
+ */
+class InviteApi(private val client: HttpClient, private val authHelper: AuthHelper) {
+
+ /**
+ * Retrieve all invites for the executing user
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise list of [GetInvite] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun list(suppress: Boolean = false): List? {
+ val body: GetInvitesResponse? = request(
+ HttpMethod.Get, "api/v2/invites",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ return body?.invites
+ }
+
+ /**
+ * Invite a friend to a lobby
+ *
+ * The executing user must be in the specified open lobby. The invited
+ * player (identified by its [friendUUID]) must not be in a friend request state.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun new(friendUUID: UUID, lobbyUUID: UUID, suppress: Boolean = false): Boolean {
+ val response = request(
+ HttpMethod.Post, "api/v2/invites",
+ client, authHelper,
+ suppress = suppress,
+ refine = { b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(CreateInviteRequest(friendUUID, lobbyUUID))
+ },
+ retry = getDefaultRetry(client, authHelper)
+ )
+ return response?.status?.isSuccess() == true
+ }
+
+ /**
+ * Reject or retract an invite to a lobby
+ *
+ * This endpoint can be used either by the sender of the invite
+ * to retract the invite or by the receiver to reject the invite.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun reject(inviteUUID: UUID, suppress: Boolean = false): Boolean {
+ val response = request(
+ HttpMethod.Delete, "api/v2/invites/$inviteUUID",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )
+ return response?.status?.isSuccess() == true
+ }
+
+}
+
+/**
+ * API wrapper for lobby handling (do not use directly; use the Api class instead)
+ */
+class LobbyApi(private val client: HttpClient, private val authHelper: AuthHelper) {
+
+ /**
+ * Retrieves all open lobbies
+ *
+ * If [LobbyResponse.hasPassword] is true, the lobby is secured by a user-set password.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise list of [LobbyResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun list(suppress: Boolean = false): List? {
+ val body: GetLobbiesResponse? = request(
+ HttpMethod.Get, "api/v2/lobbies",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ return body?.lobbies
+ }
+
+ /**
+ * Fetch a single open lobby
+ *
+ * If [LobbyResponse.hasPassword] is true, the lobby is secured by a user-set password.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [GetLobbyResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun get(lobbyUUID: UUID, suppress: Boolean = false): GetLobbyResponse? {
+ return request(
+ HttpMethod.Get, "api/v2/lobbies/$lobbyUUID",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ }
+
+ /**
+ * Create a new lobby and return the new lobby with some extra info as [CreateLobbyResponse]
+ *
+ * You can't be in more than one lobby at the same time. If [password] is set, the lobby
+ * will be considered closed. Users need the specified [password] to be able to join the
+ * lobby on their own behalf. Invites to the lobby are always possible as lobby creator.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [CreateLobbyResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun open(name: String, password: String? = null, maxPlayers: Int = DEFAULT_LOBBY_MAX_PLAYERS, suppress: Boolean = false): CreateLobbyResponse? {
+ return open(CreateLobbyRequest(name, password, maxPlayers), suppress)
+ }
+
+ /**
+ * Create a new private lobby and return the new lobby with some extra info as [CreateLobbyResponse]
+ *
+ * You can't be in more than one lobby at the same time. *Important*:
+ * This lobby will be created with a random password which will *not* be stored.
+ * Other users can't join without invitation to this lobby, afterwards.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [CreateLobbyResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun openPrivate(name: String, maxPlayers: Int = DEFAULT_LOBBY_MAX_PLAYERS, suppress: Boolean = false): CreateLobbyResponse? {
+ val charset = ('a'..'z') + ('A'..'Z') + ('0'..'9')
+ val password = (1..DEFAULT_RANDOM_PASSWORD_LENGTH)
+ .map { charset.random() }
+ .joinToString("")
+ return open(CreateLobbyRequest(name, password, maxPlayers), suppress)
+ }
+
+ /**
+ * Endpoint implementation to create a new lobby
+ */
+ private suspend fun open(req: CreateLobbyRequest, suppress: Boolean): CreateLobbyResponse? {
+ return request(
+ HttpMethod.Post, "api/v2/lobbies",
+ client, authHelper,
+ suppress = suppress,
+ refine = { b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(req)
+ },
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ }
+
+ /**
+ * Kick a player from an open lobby (as the lobby owner)
+ *
+ * All players in the lobby as well as the kicked player will receive a [LobbyKickMessage] on success.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun kick(lobbyUUID: UUID, playerUUID: UUID, suppress: Boolean = false): Boolean {
+ val response = request(
+ HttpMethod.Delete, "api/v2/lobbies/$lobbyUUID/$playerUUID",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )
+ return response?.status?.isSuccess() == true
+ }
+
+ /**
+ * Close an open lobby (as the lobby owner)
+ *
+ * On success, all joined players will receive a [LobbyClosedMessage] via WebSocket.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun close(lobbyUUID: UUID, suppress: Boolean = false): Boolean {
+ val response = request(
+ HttpMethod.Delete, "api/v2/lobbies/$lobbyUUID",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )
+ return response?.status?.isSuccess() == true
+ }
+
+ /**
+ * Join an existing lobby
+ *
+ * The executing user must not be the owner of a lobby or member of a lobby.
+ * To be placed in a lobby, an active WebSocket connection is required.
+ * As a lobby might be protected by password, the optional parameter password
+ * may be specified. On success, all players that were in the lobby before,
+ * are notified about the new player with a [LobbyJoinMessage].
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun join(lobbyUUID: UUID, password: String? = null, suppress: Boolean = false): Boolean {
+ return request(
+ HttpMethod.Post, "api/v2/lobbies/$lobbyUUID/join",
+ client, authHelper,
+ suppress = suppress,
+ refine = { b ->
+ b.contentType(ContentType.Application.Json)
+ b.setBody(JoinLobbyRequest(password = password))
+ },
+ retry = getDefaultRetry(client, authHelper)
+ )?.status?.isSuccess() == true
+ }
+
+ /**
+ * Leave an open lobby
+ *
+ * This endpoint can only be used by joined users.
+ * All players in the lobby will receive a [LobbyLeaveMessage] on success.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns false, otherwise true or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun leave(lobbyUUID: UUID, suppress: Boolean = false): Boolean {
+ return request(
+ HttpMethod.Post, "api/v2/lobbies/$lobbyUUID/leave",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )?.status?.isSuccess() == true
+ }
+
+ /**
+ * Start a game from an existing lobby
+ *
+ * The executing user must be the owner of the lobby. The lobby is deleted in the
+ * process, a new chatroom is created and all messages from the lobby chatroom are
+ * attached to the game chatroom. This will invoke a [GameStartedMessage] that is sent
+ * to all members of the lobby to inform them which lobby was started. It also contains
+ * the the new and old chatroom [UUID]s to make mapping for the clients easier. Afterwards,
+ * the lobby owner must use the [GameApi.upload] to upload the initial game state.
+ *
+ * Note: This behaviour is subject to change. The server should be set the order in
+ * which players are allowed to make their turns. This allows the server to detect
+ * malicious players trying to update the game state before its their turn.
+ *
+ * Use [suppress] to forbid throwing *any* errors (returns null, otherwise [StartGameResponse] or an error).
+ *
+ * @throws ApiException: thrown for defined and recognized API problems
+ * @throws UncivNetworkException: thrown for any kind of network error or de-serialization problems
+ */
+ suspend fun startGame(lobbyUUID: UUID, suppress: Boolean = false): StartGameResponse? {
+ return request(
+ HttpMethod.Post, "api/v2/lobbies/$lobbyUUID/start",
+ client, authHelper,
+ suppress = suppress,
+ retry = getDefaultRetry(client, authHelper)
+ )?.body()
+ }
+
+}
diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt
new file mode 100644
index 0000000000..013ec6d3b0
--- /dev/null
+++ b/core/src/com/unciv/logic/multiplayer/apiv2/JsonSerializers.kt
@@ -0,0 +1,118 @@
+package com.unciv.logic.multiplayer.apiv2
+
+import kotlinx.serialization.KSerializer
+import kotlinx.serialization.descriptors.PrimitiveKind
+import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
+import kotlinx.serialization.descriptors.SerialDescriptor
+import kotlinx.serialization.encoding.Decoder
+import kotlinx.serialization.encoding.Encoder
+import kotlinx.serialization.json.JsonContentPolymorphicSerializer
+import kotlinx.serialization.json.JsonElement
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+import java.time.Instant
+import java.util.UUID
+
+/**
+ * Serializer for the ApiStatusCode enum to make encoding/decoding as integer work
+ */
+internal class ApiStatusCodeSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("ApiStatusCode", PrimitiveKind.INT)
+
+ override fun serialize(encoder: Encoder, value: ApiStatusCode) {
+ encoder.encodeInt(value.value)
+ }
+
+ override fun deserialize(decoder: Decoder): ApiStatusCode {
+ return ApiStatusCode.getByValue(decoder.decodeInt())
+ }
+}
+
+/**
+ * Serializer for instants (date times) from/to strings in ISO 8601 format
+ */
+internal class InstantSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("Instant", PrimitiveKind.STRING)
+
+ override fun serialize(encoder: Encoder, value: Instant) {
+ encoder.encodeString(value.toString())
+ }
+
+ override fun deserialize(decoder: Decoder): Instant {
+ return Instant.parse(decoder.decodeString())
+ }
+}
+
+/**
+ * Serializer for UUIDs from/to strings
+ */
+internal class UUIDSerializer : KSerializer {
+ override val descriptor = PrimitiveSerialDescriptor("UUID", PrimitiveKind.STRING)
+
+ override fun serialize(encoder: Encoder, value: UUID) {
+ encoder.encodeString(value.toString())
+ }
+
+ override fun deserialize(decoder: Decoder): UUID {
+ return UUID.fromString(decoder.decodeString())
+ }
+}
+
+/**
+ * Serializer for incoming and outgoing WebSocket messages that also differentiate by type
+ */
+internal class WebSocketMessageSerializer : JsonContentPolymorphicSerializer(WebSocketMessage::class) {
+ override fun selectDeserializer(element: JsonElement) = when {
+ // Text frames in JSON format but without 'type' field are invalid
+ "type" !in element.jsonObject -> InvalidMessage.serializer()
+ else -> {
+ // This mapping of the enum enforces to specify all serializer types at compile time
+ when (WebSocketMessageType.getByValue(element.jsonObject["type"]!!.jsonPrimitive.content)) {
+ WebSocketMessageType.InvalidMessage -> InvalidMessage.serializer()
+ WebSocketMessageType.GameStarted -> GameStartedMessage.serializer()
+ WebSocketMessageType.UpdateGameData -> UpdateGameDataMessage.serializer()
+ WebSocketMessageType.ClientDisconnected -> ClientDisconnectedMessage.serializer()
+ WebSocketMessageType.ClientReconnected -> ClientReconnectedMessage.serializer()
+ WebSocketMessageType.IncomingChatMessage -> IncomingChatMessageMessage.serializer()
+ WebSocketMessageType.IncomingInvite -> IncomingInviteMessage.serializer()
+ WebSocketMessageType.IncomingFriendRequest -> IncomingFriendRequestMessage.serializer()
+ WebSocketMessageType.FriendshipChanged -> FriendshipChangedMessage.serializer()
+ WebSocketMessageType.LobbyJoin -> LobbyJoinMessage.serializer()
+ WebSocketMessageType.LobbyClosed -> LobbyClosedMessage.serializer()
+ WebSocketMessageType.LobbyLeave -> LobbyLeaveMessage.serializer()
+ WebSocketMessageType.LobbyKick -> LobbyKickMessage.serializer()
+ WebSocketMessageType.AccountUpdated -> AccountUpdatedMessage.serializer()
+ }
+ }
+ }
+}
+
+/**
+ * Serializer for the WebSocket message type enum to make encoding/decoding as string work
+ */
+internal class WebSocketMessageTypeSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("WebSocketMessageType", PrimitiveKind.STRING)
+
+ override fun serialize(encoder: Encoder, value: WebSocketMessageType) {
+ encoder.encodeString(value.type)
+ }
+
+ override fun deserialize(decoder: Decoder): WebSocketMessageType {
+ return WebSocketMessageType.getByValue(decoder.decodeString())
+ }
+}
+
+/**
+ * Serializer for the FriendshipEvent WebSocket message enum to make encoding/decoding as string work
+ */
+internal class FriendshipEventSerializer : KSerializer {
+ override val descriptor: SerialDescriptor = PrimitiveSerialDescriptor("FriendshipEventSerializer", PrimitiveKind.STRING)
+
+ override fun serialize(encoder: Encoder, value: FriendshipEvent) {
+ encoder.encodeString(value.type)
+ }
+
+ override fun deserialize(decoder: Decoder): FriendshipEvent {
+ return FriendshipEvent.getByValue(decoder.decodeString())
+ }
+}
diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt
new file mode 100644
index 0000000000..24154d80d4
--- /dev/null
+++ b/core/src/com/unciv/logic/multiplayer/apiv2/RequestStructs.kt
@@ -0,0 +1,124 @@
+/**
+ * Collection of API request structs in a single file for simplicity
+ */
+
+package com.unciv.logic.multiplayer.apiv2
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.SerialName
+import java.util.UUID
+
+/**
+ * The content to register a new account
+ */
+@Serializable
+data class AccountRegistrationRequest(
+ val username: String,
+ @SerialName("display_name")
+ val displayName: String,
+ val password: String
+)
+
+/**
+ * The request of a new friendship
+ */
+@Serializable
+data class CreateFriendRequest(
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID
+)
+
+/**
+ * The request to invite a friend into a lobby
+ */
+@Serializable
+data class CreateInviteRequest(
+ @SerialName("friend_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val friendUUID: UUID,
+ @SerialName("lobby_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val lobbyUUID: UUID
+)
+
+/**
+ * The parameters to create a lobby
+ *
+ * The parameter [maxPlayers] must be greater or equals 2.
+ */
+@Serializable
+data class CreateLobbyRequest(
+ val name: String,
+ val password: String?,
+ @SerialName("max_players")
+ val maxPlayers: Int
+)
+
+/**
+ * The request a user sends to the server to upload a new game state (non-WebSocket API)
+ *
+ * The game's UUID has to be set via the path argument of the endpoint.
+ */
+@Serializable
+data class GameUploadRequest(
+ @SerialName("game_data")
+ val gameData: String
+)
+
+/**
+ * The request to join a lobby
+ */
+@Serializable
+data class JoinLobbyRequest(
+ val password: String? = null
+)
+
+/**
+ * The request data of a login request
+ */
+@Serializable
+data class LoginRequest(
+ val username: String,
+ val password: String
+)
+
+/**
+ * The request to lookup an account by its username
+ */
+@Serializable
+data class LookupAccountUsernameRequest(
+ val username: String
+)
+
+/**
+ * The request for sending a message to a chatroom
+ */
+@Serializable
+data class SendMessageRequest(
+ val message: String
+)
+
+/**
+ * The set password request data
+ *
+ * The parameter [newPassword] must not be empty.
+ */
+@Serializable
+data class SetPasswordRequest(
+ @SerialName("old_password")
+ val oldPassword: String,
+ @SerialName("new_password")
+ val newPassword: String
+)
+
+/**
+ * Update account request data
+ *
+ * All parameter are optional, but at least one of them is required.
+ */
+@Serializable
+data class UpdateAccountRequest(
+ val username: String?,
+ @SerialName("display_name")
+ val displayName: String?
+)
diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt
new file mode 100644
index 0000000000..751b34abb6
--- /dev/null
+++ b/core/src/com/unciv/logic/multiplayer/apiv2/ResponseStructs.kt
@@ -0,0 +1,391 @@
+/**
+ * Collection of API response structs in a single file for simplicity
+ */
+
+package com.unciv.logic.multiplayer.apiv2
+
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import java.time.Instant
+import java.util.UUID
+
+/**
+ * The account data
+ */
+@Serializable
+data class AccountResponse(
+ val username: String,
+ @SerialName("display_name")
+ val displayName: String,
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID
+)
+
+/**
+ * The Response that is returned in case of an error
+ *
+ * For client errors the HTTP status code will be 400, for server errors the 500 will be used.
+ */
+@Serializable
+data class ApiErrorResponse(
+ val message: String,
+ @SerialName("status_code")
+ @Serializable(with = ApiStatusCodeSerializer::class)
+ val statusCode: ApiStatusCode
+) {
+
+ /**
+ * Convert the [ApiErrorResponse] to a [ApiException] for throwing and showing to users
+ */
+ fun to() = ApiException(this)
+}
+
+/**
+ * API status code enum for mapping integer codes to names
+ *
+ * The status code represents a unique identifier for an error.
+ * Error codes in the range of 1000..2000 represent client errors that could be handled
+ * by the client. Error codes in the range of 2000..3000 represent server errors.
+ */
+@Serializable(with = ApiStatusCodeSerializer::class)
+enum class ApiStatusCode(val value: Int) {
+ Unauthenticated(1000),
+ NotFound(1001),
+ InvalidContentType(1002),
+ InvalidJson(1003),
+ PayloadOverflow(1004),
+
+ LoginFailed(1005),
+ UsernameAlreadyOccupied(1006),
+ InvalidPassword(1007),
+ EmptyJson(1008),
+ InvalidUsername(1009),
+ InvalidDisplayName(1010),
+ FriendshipAlreadyRequested(1011),
+ AlreadyFriends(1012),
+ MissingPrivileges(1013),
+ InvalidMaxPlayersCount(1014),
+ AlreadyInALobby(1015),
+ InvalidUuid(1016),
+ InvalidLobbyUuid(1017),
+ InvalidFriendUuid(1018),
+ GameNotFound(1019),
+ InvalidMessage(1020),
+ WsNotConnected(1021),
+ LobbyFull(1022),
+ InvalidPlayerUUID(1023),
+
+ InternalServerError(2000),
+ DatabaseError(2001),
+ SessionError(2002);
+
+ companion object {
+ private val VALUES = values()
+ fun getByValue(value: Int) = VALUES.first { it.value == value }
+ }
+}
+
+/**
+ * A member of a chatroom
+ */
+@Serializable
+data class ChatMember(
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID,
+ val username: String,
+ @SerialName("display_name")
+ val displayName: String,
+ @SerialName("joined_at")
+ @Serializable(with = InstantSerializer::class)
+ val joinedAt: Instant
+)
+
+/**
+ * The message of a chatroom
+ *
+ * The parameter [uuid] should be used to uniquely identify a message.
+ */
+@Serializable
+data class ChatMessage(
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID,
+ val sender: AccountResponse,
+ val message: String,
+ @SerialName("created_at")
+ @Serializable(with = InstantSerializer::class)
+ val createdAt: Instant
+)
+
+/**
+ * The small representation of a chatroom
+ */
+@Serializable
+data class ChatSmall(
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID,
+ @SerialName("last_message_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val lastMessageUUID: UUID? = null
+)
+
+/**
+ * The response of a create lobby request, which contains the [lobbyUUID] and [lobbyChatRoomUUID]
+ */
+@Serializable
+data class CreateLobbyResponse(
+ @SerialName("lobby_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val lobbyUUID: UUID,
+ @SerialName("lobby_chat_room_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val lobbyChatRoomUUID: UUID
+)
+
+/**
+ * A single friend (the relationship is identified by the [uuid])
+ */
+@Serializable
+data class FriendResponse(
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID,
+ @SerialName("chat_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val chatUUID: UUID,
+ val friend: OnlineAccountResponse
+)
+
+/**
+ * A single friend request
+ *
+ * Use [from] and [to] comparing with "myself" to determine if it's incoming or outgoing.
+ */
+@Serializable
+data class FriendRequestResponse(
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID,
+ val from: AccountResponse,
+ val to: AccountResponse
+)
+
+/**
+ * A shortened game state identified by its ID and state identifier
+ *
+ * If the state ([gameDataID]) of a known game differs from the last known
+ * identifier, the server has a newer state of the game. The [lastActivity]
+ * field is a convenience attribute and shouldn't be used for update checks.
+ */
+@Serializable
+data class GameOverviewResponse(
+ @SerialName("chat_room_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val chatRoomUUID: UUID,
+ @SerialName("game_data_id")
+ val gameDataID: Long,
+ @SerialName("game_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val gameUUID: UUID,
+ @SerialName("last_activity")
+ @Serializable(with = InstantSerializer::class)
+ val lastActivity: Instant,
+ @SerialName("last_player")
+ val lastPlayer: AccountResponse,
+ @SerialName("max_players")
+ val maxPlayers: Int,
+ val name: String
+)
+
+/**
+ * A single game state identified by its ID and state identifier; see [gameData]
+ *
+ * If the state ([gameDataID]) of a known game differs from the last known
+ * identifier, the server has a newer state of the game. The [lastActivity]
+ * field is a convenience attribute and shouldn't be used for update checks.
+ */
+@Serializable
+data class GameStateResponse(
+ @SerialName("chat_room_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val chatRoomUUID: UUID,
+ @SerialName("game_data")
+ val gameData: String,
+ @SerialName("game_data_id")
+ val gameDataID: Long,
+ @SerialName("last_activity")
+ @Serializable(with = InstantSerializer::class)
+ val lastActivity: Instant,
+ @SerialName("last_player")
+ val lastPlayer: AccountResponse,
+ @SerialName("max_players")
+ val maxPlayers: Int,
+ val name: String
+)
+
+/**
+ * The response a user receives after uploading a new game state successfully
+ */
+@Serializable
+data class GameUploadResponse(
+ @SerialName("game_data_id")
+ val gameDataID: Long
+)
+
+/**
+ * All chat rooms your user has access to
+ */
+@Serializable
+data class GetAllChatsResponse(
+ @SerialName("friend_chat_rooms")
+ val friendChatRooms: List,
+ @SerialName("game_chat_rooms")
+ val gameChatRooms: List,
+ @SerialName("lobby_chat_rooms")
+ val lobbyChatRooms: List
+)
+
+/**
+ * The response to a get chat
+ *
+ * [messages] should be sorted by the datetime of message.created_at.
+ */
+@Serializable
+data class GetChatResponse(
+ val members: List,
+ val messages: List
+)
+
+/**
+ * A list of your friends and friend requests
+ *
+ * [friends] is a list of already established friendships
+ * [friendRequests] is a list of friend requests (incoming and outgoing)
+ */
+@Serializable
+data class GetFriendResponse(
+ val friends: List,
+ @SerialName("friend_requests")
+ val friendRequests: List
+)
+
+/**
+ * An overview of games a player participates in
+ */
+@Serializable
+data class GetGameOverviewResponse(
+ val games: List
+)
+
+/**
+ * A single invite
+ */
+@Serializable
+data class GetInvite(
+ @SerialName("created_at")
+ @Serializable(with = InstantSerializer::class)
+ val createdAt: Instant,
+ val from: AccountResponse,
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID,
+ @SerialName("lobby_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val lobbyUUID: UUID
+)
+
+/**
+ * The invites that an account has received
+ */
+@Serializable
+data class GetInvitesResponse(
+ val invites: List
+)
+
+/**
+ * The lobbies that are open
+ */
+@Serializable
+data class GetLobbiesResponse(
+ val lobbies: List
+)
+
+/**
+ * A single lobby (in contrast to [LobbyResponse], this is fetched by its own)
+ */
+@Serializable
+data class GetLobbyResponse(
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID,
+ val name: String,
+ @SerialName("max_players")
+ val maxPlayers: Int,
+ @SerialName("current_players")
+ val currentPlayers: List,
+ @SerialName("chat_room_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val chatRoomUUID: UUID,
+ @SerialName("created_at")
+ @Serializable(with = InstantSerializer::class)
+ val createdAt: Instant,
+ @SerialName("password")
+ val hasPassword: Boolean,
+ val owner: AccountResponse
+)
+
+/**
+ * A single lobby
+ */
+@Serializable
+data class LobbyResponse(
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID,
+ val name: String,
+ @SerialName("max_players")
+ val maxPlayers: Int,
+ @SerialName("current_players")
+ val currentPlayers: Int,
+ @SerialName("chat_room_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val chatRoomUUID: UUID,
+ @SerialName("created_at")
+ @Serializable(with = InstantSerializer::class)
+ val createdAt: Instant,
+ @SerialName("password")
+ val hasPassword: Boolean,
+ val owner: AccountResponse
+)
+
+/**
+ * The account data
+ *
+ * It provides the extra field [online] indicating whether the account has any connected client.
+ */
+@Serializable
+data class OnlineAccountResponse(
+ val online: Boolean,
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID,
+ val username: String,
+ @SerialName("display_name")
+ val displayName: String
+) {
+ fun to() = AccountResponse(uuid = uuid, username = username, displayName = displayName)
+}
+
+/**
+ * The response when starting a game
+ */
+@Serializable
+data class StartGameResponse(
+ @SerialName("game_chat_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val gameChatUUID: UUID,
+ @SerialName("game_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val gameUUID: UUID
+)
+
+/**
+ * The version data for clients
+ */
+@Serializable
+data class VersionResponse(
+ val version: Int
+)
diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/UncivNetworkException.kt b/core/src/com/unciv/logic/multiplayer/apiv2/UncivNetworkException.kt
new file mode 100644
index 0000000000..01019d6f32
--- /dev/null
+++ b/core/src/com/unciv/logic/multiplayer/apiv2/UncivNetworkException.kt
@@ -0,0 +1,11 @@
+package com.unciv.logic.multiplayer.apiv2
+
+import com.unciv.logic.UncivShowableException
+
+/**
+ * Subclass of [UncivShowableException] indicating network errors (timeout, connection refused and so on)
+ */
+class UncivNetworkException : UncivShowableException {
+ constructor(cause: Throwable) : super("An unexpected network error occurred.", cause)
+ constructor(text: String, cause: Throwable?) : super(text, cause)
+}
diff --git a/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt
new file mode 100644
index 0000000000..0dd38726ea
--- /dev/null
+++ b/core/src/com/unciv/logic/multiplayer/apiv2/WebSocketStructs.kt
@@ -0,0 +1,344 @@
+package com.unciv.logic.multiplayer.apiv2
+
+import com.unciv.logic.event.Event
+import kotlinx.serialization.SerialName
+import kotlinx.serialization.Serializable
+import java.util.UUID
+
+/**
+ * Enum of all events that can happen in a friendship
+ */
+@Serializable(with = FriendshipEventSerializer::class)
+enum class FriendshipEvent(val type: String) {
+ Accepted("accepted"),
+ Rejected("rejected"),
+ Deleted("deleted");
+
+ companion object {
+ private val VALUES = FriendshipEvent.values()
+ fun getByValue(type: String) = VALUES.first { it.type == type }
+ }
+}
+
+/**
+ * The notification for the clients that a new game has started
+ */
+@Serializable
+data class GameStarted(
+ @SerialName("game_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val gameUUID: UUID,
+ @SerialName("game_chat_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val gameChatUUID: UUID,
+ @SerialName("lobby_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val lobbyUUID: UUID,
+ @SerialName("lobby_chat_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val lobbyChatUUID: UUID,
+) : Event
+
+/**
+ * An update of the game data
+ *
+ * This variant is sent from the server to all accounts that are in the game.
+ */
+@Serializable
+data class UpdateGameData(
+ @SerialName("game_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val gameUUID: UUID,
+ @SerialName("game_data")
+ val gameData: String, // base64-encoded, gzipped game state
+ /** A counter that is incremented every time a new game states has been uploaded for the same [gameUUID] via HTTP API. */
+ @SerialName("game_data_id")
+ val gameDataID: Long
+) : Event
+
+/**
+ * Notification for clients if a client in their game disconnected
+ */
+@Serializable
+data class ClientDisconnected(
+ @SerialName("game_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val gameUUID: UUID,
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID // client identifier
+) : Event
+
+/**
+ * Notification for clients if a client in their game reconnected
+ */
+@Serializable
+data class ClientReconnected(
+ @SerialName("game_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val gameUUID: UUID,
+ @Serializable(with = UUIDSerializer::class)
+ val uuid: UUID // client identifier
+) : Event
+
+/**
+ * A new chat message is sent to the client
+ */
+@Serializable
+data class IncomingChatMessage(
+ @SerialName("chat_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val chatUUID: UUID,
+ val message: ChatMessage
+) : Event
+
+/**
+ * An invite to a lobby is sent to the client
+ */
+@Serializable
+data class IncomingInvite(
+ @SerialName("invite_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val inviteUUID: UUID,
+ val from: AccountResponse,
+ @SerialName("lobby_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val lobbyUUID: UUID
+) : Event
+
+/**
+ * A friend request is sent to a client
+ */
+@Serializable
+data class IncomingFriendRequest(
+ val from: AccountResponse
+) : Event
+
+/**
+ * A friendship was modified
+ */
+@Serializable
+data class FriendshipChanged(
+ val friend: AccountResponse,
+ val event: FriendshipEvent
+) : Event
+
+/**
+ * A new player joined the lobby
+ */
+@Serializable
+data class LobbyJoin(
+ @SerialName("lobby_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val lobbyUUID: UUID,
+ val player: AccountResponse
+) : Event
+
+/**
+ * A lobby closed in which the client was part of
+ */
+@Serializable
+data class LobbyClosed(
+ @SerialName("lobby_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val lobbyUUID: UUID
+) : Event
+
+/**
+ * A player has left the lobby
+ */
+@Serializable
+data class LobbyLeave(
+ @SerialName("lobby_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val lobbyUUID: UUID,
+ val player: AccountResponse
+) : Event
+
+/**
+ * A player was kicked out of the lobby.
+ *
+ * Make sure to check the player if you were kicked ^^
+ */
+@Serializable
+data class LobbyKick(
+ @SerialName("lobby_uuid")
+ @Serializable(with = UUIDSerializer::class)
+ val lobbyUUID: UUID,
+ val player: AccountResponse
+) : Event
+
+/**
+ * The user account was updated
+ *
+ * This might be especially useful for reflecting changes in the username, etc. in the frontend
+ */
+@Serializable
+data class AccountUpdated(
+ val account: AccountResponse
+) : Event
+
+/**
+ * The base WebSocket message, encapsulating only the type of the message
+ */
+interface WebSocketMessage {
+ val type: WebSocketMessageType
+}
+
+/**
+ * The useful base WebSocket message, encapsulating only the type of the message and the content
+ */
+interface WebSocketMessageWithContent: WebSocketMessage {
+ override val type: WebSocketMessageType
+ val content: Event
+}
+
+/**
+ * Message when a previously sent WebSocket frame a received frame is invalid
+ */
+@Serializable
+data class InvalidMessage(
+ override val type: WebSocketMessageType,
+) : WebSocketMessage
+
+/**
+ * Message to indicate that a game started
+ */
+@Serializable
+data class GameStartedMessage (
+ override val type: WebSocketMessageType,
+ override val content: GameStarted
+) : WebSocketMessageWithContent
+
+/**
+ * Message to publish the new game state from the server to all clients
+ */
+@Serializable
+data class UpdateGameDataMessage (
+ override val type: WebSocketMessageType,
+ override val content: UpdateGameData
+) : WebSocketMessageWithContent
+
+/**
+ * Message to indicate that a client disconnected
+ */
+@Serializable
+data class ClientDisconnectedMessage (
+ override val type: WebSocketMessageType,
+ override val content: ClientDisconnected
+) : WebSocketMessageWithContent
+
+/**
+ * Message to indicate that a client, who previously disconnected, reconnected
+ */
+@Serializable
+data class ClientReconnectedMessage (
+ override val type: WebSocketMessageType,
+ override val content: ClientReconnected
+) : WebSocketMessageWithContent
+
+/**
+ * Message to indicate that a user received a new text message via the chat feature
+ */
+@Serializable
+data class IncomingChatMessageMessage (
+ override val type: WebSocketMessageType,
+ override val content: IncomingChatMessage
+) : WebSocketMessageWithContent
+
+/**
+ * Message to indicate that a client gets invited to a lobby
+ */
+@Serializable
+data class IncomingInviteMessage (
+ override val type: WebSocketMessageType,
+ override val content: IncomingInvite
+) : WebSocketMessageWithContent
+
+/**
+ * Message to indicate that a client received a friend request
+ */
+@Serializable
+data class IncomingFriendRequestMessage (
+ override val type: WebSocketMessageType,
+ override val content: IncomingFriendRequest
+) : WebSocketMessageWithContent
+
+/**
+ * Message to indicate that a friendship has changed
+ */
+@Serializable
+data class FriendshipChangedMessage (
+ override val type: WebSocketMessageType,
+ override val content: FriendshipChanged
+) : WebSocketMessageWithContent
+
+/**
+ * Message to indicate that a client joined the lobby
+ */
+@Serializable
+data class LobbyJoinMessage (
+ override val type: WebSocketMessageType,
+ override val content: LobbyJoin
+) : WebSocketMessageWithContent
+
+/**
+ * Message to indicate that the current lobby got closed
+ */
+@Serializable
+data class LobbyClosedMessage (
+ override val type: WebSocketMessageType,
+ override val content: LobbyClosed
+) : WebSocketMessageWithContent
+
+/**
+ * Message to indicate that a client left the lobby
+ */
+@Serializable
+data class LobbyLeaveMessage (
+ override val type: WebSocketMessageType,
+ override val content: LobbyLeave
+) : WebSocketMessageWithContent
+
+/**
+ * Message to indicate that a client got kicked out of the lobby
+ */
+@Serializable
+data class LobbyKickMessage (
+ override val type: WebSocketMessageType,
+ override val content: LobbyKick
+) : WebSocketMessageWithContent
+
+/**
+ * Message to indicate that the current user account's data have been changed
+ */
+@Serializable
+data class AccountUpdatedMessage (
+ override val type: WebSocketMessageType,
+ override val content: AccountUpdated
+) : WebSocketMessageWithContent
+
+/**
+ * Type enum of all known WebSocket messages
+ */
+@Serializable(with = WebSocketMessageTypeSerializer::class)
+enum class WebSocketMessageType(val type: String) {
+ InvalidMessage("invalidMessage"),
+ GameStarted("gameStarted"),
+ UpdateGameData("updateGameData"),
+ ClientDisconnected("clientDisconnected"),
+ ClientReconnected("clientReconnected"),
+ IncomingChatMessage("incomingChatMessage"),
+ IncomingInvite("incomingInvite"),
+ IncomingFriendRequest("incomingFriendRequest"),
+ FriendshipChanged("friendshipChanged"),
+ LobbyJoin("lobbyJoin"),
+ LobbyClosed("lobbyClosed"),
+ LobbyLeave("lobbyLeave"),
+ LobbyKick("lobbyKick"),
+ AccountUpdated("accountUpdated");
+
+ companion object {
+ private val VALUES = values()
+ fun getByValue(type: String) = VALUES.first { it.type == type }
+ }
+}
diff --git a/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt
new file mode 100644
index 0000000000..9fb57d10f1
--- /dev/null
+++ b/core/src/com/unciv/logic/multiplayer/storage/ApiV2FileStorageEmulator.kt
@@ -0,0 +1,104 @@
+package com.unciv.logic.multiplayer.storage
+
+import com.unciv.logic.files.UncivFiles
+import com.unciv.logic.multiplayer.apiv2.ApiV2
+import com.unciv.utils.Log
+import kotlinx.coroutines.runBlocking
+import java.util.UUID
+
+private const val PREVIEW_SUFFIX = "_Preview"
+
+/**
+ * Transition helper that emulates file storage behavior using the API v2
+ */
+class ApiV2FileStorageEmulator(private val api: ApiV2): FileStorage {
+
+ private suspend fun saveGameData(gameId: String, data: String) {
+ val uuid = UUID.fromString(gameId.lowercase())
+ api.game.upload(uuid, data)
+ }
+
+ @Suppress("UNUSED_PARAMETER")
+ private suspend fun savePreviewData(gameId: String, data: String) {
+ // Not implemented for this API
+ Log.debug("Call to deprecated API 'savePreviewData'")
+ }
+
+ override fun saveFileData(fileName: String, data: String) {
+ return runBlocking {
+ if (fileName.endsWith(PREVIEW_SUFFIX)) {
+ savePreviewData(fileName.dropLast(8), data)
+ } else {
+ saveGameData(fileName, data)
+ }
+ }
+ }
+
+ private suspend fun loadGameData(gameId: String): String {
+ val uuid = UUID.fromString(gameId.lowercase())
+ return api.game.get(uuid, cache = false)!!.gameData
+ }
+
+ private suspend fun loadPreviewData(gameId: String): String {
+ // Not implemented for this API
+ Log.debug("Call to deprecated API 'loadPreviewData'")
+ // TODO: This could be improved, since this consumes more resources than necessary
+ return UncivFiles.gameInfoToString(UncivFiles.gameInfoFromString(loadGameData(gameId)).asPreview())
+ }
+
+ override fun loadFileData(fileName: String): String {
+ return runBlocking {
+ if (fileName.endsWith(PREVIEW_SUFFIX)) {
+ loadPreviewData(fileName.dropLast(8))
+ } else {
+ loadGameData(fileName)
+ }
+ }
+ }
+
+ override fun getFileMetaData(fileName: String): FileMetaData {
+ TODO("Not yet implemented")
+ }
+
+ override fun deleteFile(fileName: String) {
+ return runBlocking {
+ if (fileName.endsWith(PREVIEW_SUFFIX)) {
+ deletePreviewData(fileName.dropLast(8))
+ } else {
+ deleteGameData(fileName)
+ }
+ }
+ }
+
+ @Suppress("UNUSED_PARAMETER")
+ private suspend fun deleteGameData(gameId: String) {
+ TODO("Not yet implemented")
+ }
+
+ private suspend fun deletePreviewData(gameId: String) {
+ // Not implemented for this API
+ Log.debug("Call to deprecated API 'deletedPreviewData'")
+ deleteGameData(gameId)
+ }
+
+ override fun authenticate(userId: String, password: String): Boolean {
+ return runBlocking { api.auth.loginOnly(userId, password) }
+ }
+
+ override fun setPassword(newPassword: String): Boolean {
+ return runBlocking { api.account.setPassword(newPassword, suppress = true) }
+ }
+
+}
+
+/**
+ * Workaround to "just get" the file storage handler and the API, but without initializing
+ *
+ * TODO: This wrapper should be replaced by better file storage initialization handling.
+ *
+ * This object keeps references which are populated during program startup at runtime.
+ */
+object ApiV2FileStorageWrapper {
+ var api: ApiV2? = null
+ var storage: ApiV2FileStorageEmulator? = null
+}