diff --git a/README.md b/README.md index 66d9f939..04449d60 100644 --- a/README.md +++ b/README.md @@ -72,10 +72,8 @@ Maven * Note: To eliminate any errors or warnings, you should try and match the same version JavaSteam uses.

* Content Downloading: - * If you plan on working with Content Downloading, Depot files may be compressed with Zstd *(Zstandard)*. - * You will need to implement the correct type - of [ztd implementation](https://mvnrepository.com/artifact/com.github.luben/zstd-jni) if using JVM or Android. - * Android uses `aar` for the library type. + * Add the following dependencies to your project: [XZ For Java](https://mvnrepository.com/artifact/org.tukaani/xz) and [ZSTD JNI](https://mvnrepository.com/artifact/com.github.luben/zstd-jni). + * ZSTD for android uses `aar` for the libray type. You can find the latest version of these dependencies JavaSteam supports [here](https://github.com/Longi94/JavaSteam/blob/master/gradle/libs.versions.toml). diff --git a/build.gradle.kts b/build.gradle.kts index ae7af00c..8ab101ac 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -107,6 +107,13 @@ sourceSets.main { ) } +/* Basic Java 9 JPMS support */ +tasks.jar { + manifest { + attributes["Automatic-Module-Name"] = "in.dragonbra.javasteam" + } +} + /* Dependencies */ tasks["lintKotlinMain"].dependsOn("formatKotlin") tasks["check"].dependsOn("jacocoTestReport") @@ -126,18 +133,17 @@ tasks.withType { dependencies { implementation(libs.bundles.ktor) - implementation(libs.commons.io) implementation(libs.commons.lang3) - implementation(libs.commons.validator) - implementation(libs.gson) implementation(libs.kotlin.coroutines) implementation(libs.kotlin.stdib) implementation(libs.okHttp) implementation(libs.protobuf.java) - implementation(libs.xz) + compileOnly(libs.xz) compileOnly(libs.zstd) + testImplementation(platform(libs.tests.junit.bom)) testImplementation(libs.bundles.testing) + testRuntimeOnly(libs.tests.junit.platform) } /* Artifact publishing */ diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index 1f8d7fd3..bb62a8fa 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.18.0") + implementation("commons-io:commons-io:2.20.0") // https://mvnrepository.com/artifact/com.squareup/kotlinpoet - implementation("com.squareup:kotlinpoet:2.0.0") + implementation("com.squareup:kotlinpoet:2.2.0") } gradlePlugin { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3236a389..d42f0aef 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,41 +4,37 @@ # **** [versions] -# Java / Kotlin versions java = "11" -kotlin = "2.1.20" # https://kotlinlang.org/docs/releases.html#release-details +kotlin = "2.2.0" # https://kotlinlang.org/docs/releases.html#release-details dokka = "2.0.0" # https://mvnrepository.com/artifact/org.jetbrains.dokka/dokka-gradle-plugin -kotlinter = "5.0.2" # https://plugins.gradle.org/plugin/org.jmailen.kotlinter +kotlinter = "5.1.1" # https://plugins.gradle.org/plugin/org.jmailen.kotlinter +jacoco = "0.8.13" # https://www.eclemma.org/jacoco # Standard Library versions -bouncyCastle = "1.80" # https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk18on -commons-io = "2.19.0" # https://mvnrepository.com/artifact/commons-io/commons-io -commons-lang3 = "3.17.0" # https://mvnrepository.com/artifact/org.apache.commons/commons-lang3 -commons-validator = "1.9.0" # https://mvnrepository.com/artifact/commons-validator/commons-validator -gson = "2.13.1" # https://mvnrepository.com/artifact/com.google.code.gson/gson -jacoco = "0.8.13" # https://www.eclemma.org/jacoco +commons-lang3 = "3.18.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.2.1" # https://mvnrepository.com/artifact/io.ktor/ktor-client-cio -okHttp = "5.0.0-alpha.14" # https://mvnrepository.com/artifact/com.squareup.okhttp3/okhttp -protobuf = "4.30.2" # https://mvnrepository.com/artifact/com.google.protobuf/protobuf-java +ktor = "3.2.2" # https://mvnrepository.com/artifact/io.ktor/ktor-client-cio +okHttp = "5.1.0" # 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 publishPlugin = "2.0.0" # https://mvnrepository.com/artifact/io.github.gradle-nexus/publish-plugin -qrCode = "1.0.1" # https://mvnrepository.com/artifact/pro.leaco.qrcode/console-qrcode xz = "1.10" # https://mvnrepository.com/artifact/org.tukaani/xz -zstd = "1.5.7-3" # https://search.maven.org/artifact/com.github.luben/zstd-jni +zstd = "1.5.7-4" # https://search.maven.org/artifact/com.github.luben/zstd-jni # Testing Lib versions -commonsCodec = "1.18.0" # https://mvnrepository.com/artifact/commons-codec/commons-codec -junit5 = "5.11.4" # https://mvnrepository.com/artifact/org.junit/junit-bom -mockWebServer = "5.0.0-alpha.14" # https://mvnrepository.com/artifact/com.squareup.okhttp3/mockwebserver3-junit5 -mockitoVersion = "5.15.2" # https://mvnrepository.com/artifact/org.mockito/mockito-core +commons-io = "2.20.0" # https://mvnrepository.com/artifact/commons-io/commons-io +commonsCodec = "1.19.0" # https://mvnrepository.com/artifact/commons-codec/commons-codec +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 + +# Samples +bouncyCastle = "1.81" # https://mvnrepository.com/artifact/org.bouncycastle/bcprov-jdk18on +gson = "2.13.1" # https://mvnrepository.com/artifact/com.google.code.gson/gson +qrCode = "1.0.1" # https://mvnrepository.com/artifact/pro.leaco.qrcode/console-qrcode [libraries] -bouncyCastle = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } -commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" } commons-lang3 = { module = "org.apache.commons:commons-lang3", version.ref = "commons-lang3" } -commons-validator = { module = "commons-validator:commons-validator", version.ref = "commons-validator" } -gson = { module = "com.google.code.gson:gson", version.ref = "gson" } kotlin-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlin-coroutines" } kotlin-stdib = { module = "org.jetbrains.kotlin:kotlin-stdlib-jdk8", version.ref = "kotlin" } ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } @@ -47,17 +43,23 @@ ktor-client-websocket = { module = "io.ktor:ktor-client-websockets", version.ref okHttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okHttp" } protobuf-java = { module = "com.google.protobuf:protobuf-java", version.ref = "protobuf" } protobuf-protoc = { module = "com.google.protobuf:protoc", version.ref = "protobuf" } -qrCode = { module = "pro.leaco.qrcode:console-qrcode", version.ref = "qrCode" } xz = { module = "org.tukaani:xz", version.ref = "xz" } zstd = { module = "com.github.luben:zstd-jni", version.ref = "zstd" } +# Tests test-commons-codec = { module = "commons-codec:commons-codec", version.ref = "commonsCodec" } -test-jupiter-api = { module = "org.junit.jupiter:junit-jupiter-api", version.ref = "junit5" } -test-jupiter-engine = { module = "org.junit.jupiter:junit-jupiter-engine", version.ref = "junit5" } -test-jupiter-params = { module = "org.junit.jupiter:junit-jupiter-params", version.ref = "junit5" } +test-commons-io = { module = "commons-io:commons-io", version.ref = "commons-io" } +test-mock-core = { module = "org.mockito:mockito-core", version.ref = "mockitoVersion" } +test-mock-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockitoVersion" } test-mock-webserver3 = { module = "com.squareup.okhttp3:mockwebserver3-junit5", version.ref = "mockWebServer" } -test-mockito-core = { module = "org.mockito:mockito-core", version.ref = "mockitoVersion" } -test-mockito-jupiter = { module = "org.mockito:mockito-junit-jupiter", version.ref = "mockitoVersion" } +tests-junit-bom = { module = "org.junit:junit-bom", version.ref = "junit5" } +tests-junit-jupiter = { module = "org.junit.jupiter:junit-jupiter" } +tests-junit-platform = { module = "org.junit.platform:junit-platform-launcher" } + +# Samples +bouncyCastle = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bouncyCastle" } +gson = { module = "com.google.code.gson:gson", version.ref = "gson" } +qrCode = { module = "pro.leaco.qrcode:console-qrcode", version.ref = "qrCode" } [plugins] kotlin-dokka = { id = "org.jetbrains.dokka", version.ref = "dokka" } @@ -69,14 +71,14 @@ protobuf-gradle = { id = "com.google.protobuf", version.ref = "protobuf-gradle" [bundles] testing = [ "bouncyCastle", + "zstd", + "xz", "test-commons-codec", - "test-jupiter-api", - "test-jupiter-engine", - "test-jupiter-params", + "test-commons-io", + "test-mock-core", + "test-mock-jupiter", "test-mock-webserver3", - "test-mockito-core", - "test-mockito-jupiter", - "zstd" + "tests-junit-jupiter", ] ktor = [ diff --git a/src/main/java/in/dragonbra/javasteam/steam/contentdownloader/ContentDownloader.kt b/src/main/java/in/dragonbra/javasteam/steam/contentdownloader/ContentDownloader.kt index 92e5d764..804cabc1 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/contentdownloader/ContentDownloader.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/contentdownloader/ContentDownloader.kt @@ -7,7 +7,6 @@ import `in`.dragonbra.javasteam.steam.cdn.Server import `in`.dragonbra.javasteam.steam.handlers.steamapps.PICSProductInfo import `in`.dragonbra.javasteam.steam.handlers.steamapps.PICSRequest import `in`.dragonbra.javasteam.steam.handlers.steamapps.SteamApps -import `in`.dragonbra.javasteam.steam.handlers.steamapps.callback.PICSProductInfoCallback import `in`.dragonbra.javasteam.steam.handlers.steamcontent.SteamContent import `in`.dragonbra.javasteam.steam.steamclient.SteamClient import `in`.dragonbra.javasteam.types.ChunkData @@ -104,7 +103,7 @@ class ContentDownloader(val steamClient: SteamClient) { private fun getAppDirName(app: PICSProductInfo): String { val installDirKeyValue = app.keyValues["config"]["installdir"] - return if (installDirKeyValue != KeyValue.INVALID) installDirKeyValue.value else app.id.toString() + return if (installDirKeyValue != KeyValue.INVALID) installDirKeyValue.value!! else app.id.toString() } private fun getAppInfo( @@ -113,7 +112,7 @@ class ContentDownloader(val steamClient: SteamClient) { ): Deferred = parentScope.async { val steamApps = steamClient.getHandler(SteamApps::class.java) val callback = steamApps?.picsGetProductInfo(PICSRequest(appId))?.await() - val apps = callback?.results?.flatMap { (it as PICSProductInfoCallback).apps.values } + val apps = callback?.results?.flatMap { it.apps.values } if (apps.isNullOrEmpty()) { logger.error("Received empty apps list in PICSProductInfo response for $appId") diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Lobby.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Lobby.kt index 7b950dbb..b2b6cb33 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Lobby.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steammatchmaking/Lobby.kt @@ -79,7 +79,7 @@ class Lobby( return metadata } - metadata[value.name] = value.value + metadata[value.name!!] = value.value!! } return metadata.toMap() diff --git a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/SteamUserStats.kt b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/SteamUserStats.kt index 722d2c53..da33691d 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/SteamUserStats.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/handlers/steamuserstats/SteamUserStats.kt @@ -151,6 +151,7 @@ class SteamUserStats : ClientMsgHandler() { } // JavaSteam addition. + /** * Gets the Stats-Schema for the specified app. This schema includes Global Achievements and Stats, * @param appId The appID of the game. 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 a873f743..1872ff77 100644 --- a/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt +++ b/src/main/java/in/dragonbra/javasteam/steam/steamclient/SteamClient.kt @@ -96,6 +96,7 @@ class SteamClient @JvmOverloads constructor( } //region Handlers + /** * Adds a new handler to the internal list of message handlers. * @param handler The handler to add. @@ -150,6 +151,7 @@ class SteamClient @JvmOverloads constructor( //endregion //region Callbacks + /** * Gets the next callback object in the queue, and removes it. * @return The next callback in the queue, or null if no callback is waiting. @@ -196,6 +198,7 @@ class SteamClient @JvmOverloads constructor( //endregion //region Jobs + /** * Returns the next available JobID for job based messages. * @return The next available JobID. diff --git a/src/main/java/in/dragonbra/javasteam/types/KeyValue.java b/src/main/java/in/dragonbra/javasteam/types/KeyValue.java deleted file mode 100644 index 7ced87fb..00000000 --- a/src/main/java/in/dragonbra/javasteam/types/KeyValue.java +++ /dev/null @@ -1,714 +0,0 @@ -package in.dragonbra.javasteam.types; - -import in.dragonbra.javasteam.util.Passable; -import in.dragonbra.javasteam.util.Strings; -import in.dragonbra.javasteam.util.log.LogManager; -import in.dragonbra.javasteam.util.log.Logger; -import in.dragonbra.javasteam.util.stream.BinaryReader; -import in.dragonbra.javasteam.util.stream.MemoryStream; -import org.apache.commons.io.IOUtils; - -import java.io.*; -import java.lang.reflect.Field; -import java.lang.reflect.InvocationTargetException; -import java.lang.reflect.Method; -import java.lang.reflect.Modifier; -import java.nio.charset.Charset; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.EnumSet; -import java.util.List; -import java.util.Map; - -/** - * Represents a recursive string key to arbitrary value container. - */ -@SuppressWarnings("unchecked") -public class KeyValue { - - private static final Logger logger = LogManager.getLogger(KeyValue.class); - - /** - * Represents an invalid {@link KeyValue} given when a searched for child does not exist. - */ - public static final KeyValue INVALID = new KeyValue(); - - private String name; - - private String value; - - private List children = new ArrayList<>(); - - /** - * Initializes a new instance of the {@link KeyValue} class. - */ - public KeyValue() { - this(null); - } - - /** - * Initializes a new instance of the {@link KeyValue} class. - * - * @param name The optional name of the root key. - */ - public KeyValue(String name) { - this(name, null); - } - - /** - * Initializes a new instance of the {@link KeyValue} class. - * - * @param name The optional name of the root key. - * @param value The optional value assigned to the root key. - */ - public KeyValue(String name, String value) { - this.name = name; - this.value = value; - } - - /** - * Gets the child {@link KeyValue} with the specified key. - * If no child with the given key exists, {@link KeyValue#INVALID} is returned. - * - * @param key key - * @return the child {@link KeyValue} - */ - public KeyValue get(String key) { - if (key == null) { - throw new IllegalArgumentException("key is null"); - } - - for (KeyValue c : children) { - if (key.equalsIgnoreCase(c.name)) { - return c; - } - } - return INVALID; - } - - /** - * Sets the child {@link KeyValue} with the specified key. - * - * @param key key - * @param value the child {@link KeyValue} - */ - public void set(String key, KeyValue value) { - if (key == null) { - throw new IllegalArgumentException("key is null"); - } - - children.removeIf(keyValue -> key.equalsIgnoreCase(keyValue.name)); - - value.setName(key); - children.add(value); - } - - /** - * Returns the value of this instance as a string. - * - * @return The value of this instance as a string. - */ - public String asString() { - return value; - } - - /** - * Attempts to convert and return the value of this instance as a byte. - * If the conversion is invalid, the default value is returned. - * - * @param defaultValue The default value to return if the conversion is invalid. - * @return The value of this instance as an unsigned byte. - */ - public byte asByte(byte defaultValue) { - try { - return Byte.parseByte(value); - } catch (NullPointerException | NumberFormatException nfe) { - return defaultValue; - } - } - - /** - * Attempts to convert and return the value of this instance as a byte. - * If the conversion is invalid, the default value is returned. - * - * @return The value of this instance as an unsigned byte. - */ - public byte asByte() { - return asByte((byte) 0); - } - - /** - * Attempts to convert and return the value of this instance as a short. - * If the conversion is invalid, the default value is returned. - * - * @param defaultValue The default value to return if the conversion is invalid. - * @return The value of this instance as an unsigned byte. - */ - public short asShort(short defaultValue) { - try { - return Short.parseShort(value); - } catch (NullPointerException | NumberFormatException nfe) { - return defaultValue; - } - } - - /** - * Attempts to convert and return the value of this instance as a short. - * If the conversion is invalid, the default value is returned. - * - * @return The value of this instance as an unsigned byte. - */ - public short asShort() { - return asShort((short) 0); - } - - /** - * Attempts to convert and return the value of this instance as an integer. - * If the conversion is invalid, the default value is returned. - * - * @param defaultValue The default value to return if the conversion is invalid. - * @return The value of this instance as an unsigned byte. - */ - public int asInteger(int defaultValue) { - try { - return Integer.parseInt(value); - } catch (NullPointerException | NumberFormatException nfe) { - return defaultValue; - } - } - - /** - * Attempts to convert and return the value of this instance as an integer. - * If the conversion is invalid, the default value is returned. - * - * @return The value of this instance as an unsigned byte. - */ - public int asInteger() { - return asInteger(0); - } - - /** - * Attempts to convert and return the value of this instance as a long. - * If the conversion is invalid, the default value is returned. - * - * @param defaultValue The default value to return if the conversion is invalid. - * @return The value of this instance as an unsigned byte. - */ - public long asLong(long defaultValue) { - try { - return Long.parseLong(value); - } catch (NullPointerException | NumberFormatException nfe) { - return defaultValue; - } - } - - /** - * Attempts to convert and return the value of this instance as a long. - * If the conversion is invalid, the default value is returned. - * - * @return The value of this instance as an unsigned byte. - */ - public long asLong() { - return asLong(0L); - } - - /** - * Attempts to convert and return the value of this instance as a float. - * If the conversion is invalid, the default value is returned. - * - * @param defaultValue The default value to return if the conversion is invalid. - * @return The value of this instance as an unsigned byte. - */ - public float asFloat(float defaultValue) { - try { - return Float.parseFloat(value); - } catch (NullPointerException | NumberFormatException nfe) { - return defaultValue; - } - } - - /** - * Attempts to convert and return the value of this instance as a float. - * If the conversion is invalid, the default value is returned. - * - * @return The value of this instance as an unsigned byte. - */ - public float asFloat() { - return asFloat(0.0f); - } - - /** - * Attempts to convert and return the value of this instance as a boolean. - * If the conversion is invalid, the default value is returned. - * - * @param defaultValue The default value to return if the conversion is invalid. - * @return The value of this instance as an unsigned byte. - */ - public boolean asBoolean(boolean defaultValue) { - try { - return Integer.parseInt(value) != 0; - } catch (NullPointerException | NumberFormatException e) { - try { - return Boolean.parseBoolean(value); - } catch (NullPointerException | NumberFormatException e1) { - return defaultValue; - } - } - } - - /** - * Attempts to convert and return the value of this instance as a boolean. - * If the conversion is invalid, the default value is returned. - * - * @return The value of this instance as an unsigned byte. - */ - public boolean asBoolean() { - return asBoolean(false); - } - - /** - * Attempts to convert and return the value of this instance as an enum. - * If the conversion is invalid, the default value is returned. - * - * @param the type of the enum to convert to - * @param enumClass the type of the enum to convert to - * @param defaultValue The default value to return if the conversion is invalid. - * @return The value of this instance as an unsigned byte. - */ - public > EnumSet asEnum(Class enumClass, T defaultValue) { - return asEnum(enumClass, EnumSet.of(defaultValue)); - } - - /** - * Attempts to convert and return the value of this instance as an enum. - * If the conversion is invalid, the default value is returned. - * - * @param the type of the enum to convert to - * @param enumClass the type of the enum to convert to - * @param defaultValue The default value to return if the conversion is invalid. - * @return The value of this instance as an unsigned byte. - */ - public > EnumSet asEnum(Class enumClass, EnumSet defaultValue) { - // this is ugly af, but it comes with handling bit flags as enumsets - try { - // see if it's a number first - int code = Integer.parseInt(value); - - Field codeField = enumClass.getDeclaredField("code"); - Method from = enumClass.getMethod("from", codeField.getType()); - Object res = from.invoke(null, code); - - if (res instanceof EnumSet) { - return (EnumSet) res; - } else { - return EnumSet.of(enumClass.cast(res)); - } - } catch (NullPointerException | NumberFormatException ignored) { - } catch (NoSuchFieldException | NoSuchMethodException | IllegalAccessException | InvocationTargetException e) { - return null; - } - - try { - // see if it exists as an enum - return EnumSet.of(T.valueOf(enumClass, value)); - } catch (NullPointerException | IllegalArgumentException ignored) { - } - - // check for static enumset fields - try { - for (Field field : enumClass.getDeclaredFields()) { - if (Modifier.isStatic(field.getModifiers()) && field.getName().equals(value) && field.getType().isAssignableFrom(EnumSet.class)) { - return (EnumSet) field.get(null); - } - } - } catch (IllegalAccessException e) { - e.printStackTrace(); - } - - return defaultValue; - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getValue() { - return value; - } - - public void setValue(String value) { - this.value = value; - } - - public List getChildren() { - return children; - } - - public boolean readAsText(InputStream is) throws IOException { - if (is == null) { - throw new IllegalArgumentException("input stream is null"); - } - - children = new ArrayList<>(); - - new KVTextReader(this, is); - - return true; - } - - /** - * Opens and reads the given filename as text. - * - * @param filename The file to open and read. - * @return true if the read was successful; otherwise, false. - * @throws IOException exception while reading from the file - */ - public boolean readFileAsText(String filename) throws IOException { - try (var fis = new FileInputStream(filename)) { - return readAsText(fis); - } - } - - void recursiveLoadFromBuffer(KVTextReader kvr) throws IOException { - Passable wasQuoted = new Passable<>(false); - Passable wasConditional = new Passable<>(false); - - while (true) { - // get the key name - String name = kvr.readToken(wasQuoted, wasConditional); - - if (Strings.isNullOrEmpty(name)) { - throw new IllegalStateException("RecursiveLoadFromBuffer: got EOF or empty keyname"); - } - - if (name.startsWith("}") && !wasQuoted.getValue()) { - break; - } - - KeyValue dat = new KeyValue(name); - dat.children = new ArrayList<>(); - children.add(dat); - - String value = kvr.readToken(wasQuoted, wasConditional); - - if (value == null) { - throw new IllegalStateException("RecursiveLoadFromBuffer: got NULL key"); - } - - if (value.startsWith("}") && !wasQuoted.getValue()) { - throw new IllegalStateException("RecursiveLoadFromBuffer: got } in key"); - } - - if (value.startsWith("{") && !wasQuoted.getValue()) { - dat.recursiveLoadFromBuffer(kvr); - } else { - if (wasConditional.getValue()) { - throw new IllegalStateException("RecursiveLoadFromBuffer: got conditional between key and value"); - } - - dat.setValue(value); - } - } - } - - /** - * Attempts to load the given filename as a text {@link KeyValue}. - * - * @param path The path to the file to load. - * @return a {@link KeyValue} instance if the load was successful, or null on failure. - */ - public static KeyValue loadAsText(String path) { - return loadFromFile(path, false); - } - - /** - * Attempts to load the given filename as a binary {@link KeyValue}. - * - * @param path The path to the file to load. - * @return a {@link KeyValue} instance if the load was successful, or null on failure. - */ - public static KeyValue tryLoadAsBinary(String path) { - return loadFromFile(path, true); - } - - private static KeyValue loadFromFile(String path, boolean asBinary) { - File file = new File(path); - - if (!file.exists() || file.isDirectory()) { - return null; - } - - // TODO charsets? - try (var fis = new FileInputStream(file)) { - // Massage the incoming file to be encoded as UTF-8. - String fisString = IOUtils.toString(fis, Charset.defaultCharset()); - byte[] fisStringToBytes = fisString.getBytes(StandardCharsets.UTF_8); - - try (var ms = new MemoryStream(fisStringToBytes, 0, fisStringToBytes.length - 1)) { - KeyValue kv = new KeyValue(); - - if (asBinary) { - if (!kv.tryReadAsBinary(ms)) { - return null; - } - } else { - if (!kv.readAsText(ms)) { - return null; - } - } - - return kv; - } - } catch (Exception e) { - logger.error(e); - return null; - } - } - - /** - * Attempts to create an instance of {@link KeyValue} from the given input text. - * - * @param input The input text to load. - * @return a {@link KeyValue} instance if the load was successful, or null on failure. - */ - public static KeyValue loadFromString(String input) { - if (input == null) { - throw new IllegalArgumentException("input is null"); - } - - byte[] bytes = input.getBytes(StandardCharsets.UTF_8); - - try (var bais = new ByteArrayInputStream(bytes)) { - KeyValue kv = new KeyValue(); - - if (!kv.readAsText(bais)) { - return null; - } - - return kv; - } catch (IOException e) { - logger.error(e); - return null; - } - } - - /** - * Saves this instance to file. - * - * @param path The file path to save to. - * @param binary If set to true, saves this instance as binary. - * @throws IOException exception while writing to the file - */ - public void saveToFile(File path, boolean binary) throws IOException { - try (var fos = new FileOutputStream(path, false)) { - saveToStream(fos, binary); - } - } - - public void saveToStream(OutputStream os, boolean binary) throws IOException { - if (os == null) { - throw new IllegalArgumentException("output stream is null"); - } - - if (binary) { - recursiveSaveBinaryToStream(os); - } else { - recursiveSaveTextToFile(os); - } - } - - private void recursiveSaveBinaryToStream(OutputStream os) throws IOException { - recursiveSaveBinaryToStreamCore(os); - os.write(Type.END.code()); - } - - private void recursiveSaveBinaryToStreamCore(OutputStream os) throws IOException { - // Only supported types ATM: - // 1. KeyValue with children (no value itself) - // 2. String KeyValue - if (value == null) { - os.write(Type.NONE.code()); - os.write(name.getBytes(StandardCharsets.UTF_8)); - os.write(0); - for (KeyValue child : children) { - child.recursiveSaveBinaryToStreamCore(os); - } - os.write(Type.END.code()); - } else { - os.write(Type.STRING.code()); - os.write(name.getBytes(StandardCharsets.UTF_8)); - os.write(0); - os.write(value.getBytes(StandardCharsets.UTF_8)); - os.write(0); - } - } - - private void recursiveSaveTextToFile(OutputStream os) throws IOException { - recursiveSaveTextToFile(os, 0); - } - - private void recursiveSaveTextToFile(OutputStream os, int indentLevel) throws IOException { - // write header - writeIndents(os, indentLevel); - writeString(os, name, true); - writeString(os, "\n"); - writeIndents(os, indentLevel); - writeString(os, "{\n"); - - // loop through all our keys writing them to disk - for (KeyValue child : children) { - if (child.getValue() == null) { - child.recursiveSaveTextToFile(os, indentLevel + 1); - } else { - writeIndents(os, indentLevel + 1); - writeString(os, child.getName(), true); - writeString(os, "\t\t"); - writeString(os, escapeText(child.asString()), true); - writeString(os, "\n"); - } - } - - writeIndents(os, indentLevel); - writeString(os, "}\n"); - } - - private static String escapeText(String value) { - for (Map.Entry entry : KVTextReader.ESCAPED_MAPPING.entrySet()) { - String textToReplace = String.valueOf(entry.getValue()); - String escapedReplacement = "\\" + entry.getKey(); - value = value.replace(textToReplace, escapedReplacement); - } - - return value; - } - - private void writeIndents(OutputStream os, int indentLevel) throws IOException { - writeString(os, new String(new char[indentLevel]).replace('\0', '\t')); - } - - private static void writeString(OutputStream os, String str) throws IOException { - writeString(os, str, false); - } - - private static void writeString(OutputStream os, String str, boolean quote) throws IOException { - str = str.replaceAll("\"", "\\\""); - if (quote) { - str = "\"" + str + "\""; - } - byte[] bytes = str.getBytes(StandardCharsets.UTF_8); - os.write(bytes); - } - - /** - * Populate this instance from the given {@link InputStream} as a binary {@link KeyValue}. - * - * @param is The input {@link InputStream} to read from. - * @return true if the read was successful; otherwise, false. - * @throws IOException exception while reading from the stream - * @throws IllegalArgumentException exception while reading from the stream - */ - public boolean tryReadAsBinary(InputStream is) throws IllegalArgumentException, IOException { - if (is == null) { - throw new IllegalArgumentException("input stream is null"); - } - - return tryReadAsBinaryCore(is, this, null); - } - - @SuppressWarnings("DuplicateBranchesInSwitch") - private static boolean tryReadAsBinaryCore(InputStream is, KeyValue current, KeyValue parent) throws IOException { - current.children = new ArrayList<>(); - - try (var br = new BinaryReader(is)) { - while (true) { - Type type = Type.from(br.readByte()); - - if (type == Type.END || type == Type.ALTERNATEEND) { - break; - } - - current.setName(br.readNullTermString(StandardCharsets.UTF_8)); - switch (type) { - case NONE: - KeyValue child = new KeyValue(); - boolean didReadChild = tryReadAsBinaryCore(is, child, current); - if (!didReadChild) { - return false; - } - break; - case STRING: - current.setValue(br.readNullTermString(StandardCharsets.UTF_8)); - break; - case WIDESTRING: - logger.debug("Encountered WideString type when parsing binary KeyValue, which is unsupported. Returning false."); - return false; - case INT32: - case COLOR: - case POINTER: - current.setValue(String.valueOf(br.readInt())); - break; - case UINT64: - current.setValue(String.valueOf(br.readLong())); - break; - case FLOAT32: - current.setValue(String.valueOf(br.readFloat())); - break; - case INT64: - current.setValue(String.valueOf(br.readLong())); - break; - default: - return false; - } - - if (parent != null) { - parent.getChildren().add(current); - } - current = new KeyValue(); - } - } - - return true; - } - - @Override - public String toString() { - return String.format("%s = %s", name, value); - } - - public enum Type { - NONE((byte) 0), - STRING((byte) 1), - INT32((byte) 2), - FLOAT32((byte) 3), - POINTER((byte) 4), - WIDESTRING((byte) 5), - COLOR((byte) 6), - UINT64((byte) 7), - END((byte) 8), - INT64((byte) 10), - ALTERNATEEND((byte) 11); - - private final byte code; - - Type(byte code) { - this.code = code; - } - - public byte code() { - return this.code; - } - - public static Type from(byte code) { - for (Type e : Type.values()) { - if (e.code == code) { - return e; - } - } - return null; - } - } -} diff --git a/src/main/java/in/dragonbra/javasteam/types/KeyValue.kt b/src/main/java/in/dragonbra/javasteam/types/KeyValue.kt new file mode 100644 index 00000000..573ddd28 --- /dev/null +++ b/src/main/java/in/dragonbra/javasteam/types/KeyValue.kt @@ -0,0 +1,583 @@ +package `in`.dragonbra.javasteam.types + +import `in`.dragonbra.javasteam.util.Passable +import `in`.dragonbra.javasteam.util.log.LogManager +import `in`.dragonbra.javasteam.util.log.Logger +import `in`.dragonbra.javasteam.util.stream.BinaryReader +import java.io.ByteArrayInputStream +import java.io.EOFException +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.lang.reflect.InvocationTargetException +import java.lang.reflect.Modifier +import java.nio.charset.StandardCharsets +import java.util.* + +/** + * Represents a recursive string key to arbitrary value container. + * @constructor Initializes a new instance of the class. + * @param name The optional name of the root key. + * @param value The optional value assigned to the root key. + * @property name Gets or sets the name of this instance. + * @property value Gets or sets the value of this instance. + */ +@Suppress("unused") +class KeyValue @JvmOverloads constructor( + var name: String? = null, + var value: String? = null, +) { + + /** + * Gets the children of this instance. + */ + var children: MutableList = mutableListOf() + + /** + * Gets the child [KeyValue] with the specified key. + * If no child with the given key exists, [KeyValue.INVALID] is returned. + * @param key key + * @return the child [KeyValue] + */ + operator fun get(key: String): KeyValue = children.firstOrNull { + it.name?.equals(key, ignoreCase = true) == true + } ?: INVALID + + operator fun set(key: String, value: KeyValue) { + // Remove existing key if it exists + children.removeAll { it.name?.equals(key, ignoreCase = true) == true } + + // Ensure the given KV has the correct key assigned + value.name = key + + children.add(value) + } + + /** + * Returns the value of this instance as a string. + */ + fun asString(): String? = this.value + + /** + * Attempts to convert and return the value of this instance as a byte. + * If the conversion is invalid, the default value is returned. + * @param defaultValue The default value to return if the conversion is invalid. + * @return The value of this instance as a byte. + */ + @JvmOverloads + fun asByte(defaultValue: Byte = 0): Byte = value?.toByteOrNull() ?: defaultValue + + /** + * Attempts to convert and return the value of this instance as an unsigned byte. + * If the conversion is invalid, the default value is returned. + * @param defaultValue The default value to return if the conversion is invalid. + * @return The value of this instance as an unsigned byte. + */ + @JvmOverloads + fun asUnsignedByte(defaultValue: UByte = 0u): UByte = value?.toUByteOrNull() ?: defaultValue + + /** + * Attempts to convert and return the value of this instance as a short. + * If the conversion is invalid, the default value is returned. + * @param defaultValue The default value to return if the conversion is invalid. + * @return The value of this instance as a short. + */ + @JvmOverloads + fun asShort(defaultValue: Short = 0): Short = value?.toShortOrNull() ?: defaultValue + + /** + * Attempts to convert and return the value of this instance as an unsigned short. + * If the conversion is invalid, the default value is returned. + * @param defaultValue The default value to return if the conversion is invalid. + * @return The value of this instance as an unsigned short. + */ + @JvmOverloads + fun asUnsignedShort(defaultValue: UShort = 0u): UShort = value?.toUShortOrNull() ?: defaultValue + + /** + * Attempts to convert and return the value of this instance as an integer. + * If the conversion is invalid, the default value is returned. + * @param defaultValue The default value to return if the conversion is invalid. + * @return The value of this instance as an integer. + */ + @JvmOverloads + fun asInteger(defaultValue: Int = 0): Int = value?.toIntOrNull() ?: defaultValue + + /** + * Attempts to convert and return the value of this instance as an unsigned integer. + * If the conversion is invalid, the default value is returned. + * @param defaultValue The default value to return if the conversion is invalid. + * @return The value of this instance as an unsigned integer. + */ + @JvmOverloads + fun asUnsignedInteger(defaultValue: UInt = 0u): UInt = value?.toUIntOrNull() ?: defaultValue + + /** + * Attempts to convert and return the value of this instance as a long. + * If the conversion is invalid, the default value is returned. + * @param defaultValue The default value to return if the conversion is invalid. + * @return The value of this instance as a long. + */ + @JvmOverloads + fun asLong(defaultValue: Long = 0L): Long = value?.toLongOrNull() ?: defaultValue + + /** + * Attempts to convert and return the value of this instance as an unsigned long. + * If the conversion is invalid, the default value is returned. + * @param defaultValue The default value to return if the conversion is invalid. + * @return The value of this instance as an unsigned long. + */ + @JvmOverloads + fun asUnsignedLong(defaultValue: ULong = 0uL): ULong = value?.toULongOrNull() ?: defaultValue + + /** + * Attempts to convert and return the value of this instance as a float. + * If the conversion is invalid, the default value is returned. + * @param defaultValue The default value to return if the conversion is invalid. + * @return The value of this instance as a float. + */ + @JvmOverloads + fun asFloat(defaultValue: Float = 0f): Float = value?.toFloatOrNull() ?: defaultValue + + /** + * Attempts to convert and return the value of this instance as a boolean. + * If the conversion is invalid, the default value is returned. + * @param defaultValue The default value to return if the conversion is invalid. + * @return The value of this instance as a boolean. + */ + @JvmOverloads + fun asBoolean(defaultValue: Boolean = false): Boolean = try { + value!!.toInt() != 0 + } catch (e: Exception) { + when (value?.lowercase()) { + "true" -> true + "false" -> false + else -> defaultValue + } + } + + /** + * Attempts to convert and return the value of this instance as an enum. + * If the conversion is invalid, the default value is returned. + * @param T The type of the enum to convert to + * @param enumClass The type of the enum to convert to + * @param defaultValue The default value to return if the conversion is invalid. + * @return The value of this instance as an unsigned byte. + */ + fun > asEnum(enumClass: Class, defaultValue: T): EnumSet = + asEnum(enumClass, EnumSet.of(defaultValue)) + + /** + * Attempts to convert and return the value of this instance as an enum. + * If the conversion is invalid, the default value is returned. + * @param T The type of the enum to convert to + * @param enumClass The type of the enum to convert to + * @param defaultValue The default value to return if the conversion is invalid. + * @return The value of this instance as an unsigned byte. + */ + @Suppress("UNCHECKED_CAST") + fun > asEnum(enumClass: Class, defaultValue: EnumSet): EnumSet { + // this is ugly af, but it comes with handling bit flags as enumsets + try { + // see if it's a number first + val code = value?.toInt() ?: return defaultValue + + val codeField = enumClass.getDeclaredField("code") + val fromMethod = enumClass.getMethod("from", codeField.type) + + @Suppress("MoveVariableDeclarationIntoWhen") + val result = fromMethod.invoke(null, code) + + return when (result) { + is EnumSet<*> -> result as EnumSet + else -> EnumSet.of(enumClass.cast(result)) + } + } catch (e: NumberFormatException) { + // ignore and try next approach + } catch (e: NoSuchFieldException) { + return defaultValue + } catch (e: NoSuchMethodException) { + return defaultValue + } catch (e: IllegalAccessException) { + return defaultValue + } catch (e: InvocationTargetException) { + return defaultValue + } + + try { + // see if it exists as an enum + val enumValue = java.lang.Enum.valueOf(enumClass, value ?: return defaultValue) + return EnumSet.of(enumValue) + } catch (e: IllegalArgumentException) { + // ignore and try next approach + } + + // check for static enumset fields + try { + for (field in enumClass.declaredFields) { + if (Modifier.isStatic(field.modifiers) && + field.name == value && + EnumSet::class.java.isAssignableFrom(field.type) + ) { + @Suppress("UNCHECKED_CAST") + return field.get(null) as EnumSet + } + } + } catch (e: IllegalAccessException) { + e.printStackTrace() + } + + return defaultValue + } + + /** + * Returns a [String] that represents this instance. + */ + override fun toString(): String = "$name = $value" + + /** + * Populate this instance from the given [InputStream] as a text [KeyValue]. + * @param input The input [InputStream] to read from. + * @return true if the read was successful otherwise, false. + */ + fun readAsText(input: InputStream): Boolean { + children.clear() + + KVTextReader(this, input).use { _ -> } + + return true + } + + /** + * Opens and reads the given filename as text. + * @see [readAsText] + * @param filename The file to open and read. + * @return true if the read was successful otherwise, false. + */ + fun readFileAsText(filename: String): Boolean = FileInputStream(filename).use(::readAsText) + + internal fun recursiveLoadFromBuffer(kvr: KVTextReader) { + val wasQuoted = Passable(false) + val wasConditional = Passable(false) + + while (true) { + // val bAccepted = true + + // get the key name + val name = kvr.readToken(wasQuoted = wasQuoted, wasConditional = wasConditional) + + if (name.isNullOrEmpty()) { + throw IllegalStateException("RecursiveLoadFromBuffer: got EOF or empty keyname") + } + + if (name.startsWith('}') && wasQuoted.value == false) { + // top level closed, stop reading + break + } + + val dat = KeyValue(name) + dat.children.clear() + this.children.add(dat) + + // get the value + var value = kvr.readToken(wasQuoted, wasConditional) + + if (wasConditional.value == true && value != null) { + // bAccepted = ( value == "[$WIN32]" ) + value = kvr.readToken(wasQuoted, wasConditional) + } + + if (value == null) { + throw IllegalStateException("RecursiveLoadFromBuffer: got NULL key") + } + + if (value.startsWith('}') && wasQuoted.value == false) { + throw IllegalStateException("RecursiveLoadFromBuffer: got } in key") + } + + if (value.startsWith('{') && wasQuoted.value == false) { + dat.recursiveLoadFromBuffer(kvr) + } else { + if (wasConditional.value == true) { + throw IllegalStateException("RecursiveLoadFromBuffer: got conditional between key and value") + } + + dat.value = value + // blahconditionalsdontcare + } + } + } + + /** + * Saves this instance to file. + * @param file The file to save to. + * @param asBinary If set to true, saves this instance as binary. + */ + fun saveToFile(file: File, asBinary: Boolean) { + FileOutputStream(file, false).use { f -> saveToStream(f, asBinary) } + } + + /** + * Saves this instance to file. + * @param path The file path to save to. + * @param asBinary If set to true, saves this instance as binary. + */ + fun saveToFile(path: String, asBinary: Boolean) { + FileOutputStream(path, false).use { f -> saveToStream(f, asBinary) } + } + + /** + * Saves this instance to a given [OutputStream] + * @param stream The [OutputStream] to save to. + * @param asBinary If set to true, saves this instance as binary. + */ + @Throws(IOException::class) + fun saveToStream(stream: OutputStream, asBinary: Boolean) { + if (asBinary) { + recursiveSaveBinaryToStream(stream) + } else { + recursiveSaveTextToFile(stream) + } + } + + @Throws(IOException::class) + private fun recursiveSaveBinaryToStream(f: OutputStream) { + recursiveSaveBinaryToStreamCore(f) + f.write(Type.END.code.toInt()) + } + + @Throws(IOException::class) + private fun recursiveSaveBinaryToStreamCore(f: OutputStream) { + // Only supported types ATM: + // 1. KeyValue with children (no value itself) + // 2. String KeyValue + if (value == null) { + f.write(Type.NONE.code.toInt()) + f.write(getNameForSerialization().toByteArray(StandardCharsets.UTF_8)) + f.write(0) + children.forEach { child -> + child.recursiveSaveBinaryToStreamCore(f) + } + f.write(Type.END.code.toInt()) + } else { + f.write(Type.STRING.code.toInt()) + f.write(getNameForSerialization().toByteArray(StandardCharsets.UTF_8)) + f.write(0) + f.write(value?.toByteArray(StandardCharsets.UTF_8)) + f.write(0) + } + } + + private fun recursiveSaveTextToFile(os: OutputStream, indentLevel: Int = 0) { + // write header + writeIndents(os, indentLevel) + writeString(os, getNameForSerialization(), true) + writeString(os, "\n") + writeIndents(os, indentLevel) + writeString(os, "{\n") + + // loop through all our keys writing them to disk + children.forEach { child -> + if (child.value == null) { + child.recursiveSaveTextToFile(os, indentLevel + 1) + } else { + writeIndents(os, indentLevel + 1) + writeString(os, child.getNameForSerialization(), true) + writeString(os, "\t\t") + writeString(os, escapeText(child.asString()!!), true) + writeString(os, "\n") + } + } + + writeIndents(os, indentLevel) + writeString(os, "}\n") + } + + /** + * Populate this instance from the given [InputStream] as a binary [KeyValue]. + * @param input The input [InputStream] to read from. + * @return true if the read was successful otherwise, false. + */ + @Throws(IOException::class, EOFException::class) + fun tryReadAsBinary(input: InputStream): Boolean = BinaryReader(input).use { br -> + tryReadAsBinaryCore(br, this, null) + } + + private fun getNameForSerialization(): String = requireNotNull(name) { + "Cannot serialise a KeyValue object with a null name!" + } + + companion object { + enum class Type(val code: Byte) { + NONE(0), + STRING(1), + INT32(2), + FLOAT32(3), + POINTER(4), + WIDESTRING(5), + COLOR(6), + UINT64(7), + END(8), + INT64(10), + ALTERNATEEND(11), + ; + + companion object { + private val codeMap = entries.associateBy { it.code } + + fun from(code: Byte): Type? = codeMap[code] + } + } + + private val logger: Logger = LogManager.getLogger(KeyValue::class.java) + + /** + * Represents an invalid [KeyValue] given when a searched for child does not exist. + */ + @JvmField + val INVALID = KeyValue() + + /** + * Attempts to load the given filename as a text [KeyValue]. + * This method will swallow any exceptions that occur when reading, use [readAsText] if you wish to handle exceptions. + * @param path The path to the file to load. + * @return a [KeyValue] instance if the load was successful, or null on failure. + */ + @JvmStatic + fun loadAsText(path: String): KeyValue? = loadFromFile(path, false) + + /** + * Attempts to load the given filename as a binary . + * @param path The path to the file to load. + * @return The resulting [KeyValue] object if the load was successful, or null if unsuccessful. + */ + @JvmStatic + fun tryLoadAsBinary(path: String): KeyValue? = loadFromFile(path, true) + + private fun loadFromFile(path: String, asBinary: Boolean): KeyValue? { + val file = File(path) + + if (!file.exists() || file.isDirectory()) { + return null + } + + try { + FileInputStream(file).use { input -> + val kv = KeyValue() + + if (asBinary) { + if (!kv.tryReadAsBinary(input)) { + return null + } + } else { + if (!kv.readAsText(input)) { + return null + } + } + + return kv + } + } catch (e: Exception) { + logger.error(e.message, e) + return null + } + } + + /** + * Attempts to create an instance of [KeyValue] from the given input text. + * This method will swallow any exceptions that occur when reading, use [readAsText] if you wish to handle exceptions. + * @param input The input text to load. + * @return a [KeyValue] instance if the load was successful, or null on failure. + */ + @JvmStatic + fun loadFromString(input: String): KeyValue? { + val bytes = input.toByteArray(StandardCharsets.UTF_8) + + try { + ByteArrayInputStream(bytes).use { stream -> + val kv = KeyValue() + + if (!kv.readAsText(stream)) { + return null + } + return kv + } + } catch (e: Exception) { + logger.error(e.message, e) + return null + } + } + + private fun escapeText(value: String): String { + var localValue = value + KVTextReader.ESCAPED_MAPPING.forEach { kvp -> + val textToReplace = kvp.value.toString() + val escapedReplacement = "\\" + kvp.key + localValue = localValue.replace(textToReplace, escapedReplacement) + } + return localValue + } + + private fun writeIndents(stream: OutputStream, indentLevel: Int) { + writeString(stream, "\t".repeat(indentLevel)) + } + + private fun writeString(stream: OutputStream, str: String, quote: Boolean = false) { + val processedStr = str.replace("\"", "\\\"") + val finalStr = if (quote) "\"$processedStr\"" else processedStr + val bytes = finalStr.toByteArray(Charsets.UTF_8) + stream.write(bytes) + } + + private fun tryReadAsBinaryCore(input: BinaryReader, current: KeyValue, parent: KeyValue?): Boolean { + var localCurrent = current + + localCurrent.children.clear() + + while (true) { + val type = Type.from(input.readByte()) + + if (type == Type.END || type == Type.ALTERNATEEND) { + break + } + + localCurrent.name = input.readNullTermString(StandardCharsets.UTF_8) + + when (type) { + Type.NONE -> { + val child = KeyValue() + val didReadChild = tryReadAsBinaryCore(input, child, localCurrent) + if (!didReadChild) { + return false + } + } + + Type.STRING -> localCurrent.value = input.readNullTermString(StandardCharsets.UTF_8) + Type.WIDESTRING -> { + logger.debug("Encountered WideString type when parsing binary KeyValue, which is unsupported. Returning false.") + return false + } + + Type.INT32, + Type.COLOR, + Type.POINTER, + -> localCurrent.value = input.readInt().toString() + + Type.UINT64 -> localCurrent.value = input.readLong().toString() + Type.FLOAT32 -> localCurrent.value = input.readFloat().toString() + Type.INT64 -> localCurrent.value = input.readLong().toString() + else -> return false + } + + parent?.children?.add(localCurrent) + + localCurrent = KeyValue() + } + + return true + } + } +} diff --git a/src/main/java/in/dragonbra/javasteam/util/VZipUtil.kt b/src/main/java/in/dragonbra/javasteam/util/VZipUtil.kt index 021007f1..642faa2e 100644 --- a/src/main/java/in/dragonbra/javasteam/util/VZipUtil.kt +++ b/src/main/java/in/dragonbra/javasteam/util/VZipUtil.kt @@ -2,6 +2,7 @@ package `in`.dragonbra.javasteam.util import `in`.dragonbra.javasteam.util.compat.readNBytesCompat import `in`.dragonbra.javasteam.util.crypto.CryptoHelper +import `in`.dragonbra.javasteam.util.log.LogManager import `in`.dragonbra.javasteam.util.stream.BinaryReader import `in`.dragonbra.javasteam.util.stream.BinaryWriter import `in`.dragonbra.javasteam.util.stream.MemoryStream @@ -16,6 +17,8 @@ import kotlin.math.max @Suppress("SpellCheckingInspection", "unused") object VZipUtil { + private val logger = LogManager.getLogger(VZipUtil::class.java) + private const val VZIP_HEADER: Short = 0x5A56 // "VZ" in hex private const val VZIP_FOOTER: Short = 0x767A // "vz" in hex private const val HEADER_LENGTH = 7 // magic + version + timestamp/crc @@ -25,60 +28,68 @@ object VZipUtil { @JvmStatic fun decompress(ms: MemoryStream, destination: ByteArray, verifyChecksum: Boolean = true): Int { - BinaryReader(ms).use { reader -> - if (reader.readShort() != VZIP_HEADER) { - throw IllegalArgumentException("Expecting VZipHeader at start of stream") - } + try { + BinaryReader(ms).use { reader -> + if (reader.readShort() != VZIP_HEADER) { + throw IllegalArgumentException("Expecting VZipHeader at start of stream") + } - if (reader.readByte() != VERSION) { - throw IllegalArgumentException("Expecting VZip version 'a'") - } + if (reader.readByte() != VERSION) { + throw IllegalArgumentException("Expecting VZip version 'a'") + } - // Sometimes this is a creation timestamp (e.g. for Steam Client VZips). - // Sometimes this is a CRC32 (e.g. for depot chunks). - /* val creationTimestampOrSecondaryCRC: UInt = */ - reader.readInt() + // Sometimes this is a creation timestamp (e.g. for Steam Client VZips). + // Sometimes this is a CRC32 (e.g. for depot chunks). + /* val creationTimestampOrSecondaryCRC: UInt = */ + reader.readInt() - // this is 5 bytes of LZMA properties - val propertyBits = reader.readByte() - val dictionarySize = reader.readInt() - val compressedBytesOffset = ms.position + // this is 5 bytes of LZMA properties + val propertyBits = reader.readByte() + val dictionarySize = reader.readInt() + val compressedBytesOffset = ms.position - // jump to the end of the buffer to read the footer - ms.seek((-FOOTER_LENGTH).toLong(), SeekOrigin.END) + // jump to the end of the buffer to read the footer + ms.seek((-FOOTER_LENGTH).toLong(), SeekOrigin.END) - val outputCrc = reader.readInt() - val sizeDecompressed = reader.readInt() + val outputCrc = reader.readInt() + val sizeDecompressed = reader.readInt() - if (reader.readShort() != VZIP_FOOTER) { - throw IllegalArgumentException("Expecting VZipFooter at end of stream") - } + if (reader.readShort() != VZIP_FOOTER) { + throw IllegalArgumentException("Expecting VZipFooter at end of stream") + } - if (destination.size < sizeDecompressed) { - throw IllegalArgumentException("The destination buffer is smaller than the decompressed data size.") - } + if (destination.size < sizeDecompressed) { + throw IllegalArgumentException("The destination buffer is smaller than the decompressed data size.") + } - // jump back to the beginning of the compressed data - ms.position = compressedBytesOffset - - // If the value of dictionary size in properties is smaller than (1 << 12), - // the LZMA decoder must set the dictionary size variable to (1 << 12). - val windowBuffer = ByteArray(max(1 shl 12, dictionarySize)) - val bytesRead = LZMAInputStream( - ms, - sizeDecompressed.toLong(), - propertyBits, - dictionarySize, - windowBuffer - ).use { lzmaInput -> - lzmaInput.readNBytesCompat(destination, 0, sizeDecompressed) - } + // jump back to the beginning of the compressed data + ms.position = compressedBytesOffset + + // If the value of dictionary size in properties is smaller than (1 << 12), + // the LZMA decoder must set the dictionary size variable to (1 << 12). + val windowBuffer = ByteArray(max(1 shl 12, dictionarySize)) + val bytesRead = LZMAInputStream( + ms, + sizeDecompressed.toLong(), + propertyBits, + dictionarySize, + windowBuffer + ).use { lzmaInput -> + lzmaInput.readNBytesCompat(destination, 0, sizeDecompressed) + } - if (verifyChecksum && Utils.crc32(destination).toInt() != outputCrc) { - throw DataFormatException("CRC does not match decompressed data. VZip data may be corrupted.") - } + if (verifyChecksum && Utils.crc32(destination).toInt() != outputCrc) { + throw DataFormatException("CRC does not match decompressed data. VZip data may be corrupted.") + } - return bytesRead + return bytesRead + } + } catch (e: NoClassDefFoundError) { + logger.error("Missing implementation of org.tukaani:xz") + throw e + } catch (e: ClassNotFoundException) { + logger.error("Missing implementation of org.tukaani:xz") + throw e } } @@ -87,33 +98,41 @@ object VZipUtil { */ @JvmStatic fun compress(buffer: ByteArray): ByteArray { - ByteArrayOutputStream().use { ms -> - BinaryWriter(ms).use { writer -> - val crc = CryptoHelper.crcHash(buffer) - writer.writeShort(VZIP_HEADER) - writer.writeByte(VERSION) - writer.write(crc) - - // Configure LZMA options to match SteamKit2's settings - val options = LZMA2Options().apply { - dictSize = 1 shl 23 // 8MB dictionary - setPreset(2) // Algorithm setting - niceLen = 128 // numFastBytes equivalent - matchFinder = LZMA2Options.MF_BT4 - mode = LZMA2Options.MODE_NORMAL + try { + ByteArrayOutputStream().use { ms -> + BinaryWriter(ms).use { writer -> + val crc = CryptoHelper.crcHash(buffer) + writer.writeShort(VZIP_HEADER) + writer.writeByte(VERSION) + writer.write(crc) + + // Configure LZMA options to match SteamKit2's settings + val options = LZMA2Options().apply { + dictSize = 1 shl 23 // 8MB dictionary + setPreset(2) // Algorithm setting + niceLen = 128 // numFastBytes equivalent + matchFinder = LZMA2Options.MF_BT4 + mode = LZMA2Options.MODE_NORMAL + } + + // Write LZMA-compressed data + LZMAOutputStream(ms, options, false).use { lzmaStream -> + lzmaStream.write(buffer) + } + + writer.write(crc) + writer.writeInt(buffer.size) + writer.writeShort(VZIP_FOOTER) + + return ms.toByteArray() } - - // Write LZMA-compressed data - LZMAOutputStream(ms, options, false).use { lzmaStream -> - lzmaStream.write(buffer) - } - - writer.write(crc) - writer.writeInt(buffer.size) - writer.writeShort(VZIP_FOOTER) - - return ms.toByteArray() } + } catch (e: NoClassDefFoundError) { + logger.error("Missing implementation of org.tukaani:xz") + throw e + } catch (e: ClassNotFoundException) { + logger.error("Missing implementation of org.tukaani:xz") + throw e } } } diff --git a/src/test/java/in/dragonbra/javasteam/steam/cdn/DepotChunkTest.java b/src/test/java/in/dragonbra/javasteam/steam/cdn/DepotChunkTest.java index d14eb1f5..cf65a2fc 100644 --- a/src/test/java/in/dragonbra/javasteam/steam/cdn/DepotChunkTest.java +++ b/src/test/java/in/dragonbra/javasteam/steam/cdn/DepotChunkTest.java @@ -1,5 +1,6 @@ package in.dragonbra.javasteam.steam.cdn; +import in.dragonbra.javasteam.TestBase; import in.dragonbra.javasteam.types.ChunkData; import in.dragonbra.javasteam.util.stream.MemoryStream; import org.apache.commons.io.IOUtils; @@ -11,7 +12,7 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -public class DepotChunkTest { +public class DepotChunkTest extends TestBase { @Test public void decryptsAndDecompressesDepotChunkPKZip() throws IOException, NoSuchAlgorithmException { diff --git a/src/test/java/in/dragonbra/javasteam/steam/webapi/SteamDirectoryTest.java b/src/test/java/in/dragonbra/javasteam/steam/webapi/SteamDirectoryTest.java index 07912113..d5984349 100644 --- a/src/test/java/in/dragonbra/javasteam/steam/webapi/SteamDirectoryTest.java +++ b/src/test/java/in/dragonbra/javasteam/steam/webapi/SteamDirectoryTest.java @@ -35,7 +35,7 @@ public void load() { String resource = IOUtils.toString(vdf, StandardCharsets.UTF_8); - MockResponse resp = new MockResponse().newBuilder().body(resource).build(); + MockResponse resp = new MockResponse.Builder().body(resource).build(); server.enqueue(resp); server.start(); @@ -49,10 +49,11 @@ public void load() { assertEquals(80, servers.size()); RecordedRequest request = server.takeRequest(); - assertEquals("/ISteamDirectory/GetCMListForConnect/v1?format=vdf&cellid=0", request.getPath()); - assertEquals("GET", request.getMethod()); - server.shutdown(); + // "/ISteamDirectory/GetCMListForConnect/v1?format=vdf&cellid=0" + assertEquals("/ISteamDirectory/GetCMListForConnect/v1", request.getUrl().encodedPath()); + assertEquals("format=vdf&cellid=0", request.getUrl().encodedQuery()); + assertEquals("GET", request.getMethod()); } catch (Exception e) { fail(e); } diff --git a/src/test/java/in/dragonbra/javasteam/steam/webapi/WebAPITest.java b/src/test/java/in/dragonbra/javasteam/steam/webapi/WebAPITest.java index 889cf37d..067827e4 100644 --- a/src/test/java/in/dragonbra/javasteam/steam/webapi/WebAPITest.java +++ b/src/test/java/in/dragonbra/javasteam/steam/webapi/WebAPITest.java @@ -41,7 +41,7 @@ public void setUp() throws IOException { lock = new CountDownLatch(1); server = new MockWebServer(); - MockResponse resp = new MockResponse().newBuilder().body("" + + MockResponse resp = new MockResponse.Builder().body("" + "\"root\"" + "{" + " \"name\" \"stringvalue\"" + @@ -58,8 +58,8 @@ public void setUp() throws IOException { } @AfterEach - public void tearDown() throws IOException { - server.shutdown(); + public void tearDown() { + server.close(); } @Test @@ -96,7 +96,9 @@ public void testSyncCall() throws IOException, InterruptedException { assertEquals("stringvalue", result.get("name").getValue()); RecordedRequest request = server.takeRequest(); - assertEquals("/TestInterface/TestFunction/v1?format=vdf", request.getPath()); + // "/TestInterface/TestFunction/v1?format=vdf" + assertEquals("/TestInterface/TestFunction/v1", request.getUrl().encodedPath()); + assertEquals("format=vdf", request.getUrl().encodedQuery()); assertEquals("GET", request.getMethod()); } @@ -111,7 +113,9 @@ public void testAsyncCall() throws IOException, InterruptedException { }, null); RecordedRequest request = server.takeRequest(); - assertEquals("/TestInterface/TestFunction/v1?format=vdf", request.getPath()); + // "/TestInterface/TestFunction/v1?format=vdf" + assertEquals("/TestInterface/TestFunction/v1", request.getUrl().encodedPath()); + assertEquals("format=vdf", request.getUrl().encodedQuery()); assertEquals("GET", request.getMethod()); //noinspection ResultOfMethodCallIgnored @@ -128,9 +132,9 @@ public void testPostCall() throws IOException, InterruptedException { assertEquals("stringvalue", result.get("name").getValue()); RecordedRequest request = server.takeRequest(); - assertEquals("/TestInterface/TestFunction/v1", request.getPath()); + assertEquals("/TestInterface/TestFunction/v1", request.getUrl().encodedPath()); assertEquals("POST", request.getMethod()); - assertEquals("format=vdf", request.getBody().readString(StandardCharsets.UTF_8)); + assertEquals("format=vdf", request.getBody().utf8()); } @Test @@ -143,7 +147,9 @@ public void testVersionCall() throws IOException, InterruptedException { assertEquals("stringvalue", result.get("name").getValue()); RecordedRequest request = server.takeRequest(); - assertEquals("/TestInterface/TestFunction/v69?format=vdf", request.getPath()); + // "/TestInterface/TestFunction/v69?format=vdf" + assertEquals("/TestInterface/TestFunction/v69", request.getUrl().encodedPath()); + assertEquals("format=vdf", request.getUrl().encodedQuery()); assertEquals("GET", request.getMethod()); } @@ -161,7 +167,9 @@ public void testParametersCall() throws IOException, InterruptedException { assertEquals("stringvalue", result.get("name").getValue()); RecordedRequest request = server.takeRequest(); - assertEquals("/TestInterface/TestFunction/v1?key1=value1&key2=value2&format=vdf", request.getPath()); + // "/TestInterface/TestFunction/v1?key1=value1&key2=value2&format=vdf" + assertEquals("/TestInterface/TestFunction/v1", request.getUrl().encodedPath()); + assertEquals("key1=value1&key2=value2&format=vdf", request.getUrl().encodedQuery()); assertEquals("GET", request.getMethod()); } @@ -179,9 +187,9 @@ public void testParametersPostCall() throws IOException, InterruptedException { assertEquals("stringvalue", result.get("name").getValue()); RecordedRequest request = server.takeRequest(); - assertEquals("/TestInterface/TestFunction/v1", request.getPath()); + assertEquals("/TestInterface/TestFunction/v1", request.getUrl().encodedPath()); assertEquals("POST", request.getMethod()); - assertEquals("key1=value1&key2=value2&format=vdf", request.getBody().readString(StandardCharsets.UTF_8)); + assertEquals("key1=value1&key2=value2&format=vdf", request.getBody().utf8()); } @Test diff --git a/src/test/java/in/dragonbra/javasteam/types/KeyValueTest.java b/src/test/java/in/dragonbra/javasteam/types/KeyValueTest.java index 416bd603..3918cfed 100644 --- a/src/test/java/in/dragonbra/javasteam/types/KeyValueTest.java +++ b/src/test/java/in/dragonbra/javasteam/types/KeyValueTest.java @@ -13,7 +13,6 @@ import java.io.EOFException; import java.io.IOException; -import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -149,6 +148,25 @@ public void keyValuesHandlesBool() { Assertions.assertFalse(kv.get("name").asBoolean(), "values that cannot be converted to integers are falsey"); } + @Test + public void keyValuesHandlesBoolAdditional() { + var kvFalse = new KeyValue("key", "false"); + Assertions.assertFalse(kvFalse.asBoolean()); + + var kvTrue = new KeyValue("key", "true"); + Assertions.assertTrue(kvTrue.asBoolean()); + + var kv0 = new KeyValue("key", "0"); + Assertions.assertFalse(kv0.asBoolean()); + + var kv1 = new KeyValue("key", "1"); + Assertions.assertTrue(kv1.asBoolean()); + + var kvDefault = new KeyValue("key", "invalid value"); + Assertions.assertFalse(kvDefault.asBoolean()); + Assertions.assertTrue(kvDefault.asBoolean(true)); + } + @Test public void keyValuesHandlesFloat() { KeyValue kv = KeyValue.loadFromString("" + @@ -441,6 +459,23 @@ public void canLoadUnicodeTextStream() { // @Test // public void canReadAndIgnoreConditionals() { + // String text = ("\"Repro\"\n" + + // "{" + "\n" + + // "\"Conditional\" \"You're not running Windows.\" [$!WIN32] // DEPRECATED" + "\n" + + // "\"EmptyThing\"\t\"\"" + "\n" + + // "}").trim(); + // + // var kv = new KeyValue(); + // try (var ms = new MemoryStream(text.getBytes(StandardCharsets.UTF_8))) { + // kv.readAsText(ms); + // } + // + // Assertions.assertEquals( "Repro", kv.getName() ); + // Assertions.assertEquals( 2, kv.getChildren().size() ); + // Assertions.assertEquals( "Conditional", kv.getChildren().get(0).getName() ); + // Assertions.assertEquals( "You're not running Windows.", kv.getChildren().get(0).getValue() ); + // Assertions.assertEquals( "EmptyThing", kv.getChildren().get(1).getName() ); + // Assertions.assertEquals( "", kv.getChildren().get(1).getValue() ); // } @Test @@ -595,18 +630,6 @@ public void keyValuesHandlesEnum() { Assertions.assertEquals(EChatPermission.OwnerDefault, kv.get("name").asEnum(EChatPermission.class, EChatPermission.EveryoneDefault)); } - @Test - public void keyValues_loadAsText_should_read_successfully() { - URL file = this.getClass().getClassLoader().getResource("textkeyvalues/appinfo_utf8.txt"); - - Assertions.assertNotNull(file, "Resource file was null"); - - KeyValue kv = KeyValue.loadAsText(file.getPath()); - - Assertions.assertEquals("1234567", kv.get("appid").getValue(), "appid should be 1234567"); - Assertions.assertEquals(2, kv.getChildren().size(), "Children should be 2"); - } - private static String saveToText(KeyValue kv) { String text = null; @@ -620,4 +643,37 @@ private static String saveToText(KeyValue kv) { return text; } + +// @Test +// public void tryReadAsBinary() throws IOException { +// var nis = Files.newInputStream(Path.of("C:\\Program Files (x86)\\Steam\\appcache\\stats\\UserGameStatsSchema_550.bin")); +// var kv = new KeyValue(); +// var result = kv.tryReadAsBinary(nis); +// System.out.println(result); +// } +// +// @Test +// public void tryLoadAsBinary_UserGameStatsSchema() throws IOException { +// var kv = KeyValue.tryLoadAsBinary("C:\\Program Files (x86)\\Steam\\appcache\\stats\\UserGameStatsSchema_550.bin"); +// printKeyValue(kv, 0); +// } +// +// @Test +// public void tryLoadAsBinary_UserGameStats() throws IOException { +// var kv = KeyValue.tryLoadAsBinary("C:\\Program Files (x86)\\Steam\\appcache\\stats\\UserGameStats_43540078_3527290.bin"); +// printKeyValue(kv, 0); +// } +// +// private void printKeyValue(KeyValue keyValue, int depth) { +// String spacePadding = String.join("", Collections.nCopies(depth, " ")); +// +// if (keyValue.getChildren().isEmpty()) { +// System.out.println(spacePadding + keyValue.getName() + ": " + keyValue.getValue()); +// } else { +// System.out.println(spacePadding + keyValue.getName() + ":"); +// for (KeyValue child : keyValue.getChildren()) { +// printKeyValue(child, depth + 1); +// } +// } +// } } diff --git a/src/test/resources/textkeyvalues/appinfo_utf8.txt b/src/test/resources/textkeyvalues/appinfo_utf8.txt deleted file mode 100644 index 1fd546be..00000000 Binary files a/src/test/resources/textkeyvalues/appinfo_utf8.txt and /dev/null differ