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/.github/workflows/javasteam-build-pr.yml b/.github/workflows/javasteam-build-pr.yml index 42ebeadb..f6642f6e 100644 --- a/.github/workflows/javasteam-build-pr.yml +++ b/.github/workflows/javasteam-build-pr.yml @@ -25,14 +25,14 @@ 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 - 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 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 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/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/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/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( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2878fb0..ae5e6b26 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,38 +4,38 @@ # **** [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 +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 +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 -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 -mockitoVersion = "5.18.0" # https://mvnrepository.com/artifact/org.mockito/mockito-core +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 = "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.23.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 a4b76b95..1b33c55b 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ 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/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 539717d0..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 @@ -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. @@ -100,8 +62,7 @@ import kotlin.text.toLongOrNull * @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. @@ -118,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, @@ -165,16 +125,28 @@ 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) + + // 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 + // 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 +163,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 +222,21 @@ class DepotDownloader @JvmOverloads constructor( } } - private fun createChunkProcessingFlow(): kotlinx.coroutines.flow.Flow = networkChunkFlow + private fun createChunkProcessingFlow() = 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: CancellationException) { + throw e } catch (e: Exception) { logger?.error("Error downloading chunk: ${e.message}", e) } @@ -273,10 +245,9 @@ class DepotDownloader @JvmOverloads constructor( .flatMapMerge(concurrency = maxDecompress) { item -> flow { try { - val result = processFileDecompress(item) - if (result != null) { - emit(result) - } + emit(processFileDecompress(item)) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { logger?.error("Error decompressing chunk: ${e.message}", e) } @@ -284,14 +255,19 @@ class DepotDownloader @JvmOverloads constructor( } .flatMapMerge(concurrency = maxFileWrites) { item -> flow { + var rethrow: CancellationException? = null try { processFileWrites(item) - pendingChunks.decrementAndGet() - emit(Unit) + } catch (e: CancellationException) { + rethrow = e } catch (e: Exception) { logger?.error("Error writing file: ${e.message}", e) - pendingChunks.decrementAndGet() } + if (pendingChunks.decrementAndGet() == 0) { + downloadCompletion?.complete(Unit) + } + rethrow?.let { throw it } + emit(Unit) }.flowOn(Dispatchers.IO) } @@ -408,17 +384,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 +844,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) @@ -902,23 +872,18 @@ 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) 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 +900,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 +916,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" ) } @@ -1067,7 +1036,7 @@ class DepotDownloader @JvmOverloads constructor( continue } - cdnClientPool!!.returnBrokenConnection(connection) + cdnClientPool!!.skipConnection(connection) // Unauthorized || Forbidden if (e.statusCode == 401 || e.statusCode == 403) { @@ -1083,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) @@ -1109,11 +1083,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 +1105,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 +1118,7 @@ class DepotDownloader @JvmOverloads constructor( previousManifest = oldManifest, filteredFiles = filesAfterExclusions.toMutableList(), allFileNames = allFileNames, + previousManifestIndex = oldManifest?.files?.associateBy { it.fileName }, ) } @@ -1168,36 +1139,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 +1222,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 +1291,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 +1364,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 +1374,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 +1397,7 @@ class DepotDownloader @JvmOverloads constructor( neededChunks!!.forEach { chunk -> pendingChunks.incrementAndGet() - networkChunkFlow.tryEmit( + networkChunkChannel.send( NetworkChunkItem( downloadCounter = downloadCounter, depotFilesData = depotFilesData, @@ -1477,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) @@ -1519,7 +1465,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 @@ -1536,7 +1482,7 @@ class DepotDownloader @JvmOverloads constructor( continue } - cdnClientPool!!.returnBrokenConnection(connection) + cdnClientPool!!.skipConnection(connection) // Unauthorized || Forbidden if (e.statusCode == 401 || e.statusCode == 403) { @@ -1546,8 +1492,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) @@ -1564,7 +1515,6 @@ class DepotDownloader @JvmOverloads constructor( depot = depot, depotDownloadCounter = depotDownloadCounter, downloadCounter = downloadCounter, - downloaded = downloaded, file = file, fileStreamData = fileStreamData, chunk = chunk, @@ -1592,13 +1542,11 @@ 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) } } - - completionFuture.complete(null) } // endregion @@ -1614,9 +1562,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 @@ -1685,97 +1631,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) } } @@ -1784,18 +1735,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 +1791,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 +1824,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 +1855,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/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..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; /** @@ -88,15 +89,22 @@ public void run() { // Anything pertaining to this sample will be commented. // Depot chunks are downloaded using OKHttp, it's best to set some timeouts. - var config = SteamConfiguration.create(builder -> { - builder.withHttpClient( - new OkHttpClient.Builder() - .connectTimeout(10, TimeUnit.SECONDS) // Time to establish connection - .readTimeout(60, TimeUnit.SECONDS) // Max inactivity between reads - .writeTimeout(30, TimeUnit.SECONDS) // Time for writes - .build() - ); - }); + // 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(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() + )); steamClient = new SteamClient(config); @@ -194,7 +202,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 +260,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. 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 fe8b3d2d..1f14159a 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,55 @@ 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 + @Volatile private var client: HttpClient? = null - private var session: WebSocketSession? = null + @Volatile private var session: WebSocketSession? = null - private var endpoint: InetSocketAddress? = null + @Volatile private var connectionJob: Job? = null - private var lastFrameTime = System.currentTimeMillis() - - override val coroutineContext: CoroutineContext = Dispatchers.IO + job + @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 +74,102 @@ class WebSocketConnection : path("cmsocket/") } } + session = newSession - this@WebSocketConnection.session = session + logger.debug("Connected to ${endPoint.hostName}:${endPoint.port}") + onConnected() - startConnectionMonitoring() + launch { runWatchdog() } - 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)) - } + newSession.incoming.consumeEach { frame -> + when (frame) { + is Frame.Binary -> { + lastFrameTime.set(System.currentTimeMillis()) + onNetMsgReceived(NetMsgEventArgs(frame.readBytes(), currentEndPoint)) + } - is Frame.Close -> disconnect(false) + is Frame.Close -> doDisconnect(false) - is Frame.Ping -> logger.debug("Received pong") + is Frame.Ping -> logger.debug("Received ping") - // Never Used. - is Frame.Pong -> logger.debug("Received pong") + is Frame.Pong -> logger.debug("Received pong") - // Never Used. - is Frame.Text -> logger.debug("Received plain text ${frame.readText()}") - } - } - } 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.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 { - try { - session?.close() - client?.close() - } finally { - session = null - client = null + doDisconnect(userInitiated) + } - job.cancelChildren() + private fun doDisconnect(userInitiated: Boolean) { + if (!disconnecting.compareAndSet(false, true)) return + + scope.launch { + val currentJob = connectionJob + val currentSession = session + val currentClient = client + connectionJob = null + session = null + client = null + + try { + currentSession?.close() + } catch (e: Exception) { + logger.debug("Error closing WebSocket session: ${e.message}") + } + try { + currentClient?.close() + } catch (e: Exception) { + logger.debug("Error closing HTTP client: ${e.message}") } + 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 +179,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/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/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/discovery/SmartCMServerList.kt b/src/main/java/in/dragonbra/javasteam/steam/discovery/SmartCMServerList.kt index 4a3b17a3..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. */ @@ -55,15 +63,23 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { @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) } } @@ -135,7 +151,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) @@ -145,7 +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") - fun resetOldScores() { + fun resetOldScores() = listLock.withLock { val cutoff = Instant.now().minus(badConnectionMemoryTimeSpan) servers.forEach { serverInfo -> @@ -159,7 +175,6 @@ 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. @@ -168,10 +183,11 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { 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) @@ -188,7 +204,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { /** * Explicitly resets the known state of all servers. */ - fun resetBadServers() { + fun resetBadServers(): Unit = listLock.withLock { servers.forEach { serverInfo -> serverInfo.lastBadConnectionTimeUtc = null } @@ -197,28 +213,26 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { fun tryMark(endPoint: InetSocketAddress?, protocolTypes: ProtocolTypes?, quality: ServerQuality): Boolean = tryMark(endPoint, protocolTypes?.let { EnumSet.of(it) }, quality) - 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) { @@ -226,7 +240,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { markServerCore(serverInfo, quality) } - return true + true } private fun markServerCore(serverInfo: ServerInfo, quality: ServerQuality) { @@ -238,11 +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. */ - private fun getNextServerCandidateInternal(supportedProtocolTypes: EnumSet): ServerRecord? { + private fun getNextServerCandidateInternal( + supportedProtocolTypes: EnumSet, + ): ServerRecord? = listLock.withLock { resetOldScores() val result = servers @@ -252,18 +267,14 @@ 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. */ @@ -283,7 +294,6 @@ 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. */ @@ -297,7 +307,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { 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() @@ -310,7 +320,7 @@ class SmartCMServerList(private val configuration: SteamConfiguration) { * @return whether the refresh was successful or not. **/ 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/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/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 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) } 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..e95df567 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt @@ -31,14 +31,12 @@ 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 +50,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 +200,52 @@ 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 { + if (timeout <= 0L) { + return CompletableFuture.completedFuture(callbackQueue.tryReceive().getOrNull()) + } + 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 +255,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 +368,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/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, ) 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/CollectionUtils.java b/src/main/java/in/dragonbra/javasteam/util/CollectionUtils.java deleted file mode 100644 index 4ba29f90..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/CollectionUtils.java +++ /dev/null @@ -1,20 +0,0 @@ -package in.dragonbra.javasteam.util; - -import in.dragonbra.javasteam.util.compat.ObjectsCompat; - -import java.util.Map; - -/** - * @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; - } -} diff --git a/src/main/java/in/dragonbra/javasteam/util/CollectionUtils.kt b/src/main/java/in/dragonbra/javasteam/util/CollectionUtils.kt new file mode 100644 index 00000000..0157e277 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/CollectionUtils.kt @@ -0,0 +1,14 @@ +package `in`.dragonbra.javasteam.util + +import `in`.dragonbra.javasteam.util.compat.ObjectsCompat + +/** + * @author lngtr + * @since 2018-02-19 + */ +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/HardwareUtils.java b/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.java deleted file mode 100644 index 0c71d162..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.java +++ /dev/null @@ -1,260 +0,0 @@ -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; - -/** - * @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(); - } - - 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 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; - } - - var os = process.getOutputStream(); - - try { - os.close(); - } catch (IOException ignored) { - } - - return new BufferedReader(new InputStreamReader(process.getInputStream())); - } - - 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; - } - - return sn; - } - - private static String readLshal() { - String sn = null; - - String line; - String marker = "system.hardware.serial ="; - - try (var br = read("lshal")) { - if (br == null) { - return null; - } - 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); - } - - 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 static String getDeviceName() { - var hostname = SystemUtils.getHostName(); - if (StringUtils.isBlank(hostname)) { - try { - // Last fallback. - hostname = InetAddress.getLocalHost().getHostName(); - } catch (UnknownHostException e) { - hostname = null; - } - } - - return hostname; - } - - 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 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; - } - } -} diff --git a/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.kt b/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.kt new file mode 100644 index 00000000..71cfd3dd --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/HardwareUtils.kt @@ -0,0 +1,118 @@ +package `in`.dragonbra.javasteam.util + +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 + */ +// 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 val resolvedMachineName: String by lazy { + val name = if (SystemUtils.IS_OS_ANDROID) getAndroidDeviceName() else getDeviceName() + name.takeUnless { it.isNullOrBlank() } ?: "Unknown" + } + + // 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() + } + null + } + }.getOrNull() + + 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() + + private fun getSerialNumberUnix(): String? = readDmidecode() ?: readLshal() + + private fun read(command: String): BufferedReader? { + val process = runCatching { + Runtime.getRuntime().exec(command.split(" ").toTypedArray()) + }.getOrNull() ?: return null + + runCatching { process.outputStream.close() } + + return runCatching { + BufferedReader(InputStreamReader(process.inputStream)).use { br -> + BufferedReader(StringReader(br.readText())) + } + }.also { + process.destroy() + }.getOrNull() + } + + private fun readDmidecode(): String? = + read("dmidecode -t system")?.use { br -> + br.lineSequence() + .firstOrNull { it.contains("Serial Number:") } + ?.substringAfter("Serial Number:") + ?.trim() + } + + private fun readLshal(): String? = + read("lshal")?.use { br -> + br.lineSequence() + .firstOrNull { it.contains("system.hardware.serial =") } + ?.substringAfter("system.hardware.serial =") + ?.replace("(string)", "") + ?.replace("'", "") + ?.trim() + } + + private fun getDeviceName(): String? { + val hostname = SystemUtils.getHostName() + if (!hostname.isNullOrBlank()) return hostname + return runCatching { InetAddress.getLocalHost().hostName }.getOrNull() + } + + 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 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/IDebugNetworkListener.java b/src/main/java/in/dragonbra/javasteam/util/IDebugNetworkListener.kt similarity index 70% rename from src/main/java/in/dragonbra/javasteam/util/IDebugNetworkListener.java rename to src/main/java/in/dragonbra/javasteam/util/IDebugNetworkListener.kt index a2ad8876..256d36dd 100644 --- a/src/main/java/in/dragonbra/javasteam/util/IDebugNetworkListener.java +++ 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.java b/src/main/java/in/dragonbra/javasteam/util/KeyDictionary.java deleted file mode 100644 index 7c10695d..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/KeyDictionary.java +++ /dev/null @@ -1,76 +0,0 @@ -package in.dragonbra.javasteam.util; - -import in.dragonbra.javasteam.enums.EUniverse; - -import java.util.Map; - -/** - * Contains the public keys that Steam uses for each of the {@link 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 - } - ); - } - - /** - * 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); - } -} diff --git a/src/main/java/in/dragonbra/javasteam/util/KeyDictionary.kt b/src/main/java/in/dragonbra/javasteam/util/KeyDictionary.kt new file mode 100644 index 00000000..a65a84f7 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/KeyDictionary.kt @@ -0,0 +1,135 @@ +package `in`.dragonbra.javasteam.util + +import `in`.dragonbra.javasteam.enums.EUniverse + +/** + * Contains the public keys that Steam uses for each of the [EUniverse] + */ +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. + */ + @JvmStatic + fun getPublicKey(universe: EUniverse?): ByteArray? = KEYS[universe] +} diff --git a/src/main/java/in/dragonbra/javasteam/util/MsgUtil.java b/src/main/java/in/dragonbra/javasteam/util/MsgUtil.kt similarity index 50% rename from src/main/java/in/dragonbra/javasteam/util/MsgUtil.java rename to src/main/java/in/dragonbra/javasteam/util/MsgUtil.kt index 9e7e5087..7f7cb55f 100644 --- a/src/main/java/in/dragonbra/javasteam/util/MsgUtil.java +++ 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 } 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/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.java b/src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.java deleted file mode 100644 index 93a16eb4..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.java +++ /dev/null @@ -1,68 +0,0 @@ -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; - -/** - * 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); - - private final File logDirectory; - - public NetHookNetworkListener() { - this("netlogs"); - } - - public NetHookNetworkListener(String path) { - - File dir = new File(path); - dir.mkdir(); - - logDirectory = new File(dir, FORMAT.format(new Date())); - logDirectory.mkdir(); - } - - @Override - public void onIncomingNetworkMessage(EMsg msgType, byte[] data) { - logger.debug(String.format("<- Recv'd EMsg: %s (%d)", msgType, msgType.code())); - - try { - Files.write(Paths.get(new File(logDirectory, getFile("in", msgType)).getAbsolutePath()), data); - } catch (IOException e) { - logger.debug(e); - } - } - - @Override - public void onOutgoingNetworkMessage(EMsg msgType, byte[] data) { - logger.debug(String.format("Sent -> EMsg: %s", msgType)); - - try { - Files.write(Paths.get(new File(logDirectory, getFile("out", msgType)).getAbsolutePath()), data); - } catch (IOException e) { - 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); - } -} diff --git a/src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.kt b/src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.kt new file mode 100644 index 00000000..999dd8c3 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/NetHookNetworkListener.kt @@ -0,0 +1,64 @@ +package `in`.dragonbra.javasteam.util + +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). + */ +class NetHookNetworkListener @JvmOverloads constructor(path: String = "netlogs") : IDebugNetworkListener { + + companion object { + private val logger = LogManager.getLogger() + + private val FORMAT = SimpleDateFormat("yyyy_MM_dd_H_m_s_S") + } + + private val messageNumber = AtomicLong(0L) + + private val logDirectory: File + + init { + val dir = File(path) + dir.mkdir() + + logDirectory = File(dir, FORMAT.format(Date())) + logDirectory.mkdir() + } + + override fun onIncomingNetworkMessage(msgType: EMsg, data: ByteArray) { + logger.debug("<- Recv'd EMsg: $msgType (${msgType.code()})") + + try { + val file = File(logDirectory, getFile("in", msgType)) + val path = Paths.get(file.absolutePath) + Files.write(path, data) + } catch (e: IOException) { + logger.debug(e) + } + } + + override fun onOutgoingNetworkMessage(msgType: EMsg, data: ByteArray) { + logger.debug("Sent -> EMsg: $msgType") + + try { + val file = File(logDirectory, getFile("out", msgType)) + val path = Paths.get(file.absolutePath) + Files.write(path, data) + } catch (e: IOException) { + logger.debug(e) + } + } + + 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.java b/src/main/java/in/dragonbra/javasteam/util/Strings.java deleted file mode 100644 index 68763e5f..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/Strings.java +++ /dev/null @@ -1,49 +0,0 @@ -package in.dragonbra.javasteam.util; - -import java.math.BigInteger; - -/** - * @author lngtr - * @since 2018-02-19 - */ -public class Strings { - - /** - * the constant 2^64 - */ - 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(); - - 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]; - } - return new 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)); - } - return data; - } -} diff --git a/src/main/java/in/dragonbra/javasteam/util/Strings.kt b/src/main/java/in/dragonbra/javasteam/util/Strings.kt new file mode 100644 index 00000000..2603cb0e --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/Strings.kt @@ -0,0 +1,50 @@ +package `in`.dragonbra.javasteam.util + +/** + * Provides helper functions for null/empty string checks and hex string conversion. + * @author lngtr + * @since 2018-02-19 + */ +object Strings { + + private val HEX_ARRAY = "0123456789ABCDEF".toCharArray() + + /** + * 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. + */ + @JvmStatic + fun isNullOrEmpty(str: String?): Boolean = str == null || str.isEmpty() + + /** + * 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 String(hexChars) + } + + /** + * 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 + } +} diff --git a/src/main/java/in/dragonbra/javasteam/util/Utils.java b/src/main/java/in/dragonbra/javasteam/util/Utils.java deleted file mode 100644 index c027c605..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/Utils.java +++ /dev/null @@ -1,207 +0,0 @@ -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(); - } -} diff --git a/src/main/java/in/dragonbra/javasteam/util/Utils.kt b/src/main/java/in/dragonbra/javasteam/util/Utils.kt new file mode 100644 index 00000000..2dac4e3d --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/Utils.kt @@ -0,0 +1,177 @@ +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.java b/src/main/java/in/dragonbra/javasteam/util/WebHelpers.java deleted file mode 100644 index 6b1e907b..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/WebHelpers.java +++ /dev/null @@ -1,41 +0,0 @@ -package in.dragonbra.javasteam.util; - -import java.nio.charset.StandardCharsets; - -/** - * @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)); - } - } - - return encoded.toString(); - } -} diff --git a/src/main/java/in/dragonbra/javasteam/util/WebHelpers.kt b/src/main/java/in/dragonbra/javasteam/util/WebHelpers.kt new file mode 100644 index 00000000..1b386016 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/WebHelpers.kt @@ -0,0 +1,44 @@ +package `in`.dragonbra.javasteam.util + +import java.nio.charset.StandardCharsets + +/** + * Provides helper functions for URL encoding. + * @author lngtr + * @since 2018-04-16 + */ +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() + } +} diff --git a/src/main/java/in/dragonbra/javasteam/util/compat/Consumer.java b/src/main/java/in/dragonbra/javasteam/util/compat/Consumer.java deleted file mode 100644 index 1756895e..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/compat/Consumer.java +++ /dev/null @@ -1,5 +0,0 @@ -package in.dragonbra.javasteam.util.compat; - -public interface Consumer { - void accept(T t); -} diff --git a/src/main/java/in/dragonbra/javasteam/util/compat/Consumer.kt b/src/main/java/in/dragonbra/javasteam/util/compat/Consumer.kt new file mode 100644 index 00000000..d5043bcd --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/compat/Consumer.kt @@ -0,0 +1,5 @@ +package `in`.dragonbra.javasteam.util.compat + +fun interface Consumer { + fun accept(t: T) +} diff --git a/src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.java b/src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.java deleted file mode 100644 index 591a32b0..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.java +++ /dev/null @@ -1,13 +0,0 @@ -package in.dragonbra.javasteam.util.compat; - -import java.util.Objects; - -/** - * @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 diff --git a/src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.kt b/src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.kt new file mode 100644 index 00000000..25daec52 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/compat/ObjectsCompat.kt @@ -0,0 +1,11 @@ +package `in`.dragonbra.javasteam.util.compat + +/** + * Compatibility for [java.util.Objects] for Android, which requires API 19+. + * @author steev + * @since 2018-03-21 + */ +object ObjectsCompat { + @JvmStatic + fun equals(a: Any?, b: Any?): Boolean = a == b +} diff --git a/src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.java b/src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.java deleted file mode 100644 index 0b4dd03b..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.java +++ /dev/null @@ -1,40 +0,0 @@ -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()); - } -} diff --git a/src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.kt b/src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.kt new file mode 100644 index 00000000..10bf986c --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/crypto/BerDecodeException.kt @@ -0,0 +1,10 @@ +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.java b/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.java deleted file mode 100644 index 4d5f624e..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.java +++ /dev/null @@ -1,26 +0,0 @@ -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); - } -} diff --git a/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt b/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt new file mode 100644 index 00000000..3d09328e --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/crypto/CryptoException.kt @@ -0,0 +1,10 @@ +package `in`.dragonbra.javasteam.util.crypto + +/** + * @author lngtr + * @since 2018-03-02 + */ +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.java b/src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.java deleted file mode 100644 index 60ce0018..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.java +++ /dev/null @@ -1,72 +0,0 @@ -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; - -/** - * 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"); - } - - 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); - } - } - - 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); - } - } - - public byte[] encrypt(byte[] input) { - try { - return cipher.doFinal(input); - } catch (final IllegalBlockSizeException | BadPaddingException e) { - logger.debug(e); - } - - return null; - } -} diff --git a/src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.kt b/src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.kt new file mode 100644 index 00000000..bd99db2c --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/crypto/RSACrypto.kt @@ -0,0 +1,41 @@ +package `in`.dragonbra.javasteam.util.crypto + +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. + */ +class RSACrypto(key: ByteArray?) { + + companion object { + private val logger = getLogger(RSACrypto::class.java) + } + + 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 + } + + fun encrypt(input: ByteArray): ByteArray? = try { + cipher?.doFinal(input) + } catch (e: GeneralSecurityException) { + logger.debug(e) + null + } +} diff --git a/src/main/java/in/dragonbra/javasteam/util/event/Event.java b/src/main/java/in/dragonbra/javasteam/util/event/Event.java deleted file mode 100644 index e13cc882..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/event/Event.java +++ /dev/null @@ -1,28 +0,0 @@ -package in.dragonbra.javasteam.util.event; - -import java.util.HashSet; - -public class Event { - - protected final HashSet> handlers = new HashSet<>(); - - public void addEventHandler(EventHandler handler) { - synchronized (handlers) { - handlers.add(handler); - } - } - - public void removeEventHandler(EventHandler handler) { - synchronized (handlers) { - handlers.remove(handler); - } - } - - public void handleEvent(Object sender, T e) { - synchronized (handlers) { - for (final EventHandler handler : handlers) { - handler.handleEvent(sender, e); - } - } - } -} \ No newline at end of file diff --git a/src/main/java/in/dragonbra/javasteam/util/event/Event.kt b/src/main/java/in/dragonbra/javasteam/util/event/Event.kt new file mode 100644 index 00000000..c11e4f2e --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/event/Event.kt @@ -0,0 +1,19 @@ +package `in`.dragonbra.javasteam.util.event + +import java.util.concurrent.CopyOnWriteArrayList + +class Event { + private val handlers = CopyOnWriteArrayList>() + + fun addEventHandler(handler: EventHandler) { + handlers.add(handler) + } + + fun removeEventHandler(handler: EventHandler) { + handlers.remove(handler) + } + + fun handleEvent(sender: Any, e: T) { + handlers.forEach { it.handleEvent(sender, e) } + } +} diff --git a/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.java b/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.java deleted file mode 100644 index 062b8d25..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.java +++ /dev/null @@ -1,9 +0,0 @@ -package in.dragonbra.javasteam.util.event; - -public class EventArgs { - - public static final EventArgs EMPTY = new EventArgs(); - - public EventArgs() { - } -} \ 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 new file mode 100644 index 00000000..828e0918 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/event/EventArgs.kt @@ -0,0 +1,8 @@ +package `in`.dragonbra.javasteam.util.event + +open class EventArgs { + companion object { + @JvmField + val EMPTY = EventArgs() + } +} diff --git a/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.java b/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.java deleted file mode 100644 index dad86e7c..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.java +++ /dev/null @@ -1,5 +0,0 @@ -package in.dragonbra.javasteam.util.event; - -public interface EventHandler { - void handleEvent(Object sender, T e); -} diff --git a/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt b/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt new file mode 100644 index 00000000..70bbb851 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/event/EventHandler.kt @@ -0,0 +1,5 @@ +package `in`.dragonbra.javasteam.util.event + +fun interface EventHandler { + fun handleEvent(sender: Any, e: T) +} diff --git a/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.java b/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.java deleted file mode 100644 index 66dcb852..00000000 --- a/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.java +++ /dev/null @@ -1,55 +0,0 @@ -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; - } - } - - public void stop() { - if (bStarted) { - timer.cancel(); - timer = null; - bStarted = false; - } - } - - public long getDelay() { - return delay; - } - - public void setDelay(long delay) { - this.delay = delay; - } -} diff --git a/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt b/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt new file mode 100644 index 00000000..b860d0e2 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/util/event/ScheduledFunction.kt @@ -0,0 +1,32 @@ +package `in`.dragonbra.javasteam.util.event + +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 val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var job: Job? = null + + @Synchronized + fun start() { + if (job?.isActive == true) return + job = scope.launch { + while (isActive) { + func.run() + delay(delay.milliseconds) + } + } + } + + @Synchronized + fun stop() { + job?.cancel() + job = null + } +} 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/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); 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"); 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); + } } 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()); + } +} 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)); + } } 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