From 3ff704c35dfb323e50fcfc339dce238b2a2b35b9 Mon Sep 17 00:00:00 2001 From: Lossy Date: Sat, 13 Jun 2026 03:34:56 -0500 Subject: [PATCH 01/27] add ipv6 tests --- .../in/dragonbra/javasteam/util/NetHelpers.kt | 1 - .../javasteam/util/NetHelpersTest.java | 20 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/src/main/java/in/dragonbra/javasteam/util/NetHelpers.kt b/src/main/java/in/dragonbra/javasteam/util/NetHelpers.kt index caa8894b..26bdc840 100644 --- a/src/main/java/in/dragonbra/javasteam/util/NetHelpers.kt +++ b/src/main/java/in/dragonbra/javasteam/util/NetHelpers.kt @@ -81,7 +81,6 @@ object NetHelpers { val localIp = msgIpAddress.toBuilder() if (localIp.hasV6()) { - // TODO v6 validation val v6Bytes = msgIpAddress.v6.toByteArray() for (i in 0..15 step 4) { v6Bytes[i] = (v6Bytes[i].toInt() xor 0x0D).toByte() diff --git a/src/test/java/in/dragonbra/javasteam/util/NetHelpersTest.java b/src/test/java/in/dragonbra/javasteam/util/NetHelpersTest.java index 00b4a5af..ea8950df 100644 --- a/src/test/java/in/dragonbra/javasteam/util/NetHelpersTest.java +++ b/src/test/java/in/dragonbra/javasteam/util/NetHelpersTest.java @@ -76,6 +76,26 @@ public void obfuscatePrivateIP() { Assertions.assertArrayEquals(ipv6Bytes, NetHelpers.obfuscatePrivateIP(v6Addr).getV6().toByteArray()); } + @Test + public void obfuscatePrivateIPv6_isOwnInverse() throws UnknownHostException { + var v6Addr = NetHelpers.getMsgIPAddress(InetAddress.getByName("2001:db8::1")); + var obfuscated = NetHelpers.obfuscatePrivateIP(v6Addr); + var restored = NetHelpers.obfuscatePrivateIP(obfuscated); + Assertions.assertArrayEquals(v6Addr.getV6().toByteArray(), restored.getV6().toByteArray()); + } + + @Test + public void obfuscatePrivateIPv6_allZerosProducesMask() throws UnknownHostException { + var v6Addr = NetHelpers.getMsgIPAddress(InetAddress.getByName("::")); + byte[] expected = { + (byte) 0x0D, (byte) 0xF0, (byte) 0xAD, (byte) 0xBA, + (byte) 0x0D, (byte) 0xF0, (byte) 0xAD, (byte) 0xBA, + (byte) 0x0D, (byte) 0xF0, (byte) 0xAD, (byte) 0xBA, + (byte) 0x0D, (byte) 0xF0, (byte) 0xAD, (byte) 0xBA + }; + Assertions.assertArrayEquals(expected, NetHelpers.obfuscatePrivateIP(v6Addr).getV6().toByteArray()); + } + @Test public void tryParseIPEndPoint() throws UnknownHostException { var v4Addr = NetHelpers.tryParseIPEndPoint("127.0.0.1:1337"); From c27b48e1f01ffeb3e6b2ec3f741f07a5980ac020 Mon Sep 17 00:00:00 2001 From: Lossy Date: Sat, 13 Jun 2026 03:35:19 -0500 Subject: [PATCH 02/27] Add missing kdoc --- .../steamcontent/GetPeerContentInfo.kt | 6 ++--- .../steamcontent/RequestPeerContentServer.kt | 8 +++---- .../handlers/steamcontent/SteamContent.kt | 24 +++++++++---------- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamcontent/GetPeerContentInfo.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamcontent/GetPeerContentInfo.kt index f96187e2..31da2628 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamcontent/GetPeerContentInfo.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamcontent/GetPeerContentInfo.kt @@ -3,9 +3,9 @@ package `in`.dragonbra.javasteam.steam.handlers.steamcontent import `in`.dragonbra.javasteam.util.JavaSteamAddition /** - * TODO kdoc - * @param appIds - * @param ipPublic + * Represents the response from [SteamContent.getPeerContentInfo]. + * @param appIds list of app IDs for which peer content is available on the remote client. + * @param ipPublic public IP address of the remote peer content server. */ @JavaSteamAddition data class GetPeerContentInfo( diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamcontent/RequestPeerContentServer.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamcontent/RequestPeerContentServer.kt index b0438d37..4042cadb 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamcontent/RequestPeerContentServer.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamcontent/RequestPeerContentServer.kt @@ -3,10 +3,10 @@ package `in`.dragonbra.javasteam.steam.handlers.steamcontent import `in`.dragonbra.javasteam.util.JavaSteamAddition /** - * TODO kdoc - * @param serverPort - * @param installedDepots - * @param accessToken + * Represents the response from [SteamContent.requestPeerContentServer]. + * @param serverPort The port the peer content server is listening on. + * @param installedDepots List of depot IDs installed on the remote client and available for transfer. + * @param accessToken Token used to authenticate with the peer content server. */ @JavaSteamAddition data class RequestPeerContentServer( diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamcontent/SteamContent.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamcontent/SteamContent.kt index 7d2cb71d..9189f30a 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamcontent/SteamContent.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamcontent/SteamContent.kt @@ -172,13 +172,13 @@ class SteamContent : ClientMsgHandler() { } /** - * TODO kdoc - * @param remoteClientId - * @param steamId - * @param serverRemoteClientId - * @param appId - * @param currentBuildId - * @return A [RequestPeerContentServer] + * Requests that a remote Steam client act as a peer content server for the specified app. + * @param remoteClientId The remote client ID of the peer to request content from. + * @param steamId The Steam ID of the user on the remote client. + * @param serverRemoteClientId The remote client ID that will serve the content. + * @param appId The app ID to request peer content for. + * @param currentBuildId The currently installed build ID of the app, used to determine what needs to be transferred. + * @return A [RequestPeerContentServer] containing the server port, installed depots, and an access token. */ @JavaSteamAddition @JvmOverloads @@ -213,11 +213,11 @@ class SteamContent : ClientMsgHandler() { } /** - * TODO kdoc - * @param remoteClientId - * @param steamId - * @param serverRemoteClientId - * @return A [GetPeerContentInfo] + * Retrieves content information about what a remote peer has available to serve. + * @param remoteClientId The remote client ID of the peer to query. + * @param steamId The Steam ID of the user on the remote client. + * @param serverRemoteClientId The remote client ID acting as the content server. + * @return A [GetPeerContentInfo] containing the app IDs available on the peer and its public IP address. */ @JavaSteamAddition @JvmOverloads From c4476fa4d2491f565cbb5d7ccce17161afd3179b Mon Sep 17 00:00:00 2001 From: Lossy Date: Sat, 13 Jun 2026 03:52:05 -0500 Subject: [PATCH 03/27] Rework WebSocketConnection and remove one todo --- .../networking/steam3/WebSocketConnection.kt | 183 ++++++++---------- .../steam/discovery/SmartCMServerList.kt | 2 +- 2 files changed, 86 insertions(+), 99 deletions(-) diff --git a/src/main/java/in/dragonbra/javasteam/networking/steam3/WebSocketConnection.kt b/src/main/java/in/dragonbra/javasteam/networking/steam3/WebSocketConnection.kt index fe8b3d2d..fa70b245 100644 --- a/src/main/java/in/dragonbra/javasteam/networking/steam3/WebSocketConnection.kt +++ b/src/main/java/in/dragonbra/javasteam/networking/steam3/WebSocketConnection.kt @@ -18,51 +18,52 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancelChildren import kotlinx.coroutines.channels.consumeEach import kotlinx.coroutines.delay -import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import java.net.InetAddress import java.net.InetSocketAddress -import kotlin.coroutines.CoroutineContext +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong import kotlin.time.DurationUnit import kotlin.time.toDuration -class WebSocketConnection : - Connection(), - CoroutineScope { +class WebSocketConnection : Connection() { companion object { private val logger = LogManager.getLogger() + private const val WATCHDOG_TIMEOUT_MS = 30_000L + private val PING_INTERVAL = 30.toDuration(DurationUnit.SECONDS) + private val WATCHDOG_POLL = 5.toDuration(DurationUnit.SECONDS) } - private val job: Job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val disconnecting = AtomicBoolean(false) + private val lastFrameTime = AtomicLong(0L) - private var client: HttpClient? = null - - private var session: WebSocketSession? = null - - private var endpoint: InetSocketAddress? = null - - private var lastFrameTime = System.currentTimeMillis() - - override val coroutineContext: CoroutineContext = Dispatchers.IO + job + @Volatile private var client: HttpClient? = null + @Volatile private var session: WebSocketSession? = null + @Volatile private var connectionJob: Job? = null + @Volatile private var endpoint: InetSocketAddress? = null override fun connect(endPoint: InetSocketAddress, timeout: Int) { - launch { + disconnecting.set(false) + connectionJob?.cancel() + + connectionJob = scope.launch { logger.debug("Trying connection to ${endPoint.hostName}:${endPoint.port}") + endpoint = endPoint + lastFrameTime.set(System.currentTimeMillis()) try { - endpoint = endPoint - - client = HttpClient(CIO) { + val newClient = HttpClient(CIO) { install(WebSockets) { - pingInterval = timeout.toDuration(DurationUnit.SECONDS) + pingInterval = PING_INTERVAL } } + client = newClient - val session = client?.webSocketSession { + val newSession = newClient.webSocketSession { url { host = endPoint.hostName port = endPoint.port @@ -70,75 +71,94 @@ class WebSocketConnection : path("cmsocket/") } } + session = newSession - this@WebSocketConnection.session = session - - startConnectionMonitoring() - - launch { - try { - session?.incoming?.consumeEach { frame -> - when (frame) { - is Frame.Binary -> { - // logger.debug("on Binary ${frame.data.size}") - lastFrameTime = System.currentTimeMillis() - onNetMsgReceived(NetMsgEventArgs(frame.readBytes(), currentEndPoint)) - } - - is Frame.Close -> disconnect(false) - - is Frame.Ping -> logger.debug("Received pong") + logger.debug("Connected to ${endPoint.hostName}:${endPoint.port}") + onConnected() - // Never Used. - is Frame.Pong -> logger.debug("Received pong") + launch { runWatchdog() } - // Never Used. - is Frame.Text -> logger.debug("Received plain text ${frame.readText()}") - } + newSession.incoming.consumeEach { frame -> + when (frame) { + is Frame.Binary -> { + lastFrameTime.set(System.currentTimeMillis()) + onNetMsgReceived(NetMsgEventArgs(frame.readBytes(), currentEndPoint)) } - } catch (e: CancellationException) { - // This won't print most times. - logger.debug("Websocket cancelling: ${e.message}") - } catch (e: Exception) { - logger.error("An error occurred while receiving data", e) - disconnect(false) + is Frame.Close -> doDisconnect(false) + is Frame.Ping -> logger.debug("Received ping") + is Frame.Pong -> logger.debug("Received pong") + is Frame.Text -> logger.debug("Received text: ${frame.readText()}") } } - logger.debug("Connected to ${endPoint.hostName}:${endPoint.port}") - onConnected() + // Session ended without a Close frame + doDisconnect(false) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { - logger.error("An error occurred setting up the web socket client", e) - disconnect(false) + logger.error("An error occurred with the WebSocket connection", e) + doDisconnect(false) } } } override fun disconnect(userInitiated: Boolean) { - logger.debug("Disconnect called: $userInitiated") - launch { + doDisconnect(userInitiated) + } + + private fun doDisconnect(userInitiated: Boolean) { + if (!disconnecting.compareAndSet(false, true)) return + + scope.launch { + val currentJob = connectionJob + connectionJob = null + try { session?.close() + } catch (e: Exception) { + logger.debug("Error closing WebSocket session: ${e.message}") + } + try { client?.close() - } finally { - session = null - client = null - - job.cancelChildren() + } catch (e: Exception) { + logger.debug("Error closing HTTP client: ${e.message}") } + session = null + client = null + + currentJob?.cancel() + currentJob?.join() + onDisconnected(userInitiated) } } + private suspend fun runWatchdog() { + while (true) { + delay(WATCHDOG_POLL) + + val elapsed = System.currentTimeMillis() - lastFrameTime.get() + when { + elapsed > WATCHDOG_TIMEOUT_MS -> { + logger.error("Watchdog: No response for ${WATCHDOG_TIMEOUT_MS / 1000} seconds, disconnecting") + doDisconnect(false) + return + } + elapsed > 25_000 -> logger.debug("Watchdog: No response for 25 seconds") + elapsed > 20_000 -> logger.debug("Watchdog: No response for 20 seconds") + elapsed > 15_000 -> logger.debug("Watchdog: No response for 15 seconds") + } + } + } + override fun send(data: ByteArray) { - launch { + scope.launch { try { - val frame = Frame.Binary(true, data) - session?.send(frame) + session?.send(Frame.Binary(true, data)) } catch (e: Exception) { logger.error("An error occurred while sending data", e) - disconnect(false) + doDisconnect(false) } } } @@ -148,37 +168,4 @@ class WebSocketConnection : override fun getCurrentEndPoint(): InetSocketAddress? = endpoint override fun getProtocolTypes(): ProtocolTypes = ProtocolTypes.WEB_SOCKET - - /** - * Rudimentary watchdog - */ - private fun startConnectionMonitoring() { - launch { - while (isActive) { - if (client?.isActive == false || session?.isActive == false) { - logger.error("Client or Session is no longer active") - disconnect(userInitiated = false) - } - - val timeSinceLastFrame = System.currentTimeMillis() - lastFrameTime - - // logger.debug("Watchdog status: $timeSinceLastFrame") - when { - timeSinceLastFrame > 30000 -> { - logger.error("Watchdog: No response for 30 seconds. Disconnecting from steam") - disconnect(userInitiated = false) - break - } - - timeSinceLastFrame > 25000 -> logger.debug("Watchdog: No response for 25 seconds") - - timeSinceLastFrame > 20000 -> logger.debug("Watchdog: No response for 20 seconds") - - timeSinceLastFrame > 15000 -> logger.debug("Watchdog: No response for 15 seconds") - } - - delay(5000) - } - } - } } diff --git a/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt b/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt index 4a3b17a3..c908d38e 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt @@ -135,7 +135,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { endpointList = listOfNotNull( ServerRecord.createWebSocketServer(defaultServerWebSocket), - ServerRecord.tryCreateSocketServer(defaultServerNetFilter), // TODO 'tryCreateSocketServer' can return null + ServerRecord.tryCreateSocketServer(defaultServerNetFilter), ) replaceList(endpointList, writeProvider = false, Instant.MIN) From f07f4a74f78f65f49dcfeff38f4a8cd93a8024ec Mon Sep 17 00:00:00 2001 From: Lossy Date: Sat, 13 Jun 2026 17:13:44 -0500 Subject: [PATCH 04/27] Rename .java to .kt --- .../in/dragonbra/javasteam/util/event/{Event.java => Event.kt} | 0 .../javasteam/util/event/{EventArgs.java => EventArgs.kt} | 0 .../javasteam/util/event/{EventHandler.java => EventHandler.kt} | 0 .../util/event/{ScheduledFunction.java => ScheduledFunction.kt} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename src/main/java/in/dragonbra/javasteam/util/event/{Event.java => Event.kt} (100%) rename src/main/java/in/dragonbra/javasteam/util/event/{EventArgs.java => EventArgs.kt} (100%) rename src/main/java/in/dragonbra/javasteam/util/event/{EventHandler.java => EventHandler.kt} (100%) rename src/main/java/in/dragonbra/javasteam/util/event/{ScheduledFunction.java => ScheduledFunction.kt} (100%) diff --git a/src/main/java/in/dragonbra/javasteam/util/event/Event.java b/src/main/java/in/dragonbra/javasteam/util/event/Event.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/event/Event.java rename to src/main/java/in/dragonbra/javasteam/util/event/Event.kt diff --git a/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.java b/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/event/EventArgs.java rename to src/main/java/in/dragonbra/javasteam/util/event/EventArgs.kt diff --git a/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.java b/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/event/EventHandler.java rename to src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt diff --git a/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.java b/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.java rename to src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt From 844d2559e5c26037b1a5d1052d235d644a03d200 Mon Sep 17 00:00:00 2001 From: Lossy Date: Sat, 13 Jun 2026 17:13:45 -0500 Subject: [PATCH 05/27] Apply formatting and analysis findings. --- .../networking/steam3/Connection.java | 2 +- .../networking/steam3/WebSocketConnection.kt | 21 ++++-- .../steam/discovery/SmartCMServerList.kt | 12 +++ .../javasteam/util/HardwareUtils.java | 18 +++-- .../dragonbra/javasteam/util/event/Event.kt | 29 +++---- .../javasteam/util/event/EventArgs.kt | 11 ++- .../javasteam/util/event/EventHandler.kt | 8 +- .../javasteam/util/event/ScheduledFunction.kt | 75 +++++++------------ .../javasteam/util/stream/BinaryReader.java | 9 +-- .../javasteam/util/stream/MemoryStream.java | 6 ++ .../util/event/ScheduledFunctionTest.java | 65 ++++++++++++++++ 11 files changed, 161 insertions(+), 95 deletions(-) create mode 100644 src/test/java/in/dragonbra/javasteam/util/event/ScheduledFunctionTest.java diff --git a/src/main/java/in/dragonbra/javasteam/networking/steam3/Connection.java b/src/main/java/in/dragonbra/javasteam/networking/steam3/Connection.java index 0f6c7b2b..cb6bbe11 100644 --- a/src/main/java/in/dragonbra/javasteam/networking/steam3/Connection.java +++ b/src/main/java/in/dragonbra/javasteam/networking/steam3/Connection.java @@ -32,7 +32,7 @@ protected void onNetMsgReceived(NetMsgEventArgs e) { } protected void onConnected() { - connected.handleEvent(this, null); + connected.handleEvent(this, EventArgs.EMPTY); } protected void onDisconnected(boolean e) { diff --git a/src/main/java/in/dragonbra/javasteam/networking/steam3/WebSocketConnection.kt b/src/main/java/in/dragonbra/javasteam/networking/steam3/WebSocketConnection.kt index fa70b245..1f14159a 100644 --- a/src/main/java/in/dragonbra/javasteam/networking/steam3/WebSocketConnection.kt +++ b/src/main/java/in/dragonbra/javasteam/networking/steam3/WebSocketConnection.kt @@ -42,8 +42,11 @@ class WebSocketConnection : Connection() { private val lastFrameTime = AtomicLong(0L) @Volatile private var client: HttpClient? = null + @Volatile private var session: WebSocketSession? = null + @Volatile private var connectionJob: Job? = null + @Volatile private var endpoint: InetSocketAddress? = null override fun connect(endPoint: InetSocketAddress, timeout: Int) { @@ -84,9 +87,13 @@ class WebSocketConnection : Connection() { lastFrameTime.set(System.currentTimeMillis()) onNetMsgReceived(NetMsgEventArgs(frame.readBytes(), currentEndPoint)) } + is Frame.Close -> doDisconnect(false) + is Frame.Ping -> logger.debug("Received ping") + is Frame.Pong -> logger.debug("Received pong") + is Frame.Text -> logger.debug("Received text: ${frame.readText()}") } } @@ -111,22 +118,23 @@ class WebSocketConnection : Connection() { scope.launch { val currentJob = connectionJob + val currentSession = session + val currentClient = client connectionJob = null + session = null + client = null try { - session?.close() + currentSession?.close() } catch (e: Exception) { logger.debug("Error closing WebSocket session: ${e.message}") } try { - client?.close() + currentClient?.close() } catch (e: Exception) { logger.debug("Error closing HTTP client: ${e.message}") } - session = null - client = null - currentJob?.cancel() currentJob?.join() @@ -145,8 +153,11 @@ class WebSocketConnection : Connection() { doDisconnect(false) return } + elapsed > 25_000 -> logger.debug("Watchdog: No response for 25 seconds") + elapsed > 20_000 -> logger.debug("Watchdog: No response for 20 seconds") + elapsed > 15_000 -> logger.debug("Watchdog: No response for 15 seconds") } } diff --git a/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt b/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt index c908d38e..d3747c10 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt @@ -53,6 +53,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { @Suppress("MemberVisibilityCanBePrivate") var badConnectionMemoryTimeSpan: Duration = Duration.ofMinutes(5) + @Synchronized @Throws(IOException::class) private fun startFetchingServers() { if (servers.isNotEmpty()) { @@ -67,6 +68,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { } } + @Synchronized @Throws(IOException::class) private fun resolveServerList(forceRefresh: Boolean = false) { var forcedRefresh = forceRefresh @@ -145,6 +147,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { * Resets the scores of all servers which has a last bad connection more than [SmartCMServerList.badConnectionMemoryTimeSpan] ago. */ @Suppress("MemberVisibilityCanBePrivate") + @Synchronized fun resetOldScores() { val cutoff = Instant.now().minus(badConnectionMemoryTimeSpan) @@ -165,6 +168,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { * @param serversTime The time when the provided server list has been updated. */ @JvmOverloads + @Synchronized fun replaceList(endpointList: List, writeProvider: Boolean = true, serversTime: Instant? = null) { val distinctEndPoints = endpointList.distinct() @@ -188,15 +192,18 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { /** * Explicitly resets the known state of all servers. */ + @Synchronized fun resetBadServers() { servers.forEach { serverInfo -> serverInfo.lastBadConnectionTimeUtc = null } } + @Synchronized fun tryMark(endPoint: InetSocketAddress?, protocolTypes: ProtocolTypes?, quality: ServerQuality): Boolean = tryMark(endPoint, protocolTypes?.let { EnumSet.of(it) }, quality) + @Synchronized fun tryMark(endPoint: InetSocketAddress?, protocolTypes: EnumSet?, quality: ServerQuality): Boolean { if (endPoint == null || protocolTypes == null) { logger.error("Couldn't mark an endpoint ${quality.name}, skipping it") @@ -242,6 +249,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { * @param supportedProtocolTypes The minimum supported [ProtocolTypes] of the server to return. * @return An [ServerRecord], or null if the list is empty. */ + @Synchronized private fun getNextServerCandidateInternal(supportedProtocolTypes: EnumSet): ServerRecord? { resetOldScores() @@ -267,6 +275,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { * @param supportedProtocolTypes The minimum supported [ProtocolTypes] of the server to return. * @return An [ServerRecord], or null if the list is empty. */ + @Synchronized fun getNextServerCandidate(supportedProtocolTypes: EnumSet): ServerRecord? { return runCatching { startFetchingServers() @@ -287,6 +296,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { * @param supportedProtocolTypes The minimum supported [ProtocolTypes] of the server to return. * @return An [ServerRecord], or null if the list is empty. */ + @Synchronized fun getNextServerCandidate(supportedProtocolTypes: ProtocolTypes): ServerRecord? = getNextServerCandidate(EnumSet.of(supportedProtocolTypes)) @@ -294,6 +304,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { * Gets the [ServerRecords][ServerRecord] of all servers in the server list. * @return An [List] array contains the [InetSocketAddress] of the servers in the list */ + @Synchronized fun getAllEndPoints(): List = runCatching { startFetchingServers() }.fold( @@ -309,6 +320,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { * and then fallback to the server list provider. * @return whether the refresh was successful or not. **/ + @Synchronized fun forceRefreshServerList(): Boolean = runCatching { resolveServerList(forceRefresh = true) }.fold( diff --git a/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.java b/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.java index 0c71d162..10a7c06c 100644 --- a/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.java +++ b/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.java @@ -125,7 +125,6 @@ private static String getSerialNumberUnix() { } private static BufferedReader read(String command) { - Runtime runtime = Runtime.getRuntime(); Process process; try { @@ -134,14 +133,23 @@ private static BufferedReader read(String command) { return null; } - var os = process.getOutputStream(); - try { - os.close(); + process.getOutputStream().close(); } catch (IOException ignored) { } - return new BufferedReader(new InputStreamReader(process.getInputStream())); + try (var br = new BufferedReader(new InputStreamReader(process.getInputStream()))) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = br.readLine()) != null) { + sb.append(line).append('\n'); + } + return new BufferedReader(new StringReader(sb.toString())); + } catch (IOException e) { + return null; + } finally { + process.destroy(); + } } private static String readDmidecode() { diff --git a/src/main/java/in/dragonbra/javasteam/util/event/Event.kt b/src/main/java/in/dragonbra/javasteam/util/event/Event.kt index e13cc882..f6d54c8f 100644 --- a/src/main/java/in/dragonbra/javasteam/util/event/Event.kt +++ b/src/main/java/in/dragonbra/javasteam/util/event/Event.kt @@ -1,28 +1,19 @@ -package in.dragonbra.javasteam.util.event; +package `in`.dragonbra.javasteam.util.event -import java.util.HashSet; +import java.util.concurrent.CopyOnWriteArrayList -public class Event { +class Event { + private val handlers = CopyOnWriteArrayList>() - protected final HashSet> handlers = new HashSet<>(); - - public void addEventHandler(EventHandler handler) { - synchronized (handlers) { - handlers.add(handler); - } + fun addEventHandler(handler: EventHandler) { + handlers.add(handler) } - public void removeEventHandler(EventHandler handler) { - synchronized (handlers) { - handlers.remove(handler); - } + fun removeEventHandler(handler: EventHandler) { + handlers.remove(handler) } - public void handleEvent(Object sender, T e) { - synchronized (handlers) { - for (final EventHandler handler : handlers) { - handler.handleEvent(sender, e); - } - } + fun handleEvent(sender: Any, e: T) { + handlers.forEach { it.handleEvent(sender, e) } } } \ No newline at end of file diff --git a/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.kt b/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.kt index 062b8d25..5183cc98 100644 --- a/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.kt +++ b/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.kt @@ -1,9 +1,8 @@ -package in.dragonbra.javasteam.util.event; +package `in`.dragonbra.javasteam.util.event -public class EventArgs { - - public static final EventArgs EMPTY = new EventArgs(); - - public EventArgs() { +open class EventArgs { + companion object { + @JvmField + val EMPTY = EventArgs() } } \ No newline at end of file diff --git a/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt b/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt index dad86e7c..4af49f7c 100644 --- a/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt +++ b/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt @@ -1,5 +1,5 @@ -package in.dragonbra.javasteam.util.event; +package `in`.dragonbra.javasteam.util.event -public interface EventHandler { - void handleEvent(Object sender, T e); -} +fun interface EventHandler { + fun handleEvent(sender: Any, e: T) +} \ No newline at end of file diff --git a/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt b/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt index 66dcb852..a432838c 100644 --- a/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt +++ b/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt @@ -1,55 +1,30 @@ -package in.dragonbra.javasteam.util.event; - -import java.util.Timer; -import java.util.TimerTask; - -/** - * @author lngtr - * @since 2018-02-20 - */ -public class ScheduledFunction { - - private long delay; - - private final Runnable func; - - private Timer timer; - - private boolean bStarted = false; - - public ScheduledFunction(Runnable func, long delay) { - this.delay = delay; - this.func = func; - } - - public void start() { - if (!bStarted) { - timer = new Timer(); - timer.scheduleAtFixedRate(new TimerTask() { - @Override - public void run() { - if (func != null) { - func.run(); - } - } - }, 0, delay); - bStarted = true; +package `in`.dragonbra.javasteam.util.event + +import java.util.Timer +import java.util.TimerTask + +class ScheduledFunction(private val func: Runnable, var delay: Long) { + private var timer: Timer? = null + private var started = false + + @Synchronized + fun start() { + if (!started) { + timer = Timer().also { + it.scheduleAtFixedRate(object : TimerTask() { + override fun run() = func.run() + }, 0L, delay) + } + started = true } } - public void stop() { - if (bStarted) { - timer.cancel(); - timer = null; - bStarted = false; + @Synchronized + fun stop() { + if (started) { + timer?.cancel() + timer = null + started = false } } - - public long getDelay() { - return delay; - } - - public void setDelay(long delay) { - this.delay = delay; - } -} +} \ No newline at end of file diff --git a/src/main/java/in/dragonbra/javasteam/util/stream/BinaryReader.java b/src/main/java/in/dragonbra/javasteam/util/stream/BinaryReader.java index 87ed2039..3e36eca7 100644 --- a/src/main/java/in/dragonbra/javasteam/util/stream/BinaryReader.java +++ b/src/main/java/in/dragonbra/javasteam/util/stream/BinaryReader.java @@ -155,15 +155,14 @@ private String readNullTermUtf8String() throws IOException { ByteArrayOutputStream baos = new ByteArrayOutputStream(); int b; - while ((b = in.read()) != 0) { - if (b <= 0) { - break; - } + while ((b = in.read()) > 0) { baos.write(b); position++; } - position++; // Increment for the null terminator + if (b == 0) { + position++; // Increment for the null terminator + } return ByteArrayOutputStreamCompat.toString(baos); } diff --git a/src/main/java/in/dragonbra/javasteam/util/stream/MemoryStream.java b/src/main/java/in/dragonbra/javasteam/util/stream/MemoryStream.java index 59f67fa4..fe927732 100644 --- a/src/main/java/in/dragonbra/javasteam/util/stream/MemoryStream.java +++ b/src/main/java/in/dragonbra/javasteam/util/stream/MemoryStream.java @@ -286,6 +286,9 @@ public long getPosition() { * @param value The new position within the stream. */ public void setPosition(long value) { + if (value < 0 || value > Integer.MAX_VALUE) { + throw new IllegalArgumentException("value out of range: " + value); + } position = origin + (int) value; } @@ -314,6 +317,9 @@ public long seek(long offset, SeekOrigin loc) { throw new IllegalArgumentException("loc"); } + if (offset < Integer.MIN_VALUE || offset > Integer.MAX_VALUE) { + throw new IllegalArgumentException("offset out of range: " + offset); + } position = reference + (int) offset; return position; } diff --git a/src/test/java/in/dragonbra/javasteam/util/event/ScheduledFunctionTest.java b/src/test/java/in/dragonbra/javasteam/util/event/ScheduledFunctionTest.java new file mode 100644 index 00000000..31bdb484 --- /dev/null +++ b/src/test/java/in/dragonbra/javasteam/util/event/ScheduledFunctionTest.java @@ -0,0 +1,65 @@ +package in.dragonbra.javasteam.util.event; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +class ScheduledFunctionTest { + + @Test + void startInvokesFunction() throws InterruptedException { + var latch = new CountDownLatch(3); + var func = new ScheduledFunction(latch::countDown, 50L); + + func.start(); + boolean completed = latch.await(2, TimeUnit.SECONDS); + func.stop(); + + Assertions.assertTrue(completed, "Function was not invoked 3 times within timeout"); + } + + @Test + void stopPreventsInvocations() throws InterruptedException { + var count = new AtomicInteger(); + var func = new ScheduledFunction(count::incrementAndGet, 50L); + + func.start(); + Thread.sleep(150); + func.stop(); + + int countAfterStop = count.get(); + Thread.sleep(150); + + Assertions.assertEquals(countAfterStop, count.get(), "Function was invoked after stop()"); + } + + @Test + void startIsIdempotent() throws InterruptedException { + var latch = new CountDownLatch(3); + var func = new ScheduledFunction(latch::countDown, 50L); + + func.start(); + func.start(); + boolean completed = latch.await(2, TimeUnit.SECONDS); + func.stop(); + + Assertions.assertTrue(completed); + } + + @Test + void stopWhenNotStartedDoesNotThrow() { + var func = new ScheduledFunction(() -> {}, 100L); + Assertions.assertDoesNotThrow(func::stop); + } + + @Test + void delayGetterAndSetter() { + var func = new ScheduledFunction(() -> {}, 100L); + Assertions.assertEquals(100L, func.getDelay()); + func.setDelay(200L); + Assertions.assertEquals(200L, func.getDelay()); + } +} \ No newline at end of file From a561b0eaad6eed232a186812bb16539a3d5ba590 Mon Sep 17 00:00:00 2001 From: Lossy Date: Sat, 13 Jun 2026 18:06:05 -0500 Subject: [PATCH 06/27] Optimze depotdownloader --- .editorconfig | 1 + .../depotdownloader/DepotDownloader.kt | 271 ++++++------------ .../depotdownloader/data/DepotFilesData.kt | 1 + .../depotdownloader/data/DownloadCounters.kt | 16 +- .../dragonbra/javasteam/util/event/Event.kt | 2 +- .../javasteam/util/event/EventArgs.kt | 2 +- .../javasteam/util/event/EventHandler.kt | 2 +- .../javasteam/util/event/ScheduledFunction.kt | 12 +- 8 files changed, 117 insertions(+), 190 deletions(-) diff --git a/.editorconfig b/.editorconfig index 526bdb0d..f99d5633 100644 --- a/.editorconfig +++ b/.editorconfig @@ -18,5 +18,6 @@ ktlint_code_style = intellij_idea ktlint_standard_package-name = disabled ktlint_standard_multiline-expression-wrapping = disabled ktlint_standard_trailing-comma-on-call-site = disabled +ktlint_standard_no-wildcard-imports = disabled diff --git a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/DepotDownloader.kt b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/DepotDownloader.kt index 539717d0..027d522f 100644 --- a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/DepotDownloader.kt +++ b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/DepotDownloader.kt @@ -1,15 +1,6 @@ package `in`.dragonbra.javasteam.depotdownloader -import `in`.dragonbra.javasteam.depotdownloader.data.AppItem -import `in`.dragonbra.javasteam.depotdownloader.data.ChunkMatch -import `in`.dragonbra.javasteam.depotdownloader.data.DepotDownloadCounter -import `in`.dragonbra.javasteam.depotdownloader.data.DepotDownloadInfo -import `in`.dragonbra.javasteam.depotdownloader.data.DepotFilesData -import `in`.dragonbra.javasteam.depotdownloader.data.DownloadItem -import `in`.dragonbra.javasteam.depotdownloader.data.FileStreamData -import `in`.dragonbra.javasteam.depotdownloader.data.GlobalDownloadCounter -import `in`.dragonbra.javasteam.depotdownloader.data.PubFileItem -import `in`.dragonbra.javasteam.depotdownloader.data.UgcItem +import `in`.dragonbra.javasteam.depotdownloader.data.* import `in`.dragonbra.javasteam.enums.EAccountType import `in`.dragonbra.javasteam.enums.EAppInfoSection import `in`.dragonbra.javasteam.enums.EDepotFileFlag @@ -21,12 +12,7 @@ import `in`.dragonbra.javasteam.steam.handlers.steamapps.License import `in`.dragonbra.javasteam.steam.handlers.steamapps.callback.LicenseListCallback import `in`.dragonbra.javasteam.steam.handlers.steamcloud.callback.UGCDetailsCallback import `in`.dragonbra.javasteam.steam.steamclient.SteamClient -import `in`.dragonbra.javasteam.types.ChunkData -import `in`.dragonbra.javasteam.types.DepotManifest -import `in`.dragonbra.javasteam.types.FileData -import `in`.dragonbra.javasteam.types.KeyValue -import `in`.dragonbra.javasteam.types.PublishedFileID -import `in`.dragonbra.javasteam.types.UGCHandle +import `in`.dragonbra.javasteam.types.* import `in`.dragonbra.javasteam.util.Adler32 import `in`.dragonbra.javasteam.util.SteamKitWebRequestException import `in`.dragonbra.javasteam.util.Strings @@ -35,32 +21,12 @@ import `in`.dragonbra.javasteam.util.log.Logger import io.ktor.client.request.get import io.ktor.client.statement.bodyAsChannel import io.ktor.http.HttpHeaders -import io.ktor.utils.io.core.readAvailable -import io.ktor.utils.io.readRemaining +import io.ktor.utils.io.readAvailable +import kotlinx.coroutines.* import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.cancel -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.ensureActive -import kotlinx.coroutines.flow.MutableSharedFlow -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.flatMapMerge -import kotlinx.coroutines.flow.flow -import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield -import okio.Buffer +import kotlinx.coroutines.channels.* +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.sync.* import okio.FileSystem import okio.Path import okio.Path.Companion.toPath @@ -69,14 +35,10 @@ import org.apache.commons.lang3.SystemUtils import java.io.Closeable import java.io.IOException import java.io.RandomAccessFile -import java.lang.IllegalStateException import java.time.Instant import java.time.temporal.ChronoUnit import java.util.concurrent.* -import java.util.concurrent.atomic.AtomicInteger -import kotlin.collections.mutableListOf -import kotlin.collections.set -import kotlin.text.toLongOrNull +import java.util.concurrent.atomic.* /** * Downloads games, workshop items, and other Steam content via depot manifests. @@ -165,16 +127,25 @@ class DepotDownloader @JvmOverloads constructor( private var processingChannel = Channel(Channel.UNLIMITED) - private val networkChunkFlow = MutableSharedFlow(extraBufferCapacity = Int.MAX_VALUE) + // Bounded channel — send() suspends producers when full, creating backpressure through the pipeline. + private val networkChunkChannel = Channel(64) private val pendingChunks = AtomicInteger(0) + // Completed by processFileWrites when all pending chunks drain, or by the sentinel release in + // downloadSteam3DepotFiles. Replaces the 100ms polling loop. + @Volatile + private var downloadCompletion: CompletableDeferred? = null + private var chunkProcessingJob: Job? = null private var steam3: Steam3Session? = null private var processingItemsMap = mutableMapOf() + // Lazily built on first accountHasAccess call; all app/depot IDs the account can access. + private var accessibleIds: Set? = null + // region [REGION] Private data classes. private data class NetworkChunkItem( @@ -191,7 +162,6 @@ class DepotDownloader @JvmOverloads constructor( val depot: DepotDownloadInfo, val depotDownloadCounter: DepotDownloadCounter, val downloadCounter: GlobalDownloadCounter, - val downloaded: Int, val file: FileData, val fileStreamData: FileStreamData, val chunk: ChunkData, @@ -251,20 +221,19 @@ class DepotDownloader @JvmOverloads constructor( } } - private fun createChunkProcessingFlow(): kotlinx.coroutines.flow.Flow = networkChunkFlow + private fun createChunkProcessingFlow(): kotlinx.coroutines.flow.Flow = networkChunkChannel.receiveAsFlow() .flatMapMerge(concurrency = maxDownloads) { item -> flow { try { - val result = downloadSteam3DepotFileChunk( - downloadCounter = item.downloadCounter, - depotFilesData = item.depotFilesData, - file = item.fileData, - fileStreamData = item.fileStreamData, - chunk = item.chunk + emit( + downloadSteam3DepotFileChunk( + downloadCounter = item.downloadCounter, + depotFilesData = item.depotFilesData, + file = item.fileData, + fileStreamData = item.fileStreamData, + chunk = item.chunk + ) ) - if (result != null) { - emit(result) - } } catch (e: Exception) { logger?.error("Error downloading chunk: ${e.message}", e) } @@ -273,10 +242,7 @@ class DepotDownloader @JvmOverloads constructor( .flatMapMerge(concurrency = maxDecompress) { item -> flow { try { - val result = processFileDecompress(item) - if (result != null) { - emit(result) - } + emit(processFileDecompress(item)) } catch (e: Exception) { logger?.error("Error decompressing chunk: ${e.message}", e) } @@ -286,12 +252,13 @@ class DepotDownloader @JvmOverloads constructor( flow { try { processFileWrites(item) - pendingChunks.decrementAndGet() - emit(Unit) } catch (e: Exception) { logger?.error("Error writing file: ${e.message}", e) - pendingChunks.decrementAndGet() } + if (pendingChunks.decrementAndGet() == 0) { + downloadCompletion?.complete(Unit) + } + emit(Unit) }.flowOn(Dispatchers.IO) } @@ -408,17 +375,12 @@ class DepotDownloader @JvmOverloads constructor( logger?.debug("File size: ${totalBytes?.let { Util.formatBytes(it) } ?: "Unknown"}") filesystem.sink(fileStagingPath).buffer().use { sink -> - val buffer = Buffer() val tempArray = ByteArray(DEFAULT_BUFFER_SIZE) while (!channel.isClosedForRead) { - val packet = channel.readRemaining(DEFAULT_BUFFER_SIZE.toLong()) - if (!packet.exhausted()) { - val bytesRead = packet.readAvailable(tempArray, 0, tempArray.size) - if (bytesRead > 0) { - buffer.write(tempArray, 0, bytesRead) - sink.writeAll(buffer) - } + val bytesRead = channel.readAvailable(tempArray, 0, tempArray.size) + if (bytesRead > 0) { + sink.write(tempArray, 0, bytesRead) } } } @@ -873,28 +835,27 @@ class DepotDownloader @JvmOverloads constructor( return false } - val licenseQuery = arrayListOf() - if (steamID.accountType == EAccountType.AnonUser) { - licenseQuery.add(17906) - } else { - licenseQuery.addAll(licenses.map { it.packageID }.distinct()) - } + if (accessibleIds == null) { + val licenseQuery = if (steamID.accountType == EAccountType.AnonUser) { + listOf(17906) + } else { + licenses.map { it.packageID }.distinct() + } - steam3!!.requestPackageInfo(licenseQuery) + steam3!!.requestPackageInfo(licenseQuery) - licenseQuery.forEach { license -> - steam3!!.packageInfo[license]?.value?.let { pkg -> - val appIds = pkg.keyValues["appids"].children.map { it.asInteger() } - val depotIds = pkg.keyValues["depotids"].children.map { it.asInteger() } - if (depotId in appIds) { - return true - } - if (depotId in depotIds) { - return true + accessibleIds = buildSet { + licenseQuery.forEach { license -> + steam3!!.packageInfo[license]?.value?.let { pkg -> + pkg.keyValues["appids"].children.forEach { add(it.asInteger()) } + pkg.keyValues["depotids"].children.forEach { add(it.asInteger()) } + } } } } + if (depotId in accessibleIds!!) return true + // Check if this app is free to download without a license val info = getSteam3AppSection(appId, EAppInfoSection.Common) @@ -909,16 +870,12 @@ class DepotDownloader @JvmOverloads constructor( val depotsToDownload = ArrayList(depots.size) val allFileNamesAllDepots = hashSetOf() - // First, fetch all the manifests for each depot (including previous manifests) and perform the initial setup - depots.forEach { depot -> - val depotFileData = processDepotManifestAndFiles(depot, downloadCounter) - - if (depotFileData != null) { - depotsToDownload.add(depotFileData) - allFileNamesAllDepots.addAll(depotFileData.allFileNames) - } - - ensureActive() + // Fetch all depot manifests in parallel for faster startup + depots.map { depot -> + async { processDepotManifestAndFiles(depot, downloadCounter) } + }.awaitAll().filterNotNull().forEach { depotFileData -> + depotsToDownload.add(depotFileData) + allFileNamesAllDepots.addAll(depotFileData.allFileNames) } // If we're about to write all the files to the same directory, we will need to first de-duplicate any files by path @@ -935,6 +892,10 @@ class DepotDownloader @JvmOverloads constructor( if (depotsToDownload.isEmpty()) { finishDepotDownload(mainAppId) } else { + // Sentinel: keeps pendingChunks ≥ 1 while files are still being enumerated, + // preventing a premature zero signal before all chunks have been submitted. + pendingChunks.set(1) + downloadCompletion = CompletableDeferred() depotsToDownload.forEachIndexed { index, depotFileData -> downloadSteam3DepotFiles( mainAppId, @@ -947,8 +908,8 @@ class DepotDownloader @JvmOverloads constructor( } logger?.debug( - "Total downloaded: ${downloadCounter.totalBytesCompressed} bytes " + - "(${downloadCounter.totalBytesUncompressed} bytes uncompressed) from ${depots.size} depots" + "Total downloaded: ${downloadCounter.totalBytesCompressed.get()} bytes " + + "(${downloadCounter.totalBytesUncompressed.get()} bytes uncompressed) from ${depots.size} depots" ) } @@ -1109,11 +1070,7 @@ class DepotDownloader @JvmOverloads constructor( val stagingDir = depot.installDir / STAGING_DIR - val filesAfterExclusions = coroutineScope { - newManifest.files.filter { file -> - async { testIsFileIncluded(file.fileName) }.await() - } - } + val filesAfterExclusions = newManifest.files.filter { testIsFileIncluded(it.fileName) } val allFileNames = HashSet(filesAfterExclusions.size) // Pre-process @@ -1135,7 +1092,7 @@ class DepotDownloader @JvmOverloads constructor( filesystem.createDirectories(fileFinalPath.parent!!) filesystem.createDirectories(fileStagingPath.parent!!) - downloadCounter.completeDownloadSize += file.totalSize + downloadCounter.completeDownloadSize.addAndGet(file.totalSize) depotCounter.completeDownloadSize += file.totalSize } } @@ -1148,6 +1105,7 @@ class DepotDownloader @JvmOverloads constructor( previousManifest = oldManifest, filteredFiles = filesAfterExclusions.toMutableList(), allFileNames = allFileNames, + previousManifestIndex = oldManifest?.files?.associateBy { it.fileName }, ) } @@ -1168,36 +1126,29 @@ class DepotDownloader @JvmOverloads constructor( try { coroutineScope { - // Second parallel loop - process files and enqueue chunks - files.chunked(50).forEach { batch -> - yield() - - batch.map { file -> - async { - downloadSteam3DepotFile( - downloadCounter = downloadCounter, - depotFilesData = depotFilesData, - file = file, - ) - } - }.awaitAll() - } + files.map { file -> + async { + downloadSteam3DepotFile( + downloadCounter = downloadCounter, + depotFilesData = depotFilesData, + file = file, + ) + } + }.awaitAll() } } finally { if (isLastDepot) { logger?.debug("Waiting for ${pendingChunks.get()} pending chunks to complete for depot ${depot.depotId}") - // Wait for all pending chunks to complete processing - while (pendingChunks.get() > 0) { - kotlinx.coroutines.delay(100) + // Release the sentinel. If no chunks are still in flight, signal immediately; + // otherwise suspend until the last processFileWrites call signals us. + if (pendingChunks.decrementAndGet() == 0) { + downloadCompletion?.complete(Unit) } + downloadCompletion?.await() + downloadCompletion = null - logger?.debug("All chunks completed, canceling processing job for depot ${depot.depotId}") - - // Cancel the continuous flow job since no more chunks will be added - chunkProcessingJob?.cancel() - - logger?.debug("Canceled chunk processing job for depot ${depot.depotId}") + logger?.debug("All chunks completed for depot ${depot.depotId}") } } @@ -1258,12 +1209,8 @@ class DepotDownloader @JvmOverloads constructor( val depot = depotFilesData.depotDownloadInfo val stagingDir = depotFilesData.stagingDir val depotDownloadCounter = depotFilesData.depotCounter - val oldProtoManifest = depotFilesData.previousManifest - var oldManifestFile: FileData? = null - if (oldProtoManifest != null) { - oldManifestFile = oldProtoManifest.files.singleOrNull { it.fileName == file.fileName } - } + val oldManifestFile: FileData? = depotFilesData.previousManifestIndex?.get(file.fileName) val fileFinalPath = depot.installDir / file.fileName val fileStagingPath = stagingDir / file.fileName @@ -1331,18 +1278,7 @@ class DepotDownloader @JvmOverloads constructor( val buffer = ByteArray(length) handle.read(match.oldChunk.offset, buffer, 0, length) - // Calculate Adler32 checksum - val adler = Adler32.calculate(buffer) - - // Convert checksum to byte array for comparison - val checksumBytes = Buffer().apply { - writeIntLe(match.oldChunk.checksum) - }.readByteArray() - val calculatedChecksumBytes = Buffer().apply { - writeIntLe(adler) - }.readByteArray() - - if (!calculatedChecksumBytes.contentEquals(checksumBytes)) { + if (Adler32.calculate(buffer) != match.oldChunk.checksum) { neededChunks.add(match.newChunk) } else { copyChunks.add(match) @@ -1415,9 +1351,7 @@ class DepotDownloader @JvmOverloads constructor( logger?.debug("%.2f%% %s".format(percentage, fileFinalPath)) } - synchronized(downloadCounter) { - downloadCounter.completeDownloadSize -= file.totalSize - } + downloadCounter.completeDownloadSize.addAndGet(-file.totalSize) return@withContext } @@ -1427,9 +1361,7 @@ class DepotDownloader @JvmOverloads constructor( depotDownloadCounter.sizeDownloaded += sizeOnDisk } - synchronized(downloadCounter) { - downloadCounter.completeDownloadSize -= sizeOnDisk - } + downloadCounter.completeDownloadSize.addAndGet(-sizeOnDisk) } val fileIsExecutable = file.flags.contains(EDepotFileFlag.Executable) @@ -1452,7 +1384,7 @@ class DepotDownloader @JvmOverloads constructor( neededChunks!!.forEach { chunk -> pendingChunks.incrementAndGet() - networkChunkFlow.tryEmit( + networkChunkChannel.send( NetworkChunkItem( downloadCounter = downloadCounter, depotFilesData = depotFilesData, @@ -1564,7 +1496,6 @@ class DepotDownloader @JvmOverloads constructor( depot = depot, depotDownloadCounter = depotDownloadCounter, downloadCounter = downloadCounter, - downloaded = downloaded, file = file, fileStreamData = fileStreamData, chunk = chunk, @@ -1592,7 +1523,7 @@ class DepotDownloader @JvmOverloads constructor( return false } - private suspend fun finishDepotDownload(mainAppId: Int) { + private fun finishDepotDownload(mainAppId: Int) { val appItem = processingItemsMap[mainAppId] if (appItem != null) { notifyListeners { it.onDownloadCompleted(appItem) } @@ -1614,9 +1545,7 @@ class DepotDownloader @JvmOverloads constructor( } private fun notifyListeners(action: (IDownloadListener) -> Unit) { - scope.launch(Dispatchers.IO) { - listeners.forEach { listener -> action(listener) } - } + listeners.forEach { action(it) } } // endregion @@ -1784,18 +1713,11 @@ class DepotDownloader @JvmOverloads constructor( ensureActive() val depot = item.depot - val depotKey = depot.depotKey - val downloaded = item.downloaded val chunk = item.chunk val chunkBuffer = item.chunkBuffer - var written = downloaded - var decompressedBuffer = chunkBuffer - - if (depotKey != null) { - decompressedBuffer = ByteArray(chunk.uncompressedLength) - written = DepotChunk.process(chunk, chunkBuffer, decompressedBuffer, depotKey) - } + val decompressedBuffer = ByteArray(chunk.uncompressedLength) + val written = DepotChunk.process(chunk, chunkBuffer, decompressedBuffer, depot.depotKey) return@withContext FileWriteItem( depot = depot, @@ -1847,10 +1769,8 @@ class DepotDownloader @JvmOverloads constructor( depotDownloadCounter.sizeDownloaded } - synchronized(downloadCounter) { - downloadCounter.totalBytesCompressed += chunk.compressedLength - downloadCounter.totalBytesUncompressed += chunk.uncompressedLength - } + downloadCounter.totalBytesCompressed.addAndGet(chunk.compressedLength.toLong()) + downloadCounter.totalBytesUncompressed.addAndGet(chunk.uncompressedLength.toLong()) val fileFinalPath = depot.installDir / file.fileName val depotPercentage = (sizeDownloaded.toFloat() / depotDownloadCounter.completeDownloadSize) @@ -1882,10 +1802,8 @@ class DepotDownloader @JvmOverloads constructor( depotPercentage = (sizeDownloaded.toFloat() / depotDownloadCounter.completeDownloadSize) } - synchronized(downloadCounter) { - downloadCounter.totalBytesCompressed += chunk.compressedLength - downloadCounter.totalBytesUncompressed += chunk.uncompressedLength - } + downloadCounter.totalBytesCompressed.addAndGet(chunk.compressedLength.toLong()) + downloadCounter.totalBytesUncompressed.addAndGet(chunk.uncompressedLength.toLong()) notifyListeners { listener -> listener.onChunkCompleted( @@ -1915,6 +1833,7 @@ class DepotDownloader @JvmOverloads constructor( override fun close() { processingChannel.close() + networkChunkChannel.close() scope.cancel("DepotDownloader Closing") diff --git a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/data/DepotFilesData.kt b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/data/DepotFilesData.kt index 15d4dda9..7ff6945d 100644 --- a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/data/DepotFilesData.kt +++ b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/data/DepotFilesData.kt @@ -29,4 +29,5 @@ data class DepotFilesData( val previousManifest: DepotManifest?, val filteredFiles: MutableList, val allFileNames: HashSet, + val previousManifestIndex: Map?, ) diff --git a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/data/DownloadCounters.kt b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/data/DownloadCounters.kt index 380463c2..44c1ac0a 100644 --- a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/data/DownloadCounters.kt +++ b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/data/DownloadCounters.kt @@ -1,11 +1,13 @@ package `in`.dragonbra.javasteam.depotdownloader.data +import java.util.concurrent.atomic.AtomicLong + // https://kotlinlang.org/docs/coding-conventions.html#source-file-organization /** * Tracks cumulative download statistics across all depots in a download session. - * Used for overall progress reporting and final download summary. All fields are - * accessed under synchronization to ensure thread-safe updates from concurrent downloads. + * Used for overall progress reporting and final download summary. Fields are AtomicLong + * so parallel depot manifest fetches can update them concurrently without locking. * * @property completeDownloadSize Total bytes expected to download across all depots. Adjusted during validation when existing chunks are reused. * @property totalBytesCompressed Total compressed bytes transferred from CDN servers @@ -15,11 +17,11 @@ package `in`.dragonbra.javasteam.depotdownloader.data * @author Lossy * @since Oct 29, 2024 */ -data class GlobalDownloadCounter( - var completeDownloadSize: Long = 0, - var totalBytesCompressed: Long = 0, - var totalBytesUncompressed: Long = 0, -) +class GlobalDownloadCounter { + val completeDownloadSize = AtomicLong(0) + val totalBytesCompressed = AtomicLong(0) + val totalBytesUncompressed = AtomicLong(0) +} /** * Tracks download statistics for a single depot. diff --git a/src/main/java/in/dragonbra/javasteam/util/event/Event.kt b/src/main/java/in/dragonbra/javasteam/util/event/Event.kt index f6d54c8f..c11e4f2e 100644 --- a/src/main/java/in/dragonbra/javasteam/util/event/Event.kt +++ b/src/main/java/in/dragonbra/javasteam/util/event/Event.kt @@ -16,4 +16,4 @@ class Event { fun handleEvent(sender: Any, e: T) { handlers.forEach { it.handleEvent(sender, e) } } -} \ No newline at end of file +} diff --git a/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.kt b/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.kt index 5183cc98..828e0918 100644 --- a/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.kt +++ b/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.kt @@ -5,4 +5,4 @@ open class EventArgs { @JvmField val EMPTY = EventArgs() } -} \ No newline at end of file +} diff --git a/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt b/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt index 4af49f7c..70bbb851 100644 --- a/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt +++ b/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt @@ -2,4 +2,4 @@ package `in`.dragonbra.javasteam.util.event fun interface EventHandler { fun handleEvent(sender: Any, e: T) -} \ No newline at end of file +} diff --git a/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt b/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt index a432838c..744f98f7 100644 --- a/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt +++ b/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt @@ -11,9 +11,13 @@ class ScheduledFunction(private val func: Runnable, var delay: Long) { fun start() { if (!started) { timer = Timer().also { - it.scheduleAtFixedRate(object : TimerTask() { - override fun run() = func.run() - }, 0L, delay) + it.scheduleAtFixedRate( + object : TimerTask() { + override fun run() = func.run() + }, + 0L, + delay + ) } started = true } @@ -27,4 +31,4 @@ class ScheduledFunction(private val func: Runnable, var delay: Long) { started = false } } -} \ No newline at end of file +} From 5275f3dce2ca0fd25e86a61f68188112f3bb7089 Mon Sep 17 00:00:00 2001 From: Lossy Date: Sat, 13 Jun 2026 21:22:06 -0500 Subject: [PATCH 07/27] Drastically improve DepotDownloader performance. Breaking: DownloadItems items use a builder pattern now. --- .../depotdownloader/CDNClientPool.kt | 52 ++++- .../depotdownloader/DepotDownloader.kt | 191 +++++++++-------- .../depotdownloader/data/DownloadItems.kt | 197 +++++++++++++++--- .../_023_downloadapp/SampleDownloadApp.java | 81 +++---- 4 files changed, 364 insertions(+), 157 deletions(-) diff --git a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/CDNClientPool.kt b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/CDNClientPool.kt index ce7ec2d5..e90a9841 100644 --- a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/CDNClientPool.kt +++ b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/CDNClientPool.kt @@ -54,6 +54,10 @@ class CDNClientPool( } } + /** + * Releases all resources held by this pool. Clears the server list and nulls out the CDN client. + * After closing, [getConnection] will throw [IllegalStateException]. + */ override fun close() { logger?.debug("Closing...") @@ -65,6 +69,13 @@ class CDNClientPool( logger = null } + /** + * Fetches the current CDN server list from Steam and resets the round-robin index. + * Servers are filtered to those eligible for [appId] and sorted by weighted load. + * Must be called before [getConnection]. Throws if no servers are returned. + * @param maxNumServers Optional cap on the number of servers to request. Null requests the default amount. + * @throws Exception if Steam returns an empty server list. + */ @Throws(Exception::class) suspend fun updateServerList(maxNumServers: Int? = null) = mutex.withLock { val serversForSteamPipe = steamSession.steamContent!!.getServersForSteamPipe( @@ -96,9 +107,18 @@ class CDNClientPool( } } + /** Returns true if the pool contains at least one server. */ + fun hasServers(): Boolean = servers.get().isNotEmpty() + + /** + * Returns the next server in round-robin order. + * @throws IllegalStateException if the server list is empty. + */ fun getConnection(): Server { val servers = servers.get() + if (servers.isEmpty()) throw IllegalStateException("No CDN servers available") + val index = nextServer.getAndIncrement() val server = servers[index % servers.size] @@ -107,6 +127,10 @@ class CDNClientPool( return server } + /** + * Returns a successfully used [server] to the pool. + * Call this after a chunk or manifest download completes without error. + */ fun returnConnection(server: Server?) { if (server == null) { logger?.error("null server returned to cdn pool.") @@ -118,6 +142,22 @@ class CDNClientPool( // (SK) nothing to do, maybe remove from ContentServerPenalty? } + /** + * Transiently skips [server] by advancing the round-robin index. + * Use for recoverable failures (HTTP 5xx, timeouts) where the server may succeed later. + */ + fun skipConnection(server: Server?) { + if (server == null) return + + logger?.debug("Skipping connection: $server") + + nextServer.incrementAndGet() + } + + /** + * Permanently removes [server] from the pool. + * Use only for unrecoverable failures (e.g. DNS resolution failure) where the host is unreachable. + */ fun returnBrokenConnection(server: Server?) { if (server == null) { logger?.error("null broken server returned to pool") @@ -126,13 +166,11 @@ class CDNClientPool( logger?.debug("Returning broken connection: $server") - val servers = servers.get() - val currentIndex = nextServer.get() - - if (servers.isNotEmpty() && servers[currentIndex % servers.size] == server) { - nextServer.incrementAndGet() - - // TODO: (SK) Add server to ContentServerPenalty + val updated = servers.updateAndGet { it.filter { s -> s != server } } + if (updated.isEmpty()) { + logger?.error("No CDN servers remaining after removing broken connection: $server") } + + // TODO: (SK) Add server to ContentServerPenalty } } diff --git a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/DepotDownloader.kt b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/DepotDownloader.kt index 027d522f..80d84cc8 100644 --- a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/DepotDownloader.kt +++ b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/DepotDownloader.kt @@ -62,8 +62,7 @@ import java.util.concurrent.atomic.* * @param licenses User's license list from [LicenseListCallback]. Required to determine which depots the account has access to. * @param debug Enables detailed logging of all operations via [LogManager] * @param useLanCache Attempts to detect and use local Steam cache servers (e.g., LANCache) for faster downloads on local networks - * @param maxDownloads Number of concurrent chunk downloads. Automatically increased to 25 when a LAN cache is detected. Default: 8 - * @param maxDecompress Number of concurrent chunk decompress. Default: 8 + * @param maxDownloads Number of concurrent chunk downloads. Automatically increased to 25 when a LAN cache is detected. Default: 8. Higher is better * @param maxFileWrites Number of concurrent files being written. Default: 1 * @param androidEmulation Forces "Windows" as the default OS filter. Used when running Android games in PC emulators that expect Windows builds. * @param parentJob Parent job for the downloader. If provided, the downloader will be cancelled when the parent job is cancelled. @@ -80,7 +79,6 @@ class DepotDownloader @JvmOverloads constructor( private val debug: Boolean = false, private val useLanCache: Boolean = false, private var maxDownloads: Int = 8, - private var maxDecompress: Int = 8, private var maxFileWrites: Int = 1, private val androidEmulation: Boolean = false, private val parentJob: Job? = null, @@ -130,6 +128,9 @@ class DepotDownloader @JvmOverloads constructor( // Bounded channel — send() suspends producers when full, creating backpressure through the pipeline. private val networkChunkChannel = Channel(64) + // Half of available processors, clamped to at least 1. Leaves remaining cores for the OS and app. + private val maxDecompress: Int = (Runtime.getRuntime().availableProcessors() / 2).coerceAtLeast(1) + private val pendingChunks = AtomicInteger(0) // Completed by processFileWrites when all pending chunks drain, or by the sentinel release in @@ -221,7 +222,7 @@ class DepotDownloader @JvmOverloads constructor( } } - private fun createChunkProcessingFlow(): kotlinx.coroutines.flow.Flow = networkChunkChannel.receiveAsFlow() + private fun createChunkProcessingFlow() = networkChunkChannel.receiveAsFlow() .flatMapMerge(concurrency = maxDownloads) { item -> flow { try { @@ -234,6 +235,8 @@ class DepotDownloader @JvmOverloads constructor( chunk = item.chunk ) ) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { logger?.error("Error downloading chunk: ${e.message}", e) } @@ -243,6 +246,8 @@ class DepotDownloader @JvmOverloads constructor( flow { try { emit(processFileDecompress(item)) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { logger?.error("Error decompressing chunk: ${e.message}", e) } @@ -250,14 +255,18 @@ class DepotDownloader @JvmOverloads constructor( } .flatMapMerge(concurrency = maxFileWrites) { item -> flow { + var rethrow: CancellationException? = null try { processFileWrites(item) + } catch (e: CancellationException) { + rethrow = e } catch (e: Exception) { logger?.error("Error writing file: ${e.message}", e) } if (pendingChunks.decrementAndGet() == 0) { downloadCompletion?.complete(Unit) } + rethrow?.let { throw it } emit(Unit) }.flowOn(Dispatchers.IO) } @@ -863,8 +872,7 @@ class DepotDownloader @JvmOverloads constructor( } private suspend fun downloadSteam3(mainAppId: Int, depots: List): Unit = coroutineScope { - val maxNumServers = maxDownloads.coerceIn(20, 64) // Hard clamp at 64. Not sure how high we can go. - cdnClientPool?.updateServerList(maxNumServers) + cdnClientPool?.updateServerList(maxDownloads) val downloadCounter = GlobalDownloadCounter() val depotsToDownload = ArrayList(depots.size) @@ -1028,7 +1036,7 @@ class DepotDownloader @JvmOverloads constructor( continue } - cdnClientPool!!.returnBrokenConnection(connection) + cdnClientPool!!.skipConnection(connection) // Unauthorized || Forbidden if (e.statusCode == 401 || e.statusCode == 403) { @@ -1044,8 +1052,13 @@ class DepotDownloader @JvmOverloads constructor( logger?.error("Encountered error downloading depot manifest ${depot.depotId} ${depot.manifestId}: ${e.statusCode}") } catch (e: Exception) { - cdnClientPool!!.returnBrokenConnection(connection) + if (e is java.net.UnknownHostException || e.cause is java.net.UnknownHostException) { + cdnClientPool!!.returnBrokenConnection(connection) + } else { + cdnClientPool!!.skipConnection(connection) + } logger?.error("Encountered error downloading manifest for depot ${depot.depotId} ${depot.manifestId}: ${e.message}") + if (!cdnClientPool!!.hasServers()) break } } while (newManifest == null) @@ -1451,7 +1464,7 @@ class DepotDownloader @JvmOverloads constructor( break } catch (e: CancellationException) { - logger?.error("Cancellation exception in download depot file chunk", e) + throw e } catch (e: SteamKitWebRequestException) { // If the CDN returned 403, attempt to get a cdn auth if we didn't yet, // if auth task already exists, make sure it didn't complete yet, so that it gets awaited above @@ -1468,7 +1481,7 @@ class DepotDownloader @JvmOverloads constructor( continue } - cdnClientPool!!.returnBrokenConnection(connection) + cdnClientPool!!.skipConnection(connection) // Unauthorized || Forbidden if (e.statusCode == 401 || e.statusCode == 403) { @@ -1478,8 +1491,13 @@ class DepotDownloader @JvmOverloads constructor( logger?.error("Encountered error downloading chunk $chunkID: ${e.statusCode}") } catch (e: Exception) { - cdnClientPool!!.returnBrokenConnection(connection) + if (e is java.net.UnknownHostException || e.cause is java.net.UnknownHostException) { + cdnClientPool!!.returnBrokenConnection(connection) + } else { + cdnClientPool!!.skipConnection(connection) + } logger?.error("Encountered unexpected error downloading chunk $chunkID", e) + if (!cdnClientPool!!.hasServers()) break } } while (downloaded == 0) @@ -1528,8 +1546,6 @@ class DepotDownloader @JvmOverloads constructor( if (appItem != null) { notifyListeners { it.onDownloadCompleted(appItem) } } - - completionFuture.complete(null) } // endregion @@ -1614,97 +1630,102 @@ class DepotDownloader @JvmOverloads constructor( createChunkProcessingFlow().collect() } - for (item in processingChannel) { - try { - ensureActive() - - // Set configuration values - config = config.copy( - downloadManifestOnly = item.downloadManifestOnly, - installPath = item.installDirectory?.toPath(), - installToGameNameDirectory = item.installToGameNameDirectory, - ) - - processingItemsMap[item.appId] = item + try { + for (item in processingChannel) { + try { + ensureActive() - when (item) { - is PubFileItem -> { - logger?.debug("Downloading PUB File for ${item.appId}") - notifyListeners { it.onDownloadStarted(item) } - downloadPubFile(item.appId, item.pubFile) - } + // Set configuration values + config = config.copy( + downloadManifestOnly = item.downloadManifestOnly, + installPath = item.installDirectory?.toPath(), + installToGameNameDirectory = item.installToGameNameDirectory, + verifyAll = item.verify, + ) - is UgcItem -> { - logger?.debug("Downloading UGC File for ${item.appId}") - notifyListeners { it.onDownloadStarted(item) } - downloadUGC(item.appId, item.ugcId) - } + processingItemsMap[item.appId] = item - is AppItem -> { - val branch = item.branch ?: DEFAULT_BRANCH - config = config.copy(betaPassword = item.branchPassword) + when (item) { + is PubFileItem -> { + logger?.debug("Downloading PUB File for ${item.appId}") + notifyListeners { it.onDownloadStarted(item) } + downloadPubFile(item.appId, item.pubFile) + } - if (!config.betaPassword.isNullOrBlank() && branch.isBlank()) { - logger?.error("Error: Cannot specify 'branchpassword' when 'branch' is not specified.") - continue + is UgcItem -> { + logger?.debug("Downloading UGC File for ${item.appId}") + notifyListeners { it.onDownloadStarted(item) } + downloadUGC(item.appId, item.ugcId) } - config = config.copy(downloadAllPlatforms = item.downloadAllPlatforms) - val os = item.os + is AppItem -> { + val branch = item.branch ?: DEFAULT_BRANCH + config = config.copy(betaPassword = item.branchPassword) - if (config.downloadAllPlatforms && !os.isNullOrBlank()) { - logger?.error("Error: Cannot specify `os` when `all-platforms` is specified.") - continue - } + if (!config.betaPassword.isNullOrBlank() && branch.isBlank()) { + logger?.error("Error: Cannot specify 'branchpassword' when 'branch' is not specified.") + continue + } - config = config.copy(downloadAllArchs = item.downloadAllArchs) - val arch = item.osArch + config = config.copy(downloadAllPlatforms = item.downloadAllPlatforms) + val os = item.os - if (config.downloadAllArchs && !arch.isNullOrBlank()) { - logger?.error("Error: Cannot specify `osarch` when `all-archs` is specified.") - continue - } + if (config.downloadAllPlatforms && !os.isNullOrBlank()) { + logger?.error("Error: Cannot specify `os` when `all-platforms` is specified.") + continue + } - config = config.copy(downloadAllLanguages = item.downloadAllLanguages) - val language = item.language + config = config.copy(downloadAllArchs = item.downloadAllArchs) + val arch = item.osArch - if (config.downloadAllLanguages && !language.isNullOrBlank()) { - logger?.error("Error: Cannot specify `language` when `all-languages` is specified.") - continue - } + if (config.downloadAllArchs && !arch.isNullOrBlank()) { + logger?.error("Error: Cannot specify `osarch` when `all-archs` is specified.") + continue + } - val depotManifestIds = mutableListOf>() - val depotIdList = item.depot - val manifestIdList = item.manifest + config = config.copy(downloadAllLanguages = item.downloadAllLanguages) + val language = item.language - if (manifestIdList.isNotEmpty()) { - if (depotIdList.size != manifestIdList.size) { - logger?.error("Error: `manifest` requires one id for every `depot` specified") + if (config.downloadAllLanguages && !language.isNullOrBlank()) { + logger?.error("Error: Cannot specify `language` when `all-languages` is specified.") continue } - depotManifestIds.addAll(depotIdList.zip(manifestIdList)) - } else { - depotManifestIds.addAll(depotIdList.map { it to INVALID_MANIFEST_ID }) - } - logger?.debug("Downloading App for ${item.appId}") - notifyListeners { it.onDownloadStarted(item) } - downloadApp( - appId = item.appId, - depotManifestIds = depotManifestIds, - branch = branch, - os = os, - arch = arch, - language = language, - lv = item.lowViolence, - isUgc = false, - ) + val depotManifestIds = mutableListOf>() + val depotIdList = item.depot + val manifestIdList = item.manifest + + if (manifestIdList.isNotEmpty()) { + if (depotIdList.size != manifestIdList.size) { + logger?.error("Error: `manifest` requires one id for every `depot` specified") + continue + } + depotManifestIds.addAll(depotIdList.zip(manifestIdList)) + } else { + depotManifestIds.addAll(depotIdList.map { it to INVALID_MANIFEST_ID }) + } + + logger?.debug("Downloading App for ${item.appId}") + notifyListeners { it.onDownloadStarted(item) } + downloadApp( + appId = item.appId, + depotManifestIds = depotManifestIds, + branch = branch, + os = os, + arch = arch, + language = language, + lv = item.lowViolence, + isUgc = false, + ) + } } + } catch (e: Exception) { + logger?.error("Error downloading item ${item.appId}: ${e.message}", e) + notifyListeners { it.onDownloadFailed(item, e) } } - } catch (e: Exception) { - logger?.error("Error downloading item ${item.appId}: ${e.message}", e) - notifyListeners { it.onDownloadFailed(item, e) } } + } finally { + completionFuture.complete(null) } } diff --git a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/data/DownloadItems.kt b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/data/DownloadItems.kt index 813e0b52..646e9670 100644 --- a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/data/DownloadItems.kt +++ b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/data/DownloadItems.kt @@ -2,14 +2,15 @@ package `in`.dragonbra.javasteam.depotdownloader.data // https://kotlinlang.org/docs/coding-conventions.html#source-file-organization -// TODO should these be a builder pattern for Java users? - /** * Base class for downloadable Steam content items. * @property appId The Steam application ID - * @property installDirectory Optional custom installation directory path - * @property installToGameNameDirectory If true, installs to a directory named after the game - * @property downloadManifestOnly If true, only downloads the manifest file without actual content + * @property installDirectory Optional custom installation directory path. If null, defaults to a + * depot-specific subdirectory under the current working directory. + * @property installToGameNameDirectory If true, installs into a subdirectory named after the game + * instead of directly into [installDirectory]. + * @property verify If true, validates existing files against the manifest before downloading. + * @property downloadManifestOnly If true, saves the depot manifest to disk without downloading content. * * @author Lossy * @since Oct 1, 2025 @@ -18,15 +19,24 @@ abstract class DownloadItem( val appId: Int, val installDirectory: String?, val installToGameNameDirectory: Boolean, - val verify: Boolean, // TODO + val verify: Boolean, val downloadManifestOnly: Boolean, ) /** * Represents a Steam Workshop (UGC - User Generated Content) item for download. - * - * @property ugcId The unique UGC item identifier - * + * Prefer [Builder] when calling from Java. From Kotlin, use the primary constructor with named arguments. + * @property ugcId The unique UGC handle identifying the workshop item. + * **Kotlin:** + * ```kotlin + * val item = UgcItem(appId = 440, ugcId = 123456789L, installDirectory = "tf2") + * ``` + * **Java:** + * ```java + * var item = new UgcItem.Builder(440, 123456789L) + * .installDirectory("tf2") + * .build(); + * ``` * @author Lossy * @since Oct 1, 2025 */ @@ -37,13 +47,46 @@ class UgcItem @JvmOverloads constructor( installDirectory: String? = null, verify: Boolean = false, downloadManifestOnly: Boolean = false, -) : DownloadItem(appId, installDirectory, installToGameNameDirectory, verify, downloadManifestOnly) +) : DownloadItem(appId, installDirectory, installToGameNameDirectory, verify, downloadManifestOnly) { + + @Suppress("unused") + class Builder(private val appId: Int, private val ugcId: Long) { + private var installToGameNameDirectory: Boolean = false + private var installDirectory: String? = null + private var verify: Boolean = false + private var downloadManifestOnly: Boolean = false + + fun installToGameNameDirectory(v: Boolean) = apply { installToGameNameDirectory = v } + fun installDirectory(v: String?) = apply { installDirectory = v } + fun verify(v: Boolean) = apply { verify = v } + fun downloadManifestOnly(v: Boolean) = apply { downloadManifestOnly = v } + + fun build() = UgcItem( + appId = appId, + ugcId = ugcId, + installToGameNameDirectory = installToGameNameDirectory, + installDirectory = installDirectory, + verify = verify, + downloadManifestOnly = downloadManifestOnly, + ) + } +} /** * Represents a Steam published file for download. * - * @property pubfile The published file identifier - * + * Prefer [Builder] when calling from Java. From Kotlin, use the primary constructor with named arguments. + * @property pubFile The published file ID identifying the item on the Steam Workshop. + * **Kotlin:** + * ```kotlin + * val item = PubFileItem(appId = 440, pubFile = 123456789L, installDirectory = "tf2") + * ``` + * **Java:** + * ```java + * var item = new PubFileItem.Builder(440, 123456789L) + * .installDirectory("tf2") + * .build(); + * ``` * @author Lossy * @since Oct 1, 2025 */ @@ -54,23 +97,68 @@ class PubFileItem @JvmOverloads constructor( installDirectory: String? = null, verify: Boolean = false, downloadManifestOnly: Boolean = false, -) : DownloadItem(appId, installDirectory, installToGameNameDirectory, verify, downloadManifestOnly) +) : DownloadItem(appId, installDirectory, installToGameNameDirectory, verify, downloadManifestOnly) { + + @Suppress("unused") + class Builder(private val appId: Int, private val pubFile: Long) { + private var installToGameNameDirectory: Boolean = false + private var installDirectory: String? = null + private var verify: Boolean = false + private var downloadManifestOnly: Boolean = false + + fun installToGameNameDirectory(v: Boolean) = apply { installToGameNameDirectory = v } + fun installDirectory(v: String?) = apply { installDirectory = v } + fun verify(v: Boolean) = apply { verify = v } + fun downloadManifestOnly(v: Boolean) = apply { downloadManifestOnly = v } + + fun build() = PubFileItem( + appId = appId, + pubFile = pubFile, + installToGameNameDirectory = installToGameNameDirectory, + installDirectory = installDirectory, + verify = verify, + downloadManifestOnly = downloadManifestOnly, + ) + } +} /** - * Represents a Steam application/game for download from a depot. - * - * @property branch The branch name to download from (e.g., "public", "beta") - * @property branchPassword Password for password-protected branches - * @property downloadAllPlatforms If true, downloads depots for all platforms - * @property os Operating system filter (e.g., "windows", "macos", "linux") - * @property downloadAllArchs If true, downloads depots for all architectures - * @property osArch Architecture filter (e.g., "32", "64") - * @property downloadAllLanguages If true, downloads depots for all languages - * @property language Language filter (e.g., "english", "french") - * @property lowViolence If true, downloads low-violence versions where available - * @property depot List of specific depot IDs to download - * @property manifest List of specific manifest IDs corresponding to depot IDs + * Represents a Steam application or game for download from a depot. + * Prefer [Builder] when calling from Java. From Kotlin, use the primary constructor with named arguments. + * @property branch Branch to download from (e.g. `"public"`, `"beta"`). Defaults to `"public"` when null. + * @property branchPassword Password for password-protected branches. + * @property downloadAllPlatforms If true, ignores the [os] filter and downloads depots for all platforms. + * @property os Operating system filter (e.g. `"windows"`, `"macos"`, `"linux"`). Null means use the + * host OS. + * @property downloadAllArchs If true, ignores the [osArch] filter and downloads depots for all architectures. + * @property osArch Architecture filter (e.g. `"32"`, `"64"`). Null means use the host architecture. + * @property downloadAllLanguages If true, ignores the [language] filter and downloads depots for all languages. + * @property language Language filter (e.g. `"english"`, `"french"`). Null means use `"english"`. + * @property lowViolence If true, prefers low-violence depot variants where available. + * @property depot Specific depot IDs to download. Empty list means download all depots for the app. + * @property manifest Specific manifest IDs paired 1:1 with [depot]. Empty list means use the latest manifest. + * **Kotlin:** + * ```kotlin + * val item = AppItem( + * appId = 1303350, + * installDirectory = "steamapps", + * branch = "public", + * os = "windows", + * osArch = "64", + * language = "english", + * ) + * ``` * + * **Java:** + * ```java + * var item = new AppItem.Builder(1303350) + * .installDirectory("steamapps") + * .branch("public") + * .os("windows") + * .osArch("64") + * .language("english") + * .build(); + * ``` * @author Lossy * @since Oct 1, 2025 */ @@ -91,4 +179,59 @@ class AppItem @JvmOverloads constructor( val manifest: List = emptyList(), verify: Boolean = false, downloadManifestOnly: Boolean = false, -) : DownloadItem(appId, installDirectory, installToGameNameDirectory, verify, downloadManifestOnly) +) : DownloadItem(appId, installDirectory, installToGameNameDirectory, verify, downloadManifestOnly) { + + @Suppress("unused") + class Builder(private val appId: Int) { + private var installToGameNameDirectory: Boolean = false + private var installDirectory: String? = null + private var branch: String? = null + private var branchPassword: String? = null + private var downloadAllPlatforms: Boolean = false + private var os: String? = null + private var downloadAllArchs: Boolean = false + private var osArch: String? = null + private var downloadAllLanguages: Boolean = false + private var language: String? = null + private var lowViolence: Boolean = false + private var depot: List = emptyList() + private var manifest: List = emptyList() + private var verify: Boolean = false + private var downloadManifestOnly: Boolean = false + + fun installToGameNameDirectory(v: Boolean) = apply { installToGameNameDirectory = v } + fun installDirectory(v: String?) = apply { installDirectory = v } + fun branch(v: String?) = apply { branch = v } + fun branchPassword(v: String?) = apply { branchPassword = v } + fun downloadAllPlatforms(v: Boolean) = apply { downloadAllPlatforms = v } + fun os(v: String?) = apply { os = v } + fun downloadAllArchs(v: Boolean) = apply { downloadAllArchs = v } + fun osArch(v: String?) = apply { osArch = v } + fun downloadAllLanguages(v: Boolean) = apply { downloadAllLanguages = v } + fun language(v: String?) = apply { language = v } + fun lowViolence(v: Boolean) = apply { lowViolence = v } + fun depot(v: List) = apply { depot = v } + fun manifest(v: List) = apply { manifest = v } + fun verify(v: Boolean) = apply { verify = v } + fun downloadManifestOnly(v: Boolean) = apply { downloadManifestOnly = v } + + fun build() = AppItem( + appId = appId, + installToGameNameDirectory = installToGameNameDirectory, + installDirectory = installDirectory, + branch = branch, + branchPassword = branchPassword, + downloadAllPlatforms = downloadAllPlatforms, + os = os, + downloadAllArchs = downloadAllArchs, + osArch = osArch, + downloadAllLanguages = downloadAllLanguages, + language = language, + lowViolence = lowViolence, + depot = depot, + manifest = manifest, + verify = verify, + downloadManifestOnly = downloadManifestOnly, + ) + } +} diff --git a/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_023_downloadapp/SampleDownloadApp.java b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_023_downloadapp/SampleDownloadApp.java index 1305d187..b7297747 100644 --- a/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_023_downloadapp/SampleDownloadApp.java +++ b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_023_downloadapp/SampleDownloadApp.java @@ -88,9 +88,18 @@ public void run() { // Anything pertaining to this sample will be commented. // Depot chunks are downloaded using OKHttp, it's best to set some timeouts. + // OkHttp's default dispatcher uses non-daemon threads with a 60s idle keepalive, which + // would prevent JVM exit after the download completes. Daemon threads exit with the app. + var dispatcher = new okhttp3.Dispatcher(java.util.concurrent.Executors.newCachedThreadPool(r -> { + var t = new Thread(r, "OkHttp Dispatcher"); + t.setDaemon(true); + return t; + })); + var config = SteamConfiguration.create(builder -> { builder.withHttpClient( new OkHttpClient.Builder() + .dispatcher(dispatcher) .connectTimeout(10, TimeUnit.SECONDS) // Time to establish connection .readTimeout(60, TimeUnit.SECONDS) // Max inactivity between reads .writeTimeout(30, TimeUnit.SECONDS) // Time for writes @@ -194,7 +203,7 @@ private void onConnected(ConnectedCallback callback) { private void onDisconnected(DisconnectedCallback callback) { System.out.println("Disconnected from Steam, UserInitiated: " + callback.isUserInitiated()); - if (callback.isUserInitiated()) { + if (callback.isUserInitiated() || !isRunning) { isRunning = false; } else { try { @@ -252,47 +261,43 @@ private void onLoggedOff(LoggedOffCallback callback) { private void downloadApp() { // Initiate the DepotDownloader, it is a Closable so it can be cleaned up when no longer used. // You will need to subscribe to LicenseListCallback to obtain your app licenses. - try (var depotDownloader = new DepotDownloader(steamClient, licenseList, true)) { + try (var depotDownloader = new DepotDownloader( + steamClient, + licenseList, + false, // debug + false, // useLanCache + 50, // maxDownloads: concurrent chunk downloads from CDN. Higher = faster on good connections, + // but increases memory usage and may overwhelm slow or metered connections. + 20 // maxFileWrites: concurrent file write operations. Higher = better throughput on SSDs, + // but diminishing returns on HDDs where concurrent writes cause seek thrashing. + )) { // Add this class as a listener of IDownloadListener depotDownloader.addListener(this); - var pubItem = new PubFileItem( - /* (Required) appId */ 0, - /* (Required) pubFile */ 0, - /* (Optional) installToGameNameDirectory */ false, - /* (Optional) installDirectory */ null, - /* (Optional) verify */ false, - /* (Optional) downloadManifestOnly */ false - ); - - var ugcItem = new UgcItem( - /* (Required) appId */0, - /* (Required) ugcId */ 0, - /* (Optional) installToGameNameDirectory */ false, - /* (Optional) installDirectory */ null, - /* (Optional) verify */ false, - /* (Optional) downloadManifestOnly */ false - ); - - var appItem = new AppItem( - /* (Required) appId */ 1303350, - /* (Optional) installToGameNameDirectory */ true, - /* (Optional) installDirectory */ "steamapps", - /* (Optional) branch */ "public", - /* (Optional) branchPassword */ "", - /* (Optional) downloadAllPlatforms */ false, - /* (Optional) os */ "windows", - /* (Optional) downloadAllArchs */ false, - /* (Optional) osArch */ "64", - /* (Optional) downloadAllLanguages */ false, - /* (Optional) language */ "english", - /* (Optional) lowViolence */ false, - /* (Optional) depot */ List.of(), - /* (Optional) manifest */ List.of(), - /* (Optional) verify */ false, - /* (Optional) downloadManifestOnly */ false - ); + // PubFileItem downloads a Steam Workshop item by its published file ID. + // The published file ID appears in the Workshop item's URL, e.g.: + // "steamcommunity.com/sharedfiles/filedetails/?id=123456789" -> pubFile = 123456789 + var pubItem = new PubFileItem.Builder(0, 0) + .build(); + + // UgcItem downloads a UGC (User Generated Content) asset by its UGC handle. + // UGC handles are lower-level than published file IDs and are typically obtained + // programmatically (e.g. from GetUGCDetails), not from a URL. + var ugcItem = new UgcItem.Builder(0, 0) + .build(); + + // AppItem downloads a Steam application's depot content by app ID. + // The app ID appears in the store URL, e.g.: + // "store.steampowered.com/app/12210/Grand_Theft_Auto_IV/" -> appId = 12210 + var appItem = new AppItem.Builder(1303350) + .installToGameNameDirectory(true) + .installDirectory("steamapps") + .branch("public") + .os("windows") + .osArch("64") + .language("english") + .build(); // Items added are downloaded automatically in a FIFO (First-In, First-Out) queue. From 178454a53d17e233bd0d5c1450e75864bd957cb5 Mon Sep 17 00:00:00 2001 From: Lossy Date: Sun, 14 Jun 2026 00:53:07 -0500 Subject: [PATCH 08/27] Rename .java to .kt --- .../javasteam/util/{HardwareUtils.java => HardwareUtils.kt} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/java/in/dragonbra/javasteam/util/{HardwareUtils.java => HardwareUtils.kt} (100%) diff --git a/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.java b/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/HardwareUtils.java rename to src/main/java/in/dragonbra/javasteam/util/HardwareUtils.kt From 58647bf1e32ae774ad96c0f445b9bff4bb8b8b9f Mon Sep 17 00:00:00 2001 From: Lossy Date: Sun, 14 Jun 2026 00:53:07 -0500 Subject: [PATCH 09/27] Apply more audit recommendations for async stability --- .../dragonbra/javasteam/steam/CMClient.java | 58 ++-- .../steam/discovery/SmartCMServerList.kt | 100 +++--- .../steam/steamclient/AsyncJobManager.kt | 24 +- .../steam/steamclient/SteamClient.kt | 92 +++-- .../in/dragonbra/javasteam/types/AsyncJob.kt | 4 +- .../javasteam/types/AsyncJobMultiple.kt | 41 +-- .../dragonbra/javasteam/util/HardwareUtils.kt | 326 +++++------------- .../javasteam/util/event/ScheduledFunction.kt | 36 +- .../javasteam/util/HardwareUtilsTest.java | 11 - 9 files changed, 280 insertions(+), 412 deletions(-) diff --git a/src/main/java/in/dragonbra/javasteam/steam/CMClient.java b/src/main/java/in/dragonbra/javasteam/steam/CMClient.java index 377fd368..f67ef178 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/CMClient.java +++ b/src/main/java/in/dragonbra/javasteam/steam/CMClient.java @@ -46,37 +46,37 @@ public abstract class CMClient { private final SteamConfiguration configuration; @Nullable - private InetAddress publicIP; + private volatile InetAddress publicIP; @Nullable - private String ipCountryCode; + private volatile String ipCountryCode; @Nullable - private String userCountryCode; + private volatile String userCountryCode; - private boolean isConnected; - - private long sessionToken; + @Nullable + private volatile Integer cellID; @Nullable - private Integer cellID; + private volatile Integer sessionID; @Nullable - private Integer sessionID; + private volatile SteamID steamID; @Nullable - private SteamID steamID; + private volatile Connection connection; + + private volatile boolean isConnected; - private IDebugNetworkListener debugNetworkListener; + private volatile long sessionToken; - private boolean expectDisconnection; + private volatile IDebugNetworkListener debugNetworkListener; + + private volatile boolean expectDisconnection; // connection lock around the setup and tear down of the connection task private final Object connectionLock = new Object(); - @Nullable - private Connection connection; - private final ScheduledFunction heartBeatFunc; private final EventHandler netMsgReceived = (sender, e) -> onClientMsgReceived(getPacketMsg(e.getData())); @@ -104,16 +104,19 @@ public void handleEvent(Object sender, DisconnectedEventArgs e) { isConnected = false; - if (!e.isUserInitiated() && !expectDisconnection) { - getServers().tryMark(connection.getCurrentEndPoint(), connection.getProtocolTypes(), ServerQuality.BAD); + var conn = connection; + if (conn != null) { + if (!e.isUserInitiated() && !expectDisconnection) { + getServers().tryMark(conn.getCurrentEndPoint(), conn.getProtocolTypes(), ServerQuality.BAD); + } + + conn.getNetMsgReceived().removeEventHandler(netMsgReceived); + conn.getConnected().removeEventHandler(connected); + conn.getDisconnected().removeEventHandler(this); } sessionID = null; steamID = null; - - connection.getNetMsgReceived().removeEventHandler(netMsgReceived); - connection.getConnected().removeEventHandler(connected); - connection.getDisconnected().removeEventHandler(this); connection = null; heartBeatFunc.stop(); @@ -247,8 +250,9 @@ public void send(IClientMsg msg) { } try { - if (debugNetworkListener != null) { - debugNetworkListener.onOutgoingNetworkMessage(msg.getMsgType(), msg.serialize()); + var listener = debugNetworkListener; + if (listener != null) { + listener.onOutgoingNetworkMessage(msg.getMsgType(), msg.serialize()); } } catch (Exception e) { logger.debug("DebugNetworkListener threw an exception", e); @@ -258,8 +262,9 @@ public void send(IClientMsg msg) { // on the network thread, and that will lead to a disconnect callback // down the line - if (connection != null) { - connection.send(msg.serialize()); + var conn = connection; + if (conn != null) { + conn.send(msg.serialize()); } } @@ -273,8 +278,9 @@ protected boolean onClientMsgReceived(IPacketMsg packetMsg) { // Multi message gets logged down the line after it's decompressed if (packetMsg.getMsgType() != EMsg.Multi) { try { - if (debugNetworkListener != null) { - debugNetworkListener.onIncomingNetworkMessage(packetMsg.getMsgType(), packetMsg.getData()); + var listener = debugNetworkListener; + if (listener != null) { + listener.onIncomingNetworkMessage(packetMsg.getMsgType(), packetMsg.getData()); } } catch (Exception e) { logger.debug("debugNetworkListener threw an exception", e); diff --git a/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt b/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt index d3747c10..8d78bb51 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt @@ -10,6 +10,8 @@ import java.net.InetSocketAddress import java.time.Duration import java.time.Instant import java.util.EnumSet +import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock /** * Smart list of CM servers. @@ -41,6 +43,12 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { private val servers: MutableList = mutableListOf() private var serversLastRefresh: Instant = Instant.MIN + // Protects servers and serversLastRefresh + private val listLock = ReentrantLock() + + // Serializes concurrent network fetches so at most one thread calls SteamDirectory.load() at a time. + private val fetchLock = ReentrantLock() + /** * Determines how long the server list cache is used as-is before attempting to refresh from the Steam Directory. */ @@ -53,22 +61,28 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { @Suppress("MemberVisibilityCanBePrivate") var badConnectionMemoryTimeSpan: Duration = Duration.ofMinutes(5) - @Synchronized @Throws(IOException::class) private fun startFetchingServers() { - if (servers.isNotEmpty()) { - // if the server list has been populated, no need to perform any additional work - if (Duration.between(serversLastRefresh, Instant.now()) >= serverListBeforeRefreshTimeSpan) { - resolveServerList(forceRefresh = true) - } else { - // no work needs to be done + val (isEmpty, needsRefresh) = listLock.withLock { + val empty = servers.isEmpty() + val stale = Duration.between(serversLastRefresh, Instant.now()) >= serverListBeforeRefreshTimeSpan + empty to stale + } + + if (!isEmpty && !needsRefresh) return + + fetchLock.withLock { + val (isEmptyNow, needsRefreshNow) = listLock.withLock { + val empty = servers.isEmpty() + val stale = Duration.between(serversLastRefresh, Instant.now()) >= serverListBeforeRefreshTimeSpan + empty to stale } - } else { - resolveServerList() + if (!isEmptyNow && !needsRefreshNow) return + + resolveServerList(forceRefresh = !isEmptyNow) } } - @Synchronized @Throws(IOException::class) private fun resolveServerList(forceRefresh: Boolean = false) { var forcedRefresh = forceRefresh @@ -147,8 +161,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { * Resets the scores of all servers which has a last bad connection more than [SmartCMServerList.badConnectionMemoryTimeSpan] ago. */ @Suppress("MemberVisibilityCanBePrivate") - @Synchronized - fun resetOldScores() { + fun resetOldScores() = listLock.withLock { val cutoff = Instant.now().minus(badConnectionMemoryTimeSpan) servers.forEach { serverInfo -> @@ -162,20 +175,19 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { /** * Replace the list with a new list of servers provided to us by the Steam servers. - * * @param endpointList The [ServerRecord] to use for this [SmartCMServerList]. * @param writeProvider If true, the replaced list will be updated in the server list provider. * @param serversTime The time when the provided server list has been updated. */ @JvmOverloads - @Synchronized fun replaceList(endpointList: List, writeProvider: Boolean = true, serversTime: Instant? = null) { val distinctEndPoints = endpointList.distinct() - serversLastRefresh = serversTime ?: Instant.now() - servers.clear() - - distinctEndPoints.forEach(::addCore) + listLock.withLock { + serversLastRefresh = serversTime ?: Instant.now() + servers.clear() + distinctEndPoints.forEach(::addCore) + } if (writeProvider) { configuration.serverListProvider.updateServerList(distinctEndPoints) @@ -192,40 +204,35 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { /** * Explicitly resets the known state of all servers. */ - @Synchronized - fun resetBadServers() { + fun resetBadServers(): Unit = listLock.withLock { servers.forEach { serverInfo -> serverInfo.lastBadConnectionTimeUtc = null } } - @Synchronized fun tryMark(endPoint: InetSocketAddress?, protocolTypes: ProtocolTypes?, quality: ServerQuality): Boolean = tryMark(endPoint, protocolTypes?.let { EnumSet.of(it) }, quality) - @Synchronized - fun tryMark(endPoint: InetSocketAddress?, protocolTypes: EnumSet?, quality: ServerQuality): Boolean { + fun tryMark( + endPoint: InetSocketAddress?, + protocolTypes: EnumSet?, + quality: ServerQuality, + ): Boolean = listLock.withLock { if (endPoint == null || protocolTypes == null) { logger.error("Couldn't mark an endpoint ${quality.name}, skipping it") - return false + return@withLock false } - val serverInfos: List - - if (quality == ServerQuality.GOOD) { - serverInfos = servers.filter { x -> - x.record.endpoint == endPoint && protocolTypes.contains(x.protocol) - } + val serverInfos: List = if (quality == ServerQuality.GOOD) { + servers.filter { x -> x.record.endpoint == endPoint && protocolTypes.contains(x.protocol) } } else { // If we're marking this server for any failure, mark all endpoints for the host at the same time val host = endPoint.hostString - serverInfos = servers.filter { x -> - x.record.host == host - } + servers.filter { x -> x.record.host == host } } if (serverInfos.isEmpty()) { - return false + return@withLock false } for (serverInfo in serverInfos) { @@ -233,7 +240,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { markServerCore(serverInfo, quality) } - return true + true } private fun markServerCore(serverInfo: ServerInfo, quality: ServerQuality) { @@ -245,12 +252,12 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { /** * Perform the actual score lookup of the server list and return the candidate. - * * @param supportedProtocolTypes The minimum supported [ProtocolTypes] of the server to return. * @return An [ServerRecord], or null if the list is empty. */ - @Synchronized - private fun getNextServerCandidateInternal(supportedProtocolTypes: EnumSet): ServerRecord? { + private fun getNextServerCandidateInternal( + supportedProtocolTypes: EnumSet, + ): ServerRecord? = listLock.withLock { resetOldScores() val result = servers @@ -260,22 +267,17 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { .sortedWith(compareBy({ it.first.lastBadConnectionTimeUtc ?: Instant.EPOCH }, { it.second })) .map { it.first } .firstOrNull() - - if (result == null) { - return null - } + ?: return@withLock null logger.debug("Next server candidate: ${result.record.endpoint} (${result.protocol})") - return ServerRecord(result.record.endpoint, result.protocol) + ServerRecord(result.record.endpoint, result.protocol) } /** * Get the next server in the list. - * * @param supportedProtocolTypes The minimum supported [ProtocolTypes] of the server to return. * @return An [ServerRecord], or null if the list is empty. */ - @Synchronized fun getNextServerCandidate(supportedProtocolTypes: EnumSet): ServerRecord? { return runCatching { startFetchingServers() @@ -292,11 +294,9 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { /** * Get the next server in the list. - * * @param supportedProtocolTypes The minimum supported [ProtocolTypes] of the server to return. * @return An [ServerRecord], or null if the list is empty. */ - @Synchronized fun getNextServerCandidate(supportedProtocolTypes: ProtocolTypes): ServerRecord? = getNextServerCandidate(EnumSet.of(supportedProtocolTypes)) @@ -304,11 +304,10 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { * Gets the [ServerRecords][ServerRecord] of all servers in the server list. * @return An [List] array contains the [InetSocketAddress] of the servers in the list */ - @Synchronized fun getAllEndPoints(): List = runCatching { startFetchingServers() }.fold( - onSuccess = { servers.map { s -> s.record }.distinct() }, + onSuccess = { listLock.withLock { servers.map { s -> s.record }.distinct() } }, onFailure = { error -> logger.error("Failed to fetch end points", error) emptyList() @@ -320,9 +319,8 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { * and then fallback to the server list provider. * @return whether the refresh was successful or not. **/ - @Synchronized fun forceRefreshServerList(): Boolean = runCatching { - resolveServerList(forceRefresh = true) + fetchLock.withLock { resolveServerList(forceRefresh = true) } }.fold( onSuccess = { true }, onFailure = { error -> diff --git a/src/main/java/in/dragonbra/javasteam/steam/steamclient/AsyncJobManager.kt b/src/main/java/in/dragonbra/javasteam/steam/steamclient/AsyncJobManager.kt index 66c40092..1f28ec32 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/steamclient/AsyncJobManager.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/steamclient/AsyncJobManager.kt @@ -24,7 +24,6 @@ class AsyncJobManager { /** * Tracks a job with this manager. - * * @param asyncJob The asynchronous job to track */ fun startJob(asyncJob: AsyncJob) { @@ -34,7 +33,6 @@ class AsyncJobManager { /** * Passes a callback to a pending async job. * If the given callback completes the job, the job is removed from this manager. - * * @param jobID the job. * @param callback the callback. */ @@ -54,7 +52,6 @@ class AsyncJobManager { /** * Extends the lifetime of a job. - * * @param jobID The job identifier. */ fun heartbeatJob(jobID: JobID) { @@ -66,7 +63,6 @@ class AsyncJobManager { /** * Marks a certain job as remotely failed. - * * @param jobID The job identifier. */ fun failJob(jobID: JobID) { @@ -91,7 +87,6 @@ class AsyncJobManager { /** * Enables or disables periodic checks for job timeouts. - * * @param enable Whether the job timeout checks should be enabled. */ fun setTimeoutsEnabled(enable: Boolean) { @@ -116,23 +111,10 @@ class AsyncJobManager { /** * Retrieves a job from this manager, and optionally removes it from tracking. - * * @param jobID the job id. * @param andRemove If set to true, this job is removed from tracking. - * @return . + * @return The [AsyncJob] for the given [jobID], or null if not tracked. */ - private fun getJob(jobID: JobID, andRemove: Boolean = false): AsyncJob? { - val asyncJob: AsyncJob? - val foundJob: Boolean - - if (andRemove) { - asyncJob = asyncJobs[jobID] - foundJob = asyncJobs.remove(jobID, asyncJobs[jobID]) - } else { - asyncJob = asyncJobs[jobID] - foundJob = asyncJob != null - } - - return if (foundJob) asyncJob else null - } + private fun getJob(jobID: JobID, andRemove: Boolean = false): AsyncJob? = + if (andRemove) asyncJobs.remove(jobID) else asyncJobs[jobID] } diff --git a/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt b/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt index c0fe4075..4c34dbe2 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt @@ -27,18 +27,15 @@ import `in`.dragonbra.javasteam.steam.steamclient.callbacks.DisconnectedCallback import `in`.dragonbra.javasteam.steam.steamclient.configuration.SteamConfiguration import `in`.dragonbra.javasteam.types.AsyncJob import `in`.dragonbra.javasteam.types.JobID -import `in`.dragonbra.javasteam.types.JobID.Companion.toJobID import `in`.dragonbra.javasteam.util.JavaSteamAddition import `in`.dragonbra.javasteam.util.log.LogManager import `in`.dragonbra.javasteam.util.log.Logger -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withTimeoutOrNull +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.* +import java.io.Closeable import java.util.* -import java.util.concurrent.atomic.AtomicLong +import java.util.concurrent.* +import java.util.concurrent.atomic.* import kotlin.time.Duration.Companion.milliseconds /** @@ -52,7 +49,8 @@ import kotlin.time.Duration.Companion.milliseconds class SteamClient @JvmOverloads constructor( configuration: SteamConfiguration? = SteamConfiguration.createDefault(), internal val defaultScope: CoroutineScope = CoroutineScope(Dispatchers.IO + SupervisorJob()), -) : CMClient(configuration) { +) : CMClient(configuration), + Closeable { private val handlers = HashMap, ClientMsgHandler>(HANDLERS_COUNT) @@ -201,13 +199,49 @@ class SteamClient @JvmOverloads constructor( */ fun getCallback(): CallbackMsg? = callbackQueue.tryReceive().getOrNull() + /** + * Returns a [CompletableFuture] that completes with the next callback posted to the queue. + * The future completes on whichever coroutine thread delivers the callback — no calling thread is blocked. + * Java callers can chain [CompletableFuture.thenAccept] for non-blocking handling, or call + * [CompletableFuture.get] to block. + * @return A future that resolves to the next callback in the queue. + */ + fun waitForCallbackFuture(): CompletableFuture { + val future = CompletableFuture() + defaultScope.launch { + try { + future.complete(callbackQueue.receive()) + } catch (e: Exception) { + future.completeExceptionally(e) + } + } + return future + } + + /** + * Returns a [CompletableFuture] that completes with the next callback posted to the queue, + * or null if [timeout] milliseconds elapse first. No calling thread is blocked. + * @param timeout The maximum time to wait in milliseconds. + * @return A future that resolves to the next callback, or null on timeout. + */ + fun waitForCallbackFuture(timeout: Long): CompletableFuture { + val future = CompletableFuture() + defaultScope.launch { + try { + future.complete(withTimeoutOrNull(timeout.milliseconds) { callbackQueue.receive() }) + } catch (e: Exception) { + future.completeExceptionally(e) + } + } + return future + } + /** * Blocks the calling thread until a callback object is posted to the queue, and removes it. + * Prefer [waitForCallbackAsync] from Kotlin coroutines, or [waitForCallbackFuture] for non-blocking Java use. * @return The callback object from the queue. */ - fun waitForCallback(): CallbackMsg = runBlocking(Dispatchers.Default) { - callbackQueue.receive() - } + fun waitForCallback(): CallbackMsg = waitForCallbackFuture().getUnwrapped() /** * Asynchronously awaits until a callback object is posted to the queue, and removes it. @@ -217,19 +251,11 @@ class SteamClient @JvmOverloads constructor( /** * Blocks the calling thread until a callback object is posted to the queue, or null after the timeout has elapsed. + * Prefer [waitForCallbackAsync] from Kotlin coroutines, or [waitForCallbackFuture] for non-blocking Java use. * @param timeout The length of time to block in ms. * @return A callback object from the queue if a callback has been posted, or null if the timeout has elapsed. */ - fun waitForCallback(timeout: Long): CallbackMsg? = - if (timeout <= 0L) { - callbackQueue.tryReceive().getOrNull() - } else { - runBlocking { - withTimeoutOrNull(timeout.milliseconds) { - callbackQueue.receive() - } - } - } + fun waitForCallback(timeout: Long): CallbackMsg? = waitForCallbackFuture(timeout).getUnwrapped() /** * Posts a callback to the queue. This is normally used directly by client message handlers. @@ -338,6 +364,28 @@ class SteamClient @JvmOverloads constructor( jobManager.failJob(packetMsg.targetJobID.toJobID()) } + /** + * Shuts down this client. Disconnects from Steam, cancels all pending coroutines launched in + * [defaultScope] (including any blocked [waitForCallback] / [waitForCallbackFuture] calls and + * any in-flight handler coroutines), and closes the callback channel. + * After calling [close], this instance must not be reused. To reconnect, create a new [SteamClient]. + */ + override fun close() { + disconnect() + defaultScope.cancel() + callbackQueue.close() + } + + @JavaSteamAddition + private fun CompletableFuture.getUnwrapped(): T = try { + get() + } catch (e: ExecutionException) { + throw e.cause ?: e + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + throw RuntimeException("Interrupted while waiting for callback", e) + } + companion object { private val logger: Logger = LogManager.getLogger(SteamClient::class.java) diff --git a/src/main/java/in/dragonbra/javasteam/types/AsyncJob.kt b/src/main/java/in/dragonbra/javasteam/types/AsyncJob.kt index 90132969..f84e42fa 100644 --- a/src/main/java/in/dragonbra/javasteam/types/AsyncJob.kt +++ b/src/main/java/in/dragonbra/javasteam/types/AsyncJob.kt @@ -13,9 +13,9 @@ import java.time.Instant */ abstract class AsyncJob(val client: SteamClient, val jobID: JobID) { - private var jobStart = Instant.now() + @Volatile private var jobStart = Instant.now() - var timeout: Long = 10000 // 10 Seconds + @Volatile var timeout: Long = 10000 // 10 Seconds val isTimedOut: Boolean get() = Instant.now() >= jobStart.plusMillis(timeout) diff --git a/src/main/java/in/dragonbra/javasteam/types/AsyncJobMultiple.kt b/src/main/java/in/dragonbra/javasteam/types/AsyncJobMultiple.kt index f457fb40..7e433f13 100644 --- a/src/main/java/in/dragonbra/javasteam/types/AsyncJobMultiple.kt +++ b/src/main/java/in/dragonbra/javasteam/types/AsyncJobMultiple.kt @@ -4,8 +4,7 @@ import `in`.dragonbra.javasteam.steam.steamclient.AsyncJobFailedException import `in`.dragonbra.javasteam.steam.steamclient.SteamClient import `in`.dragonbra.javasteam.steam.steamclient.callbackmgr.CallbackMsg import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.future.asCompletableFuture +import kotlinx.coroutines.future.await import java.util.* import java.util.concurrent.CompletableFuture @@ -25,38 +24,40 @@ class AsyncJobMultiple( var results: List = listOf(), ) - private val deferred = CompletableDeferred>() + private val future = CompletableFuture>() - private val results = Collections.synchronizedList(mutableListOf()) + private val results = mutableListOf() init { registerJob(client) } - // Kotlin - suspend fun await(): ResultSet = deferred.await() + @Deprecated("Use toFuture() instead", ReplaceWith("toFuture()")) + fun toDeferred(): CompletableFuture> = toFuture() - // Java interop - fun toFuture(): CompletableFuture> = deferred.asCompletableFuture() + fun toFuture(): CompletableFuture> = future + + suspend fun await(): ResultSet = future.await() @Suppress("unused") @Throws(CancellationException::class) fun runBlock(): ResultSet = toFuture().get() + @Synchronized override fun addResult(callback: CallbackMsg): Boolean { @Suppress("UNCHECKED_CAST") val callbackMsg = callback as T - // add this callback to our result set results.add(callbackMsg) return if (finishCondition(callbackMsg) == true) { - val result = ResultSet( - complete = true, - failed = false, - results = Collections.unmodifiableList(results) + future.complete( + ResultSet( + complete = true, + failed = false, + results = Collections.unmodifiableList(results.toList()) + ) ) - deferred.complete(result) true } else { heartbeat() @@ -64,23 +65,19 @@ class AsyncJobMultiple( } } + @Synchronized override fun setFailed(dueToRemoteFailure: Boolean) { if (results.isEmpty()) { // if we have zero callbacks in our result set, we cancel this task if (dueToRemoteFailure) { // if we're canceling with a remote failure, post a job failure exception - deferred.completeExceptionally(AsyncJobFailedException()) + future.completeExceptionally(AsyncJobFailedException()) } else { // otherwise, normal task cancellation for timeouts - deferred.cancel() + future.cancel(true) } } else { - val result = ResultSet( - complete = false, - failed = dueToRemoteFailure, - results = Collections.unmodifiableList(results) - ) - deferred.complete(result) + future.complete(ResultSet(false, dueToRemoteFailure, Collections.unmodifiableList(results.toList()))) } } } diff --git a/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.kt b/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.kt index 10a7c06c..71cfd3dd 100644 --- a/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.kt +++ b/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.kt @@ -1,268 +1,118 @@ -package in.dragonbra.javasteam.util; +package `in`.dragonbra.javasteam.util -import org.apache.commons.lang3.StringUtils; -import org.apache.commons.lang3.SystemUtils; - -import java.io.*; -import java.lang.reflect.Method; -import java.net.InetAddress; -import java.net.UnknownHostException; -import java.util.Scanner; +import org.apache.commons.lang3.SystemUtils +import java.io.BufferedReader +import java.io.InputStreamReader +import java.io.StringReader +import java.net.InetAddress +import java.util.Scanner /** * @author lngtr * @since 2018-02-24 */ -public class HardwareUtils { - - // Everything taken from here - // https://stackoverflow.com/questions/1986732/how-to-get-a-unique-computer-identifier-in-java-like-disk-id-or-motherboard-id - private static String SERIAL_NUMBER; - private static String MACHINE_NAME; - - public static byte[] getMachineID() { - // the aug 25th 2015 CM update made well-formed machine MessageObjects required for logon - // this was flipped off shortly after the update rolled out, likely due to linux steamclients running on distros without a way to build a machineid - // so while a valid MO isn't currently (as of aug 25th) required, they could be in the future and we'll abide by The Valve Law now - - if (SERIAL_NUMBER != null) { - return SERIAL_NUMBER.getBytes(); - } - - if (SystemUtils.IS_OS_WINDOWS) { - SERIAL_NUMBER = getSerialNumberWin(); - } - if (SystemUtils.IS_OS_MAC) { - SERIAL_NUMBER = getSerialNumberMac(); - } - if (SystemUtils.IS_OS_LINUX) { - SERIAL_NUMBER = getSerialNumberUnix(); - } - - // if SERIAL_NUMBER still was null - if (SERIAL_NUMBER == null) { - SERIAL_NUMBER = "JavaSteam-SerialNumber"; - } - - return SERIAL_NUMBER.getBytes(); +// https://stackoverflow.com/questions/1986732/how-to-get-a-unique-computer-identifier-in-java-like-disk-id-or-motherboard-id +object HardwareUtils { + + private val serialNumber: String by lazy { + when { + SystemUtils.IS_OS_WINDOWS -> getSerialNumberWin() + SystemUtils.IS_OS_MAC -> getSerialNumberMac() + SystemUtils.IS_OS_LINUX -> getSerialNumberUnix() + else -> null + } ?: "JavaSteam-SerialNumber" } - private static String getSerialNumberWin() { - String sn = null; - - Runtime runtime = Runtime.getRuntime(); - Process process; - - try { - process = runtime.exec(new String[]{"wmic", "bios", "get", "serialnumber"}); - } catch (IOException e) { - return null; - } - - var os = process.getOutputStream(); - - try { - os.close(); - } catch (IOException ignored) { - } - - try (var sc = new Scanner(process.getInputStream())) { - while (sc.hasNext()) { - String next = sc.next(); - if ("SerialNumber".equals(next)) { - sn = sc.next().trim(); - break; - } - } - } - - return sn; - } - - private static String getSerialNumberMac() { - String sn = null; - - Runtime runtime = Runtime.getRuntime(); - Process process; - - try { - process = runtime.exec(new String[]{"/usr/sbin/system_profiler", "SPHardwareDataType"}); - } catch (IOException e) { - return null; - } - - var os = process.getOutputStream(); - - try { - os.close(); - } catch (IOException ignored) { - } - - String line; - String marker = "Serial Number"; - try (var br = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - while ((line = br.readLine()) != null) { - if (line.contains(marker)) { - sn = line.split(":")[1].trim(); - break; - } - } - } catch (IOException e) { - return null; - } - - return sn; + private val resolvedMachineName: String by lazy { + val name = if (SystemUtils.IS_OS_ANDROID) getAndroidDeviceName() else getDeviceName() + name.takeUnless { it.isNullOrBlank() } ?: "Unknown" } - private static String getSerialNumberUnix() { - String sn = readDmidecode(); - - if (sn == null) { - sn = readLshal(); - } - - return sn; - } - - private static BufferedReader read(String command) { - Runtime runtime = Runtime.getRuntime(); - Process process; - try { - process = runtime.exec(command.split(" ")); - } catch (IOException e) { - return null; - } - - try { - process.getOutputStream().close(); - } catch (IOException ignored) { - } - - try (var br = new BufferedReader(new InputStreamReader(process.getInputStream()))) { - StringBuilder sb = new StringBuilder(); - String line; - while ((line = br.readLine()) != null) { - sb.append(line).append('\n'); + // the aug 25th 2015 CM update made well-formed machine MessageObjects required for logon + // this was flipped off shortly after the update rolled out, likely due to linux steamclients running on distros without a way to build a machineid + // so while a valid MO isn't currently (as of aug 25th) required, they could be in the future and we'll abide by The Valve Law now + @JvmStatic + fun getMachineID(): ByteArray = serialNumber.toByteArray() + + @JvmStatic + @JvmOverloads + fun getMachineName(addTag: Boolean = false): String = + if (addTag || resolvedMachineName.contains("Unknown")) "$resolvedMachineName (JavaSteam)" else resolvedMachineName + + private fun getSerialNumberWin(): String? = runCatching { + val process = Runtime.getRuntime().exec(arrayOf("wmic", "bios", "get", "serialnumber")) + runCatching { process.outputStream.close() } + Scanner(process.inputStream).use { sc -> + while (sc.hasNext()) { + if (sc.next() == "SerialNumber") return@runCatching sc.next().trim() } - return new BufferedReader(new StringReader(sb.toString())); - } catch (IOException e) { - return null; - } finally { - process.destroy(); + null } - } + }.getOrNull() - private static String readDmidecode() { - - String sn = null; - - String line; - String marker = "Serial Number:"; - - try (var br = read("dmidecode -t system")) { - if (br == null) { - return null; - } - - while ((line = br.readLine()) != null) { - if (line.contains(marker)) { - sn = line.split(marker)[1].trim(); - break; - } - } - } catch (IOException e) { - return null; + private fun getSerialNumberMac(): String? = runCatching { + val process = Runtime.getRuntime().exec(arrayOf("/usr/sbin/system_profiler", "SPHardwareDataType")) + runCatching { process.outputStream.close() } + BufferedReader(InputStreamReader(process.inputStream)).use { br -> + br.lineSequence() + .firstOrNull { it.contains("Serial Number") } + ?.substringAfter(":") + ?.trim() } + }.getOrNull() - return sn; - } + private fun getSerialNumberUnix(): String? = readDmidecode() ?: readLshal() - private static String readLshal() { - String sn = null; + private fun read(command: String): BufferedReader? { + val process = runCatching { + Runtime.getRuntime().exec(command.split(" ").toTypedArray()) + }.getOrNull() ?: return null - String line; - String marker = "system.hardware.serial ="; + runCatching { process.outputStream.close() } - try (var br = read("lshal")) { - if (br == null) { - return null; + return runCatching { + BufferedReader(InputStreamReader(process.inputStream)).use { br -> + BufferedReader(StringReader(br.readText())) } - while ((line = br.readLine()) != null) { - if (line.contains(marker)) { - //noinspection RegExpRedundantEscape - sn = line.split(marker)[1].replaceAll("\\(string\\)|(\\')", "").trim(); - break; - } - } - } catch (IOException e) { - return null; - } - - return sn; - } - - public static String getMachineName() { - return getMachineName(false); + }.also { + process.destroy() + }.getOrNull() } - public static String getMachineName(boolean addTag) { - if (MACHINE_NAME != null) { - return MACHINE_NAME; - } - - - if (SystemUtils.IS_OS_ANDROID) { - MACHINE_NAME = getAndroidDeviceName(); - } else { - MACHINE_NAME = getDeviceName(); - } - - if (StringUtils.isBlank(MACHINE_NAME)) { - MACHINE_NAME = "Unknown"; - } - - if (addTag || MACHINE_NAME.contains("Unknown")) { - return MACHINE_NAME + " (JavaSteam)"; - } else { - return MACHINE_NAME; + private fun readDmidecode(): String? = + read("dmidecode -t system")?.use { br -> + br.lineSequence() + .firstOrNull { it.contains("Serial Number:") } + ?.substringAfter("Serial Number:") + ?.trim() } - } - private static String getDeviceName() { - var hostname = SystemUtils.getHostName(); - if (StringUtils.isBlank(hostname)) { - try { - // Last fallback. - hostname = InetAddress.getLocalHost().getHostName(); - } catch (UnknownHostException e) { - hostname = null; - } + private fun readLshal(): String? = + read("lshal")?.use { br -> + br.lineSequence() + .firstOrNull { it.contains("system.hardware.serial =") } + ?.substringAfter("system.hardware.serial =") + ?.replace("(string)", "") + ?.replace("'", "") + ?.trim() } - return hostname; + private fun getDeviceName(): String? { + val hostname = SystemUtils.getHostName() + if (!hostname.isNullOrBlank()) return hostname + return runCatching { InetAddress.getLocalHost().hostName }.getOrNull() } - private static String getAndroidDeviceName() { - String manufacturer = getAndroidSystemProperty("ro.product.manufacturer"); - String model = getAndroidSystemProperty("ro.product.model"); - - if (manufacturer == null || model == null) { - return "Android Device"; - } - - if (model.startsWith(manufacturer)) { - return model; - } - return manufacturer + " " + model; + private fun getAndroidDeviceName(): String? { + val manufacturer = getAndroidSystemProperty("ro.product.manufacturer") + val model = getAndroidSystemProperty("ro.product.model") + if (manufacturer == null || model == null) return "Android Device" + return if (model.startsWith(manufacturer)) model else "$manufacturer $model" } - private static String getAndroidSystemProperty(String key) { - try { - Class systemProperties = Class.forName("android.os.SystemProperties"); - Method get = systemProperties.getMethod("get", String.class); - return (String) get.invoke(null, key); - } catch (Exception e) { - return null; - } - } + private fun getAndroidSystemProperty(key: String): String? = runCatching { + val systemProperties = Class.forName("android.os.SystemProperties") + val get = systemProperties.getMethod("get", String::class.java) + get.invoke(null, key) as? String + }.getOrNull() } diff --git a/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt b/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt index 744f98f7..b860d0e2 100644 --- a/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt +++ b/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt @@ -1,34 +1,32 @@ package `in`.dragonbra.javasteam.util.event -import java.util.Timer -import java.util.TimerTask +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds class ScheduledFunction(private val func: Runnable, var delay: Long) { - private var timer: Timer? = null - private var started = false + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var job: Job? = null @Synchronized fun start() { - if (!started) { - timer = Timer().also { - it.scheduleAtFixedRate( - object : TimerTask() { - override fun run() = func.run() - }, - 0L, - delay - ) + if (job?.isActive == true) return + job = scope.launch { + while (isActive) { + func.run() + delay(delay.milliseconds) } - started = true } } @Synchronized fun stop() { - if (started) { - timer?.cancel() - timer = null - started = false - } + job?.cancel() + job = null } } diff --git a/src/test/java/in/dragonbra/javasteam/util/HardwareUtilsTest.java b/src/test/java/in/dragonbra/javasteam/util/HardwareUtilsTest.java index 299da293..c02ccf19 100644 --- a/src/test/java/in/dragonbra/javasteam/util/HardwareUtilsTest.java +++ b/src/test/java/in/dragonbra/javasteam/util/HardwareUtilsTest.java @@ -1,21 +1,10 @@ package in.dragonbra.javasteam.util; import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import java.lang.reflect.Field; - public class HardwareUtilsTest { - @BeforeEach - public void setUp() throws NoSuchFieldException, IllegalAccessException { - // Ehh.... This resets the 'MACHINE_NAME' field for every test. - Field field = HardwareUtils.class.getDeclaredField("MACHINE_NAME"); - field.setAccessible(true); - field.set(null, null); - } - @Test public void machineNameWithTag() { var name = HardwareUtils.getMachineName(true); From dc62f889882d908ff7fd239aa3d0702dc55f5aed Mon Sep 17 00:00:00 2001 From: Lossy Date: Sun, 14 Jun 2026 20:24:06 -0500 Subject: [PATCH 10/27] Post rebase fixes --- .../in/dragonbra/javasteam/steam/steamclient/SteamClient.kt | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt b/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt index 4c34dbe2..e95df567 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt @@ -27,6 +27,7 @@ import `in`.dragonbra.javasteam.steam.steamclient.callbacks.DisconnectedCallback import `in`.dragonbra.javasteam.steam.steamclient.configuration.SteamConfiguration import `in`.dragonbra.javasteam.types.AsyncJob import `in`.dragonbra.javasteam.types.JobID +import `in`.dragonbra.javasteam.types.JobID.Companion.toJobID import `in`.dragonbra.javasteam.util.JavaSteamAddition import `in`.dragonbra.javasteam.util.log.LogManager import `in`.dragonbra.javasteam.util.log.Logger @@ -225,6 +226,9 @@ class SteamClient @JvmOverloads constructor( * @return A future that resolves to the next callback, or null on timeout. */ fun waitForCallbackFuture(timeout: Long): CompletableFuture { + if (timeout <= 0L) { + return CompletableFuture.completedFuture(callbackQueue.tryReceive().getOrNull()) + } val future = CompletableFuture() defaultScope.launch { try { From 7282362c252a6ef26b0be9d88fa300f54bf4ef35 Mon Sep 17 00:00:00 2001 From: Lossy Date: Sun, 14 Jun 2026 20:37:32 -0500 Subject: [PATCH 11/27] Fix import --- .../_023_downloadapp/SampleDownloadApp.java | 21 +++++++++---------- 1 file changed, 10 insertions(+), 11 deletions(-) diff --git a/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_023_downloadapp/SampleDownloadApp.java b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_023_downloadapp/SampleDownloadApp.java index b7297747..b5558148 100644 --- a/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_023_downloadapp/SampleDownloadApp.java +++ b/javasteam-samples/src/main/java/in/dragonbra/javasteamsamples/_023_downloadapp/SampleDownloadApp.java @@ -31,6 +31,7 @@ import java.util.ArrayList; import java.util.List; import java.util.concurrent.CancellationException; +import java.util.concurrent.Executors; import java.util.concurrent.TimeUnit; /** @@ -90,22 +91,20 @@ public void run() { // Depot chunks are downloaded using OKHttp, it's best to set some timeouts. // OkHttp's default dispatcher uses non-daemon threads with a 60s idle keepalive, which // would prevent JVM exit after the download completes. Daemon threads exit with the app. - var dispatcher = new okhttp3.Dispatcher(java.util.concurrent.Executors.newCachedThreadPool(r -> { + var dispatcher = new okhttp3.Dispatcher(Executors.newCachedThreadPool(r -> { var t = new Thread(r, "OkHttp Dispatcher"); t.setDaemon(true); return t; })); - var config = SteamConfiguration.create(builder -> { - builder.withHttpClient( - new OkHttpClient.Builder() - .dispatcher(dispatcher) - .connectTimeout(10, TimeUnit.SECONDS) // Time to establish connection - .readTimeout(60, TimeUnit.SECONDS) // Max inactivity between reads - .writeTimeout(30, TimeUnit.SECONDS) // Time for writes - .build() - ); - }); + var config = SteamConfiguration.create(builder -> builder.withHttpClient( + new OkHttpClient.Builder() + .dispatcher(dispatcher) + .connectTimeout(10, TimeUnit.SECONDS) // Time to establish connection + .readTimeout(60, TimeUnit.SECONDS) // Max inactivity between reads + .writeTimeout(30, TimeUnit.SECONDS) // Time for writes + .build() + )); steamClient = new SteamClient(config); From c51809b6cea83d62710cdede37b57c14324d9cd1 Mon Sep 17 00:00:00 2001 From: Lossy Date: Sun, 14 Jun 2026 22:11:32 -0500 Subject: [PATCH 12/27] Update dependencies --- ... [wrapper --gradle-version 9.5.1].run.xml} | 7 +++- buildSrc/build.gradle.kts | 4 +- gradle/libs.versions.toml | 38 +++++++++--------- gradle/wrapper/gradle-wrapper.jar | Bin 43583 -> 43764 bytes gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 6 +-- gradlew.bat | 4 +- .../SteamUnifiedMessages.kt | 4 +- 8 files changed, 34 insertions(+), 31 deletions(-) rename .run/{javasteam [wrapper --gradle-version 8.14].run.xml => JavaSteam [wrapper --gradle-version 9.5.1].run.xml} (69%) diff --git a/.run/javasteam [wrapper --gradle-version 8.14].run.xml b/.run/JavaSteam [wrapper --gradle-version 9.5.1].run.xml similarity index 69% rename from .run/javasteam [wrapper --gradle-version 8.14].run.xml rename to .run/JavaSteam [wrapper --gradle-version 9.5.1].run.xml index b23e505b..c8677fab 100644 --- a/.run/javasteam [wrapper --gradle-version 8.14].run.xml +++ b/.run/JavaSteam [wrapper --gradle-version 9.5.1].run.xml @@ -1,5 +1,5 @@ - + true true + false false false + false + false \ No newline at end of file diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index bb62a8fa..c0e7b9c1 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -13,9 +13,9 @@ dependencies { implementation(gradleApi()) // https://mvnrepository.com/artifact/commons-io/commons-io - implementation("commons-io:commons-io:2.20.0") + implementation("commons-io:commons-io:2.22.0") // https://mvnrepository.com/artifact/com.squareup/kotlinpoet - implementation("com.squareup:kotlinpoet:2.2.0") + implementation("com.squareup:kotlinpoet:2.3.0") } gradlePlugin { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2878fb0..6f98c970 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -5,37 +5,37 @@ [versions] java = "11" -kotlin = "2.2.21" # https://kotlinlang.org/docs/releases.html#release-details -dokka = "2.0.0" # https://mvnrepository.com/artifact/org.jetbrains.dokka/dokka-gradle-plugin -kotlinter = "5.3.0" # https://plugins.gradle.org/plugin/org.jmailen.kotlinter -jacoco = "0.8.14" # https://www.eclemma.org/jacoco +kotlin = "2.4.0" # https://kotlinlang.org/docs/releases.html#release-details +dokka = "2.2.0" # https://mvnrepository.com/artifact/org.jetbrains.dokka/dokka-gradle-plugin +kotlinter = "5.5.0" # https://plugins.gradle.org/plugin/org.jmailen.kotlinter +jacoco = "0.8.15" # https://www.eclemma.org/jacoco # Standard Library versions commons-lang3 = "3.20.0" # https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -kotlin-coroutines = "1.10.2" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core -ktor = "3.3.3" # https://mvnrepository.com/artifact/io.ktor/ktor-client-cio -okHttp = "5.3.2" # https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp -protobuf = "4.31.1" # https://mvnrepository.com/artifact/com.google.protobuf/protobuf-java -protobuf-gradle = "0.9.5" # https://mvnrepository.com/artifact/com.google.protobuf/protobuf-gradle-plugin +kotlin-coroutines = "1.11.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-core +ktor = "3.5.0" # https://mvnrepository.com/artifact/io.ktor/ktor-client-cio +okHttp = "5.4.0" # https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp +protobuf = "4.35.1" # https://mvnrepository.com/artifact/com.google.protobuf/protobuf-java +protobuf-gradle = "0.10.0" # https://mvnrepository.com/artifact/com.google.protobuf/protobuf-gradle-plugin publishPlugin = "2.0.0" # https://mvnrepository.com/artifact/io.github.gradle-nexus/publish-plugin -xz = "1.11" # https://mvnrepository.com/artifact/org.tukaani/xz -zstd = "1.5.7-6" # https://search.maven.org/artifact/com.github.luben/zstd-jni +xz = "1.12" # https://mvnrepository.com/artifact/org.tukaani/xz +zstd = "1.5.7-11" # https://search.maven.org/artifact/com.github.luben/zstd-jni # Depot Downloader -kotlin-serialization-json = "1.9.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json-jvm -okio = "3.16.0" # https://mvnrepository.com/artifact/com.squareup.okio/okio +kotlin-serialization-json = "1.11.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-serialization-json-jvm +okio = "3.17.0" # https://mvnrepository.com/artifact/com.squareup.okio/okio # Testing Lib versions -commons-io = "2.21.0" # https://mvnrepository.com/artifact/commons-io/commons-io -commonsCodec = "1.20.0" # https://mvnrepository.com/artifact/commons-codec/commons-codec -coroutinesTest = "1.10.2" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-test +commons-io = "2.22.0" # https://mvnrepository.com/artifact/commons-io/commons-io +commonsCodec = "1.22.0" # https://mvnrepository.com/artifact/commons-codec/commons-codec +coroutinesTest = "1.11.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-test junit5 = "5.13.4" # https://mvnrepository.com/artifact/org.junit/junit-bom -mockWebServer = "5.1.0" # https://mvnrepository.com/artifact/com.squareup.okhttp3/mockwebserver3-junit5 +mockWebServer = "5.4.0" # https://mvnrepository.com/artifact/com.squareup.okhttp3/mockwebserver3-junit5 mockitoVersion = "5.18.0" # https://mvnrepository.com/artifact/org.mockito/mockito-core # Samples -bouncyCastle = "1.83" # https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk18on -gson = "2.13.2" # https://mvnrepository.com/artifact/com.google.code.gson/gson +bouncyCastle = "1.84" # https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk18on +gson = "2.14.0" # https://mvnrepository.com/artifact/com.google.code.gson/gson qrCode = "1.0.1" # https://mvnrepository.com/artifact/pro.leaco.qrcode/console-qrcode [libraries] diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index a4b76b9530d66f5e68d973ea569d8e19de379189..1b33c55baabb587c669f562ae36f953de2481846 100644 GIT binary patch delta 34943 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

Gn4F+B~IivciU9^t0j-Mgao3 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 DDw4oP diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index ca025c83..5dd3c012 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew index f3b75f3b..23d15a93 100755 --- a/gradlew +++ b/gradlew @@ -114,7 +114,7 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar +CLASSPATH="\\\"\\\"" # Determine the Java command to use to start the JVM. @@ -205,7 +205,7 @@ fi DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. @@ -213,7 +213,7 @@ DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index 9d21a218..db3a6ac2 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -70,11 +70,11 @@ goto fail :execute @rem Setup the command line -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar +set CLASSPATH= @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamunifiedmessages/SteamUnifiedMessages.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamunifiedmessages/SteamUnifiedMessages.kt index ddfebb6e..74c2a75e 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamunifiedmessages/SteamUnifiedMessages.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamunifiedmessages/SteamUnifiedMessages.kt @@ -93,7 +93,7 @@ class SteamUnifiedMessages : ClientMsgHandler() { val msg = ClientMsgProtobuf(message::class.java, eMsg).apply { sourceJobID = client.getNextJobID() - header!!.proto.targetJobName = name + header.proto.targetJobName = name body.mergeFrom(message) } @@ -120,7 +120,7 @@ class SteamUnifiedMessages : ClientMsgHandler() { EMsg.ServiceMethodCallFromClient } val msg = ClientMsgProtobuf(message::class.java, eMsg).apply { - header!!.proto.targetJobName = name + header.proto.targetJobName = name body.mergeFrom(message) } From f8e1c177e84710c28602c7674df8efbb905e04a9 Mon Sep 17 00:00:00 2001 From: Lossy Date: Sun, 14 Jun 2026 23:15:44 -0500 Subject: [PATCH 13/27] quiet dokka warnings --- .../generators/rpc/parser/ProtoParser.kt | 42 ++++++++++++------- 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/buildSrc/src/main/kotlin/in/dragonbra/generators/rpc/parser/ProtoParser.kt b/buildSrc/src/main/kotlin/in/dragonbra/generators/rpc/parser/ProtoParser.kt index af0264ba..371a0cfd 100644 --- a/buildSrc/src/main/kotlin/in/dragonbra/generators/rpc/parser/ProtoParser.kt +++ b/buildSrc/src/main/kotlin/in/dragonbra/generators/rpc/parser/ProtoParser.kt @@ -180,18 +180,24 @@ class ProtoParser(private val outputDir: File) { }.build() cBuilder.addFunction(funcHandleNotificationMsg) + val asyncJobSingleClass = ClassName( + packageName = "in.dragonbra.javasteam.types", + "AsyncJobSingle" + ) + val serviceMethodResponseClass = ClassName( + packageName = "in.dragonbra.javasteam.steam.handlers.steamunifiedmessages.callback", + "ServiceMethodResponse" + ) + val noResponseClass = ClassName( + packageName = "in.dragonbra.javasteam.protobufs.steamclient.SteammessagesUnifiedBaseSteamclient", + "NoResponse" + ) + // Public Methods service.methods.forEach { method -> val funcBuilder = FunSpec.builder(method.methodName.replaceFirstChar { it.lowercase(Locale.getDefault()) }) .addModifiers(KModifier.PUBLIC) - .addKdoc( - """ - |@param request The request. - |@see [${method.requestType}] - |@returns [AsyncJobSingle]<[ServiceMethodResponse]<[${method.responseType}]>> - """.trimMargin() // wow - ) .addParameter( "request", ClassName( @@ -201,15 +207,15 @@ class ProtoParser(private val outputDir: File) { ) if (method.responseType != "NoResponse") { + // I can't find a way to suppress or fix dokka warnings for this. + //funcBuilder.addKdoc( + // "@param request The request.\n@see [${method.requestType}]\n@returns [%T]<[%T]<[${method.responseType}]>>\n", + // asyncJobSingleClass, + // serviceMethodResponseClass + //) funcBuilder.returns( - ClassName( - packageName = "in.dragonbra.javasteam.types", - "AsyncJobSingle" - ).parameterizedBy( - ClassName( - packageName = "in.dragonbra.javasteam.steam.handlers.steamunifiedmessages.callback", - "ServiceMethodResponse" - ).parameterizedBy( + asyncJobSingleClass.parameterizedBy( + serviceMethodResponseClass.parameterizedBy( ClassName.bestGuess("in.dragonbra.javasteam.protobufs.$parentPathName.$protoFileName.${method.responseType}.Builder") ) ) @@ -223,6 +229,12 @@ class ProtoParser(private val outputDir: File) { "${service.name}.${method.methodName}#1" ) } else { + funcBuilder.addKdoc( + "@param request The request.\n@returns [%T]<[%T]<[%T]>>\n", + asyncJobSingleClass, + serviceMethodResponseClass, + noResponseClass + ) funcBuilder.addStatement( format = "unifiedMessages!!.sendNotification<%T.Builder>(\n%S,\nrequest\n)", ClassName( From 9f909b55c8a3a0add7e4bfd75f859bfac99855ba Mon Sep 17 00:00:00 2001 From: Lossy Date: Sun, 14 Jun 2026 23:30:12 -0500 Subject: [PATCH 14/27] Update remaining dependencies --- gradle/libs.versions.toml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6f98c970..5a9ffa82 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,9 +29,9 @@ okio = "3.17.0" # https://mvnrepository.com/artifact/com.squareup.okio/okio commons-io = "2.22.0" # https://mvnrepository.com/artifact/commons-io/commons-io commonsCodec = "1.22.0" # https://mvnrepository.com/artifact/commons-codec/commons-codec coroutinesTest = "1.11.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-test -junit5 = "5.13.4" # https://mvnrepository.com/artifact/org.junit/junit-bom +junit5 = "5.13.4" # https://mvnrepository.com/artifact/org.junit/junit-bom # Cant Update until we target Java 17 mockWebServer = "5.4.0" # https://mvnrepository.com/artifact/com.squareup.okhttp3/mockwebserver3-junit5 -mockitoVersion = "5.18.0" # https://mvnrepository.com/artifact/org.mockito/mockito-core +mockitoVersion = "5.23.0" # https://mvnrepository.com/artifact/org.mockito/mockito-core # Samples bouncyCastle = "1.84" # https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk18on From 8b29281cbc1fa1ebb18a24d38ff8cd45d0f4128d Mon Sep 17 00:00:00 2001 From: Lossy Date: Sun, 14 Jun 2026 23:32:25 -0500 Subject: [PATCH 15/27] Try again --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5a9ffa82..e9db1924 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,7 +31,7 @@ commonsCodec = "1.22.0" # https://mvnrepository.com/artifact/commons-codec/commo coroutinesTest = "1.11.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-test junit5 = "5.13.4" # https://mvnrepository.com/artifact/org.junit/junit-bom # Cant Update until we target Java 17 mockWebServer = "5.4.0" # https://mvnrepository.com/artifact/com.squareup.okhttp3/mockwebserver3-junit5 -mockitoVersion = "5.23.0" # https://mvnrepository.com/artifact/org.mockito/mockito-core +mockitoVersion = "5.18.0" # https://mvnrepository.com/artifact/org.mockito/mockito-core # Samples bouncyCastle = "1.84" # https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk18on From 2ed004be2b70ff35d504bfb97cc86c9eb042e45a Mon Sep 17 00:00:00 2001 From: Lossy Date: Mon, 15 Jun 2026 13:04:01 -0500 Subject: [PATCH 16/27] Get with the times! Target Jvm 17 since newer gradle and deps are now targeting 17+ --- README.md | 2 +- gradle/libs.versions.toml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 04449d60..7caa1312 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![Maven Central](https://img.shields.io/maven-central/v/in.dragonbra/javasteam)](https://mvnrepository.com/artifact/in.dragonbra/javasteam) [![Discord](https://img.shields.io/discord/420907597906968586.svg)](https://discord.gg/8F2JuTu) -Java port of [SteamKit2](https://github.com/SteamRE/SteamKit). JavaSteam targets Java 11. +Java port of [SteamKit2](https://github.com/SteamRE/SteamKit). JavaSteam targets Java 17. ## Download diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e9db1924..ae5e6b26 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,7 +4,7 @@ # **** [versions] -java = "11" +java = "17" kotlin = "2.4.0" # https://kotlinlang.org/docs/releases.html#release-details dokka = "2.2.0" # https://mvnrepository.com/artifact/org.jetbrains.dokka/dokka-gradle-plugin kotlinter = "5.5.0" # https://plugins.gradle.org/plugin/org.jmailen.kotlinter @@ -29,9 +29,9 @@ okio = "3.17.0" # https://mvnrepository.com/artifact/com.squareup.okio/okio commons-io = "2.22.0" # https://mvnrepository.com/artifact/commons-io/commons-io commonsCodec = "1.22.0" # https://mvnrepository.com/artifact/commons-codec/commons-codec coroutinesTest = "1.11.0" # https://mvnrepository.com/artifact/org.jetbrains.kotlinx/kotlinx-coroutines-test -junit5 = "5.13.4" # https://mvnrepository.com/artifact/org.junit/junit-bom # Cant Update until we target Java 17 +junit5 = "6.1.0" # https://mvnrepository.com/artifact/org.junit/junit-bom mockWebServer = "5.4.0" # https://mvnrepository.com/artifact/com.squareup.okhttp3/mockwebserver3-junit5 -mockitoVersion = "5.18.0" # https://mvnrepository.com/artifact/org.mockito/mockito-core +mockitoVersion = "5.23.0" # https://mvnrepository.com/artifact/org.mockito/mockito-core # Samples bouncyCastle = "1.84" # https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk18on From 3b46299204954db41bba5f37ade18bf932cd0135 Mon Sep 17 00:00:00 2001 From: Lossy Date: Mon, 15 Jun 2026 13:05:49 -0500 Subject: [PATCH 17/27] Dont forget the runners --- .github/workflows/javasteam-build-pr.yml | 4 ++-- .github/workflows/javasteam-build-push.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/javasteam-build-pr.yml b/.github/workflows/javasteam-build-pr.yml index 42ebeadb..820a9bd2 100644 --- a/.github/workflows/javasteam-build-pr.yml +++ b/.github/workflows/javasteam-build-pr.yml @@ -25,10 +25,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Checkout JavaSteam with Java 11 + - name: Checkout JavaSteam with Java 17 uses: actions/setup-java@v4 with: - java-version: '11' + java-version: '17' distribution: 'temurin' - name: Validate Gradle wrapper uses: gradle/actions/wrapper-validation@v4 diff --git a/.github/workflows/javasteam-build-push.yml b/.github/workflows/javasteam-build-push.yml index cbc6ad95..74121a02 100644 --- a/.github/workflows/javasteam-build-push.yml +++ b/.github/workflows/javasteam-build-push.yml @@ -24,10 +24,10 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Checkout JavaSteam with Java 11 + - name: Checkout JavaSteam with Java 17 uses: actions/setup-java@v4 with: - java-version: '11' + java-version: '17' distribution: 'temurin' - name: Validate Gradle wrapper uses: gradle/actions/wrapper-validation@v4 From f412115bd91d7ccc33c6fa45796aa9e31c407494 Mon Sep 17 00:00:00 2001 From: Lossy Date: Mon, 15 Jun 2026 16:27:07 -0500 Subject: [PATCH 18/27] Try build caching --- .github/workflows/javasteam-build-pr.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/javasteam-build-pr.yml b/.github/workflows/javasteam-build-pr.yml index 820a9bd2..f6642f6e 100644 --- a/.github/workflows/javasteam-build-pr.yml +++ b/.github/workflows/javasteam-build-pr.yml @@ -35,4 +35,4 @@ jobs: - name: Setup Gradle uses: gradle/actions/setup-gradle@v4 - name: Build with Gradle, skip signing - run: ./gradlew build -x signMavenJavaPublication + run: ./gradlew build -x signMavenJavaPublication --build-cache From f66dfba1c2e46eaa816f7a53c1429928ab941046 Mon Sep 17 00:00:00 2001 From: Lossy Date: Tue, 16 Jun 2026 00:34:37 -0500 Subject: [PATCH 19/27] Rename .java to .kt --- .../{NetHookNetworkListener.java => NetHookNetworkListener.kt} | 0 .../java/in/dragonbra/javasteam/util/{Strings.java => Strings.kt} | 0 .../java/in/dragonbra/javasteam/util/{Utils.java => Utils.kt} | 0 .../dragonbra/javasteam/util/{WebHelpers.java => WebHelpers.kt} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename src/main/java/in/dragonbra/javasteam/util/{NetHookNetworkListener.java => NetHookNetworkListener.kt} (100%) rename src/main/java/in/dragonbra/javasteam/util/{Strings.java => Strings.kt} (100%) rename src/main/java/in/dragonbra/javasteam/util/{Utils.java => Utils.kt} (100%) rename src/main/java/in/dragonbra/javasteam/util/{WebHelpers.java => WebHelpers.kt} (100%) diff --git a/src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.java b/src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.java rename to src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.kt diff --git a/src/main/java/in/dragonbra/javasteam/util/Strings.java b/src/main/java/in/dragonbra/javasteam/util/Strings.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/Strings.java rename to src/main/java/in/dragonbra/javasteam/util/Strings.kt diff --git a/src/main/java/in/dragonbra/javasteam/util/Utils.java b/src/main/java/in/dragonbra/javasteam/util/Utils.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/Utils.java rename to src/main/java/in/dragonbra/javasteam/util/Utils.kt diff --git a/src/main/java/in/dragonbra/javasteam/util/WebHelpers.java b/src/main/java/in/dragonbra/javasteam/util/WebHelpers.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/WebHelpers.java rename to src/main/java/in/dragonbra/javasteam/util/WebHelpers.kt From 4cd3986724d7edabd19c13aa494a55bcf14053c4 Mon Sep 17 00:00:00 2001 From: Lossy Date: Tue, 16 Jun 2026 00:34:37 -0500 Subject: [PATCH 20/27] Port some more classes to kotlin, kdoc missing methods. --- .../depotdownloader/DepotDownloader.kt | 3 +- .../dragonbra/javasteam/steam/cdn/Client.kt | 7 +- .../javasteam/steam/cdn/DepotChunk.kt | 3 +- .../handlers/steamapps/PICSProductInfo.kt | 1 + .../javasteam/util/NetHookNetworkListener.kt | 80 ++-- .../in/dragonbra/javasteam/util/Strings.kt | 69 ++-- .../java/in/dragonbra/javasteam/util/Utils.kt | 384 ++++++++---------- .../in/dragonbra/javasteam/util/WebHelpers.kt | 63 +-- .../dragonbra/javasteam/util/UtilsTest.java | 14 + 9 files changed, 305 insertions(+), 319 deletions(-) diff --git a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/DepotDownloader.kt b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/DepotDownloader.kt index 80d84cc8..5cb7f240 100644 --- a/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/DepotDownloader.kt +++ b/javasteam-depotdownloader/src/main/kotlin/in/dragonbra/javasteam/depotdownloader/DepotDownloader.kt @@ -1422,7 +1422,8 @@ class DepotDownloader @JvmOverloads constructor( val depot = depotFilesData.depotDownloadInfo val depotDownloadCounter = depotFilesData.depotCounter - val chunkID = Strings.toHex(chunk.chunkID) + val chunkIdBytes = requireNotNull(chunk.chunkID) { "Chunk must have a ChunkID." } + val chunkID = Strings.toHex(chunkIdBytes) var downloaded = 0 val chunkBuffer = ByteArray(chunk.compressedLength) diff --git a/src/main/java/in/dragonbra/javasteam/steam/cdn/Client.kt b/src/main/java/in/dragonbra/javasteam/steam/cdn/Client.kt index 761a03f5..1fe2ad9d 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/cdn/Client.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/cdn/Client.kt @@ -144,8 +144,7 @@ class Client(steamClient: SteamClient) : Closeable { } return@withContext response.use { resp -> - val responseBody = resp.body?.bytes() - ?: throw SteamKitWebRequestException("Response body is null") + val responseBody = resp.body.bytes() if (responseBody.isEmpty()) { throw SteamKitWebRequestException("Response is empty") @@ -201,13 +200,13 @@ class Client(steamClient: SteamClient) : Closeable { proxyServer: Server? = null, cdnAuthToken: String? = null, ): Int = withContext(Dispatchers.IO) { - require(chunk.chunkID != null) { "Chunk must have a ChunkID." } + val chunkIdBytes = requireNotNull(chunk.chunkID) { "Chunk must have a ChunkID." } if (destination.size != chunk.compressedLength) { throw IllegalArgumentException("The destination buffer must be the same size as the chunk CompressedLength (Since we take out decompression step from download") } - val chunkID = Strings.toHex(chunk.chunkID) + val chunkID = Strings.toHex(chunkIdBytes) val url = "depot/$depotId/chunk/$chunkID" val request = if (ClientLancache.useLanCacheServer) { diff --git a/src/main/java/in/dragonbra/javasteam/steam/cdn/DepotChunk.kt b/src/main/java/in/dragonbra/javasteam/steam/cdn/DepotChunk.kt index db744e3c..32ebf113 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/cdn/DepotChunk.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/cdn/DepotChunk.kt @@ -115,7 +115,8 @@ object DepotChunk { ) } } catch (e: Exception) { - throw IOException("Failed to decompress chunk ${Strings.toHex(info.chunkID)}: $e\n${e.stackTraceToString()}") + val chunkID = info.chunkID?.let { Strings.toHex(it) } ?: "unknown" + throw IOException("Failed to decompress chunk $chunkID: $e\n${e.stackTraceToString()}") } finally { buffer.fill(0) } diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamapps/PICSProductInfo.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamapps/PICSProductInfo.kt index 8264c318..7978873b 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamapps/PICSProductInfo.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamapps/PICSProductInfo.kt @@ -69,6 +69,7 @@ class PICSProductInfo : CallbackMsg { return null } + val shaHash = shaHash ?: return null val shaString = Strings.toHex(shaHash).replace("-", "").lowercase(Locale.getDefault()) val uriString = String.format("https://%s/appinfo/%d/sha/%s.txt.gz", httpHost, id, shaString) return URI.create(uriString) diff --git a/src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.kt b/src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.kt index 93a16eb4..999dd8c3 100644 --- a/src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.kt +++ b/src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.kt @@ -1,68 +1,64 @@ -package in.dragonbra.javasteam.util; +package `in`.dragonbra.javasteam.util -import in.dragonbra.javasteam.enums.EMsg; -import in.dragonbra.javasteam.util.log.LogManager; -import in.dragonbra.javasteam.util.log.Logger; - -import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.concurrent.atomic.AtomicLong; +import `in`.dragonbra.javasteam.enums.EMsg +import `in`.dragonbra.javasteam.util.log.LogManager +import java.io.File +import java.io.IOException +import java.nio.file.Files +import java.nio.file.Paths +import java.text.SimpleDateFormat +import java.util.Date +import java.util.concurrent.atomic.AtomicLong /** * Dump any network messages sent to and received from the Steam server that the client is connected to. * These messages are dumped to file, and can be analyzed further with NetHookAnalyzer, a hex editor, or your own purpose-built tools. - *

* Be careful with this, sensitive data may be written to the disk (such as your Steam password). */ -public class NetHookNetworkListener implements IDebugNetworkListener { - private static final Logger logger = LogManager.getLogger(NetHookNetworkListener.class); - - private static final SimpleDateFormat FORMAT = new SimpleDateFormat("yyyy_MM_dd_H_m_s_S"); - - private final AtomicLong messageNumber = new AtomicLong(0L); +class NetHookNetworkListener @JvmOverloads constructor(path: String = "netlogs") : IDebugNetworkListener { - private final File logDirectory; + companion object { + private val logger = LogManager.getLogger() - public NetHookNetworkListener() { - this("netlogs"); + private val FORMAT = SimpleDateFormat("yyyy_MM_dd_H_m_s_S") } - public NetHookNetworkListener(String path) { + private val messageNumber = AtomicLong(0L) - File dir = new File(path); - dir.mkdir(); + private val logDirectory: File - logDirectory = new File(dir, FORMAT.format(new Date())); - logDirectory.mkdir(); + init { + val dir = File(path) + dir.mkdir() + + logDirectory = File(dir, FORMAT.format(Date())) + logDirectory.mkdir() } - @Override - public void onIncomingNetworkMessage(EMsg msgType, byte[] data) { - logger.debug(String.format("<- Recv'd EMsg: %s (%d)", msgType, msgType.code())); + override fun onIncomingNetworkMessage(msgType: EMsg, data: ByteArray) { + logger.debug("<- Recv'd EMsg: $msgType (${msgType.code()})") try { - Files.write(Paths.get(new File(logDirectory, getFile("in", msgType)).getAbsolutePath()), data); - } catch (IOException e) { - logger.debug(e); + val file = File(logDirectory, getFile("in", msgType)) + val path = Paths.get(file.absolutePath) + Files.write(path, data) + } catch (e: IOException) { + logger.debug(e) } } - @Override - public void onOutgoingNetworkMessage(EMsg msgType, byte[] data) { - logger.debug(String.format("Sent -> EMsg: %s", msgType)); + override fun onOutgoingNetworkMessage(msgType: EMsg, data: ByteArray) { + logger.debug("Sent -> EMsg: $msgType") try { - Files.write(Paths.get(new File(logDirectory, getFile("out", msgType)).getAbsolutePath()), data); - } catch (IOException e) { - logger.debug(e); + val file = File(logDirectory, getFile("out", msgType)) + val path = Paths.get(file.absolutePath) + Files.write(path, data) + } catch (e: IOException) { + logger.debug(e) } } - private String getFile(String direction, EMsg msgType) { - return String.format("%d_%s_%d_k_EMsg%s.bin", messageNumber.getAndIncrement(), direction, msgType.code(), msgType); - } + private fun getFile(direction: String, msgType: EMsg): String = + "${messageNumber.getAndIncrement()}_${direction}_${msgType.code()}_k_EMsg$msgType.bin" } diff --git a/src/main/java/in/dragonbra/javasteam/util/Strings.kt b/src/main/java/in/dragonbra/javasteam/util/Strings.kt index 68763e5f..2603cb0e 100644 --- a/src/main/java/in/dragonbra/javasteam/util/Strings.kt +++ b/src/main/java/in/dragonbra/javasteam/util/Strings.kt @@ -1,49 +1,50 @@ -package in.dragonbra.javasteam.util; - -import java.math.BigInteger; +package `in`.dragonbra.javasteam.util /** + * Provides helper functions for null/empty string checks and hex string conversion. * @author lngtr * @since 2018-02-19 */ -public class Strings { +object Strings { + + private val HEX_ARRAY = "0123456789ABCDEF".toCharArray() /** - * the constant 2^64 + * Checks whether the given string is `null` or empty. + * @param str the string to check. + * @return `true` if [str] is `null` or empty, `false` otherwise. */ - private static final BigInteger TWO_64 = BigInteger.ONE.shiftLeft(64); - - public static boolean isNullOrEmpty(String str) { - return str == null || str.isEmpty(); - } - - public String asUnsignedDecimalString(long l) { - BigInteger b = BigInteger.valueOf(l); - if (b.signum() < 0) { - b = b.add(TWO_64); - } - return b.toString(); - } - - private final static char[] HEX_ARRAY = "0123456789ABCDEF".toCharArray(); + @JvmStatic + fun isNullOrEmpty(str: String?): Boolean = str == null || str.isEmpty() - public static String toHex(byte[] bytes) { - char[] hexChars = new char[bytes.length * 2]; - for (int j = 0; j < bytes.length; j++) { - int v = bytes[j] & 0xFF; - hexChars[j * 2] = HEX_ARRAY[v >>> 4]; - hexChars[j * 2 + 1] = HEX_ARRAY[v & 0x0F]; + /** + * Converts a byte array into its uppercase hexadecimal string representation. + * @param bytes the bytes to encode. + * @return a string twice the length of [bytes], containing its hex encoding. + */ + @JvmStatic + fun toHex(bytes: ByteArray): String { + val hexChars = CharArray(bytes.size * 2) + for (j in bytes.indices) { + val v = bytes[j].toInt() and 0xFF + hexChars[j * 2] = HEX_ARRAY[v ushr 4] + hexChars[j * 2 + 1] = HEX_ARRAY[v and 0x0F] } - return new String(hexChars); + return String(hexChars) } - public static byte[] decodeHex(String s) { - int len = s.length(); - byte[] data = new byte[len / 2]; - for (int i = 0; i < len; i += 2) { - data[i / 2] = (byte) ((Character.digit(s.charAt(i), 16) << 4) - + Character.digit(s.charAt(i + 1), 16)); + /** + * Decodes a hexadecimal string into a byte array. + * @param s the hex string to decode. + * @return the decoded bytes. + * @exception StringIndexOutOfBoundsException if [s] has an odd length. + */ + @JvmStatic + fun decodeHex(s: String): ByteArray { + val data = ByteArray(s.length / 2) + for (i in s.indices step 2) { + data[i / 2] = ((Character.digit(s[i], 16) shl 4) + Character.digit(s[i + 1], 16)).toByte() } - return data; + return data } } diff --git a/src/main/java/in/dragonbra/javasteam/util/Utils.kt b/src/main/java/in/dragonbra/javasteam/util/Utils.kt index c027c605..2dac4e3d 100644 --- a/src/main/java/in/dragonbra/javasteam/util/Utils.kt +++ b/src/main/java/in/dragonbra/javasteam/util/Utils.kt @@ -1,207 +1,177 @@ -package in.dragonbra.javasteam.util; - -import in.dragonbra.javasteam.enums.EOSType; -import in.dragonbra.javasteam.types.ChunkData; -import org.apache.commons.lang3.SystemUtils; - -import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.channels.ClosedChannelException; -import java.util.Map; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.zip.CRC32; - -/** - * @author lngtr - * @since 2018-02-23 - */ -public class Utils { - - private static final String JAVA_RUNTIME = getSystemProperty("java.runtime.name"); - - private static final Map WIN_OS_MAP = new LinkedHashMap<>(); - - private static final Map OSX_OS_MAP = new LinkedHashMap<>(); - - private static final Map LINUX_OS_MAP = new LinkedHashMap<>(); - - private static final Map GENERIC_LINUX_OS_MAP = new LinkedHashMap<>(); - - static { - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_95, EOSType.Win95); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_98, EOSType.Win98); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_ME, EOSType.WinME); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_NT, EOSType.WinNT); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_2000, EOSType.Win2000); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_XP, EOSType.WinXP); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_VISTA, EOSType.WinVista); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_7, EOSType.Windows7); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_8, EOSType.Windows8); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_10, EOSType.Windows10); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_11, EOSType.Win11); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_2003, EOSType.Win2003); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_2008, EOSType.Win2008); - WIN_OS_MAP.put(SystemUtils.IS_OS_WINDOWS_2012, EOSType.Win2012); - WIN_OS_MAP.put(checkOS("Windows Server 2016", "10.0"), EOSType.Win2016); - WIN_OS_MAP.put(checkOS("Windows Server 2019", "10.0"), EOSType.Win2019); - WIN_OS_MAP.put(checkOS("Windows Server 2022", "10.0"), EOSType.Win2022); - - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_TIGER, EOSType.MacOS104); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_LEOPARD, EOSType.MacOS105); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_SNOW_LEOPARD, EOSType.MacOS106); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_LION, EOSType.MacOS107); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_MOUNTAIN_LION, EOSType.MacOS108); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_MAVERICKS, EOSType.MacOS109); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_YOSEMITE, EOSType.MacOS1010); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_EL_CAPITAN, EOSType.MacOS1011); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_SIERRA, EOSType.MacOS1012); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_HIGH_SIERRA, EOSType.Macos1013); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_MOJAVE, EOSType.Macos1014); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_CATALINA, EOSType.Macos1015); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_BIG_SUR, EOSType.MacOS11); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_MONTEREY, EOSType.MacOS12); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_VENTURA, EOSType.MacOS13); - OSX_OS_MAP.put(SystemUtils.IS_OS_MAC_OSX_SONOMA, EOSType.MacOS14); - OSX_OS_MAP.put(checkOS("Mac OS X", "15"), EOSType.MacOS15); - - LINUX_OS_MAP.put("2.2", EOSType.Linux22); - LINUX_OS_MAP.put("2.4", EOSType.Linux24); - LINUX_OS_MAP.put("2.6", EOSType.Linux26); - LINUX_OS_MAP.put("3.2", EOSType.Linux32); - LINUX_OS_MAP.put("3.5", EOSType.Linux35); - LINUX_OS_MAP.put("3.6", EOSType.Linux36); - LINUX_OS_MAP.put("3.10", EOSType.Linux310); - LINUX_OS_MAP.put("3.16", EOSType.Linux316); - LINUX_OS_MAP.put("3.18", EOSType.Linux318); - LINUX_OS_MAP.put("4.1", EOSType.Linux41); - LINUX_OS_MAP.put("4.4", EOSType.Linux44); - LINUX_OS_MAP.put("4.9", EOSType.Linux49); - LINUX_OS_MAP.put("4.14", EOSType.Linux414); - LINUX_OS_MAP.put("4.19", EOSType.Linux419); - LINUX_OS_MAP.put("5.4", EOSType.Linux54); - LINUX_OS_MAP.put("5.10", EOSType.Linux510); - - GENERIC_LINUX_OS_MAP.put("3x", EOSType.Linux3x); - GENERIC_LINUX_OS_MAP.put("4x", EOSType.Linux4x); - GENERIC_LINUX_OS_MAP.put("5x", EOSType.Linux5x); - GENERIC_LINUX_OS_MAP.put("6x", EOSType.Linux6x); - GENERIC_LINUX_OS_MAP.put("7x", EOSType.Linux7x); - } - - // Sorted in history order by each OS release. - public static EOSType getOSType() { - // Windows - if (SystemUtils.IS_OS_WINDOWS) { - for (Map.Entry winEntry : WIN_OS_MAP.entrySet()) { - if (winEntry.getKey()) { - return winEntry.getValue(); - } - } - - return EOSType.WinUnknown; - } - - // Mac OS - if (SystemUtils.IS_OS_MAC) { - for (Map.Entry osxEntry : OSX_OS_MAP.entrySet()) { - if (osxEntry.getKey()) { - return osxEntry.getValue(); - } - } - - return EOSType.MacOSUnknown; - } - - // Android - if (JAVA_RUNTIME != null && JAVA_RUNTIME.startsWith("Android")) { - return EOSType.AndroidUnknown; - } - - // Linux - if (SystemUtils.IS_OS_LINUX) { - String linuxOsVersion = getSystemProperty("os.version"); - - if (linuxOsVersion == null) { - return EOSType.LinuxUnknown; - } - - String[] osVersion = linuxOsVersion.split("\\."); - - if (osVersion.length < 2) { - return EOSType.LinuxUnknown; - } - - String version = osVersion[0] + "." + osVersion[1]; - - EOSType linuxVersion = LINUX_OS_MAP.get(version); - if (linuxVersion != null) { - // Found Major/Minor version - return linuxVersion; - } - - String majorVersion = osVersion[0] + "x"; - for (Map.Entry linuxEntry : GENERIC_LINUX_OS_MAP.entrySet()) { - if (linuxEntry.getKey().equals(majorVersion)) { - // Found generic Linux version - return linuxEntry.getValue(); - } - } - - return EOSType.LinuxUnknown; - } - - // Unknown OS - return EOSType.Unknown; - } - - @SuppressWarnings("SameParameterValue") - private static boolean checkOS(String namePrefix, String versionPrefix) { - return SystemUtils.OS_NAME.startsWith(namePrefix) && SystemUtils.OS_VERSION.startsWith(versionPrefix); - } - - private static String getSystemProperty(final String property) { - try { - return System.getProperty(property); - } catch (final SecurityException ex) { - // we are not allowed to look at this property - return null; - } - } - - /** - * Convenience method for calculating the CRC32 checksum of a string. - * - * @param s the string - * @return long value of the CRC32 - */ - public static long crc32(String s) { - return crc32(s.getBytes()); - } - - /** - * Convenience method for calculating the CRC32 checksum of a byte array. - * - * @param bytes the byte array - * @return long value of the CRC32 - */ - public static long crc32(byte[] bytes) { - return crc32(bytes, 0, bytes.length); - } - - /** - * Convenience method for calculating the CRC32 checksum of a byte array with offset and length. - * - * @param bytes the byte array - * @param offset the offset to start from - * @param length the number of bytes to checksum - * @return long value of the CRC32 - */ - public static long crc32(byte[] bytes, int offset, int length) { - var checksum = new CRC32(); - checksum.update(bytes, offset, length); - return checksum.getValue(); - } -} +package `in`.dragonbra.javasteam.util + +import `in`.dragonbra.javasteam.enums.EOSType +import org.apache.commons.lang3.SystemUtils +import java.util.zip.CRC32 + +/** + * @author lngtr + * @since 2018-02-23 + */ +object Utils { + + private val javaRuntime: String? = getSystemProperty("java.runtime.name") + + private val winOsMap: Map = linkedMapOf( + SystemUtils.IS_OS_WINDOWS_95 to EOSType.Win95, + SystemUtils.IS_OS_WINDOWS_98 to EOSType.Win98, + SystemUtils.IS_OS_WINDOWS_ME to EOSType.WinME, + SystemUtils.IS_OS_WINDOWS_NT to EOSType.WinNT, + SystemUtils.IS_OS_WINDOWS_2000 to EOSType.Win2000, + SystemUtils.IS_OS_WINDOWS_XP to EOSType.WinXP, + SystemUtils.IS_OS_WINDOWS_VISTA to EOSType.WinVista, + SystemUtils.IS_OS_WINDOWS_7 to EOSType.Windows7, + SystemUtils.IS_OS_WINDOWS_8 to EOSType.Windows8, + SystemUtils.IS_OS_WINDOWS_10 to EOSType.Windows10, + SystemUtils.IS_OS_WINDOWS_11 to EOSType.Win11, + SystemUtils.IS_OS_WINDOWS_2003 to EOSType.Win2003, + SystemUtils.IS_OS_WINDOWS_2008 to EOSType.Win2008, + SystemUtils.IS_OS_WINDOWS_2012 to EOSType.Win2012, + checkOS("Windows Server 2016", "10.0") to EOSType.Win2016, + checkOS("Windows Server 2019", "10.0") to EOSType.Win2019, + checkOS("Windows Server 2022", "10.0") to EOSType.Win2022, + ) + + private val osxOsMap: Map = linkedMapOf( + SystemUtils.IS_OS_MAC_OSX_TIGER to EOSType.MacOS104, + SystemUtils.IS_OS_MAC_OSX_LEOPARD to EOSType.MacOS105, + SystemUtils.IS_OS_MAC_OSX_SNOW_LEOPARD to EOSType.MacOS106, + SystemUtils.IS_OS_MAC_OSX_LION to EOSType.MacOS107, + SystemUtils.IS_OS_MAC_OSX_MOUNTAIN_LION to EOSType.MacOS108, + SystemUtils.IS_OS_MAC_OSX_MAVERICKS to EOSType.MacOS109, + SystemUtils.IS_OS_MAC_OSX_YOSEMITE to EOSType.MacOS1010, + SystemUtils.IS_OS_MAC_OSX_EL_CAPITAN to EOSType.MacOS1011, + SystemUtils.IS_OS_MAC_OSX_SIERRA to EOSType.MacOS1012, + SystemUtils.IS_OS_MAC_OSX_HIGH_SIERRA to EOSType.Macos1013, + SystemUtils.IS_OS_MAC_OSX_MOJAVE to EOSType.Macos1014, + SystemUtils.IS_OS_MAC_OSX_CATALINA to EOSType.Macos1015, + SystemUtils.IS_OS_MAC_OSX_BIG_SUR to EOSType.MacOS11, + SystemUtils.IS_OS_MAC_OSX_MONTEREY to EOSType.MacOS12, + SystemUtils.IS_OS_MAC_OSX_VENTURA to EOSType.MacOS13, + SystemUtils.IS_OS_MAC_OSX_SONOMA to EOSType.MacOS14, + SystemUtils.IS_OS_MAC_OSX_SEQUOIA to EOSType.MacOS15, + ) + + private val linuxOsMap: Map = linkedMapOf( + "2.2" to EOSType.Linux22, + "2.4" to EOSType.Linux24, + "2.6" to EOSType.Linux26, + "3.2" to EOSType.Linux32, + "3.5" to EOSType.Linux35, + "3.6" to EOSType.Linux36, + "3.10" to EOSType.Linux310, + "3.16" to EOSType.Linux316, + "3.18" to EOSType.Linux318, + "4.1" to EOSType.Linux41, + "4.4" to EOSType.Linux44, + "4.9" to EOSType.Linux49, + "4.14" to EOSType.Linux414, + "4.19" to EOSType.Linux419, + "5.4" to EOSType.Linux54, + "5.10" to EOSType.Linux510, + ) + + private val genericLinuxOsMap: Map = linkedMapOf( + "3x" to EOSType.Linux3x, + "4x" to EOSType.Linux4x, + "5x" to EOSType.Linux5x, + "6x" to EOSType.Linux6x, + "7x" to EOSType.Linux7x, + ) + + // Sorted in history order by each OS release. + @JvmStatic + fun getOSType(): EOSType { + // Windows + if (SystemUtils.IS_OS_WINDOWS) { + for ((matched, type) in winOsMap) { + if (matched) { + return type + } + } + + return EOSType.WinUnknown + } + + // Mac OS + if (SystemUtils.IS_OS_MAC) { + for ((matched, type) in osxOsMap) { + if (matched) { + return type + } + } + + return EOSType.MacOSUnknown + } + + // Android + if (javaRuntime?.startsWith("Android") == true) { + return EOSType.AndroidUnknown + } + + // Linux + if (SystemUtils.IS_OS_LINUX) { + val linuxOsVersion = getSystemProperty("os.version") ?: return EOSType.LinuxUnknown + + val osVersion = linuxOsVersion.split(".") + if (osVersion.size < 2) { + return EOSType.LinuxUnknown + } + + val version = "${osVersion[0]}.${osVersion[1]}" + + // Found Major/Minor version + linuxOsMap[version]?.let { return it } + + // Found generic Linux version + val majorVersion = "${osVersion[0]}x" + genericLinuxOsMap[majorVersion]?.let { return it } + + return EOSType.LinuxUnknown + } + + // Unknown OS + return EOSType.Unknown + } + + @Suppress("SameParameterValue") + private fun checkOS(namePrefix: String, versionPrefix: String): Boolean = + SystemUtils.OS_NAME.startsWith(namePrefix) && SystemUtils.OS_VERSION.startsWith(versionPrefix) + + private fun getSystemProperty(property: String): String? = try { + System.getProperty(property) + } catch (_: SecurityException) { + // we are not allowed to look at this property + null + } + + /** + * Convenience method for calculating the CRC32 checksum of a string. + * @param s the string + * @return long value of the CRC32 + */ + @JvmStatic + fun crc32(s: String): Long = crc32(s.toByteArray()) + + /** + * Convenience method for calculating the CRC32 checksum of a byte array. + * @param bytes the byte array + * @return long value of the CRC32 + */ + @JvmStatic + fun crc32(bytes: ByteArray): Long = crc32(bytes, 0, bytes.size) + + /** + * Convenience method for calculating the CRC32 checksum of a byte array with offset and length. + * @param bytes the byte array + * @param offset the offset to start from + * @param length the number of bytes to checksum + * @return long value of the CRC32 + */ + @JvmStatic + fun crc32(bytes: ByteArray, offset: Int, length: Int): Long { + val checksum = CRC32() + checksum.update(bytes, offset, length) + return checksum.value + } +} diff --git a/src/main/java/in/dragonbra/javasteam/util/WebHelpers.kt b/src/main/java/in/dragonbra/javasteam/util/WebHelpers.kt index 6b1e907b..1b386016 100644 --- a/src/main/java/in/dragonbra/javasteam/util/WebHelpers.kt +++ b/src/main/java/in/dragonbra/javasteam/util/WebHelpers.kt @@ -1,41 +1,44 @@ -package in.dragonbra.javasteam.util; +package `in`.dragonbra.javasteam.util -import java.nio.charset.StandardCharsets; +import java.nio.charset.StandardCharsets /** + * Provides helper functions for URL encoding. * @author lngtr * @since 2018-04-16 */ -public class WebHelpers { - - private static boolean isUrlSafeChar(char ch) { - return ch >= 'a' && ch <= 'z' || - ch >= 'A' && ch <= 'Z' || - ch >= '0' && ch <= '9' || - ch == '-' || - ch == '.' || - ch == '_'; - } - - public static String urlEncode(String input) { - return urlEncode(input.getBytes(StandardCharsets.UTF_8)); - } - - public static String urlEncode(byte[] input) { - StringBuilder encoded = new StringBuilder(input.length * 2); - - for (byte i : input) { - char inch = (char) i; - - if (isUrlSafeChar(inch)) { - encoded.append(inch); - } else if (inch == ' ') { - encoded.append('+'); - } else { - encoded.append(String.format("%%%02X", i)); +object WebHelpers { + + private fun isUrlSafeChar(ch: Char): Boolean = + ch in 'a'..'z' || ch in 'A'..'Z' || ch in '0'..'9' || ch == '-' || ch == '.' || ch == '_' + + /** + * URL-encodes the given string, using UTF-8 to convert it to bytes first. + * @param input the string to encode. + * @return the URL-encoded string. + */ + @JvmStatic + fun urlEncode(input: String): String = urlEncode(input.toByteArray(StandardCharsets.UTF_8)) + + /** + * URL-encodes the given bytes. + * @param input the bytes to encode. + * @return the URL-encoded string. + */ + @JvmStatic + fun urlEncode(input: ByteArray): String { + val encoded = StringBuilder(input.size * 2) + + for (i in input) { + val inch = i.toInt().toChar() + + when { + isUrlSafeChar(inch) -> encoded.append(inch) + inch == ' ' -> encoded.append('+') + else -> encoded.append("%%%02X".format(i)) } } - return encoded.toString(); + return encoded.toString() } } diff --git a/src/test/java/in/dragonbra/javasteam/util/UtilsTest.java b/src/test/java/in/dragonbra/javasteam/util/UtilsTest.java index 3fdf40cc..1f8200b4 100644 --- a/src/test/java/in/dragonbra/javasteam/util/UtilsTest.java +++ b/src/test/java/in/dragonbra/javasteam/util/UtilsTest.java @@ -18,4 +18,18 @@ public void crc32() { long result = Utils.crc32("test_string"); Assertions.assertEquals(0x0967B587, result); } + + @Test + public void crc32WithByteArray() { + byte[] input = "test_string".getBytes(); + long result = Utils.crc32(input); + Assertions.assertEquals(0x0967B587, result); + } + + @Test + public void crc32WithOffsetAndLength() { + byte[] input = "xxtest_stringxx".getBytes(); + long result = Utils.crc32(input, 2, "test_string".length()); + Assertions.assertEquals(0x0967B587, result); + } } From b330f8a34e7503e450dea9b4b978baf62f05bb82 Mon Sep 17 00:00:00 2001 From: Lossy Date: Tue, 16 Jun 2026 20:59:59 -0500 Subject: [PATCH 21/27] Rename .java to .kt --- .../javasteam/util/{CollectionUtils.java => CollectionUtils.kt} | 0 .../util/{IDebugNetworkListener.java => IDebugNetworkListener.kt} | 0 .../javasteam/util/{KeyDictionary.java => KeyDictionary.kt} | 0 .../java/in/dragonbra/javasteam/util/{MsgUtil.java => MsgUtil.kt} | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename src/main/java/in/dragonbra/javasteam/util/{CollectionUtils.java => CollectionUtils.kt} (100%) rename src/main/java/in/dragonbra/javasteam/util/{IDebugNetworkListener.java => IDebugNetworkListener.kt} (100%) rename src/main/java/in/dragonbra/javasteam/util/{KeyDictionary.java => KeyDictionary.kt} (100%) rename src/main/java/in/dragonbra/javasteam/util/{MsgUtil.java => MsgUtil.kt} (100%) diff --git a/src/main/java/in/dragonbra/javasteam/util/CollectionUtils.java b/src/main/java/in/dragonbra/javasteam/util/CollectionUtils.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/CollectionUtils.java rename to src/main/java/in/dragonbra/javasteam/util/CollectionUtils.kt diff --git a/src/main/java/in/dragonbra/javasteam/util/IDebugNetworkListener.java b/src/main/java/in/dragonbra/javasteam/util/IDebugNetworkListener.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/IDebugNetworkListener.java rename to src/main/java/in/dragonbra/javasteam/util/IDebugNetworkListener.kt diff --git a/src/main/java/in/dragonbra/javasteam/util/KeyDictionary.java b/src/main/java/in/dragonbra/javasteam/util/KeyDictionary.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/KeyDictionary.java rename to src/main/java/in/dragonbra/javasteam/util/KeyDictionary.kt diff --git a/src/main/java/in/dragonbra/javasteam/util/MsgUtil.java b/src/main/java/in/dragonbra/javasteam/util/MsgUtil.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/MsgUtil.java rename to src/main/java/in/dragonbra/javasteam/util/MsgUtil.kt From ea0186abf37934d8bd601e2a606a6bae6457c991 Mon Sep 17 00:00:00 2001 From: Lossy Date: Tue, 16 Jun 2026 20:59:59 -0500 Subject: [PATCH 22/27] Port even more classes to kotlin --- .../javasteam/util/CollectionUtils.kt | 20 +- .../javasteam/util/IDebugNetworkListener.kt | 14 +- .../dragonbra/javasteam/util/KeyDictionary.kt | 189 ++++++++++++------ .../in/dragonbra/javasteam/util/MsgUtil.kt | 49 ++--- 4 files changed, 157 insertions(+), 115 deletions(-) diff --git a/src/main/java/in/dragonbra/javasteam/util/CollectionUtils.kt b/src/main/java/in/dragonbra/javasteam/util/CollectionUtils.kt index 4ba29f90..0157e277 100644 --- a/src/main/java/in/dragonbra/javasteam/util/CollectionUtils.kt +++ b/src/main/java/in/dragonbra/javasteam/util/CollectionUtils.kt @@ -1,20 +1,14 @@ -package in.dragonbra.javasteam.util; +package `in`.dragonbra.javasteam.util -import in.dragonbra.javasteam.util.compat.ObjectsCompat; - -import java.util.Map; +import `in`.dragonbra.javasteam.util.compat.ObjectsCompat /** * @author lngtr * @since 2018-02-19 */ -public class CollectionUtils { - public static T getKeyByValue(Map map, E value) { - for (Map.Entry entry : map.entrySet()) { - if (ObjectsCompat.equals(value, entry.getValue())) { - return entry.getKey(); - } - } - return null; - } +object CollectionUtils { + @JvmStatic + fun getKeyByValue(map: Map, value: E): T? = map.entries.firstOrNull { + ObjectsCompat.equals(value, it.value) + }?.key } diff --git a/src/main/java/in/dragonbra/javasteam/util/IDebugNetworkListener.kt b/src/main/java/in/dragonbra/javasteam/util/IDebugNetworkListener.kt index a2ad8876..256d36dd 100644 --- a/src/main/java/in/dragonbra/javasteam/util/IDebugNetworkListener.kt +++ b/src/main/java/in/dragonbra/javasteam/util/IDebugNetworkListener.kt @@ -1,27 +1,23 @@ -package in.dragonbra.javasteam.util; +package `in`.dragonbra.javasteam.util -import in.dragonbra.javasteam.enums.EMsg; +import `in`.dragonbra.javasteam.enums.EMsg /** * This is a debug utility, do not use it to implement your business logic. - *

