diff --git a/.github/workflows/android-ci.yml b/.github/workflows/android-ci.yml index c1f8e650..034718e2 100644 --- a/.github/workflows/android-ci.yml +++ b/.github/workflows/android-ci.yml @@ -69,16 +69,6 @@ jobs: - name: Checkout kit-android code uses: actions/checkout@v4 - - name: Checkout kit repository - uses: actions/checkout@v4 - with: - repository: ton-connect/kit - ref: fix/android-dto # Branch with RequestError and nullable fixes - path: ton-repo - sparse-checkout: | - packages/walletkit - packages/walletkit-android-bridge - - name: Set up JDK 17 uses: actions/setup-java@v4 with: @@ -89,35 +79,15 @@ jobs: - name: Setup Android SDK uses: android-actions/setup-android@v3 - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Set up pnpm - uses: pnpm/action-setup@v2 - with: - version: 8 - - - name: Build TypeScript Bridge Bundle - run: | - cd ton-repo - pnpm install - pnpm turbo run build --filter=walletkit-android-bridge --force - - - name: Copy Bridge Bundle to dist-android + - name: Populate dist-android from committed assets run: | mkdir -p dist-android - cp ton-repo/packages/walletkit-android-bridge/dist/* dist-android/ + cp TONWalletKit-Android/impl/src/main/assets/walletkit/* dist-android/ - name: Grant execute permission for gradlew working-directory: TONWalletKit-Android run: chmod +x gradlew - - name: Sync WebView Assets - working-directory: TONWalletKit-Android - run: ./gradlew syncWalletKitWebViewAssets - - name: Run unit tests working-directory: TONWalletKit-Android run: ./gradlew :impl:testWebviewDebugUnitTest @@ -288,7 +258,7 @@ jobs: path: | ~/.android/avd/* ~/.android/adb* - key: avd-api-35-x86_64 + key: avd-api-35-x86_64-pixel6 - name: Create AVD and generate snapshot if: steps.avd-cache.outputs.cache-hit != 'true' @@ -297,6 +267,7 @@ jobs: api-level: 35 arch: x86_64 target: google_apis + profile: pixel_6 force-avd-creation: false emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true @@ -418,6 +389,7 @@ jobs: api-level: 35 arch: x86_64 target: google_apis + profile: pixel_6 force-avd-creation: false emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true diff --git a/Scripts/generate-api/generate-api-models.sh b/Scripts/generate-api/generate-api-models.sh index d2cb3f70..311a6db0 100755 --- a/Scripts/generate-api/generate-api-models.sh +++ b/Scripts/generate-api/generate-api-models.sh @@ -175,6 +175,17 @@ rm -rf "$DEST_DIR" mkdir -p "$DEST_DIR" cp -R "$MODELS_DIR/"* "$DEST_DIR/" +# Remove empty generated files (from x-skip-model suppression) +echo "🧹 Removing empty generated files..." +find "$DEST_DIR" -name '*.kt' -type f -empty -delete +find "$DEST_DIR" -name '*.kt' -type f | while read -r file; do + # Check if file contains only whitespace/blank lines/comments/package/suppress but no actual code + if ! grep -qE '^\s*(class |data class |sealed class |object |interface |typealias |enum class |fun |val |var |abstract )' "$file"; then + echo " Removing boilerplate-only file: $(basename "$file")" + rm "$file" + fi +done + # Clean up generated directory echo "🧹 Cleaning up generated directory..." rm -rf "$OUTPUT_DIR" diff --git a/Scripts/generate-api/templates/data_class.mustache b/Scripts/generate-api/templates/data_class.mustache index db36f7de..c0ac6b38 100644 --- a/Scripts/generate-api/templates/data_class.mustache +++ b/Scripts/generate-api/templates/data_class.mustache @@ -1,7 +1,19 @@ +{{#vendorExtensions.x-skip-model}} +{{! Suppressed model (e.g., single-field variant inlined into parent union) }} +{{/vendorExtensions.x-skip-model}} +{{^vendorExtensions.x-skip-model}} +{{#vendorExtensions.x-type-alias}} +typealias {{{classname}}} = {{modelNamePrefix}}{{{vendorExtensions.x-alias-target}}} +{{/vendorExtensions.x-type-alias}} +{{^vendorExtensions.x-type-alias}} {{#vendorExtensions.x-discriminated-union}} {{>modelDiscriminatedUnion}} {{/vendorExtensions.x-discriminated-union}} {{^vendorExtensions.x-discriminated-union}} +{{#vendorExtensions.x-is-generic}} +{{>modelGeneric}} +{{/vendorExtensions.x-is-generic}} +{{^vendorExtensions.x-is-generic}} {{^multiplatform}} {{#kotlinx_serialization}} import kotlinx.serialization.Serializable @@ -16,6 +28,21 @@ import kotlinx.serialization.builtins.serializer import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder {{/enumUnknownDefaultCase}} +{{#vendorExtensions.x-inline-interface-unions}} +{{#-first}} +import kotlinx.serialization.KSerializer +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.serializer +{{/-first}} +{{/vendorExtensions.x-inline-interface-unions}} {{/kotlinx_serialization}} {{#parcelizeModels}} import android.os.Parcelable @@ -40,12 +67,77 @@ import kotlinx.parcelize.Parcelize {{#nonPublicApi}}internal {{/nonPublicApi}}{{#hasVars}}data {{/hasVars}}class {{classname}} ( {{#allVars}} -{{#required}}{{>data_class_req_var}}{{/required}}{{^required}}{{>data_class_opt_var}}{{/required}}{{^-last}},{{/-last}} +{{#vendorExtensions.x-frozen}} + @SerialName("{{{baseName}}}") + private val {{{name}}}: {{{dataType}}}{{^required}}? = null{{/required}}{{/vendorExtensions.x-frozen}} +{{^vendorExtensions.x-frozen}} +{{#vendorExtensions.x-interface-union}} + @SerialName("{{{baseName}}}") + val {{{name}}}: {{#lambda.titlecase}}{{{name}}}{{/lambda.titlecase}}{{^required}}? = null{{/required}}{{/vendorExtensions.x-interface-union}} +{{^vendorExtensions.x-interface-union}} +{{#required}}{{>data_class_req_var}}{{/required}}{{^required}}{{>data_class_opt_var}}{{/required}}{{/vendorExtensions.x-interface-union}} +{{/vendorExtensions.x-frozen}} +{{^-last}},{{/-last}} {{/allVars}} +{{#vendorExtensions.x-constant-fields}} +{{#-first}}{{#hasVars}},{{/hasVars}}{{/-first}} + @SerialName("{{{name}}}") + val {{{name}}}: kotlin.String = "{{{value}}}"{{^-last}},{{/-last}} +{{/vendorExtensions.x-constant-fields}} ){{#parent}} : {{{parent}}}(){{/parent}} { companion object +{{#vendorExtensions.x-inline-interface-unions}} + + @Serializable(with = {{#lambda.titlecase}}{{{propertyName}}}{{/lambda.titlecase}}.Serializer::class) + sealed class {{#lambda.titlecase}}{{{propertyName}}}{{/lambda.titlecase}} { +{{#cases}} + + @Serializable + data class {{#lambda.titlecase}}{{{caseName}}}{{/lambda.titlecase}}( + val value: {{modelNamePrefix}}{{{typeName}}} + ) : {{#lambda.titlecase}}{{{propertyName}}}{{/lambda.titlecase}}() +{{/cases}} + + internal object Serializer : KSerializer<{{#lambda.titlecase}}{{{propertyName}}}{{/lambda.titlecase}}> { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("{{classname}}.{{#lambda.titlecase}}{{{propertyName}}}{{/lambda.titlecase}}") + + @Suppress("UNCHECKED_CAST") + override fun serialize(encoder: Encoder, value: {{#lambda.titlecase}}{{{propertyName}}}{{/lambda.titlecase}}) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("{{#lambda.titlecase}}{{{propertyName}}}{{/lambda.titlecase}} can only be serialized with JSON") + + val jsonElement = when (value) { +{{#cases}} + is {{#lambda.titlecase}}{{{caseName}}}{{/lambda.titlecase}} -> + jsonEncoder.json.encodeToJsonElement(serializer<{{modelNamePrefix}}{{{typeName}}}>(), value.value) +{{/cases}} + } + jsonEncoder.encodeJsonElement(jsonElement) + } + + override fun deserialize(decoder: Decoder): {{#lambda.titlecase}}{{{propertyName}}}{{/lambda.titlecase}} { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("{{#lambda.titlecase}}{{{propertyName}}}{{/lambda.titlecase}} can only be deserialized from JSON") + + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val discriminatorValue = jsonObject["{{{discriminatorField}}}"]?.jsonPrimitive?.content + ?: throw SerializationException("Missing '{{{discriminatorField}}}' discriminator for {{#lambda.titlecase}}{{{propertyName}}}{{/lambda.titlecase}}") + + return when (discriminatorValue) { +{{#cases}} + "{{{rawValue}}}" -> + {{#lambda.titlecase}}{{{caseName}}}{{/lambda.titlecase}}( + jsonDecoder.json.decodeFromJsonElement(serializer<{{modelNamePrefix}}{{{typeName}}}>(), jsonObject) + ) +{{/cases}} + else -> throw SerializationException("Unknown discriminator '$discriminatorValue' for {{#lambda.titlecase}}{{{propertyName}}}{{/lambda.titlecase}}") + } + } + } + } +{{/vendorExtensions.x-inline-interface-unions}} {{#hasEnums}} {{#vars}} {{#isEnum}} @@ -70,4 +162,7 @@ import kotlinx.parcelize.Parcelize {{/vars}} {{/hasEnums}} } +{{/vendorExtensions.x-is-generic}} {{/vendorExtensions.x-discriminated-union}} +{{/vendorExtensions.x-type-alias}} +{{/vendorExtensions.x-skip-model}} diff --git a/Scripts/generate-api/templates/modelDiscriminatedUnion.mustache b/Scripts/generate-api/templates/modelDiscriminatedUnion.mustache index 36dd25d6..2e6d2072 100644 --- a/Scripts/generate-api/templates/modelDiscriminatedUnion.mustache +++ b/Scripts/generate-api/templates/modelDiscriminatedUnion.mustache @@ -1,13 +1,180 @@ {{!-- Template for discriminated union types (sealed classes in Kotlin). - - Uses vendor extensions from OpenAPI spec: + + Two variants: + 1. Inline object unions (x-interface-union is false/absent): + e.g., { type: 'null' } | { type: 'num'; value: string } + Uses type/value JSON wrapper with per-case value decoding. + + 2. Interface unions (x-interface-union is true): + e.g., /** @discriminator name */ export type Test = TestWithMessage | TestWithNumber; + Uses discriminator field from full JSON object, decodes full member type. + + Vendor extensions used: - x-discriminated-union: boolean indicating this is a discriminated union + - x-interface-union: boolean indicating this uses interface-based members + - x-discriminator-field: string name of the JSON discriminator property - x-enum-cases: array of {name, rawValue, hasAssociatedValue, valuePropertyName} - - Properties with x-enum-case-name extension become sealed class subclasses. - The discriminator is always "type" field. + - x-enum-case-name: camelCase Kotlin case name (on vars) + - x-enum-case-raw-value: original JSON value for encoding/decoding (on vars) --}} +{{#vendorExtensions.x-interface-union}} +{{!-- ====== Interface union variant ====== --}} +import kotlinx.serialization.KSerializer +import kotlinx.serialization.Serializable +import kotlinx.serialization.SerialName +import kotlinx.serialization.SerializationException +import kotlinx.serialization.descriptors.SerialDescriptor +import kotlinx.serialization.descriptors.buildClassSerialDescriptor +import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.serializer +import io.ton.walletkit.model.TONBase64 +import io.ton.walletkit.model.TONUserFriendlyAddress + +/** + * {{{description}}} + * + * This is a discriminated union type. Use the appropriate subclass based on the `{{{vendorExtensions.x-discriminator-field}}}` field. + */ +@Serializable(with = {{classname}}.Serializer::class) +sealed class {{classname}} { + + companion object { + internal const val DISCRIMINATOR_FIELD = "{{{vendorExtensions.x-discriminator-field}}}" + } + +{{#vars}} +{{#vendorExtensions.x-enum-case-name}} +{{#vendorExtensions.x-empty-variant}} + /** + * {{description}} + */ + object {{#lambda.titlecase}}{{vendorExtensions.x-enum-case-name}}{{/lambda.titlecase}} : {{classname}}() + +{{/vendorExtensions.x-empty-variant}} +{{#vendorExtensions.x-single-field-variant}} + /** + * {{description}} + */ + @Serializable + data class {{#lambda.titlecase}}{{vendorExtensions.x-enum-case-name}}{{/lambda.titlecase}}( + val {{{vendorExtensions.x-single-field-name}}}: {{{dataType}}}{{#vendorExtensions.x-single-field-optional}}? = null{{/vendorExtensions.x-single-field-optional}} + ) : {{classname}}() + +{{/vendorExtensions.x-single-field-variant}} +{{^vendorExtensions.x-empty-variant}}{{^vendorExtensions.x-single-field-variant}} + /** + * {{description}} + */ + @Serializable + data class {{#lambda.titlecase}}{{vendorExtensions.x-enum-case-name}}{{/lambda.titlecase}}( + val value: {{{dataType}}} + ) : {{classname}}() + +{{/vendorExtensions.x-single-field-variant}}{{/vendorExtensions.x-empty-variant}} +{{/vendorExtensions.x-enum-case-name}} +{{/vars}} + internal object Serializer : KSerializer<{{classname}}> { + override val descriptor: SerialDescriptor = buildClassSerialDescriptor("{{classname}}") + + @Suppress("UNCHECKED_CAST") + override fun serialize(encoder: Encoder, value: {{classname}}) { + val jsonEncoder = encoder as? JsonEncoder + ?: throw SerializationException("{{classname}} can only be serialized with JSON") + + val jsonElement = when (value) { +{{#vars}} +{{#vendorExtensions.x-enum-case-name}} +{{#vendorExtensions.x-empty-variant}} + is {{#lambda.titlecase}}{{vendorExtensions.x-enum-case-name}}{{/lambda.titlecase}} -> + buildJsonObject { + put(DISCRIMINATOR_FIELD, JsonPrimitive("{{vendorExtensions.x-enum-case-raw-value}}")) + } +{{/vendorExtensions.x-empty-variant}} +{{#vendorExtensions.x-single-field-variant}} +{{^vendorExtensions.x-single-field-optional}} + is {{#lambda.titlecase}}{{vendorExtensions.x-enum-case-name}}{{/lambda.titlecase}} -> + buildJsonObject { + put(DISCRIMINATOR_FIELD, JsonPrimitive("{{vendorExtensions.x-enum-case-raw-value}}")) + put("{{{vendorExtensions.x-single-field-name}}}", jsonEncoder.json.encodeToJsonElement(serializer<{{{dataType}}}>(), value.{{{vendorExtensions.x-single-field-name}}})) + } +{{/vendorExtensions.x-single-field-optional}} +{{#vendorExtensions.x-single-field-optional}} + is {{#lambda.titlecase}}{{vendorExtensions.x-enum-case-name}}{{/lambda.titlecase}} -> + buildJsonObject { + put(DISCRIMINATOR_FIELD, JsonPrimitive("{{vendorExtensions.x-enum-case-raw-value}}")) + value.{{{vendorExtensions.x-single-field-name}}}?.let { put("{{{vendorExtensions.x-single-field-name}}}", jsonEncoder.json.encodeToJsonElement(serializer<{{{dataType}}}>(), it)) } + } +{{/vendorExtensions.x-single-field-optional}} +{{/vendorExtensions.x-single-field-variant}} +{{^vendorExtensions.x-empty-variant}}{{^vendorExtensions.x-single-field-variant}} + is {{#lambda.titlecase}}{{vendorExtensions.x-enum-case-name}}{{/lambda.titlecase}} -> + jsonEncoder.json.encodeToJsonElement(serializer<{{{dataType}}}>(), value.value) +{{/vendorExtensions.x-single-field-variant}}{{/vendorExtensions.x-empty-variant}} +{{/vendorExtensions.x-enum-case-name}} +{{/vars}} + } + jsonEncoder.encodeJsonElement(jsonElement) + } + + override fun deserialize(decoder: Decoder): {{classname}} { + val jsonDecoder = decoder as? JsonDecoder + ?: throw SerializationException("{{classname}} can only be deserialized from JSON") + + val jsonObject = jsonDecoder.decodeJsonElement().jsonObject + val discriminatorValue = jsonObject[DISCRIMINATOR_FIELD]?.jsonPrimitive?.content + ?: throw SerializationException("Missing '$DISCRIMINATOR_FIELD' discriminator for {{classname}}") + + return when (discriminatorValue) { +{{#vars}} +{{#vendorExtensions.x-enum-case-name}} +{{#vendorExtensions.x-empty-variant}} + "{{vendorExtensions.x-enum-case-raw-value}}" -> + {{#lambda.titlecase}}{{vendorExtensions.x-enum-case-name}}{{/lambda.titlecase}} +{{/vendorExtensions.x-empty-variant}} +{{#vendorExtensions.x-single-field-variant}} +{{^vendorExtensions.x-single-field-optional}} + "{{vendorExtensions.x-enum-case-raw-value}}" -> + {{#lambda.titlecase}}{{vendorExtensions.x-enum-case-name}}{{/lambda.titlecase}}( + {{{vendorExtensions.x-single-field-name}}} = jsonDecoder.json.decodeFromJsonElement(serializer<{{{dataType}}}>(), jsonObject["{{{vendorExtensions.x-single-field-name}}}"] ?: throw SerializationException("Missing '{{{vendorExtensions.x-single-field-name}}}' for {{classname}}")) + ) +{{/vendorExtensions.x-single-field-optional}} +{{#vendorExtensions.x-single-field-optional}} + "{{vendorExtensions.x-enum-case-raw-value}}" -> { + val fieldElement = jsonObject["{{{vendorExtensions.x-single-field-name}}}"] + if (fieldElement != null) { + {{#lambda.titlecase}}{{vendorExtensions.x-enum-case-name}}{{/lambda.titlecase}}( + {{{vendorExtensions.x-single-field-name}}} = jsonDecoder.json.decodeFromJsonElement(serializer<{{{dataType}}}>(), fieldElement) + ) + } else { + {{#lambda.titlecase}}{{vendorExtensions.x-enum-case-name}}{{/lambda.titlecase}}() + } + } +{{/vendorExtensions.x-single-field-optional}} +{{/vendorExtensions.x-single-field-variant}} +{{^vendorExtensions.x-empty-variant}}{{^vendorExtensions.x-single-field-variant}} + "{{vendorExtensions.x-enum-case-raw-value}}" -> + {{#lambda.titlecase}}{{vendorExtensions.x-enum-case-name}}{{/lambda.titlecase}}( + jsonDecoder.json.decodeFromJsonElement(serializer<{{{dataType}}}>(), jsonObject) + ) +{{/vendorExtensions.x-single-field-variant}}{{/vendorExtensions.x-empty-variant}} +{{/vendorExtensions.x-enum-case-name}} +{{/vars}} + else -> throw SerializationException("Unknown discriminator '$discriminatorValue' for {{classname}}") + } + } + } +} +{{/vendorExtensions.x-interface-union}} +{{^vendorExtensions.x-interface-union}} +{{!-- ====== Type/value wrapper variant (original) ====== --}} import kotlinx.serialization.KSerializer import kotlinx.serialization.Serializable import kotlinx.serialization.SerialName @@ -128,3 +295,4 @@ sealed class {{classname}} { } } } +{{/vendorExtensions.x-interface-union}} diff --git a/Scripts/generate-api/templates/modelGeneric.mustache b/Scripts/generate-api/templates/modelGeneric.mustache index 7db55ae9..6c12476c 100644 --- a/Scripts/generate-api/templates/modelGeneric.mustache +++ b/Scripts/generate-api/templates/modelGeneric.mustache @@ -4,6 +4,7 @@ Vendor extensions used: - x-generic-params: array of {name} for type parameters - x-generic-type-ref: string indicating a property uses a generic type parameter + - x-frozen: boolean indicating the field should use JsonElement type with private val access Generates Kotlin data classes with generic type parameters and kotlinx.serialization support. --}} @@ -23,6 +24,11 @@ import io.ton.walletkit.model.TONUserFriendlyAddress @Serializable {{#nonPublicApi}}internal {{/nonPublicApi}}data class {{{classname}}}<{{#vendorExtensions.x-generic-params}}{{{name}}}{{^-last}}, {{/-last}}{{/vendorExtensions.x-generic-params}}>( {{#vars}} +{{#vendorExtensions.x-frozen}} + @SerialName("{{{baseName}}}") + private val {{{name}}}: {{{dataType}}}{{^required}}? = null{{/required}}{{^-last}},{{/-last}} +{{/vendorExtensions.x-frozen}} +{{^vendorExtensions.x-frozen}} {{#vendorExtensions.x-generic-type-ref}} @SerialName("{{{baseName}}}") val {{{name}}}: {{{vendorExtensions.x-generic-type-ref}}}{{^required}}? = null{{/required}}{{^-last}},{{/-last}} @@ -31,6 +37,7 @@ import io.ton.walletkit.model.TONUserFriendlyAddress @SerialName("{{{baseName}}}") val {{{name}}}: {{{dataType}}}{{^required}}? = null{{/required}}{{^-last}},{{/-last}} {{/vendorExtensions.x-generic-type-ref}} +{{/vendorExtensions.x-frozen}} {{/vars}} ) { companion object diff --git a/TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs b/TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs index e4be5335..b8e3e5f3 100644 --- a/TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs +++ b/TONWalletKit-Android/impl/src/main/assets/walletkit/walletkit-android-bridge.mjs @@ -35233,7 +35233,7 @@ class ApiClientTonApi extends BaseApiClient { return mapJettonMasters(raw); } async jettonsByOwnerAddress(request) { - const raw = await this.getJson(`/v2/accounts/${request.ownerAddress}/jettons`); + const raw = await this.getJson(`/v2/accounts/${request.ownerAddress}/jettons?currencies=usd`); return mapUserJettons(raw); } async nftItemsByAddress(request) {