Update to Ktor 3 and more (#13782)

* upgrade ktor version to `3.2.3`

* Update EndpointImplementations.kt

* update `androidx.core:core-ktx` to `1.16.0`

* fix `Principal` deprecated message
This commit is contained in:
Md. Touhidur Rahman 2025-08-07 09:01:52 +06:00 committed by GitHub
parent e059d0dc4c
commit 99fa2cd964
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 50 additions and 55 deletions

View File

@ -147,11 +147,11 @@ tasks.register<Exec>("run") {
} }
dependencies { dependencies {
implementation("androidx.core:core-ktx:1.15.0") implementation("androidx.core:core-ktx:1.16.0")
implementation("androidx.work:work-runtime-ktx:2.10.0") implementation("androidx.work:work-runtime-ktx:2.10.3")
// Needed to convert e.g. Android 26 API calls to Android 21 // Needed to convert e.g. Android 26 API calls to Android 21
// If you remove this run `./gradlew :android:lintDebug` to ensure everything's okay. // If you remove this run `./gradlew :android:lintDebug` to ensure everything's okay.
// If you want to upgrade this, check it's working by building an apk, // If you want to upgrade this, check it's working by building an apk,
// or by running `./gradlew :android:assembleRelease` which does that // or by running `./gradlew :android:assembleRelease` which does that
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.4") coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
} }

View File

