Skip to content

Commit 21a4ae3

Browse files
VelikovPetarCopilotaleksandar-apostolov
authored
DeleteDevice improvements (#5978)
* Decouple deleteDevice from flushPersistence. * Rework automatic add/remove device logic to not depend on local storage. * Add PushTokenUpdateHandler tests. * Add JvmOverloads to ChatClient.disconnect. * Api dump. * Update stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/PushTokenUpdateHandlerTest.kt Co-authored-by: Copilot <[email protected]> --------- Co-authored-by: Copilot <[email protected]> Co-authored-by: Aleksandar Apostolov <[email protected]>
1 parent de735f4 commit 21a4ae3

File tree

5 files changed

+294
-120
lines changed

5 files changed

+294
-120
lines changed

stream-chat-android-client/api/stream-chat-android-client.api

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,8 @@ public final class io/getstream/chat/android/client/ChatClient {
6161
public final fun devToken (Ljava/lang/String;)Ljava/lang/String;
6262
public final fun disableSlowMode (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call;
6363
public final fun disconnect (Z)Lio/getstream/result/call/Call;
64+
public final fun disconnect (ZZ)Lio/getstream/result/call/Call;
65+
public static synthetic fun disconnect$default (Lio/getstream/chat/android/client/ChatClient;ZZILjava/lang/Object;)Lio/getstream/result/call/Call;
6466
public final fun disconnectSocket ()Lio/getstream/result/call/Call;
6567
public final fun dismissChannelNotifications (Ljava/lang/String;Ljava/lang/String;)V
6668
public final fun enableSlowMode (Ljava/lang/String;Ljava/lang/String;I)Lio/getstream/result/call/Call;

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -441,7 +441,7 @@ internal constructor(
441441
val user = event.me
442442
val connectionId = event.connectionId
443443
api.setConnection(user.id, connectionId)
444-
notifications.onSetUser()
444+
notifications.onSetUser(user)
445445

446446
mutableClientState.setConnectionState(ConnectionState.Connected)
447447
mutableClientState.setUser(user)
@@ -738,6 +738,7 @@ internal constructor(
738738
return CoroutineCall(clientScope) {
739739
logger.d { "[switchUser] user.id: '${user.id}'" }
740740
userScope.userId.value = user.id
741+
notifications.deleteDevice() // always delete device if switching users
741742
disconnectUserSuspend(flushPersistence = true)
742743
onDisconnectionComplete()
743744
connectUserSuspend(user, tokenProvider, timeoutMilliseconds).also {
@@ -1449,15 +1450,24 @@ internal constructor(
14491450
* You shouldn't call this method, if the user will continue using the Chat in the future.
14501451
*
14511452
* @param flushPersistence if true will clear user data.
1453+
* @param deleteDevice If set to true, will attempt to delete the registered device from Stream backend. For
1454+
* backwards compatibility, by default it's set to the value of [flushPersistence].
14521455
*
14531456
* @return Executable async [Call] which performs the disconnection.
14541457
*/
14551458
@CheckResult
1456-
public fun disconnect(flushPersistence: Boolean): Call<Unit> =
1459+
@JvmOverloads
1460+
public fun disconnect(
1461+
flushPersistence: Boolean,
1462+
deleteDevice: Boolean = flushPersistence,
1463+
): Call<Unit> =
14571464
CoroutineCall(clientScope) {
14581465
logger.d { "[disconnect] flushPersistence: $flushPersistence" }
14591466
when (isUserSet()) {
14601467
true -> {
1468+
if (deleteDevice) {
1469+
notifications.deleteDevice()
1470+
}
14611471
disconnectSuspend(flushPersistence)
14621472
Result.Success(Unit)
14631473
}
@@ -1483,7 +1493,7 @@ internal constructor(
14831493
initializedUserId.set(null)
14841494
logger.d { "[disconnectUserSuspend] userId: '$userId', flushPersistence: $flushPersistence" }
14851495

1486-
notifications.onLogout(flushPersistence)
1496+
notifications.onLogout()
14871497
plugins.forEach { it.onUserDisconnected() }
14881498
plugins = emptyList()
14891499
userStateService.onLogout()

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,30 +29,32 @@ import io.getstream.chat.android.client.notifications.handler.NotificationHandle
2929
import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider
3030
import io.getstream.chat.android.models.Device
3131
import io.getstream.chat.android.models.PushMessage
32+
import io.getstream.chat.android.models.User
3233
import io.getstream.log.taggedLogger
3334
import kotlinx.coroutines.CoroutineScope
3435
import kotlinx.coroutines.launch
3536

3637
internal interface ChatNotifications {
37-
fun onSetUser()
38+
fun onSetUser(user: User)
3839
fun setDevice(device: Device)
40+
suspend fun deleteDevice()
3941
fun onPushMessage(message: PushMessage, pushNotificationReceivedListener: PushNotificationReceivedListener)
4042
fun onChatEvent(event: ChatEvent)
41-
suspend fun onLogout(flushPersistence: Boolean)
43+
suspend fun onLogout()
4244
fun displayNotification(notification: ChatNotification)
4345
fun dismissChannelNotifications(channelType: String, channelId: String)
4446
}
4547

4648
@Suppress("TooManyFunctions")
47-
internal class ChatNotificationsImpl constructor(
49+
internal class ChatNotificationsImpl(
4850
private val handler: NotificationHandler,
4951
private val notificationConfig: NotificationConfig,
5052
private val context: Context,
5153
private val scope: CoroutineScope = CoroutineScope(DispatcherProvider.IO),
5254
) : ChatNotifications {
5355
private val logger by taggedLogger("Chat:Notifications")
5456

55-
private val pushTokenUpdateHandler = PushTokenUpdateHandler(context)
57+
private val pushTokenUpdateHandler = PushTokenUpdateHandler()
5658
private val showedMessages = mutableSetOf<String>()
5759
private val permissionManager: NotificationPermissionManager =
5860
NotificationPermissionManager.createNotificationPermissionsManager(
@@ -68,23 +70,28 @@ internal class ChatNotificationsImpl constructor(
6870
logger.i { "<init> no args" }
6971
}
7072

71-
override fun onSetUser() {
72-
logger.i { "[onSetUser] no args" }
73+
override fun onSetUser(user: User) {
74+
logger.i { "[onSetUser] user: $user" }
7375
permissionManager
7476
.takeIf { notificationConfig.requestPermissionOnAppLaunch() }
7577
?.start()
7678
notificationConfig.pushDeviceGenerators.firstOrNull { it.isValidForThisDevice() }
7779
?.let {
7880
it.onPushDeviceGeneratorSelected()
79-
it.asyncGeneratePushDevice { setDevice(it.toDevice()) }
81+
it.asyncGeneratePushDevice { pushDevice ->
82+
setDeviceForUser(user, pushDevice.toDevice())
83+
}
8084
}
8185
}
8286

8387
override fun setDevice(device: Device) {
8488
logger.i { "[setDevice] device: $device" }
85-
scope.launch {
86-
pushTokenUpdateHandler.updateDeviceIfNecessary(device)
87-
}
89+
// If no user is passed, we assume the device is NOT already registered
90+
setDeviceForUser(null, device)
91+
}
92+
93+
override suspend fun deleteDevice() {
94+
pushTokenUpdateHandler.deleteDevice()
8895
}
8996

9097
override fun onPushMessage(
@@ -111,14 +118,11 @@ internal class ChatNotificationsImpl constructor(
111118
}
112119
}
113120

114-
override suspend fun onLogout(flushPersistence: Boolean) {
115-
logger.i { "[onLogout] flushPersistence: $flushPersistence" }
121+
override suspend fun onLogout() {
122+
logger.i { "[onLogout]" }
116123
permissionManager.stop()
117124
handler.dismissAllNotifications()
118125
cancelLoadDataWork()
119-
if (flushPersistence) {
120-
removeStoredDevice()
121-
}
122126
}
123127

124128
/**
@@ -193,21 +197,25 @@ internal class ChatNotificationsImpl constructor(
193197
}
194198
}
195199

196-
private suspend fun removeStoredDevice() {
197-
pushTokenUpdateHandler.removeStoredDevice()
200+
private fun setDeviceForUser(user: User?, device: Device) {
201+
logger.i { "[setDeviceForUser] userId: ${user?.id}, device: $device" }
202+
scope.launch {
203+
pushTokenUpdateHandler.addDevice(user, device)
204+
}
198205
}
199206
}
200207

201208
internal object NoOpChatNotifications : ChatNotifications {
202-
override fun onSetUser() = Unit
209+
override fun onSetUser(user: User) = Unit
203210
override fun setDevice(device: Device) = Unit
211+
override suspend fun deleteDevice() = Unit
204212
override fun onPushMessage(
205213
message: PushMessage,
206214
pushNotificationReceivedListener: PushNotificationReceivedListener,
207215
) = Unit
208216

209217
override fun onChatEvent(event: ChatEvent) = Unit
210-
override suspend fun onLogout(flushPersistence: Boolean) = Unit
218+
override suspend fun onLogout() = Unit
211219
override fun displayNotification(notification: ChatNotification) = Unit
212220
override fun dismissChannelNotifications(channelType: String, channelId: String) = Unit
213221
}

stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/PushTokenUpdateHandler.kt

Lines changed: 88 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -16,119 +16,109 @@
1616

1717
package io.getstream.chat.android.client.notifications
1818

19-
import android.content.Context
20-
import android.content.SharedPreferences
21-
import androidx.core.content.edit
2219
import io.getstream.chat.android.client.ChatClient
23-
import io.getstream.chat.android.client.errors.isPermanent
24-
import io.getstream.chat.android.client.extensions.getNonNullString
25-
import io.getstream.chat.android.core.utils.Debouncer
2620
import io.getstream.chat.android.models.Device
27-
import io.getstream.chat.android.models.PushProvider
21+
import io.getstream.chat.android.models.User
2822
import io.getstream.log.taggedLogger
23+
import org.jetbrains.annotations.VisibleForTesting
2924

30-
internal class PushTokenUpdateHandler(context: Context) {
31-
private val logger by taggedLogger("Chat:Notifications-UH")
32-
33-
private val prefs: SharedPreferences = context.applicationContext.getSharedPreferences(
34-
PREFS_NAME,
35-
Context.MODE_PRIVATE,
36-
)
37-
38-
private val chatClient: ChatClient get() = ChatClient.instance()
25+
/**
26+
* Manages the lifecycle of push notification devices for the current user.
27+
*
28+
* This handler is responsible for registering and unregistering push notification devices
29+
* with the Stream Chat backend. It tracks the currently active device and ensures that
30+
* device state stays synchronized with the server, avoiding duplicate registrations.
31+
*
32+
* The handler skips operations when:
33+
* - A device is already registered for the user (during [addDevice])
34+
* - No current device exists (during [deleteDevice])
35+
*
36+
* @param getChatClient A function that provides the current [ChatClient] instance.
37+
*/
38+
internal class PushTokenUpdateHandler(
39+
private val getChatClient: () -> ChatClient = { ChatClient.instance() },
40+
) {
3941

40-
private val updateDebouncer = Debouncer(DEBOUNCE_TIMEOUT)
42+
private val logger by taggedLogger("Chat:Notifications-UH")
4143

42-
private var userPushToken: UserPushToken
43-
set(value) {
44-
prefs.edit(true) {
45-
putString(KEY_USER_ID, value.userId)
46-
putString(KEY_TOKEN, value.token)
47-
putString(KEY_PUSH_PROVIDER, value.pushProvider)
48-
putString(KEY_PUSH_PROVIDER_NAME, value.providerName)
49-
}
50-
}
51-
get() {
52-
return UserPushToken(
53-
userId = prefs.getNonNullString(KEY_USER_ID, ""),
54-
token = prefs.getNonNullString(KEY_TOKEN, ""),
55-
pushProvider = prefs.getNonNullString(KEY_PUSH_PROVIDER, ""),
56-
providerName = prefs.getString(KEY_PUSH_PROVIDER_NAME, null),
57-
)
58-
}
44+
/**
45+
* The registered device in this ChatClient session.
46+
*/
47+
@VisibleForTesting
48+
internal var currentDevice: Device? = null
5949

6050
/**
61-
* Registers the current device on the server if necessary. Does no do
62-
* anything if the token has already been sent to the server previously.
51+
* Registers a new push notification device for the current user.
52+
*
53+
* This method attempts to add a device to the server if it is not already registered.
54+
* Before sending the request, it checks whether the device is already in the user's
55+
* registered devices list, and if so, skips the registration to avoid redundant operations.
56+
*
57+
* Upon successful registration, [currentDevice] is updated to track the newly added device.
58+
* Upon failure, the operation is logged but does not rethrow the error.
59+
*
60+
* @param user The current user, or `null` if no user is logged in. Used to check if the
61+
* device is already registered. If `null`, the device will be treated as
62+
* unregistered.
63+
* @param device The device to register. Must contain a valid token and push provider.
64+
*
65+
* **Behavior**:
66+
* - If the device is already registered (found in [User.devices]), logs a message and returns early.
67+
* - If not registered, sends an add device request to the server.
68+
* - On success: updates [currentDevice] and logs the device token.
69+
* - On error: logs the failure but does not propagate the exception.
6370
*/
64-
suspend fun updateDeviceIfNecessary(device: Device) {
65-
val userPushToken = device.toUserPushToken()
66-
if (!device.isValid()) return
67-
if (this.userPushToken == userPushToken) return
68-
updateDebouncer.submitSuspendable {
69-
logger.d { "[updateDeviceIfNecessary] device: $device" }
70-
val removed = removeStoredDeviceInternal()
71-
logger.v { "[updateDeviceIfNecessary] removed: $removed" }
72-
val result = chatClient.addDevice(device).await()
73-
if (result.isSuccess) {
74-
this.userPushToken = userPushToken
75-
val pushProvider = device.pushProvider.key
76-
logger.i { "[updateDeviceIfNecessary] device registered with token($pushProvider): ${device.token}" }
77-
} else {
78-
logger.e { "[updateDeviceIfNecessary] failed registering device ${result.errorOrNull()?.message}" }
79-
}
71+
suspend fun addDevice(user: User?, device: Device) {
72+
val isDeviceRegistered = isDeviceRegistered(user, device)
73+
if (isDeviceRegistered) {
74+
logger.d { "[addDevice] skip adding device: already registered on server" }
75+
currentDevice = device
76+
return
8077
}
78+
getChatClient().addDevice(device).await()
79+
.onSuccess {
80+
currentDevice = device
81+
logger.d { "[addDevice] successfully added ${device.pushProvider.key} device ${device.token}" }
82+
}
83+
.onError {
84+
logger.d { "[addDevice] failed to add ${device.pushProvider.key} device ${device.token}" }
85+
}
8186
}
8287

83-
suspend fun removeStoredDevice() {
84-
logger.v { "[removeStoredDevice] no args" }
85-
val removed = removeStoredDeviceInternal()
86-
logger.i { "[removeStoredDevice] removed: $removed" }
87-
}
88-
89-
private suspend fun removeStoredDeviceInternal(): Boolean {
90-
val device = userPushToken.toDevice()
91-
.takeIf { it.isValid() }
92-
?: return false
93-
userPushToken = UserPushToken("", "", "", null)
94-
return chatClient.deleteDevice(device).await()
88+
/**
89+
* Unregisters the currently tracked push notification device from the server.
90+
*
91+
* This method attempts to delete the device that is stored in [currentDevice].
92+
* If no device is currently tracked, the operation is skipped.
93+
*
94+
* Upon successful deletion, [currentDevice] is cleared to reflect that no device
95+
* is currently registered. Upon failure, the operation is logged but does not
96+
* rethrow the error, and [currentDevice] remains unchanged.
97+
*
98+
* **Behavior**:
99+
* - If [currentDevice] is `null`, logs a message and returns early.
100+
* - If a device is tracked, sends a delete device request to the server.
101+
* - On success: clears [currentDevice] and logs the device token.
102+
* - On error: logs the failure but does not propagate the exception.
103+
*/
104+
suspend fun deleteDevice() {
105+
val device = currentDevice
106+
if (device == null) {
107+
logger.d { "[deleteDevice] skip deleting device: no current device" }
108+
return
109+
}
110+
getChatClient().deleteDevice(device).await()
111+
.onSuccess {
112+
currentDevice = null
113+
logger.d { "[deleteDevice] successfully deleted ${device.pushProvider.key} device ${device.token}" }
114+
}
95115
.onError {
96-
if (!it.isPermanent()) {
97-
userPushToken = device.toUserPushToken()
98-
}
99-
logger.e { "[removeStoredDeviceInternal] failed: $it" }
116+
logger.d { "[deleteDevice] failed to delete ${device.pushProvider.key} device ${device.token}" }
100117
}
101-
.isSuccess
102118
}
103119

104-
private data class UserPushToken(
105-
val userId: String,
106-
val token: String,
107-
val pushProvider: String,
108-
val providerName: String?,
109-
)
110-
111-
companion object {
112-
private const val PREFS_NAME = "stream_firebase_token_store"
113-
private const val KEY_USER_ID = "user_id"
114-
private const val KEY_TOKEN = "token"
115-
private const val KEY_PUSH_PROVIDER = "push_provider"
116-
private const val KEY_PUSH_PROVIDER_NAME = "push_provider_name"
117-
private const val DEBOUNCE_TIMEOUT = 200L
120+
private fun isDeviceRegistered(user: User?, device: Device): Boolean {
121+
val registeredDevices = user?.devices ?: return false
122+
return registeredDevices.any { it == device }
118123
}
119-
120-
private fun Device.toUserPushToken() = UserPushToken(
121-
userId = chatClient.getCurrentUser()?.id ?: "",
122-
token = token,
123-
pushProvider = pushProvider.key,
124-
providerName = providerName,
125-
)
126-
127-
private fun UserPushToken.toDevice() = Device(
128-
token = token,
129-
pushProvider = PushProvider.fromKey(pushProvider),
130-
providerName = providerName,
131-
)
132-
133-
private fun Device.isValid() = pushProvider != PushProvider.UNKNOWN
134124
}

0 commit comments

Comments
 (0)