diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e6f6f47..4f120643 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
# Change Log
+## 24.0.0
+
+* 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
* Added `x` OAuth provider to `OAuthProvider` enum
diff --git a/README.md b/README.md
index a2f1e3ab..1e2528ba 100644
--- a/README.md
+++ b/README.md
@@ -2,7 +2,7 @@


-
+
[](https://travis-ci.com/appwrite/sdk-generator)
[](https://twitter.com/appwrite)
[](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:24.0.0")
```
### Maven
@@ -49,7 +49,7 @@ Add this to your project's `pom.xml` file:
io.appwrite
sdk-for-android
- 23.1.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 4eb6a51c..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.1.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/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..841fe279 100644
--- a/library/src/main/java/io/appwrite/services/Realtime.kt
+++ b/library/src/main/java/io/appwrite/services/Realtime.kt
@@ -3,11 +3,13 @@ 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
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,23 +37,18 @@ 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
- // 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()
+ 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()
}
@@ -61,51 +58,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 +97,66 @@ class Realtime(client: Client) : Service(client), CoroutineScope {
socket?.close(RealtimeCode.POLICY_VIOLATION.value, null)
}
+ private fun sendUnsubscribeMessage(subscriptionIds: List) {
+ val ws = socket ?: return
+ 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()
+ )
+ }
+
+ 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")
+ }
+
+ 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) {
+ activeSubscriptions.clear()
+ pendingSubscribes.clear()
+ reconnect = false
+ closeSocket()
+ }
+ }
+
+ private fun sendPendingSubscribes() {
+ val ws = socket ?: return
+ val rows: List