From c6bafbe9249eb24bd1b3681771ebc34be3bbc103 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 3 Oct 2026 08:06:30 +0100 Subject: [PATCH 1/3] fix(kotlin): generate examples that compile Object and array examples were emitted without escaping, so a quote inside a value ended the string (the GraphQL mutation examples) and Kotlin interpolated "$id". Kotlin examples also named arguments with the spec's snake_case names (grant_id) instead of the camelCase names the service declares. --- src/SDK/Language/Kotlin.php | 27 ++++++++++---- templates/android/docs/kotlin/example.md.twig | 4 +-- templates/kotlin/docs/kotlin/example.md.twig | 4 +-- tests/generation/GenerationTest.php | 35 +++++++++++++++++++ tests/resources/spec-openapi3.json | 34 ++++++++++++++++++ 5 files changed, 94 insertions(+), 10 deletions(-) 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/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/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/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", From 19c0d86aef246901da727b62ab3916af93147d9c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 3 Oct 2026 08:52:32 +0100 Subject: [PATCH 2/3] fix(android): use the shaded HiveMQ client so apps need no packaging excludes The plain client pulls Netty in as separate jars, each with its own META-INF/INDEX.LIST and io.netty.versions.properties, so every app's mergeJavaResource failed until it excluded them, whether or not it used Push. The shaded artifact relocates Netty and JCTools into one jar. Move the consumer R8 rules to the relocated packages and drop the excludes from the Android and Flutter READMEs, the Android example app and the React Native config plugin, whose only job was adding them. --- src/SDK/Language/ReactNative.php | 5 -- templates/android/README.md.twig | 18 ------- .../android/example/build.gradle.kts.twig | 10 ---- .../android/library/build.gradle.kts.twig | 2 +- templates/android/push/consumer-rules.pro | 23 ++++----- templates/flutter/README.md.twig | 14 ++---- templates/flutter/android/build.gradle.twig | 2 +- templates/react-native/README.md.twig | 7 +-- .../react-native/android/build.gradle.twig | 2 +- templates/react-native/app.plugin.js.twig | 48 ------------------- templates/react-native/eslint.config.mjs | 1 - templates/react-native/package-lock.json.twig | 4 -- templates/react-native/package.json.twig | 6 --- 13 files changed, 19 insertions(+), 123 deletions(-) delete mode 100644 templates/react-native/app.plugin.js.twig 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/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/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/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 }, From 553a6fc496044d9678bab9cdc852ef9fd5ece25c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sat, 3 Oct 2026 08:52:33 +0100 Subject: [PATCH 3/3] fix(android): stop an unsubscribed callback on a shared topic HiveMQ keeps a subscribe callback on its filter until the filter is unsubscribed, and the SDK keeps the filter while another subscription uses it. A removed subscription's callback therefore kept firing. Look the subscription up by id on delivery instead of capturing it. --- .../java/io/package/services/Push.kt.twig | 18 +++++++------ tests/e2e/Android16Java17Test.php | 1 + tests/e2e/Android5Java17Test.php | 1 + tests/e2e/Base.php | 6 +++++ tests/e2e/languages/android/Tests.kt | 27 +++++++++++++++++++ 5 files changed, 45 insertions(+), 8 deletions(-) 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/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")