@ -8,7 +8,7 @@ object BuildConfig {
const val appVersion = "4.17.12" const val appVersion = "4.17.12"
const val gdxVersion = "1.13.1" const val gdxVersion = "1.13.1"
const val ktorVersion = "2.3.13" const val ktorVersion = "3.2.3"
const val coroutinesVersion = "1.8.1" const val coroutinesVersion = "1.8.1"
const val jnaVersion = "5.17.0" const val jnaVersion = "5.17.0"

View File

@ -11,14 +11,11 @@ import com.unciv.logic.multiplayer.storage.ApiV2FileStorageWrapper
import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException import com.unciv.logic.multiplayer.storage.MultiplayerFileNotFoundException
import com.unciv.utils.Concurrency import com.unciv.utils.Concurrency
import com.unciv.utils.Log import com.unciv.utils.Log
import io.ktor.client.call.body import io.ktor.client.call.*
import io.ktor.client.plugins.websocket.ClientWebSocketSession import io.ktor.client.plugins.websocket.*
import io.ktor.client.request.get import io.ktor.client.request.*
import io.ktor.http.isSuccess import io.ktor.http.*
import io.ktor.websocket.Frame import io.ktor.websocket.*
import io.ktor.websocket.FrameType
import io.ktor.websocket.readText
import io.ktor.websocket.close
import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
@ -35,7 +32,7 @@ import java.time.Instant
import java.util.Random import java.util.Random
import java.util.UUID import java.util.UUID
import java.util.concurrent.atomic.AtomicReference import java.util.concurrent.atomic.AtomicReference
import kotlin.collections.set import kotlin.time.Duration
/** /**
* Main class to interact with multiplayer servers implementing [ApiVersion.ApiV2] * Main class to interact with multiplayer servers implementing [ApiVersion.ApiV2]
@ -339,7 +336,7 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable {
* [delay] internally to quit waiting for the result of the operation. * [delay] internally to quit waiting for the result of the operation.
* This function may also throw arbitrary exceptions for network failures. * This function may also throw arbitrary exceptions for network failures.
*/ */
suspend fun awaitPing(size: Int = 2, timeout: Long? = null): Double? { suspend fun awaitPing(size: Int = 2, timeout: Duration? = null): Double? {
require(size < 2) { "Size too small to identify ping responses uniquely" } require(size < 2) { "Size too small to identify ping responses uniquely" }
val body = ByteArray(size) val body = ByteArray(size)
Random().nextBytes(body) Random().nextBytes(body)
@ -501,7 +498,10 @@ class ApiV2(private val baseUrl: String) : ApiV2Wrapper(baseUrl), Disposable {
* Note that this callback might not get called if no new WS connection was created. * 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. * 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? { suspend fun ensureConnectedWebSocket(
timeout: Duration = DEFAULT_WEBSOCKET_PING_TIMEOUT,
jobCallback: ((Job) -> Unit)? = null
): Double? {
val pingMeasurement = try { val pingMeasurement = try {
awaitPing(timeout = timeout) awaitPing(timeout = timeout)
} catch (e: Exception) { } catch (e: Exception) {

View File

@ -1,6 +1,7 @@
package com.unciv.logic.multiplayer.apiv2 package com.unciv.logic.multiplayer.apiv2
import java.time.Duration import java.time.Duration
import kotlin.time.Duration.Companion.seconds
/** Name of the session cookie returned and expected by the server */ /** Name of the session cookie returned and expected by the server */
internal const val SESSION_COOKIE_NAME = "id" internal const val SESSION_COOKIE_NAME = "id"
@ -9,13 +10,13 @@ internal const val SESSION_COOKIE_NAME = "id"
internal const val DEFAULT_LOBBY_MAX_PLAYERS = 32 internal const val DEFAULT_LOBBY_MAX_PLAYERS = 32
/** Default ping frequency for outgoing WebSocket connection in seconds */ /** Default ping frequency for outgoing WebSocket connection in seconds */
internal const val DEFAULT_WEBSOCKET_PING_FREQUENCY = 15_000L internal val DEFAULT_WEBSOCKET_PING_FREQUENCY = 15.seconds
/** Default session timeout expected from multiplayer servers (unreliable) */ /** Default session timeout expected from multiplayer servers (unreliable) */
internal val DEFAULT_SESSION_TIMEOUT = Duration.ofSeconds(15 * 60) internal val DEFAULT_SESSION_TIMEOUT = Duration.ofMinutes(15)
/** Default cache expiry timeout to indicate that certain data needs to be re-fetched */ /** Default cache expiry timeout to indicate that certain data needs to be re-fetched */
internal val DEFAULT_CACHE_EXPIRY = Duration.ofSeconds(30 * 60) internal val DEFAULT_CACHE_EXPIRY = Duration.ofMinutes(30)
/** Default timeout for a single request (miliseconds) */ /** Default timeout for a single request (miliseconds) */
internal const val DEFAULT_REQUEST_TIMEOUT = 10_000L internal const val DEFAULT_REQUEST_TIMEOUT = 10_000L
@ -24,4 +25,4 @@ internal const val DEFAULT_REQUEST_TIMEOUT = 10_000L
internal const val DEFAULT_CONNECT_TIMEOUT = 5_000L internal const val DEFAULT_CONNECT_TIMEOUT = 5_000L
/** Default timeout for a single WebSocket PING-PONG roundtrip */ /** Default timeout for a single WebSocket PING-PONG roundtrip */
internal const val DEFAULT_WEBSOCKET_PING_TIMEOUT = 10_000L internal val DEFAULT_WEBSOCKET_PING_TIMEOUT = 10.seconds

View File

@ -7,24 +7,17 @@
package com.unciv.logic.multiplayer.apiv2 package com.unciv.logic.multiplayer.apiv2
import com.unciv.utils.Log import com.unciv.utils.Log
import io.ktor.client.HttpClient import io.ktor.client.*
import io.ktor.client.call.body import io.ktor.client.call.*
import io.ktor.client.plugins.cookies.get import io.ktor.client.plugins.cookies.*
import io.ktor.client.request.HttpRequestBuilder import io.ktor.client.request.*
import io.ktor.client.request.request import io.ktor.client.request.request
import io.ktor.client.request.setBody import io.ktor.client.statement.*
import io.ktor.client.statement.HttpResponse
import io.ktor.client.statement.bodyAsText
import io.ktor.client.statement.request import io.ktor.client.statement.request
import io.ktor.http.ContentType import io.ktor.http.*
import io.ktor.http.HttpMethod import io.ktor.util.network.*
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.io.IOException
import java.time.Duration
import java.time.Instant import java.time.Instant
import java.util.UUID import java.util.UUID
@ -46,7 +39,7 @@ private const val DEFAULT_RANDOM_PASSWORD_LENGTH = 32
/** /**
* Max age of a cached entry before it will be re-queried * Max age of a cached entry before it will be re-queried
*/ */
private const val MAX_CACHE_AGE_SECONDS = 60L private val MAX_CACHE_AGE = Duration.ofSeconds(60)
/** /**
* Perform a HTTP request via [method] to [endpoint] * Perform a HTTP request via [method] to [endpoint]
@ -218,7 +211,7 @@ private object Cache {
} }
/** /**
* Wrapper around [request] to cache responses to GET queries up to [MAX_CACHE_AGE_SECONDS] * Wrapper around [request] to cache responses to GET queries up to [MAX_CACHE_AGE]
*/ */
suspend fun get( suspend fun get(
endpoint: String, endpoint: String,
@ -230,7 +223,7 @@ private object Cache {
retry: (suspend () -> Boolean)? = null retry: (suspend () -> Boolean)? = null
): HttpResponse? { ): HttpResponse? {
val result = responseCache[endpoint] val result = responseCache[endpoint]
if (cache && result != null && result.first.plusSeconds(MAX_CACHE_AGE_SECONDS).isAfter(Instant.now())) { if (cache && result != null && (result.first + MAX_CACHE_AGE).isAfter(Instant.now())) {
return result.second return result.second
} }
val response = request(HttpMethod.Get, endpoint, client, authHelper, refine, suppress, retry) val response = request(HttpMethod.Get, endpoint, client, authHelper, refine, suppress, retry)

View File

@ -26,6 +26,8 @@ import kotlinx.serialization.json.ClassDiscriminatorMode
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Clock import kotlin.time.Clock
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.Duration.Companion.seconds
import kotlin.time.DurationUnit import kotlin.time.DurationUnit
import kotlin.time.ExperimentalTime import kotlin.time.ExperimentalTime
@ -77,17 +79,17 @@ class ChatRestartException : CancellationException("Chat restart requested")
class ChatStopException : CancellationException("Chat stop requested") class ChatStopException : CancellationException("Chat stop requested")
object ChatWebSocket { object ChatWebSocket {
private const val MAX_RECONNECTION_ATTEMPTS = 100
private val INITIAL_RECONNECT_TIME = 1.seconds
private val MAX_RECONNECT_TIME = 64.seconds
private val INITIAL_SESSION_WAIT_FOR_TIME = 5.seconds
private var isStarted = false private var isStarted = false
@OptIn(ExperimentalTime::class) @OptIn(ExperimentalTime::class)
private var lastRetry = Clock.System.now() private var lastRetry = Clock.System.now()
private var reconnectionAttempts = 0 private var reconnectionAttempts = 0
private var reconnectTimeSeconds = INITIAL_RECONNECT_TIME_SECONDS private var reconnectTime = INITIAL_RECONNECT_TIME
private const val INITIAL_RECONNECT_TIME_SECONDS = 1
private const val MAX_RECONNECTION_ATTEMPTS = 100
private const val MAX_RECONNECT_TIME_SECONDS = 64
private const val INITIAL_SESSION_WAIT_FOR_MS = 5_000L
private var job: Job? = null private var job: Job? = null
private var session: DefaultClientWebSocketSession? = null private var session: DefaultClientWebSocketSession? = null
@ -95,7 +97,7 @@ object ChatWebSocket {
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
private val client = HttpClient(CIO) { private val client = HttpClient(CIO) {
install(WebSockets) { install(WebSockets) {
pingInterval = 30_000 pingInterval = 30.seconds
contentConverter = KotlinxWebsocketSerializationConverter(Json { contentConverter = KotlinxWebsocketSerializationConverter(Json {
classDiscriminator = "type" classDiscriminator = "type"
// DO NOT OMIT // DO NOT OMIT
@ -110,7 +112,7 @@ object ChatWebSocket {
lastRetry = Clock.System.now() lastRetry = Clock.System.now()
reconnectionAttempts = 0 reconnectionAttempts = 0
reconnectionAttempts = INITIAL_RECONNECT_TIME_SECONDS reconnectTime = INITIAL_RECONNECT_TIME
} }
private fun getChatUrl(): Url = URLBuilder( private fun getChatUrl(): Url = URLBuilder(
@ -132,9 +134,9 @@ object ChatWebSocket {
fun requestMessageSend(message: Message) { fun requestMessageSend(message: Message) {
start() start()
Concurrency.run("MultiplayerChatSendMessage") { Concurrency.run("MultiplayerChatSendMessage") {
withTimeoutOrNull(INITIAL_SESSION_WAIT_FOR_MS) { withTimeoutOrNull(INITIAL_SESSION_WAIT_FOR_TIME) {
while (session == null) { while (session == null) {
delay(100) delay(100.milliseconds)
} }
} }
session?.runCatching { session?.runCatching {
@ -241,9 +243,8 @@ object ChatWebSocket {
GlobalScope.launch { GlobalScope.launch {
if (!force) { if (!force) {
// exponential backoff same as described here: https://cloud.google.com/memorystore/docs/redis/exponential-backoff // exponential backoff same as described here: https://cloud.google.com/memorystore/docs/redis/exponential-backoff
delay(Random.nextLong(1000) + 1000L * reconnectTimeSeconds) delay(Random.nextLong(1000).milliseconds + reconnectTime)
reconnectTimeSeconds = reconnectTime = (reconnectTime * 2).coerceAtMost(MAX_RECONNECT_TIME)
(reconnectTimeSeconds * 2).coerceAtMost(MAX_RECONNECT_TIME_SECONDS)
if (job?.isActive == true) return@launch if (job?.isActive == true) return@launch
} }

View File

@ -33,6 +33,7 @@ import java.io.File
import java.util.Collections.synchronizedMap import java.util.Collections.synchronizedMap
import java.util.Collections.synchronizedSet import java.util.Collections.synchronizedSet
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import kotlin.time.Duration.Companion.seconds
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid import kotlin.uuid.Uuid
@ -140,7 +141,7 @@ private class WebSocketSessionManager {
data class BasicAuthInfo( data class BasicAuthInfo(
val userId: Uuid, val userId: Uuid,
val password: String, val password: String,
) : Principal )
/** /**
* Checks if a [String] is a valid UUID * Checks if a [String] is a valid UUID
@ -264,8 +265,8 @@ private class UncivServerRunner : CliktCommand() {
} }
if (chatV1Enabled) install(WebSockets) { if (chatV1Enabled) install(WebSockets) {
pingPeriodMillis = 30_000 pingPeriod = 30.seconds
timeoutMillis = 60_000 timeout = 60.seconds
maxFrameSize = Long.MAX_VALUE maxFrameSize = Long.MAX_VALUE
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
contentConverter = KotlinxWebsocketSerializationConverter(Json { contentConverter = KotlinxWebsocketSerializationConverter(Json {
@ -386,8 +387,7 @@ private class UncivServerRunner : CliktCommand() {
try { try {
while (isActive) { while (isActive) {
val message = receiveDeserialized<Message>() when (val message = receiveDeserialized<Message>()) {
when (message) {
is Message.Chat -> { is Message.Chat -> {
val gameId = message.gameId.toUuidOrNull() val gameId = message.gameId.toUuidOrNull()
if (gameId == null) { if (gameId == null) {