From 70dd53f67675f38a77b969c6fb3082fb771bd9fe Mon Sep 17 00:00:00 2001 From: root Date: Mon, 20 Apr 2026 08:20:45 +0000 Subject: [PATCH 1/6] chore: update Android SDK to 23.1.0 --- README.md | 4 ++-- library/src/main/java/io/appwrite/Client.kt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index a2f1e3ab..fc007695 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ ![Maven Central](https://img.shields.io/maven-central/v/io.appwrite/sdk-for-android.svg?color=green&style=flat-square) ![License](https://img.shields.io/github/license/appwrite/sdk-for-android.svg?style=flat-square) -![Version](https://img.shields.io/badge/api%20version-1.9.1-blue.svg?style=flat-square) +![Version](https://img.shields.io/badge/api%20version-1.9.0-blue.svg?style=flat-square) [![Build Status](https://img.shields.io/travis/com/appwrite/sdk-generator?style=flat-square)](https://travis-ci.com/appwrite/sdk-generator) [![Twitter Account](https://img.shields.io/twitter/follow/appwrite?color=00acee&label=twitter&style=flat-square)](https://twitter.com/appwrite) [![Discord](https://img.shields.io/discord/564160730845151244?label=discord&style=flat-square)](https://appwrite.io/discord) -**This SDK is compatible with Appwrite server version 1.9.x. For older versions, please check [previous releases](https://github.com/appwrite/sdk-for-android/releases).** +**This SDK is compatible with Appwrite server version latest. For older versions, please check [previous releases](https://github.com/appwrite/sdk-for-android/releases).** Appwrite is an open-source backend as a service server that abstracts and simplifies complex and repetitive development tasks behind a very simple to use REST API. Appwrite aims to help you develop your apps faster and in a more secure way. Use the Android SDK to integrate your app with the Appwrite server to easily start interacting with all of Appwrite backend APIs and tools. For full API documentation and tutorials go to [https://appwrite.io/docs](https://appwrite.io/docs) diff --git a/library/src/main/java/io/appwrite/Client.kt b/library/src/main/java/io/appwrite/Client.kt index 4eb6a51c..656b4199 100644 --- a/library/src/main/java/io/appwrite/Client.kt +++ b/library/src/main/java/io/appwrite/Client.kt @@ -88,7 +88,7 @@ class Client @JvmOverloads constructor( "x-sdk-platform" to "client", "x-sdk-language" to "android", "x-sdk-version" to "23.1.0", - "x-appwrite-response-format" to "1.9.1" + "x-appwrite-response-format" to "1.9.0" ) config = mutableMapOf() From 603c9a487c4d2259cb3d66bc997a444840c408a8 Mon Sep 17 00:00:00 2001 From: root Date: Mon, 20 Apr 2026 08:32:28 +0000 Subject: [PATCH 2/6] chore: update Android SDK to 23.2.0 --- CHANGELOG.md | 6 + README.md | 6 +- library/src/main/java/io/appwrite/Client.kt | 4 +- .../main/java/io/appwrite/services/Account.kt | 2 +- .../main/java/io/appwrite/services/Avatars.kt | 2 +- .../java/io/appwrite/services/Databases.kt | 2 +- .../java/io/appwrite/services/Functions.kt | 2 +- .../main/java/io/appwrite/services/Graphql.kt | 2 +- .../main/java/io/appwrite/services/Locale.kt | 2 +- .../java/io/appwrite/services/Messaging.kt | 2 +- .../java/io/appwrite/services/Realtime.kt | 157 +++++++++++------- .../main/java/io/appwrite/services/Storage.kt | 2 +- .../java/io/appwrite/services/TablesDB.kt | 2 +- .../main/java/io/appwrite/services/Teams.kt | 2 +- 14 files changed, 118 insertions(+), 75 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e6f6f47..29fbdb96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Change Log +## 23.2.0 + +* Added: `Realtime` used explicit `subscribe` messages for channel and query updates +* Fixed: `Realtime` reconnect logic preserved subscription ID mapping +* Updated: README compatibility note now targets `latest` server version + ## 23.1.0 * Added `x` OAuth provider to `OAuthProvider` enum diff --git a/README.md b/README.md index fc007695..0635aef4 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ ![Maven Central](https://img.shields.io/maven-central/v/io.appwrite/sdk-for-android.svg?color=green&style=flat-square) ![License](https://img.shields.io/github/license/appwrite/sdk-for-android.svg?style=flat-square) -![Version](https://img.shields.io/badge/api%20version-1.9.0-blue.svg?style=flat-square) +![Version](https://img.shields.io/badge/api%20version-1.9.1-blue.svg?style=flat-square) [![Build Status](https://img.shields.io/travis/com/appwrite/sdk-generator?style=flat-square)](https://travis-ci.com/appwrite/sdk-generator) [![Twitter Account](https://img.shields.io/twitter/follow/appwrite?color=00acee&label=twitter&style=flat-square)](https://twitter.com/appwrite) [![Discord](https://img.shields.io/discord/564160730845151244?label=discord&style=flat-square)](https://appwrite.io/discord) @@ -38,7 +38,7 @@ repositories { Next, add the dependency to your project's `build.gradle(.kts)` file: ```groovy -implementation("io.appwrite:sdk-for-android:23.1.0") +implementation("io.appwrite:sdk-for-android:23.2.0") ``` ### Maven @@ -49,7 +49,7 @@ Add this to your project's `pom.xml` file: io.appwrite sdk-for-android - 23.1.0 + 23.2.0 ``` diff --git a/library/src/main/java/io/appwrite/Client.kt b/library/src/main/java/io/appwrite/Client.kt index 656b4199..dc56a419 100644 --- a/library/src/main/java/io/appwrite/Client.kt +++ b/library/src/main/java/io/appwrite/Client.kt @@ -87,8 +87,8 @@ class Client @JvmOverloads constructor( "x-sdk-name" to "Android", "x-sdk-platform" to "client", "x-sdk-language" to "android", - "x-sdk-version" to "23.1.0", - "x-appwrite-response-format" to "1.9.0" + "x-sdk-version" to "23.2.0", + "x-appwrite-response-format" to "1.9.1" ) config = mutableMapOf() diff --git a/library/src/main/java/io/appwrite/services/Account.kt b/library/src/main/java/io/appwrite/services/Account.kt index a288fc10..bb9bb4fd 100644 --- a/library/src/main/java/io/appwrite/services/Account.kt +++ b/library/src/main/java/io/appwrite/services/Account.kt @@ -2242,4 +2242,4 @@ class Account(client: Client) : Service(client) { } -} \ No newline at end of file +} diff --git a/library/src/main/java/io/appwrite/services/Avatars.kt b/library/src/main/java/io/appwrite/services/Avatars.kt index 7694c0d1..004440f3 100644 --- a/library/src/main/java/io/appwrite/services/Avatars.kt +++ b/library/src/main/java/io/appwrite/services/Avatars.kt @@ -347,4 +347,4 @@ class Avatars(client: Client) : Service(client) { } -} \ No newline at end of file +} diff --git a/library/src/main/java/io/appwrite/services/Databases.kt b/library/src/main/java/io/appwrite/services/Databases.kt index 2b1b3d18..2fc95889 100644 --- a/library/src/main/java/io/appwrite/services/Databases.kt +++ b/library/src/main/java/io/appwrite/services/Databases.kt @@ -859,4 +859,4 @@ class Databases(client: Client) : Service(client) { nestedType = classOf(), ) -} \ No newline at end of file +} diff --git a/library/src/main/java/io/appwrite/services/Functions.kt b/library/src/main/java/io/appwrite/services/Functions.kt index 279d3127..12a5fee5 100644 --- a/library/src/main/java/io/appwrite/services/Functions.kt +++ b/library/src/main/java/io/appwrite/services/Functions.kt @@ -137,4 +137,4 @@ class Functions(client: Client) : Service(client) { } -} \ No newline at end of file +} diff --git a/library/src/main/java/io/appwrite/services/Graphql.kt b/library/src/main/java/io/appwrite/services/Graphql.kt index c168f5fd..1c714748 100644 --- a/library/src/main/java/io/appwrite/services/Graphql.kt +++ b/library/src/main/java/io/appwrite/services/Graphql.kt @@ -78,4 +78,4 @@ class Graphql(client: Client) : Service(client) { } -} \ No newline at end of file +} diff --git a/library/src/main/java/io/appwrite/services/Locale.kt b/library/src/main/java/io/appwrite/services/Locale.kt index 95e7d96b..d6acb84d 100644 --- a/library/src/main/java/io/appwrite/services/Locale.kt +++ b/library/src/main/java/io/appwrite/services/Locale.kt @@ -240,4 +240,4 @@ class Locale(client: Client) : Service(client) { } -} \ No newline at end of file +} diff --git a/library/src/main/java/io/appwrite/services/Messaging.kt b/library/src/main/java/io/appwrite/services/Messaging.kt index 82213f36..ee2444d9 100644 --- a/library/src/main/java/io/appwrite/services/Messaging.kt +++ b/library/src/main/java/io/appwrite/services/Messaging.kt @@ -82,4 +82,4 @@ class Messaging(client: Client) : Service(client) { } -} \ No newline at end of file +} diff --git a/library/src/main/java/io/appwrite/services/Realtime.kt b/library/src/main/java/io/appwrite/services/Realtime.kt index 8f88864f..cca242f7 100644 --- a/library/src/main/java/io/appwrite/services/Realtime.kt +++ b/library/src/main/java/io/appwrite/services/Realtime.kt @@ -8,6 +8,7 @@ import io.appwrite.exceptions.AppwriteException import io.appwrite.extensions.forEachAsync import io.appwrite.extensions.fromJson import io.appwrite.extensions.jsonCast +import io.appwrite.extensions.toJson import io.appwrite.models.* import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers.IO @@ -35,6 +36,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private const val TYPE_ERROR = "error" private const val TYPE_EVENT = "event" private const val TYPE_PONG = "pong" + private const val TYPE_RESPONSE = "response" private const val HEARTBEAT_INTERVAL = 20_000L // 20 seconds private var socket: RealWebSocket? = null @@ -44,6 +46,8 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private val slotToSubscriptionId = ConcurrentHashMap() // Inverse map: subscriptionId -> slot index (for O(1) lookup) private val subscriptionIdToSlot = ConcurrentHashMap() + // Queue of slot snapshots per in-flight subscribe message (FIFO with server responses). + private val pendingSubscribeSlotsQueue = ArrayDeque>() private var reconnectAttempts = 0 private val subscriptionsCounter = AtomicInteger(0) @@ -61,51 +65,14 @@ class Realtime(client: Client) : Service(client), CoroutineScope { val request: Request val newSocket: RealWebSocket? synchronized(subscriptionLock) { - // Rebuild activeChannels from all slots - val allChannels = mutableSetOf() - activeSubscriptions.values.forEach { subscription -> - allChannels.addAll(subscription.channels) - } - - if (allChannels.isEmpty()) { + if (activeSubscriptions.isEmpty()) { reconnect = false closeSocket() return } val encodedProject = java.net.URLEncoder.encode(client.config["project"].toString(), "UTF-8") - var queryParams = "project=$encodedProject" - - allChannels.forEach { channel -> - val encodedChannel = java.net.URLEncoder.encode(channel, "UTF-8") - queryParams += "&channels[]=$encodedChannel" - } - - // Build query string from slots → channels → queries - // Format: channel[slot][]=query (each query sent as separate parameter) - // For each slot, repeat its queries under each channel it subscribes to - // Example: slot 1 → channels [tests, prod], queries [q1, q2] - // Produces: tests[1][]=q1&tests[1][]=q2&prod[1][]=q1&prod[1][]=q2 - val selectAllQuery = Query.select(listOf("*")).toString() - activeSubscriptions.forEach { (slot, subscription) -> - // Get queries array - each query is a separate string - val queries = if (subscription.queries.isEmpty()) { - listOf(selectAllQuery) - } else { - subscription.queries.toList() - } - - // Repeat this slot's queries under each channel it subscribes to - // Each query is sent as a separate parameter: channel[slot][]=q1&channel[slot][]=q2 - subscription.channels.forEach { channel -> - val encodedChannel = java.net.URLEncoder.encode(channel, "UTF-8") - queries.forEach { query -> - val encodedQuery = java.net.URLEncoder.encode(query, "UTF-8") - queryParams += "&$encodedChannel[$slot][]=$encodedQuery" - } - } - } - + val queryParams = "project=$encodedProject" val url = "${client.endpointRealtime}/realtime?$queryParams" request = Request.Builder().url(url).build() @@ -137,6 +104,38 @@ class Realtime(client: Client) : Service(client), CoroutineScope { socket?.close(RealtimeCode.POLICY_VIOLATION.value, null) } + private fun sendSubscribeMessage() { + val ws = socket ?: return + val rows = mutableListOf>() + val slotsForMessage = mutableListOf() + + synchronized(subscriptionLock) { + activeSubscriptions.forEach { (slot, subscription) -> + val queries = subscription.queries + val row = mutableMapOf( + "channels" to subscription.channels.toList(), + "queries" to queries.toList() + ) + val knownSubscriptionId = slotToSubscriptionId[slot] + if (!knownSubscriptionId.isNullOrEmpty()) { + row["subscriptionId"] = knownSubscriptionId + } + rows.add(row) + slotsForMessage.add(slot) + } + } + + if (rows.isEmpty()) { + return + } + + synchronized(subscriptionLock) { + pendingSubscribeSlotsQueue.addLast(slotsForMessage.toList()) + } + + ws.send(mapOf("type" to "subscribe", "data" to rows).toJson()) + } + private fun startHeartbeat() { stopHeartbeat() heartbeatJob = launch { @@ -223,8 +222,14 @@ class Realtime(client: Client) : Service(client), CoroutineScope { callback as (RealtimeResponseEvent<*>) -> Unit ) } - // Sequential: rebuild socket immediately for A -> A+B -> A+B+C. - createSocket() + // Reuse active socket when possible; otherwise create it. + synchronized(subscriptionLock) { + if (socket != null) { + sendSubscribeMessage() + } else { + createSocket() + } + } return RealtimeSubscription { // Unsubscribe must update all three maps atomically so that @@ -235,8 +240,16 @@ class Realtime(client: Client) : Service(client), CoroutineScope { slotToSubscriptionId.remove(slot) subscriptionId?.let { subscriptionIdToSlot.remove(it) } } - // Sequential: rebuild socket immediately after unsubscribe. - this@Realtime.createSocket() + synchronized(subscriptionLock) { + if (activeSubscriptions.isEmpty()) { + reconnect = false + closeSocket() + } else if (socket != null) { + sendSubscribeMessage() + } else { + this@Realtime.createSocket() + } + } } } @@ -268,6 +281,7 @@ class Realtime(client: Client) : Service(client), CoroutineScope { when (message.type) { TYPE_ERROR -> handleResponseError(message) TYPE_CONNECTED -> handleResponseConnected(message) + TYPE_RESPONSE -> handleResponseAction(message) TYPE_EVENT -> handleResponseEvent(message) TYPE_PONG -> {} } @@ -276,26 +290,49 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private fun handleResponseConnected(message: RealtimeResponse) { val messageData = message.data?.jsonCast>() ?: return - val subscriptions = messageData["subscriptions"] as? Map<*, *> ?: return - - // Store subscription ID mappings from backend - // Format: { "0": "sub_a1f9", "1": "sub_b83c", ... } - synchronized(subscriptionLock) { - val newSlotToSub = mutableMapOf() - val newSubToSlot = mutableMapOf() - - subscriptions.forEach { (slotStr, subscriptionId) -> - val slot = (slotStr as? String)?.toIntOrNull() - if (slot != null && subscriptionId is String) { - newSlotToSub[slot] = subscriptionId - newSubToSlot[subscriptionId] = slot + val subscriptions = messageData["subscriptions"] as? Map<*, *> + + if (subscriptions != null) { + // Store subscription ID mappings from backend. + // Try direct slot first, then slot+1 for zero-based server maps. + synchronized(subscriptionLock) { + subscriptions.forEach { (slotStr, subscriptionId) -> + val slot = (slotStr as? String)?.toIntOrNull() + if (slot != null && subscriptionId is String) { + val targetSlot = when { + activeSubscriptions.containsKey(slot) -> slot + activeSubscriptions.containsKey(slot + 1) -> slot + 1 + else -> slot + } + slotToSubscriptionId[targetSlot] = subscriptionId + subscriptionIdToSlot[subscriptionId] = targetSlot + } } } + } - slotToSubscriptionId.clear() - slotToSubscriptionId.putAll(newSlotToSub) - subscriptionIdToSlot.clear() - subscriptionIdToSlot.putAll(newSubToSlot) + sendSubscribeMessage() + } + + private fun handleResponseAction(message: RealtimeResponse) { + val actionData = message.data?.jsonCast>() ?: return + if (actionData["to"] != "subscribe") { + return + } + val subscriptions = actionData["subscriptions"] as? List<*> ?: return + + synchronized(subscriptionLock) { + if (pendingSubscribeSlotsQueue.isEmpty()) { + return + } + val pendingSlots = pendingSubscribeSlotsQueue.removeFirst() + subscriptions.forEachIndexed { index, item -> + val slot = pendingSlots.getOrNull(index) ?: return@forEachIndexed + val row = item as? Map<*, *> ?: return@forEachIndexed + val subscriptionId = row["subscriptionId"] as? String ?: return@forEachIndexed + slotToSubscriptionId[slot] = subscriptionId + subscriptionIdToSlot[subscriptionId] = slot + } } } diff --git a/library/src/main/java/io/appwrite/services/Storage.kt b/library/src/main/java/io/appwrite/services/Storage.kt index 6a8e9737..8276d082 100644 --- a/library/src/main/java/io/appwrite/services/Storage.kt +++ b/library/src/main/java/io/appwrite/services/Storage.kt @@ -343,4 +343,4 @@ class Storage(client: Client) : Service(client) { } -} \ No newline at end of file +} diff --git a/library/src/main/java/io/appwrite/services/TablesDB.kt b/library/src/main/java/io/appwrite/services/TablesDB.kt index d662045e..9c5a955e 100644 --- a/library/src/main/java/io/appwrite/services/TablesDB.kt +++ b/library/src/main/java/io/appwrite/services/TablesDB.kt @@ -799,4 +799,4 @@ class TablesDB(client: Client) : Service(client) { nestedType = classOf(), ) -} \ No newline at end of file +} diff --git a/library/src/main/java/io/appwrite/services/Teams.kt b/library/src/main/java/io/appwrite/services/Teams.kt index 56fc955b..78019a1d 100644 --- a/library/src/main/java/io/appwrite/services/Teams.kt +++ b/library/src/main/java/io/appwrite/services/Teams.kt @@ -600,4 +600,4 @@ class Teams(client: Client) : Service(client) { nestedType = classOf(), ) -} \ No newline at end of file +} From 86df348d8f31abb1fecdb223eacf6dade372ba8f Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 14:07:21 +0530 Subject: [PATCH 3/6] chore: sync generated SDK for latest Made-with: Cursor --- .github/workflows/autoclose.yml | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 .github/workflows/autoclose.yml diff --git a/.github/workflows/autoclose.yml b/.github/workflows/autoclose.yml deleted file mode 100644 index 3e2b3cbc..00000000 --- a/.github/workflows/autoclose.yml +++ /dev/null @@ -1,11 +0,0 @@ -name: Auto-close External Pull Requests - -on: - pull_request_target: - types: [opened, reopened] - -jobs: - auto_close: - uses: appwrite/.github/.github/workflows/autoclose.yml@main - secrets: - GH_AUTO_CLOSE_PR_TOKEN: ${{ secrets.GH_AUTO_CLOSE_PR_TOKEN }} From c73ed48e6dcb370ab674677b9c3288d983724c03 Mon Sep 17 00:00:00 2001 From: ArnabChatterjee20k Date: Mon, 20 Apr 2026 14:07:55 +0530 Subject: [PATCH 4/6] chore: restore .github metadata Made-with: Cursor --- .github/workflows/autoclose.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/workflows/autoclose.yml diff --git a/.github/workflows/autoclose.yml b/.github/workflows/autoclose.yml new file mode 100644 index 00000000..3e2b3cbc --- /dev/null +++ b/.github/workflows/autoclose.yml @@ -0,0 +1,11 @@ +name: Auto-close External Pull Requests + +on: + pull_request_target: + types: [opened, reopened] + +jobs: + auto_close: + uses: appwrite/.github/.github/workflows/autoclose.yml@main + secrets: + GH_AUTO_CLOSE_PR_TOKEN: ${{ secrets.GH_AUTO_CLOSE_PR_TOKEN }} From 1cdf9bf6f53583f725a42c7e412db2a120531e4f Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Apr 2026 04:40:06 +0000 Subject: [PATCH 5/6] chore: update Android SDK to 24.0.0 --- CHANGELOG.md | 8 +- README.md | 8 +- library/src/main/java/io/appwrite/Client.kt | 4 +- .../java/io/appwrite/models/Membership.kt | 8 + .../java/io/appwrite/models/RealtimeModels.kt | 20 ++ .../java/io/appwrite/services/Realtime.kt | 217 +++++++++--------- 6 files changed, 147 insertions(+), 118 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 29fbdb96..4f120643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,10 +1,10 @@ # Change Log -## 23.2.0 +## 24.0.0 -* Added: `Realtime` used explicit `subscribe` messages for channel and query updates -* Fixed: `Realtime` reconnect logic preserved subscription ID mapping -* Updated: README compatibility note now targets `latest` server version +* Breaking: Added `unsubscribe()`, `update()`, and `close()` for Realtime subscription lifecycle. +* Added: Added `userPhone` to the `Membership` model. +* Updated: Updated `X-Appwrite-Response-Format` header to `1.9.2`. ## 23.1.0 diff --git a/README.md b/README.md index 0635aef4..1e2528ba 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,12 @@ ![Maven Central](https://img.shields.io/maven-central/v/io.appwrite/sdk-for-android.svg?color=green&style=flat-square) ![License](https://img.shields.io/github/license/appwrite/sdk-for-android.svg?style=flat-square) -![Version](https://img.shields.io/badge/api%20version-1.9.1-blue.svg?style=flat-square) +![Version](https://img.shields.io/badge/api%20version-1.9.2-blue.svg?style=flat-square) [![Build Status](https://img.shields.io/travis/com/appwrite/sdk-generator?style=flat-square)](https://travis-ci.com/appwrite/sdk-generator) [![Twitter Account](https://img.shields.io/twitter/follow/appwrite?color=00acee&label=twitter&style=flat-square)](https://twitter.com/appwrite) [![Discord](https://img.shields.io/discord/564160730845151244?label=discord&style=flat-square)](https://appwrite.io/discord) -**This SDK is compatible with Appwrite server version latest. For older versions, please check [previous releases](https://github.com/appwrite/sdk-for-android/releases).** +**This SDK is compatible with Appwrite server version 1.9.x. For older versions, please check [previous releases](https://github.com/appwrite/sdk-for-android/releases).** Appwrite is an open-source backend as a service server that abstracts and simplifies complex and repetitive development tasks behind a very simple to use REST API. Appwrite aims to help you develop your apps faster and in a more secure way. Use the Android SDK to integrate your app with the Appwrite server to easily start interacting with all of Appwrite backend APIs and tools. For full API documentation and tutorials go to [https://appwrite.io/docs](https://appwrite.io/docs) @@ -38,7 +38,7 @@ repositories { Next, add the dependency to your project's `build.gradle(.kts)` file: ```groovy -implementation("io.appwrite:sdk-for-android:23.2.0") +implementation("io.appwrite:sdk-for-android:24.0.0") ``` ### Maven @@ -49,7 +49,7 @@ Add this to your project's `pom.xml` file: io.appwrite sdk-for-android - 23.2.0 + 24.0.0 ``` diff --git a/library/src/main/java/io/appwrite/Client.kt b/library/src/main/java/io/appwrite/Client.kt index dc56a419..ccd93894 100644 --- a/library/src/main/java/io/appwrite/Client.kt +++ b/library/src/main/java/io/appwrite/Client.kt @@ -87,8 +87,8 @@ class Client @JvmOverloads constructor( "x-sdk-name" to "Android", "x-sdk-platform" to "client", "x-sdk-language" to "android", - "x-sdk-version" to "23.2.0", - "x-appwrite-response-format" to "1.9.1" + "x-sdk-version" to "24.0.0", + "x-appwrite-response-format" to "1.9.2" ) config = mutableMapOf() diff --git a/library/src/main/java/io/appwrite/models/Membership.kt b/library/src/main/java/io/appwrite/models/Membership.kt index 4a899eec..eb13be3b 100644 --- a/library/src/main/java/io/appwrite/models/Membership.kt +++ b/library/src/main/java/io/appwrite/models/Membership.kt @@ -43,6 +43,12 @@ data class Membership( @SerializedName("userEmail") val userEmail: String, + /** + * User phone number. Hide this attribute by toggling membership privacy in the Console. + */ + @SerializedName("userPhone") + val userPhone: String, + /** * Team ID. */ @@ -93,6 +99,7 @@ data class Membership( "userId" to userId as Any, "userName" to userName as Any, "userEmail" to userEmail as Any, + "userPhone" to userPhone as Any, "teamId" to teamId as Any, "teamName" to teamName as Any, "invited" to invited as Any, @@ -114,6 +121,7 @@ data class Membership( userId = map["userId"] as String, userName = map["userName"] as String, userEmail = map["userEmail"] as String, + userPhone = map["userPhone"] as String, teamId = map["teamId"] as String, teamName = map["teamName"] as String, invited = map["invited"] as String, diff --git a/library/src/main/java/io/appwrite/models/RealtimeModels.kt b/library/src/main/java/io/appwrite/models/RealtimeModels.kt index 93131f0c..23caa5ae 100644 --- a/library/src/main/java/io/appwrite/models/RealtimeModels.kt +++ b/library/src/main/java/io/appwrite/models/RealtimeModels.kt @@ -3,9 +3,29 @@ package io.appwrite.models import kotlin.collections.Collection import java.io.Closeable +data class RealtimeSubscriptionUpdate( + val channels: Collection? = null, + val queries: Collection? = null +) + data class RealtimeSubscription( + /** + * Remove this subscription only. The WebSocket stays open so other subscriptions keep + * receiving events — use [Realtime.disconnect] for full teardown. + */ + val unsubscribe: () -> Unit, + + /** + * Replace the channels and/or queries on this subscription without recreating it. + */ + val update: (RealtimeSubscriptionUpdate) -> Unit, + private val close: () -> Unit ) : Closeable { + /** + * Alias of [unsubscribe] that also tears the socket down when this was the last active + * subscription. Prefer [unsubscribe] plus [Realtime.disconnect] for explicit control. + */ override fun close() = close.invoke() } diff --git a/library/src/main/java/io/appwrite/services/Realtime.kt b/library/src/main/java/io/appwrite/services/Realtime.kt index cca242f7..f676b812 100644 --- a/library/src/main/java/io/appwrite/services/Realtime.kt +++ b/library/src/main/java/io/appwrite/services/Realtime.kt @@ -3,6 +3,7 @@ package io.appwrite.services import io.appwrite.Service import io.appwrite.Client import io.appwrite.Channel +import io.appwrite.ID import io.appwrite.Query import io.appwrite.exceptions.AppwriteException import io.appwrite.extensions.forEachAsync @@ -40,22 +41,14 @@ class Realtime(client: Client) : Service(client), CoroutineScope { private const val HEARTBEAT_INTERVAL = 20_000L // 20 seconds private var socket: RealWebSocket? = null - // Slot-centric state: Map - private val activeSubscriptions = ConcurrentHashMap() - // Map slot index -> subscriptionId (from backend) - private val slotToSubscriptionId = ConcurrentHashMap() - // Inverse map: subscriptionId -> slot index (for O(1) lookup) - private val subscriptionIdToSlot = ConcurrentHashMap() - // Queue of slot snapshots per in-flight subscribe message (FIFO with server responses). - private val pendingSubscribeSlotsQueue = ArrayDeque>() + private val activeSubscriptions = ConcurrentHashMap() + private val pendingSubscribes = LinkedHashMap>() private var reconnectAttempts = 0 - private val subscriptionsCounter = AtomicInteger(0) private val socketGeneration = AtomicInteger(0) private var reconnect = true private var heartbeatJob: Job? = null - // Lock to coordinate multi-map updates (activeSubscriptions, slotToSubscriptionId, subscriptionIdToSlot) private val subscriptionLock = Any() } @@ -104,35 +97,63 @@ class Realtime(client: Client) : Service(client), CoroutineScope { socket?.close(RealtimeCode.POLICY_VIOLATION.value, null) } - private fun sendSubscribeMessage() { + private fun sendUnsubscribeMessage(subscriptionIds: List) { val ws = socket ?: return - val rows = mutableListOf>() - val slotsForMessage = mutableListOf() + val ids = subscriptionIds.filter { it.isNotEmpty() } + if (ids.isEmpty()) { + return + } + ws.send( + mapOf( + "type" to "unsubscribe", + "data" to ids.map { mapOf("subscriptionId" to it) } + ).toJson() + ) + } - synchronized(subscriptionLock) { - activeSubscriptions.forEach { (slot, subscription) -> - val queries = subscription.queries - val row = mutableMapOf( - "channels" to subscription.channels.toList(), - "queries" to queries.toList() - ) - val knownSubscriptionId = slotToSubscriptionId[slot] - if (!knownSubscriptionId.isNullOrEmpty()) { - row["subscriptionId"] = knownSubscriptionId - } - rows.add(row) - slotsForMessage.add(slot) + private fun generateUniqueSubscriptionIdLocked(): String { + repeat(activeSubscriptions.size + 1) { + val id = ID.unique() + if (!activeSubscriptions.containsKey(id)) { + return id } } + throw AppwriteException("Failed to generate unique subscription id") + } - if (rows.isEmpty()) { - return - } + private fun enqueuePendingSubscribeLocked(subscriptionId: String) { + val subscription = activeSubscriptions[subscriptionId] ?: return + pendingSubscribes[subscriptionId] = mapOf( + "subscriptionId" to subscriptionId, + "channels" to subscription.channels.toList(), + "queries" to subscription.queries.toList() + ) + } + /** + * Close the WebSocket connection and drop all active subscriptions client-side. + * Use this instead of calling [RealtimeSubscription.unsubscribe] on every subscription + * when you want to tear everything down. + */ + fun disconnect() { synchronized(subscriptionLock) { - pendingSubscribeSlotsQueue.addLast(slotsForMessage.toList()) + activeSubscriptions.clear() + pendingSubscribes.clear() + reconnect = false + closeSocket() } + } + private fun sendPendingSubscribes() { + val ws = socket ?: return + val rows: List> + synchronized(subscriptionLock) { + if (pendingSubscribes.isEmpty()) { + return + } + rows = pendingSubscribes.values.toList() + pendingSubscribes.clear() + } ws.send(mapOf("type" to "subscribe", "data" to rows).toJson()) } @@ -208,53 +229,73 @@ class Realtime(client: Client) : Service(client), CoroutineScope { queries: Set = emptySet(), callback: (RealtimeResponseEvent) -> Unit, ): RealtimeSubscription { - // Allocate a new slot index atomically - val slot = subscriptionsCounter.incrementAndGet() - - // Store slot-centric data: channels, queries, and callback belong to the slot. - // We only touch activeSubscriptions here, but keep the pattern consistent - // and future-proof by guarding multi-map writes with a shared lock. + val subscriptionId: String synchronized(subscriptionLock) { - activeSubscriptions[slot] = RealtimeCallback( + subscriptionId = generateUniqueSubscriptionIdLocked() + activeSubscriptions[subscriptionId] = RealtimeCallback( channels.toSet(), queries, payloadType, callback as (RealtimeResponseEvent<*>) -> Unit ) - } - // Reuse active socket when possible; otherwise create it. - synchronized(subscriptionLock) { + enqueuePendingSubscribeLocked(subscriptionId) if (socket != null) { - sendSubscribeMessage() + sendPendingSubscribes() } else { createSocket() } } - return RealtimeSubscription { - // Unsubscribe must update all three maps atomically so that - // no reader observes a half-updated state. + val unsubscribeFn: () -> Unit = { + val removed: Boolean + synchronized(subscriptionLock) { + removed = activeSubscriptions.remove(subscriptionId) != null + pendingSubscribes.remove(subscriptionId) + } + if (removed) { + sendUnsubscribeMessage(listOf(subscriptionId)) + } + } + + val updateFn: (RealtimeSubscriptionUpdate) -> Unit = { changes -> synchronized(subscriptionLock) { - val subscriptionId = slotToSubscriptionId[slot] - activeSubscriptions.remove(slot) - slotToSubscriptionId.remove(slot) - subscriptionId?.let { subscriptionIdToSlot.remove(it) } + val current = activeSubscriptions[subscriptionId] + if (current != null) { + val nextChannels = changes.channels?.map { channelToString(it) }?.toSet() + ?: current.channels + val nextQueries = changes.queries?.toSet() ?: current.queries + activeSubscriptions[subscriptionId] = RealtimeCallback( + nextChannels, + nextQueries, + current.payloadClass, + current.callback + ) + enqueuePendingSubscribeLocked(subscriptionId) + } + } + if (socket != null) { + sendPendingSubscribes() + } else { + createSocket() } + } + + val closeFn: () -> Unit = { + unsubscribeFn() synchronized(subscriptionLock) { if (activeSubscriptions.isEmpty()) { reconnect = false closeSocket() - } else if (socket != null) { - sendSubscribeMessage() - } else { - this@Realtime.createSocket() } } } - } - // cleanUp is no longer needed - slots are removed directly in subscribe().close() - // Channels are automatically rebuilt from remaining slots in createSocket() + return RealtimeSubscription( + unsubscribe = unsubscribeFn, + update = updateFn, + close = closeFn + ) + } private inner class AppwriteWebSocketListener( private val generation: Int @@ -289,51 +330,18 @@ class Realtime(client: Client) : Service(client), CoroutineScope { } private fun handleResponseConnected(message: RealtimeResponse) { - val messageData = message.data?.jsonCast>() ?: return - val subscriptions = messageData["subscriptions"] as? Map<*, *> - - if (subscriptions != null) { - // Store subscription ID mappings from backend. - // Try direct slot first, then slot+1 for zero-based server maps. - synchronized(subscriptionLock) { - subscriptions.forEach { (slotStr, subscriptionId) -> - val slot = (slotStr as? String)?.toIntOrNull() - if (slot != null && subscriptionId is String) { - val targetSlot = when { - activeSubscriptions.containsKey(slot) -> slot - activeSubscriptions.containsKey(slot + 1) -> slot + 1 - else -> slot - } - slotToSubscriptionId[targetSlot] = subscriptionId - subscriptionIdToSlot[subscriptionId] = targetSlot - } - } - } - } + if (message.data == null) return - sendSubscribeMessage() + synchronized(subscriptionLock) { + activeSubscriptions.keys.forEach { enqueuePendingSubscribeLocked(it) } + } + sendPendingSubscribes() } private fun handleResponseAction(message: RealtimeResponse) { - val actionData = message.data?.jsonCast>() ?: return - if (actionData["to"] != "subscribe") { - return - } - val subscriptions = actionData["subscriptions"] as? List<*> ?: return - - synchronized(subscriptionLock) { - if (pendingSubscribeSlotsQueue.isEmpty()) { - return - } - val pendingSlots = pendingSubscribeSlotsQueue.removeFirst() - subscriptions.forEachIndexed { index, item -> - val slot = pendingSlots.getOrNull(index) ?: return@forEachIndexed - val row = item as? Map<*, *> ?: return@forEachIndexed - val subscriptionId = row["subscriptionId"] as? String ?: return@forEachIndexed - slotToSubscriptionId[slot] = subscriptionId - subscriptionIdToSlot[subscriptionId] = slot - } - } + // The SDK generates subscriptionIds client-side and sends them on every + // subscribe/unsubscribe, so subscribe/unsubscribe acks carry no state + // the SDK needs to reconcile. } private fun handleResponseError(message: RealtimeResponse) { @@ -356,19 +364,12 @@ class Realtime(client: Client) : Service(client), CoroutineScope { return } - // Use backend-provided subscription IDs for O(1) dispatch subscriptions.forEach { subscriptionId -> - // O(1) lookup using subscriptionId - val slot = subscriptionIdToSlot[subscriptionId] - if (slot != null) { - val subscription = activeSubscriptions[slot] - if (subscription != null) { - val typedEvent = event.copy( - payload = rawPayload.jsonCast(subscription.payloadClass) - ) - subscription.callback(typedEvent) - } - } + val subscription = activeSubscriptions[subscriptionId] ?: return@forEach + val typedEvent = event.copy( + payload = rawPayload.jsonCast(subscription.payloadClass) + ) + subscription.callback(typedEvent) } } From 0b84f2178360796ed57961b660ce3f378658f685 Mon Sep 17 00:00:00 2001 From: root Date: Thu, 23 Apr 2026 09:17:02 +0000 Subject: [PATCH 6/6] chore: update Android SDK to 24.0.0 --- .../java/io/appwrite/services/Realtime.kt | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/library/src/main/java/io/appwrite/services/Realtime.kt b/library/src/main/java/io/appwrite/services/Realtime.kt index f676b812..841fe279 100644 --- a/library/src/main/java/io/appwrite/services/Realtime.kt +++ b/library/src/main/java/io/appwrite/services/Realtime.kt @@ -230,6 +230,8 @@ class Realtime(client: Client) : Service(client), CoroutineScope { callback: (RealtimeResponseEvent) -> Unit, ): RealtimeSubscription { val subscriptionId: String + var shouldSendPending = false + var shouldCreateSocket = false synchronized(subscriptionLock) { subscriptionId = generateUniqueSubscriptionIdLocked() activeSubscriptions[subscriptionId] = RealtimeCallback( @@ -240,9 +242,25 @@ class Realtime(client: Client) : Service(client), CoroutineScope { ) enqueuePendingSubscribeLocked(subscriptionId) if (socket != null) { - sendPendingSubscribes() + shouldSendPending = true } else { + shouldCreateSocket = true + } + } + if (shouldSendPending) { + sendPendingSubscribes() + } else if (shouldCreateSocket) { + val shouldCreateAfterRecheck: Boolean + synchronized(subscriptionLock) { + shouldCreateAfterRecheck = (socket == null) + } + + if (shouldCreateAfterRecheck) { createSocket() + } else { + // Another concurrent subscribe() created the socket first. + // Send pending rows without holding the lock to avoid ws.send() blocking others. + sendPendingSubscribes() } }