diff --git a/src/SDK/Language/Kotlin.php b/src/SDK/Language/Kotlin.php index 783093b0ba..0ab8c9c207 100644 --- a/src/SDK/Language/Kotlin.php +++ b/src/SDK/Language/Kotlin.php @@ -160,6 +160,21 @@ protected function getNumberLiteral(mixed $value, string $type, string $lang): s : $literal; } + /** + * A double-quoted string literal. Kotlin also interpolates `$`, so it is + * escaped there; Java takes it as-is. + */ + protected function getStringLiteral(string $value, string $lang): string + { + $escaped = \str_replace(['\\', '"', "\n"], ['\\\\', '\\"', '\\n'], $value); + + if ($lang !== 'java') { + $escaped = \str_replace('$', '\\$', $escaped); + } + + return '"' . $escaped . '"'; + } + /** * @param string $lang Language variant: 'kotlin' (default) or 'java' */ @@ -228,7 +243,7 @@ public function getParamExample(Schema|Parameter $param, string $lang = 'kotlin' $output .= ($example) ? 'true' : 'false'; break; case self::TYPE_STRING: - $output .= "\"{$example}\""; + $output .= $this->getStringLiteral((string) $example, $lang); break; } } @@ -247,9 +262,9 @@ protected function getKotlinMapExample(array $data, int $indentLevel = 0): strin $baseIndent = str_repeat(' ', $indentLevel + 2); foreach ($data as $key => $value) { - $formattedKey = '"' . $key . '"'; + $formattedKey = $this->getStringLiteral((string) $key, 'kotlin'); if (is_string($value)) { - $formattedValue = '"' . $value . '"'; + $formattedValue = $this->getStringLiteral($value, 'kotlin'); } elseif (is_bool($value)) { $formattedValue = $value ? 'true' : 'false'; } elseif (is_null($value)) { @@ -287,9 +302,9 @@ protected function getJavaMapExample(array $data, int $indentLevel = 0): string $baseIndent = str_repeat(' ', $indentLevel + 2); foreach ($data as $key => $value) { - $formattedKey = '"' . $key . '"'; + $formattedKey = $this->getStringLiteral((string) $key, 'java'); if (is_string($value)) { - $formattedValue = '"' . $value . '"'; + $formattedValue = $this->getStringLiteral($value, 'java'); } elseif (is_bool($value)) { $formattedValue = $value ? 'true' : 'false'; } elseif (is_null($value)) { @@ -341,7 +356,7 @@ protected function getArrayExample(string $example, string $lang = 'kotlin'): st } } elseif (is_string($item)) { // Primitive value - $arrayItems[] = '"' . $item . '"'; + $arrayItems[] = $this->getStringLiteral($item, $lang); } elseif (is_bool($item)) { $arrayItems[] = $item ? 'true' : 'false'; } elseif (is_null($item)) { diff --git a/src/SDK/Language/ReactNative.php b/src/SDK/Language/ReactNative.php index bbea8fa7e2..dd2eda3751 100644 --- a/src/SDK/Language/ReactNative.php +++ b/src/SDK/Language/ReactNative.php @@ -162,11 +162,6 @@ public function getFiles(): array 'destination' => 'src/lib/polyfills.ts', 'template' => 'react-native/src/lib/polyfills.ts.twig', ], - [ - 'scope' => 'copy', - 'destination' => 'app.plugin.js', - 'template' => 'react-native/app.plugin.js.twig', - ], [ 'scope' => 'default', 'destination' => 'src/models.ts', diff --git a/templates/android/README.md.twig b/templates/android/README.md.twig index 2de0085d65..b5e7f6fc75 100644 --- a/templates/android/README.md.twig +++ b/templates/android/README.md.twig @@ -76,24 +76,6 @@ data class Note(val title: String, val body: String) ### Push notifications -The `Push` service pulls in HiveMQ (which bundles Netty). Netty's jars ship duplicate -`META-INF` metadata that Android's packager rejects with a "More than one file was found -with OS independent path" error. If you use `Push`, add these excludes to the `android` block -of your **application** module's `build.gradle(.kts)` (packaging is resolved when the app is -assembled, so a library dependency cannot apply this for you): - -```kotlin -android { - packaging { - resources { - excludes += "/META-INF/INDEX.LIST" - excludes += "/META-INF/io.netty.versions.properties" - excludes += "/META-INF/DEPENDENCIES" - } - } -} -``` - #### Background delivery A subscription with `background = true` keeps delivering after the app is backgrounded, its diff --git a/templates/android/docs/kotlin/example.md.twig b/templates/android/docs/kotlin/example.md.twig index bfae8cffda..d4ce4dd81c 100644 --- a/templates/android/docs/kotlin/example.md.twig +++ b/templates/android/docs/kotlin/example.md.twig @@ -37,9 +37,9 @@ val result = {% endif %}{{ service.name | caseCamel }}.{{ (method | methodName) {%~ for parameter in (method | parameters('all')) %} {%~ if (parameter | enumValues) is not empty %} - {{ parameter.name }} = {{ (parameter | enumName) }}.{{ ((parameter | enumKeys)[0] ?? (parameter | enumValues)[0]) | caseEnumKey }},{% if not parameter.required %} // (optional){% endif %} + {{ parameter.name | caseCamel }} = {{ (parameter | enumName) }}.{{ ((parameter | enumKeys)[0] ?? (parameter | enumValues)[0]) | caseEnumKey }},{% if not parameter.required %} // (optional){% endif %} {%~ else %} - {{ parameter.name }} = {{ parameter | paramExample }}, {% if not parameter.required %}// (optional){% endif %} + {{ parameter.name | caseCamel }} = {{ parameter | paramExample }}, {% if not parameter.required %}// (optional){% endif %} {%~ endif %} {%~ endfor %} diff --git a/templates/android/example/build.gradle.kts.twig b/templates/android/example/build.gradle.kts.twig index aff53b3a59..2275d9598b 100644 --- a/templates/android/example/build.gradle.kts.twig +++ b/templates/android/example/build.gradle.kts.twig @@ -32,16 +32,6 @@ android { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } - - // The push service pulls in HiveMQ (Netty), whose jars ship duplicate META-INF - // metadata; drop the duplicates so the app can be packaged. - packaging { - resources { - excludes += "/META-INF/INDEX.LIST" - excludes += "/META-INF/io.netty.versions.properties" - excludes += "/META-INF/DEPENDENCIES" - } - } } dependencies { diff --git a/templates/android/library/build.gradle.kts.twig b/templates/android/library/build.gradle.kts.twig index 54cf5fb6e7..0a683fca20 100644 --- a/templates/android/library/build.gradle.kts.twig +++ b/templates/android/library/build.gradle.kts.twig @@ -84,7 +84,7 @@ dependencies { api("com.squareup.okhttp3:okhttp:5.5.0") implementation("com.google.code.gson:gson:2.14.0") - implementation("com.hivemq:hivemq-mqtt-client:1.3.6") + implementation("com.hivemq:hivemq-mqtt-client-shaded:1.3.6") implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.11.0") implementation("androidx.lifecycle:lifecycle-common-java8:2.11.0") diff --git a/templates/android/library/src/main/java/io/package/services/Push.kt.twig b/templates/android/library/src/main/java/io/package/services/Push.kt.twig index fe0a715fa5..02feec4bfe 100644 --- a/templates/android/library/src/main/java/io/package/services/Push.kt.twig +++ b/templates/android/library/src/main/java/io/package/services/Push.kt.twig @@ -139,7 +139,9 @@ class Push( private var connectionEpoch = 0 // Local id -> subscription (filter + callback + per-sub options). The id is never sent to - // the broker; it only lets the same topic carry more than one callback. + // the broker; it only lets the same topic carry more than one callback. HiveMQ callbacks + // look their subscription up by id on delivery, because a callback stays attached to its + // filter until the filter itself is unsubscribed, which waits for the filter's last sub. private val subscriptions = ConcurrentHashMap() // Whether the connection currently lives in the process-wide background host (its own MQTT @@ -447,7 +449,7 @@ class Push( client.subscribeWith() .topicFilter(sub.filter) .qos(effectiveQos(sub.filter)) - .callback { publish -> sub.callback(publish.toPushMessage()) } + .callback { publish -> subscriptions[id]?.callback?.invoke(publish.toPushMessage()) } .send() .get() sub.client = client @@ -478,14 +480,14 @@ class Push( // background subscription goes away and the connection relocates back in-process. private fun ensureInProcessSubscribed() { val client = connect() - for (sub in subscriptions.values) { + for ((id, sub) in subscriptions) { if (sub.client === client) { continue } client.subscribeWith() .topicFilter(sub.filter) .qos(effectiveQos(sub.filter)) - .callback { publish -> sub.callback(publish.toPushMessage()) } + .callback { publish -> subscriptions[id]?.callback?.invoke(publish.toPushMessage()) } .send() .reportFailure(client) sub.client = client @@ -498,11 +500,11 @@ class Push( val client = mqtt ?: return client.unsubscribeWith().topicFilter(filter).send().reportFailure(client) val qos = effectiveQos(filter) - for (sub in subscriptions.values.filter { it.filter == filter }) { + for ((id, sub) in subscriptions.filter { it.value.filter == filter }) { client.subscribeWith() .topicFilter(filter) .qos(qos) - .callback { publish -> sub.callback(publish.toPushMessage()) } + .callback { publish -> subscriptions[id]?.callback?.invoke(publish.toPushMessage()) } .send() .reportFailure(client) sub.client = client @@ -658,14 +660,14 @@ class Push( throw e } - for (sub in subscriptions.values) { + for ((id, sub) in subscriptions) { if (sub.client == null || sub.client === client) { continue } client.subscribeWith() .topicFilter(sub.filter) .qos(effectiveQos(sub.filter)) - .callback { publish -> sub.callback(publish.toPushMessage()) } + .callback { publish -> subscriptions[id]?.callback?.invoke(publish.toPushMessage()) } .send() .reportFailure(client) sub.client = client diff --git a/templates/android/push/consumer-rules.pro b/templates/android/push/consumer-rules.pro index bca8473572..04d7db2c19 100644 --- a/templates/android/push/consumer-rules.pro +++ b/templates/android/push/consumer-rules.pro @@ -1,13 +1,14 @@ # Push: rules applied to apps that shrink with R8. # -# The HiveMQ MQTT client bundles Netty, which references optional classes that are never loaded +# The shaded HiveMQ MQTT client relocates Netty and JCTools under +# com.hivemq.client.internal.shaded. Netty references optional classes that are never loaded # on Android: native transports, the HTTP/WebSocket codecs, proxy handlers, alternative TLS # providers (tcnative, BouncyCastle, Conscrypt, Jetty ALPN) and logging backends. --dontwarn io.netty.channel.epoll.** --dontwarn io.netty.channel.kqueue.** --dontwarn io.netty.handler.codec.http.** --dontwarn io.netty.handler.proxy.** --dontwarn io.netty.internal.tcnative.** +-dontwarn com.hivemq.client.internal.shaded.io.netty.channel.epoll.** +-dontwarn com.hivemq.client.internal.shaded.io.netty.channel.kqueue.** +-dontwarn com.hivemq.client.internal.shaded.io.netty.handler.codec.http.** +-dontwarn com.hivemq.client.internal.shaded.io.netty.handler.proxy.** +-dontwarn com.hivemq.client.internal.shaded.io.netty.internal.tcnative.** -dontwarn org.bouncycastle.** -dontwarn org.conscrypt.** -dontwarn org.eclipse.jetty.** @@ -17,10 +18,10 @@ -dontwarn reactor.blockhound.** # Netty and JCTools reach their own fields by name, through atomic field updaters and Unsafe offsets. --keepclassmembernames class io.netty.** { ; } --keepclassmembers class org.jctools.** { ; } +-keepclassmembernames class com.hivemq.client.internal.shaded.io.netty.** { ; } +-keepclassmembers class com.hivemq.client.internal.shaded.org.jctools.** { ; } # Netty's leak detector looks these methods up by name when its classes load. --keepclassmembers class io.netty.buffer.AbstractByteBufAllocator { *** toLeakAwareBuffer(...); } --keepclassmembers class io.netty.buffer.AdvancedLeakAwareByteBuf { *** recordLeakNonRefCountingOperation(...); } --keepclassmembers class io.netty.util.ReferenceCountUtil { *** touch(...); } +-keepclassmembers class com.hivemq.client.internal.shaded.io.netty.buffer.AbstractByteBufAllocator { *** toLeakAwareBuffer(...); } +-keepclassmembers class com.hivemq.client.internal.shaded.io.netty.buffer.AdvancedLeakAwareByteBuf { *** recordLeakNonRefCountingOperation(...); } +-keepclassmembers class com.hivemq.client.internal.shaded.io.netty.util.ReferenceCountUtil { *** touch(...); } diff --git a/templates/flutter/README.md.twig b/templates/flutter/README.md.twig index eb7eb16ff8..64b8eb9277 100644 --- a/templates/flutter/README.md.twig +++ b/templates/flutter/README.md.twig @@ -38,23 +38,15 @@ flutter pub add {{ language.params.packageName }} ### Push on Android -`Push` includes a native Android plugin whose MQTT client bundles Netty. Its jars ship duplicate -`META-INF` files that Android's packager rejects, and `flutter_local_notifications` needs core -library desugaring, so add this to `android/app/build.gradle.kts` (packaging is resolved when the -app is assembled, so a plugin cannot apply it for you): +`Push` includes a native Android plugin, and `flutter_local_notifications` needs core library +desugaring, so add this to `android/app/build.gradle.kts` (desugaring is configured when the app +is assembled, so a plugin cannot apply it for you): ```kotlin android { compileOptions { isCoreLibraryDesugaringEnabled = true } - packaging { - resources { - excludes += "/META-INF/INDEX.LIST" - excludes += "/META-INF/io.netty.versions.properties" - excludes += "/META-INF/DEPENDENCIES" - } - } } dependencies { diff --git a/templates/flutter/android/build.gradle.twig b/templates/flutter/android/build.gradle.twig index 5ad1bd7e14..97c0c2a050 100644 --- a/templates/flutter/android/build.gradle.twig +++ b/templates/flutter/android/build.gradle.twig @@ -51,6 +51,6 @@ android { } dependencies { - implementation("com.hivemq:hivemq-mqtt-client:1.3.6") + implementation("com.hivemq:hivemq-mqtt-client-shaded:1.3.6") implementation("androidx.core:core-ktx:1.13.1") } diff --git a/templates/kotlin/docs/kotlin/example.md.twig b/templates/kotlin/docs/kotlin/example.md.twig index af7ea9c4fd..f2d1e7c77f 100644 --- a/templates/kotlin/docs/kotlin/example.md.twig +++ b/templates/kotlin/docs/kotlin/example.md.twig @@ -33,10 +33,10 @@ val {{ service.name | caseCamel }} = {{ service.name | caseUcfirst }}(client) {% for parameter in (method | parameters('all')) %} {% if parameter.required %} - {{parameter.name}} = {% if (parameter | enumValues) | length > 0 %}{{ parameter | enumExample }}{% else%}{{ parameter | paramExample }}{% endif %}{% if not loop.last %},{% endif %} + {{ parameter.name | caseCamel }} = {% if (parameter | enumValues) | length > 0 %}{{ parameter | enumExample }}{% else%}{{ parameter | paramExample }}{% endif %}{% if not loop.last %},{% endif %} {% else %} - {{parameter.name}} = {% if (parameter | enumValues) | length > 0 %}{{ parameter | enumExample }}{% else%}{{ parameter | paramExample }}{% endif %}{% if not loop.last %},{% endif %} // optional + {{ parameter.name | caseCamel }} = {% if (parameter | enumValues) | length > 0 %}{{ parameter | enumExample }}{% else%}{{ parameter | paramExample }}{% endif %}{% if not loop.last %},{% endif %} // optional {% endif %} {% if loop.last %} ) diff --git a/templates/react-native/README.md.twig b/templates/react-native/README.md.twig index 8f945bcdc2..ade3d3be34 100644 --- a/templates/react-native/README.md.twig +++ b/templates/react-native/README.md.twig @@ -31,12 +31,7 @@ npx expo install react-native-appwrite react-native-url-polyfill ### Push `Push` connects over a raw TCP socket, so it needs `react-native-tcp-socket` and a development -or standalone build (native modules do not run in Expo Go). Add the SDK's config plugin to your -`app.json`; it adds the Android packaging excludes the push client's Netty dependency needs: - -```json -{ "expo": { "plugins": ["{{ language.params.npmPackage }}"] } } -``` +or standalone build (native modules do not run in Expo Go). On Expo SDK 52 and older, enable package exports in `metro.config.js` so `mqtt` resolves to its React Native build (Expo SDK 53 and later do this by default): diff --git a/templates/react-native/android/build.gradle.twig b/templates/react-native/android/build.gradle.twig index 02d69dffc3..b3ea1cd194 100644 --- a/templates/react-native/android/build.gradle.twig +++ b/templates/react-native/android/build.gradle.twig @@ -35,6 +35,6 @@ repositories { dependencies { implementation "com.facebook.react:react-android" - implementation "com.hivemq:hivemq-mqtt-client:1.3.6" + implementation "com.hivemq:hivemq-mqtt-client-shaded:1.3.6" implementation "androidx.core:core-ktx:1.13.1" } diff --git a/templates/react-native/app.plugin.js.twig b/templates/react-native/app.plugin.js.twig deleted file mode 100644 index f4c693ced5..0000000000 --- a/templates/react-native/app.plugin.js.twig +++ /dev/null @@ -1,48 +0,0 @@ -const { - createRunOncePlugin, - withAppBuildGradle, -} = require('@expo/config-plugins'); - -const pkg = require('./package.json'); - -// Push's native Android module (android/) uses HiveMQ, whose bundled Netty jars ship duplicate -// META-INF files that Android's packager rejects ("More than one file was found with OS -// independent path"). Packaging is resolved when the app is assembled, so a library cannot -// apply the excludes itself: this plugin adds them to the app's build.gradle. -const EXCLUDES = [ - '/META-INF/INDEX.LIST', - '/META-INF/io.netty.versions.properties', - '/META-INF/DEPENDENCIES', -]; - -const MARKER = - '// Push: Netty packaging excludes (added by the SDK config plugin)'; - -function addPackagingExcludes(buildGradle) { - if (buildGradle.includes(MARKER)) { - return buildGradle; - } - const excludes = EXCLUDES.map( - (path) => ` excludes += "${path}"`, - ).join('\n'); - return `${buildGradle} -${MARKER} -android { - packagingOptions { - resources { -${excludes} - } - } -} -`; -} - -const withPush = (config) => - withAppBuildGradle(config, (props) => { - props.modResults.contents = addPackagingExcludes( - props.modResults.contents, - ); - return props; - }); - -module.exports = createRunOncePlugin(withPush, pkg.name, pkg.version); diff --git a/templates/react-native/eslint.config.mjs b/templates/react-native/eslint.config.mjs index 3363cbb5c6..206377bfd8 100644 --- a/templates/react-native/eslint.config.mjs +++ b/templates/react-native/eslint.config.mjs @@ -10,7 +10,6 @@ export default tseslint.config( 'types/', 'docs/', 'rollup.config.mjs', - 'app.plugin.js', 'react-native.config.js', ], }, diff --git a/templates/react-native/package-lock.json.twig b/templates/react-native/package-lock.json.twig index 18a300b1d4..673b356915 100644 --- a/templates/react-native/package-lock.json.twig +++ b/templates/react-native/package-lock.json.twig @@ -35,7 +35,6 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@expo/config-plugins": "*", "expo": "*", "expo-notifications": "*", "react-native": ">=0.76.7 <1.0.0", @@ -43,9 +42,6 @@ "react-native-tcp-socket": ">=6.2.0 <7.0.0" }, "peerDependenciesMeta": { - "@expo/config-plugins": { - "optional": true - }, "expo-notifications": { "optional": true }, diff --git a/templates/react-native/package.json.twig b/templates/react-native/package.json.twig index 0061df53e5..1d80ffcf15 100644 --- a/templates/react-native/package.json.twig +++ b/templates/react-native/package.json.twig @@ -11,7 +11,6 @@ "require": "./dist/cjs/sdk.js", "types": "./types/index.d.ts" }, - "./app.plugin.js": "./app.plugin.js", "./package.json": "./package.json" }, "module": "dist/esm/sdk.js", @@ -19,7 +18,6 @@ "files": [ "android", "!android/build", - "app.plugin.js", "dist", "ios", "*.podspec", @@ -67,7 +65,6 @@ "readable-stream": "4.7.0" }, "peerDependencies": { - "@expo/config-plugins": "*", "expo": "*", "expo-notifications": "*", "react-native": ">=0.76.7 <1.0.0", @@ -75,9 +72,6 @@ "react-native-tcp-socket": ">=6.2.0 <7.0.0" }, "peerDependenciesMeta": { - "@expo/config-plugins": { - "optional": true - }, "expo-notifications": { "optional": true }, diff --git a/tests/e2e/Android16Java17Test.php b/tests/e2e/Android16Java17Test.php index 67ab66f50e..bc9cbe5535 100644 --- a/tests/e2e/Android16Java17Test.php +++ b/tests/e2e/Android16Java17Test.php @@ -65,6 +65,7 @@ final class Android16Java17Test extends Base ...Base::PUSH_CREDENTIAL_SWITCH_RESPONSES, ...Base::PUSH_BACKGROUND_RESPONSES, ...Base::ANDROID_PUSH_OPT_OUT_RESPONSES, + ...Base::ANDROID_PUSH_SHARED_TOPIC_RESPONSES, ...Base::ANDROID_COOKIE_STORE_RESPONSES, ]; } diff --git a/tests/e2e/Android5Java17Test.php b/tests/e2e/Android5Java17Test.php index 8a25d65c11..bac656332a 100644 --- a/tests/e2e/Android5Java17Test.php +++ b/tests/e2e/Android5Java17Test.php @@ -62,6 +62,7 @@ final class Android5Java17Test extends Base ...Base::PUSH_CREDENTIAL_SWITCH_RESPONSES, ...Base::PUSH_BACKGROUND_RESPONSES, ...Base::ANDROID_PUSH_OPT_OUT_RESPONSES, + ...Base::ANDROID_PUSH_SHARED_TOPIC_RESPONSES, ...Base::ANDROID_COOKIE_STORE_RESPONSES, ]; } diff --git a/tests/e2e/Base.php b/tests/e2e/Base.php index 8bd82297fe..63360202a1 100644 --- a/tests/e2e/Base.php +++ b/tests/e2e/Base.php @@ -244,6 +244,12 @@ abstract class Base extends TestCase 'Push background opt-out:passed', ]; + // Android: unsubscribing one of two in-process subscriptions on the same topic stops its + // callback, though the other subscription keeps the broker filter. + protected const ANDROID_PUSH_SHARED_TOPIC_RESPONSES = [ + 'Push shared topic unsubscribe:passed', + ]; + protected const ANDROID_COOKIE_STORE_RESPONSES = [ 'Cookie store reload:passed', 'Cookie store saved format:passed', diff --git a/tests/e2e/languages/android/Tests.kt b/tests/e2e/languages/android/Tests.kt index 4556ef7d5c..837cc21a29 100644 --- a/tests/e2e/languages/android/Tests.kt +++ b/tests/e2e/languages/android/Tests.kt @@ -833,6 +833,33 @@ class ServiceTest { }, ) reopened.close() + + // Two in-process subscriptions on one topic share the broker filter, so unsubscribing + // one keeps the filter. Its callback must still stop: every SUBSCRIBE makes the mock + // publish to e2e-push, and only the remaining subscription may receive the next one. + val sharedPush = Push(pushClient().setSession(e2eSession), context) + val removedReceived = java.util.concurrent.atomic.AtomicInteger(0) + val keptLatch = java.util.concurrent.atomic.AtomicReference(java.util.concurrent.CountDownLatch(1)) + val removedSub = sharedPush.subscribe("e2e-push", background = false) { removedReceived.incrementAndGet() } + val keptSub = sharedPush.subscribe("e2e-push", background = false) { keptLatch.get().countDown() } + val bothReceived = keptLatch.get().await(10, java.util.concurrent.TimeUnit.SECONDS) && removedReceived.get() > 0 + removedSub.unsubscribe() + Thread.sleep(500) + removedReceived.set(0) + keptLatch.set(java.util.concurrent.CountDownLatch(1)) + val triggerSub = sharedPush.subscribe("e2e-trigger", background = false) { } + val keptReceived = keptLatch.get().await(10, java.util.concurrent.TimeUnit.SECONDS) + Thread.sleep(500) + writeToFile( + if (bothReceived && keptReceived && removedReceived.get() == 0) { + "Push shared topic unsubscribe:passed" + } else { + "Push shared topic unsubscribe:failed" + }, + ) + triggerSub.unsubscribe() + keptSub.unsubscribe() + sharedPush.close() } val cookieUri = java.net.URI("https://cloud.appwrite.io/v1") diff --git a/tests/generation/GenerationTest.php b/tests/generation/GenerationTest.php index 6003d2af70..8330538e8d 100644 --- a/tests/generation/GenerationTest.php +++ b/tests/generation/GenerationTest.php @@ -375,6 +375,41 @@ public static function swiftLanguages(): Iterator yield 'apple' => ['apple', 'client']; } + /** + * Kotlin and Java examples must compile as written: quotes inside example + * strings stay escaped, Kotlin's `$` is not left to interpolate, and named + * arguments use the parameter names the service method declares. + */ + #[DataProvider('kotlinLanguages')] + public function testExamplesUseKotlinAndJavaLiterals(string $name, string $platform): void + { + $files = $this->generate($name, $platform); + + $kotlin = 'docs/examples/kotlin/general/create-documents.md'; + $this->assertArrayHasKey($kotlin, $files, "{$name}/{$platform} did not generate {$kotlin}"); + $this->assertStringContainsString('"\\$id" to "one"', $files[$kotlin]); + $this->assertStringContainsString('"title" to "say \\"hello\\""', $files[$kotlin]); + + $java = 'docs/examples/java/general/create-documents.md'; + $this->assertArrayHasKey($java, $files, "{$name}/{$platform} did not generate {$java}"); + $this->assertStringContainsString('"$id", "one"', $files[$java]); + $this->assertStringContainsString('"title", "say \\"hello\\""', $files[$java]); + + $named = 'docs/examples/kotlin/general/snake-case-params.md'; + $this->assertArrayHasKey($named, $files, "{$name}/{$platform} did not generate {$named}"); + $this->assertStringContainsString('grantid = ""', $files[$named]); + $this->assertStringNotContainsString('grant_id =', $files[$named]); + } + + /** + * @return Iterator + */ + public static function kotlinLanguages(): Iterator + { + yield 'kotlin' => ['kotlin', 'server']; + yield 'android' => ['android', 'client']; + } + public function testRustBodylessResponsesUseUnitWithJsonAccept(): void { $files = $this->generate('rust', 'server'); diff --git a/tests/resources/spec-openapi3.json b/tests/resources/spec-openapi3.json index 7f890fb61b..9456456b3b 100644 --- a/tests/resources/spec-openapi3.json +++ b/tests/resources/spec-openapi3.json @@ -2531,6 +2531,40 @@ } } }, + "\/mock\/tests\/general\/snake-case-params": { + "get": { + "summary": "Snake Case Params", + "operationId": "generalSnakeCaseParams", + "tags": [ + "general" + ], + "description": "Fixture whose parameter names are snake_case in the spec.", + "responses": { + "200": { + "description": "Mock", + "content": { + "application\/json": { + "schema": { + "$ref": "#\/components\/schemas\/mock" + } + } + } + } + }, + "parameters": [ + { + "name": "grant_id", + "description": "Grant ID.", + "required": true, + "in": "query", + "schema": { + "type": "string", + "example": "" + } + } + ] + } + }, "\/mock\/tests\/general\/oauth2": { "get": { "summary": "OAuth2",