* This interface is used for logging network messages sent to and received from the Steam server that the client is connected to. */ -public interface IDebugNetworkListener { - +interface IDebugNetworkListener { /** * Called when a packet is received from the Steam server. - * * @param msgType Network message type of this packet message. * @param data Raw packet data that was received. */ - void onIncomingNetworkMessage(EMsg msgType, byte[] data); + fun onIncomingNetworkMessage(msgType: EMsg, data: ByteArray) /** * Called when a packet is about to be sent to the Steam server. - * * @param msgType Network message type of this packet message. * @param data Raw packet data that was received. */ - void onOutgoingNetworkMessage(EMsg msgType, byte[] data); + fun onOutgoingNetworkMessage(msgType: EMsg, data: ByteArray) } diff --git a/src/main/java/in/dragonbra/javasteam/util/KeyDictionary.kt b/src/main/java/in/dragonbra/javasteam/util/KeyDictionary.kt index 7c10695d..a65a84f7 100644 --- a/src/main/java/in/dragonbra/javasteam/util/KeyDictionary.kt +++ b/src/main/java/in/dragonbra/javasteam/util/KeyDictionary.kt @@ -1,76 +1,135 @@ -package in.dragonbra.javasteam.util; +package `in`.dragonbra.javasteam.util -import in.dragonbra.javasteam.enums.EUniverse; - -import java.util.Map; +import `in`.dragonbra.javasteam.enums.EUniverse /** - * Contains the public keys that Steam uses for each of the {@link EUniverse} + * Contains the public keys that Steam uses for each of the [EUniverse] */ -public class KeyDictionary { - - private static final Map KEYS; - - static { - KEYS = Map.of( - EUniverse.Public, new byte[]{ - (byte) 0x30, (byte) 0x81, (byte) 0x9D, (byte) 0x30, (byte) 0x0D, (byte) 0x06, (byte) 0x09, (byte) 0x2A, (byte) 0x86, (byte) 0x48, (byte) 0x86, (byte) 0xF7, (byte) 0x0D, (byte) 0x01, (byte) 0x01, (byte) 0x01, - (byte) 0x05, (byte) 0x00, (byte) 0x03, (byte) 0x81, (byte) 0x8B, (byte) 0x00, (byte) 0x30, (byte) 0x81, (byte) 0x87, (byte) 0x02, (byte) 0x81, (byte) 0x81, (byte) 0x00, (byte) 0xDF, (byte) 0xEC, (byte) 0x1A, - (byte) 0xD6, (byte) 0x2C, (byte) 0x10, (byte) 0x66, (byte) 0x2C, (byte) 0x17, (byte) 0x35, (byte) 0x3A, (byte) 0x14, (byte) 0xB0, (byte) 0x7C, (byte) 0x59, (byte) 0x11, (byte) 0x7F, (byte) 0x9D, (byte) 0xD3, - (byte) 0xD8, (byte) 0x2B, (byte) 0x7A, (byte) 0xE3, (byte) 0xE0, (byte) 0x15, (byte) 0xCD, (byte) 0x19, (byte) 0x1E, (byte) 0x46, (byte) 0xE8, (byte) 0x7B, (byte) 0x87, (byte) 0x74, (byte) 0xA2, (byte) 0x18, - (byte) 0x46, (byte) 0x31, (byte) 0xA9, (byte) 0x03, (byte) 0x14, (byte) 0x79, (byte) 0x82, (byte) 0x8E, (byte) 0xE9, (byte) 0x45, (byte) 0xA2, (byte) 0x49, (byte) 0x12, (byte) 0xA9, (byte) 0x23, (byte) 0x68, - (byte) 0x73, (byte) 0x89, (byte) 0xCF, (byte) 0x69, (byte) 0xA1, (byte) 0xB1, (byte) 0x61, (byte) 0x46, (byte) 0xBD, (byte) 0xC1, (byte) 0xBE, (byte) 0xBF, (byte) 0xD6, (byte) 0x01, (byte) 0x1B, (byte) 0xD8, - (byte) 0x81, (byte) 0xD4, (byte) 0xDC, (byte) 0x90, (byte) 0xFB, (byte) 0xFE, (byte) 0x4F, (byte) 0x52, (byte) 0x73, (byte) 0x66, (byte) 0xCB, (byte) 0x95, (byte) 0x70, (byte) 0xD7, (byte) 0xC5, (byte) 0x8E, - (byte) 0xBA, (byte) 0x1C, (byte) 0x7A, (byte) 0x33, (byte) 0x75, (byte) 0xA1, (byte) 0x62, (byte) 0x34, (byte) 0x46, (byte) 0xBB, (byte) 0x60, (byte) 0xB7, (byte) 0x80, (byte) 0x68, (byte) 0xFA, (byte) 0x13, - (byte) 0xA7, (byte) 0x7A, (byte) 0x8A, (byte) 0x37, (byte) 0x4B, (byte) 0x9E, (byte) 0xC6, (byte) 0xF4, (byte) 0x5D, (byte) 0x5F, (byte) 0x3A, (byte) 0x99, (byte) 0xF9, (byte) 0x9E, (byte) 0xC4, (byte) 0x3A, - (byte) 0xE9, (byte) 0x63, (byte) 0xA2, (byte) 0xBB, (byte) 0x88, (byte) 0x19, (byte) 0x28, (byte) 0xE0, (byte) 0xE7, (byte) 0x14, (byte) 0xC0, (byte) 0x42, (byte) 0x89, (byte) 0x02, (byte) 0x01, (byte) 0x11 - }, - EUniverse.Beta, new byte[]{ - (byte) 0x30, (byte) 0x81, (byte) 0x9D, (byte) 0x30, (byte) 0x0D, (byte) 0x06, (byte) 0x09, (byte) 0x2A, (byte) 0x86, (byte) 0x48, (byte) 0x86, (byte) 0xF7, (byte) 0x0D, (byte) 0x01, (byte) 0x01, (byte) 0x01, - (byte) 0x05, (byte) 0x00, (byte) 0x03, (byte) 0x81, (byte) 0x8B, (byte) 0x00, (byte) 0x30, (byte) 0x81, (byte) 0x87, (byte) 0x02, (byte) 0x81, (byte) 0x81, (byte) 0x00, (byte) 0xAE, (byte) 0xD1, (byte) 0x4B, - (byte) 0xC0, (byte) 0xA3, (byte) 0x36, (byte) 0x8B, (byte) 0xA0, (byte) 0x39, (byte) 0x0B, (byte) 0x43, (byte) 0xDC, (byte) 0xED, (byte) 0x6A, (byte) 0xC8, (byte) 0xF2, (byte) 0xA3, (byte) 0xE4, (byte) 0x7E, - (byte) 0x09, (byte) 0x8C, (byte) 0x55, (byte) 0x2E, (byte) 0xE7, (byte) 0xE9, (byte) 0x3C, (byte) 0xBB, (byte) 0xE5, (byte) 0x5E, (byte) 0x0F, (byte) 0x18, (byte) 0x74, (byte) 0x54, (byte) 0x8F, (byte) 0xF3, - (byte) 0xBD, (byte) 0x56, (byte) 0x69, (byte) 0x5B, (byte) 0x13, (byte) 0x09, (byte) 0xAF, (byte) 0xC8, (byte) 0xBE, (byte) 0xB3, (byte) 0xA1, (byte) 0x48, (byte) 0x69, (byte) 0xE9, (byte) 0x83, (byte) 0x49, - (byte) 0x65, (byte) 0x8D, (byte) 0xD2, (byte) 0x93, (byte) 0x21, (byte) 0x2F, (byte) 0xB9, (byte) 0x1E, (byte) 0xFA, (byte) 0x74, (byte) 0x3B, (byte) 0x55, (byte) 0x22, (byte) 0x79, (byte) 0xBF, (byte) 0x85, - (byte) 0x18, (byte) 0xCB, (byte) 0x6D, (byte) 0x52, (byte) 0x44, (byte) 0x4E, (byte) 0x05, (byte) 0x92, (byte) 0x89, (byte) 0x6A, (byte) 0xA8, (byte) 0x99, (byte) 0xED, (byte) 0x44, (byte) 0xAE, (byte) 0xE2, - (byte) 0x66, (byte) 0x46, (byte) 0x42, (byte) 0x0C, (byte) 0xFB, (byte) 0x6E, (byte) 0x4C, (byte) 0x30, (byte) 0xC6, (byte) 0x6C, (byte) 0x5C, (byte) 0x16, (byte) 0xFF, (byte) 0xBA, (byte) 0x9C, (byte) 0xB9, - (byte) 0x78, (byte) 0x3F, (byte) 0x17, (byte) 0x4B, (byte) 0xCB, (byte) 0xC9, (byte) 0x01, (byte) 0x5D, (byte) 0x3E, (byte) 0x37, (byte) 0x70, (byte) 0xEC, (byte) 0x67, (byte) 0x5A, (byte) 0x33, (byte) 0x48, - (byte) 0xF7, (byte) 0x46, (byte) 0xCE, (byte) 0x58, (byte) 0xAA, (byte) 0xEC, (byte) 0xD9, (byte) 0xFF, (byte) 0x4A, (byte) 0x78, (byte) 0x6C, (byte) 0x83, (byte) 0x4B, (byte) 0x02, (byte) 0x01, (byte) 0x11 - }, - EUniverse.Internal, new byte[]{ - (byte) 0x30, (byte) 0x81, (byte) 0x9D, (byte) 0x30, (byte) 0x0D, (byte) 0x06, (byte) 0x09, (byte) 0x2A, (byte) 0x86, (byte) 0x48, (byte) 0x86, (byte) 0xF7, (byte) 0x0D, (byte) 0x01, (byte) 0x01, (byte) 0x01, - (byte) 0x05, (byte) 0x00, (byte) 0x03, (byte) 0x81, (byte) 0x8B, (byte) 0x00, (byte) 0x30, (byte) 0x81, (byte) 0x87, (byte) 0x02, (byte) 0x81, (byte) 0x81, (byte) 0x00, (byte) 0xA8, (byte) 0xFE, (byte) 0x01, - (byte) 0x3B, (byte) 0xB6, (byte) 0xD7, (byte) 0x21, (byte) 0x4B, (byte) 0x53, (byte) 0x23, (byte) 0x6F, (byte) 0xA1, (byte) 0xAB, (byte) 0x4E, (byte) 0xF1, (byte) 0x07, (byte) 0x30, (byte) 0xA7, (byte) 0xC6, - (byte) 0x7E, (byte) 0x6A, (byte) 0x2C, (byte) 0xC2, (byte) 0x5D, (byte) 0x3A, (byte) 0xB8, (byte) 0x40, (byte) 0xCA, (byte) 0x59, (byte) 0x4D, (byte) 0x16, (byte) 0x2D, (byte) 0x74, (byte) 0xEB, (byte) 0x0E, - (byte) 0x72, (byte) 0x46, (byte) 0x29, (byte) 0xF9, (byte) 0xDE, (byte) 0x9B, (byte) 0xCE, (byte) 0x4B, (byte) 0x8C, (byte) 0xD0, (byte) 0xCA, (byte) 0xF4, (byte) 0x08, (byte) 0x94, (byte) 0x46, (byte) 0xA5, - (byte) 0x11, (byte) 0xAF, (byte) 0x3A, (byte) 0xCB, (byte) 0xB8, (byte) 0x4E, (byte) 0xDE, (byte) 0xC6, (byte) 0xD8, (byte) 0x85, (byte) 0x0A, (byte) 0x7D, (byte) 0xAA, (byte) 0x96, (byte) 0x0A, (byte) 0xEA, - (byte) 0x7B, (byte) 0x51, (byte) 0xD6, (byte) 0x22, (byte) 0x62, (byte) 0x5C, (byte) 0x1E, (byte) 0x58, (byte) 0xD7, (byte) 0x46, (byte) 0x1E, (byte) 0x09, (byte) 0xAE, (byte) 0x43, (byte) 0xA7, (byte) 0xC4, - (byte) 0x34, (byte) 0x69, (byte) 0xA2, (byte) 0xA5, (byte) 0xE8, (byte) 0x44, (byte) 0x76, (byte) 0x18, (byte) 0xE2, (byte) 0x3D, (byte) 0xB7, (byte) 0xC5, (byte) 0xA8, (byte) 0x96, (byte) 0xFD, (byte) 0xE5, - (byte) 0xB4, (byte) 0x4B, (byte) 0xF8, (byte) 0x40, (byte) 0x12, (byte) 0xA6, (byte) 0x17, (byte) 0x4E, (byte) 0xC4, (byte) 0xC1, (byte) 0x60, (byte) 0x0E, (byte) 0xB0, (byte) 0xC2, (byte) 0xB8, (byte) 0x40, - (byte) 0x4D, (byte) 0x9E, (byte) 0x76, (byte) 0x4C, (byte) 0x44, (byte) 0xF4, (byte) 0xFC, (byte) 0x6F, (byte) 0x14, (byte) 0x89, (byte) 0x73, (byte) 0xB4, (byte) 0x13, (byte) 0x02, (byte) 0x01, (byte) 0x11 - }, - EUniverse.Dev, new byte[]{ - (byte) 0x30, (byte) 0x81, (byte) 0x9D, (byte) 0x30, (byte) 0x0D, (byte) 0x06, (byte) 0x09, (byte) 0x2A, (byte) 0x86, (byte) 0x48, (byte) 0x86, (byte) 0xF7, (byte) 0x0D, (byte) 0x01, (byte) 0x01, (byte) 0x01, - (byte) 0x05, (byte) 0x00, (byte) 0x03, (byte) 0x81, (byte) 0x8B, (byte) 0x00, (byte) 0x30, (byte) 0x81, (byte) 0x87, (byte) 0x02, (byte) 0x81, (byte) 0x81, (byte) 0x00, (byte) 0xD0, (byte) 0x05, (byte) 0x2C, - (byte) 0xE9, (byte) 0x80, (byte) 0x95, (byte) 0xCD, (byte) 0x30, (byte) 0x83, (byte) 0xA8, (byte) 0xE9, (byte) 0x25, (byte) 0x96, (byte) 0x63, (byte) 0xCE, (byte) 0xCC, (byte) 0x48, (byte) 0x5D, (byte) 0x5C, - (byte) 0x52, (byte) 0x00, (byte) 0xDB, (byte) 0x1E, (byte) 0x78, (byte) 0xD7, (byte) 0x6A, (byte) 0x4C, (byte) 0x2C, (byte) 0xC8, (byte) 0x41, (byte) 0x8C, (byte) 0xCC, (byte) 0x87, (byte) 0x46, (byte) 0xFB, - (byte) 0x1B, (byte) 0xC9, (byte) 0xE8, (byte) 0x6E, (byte) 0x4F, (byte) 0x7A, (byte) 0x6B, (byte) 0xC3, (byte) 0xE7, (byte) 0x0F, (byte) 0xD5, (byte) 0xA9, (byte) 0x5D, (byte) 0x6C, (byte) 0xD4, (byte) 0xEE, - (byte) 0xA2, (byte) 0xCC, (byte) 0x80, (byte) 0x5A, (byte) 0xD3, (byte) 0xCE, (byte) 0x53, (byte) 0x59, (byte) 0xE6, (byte) 0x80, (byte) 0x91, (byte) 0xC4, (byte) 0xC0, (byte) 0xD5, (byte) 0xF0, (byte) 0x63, - (byte) 0x23, (byte) 0x91, (byte) 0x69, (byte) 0x70, (byte) 0xC5, (byte) 0xBB, (byte) 0xBD, (byte) 0x05, (byte) 0xE2, (byte) 0x4F, (byte) 0x7D, (byte) 0x90, (byte) 0x12, (byte) 0xED, (byte) 0xAC, (byte) 0x4F, - (byte) 0x86, (byte) 0x96, (byte) 0x3C, (byte) 0x89, (byte) 0xCC, (byte) 0x92, (byte) 0x15, (byte) 0x63, (byte) 0xCB, (byte) 0x57, (byte) 0x70, (byte) 0xB9, (byte) 0xC3, (byte) 0xAE, (byte) 0x08, (byte) 0x4F, - (byte) 0xC8, (byte) 0x56, (byte) 0x16, (byte) 0xB0, (byte) 0x0C, (byte) 0xC6, (byte) 0xC8, (byte) 0x8A, (byte) 0x80, (byte) 0xD2, (byte) 0x37, (byte) 0xF7, (byte) 0x7F, (byte) 0xAB, (byte) 0x93, (byte) 0xBB, - (byte) 0xE6, (byte) 0xDE, (byte) 0x95, (byte) 0x78, (byte) 0xB8, (byte) 0x11, (byte) 0xC9, (byte) 0xE5, (byte) 0x62, (byte) 0xAD, (byte) 0xBC, (byte) 0x0C, (byte) 0x87, (byte) 0x02, (byte) 0x01, (byte) 0x11 - } - ); - } +object KeyDictionary { + private val KEYS: Map = mapOf( + EUniverse.Public to byteArrayOf( + 0x30.toByte(), 0x81.toByte(), 0x9D.toByte(), 0x30.toByte(), 0x0D.toByte(), 0x06.toByte(), + 0x09.toByte(), 0x2A.toByte(), 0x86.toByte(), 0x48.toByte(), 0x86.toByte(), 0xF7.toByte(), + 0x0D.toByte(), 0x01.toByte(), 0x01.toByte(), 0x01.toByte(), 0x05.toByte(), 0x00.toByte(), + 0x03.toByte(), 0x81.toByte(), 0x8B.toByte(), 0x00.toByte(), 0x30.toByte(), 0x81.toByte(), + 0x87.toByte(), 0x02.toByte(), 0x81.toByte(), 0x81.toByte(), 0x00.toByte(), 0xDF.toByte(), + 0xEC.toByte(), 0x1A.toByte(), 0xD6.toByte(), 0x2C.toByte(), 0x10.toByte(), 0x66.toByte(), + 0x2C.toByte(), 0x17.toByte(), 0x35.toByte(), 0x3A.toByte(), 0x14.toByte(), 0xB0.toByte(), + 0x7C.toByte(), 0x59.toByte(), 0x11.toByte(), 0x7F.toByte(), 0x9D.toByte(), 0xD3.toByte(), + 0xD8.toByte(), 0x2B.toByte(), 0x7A.toByte(), 0xE3.toByte(), 0xE0.toByte(), 0x15.toByte(), + 0xCD.toByte(), 0x19.toByte(), 0x1E.toByte(), 0x46.toByte(), 0xE8.toByte(), 0x7B.toByte(), + 0x87.toByte(), 0x74.toByte(), 0xA2.toByte(), 0x18.toByte(), 0x46.toByte(), 0x31.toByte(), + 0xA9.toByte(), 0x03.toByte(), 0x14.toByte(), 0x79.toByte(), 0x82.toByte(), 0x8E.toByte(), + 0xE9.toByte(), 0x45.toByte(), 0xA2.toByte(), 0x49.toByte(), 0x12.toByte(), 0xA9.toByte(), + 0x23.toByte(), 0x68.toByte(), 0x73.toByte(), 0x89.toByte(), 0xCF.toByte(), 0x69.toByte(), + 0xA1.toByte(), 0xB1.toByte(), 0x61.toByte(), 0x46.toByte(), 0xBD.toByte(), 0xC1.toByte(), + 0xBE.toByte(), 0xBF.toByte(), 0xD6.toByte(), 0x01.toByte(), 0x1B.toByte(), 0xD8.toByte(), + 0x81.toByte(), 0xD4.toByte(), 0xDC.toByte(), 0x90.toByte(), 0xFB.toByte(), 0xFE.toByte(), + 0x4F.toByte(), 0x52.toByte(), 0x73.toByte(), 0x66.toByte(), 0xCB.toByte(), 0x95.toByte(), + 0x70.toByte(), 0xD7.toByte(), 0xC5.toByte(), 0x8E.toByte(), 0xBA.toByte(), 0x1C.toByte(), + 0x7A.toByte(), 0x33.toByte(), 0x75.toByte(), 0xA1.toByte(), 0x62.toByte(), 0x34.toByte(), + 0x46.toByte(), 0xBB.toByte(), 0x60.toByte(), 0xB7.toByte(), 0x80.toByte(), 0x68.toByte(), + 0xFA.toByte(), 0x13.toByte(), 0xA7.toByte(), 0x7A.toByte(), 0x8A.toByte(), 0x37.toByte(), + 0x4B.toByte(), 0x9E.toByte(), 0xC6.toByte(), 0xF4.toByte(), 0x5D.toByte(), 0x5F.toByte(), + 0x3A.toByte(), 0x99.toByte(), 0xF9.toByte(), 0x9E.toByte(), 0xC4.toByte(), 0x3A.toByte(), + 0xE9.toByte(), 0x63.toByte(), 0xA2.toByte(), 0xBB.toByte(), 0x88.toByte(), 0x19.toByte(), + 0x28.toByte(), 0xE0.toByte(), 0xE7.toByte(), 0x14.toByte(), 0xC0.toByte(), 0x42.toByte(), + 0x89.toByte(), 0x02.toByte(), 0x01.toByte(), 0x11.toByte() + ), + EUniverse.Beta to byteArrayOf( + 0x30.toByte(), 0x81.toByte(), 0x9D.toByte(), 0x30.toByte(), 0x0D.toByte(), 0x06.toByte(), + 0x09.toByte(), 0x2A.toByte(), 0x86.toByte(), 0x48.toByte(), 0x86.toByte(), 0xF7.toByte(), + 0x0D.toByte(), 0x01.toByte(), 0x01.toByte(), 0x01.toByte(), 0x05.toByte(), 0x00.toByte(), + 0x03.toByte(), 0x81.toByte(), 0x8B.toByte(), 0x00.toByte(), 0x30.toByte(), 0x81.toByte(), + 0x87.toByte(), 0x02.toByte(), 0x81.toByte(), 0x81.toByte(), 0x00.toByte(), 0xAE.toByte(), + 0xD1.toByte(), 0x4B.toByte(), 0xC0.toByte(), 0xA3.toByte(), 0x36.toByte(), 0x8B.toByte(), + 0xA0.toByte(), 0x39.toByte(), 0x0B.toByte(), 0x43.toByte(), 0xDC.toByte(), 0xED.toByte(), + 0x6A.toByte(), 0xC8.toByte(), 0xF2.toByte(), 0xA3.toByte(), 0xE4.toByte(), 0x7E.toByte(), + 0x09.toByte(), 0x8C.toByte(), 0x55.toByte(), 0x2E.toByte(), 0xE7.toByte(), 0xE9.toByte(), + 0x3C.toByte(), 0xBB.toByte(), 0xE5.toByte(), 0x5E.toByte(), 0x0F.toByte(), 0x18.toByte(), + 0x74.toByte(), 0x54.toByte(), 0x8F.toByte(), 0xF3.toByte(), 0xBD.toByte(), 0x56.toByte(), + 0x69.toByte(), 0x5B.toByte(), 0x13.toByte(), 0x09.toByte(), 0xAF.toByte(), 0xC8.toByte(), + 0xBE.toByte(), 0xB3.toByte(), 0xA1.toByte(), 0x48.toByte(), 0x69.toByte(), 0xE9.toByte(), + 0x83.toByte(), 0x49.toByte(), 0x65.toByte(), 0x8D.toByte(), 0xD2.toByte(), 0x93.toByte(), + 0x21.toByte(), 0x2F.toByte(), 0xB9.toByte(), 0x1E.toByte(), 0xFA.toByte(), 0x74.toByte(), + 0x3B.toByte(), 0x55.toByte(), 0x22.toByte(), 0x79.toByte(), 0xBF.toByte(), 0x85.toByte(), + 0x18.toByte(), 0xCB.toByte(), 0x6D.toByte(), 0x52.toByte(), 0x44.toByte(), 0x4E.toByte(), + 0x05.toByte(), 0x92.toByte(), 0x89.toByte(), 0x6A.toByte(), 0xA8.toByte(), 0x99.toByte(), + 0xED.toByte(), 0x44.toByte(), 0xAE.toByte(), 0xE2.toByte(), 0x66.toByte(), 0x46.toByte(), + 0x42.toByte(), 0x0C.toByte(), 0xFB.toByte(), 0x6E.toByte(), 0x4C.toByte(), 0x30.toByte(), + 0xC6.toByte(), 0x6C.toByte(), 0x5C.toByte(), 0x16.toByte(), 0xFF.toByte(), 0xBA.toByte(), + 0x9C.toByte(), 0xB9.toByte(), 0x78.toByte(), 0x3F.toByte(), 0x17.toByte(), 0x4B.toByte(), + 0xCB.toByte(), 0xC9.toByte(), 0x01.toByte(), 0x5D.toByte(), 0x3E.toByte(), 0x37.toByte(), + 0x70.toByte(), 0xEC.toByte(), 0x67.toByte(), 0x5A.toByte(), 0x33.toByte(), 0x48.toByte(), + 0xF7.toByte(), 0x46.toByte(), 0xCE.toByte(), 0x58.toByte(), 0xAA.toByte(), 0xEC.toByte(), + 0xD9.toByte(), 0xFF.toByte(), 0x4A.toByte(), 0x78.toByte(), 0x6C.toByte(), 0x83.toByte(), + 0x4B.toByte(), 0x02.toByte(), 0x01.toByte(), 0x11.toByte() + ), + EUniverse.Internal to byteArrayOf( + 0x30.toByte(), 0x81.toByte(), 0x9D.toByte(), 0x30.toByte(), 0x0D.toByte(), 0x06.toByte(), + 0x09.toByte(), 0x2A.toByte(), 0x86.toByte(), 0x48.toByte(), 0x86.toByte(), 0xF7.toByte(), + 0x0D.toByte(), 0x01.toByte(), 0x01.toByte(), 0x01.toByte(), 0x05.toByte(), 0x00.toByte(), + 0x03.toByte(), 0x81.toByte(), 0x8B.toByte(), 0x00.toByte(), 0x30.toByte(), 0x81.toByte(), + 0x87.toByte(), 0x02.toByte(), 0x81.toByte(), 0x81.toByte(), 0x00.toByte(), 0xA8.toByte(), + 0xFE.toByte(), 0x01.toByte(), 0x3B.toByte(), 0xB6.toByte(), 0xD7.toByte(), 0x21.toByte(), + 0x4B.toByte(), 0x53.toByte(), 0x23.toByte(), 0x6F.toByte(), 0xA1.toByte(), 0xAB.toByte(), + 0x4E.toByte(), 0xF1.toByte(), 0x07.toByte(), 0x30.toByte(), 0xA7.toByte(), 0xC6.toByte(), + 0x7E.toByte(), 0x6A.toByte(), 0x2C.toByte(), 0xC2.toByte(), 0x5D.toByte(), 0x3A.toByte(), + 0xB8.toByte(), 0x40.toByte(), 0xCA.toByte(), 0x59.toByte(), 0x4D.toByte(), 0x16.toByte(), + 0x2D.toByte(), 0x74.toByte(), 0xEB.toByte(), 0x0E.toByte(), 0x72.toByte(), 0x46.toByte(), + 0x29.toByte(), 0xF9.toByte(), 0xDE.toByte(), 0x9B.toByte(), 0xCE.toByte(), 0x4B.toByte(), + 0x8C.toByte(), 0xD0.toByte(), 0xCA.toByte(), 0xF4.toByte(), 0x08.toByte(), 0x94.toByte(), + 0x46.toByte(), 0xA5.toByte(), 0x11.toByte(), 0xAF.toByte(), 0x3A.toByte(), 0xCB.toByte(), + 0xB8.toByte(), 0x4E.toByte(), 0xDE.toByte(), 0xC6.toByte(), 0xD8.toByte(), 0x85.toByte(), + 0x0A.toByte(), 0x7D.toByte(), 0xAA.toByte(), 0x96.toByte(), 0x0A.toByte(), 0xEA.toByte(), + 0x7B.toByte(), 0x51.toByte(), 0xD6.toByte(), 0x22.toByte(), 0x62.toByte(), 0x5C.toByte(), + 0x1E.toByte(), 0x58.toByte(), 0xD7.toByte(), 0x46.toByte(), 0x1E.toByte(), 0x09.toByte(), + 0xAE.toByte(), 0x43.toByte(), 0xA7.toByte(), 0xC4.toByte(), 0x34.toByte(), 0x69.toByte(), + 0xA2.toByte(), 0xA5.toByte(), 0xE8.toByte(), 0x44.toByte(), 0x76.toByte(), 0x18.toByte(), + 0xE2.toByte(), 0x3D.toByte(), 0xB7.toByte(), 0xC5.toByte(), 0xA8.toByte(), 0x96.toByte(), + 0xFD.toByte(), 0xE5.toByte(), 0xB4.toByte(), 0x4B.toByte(), 0xF8.toByte(), 0x40.toByte(), + 0x12.toByte(), 0xA6.toByte(), 0x17.toByte(), 0x4E.toByte(), 0xC4.toByte(), 0xC1.toByte(), + 0x60.toByte(), 0x0E.toByte(), 0xB0.toByte(), 0xC2.toByte(), 0xB8.toByte(), 0x40.toByte(), + 0x4D.toByte(), 0x9E.toByte(), 0x76.toByte(), 0x4C.toByte(), 0x44.toByte(), 0xF4.toByte(), + 0xFC.toByte(), 0x6F.toByte(), 0x14.toByte(), 0x89.toByte(), 0x73.toByte(), 0xB4.toByte(), + 0x13.toByte(), 0x02.toByte(), 0x01.toByte(), 0x11.toByte() + ), + EUniverse.Dev to byteArrayOf( + 0x30.toByte(), 0x81.toByte(), 0x9D.toByte(), 0x30.toByte(), 0x0D.toByte(), 0x06.toByte(), + 0x09.toByte(), 0x2A.toByte(), 0x86.toByte(), 0x48.toByte(), 0x86.toByte(), 0xF7.toByte(), + 0x0D.toByte(), 0x01.toByte(), 0x01.toByte(), 0x01.toByte(), 0x05.toByte(), 0x00.toByte(), + 0x03.toByte(), 0x81.toByte(), 0x8B.toByte(), 0x00.toByte(), 0x30.toByte(), 0x81.toByte(), + 0x87.toByte(), 0x02.toByte(), 0x81.toByte(), 0x81.toByte(), 0x00.toByte(), 0xD0.toByte(), + 0x05.toByte(), 0x2C.toByte(), 0xE9.toByte(), 0x80.toByte(), 0x95.toByte(), 0xCD.toByte(), + 0x30.toByte(), 0x83.toByte(), 0xA8.toByte(), 0xE9.toByte(), 0x25.toByte(), 0x96.toByte(), + 0x63.toByte(), 0xCE.toByte(), 0xCC.toByte(), 0x48.toByte(), 0x5D.toByte(), 0x5C.toByte(), + 0x52.toByte(), 0x00.toByte(), 0xDB.toByte(), 0x1E.toByte(), 0x78.toByte(), 0xD7.toByte(), + 0x6A.toByte(), 0x4C.toByte(), 0x2C.toByte(), 0xC8.toByte(), 0x41.toByte(), 0x8C.toByte(), + 0xCC.toByte(), 0x87.toByte(), 0x46.toByte(), 0xFB.toByte(), 0x1B.toByte(), 0xC9.toByte(), + 0xE8.toByte(), 0x6E.toByte(), 0x4F.toByte(), 0x7A.toByte(), 0x6B.toByte(), 0xC3.toByte(), + 0xE7.toByte(), 0x0F.toByte(), 0xD5.toByte(), 0xA9.toByte(), 0x5D.toByte(), 0x6C.toByte(), + 0xD4.toByte(), 0xEE.toByte(), 0xA2.toByte(), 0xCC.toByte(), 0x80.toByte(), 0x5A.toByte(), + 0xD3.toByte(), 0xCE.toByte(), 0x53.toByte(), 0x59.toByte(), 0xE6.toByte(), 0x80.toByte(), + 0x91.toByte(), 0xC4.toByte(), 0xC0.toByte(), 0xD5.toByte(), 0xF0.toByte(), 0x63.toByte(), + 0x23.toByte(), 0x91.toByte(), 0x69.toByte(), 0x70.toByte(), 0xC5.toByte(), 0xBB.toByte(), + 0xBD.toByte(), 0x05.toByte(), 0xE2.toByte(), 0x4F.toByte(), 0x7D.toByte(), 0x90.toByte(), + 0x12.toByte(), 0xED.toByte(), 0xAC.toByte(), 0x4F.toByte(), 0x86.toByte(), 0x96.toByte(), + 0x3C.toByte(), 0x89.toByte(), 0xCC.toByte(), 0x92.toByte(), 0x15.toByte(), 0x63.toByte(), + 0xCB.toByte(), 0x57.toByte(), 0x70.toByte(), 0xB9.toByte(), 0xC3.toByte(), 0xAE.toByte(), + 0x08.toByte(), 0x4F.toByte(), 0xC8.toByte(), 0x56.toByte(), 0x16.toByte(), 0xB0.toByte(), + 0x0C.toByte(), 0xC6.toByte(), 0xC8.toByte(), 0x8A.toByte(), 0x80.toByte(), 0xD2.toByte(), + 0x37.toByte(), 0xF7.toByte(), 0x7F.toByte(), 0xAB.toByte(), 0x93.toByte(), 0xBB.toByte(), + 0xE6.toByte(), 0xDE.toByte(), 0x95.toByte(), 0x78.toByte(), 0xB8.toByte(), 0x11.toByte(), + 0xC9.toByte(), 0xE5.toByte(), 0x62.toByte(), 0xAD.toByte(), 0xBC.toByte(), 0x0C.toByte(), + 0x87.toByte(), 0x02.toByte(), 0x01.toByte(), 0x11.toByte() + ) + ) /** * Gets the public key for the given universe. - * * @param universe The universe. * @return The public key. */ - public static byte[] getPublicKey(EUniverse universe) { - return KEYS.get(universe); - } + @JvmStatic + fun getPublicKey(universe: EUniverse?): ByteArray? = KEYS[universe] } diff --git a/src/main/java/in/dragonbra/javasteam/util/MsgUtil.kt b/src/main/java/in/dragonbra/javasteam/util/MsgUtil.kt index 9e7e5087..7f7cb55f 100644 --- a/src/main/java/in/dragonbra/javasteam/util/MsgUtil.kt +++ b/src/main/java/in/dragonbra/javasteam/util/MsgUtil.kt @@ -1,72 +1,65 @@ -package in.dragonbra.javasteam.util; +package `in`.dragonbra.javasteam.util -import in.dragonbra.javasteam.enums.EMsg; +import `in`.dragonbra.javasteam.enums.EMsg /** * @author lngtr * @since 2018-02-21 */ -public class MsgUtil { - - private static final int PROTO_MASK = 0x80000000; - private static final int EMSG_MASK = ~PROTO_MASK; +object MsgUtil { + private const val PROTO_MASK = -0x80000000 + private const val EMSG_MASK = PROTO_MASK.inv() /** * Strips off the protobuf message flag and returns an EMsg. - * * @param msg The message number. * @return The underlying EMsg. */ - public static EMsg getMsg(int msg) { - return EMsg.from(msg & EMSG_MASK); - } + @JvmStatic + fun getMsg(msg: Int): EMsg? = EMsg.from(msg and EMSG_MASK) /** * Strips off the protobuf message flag and returns an EMsg. - * * @param msg The message number. * @return The underlying EMsg. */ - public static int getGCMsg(int msg) { - return msg & EMSG_MASK; - } + @JvmStatic + fun getGCMsg(msg: Int): Int = msg and EMSG_MASK /** * Crafts an EMsg, flagging it if required. - * * @param msg The EMsg to flag. * @param protobuf if set to true, the message is protobuf flagged. * @return A crafted EMsg, flagged if requested. */ - public static int makeMsg(int msg, boolean protobuf) { + @JvmStatic + fun makeMsg(msg: Int, protobuf: Boolean): Int { if (protobuf) { - return msg | PROTO_MASK; + return msg or PROTO_MASK } - return msg; + return msg } /** * Crafts an EMsg, flagging it if required. - * * @param msg The EMsg to flag. - * @param protobuf if set to true, the message is protobuf flagged. + * @param protobuf if set to **true**, the message is protobuf flagged. * @return A crafted EMsg, flagged if requested */ - public static int makeGCMsg(int msg, boolean protobuf) { + @JvmStatic + fun makeGCMsg(msg: Int, protobuf: Boolean): Int { if (protobuf) { - return msg | PROTO_MASK; + return msg or PROTO_MASK } - return msg; + return msg } /** * Determines whether message is protobuf flagged. - * * @param msg The message. - * @return true if this message is protobuf flagged; otherwise, false. + * @return **true** if this message is protobuf flagged; otherwise, **false**. */ - public static boolean isProtoBuf(int msg) { - return (msg & 0xffffffffL & (long) PROTO_MASK) > 0; - } + @JvmStatic + fun isProtoBuf(msg: Int): Boolean = (msg.toLong() and 0xffffffffL and PROTO_MASK.toLong()) > 0 } From 98ddcb756ab15bb3eb75e94099af872c7db438d8 Mon Sep 17 00:00:00 2001 From: Lossy Date: Tue, 16 Jun 2026 21:21:15 -0500 Subject: [PATCH 23/27] Rename .java to .kt --- .../crypto/{BerDecodeException.java => BerDecodeException.kt} | 0 .../util/crypto/{CryptoException.java => CryptoException.kt} | 0 .../javasteam/util/crypto/{RSACrypto.java => RSACrypto.kt} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/main/java/in/dragonbra/javasteam/util/crypto/{BerDecodeException.java => BerDecodeException.kt} (100%) rename src/main/java/in/dragonbra/javasteam/util/crypto/{CryptoException.java => CryptoException.kt} (100%) rename src/main/java/in/dragonbra/javasteam/util/crypto/{RSACrypto.java => RSACrypto.kt} (100%) diff --git a/src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.java b/src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.java rename to src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.kt diff --git a/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.java b/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.java rename to src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt diff --git a/src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.java b/src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.java rename to src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.kt From 6e02e92970e01b9725cef9d055c78d57da186a09 Mon Sep 17 00:00:00 2001 From: Lossy Date: Tue, 16 Jun 2026 21:21:15 -0500 Subject: [PATCH 24/27] Port even more classes to kotlin --- .../util/crypto/BerDecodeException.kt | 48 ++-------- .../javasteam/util/crypto/CryptoException.kt | 30 ++----- .../javasteam/util/crypto/RSACrypto.kt | 87 ++++++------------- .../javasteam/util/crypto/RSACryptoTest.java | 6 ++ 4 files changed, 48 insertions(+), 123 deletions(-) diff --git a/src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.kt b/src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.kt index 0b4dd03b..10bf986c 100644 --- a/src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.kt +++ b/src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.kt @@ -1,40 +1,10 @@ -package in.dragonbra.javasteam.util.crypto; - -@SuppressWarnings("unused") -public final class BerDecodeException extends Exception { - - private final int _position; - - public BerDecodeException() { - _position = 0; - } - - public BerDecodeException(String message) { - super(message); - _position = 0; - } - - public BerDecodeException(String message, Exception ex) { - super(message, ex); - _position = 0; - } - - public BerDecodeException(String message, int position) { - super(message); - _position = position; - } - - public BerDecodeException(String message, int position, Exception ex) { - super(message, ex); - _position = position; - } - - public int get_position() { - return _position; - } - - @Override - public String getMessage() { - return super.getMessage() + String.format(" (Position %d)%s", _position, System.lineSeparator()); - } +package `in`.dragonbra.javasteam.util.crypto + +class BerDecodeException @JvmOverloads constructor( + msg: String? = null, + val position: Int = 0, + cause: Exception? = null, +) : Exception(msg, cause) { + override val message: String + get() = super.message + " (Position $position)${System.lineSeparator()}" } diff --git a/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt b/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt index 4d5f624e..06e8a92f 100644 --- a/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt +++ b/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt @@ -1,26 +1,6 @@ -package in.dragonbra.javasteam.util.crypto; +package `in`.dragonbra.javasteam.util.crypto -/** - * @author lngtr - * @since 2018-03-02 - */ -public class CryptoException extends Exception { - public CryptoException() { - } - - public CryptoException(String message) { - super(message); - } - - public CryptoException(String message, Throwable cause) { - super(message, cause); - } - - public CryptoException(Throwable cause) { - super(cause); - } - - public CryptoException(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) { - super(message, cause, enableSuppression, writableStackTrace); - } -} +class CryptoException @JvmOverloads constructor( + message: String? = null, + cause: Throwable? = null, +) : Exception(message, cause) diff --git a/src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.kt b/src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.kt index 60ce0018..bd99db2c 100644 --- a/src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.kt +++ b/src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.kt @@ -1,72 +1,41 @@ -package in.dragonbra.javasteam.util.crypto; +package `in`.dragonbra.javasteam.util.crypto -import in.dragonbra.javasteam.util.log.LogManager; -import in.dragonbra.javasteam.util.log.Logger; - -import javax.crypto.BadPaddingException; -import javax.crypto.Cipher; -import javax.crypto.IllegalBlockSizeException; -import javax.crypto.NoSuchPaddingException; -import java.math.BigInteger; -import java.security.InvalidKeyException; -import java.security.KeyFactory; -import java.security.NoSuchAlgorithmException; -import java.security.NoSuchProviderException; -import java.security.interfaces.RSAPublicKey; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.RSAPublicKeySpec; -import java.util.ArrayList; -import java.util.List; +import `in`.dragonbra.javasteam.util.log.LogManager.getLogger +import java.security.GeneralSecurityException +import java.security.KeyFactory +import java.security.spec.RSAPublicKeySpec +import javax.crypto.Cipher /** * Handles encrypting and decrypting using the RSA public key encryption algorithm. */ -public class RSACrypto { - - private static final Logger logger = LogManager.getLogger(RSACrypto.class); - - private Cipher cipher; - - public RSACrypto(byte[] key) { - if (key == null) { - throw new IllegalArgumentException("key is null"); - } +class RSACrypto(key: ByteArray?) { - try { - final List list = new ArrayList<>(); - for (final byte b : key) { - list.add(b); - } - final AsnKeyParser keyParser = new AsnKeyParser(list); - final BigInteger[] keys = keyParser.parseRSAPublicKey(); - init(keys[0], keys[1]); - } catch (final BerDecodeException e) { - logger.error(e); - } + companion object { + private val logger = getLogger(RSACrypto::class.java) } - private void init(BigInteger mod, BigInteger exp) { - try { - final RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(mod, exp); - - final KeyFactory factory = KeyFactory.getInstance("RSA"); - RSAPublicKey rsaKey = (RSAPublicKey) factory.generatePublic(publicKeySpec); - - cipher = Cipher.getInstance("RSA/None/OAEPWithSHA1AndMGF1Padding", CryptoHelper.SEC_PROV); - cipher.init(Cipher.ENCRYPT_MODE, rsaKey); - } catch (final NoSuchAlgorithmException | NoSuchPaddingException | InvalidKeyException | InvalidKeySpecException - | NoSuchProviderException e) { - logger.debug(e); + private val cipher: Cipher? = try { + requireNotNull(key) { "key is null" } + val keys = AsnKeyParser(key.asList()).parseRSAPublicKey() + val publicKeySpec = RSAPublicKeySpec(keys[0], keys[1]) + val factory = KeyFactory.getInstance("RSA") + val rsaKey = factory.generatePublic(publicKeySpec) + Cipher.getInstance("RSA/None/OAEPWithSHA1AndMGF1Padding", CryptoHelper.SEC_PROV).also { + it.init(Cipher.ENCRYPT_MODE, rsaKey) } + } catch (e: BerDecodeException) { + logger.error(e) + null + } catch (e: GeneralSecurityException) { + logger.debug(e) + null } - public byte[] encrypt(byte[] input) { - try { - return cipher.doFinal(input); - } catch (final IllegalBlockSizeException | BadPaddingException e) { - logger.debug(e); - } - - return null; + fun encrypt(input: ByteArray): ByteArray? = try { + cipher?.doFinal(input) + } catch (e: GeneralSecurityException) { + logger.debug(e) + null } } diff --git a/src/test/java/in/dragonbra/javasteam/util/crypto/RSACryptoTest.java b/src/test/java/in/dragonbra/javasteam/util/crypto/RSACryptoTest.java index a226fd17..3aadc944 100644 --- a/src/test/java/in/dragonbra/javasteam/util/crypto/RSACryptoTest.java +++ b/src/test/java/in/dragonbra/javasteam/util/crypto/RSACryptoTest.java @@ -27,6 +27,7 @@ public void encrypt() { var encrypted = rsaCrypto.encrypt(input); Assertions.assertNotNull(encrypted); + Assertions.assertEquals(128, encrypted.length); // 1024-bit } @Test @@ -34,4 +35,9 @@ public void cipherInstance() throws NoSuchAlgorithmException, NoSuchPaddingExcep var cipher = Cipher.getInstance("RSA/None/OAEPWithSHA1AndMGF1Padding", CryptoHelper.SEC_PROV); Assertions.assertNotNull(cipher); } + + @Test + public void encryptNullKeyThrows() { + Assertions.assertThrows(IllegalArgumentException.class, () -> new RSACrypto(null)); + } } From e9e6d1973d5e707344d64283276e567444fa957d Mon Sep 17 00:00:00 2001 From: Lossy Date: Tue, 16 Jun 2026 21:45:24 -0500 Subject: [PATCH 25/27] Rename .java to .kt --- .../javasteam/util/compat/{Consumer.java => Consumer.kt} | 0 .../util/compat/{ObjectsCompat.java => ObjectsCompat.kt} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/main/java/in/dragonbra/javasteam/util/compat/{Consumer.java => Consumer.kt} (100%) rename src/main/java/in/dragonbra/javasteam/util/compat/{ObjectsCompat.java => ObjectsCompat.kt} (100%) diff --git a/src/main/java/in/dragonbra/javasteam/util/compat/Consumer.java b/src/main/java/in/dragonbra/javasteam/util/compat/Consumer.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/compat/Consumer.java rename to src/main/java/in/dragonbra/javasteam/util/compat/Consumer.kt diff --git a/src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.java b/src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.kt similarity index 100% rename from src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.java rename to src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.kt From 85ca62c94af41213959ccf40db07d35b6aeae418 Mon Sep 17 00:00:00 2001 From: Lossy Date: Tue, 16 Jun 2026 21:45:24 -0500 Subject: [PATCH 26/27] Port even more classes to kotlin --- .../javasteam/util/compat/Consumer.kt | 6 +- .../javasteam/util/compat/ObjectsCompat.kt | 14 +- .../javasteam/util/crypto/CryptoException.kt | 4 + .../javasteam/util/crypto/AsnParserTest.java | 188 ++++++++++++++++++ 4 files changed, 201 insertions(+), 11 deletions(-) create mode 100644 src/test/java/in/dragonbra/javasteam/util/crypto/AsnParserTest.java diff --git a/src/main/java/in/dragonbra/javasteam/util/compat/Consumer.kt b/src/main/java/in/dragonbra/javasteam/util/compat/Consumer.kt index 1756895e..d5043bcd 100644 --- a/src/main/java/in/dragonbra/javasteam/util/compat/Consumer.kt +++ b/src/main/java/in/dragonbra/javasteam/util/compat/Consumer.kt @@ -1,5 +1,5 @@ -package in.dragonbra.javasteam.util.compat; +package `in`.dragonbra.javasteam.util.compat -public interface Consumer { - void accept(T t); +fun interface Consumer { + fun accept(t: T) } diff --git a/src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.kt b/src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.kt index 591a32b0..25daec52 100644 --- a/src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.kt +++ b/src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.kt @@ -1,13 +1,11 @@ -package in.dragonbra.javasteam.util.compat; - -import java.util.Objects; +package `in`.dragonbra.javasteam.util.compat /** + * Compatibility for [java.util.Objects] for Android, which requires API 19+. * @author steev * @since 2018-03-21 */ -public class ObjectsCompat { - public static boolean equals(Object a, Object b) { - return Objects.equals(a, b); - } -} \ No newline at end of file +object ObjectsCompat { + @JvmStatic + fun equals(a: Any?, b: Any?): Boolean = a == b +} diff --git a/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt b/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt index 06e8a92f..3d09328e 100644 --- a/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt +++ b/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt @@ -1,5 +1,9 @@ package `in`.dragonbra.javasteam.util.crypto +/** + * @author lngtr + * @since 2018-03-02 + */ class CryptoException @JvmOverloads constructor( message: String? = null, cause: Throwable? = null, diff --git a/src/test/java/in/dragonbra/javasteam/util/crypto/AsnParserTest.java b/src/test/java/in/dragonbra/javasteam/util/crypto/AsnParserTest.java new file mode 100644 index 00000000..f8f04919 --- /dev/null +++ b/src/test/java/in/dragonbra/javasteam/util/crypto/AsnParserTest.java @@ -0,0 +1,188 @@ +package in.dragonbra.javasteam.util.crypto; + +import in.dragonbra.javasteam.TestBase; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +public class AsnParserTest extends TestBase { + + private static List bytes(int... values) { + Byte[] result = new Byte[values.length]; + for (int i = 0; i < values.length; i++) { + result[i] = (byte) values[i]; + } + return Arrays.asList(result); + } + + @Test + public void positionTracking() throws BerDecodeException { + var parser = new AsnParser(bytes(0x01, 0x02, 0x03)); + assertEquals(0, parser.currentPosition()); + assertEquals(3, parser.remainingBytes()); + + parser.getNextOctet(); + assertEquals(1, parser.currentPosition()); + assertEquals(2, parser.remainingBytes()); + } + + @Test + public void getNextOctet() throws BerDecodeException { + var parser = new AsnParser(bytes(0x42)); + assertEquals(0x42, parser.getNextOctet()); + assertEquals(0, parser.remainingBytes()); + } + + @Test + public void getNextOctetEmptyThrows() { + var parser = new AsnParser(Collections.emptyList()); + assertThrows(BerDecodeException.class, parser::getNextOctet); + } + + @Test + public void getLengthShortForm() throws BerDecodeException { + assertEquals(127, new AsnParser(bytes(0x7f)).getLength()); + assertEquals(0, new AsnParser(bytes(0x00)).getLength()); + } + + @Test + public void getLengthLongForm() throws BerDecodeException { + // 0x81 = long form, 1 byte follows: 0x42 = 66 + assertEquals(0x42, new AsnParser(bytes(0x81, 0x42)).getLength()); + } + + @Test + public void getLengthInvalidEncodingThrows() { + // 0x85 = long form with 5 length bytes — exceeds max of 4 + var parser = new AsnParser(bytes(0x85, 0x00, 0x00, 0x00, 0x00, 0x00)); + assertThrows(BerDecodeException.class, parser::getLength); + } + + @Test + public void next() throws BerDecodeException { + var parser = new AsnParser(bytes(0x02, 0x02, 0xAA, 0xBB)); + assertArrayEquals(new byte[]{(byte) 0xAA, (byte) 0xBB}, parser.next()); + } + + @Test + public void nextNull() throws BerDecodeException { + var parser = new AsnParser(bytes(0x05, 0x00)); + assertEquals(0, parser.nextNull()); + assertEquals(0, parser.remainingBytes()); + } + + @Test + public void nextNullWrongTagThrows() { + assertThrows(BerDecodeException.class, () -> new AsnParser(bytes(0x02, 0x00)).nextNull()); + } + + @Test + public void nextNullNonZeroSizeThrows() { + assertThrows(BerDecodeException.class, () -> new AsnParser(bytes(0x05, 0x01)).nextNull()); + } + + @Test + public void isNextNull() { + assertTrue(new AsnParser(bytes(0x05, 0x00)).isNextNull()); + assertFalse(new AsnParser(bytes(0x02, 0x00)).isNextNull()); + } + + @Test + public void nextSequence() throws BerDecodeException { + var parser = new AsnParser(bytes(0x30, 0x03, 0x01, 0x02, 0x03)); + assertEquals(3, parser.nextSequence()); + assertEquals(3, parser.remainingBytes()); + } + + @Test + public void nextSequenceWrongTagThrows() { + assertThrows(BerDecodeException.class, () -> new AsnParser(bytes(0x02, 0x03, 0x01, 0x02, 0x03)).nextSequence()); + } + + @Test + public void nextSequenceOverflowThrows() { + // length claims 5 but only 1 byte remains + assertThrows(BerDecodeException.class, () -> new AsnParser(bytes(0x30, 0x05, 0x01)).nextSequence()); + } + + @Test + public void isNextSequence() { + assertTrue(new AsnParser(bytes(0x30, 0x00)).isNextSequence()); + assertFalse(new AsnParser(bytes(0x02, 0x00)).isNextSequence()); + } + + @Test + public void nextOctetString() throws BerDecodeException { + var parser = new AsnParser(bytes(0x04, 0x02, 0xAA, 0xBB)); + assertEquals(2, parser.nextOctetString()); + assertEquals(2, parser.remainingBytes()); + } + + @Test + public void nextOctetStringWrongTagThrows() { + assertThrows(BerDecodeException.class, () -> new AsnParser(bytes(0x02, 0x02, 0xAA, 0xBB)).nextOctetString()); + } + + @Test + public void isNextOctetString() { + assertTrue(new AsnParser(bytes(0x04, 0x00)).isNextOctetString()); + assertFalse(new AsnParser(bytes(0x02, 0x00)).isNextOctetString()); + } + + @Test + public void nextBitString() throws BerDecodeException { + // tag 0x03, length 4, unused-bits byte 0x00, then 3 bytes content + var parser = new AsnParser(bytes(0x03, 0x04, 0x00, 0xDE, 0xAD, 0xBE)); + assertEquals(3, parser.nextBitString()); + assertEquals(3, parser.remainingBytes()); + } + + @Test + public void nextBitStringNonZeroUnusedThrows() { + assertThrows(BerDecodeException.class, () -> new AsnParser(bytes(0x03, 0x02, 0x01, 0xDE)).nextBitString()); + } + + @Test + public void isNextBitString() { + assertTrue(new AsnParser(bytes(0x03, 0x00)).isNextBitString()); + assertFalse(new AsnParser(bytes(0x02, 0x00)).isNextBitString()); + } + + @Test + public void nextInteger() throws BerDecodeException { + var parser = new AsnParser(bytes(0x02, 0x03, 0x01, 0x02, 0x03)); + assertArrayEquals(new byte[]{0x01, 0x02, 0x03}, parser.nextInteger()); + } + + @Test + public void nextIntegerWrongTagThrows() { + assertThrows(BerDecodeException.class, () -> new AsnParser(bytes(0x05, 0x03, 0x01, 0x02, 0x03)).nextInteger()); + } + + @Test + public void nextIntegerOverflowThrows() { + // length claims 5 but only 1 byte remains + assertThrows(BerDecodeException.class, () -> new AsnParser(bytes(0x02, 0x05, 0x01)).nextInteger()); + } + + @Test + public void isNextInteger() { + assertTrue(new AsnParser(bytes(0x02, 0x01, 0x00)).isNextInteger()); + assertFalse(new AsnParser(bytes(0x05, 0x00)).isNextInteger()); + } + + @Test + public void nextOID() throws BerDecodeException { + var parser = new AsnParser(bytes(0x06, 0x03, 0x55, 0x04, 0x03)); + assertArrayEquals(new byte[]{0x55, 0x04, 0x03}, parser.nextOID()); + } + + @Test + public void nextOIDWrongTagThrows() { + assertThrows(BerDecodeException.class, () -> new AsnParser(bytes(0x02, 0x03, 0x55, 0x04, 0x03)).nextOID()); + } +} From 0af1ad4b104c6669c86f9b47460689d1e91e4eed Mon Sep 17 00:00:00 2001 From: Lossy Date: Tue, 16 Jun 2026 22:07:34 -0500 Subject: [PATCH 27/27] Simplify weird hack I made for CallbackManager --- .../callbackmgr/CallbackManager.kt | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/main/java/in/dragonbra/javasteam/steam/steamclient/callbackmgr/CallbackManager.kt b/src/main/java/in/dragonbra/javasteam/steam/steamclient/callbackmgr/CallbackManager.kt index 7fd9edd3..9f01e075 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/steamclient/callbackmgr/CallbackManager.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/steamclient/callbackmgr/CallbackManager.kt @@ -174,17 +174,11 @@ class CallbackManager(private val steamClient: SteamClient) { ): Closeable { steamUnifiedMessages.createService(serviceClass) - // wrappedCallback checks that the notification body matches the expected type - // before passing it to callbackFunc, preventing ClassCastException due to type erasure. - val wrappedCallback = Consumer> { notification -> - if (notification.body::class.java == notificationClass) { - callbackFunc.accept(notification as ServiceMethodNotification) - } - } - + // All ServiceMethodNotification<*> erase to the same class at runtime, so without this + // check every subscriber would fire for every notification regardless of body type. return Callback( callbackType = ServiceMethodNotification::class.java as Class>, - onRun = wrappedCallback::accept, + onRun = { notification -> if (notificationClass.isInstance(notification.body)) callbackFunc.accept(notification) }, mgr = this, jobID = JobID.INVALID ) @@ -208,17 +202,11 @@ class CallbackManager(private val steamClient: SteamClient) { ): Closeable { steamUnifiedMessages.createService(serviceClass) - // wrappedCallback checks that the notification body matches the expected type - // before passing it to callbackFunc, preventing ClassCastException due to type erasure. - val wrappedCallback = Consumer> { notification -> - if (notification.body::class.java == notificationClass) { - callbackFunc.accept(notification as ServiceMethodResponse) - } - } - + // All ServiceMethodResponse<*> erase to the same class at runtime, so without this + // check every subscriber would fire for every response regardless of body type. return Callback( callbackType = ServiceMethodResponse::class.java as Class>, - onRun = wrappedCallback::accept, + onRun = { notification -> if (notificationClass.isInstance(notification.body)) callbackFunc.accept(notification) }, mgr = this, jobID = JobID.INVALID, )