diff --git a/stream-chat-android-client/api/stream-chat-android-client.api b/stream-chat-android-client/api/stream-chat-android-client.api index c8687229ea5..d538b9f6f5d 100644 --- a/stream-chat-android-client/api/stream-chat-android-client.api +++ b/stream-chat-android-client/api/stream-chat-android-client.api @@ -61,6 +61,8 @@ public final class io/getstream/chat/android/client/ChatClient { public final fun devToken (Ljava/lang/String;)Ljava/lang/String; public final fun disableSlowMode (Ljava/lang/String;Ljava/lang/String;)Lio/getstream/result/call/Call; public final fun disconnect (Z)Lio/getstream/result/call/Call; + public final fun disconnect (ZZ)Lio/getstream/result/call/Call; + public static synthetic fun disconnect$default (Lio/getstream/chat/android/client/ChatClient;ZZILjava/lang/Object;)Lio/getstream/result/call/Call; public final fun disconnectSocket ()Lio/getstream/result/call/Call; public final fun dismissChannelNotifications (Ljava/lang/String;Ljava/lang/String;)V public final fun enableSlowMode (Ljava/lang/String;Ljava/lang/String;I)Lio/getstream/result/call/Call; diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt index ddc6d2763f9..e0739366623 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/ChatClient.kt @@ -441,7 +441,7 @@ internal constructor( val user = event.me val connectionId = event.connectionId api.setConnection(user.id, connectionId) - notifications.onSetUser() + notifications.onSetUser(user) mutableClientState.setConnectionState(ConnectionState.Connected) mutableClientState.setUser(user) @@ -738,6 +738,7 @@ internal constructor( return CoroutineCall(clientScope) { logger.d { "[switchUser] user.id: '${user.id}'" } userScope.userId.value = user.id + notifications.deleteDevice() // always delete device if switching users disconnectUserSuspend(flushPersistence = true) onDisconnectionComplete() connectUserSuspend(user, tokenProvider, timeoutMilliseconds).also { @@ -1449,15 +1450,24 @@ internal constructor( * You shouldn't call this method, if the user will continue using the Chat in the future. * * @param flushPersistence if true will clear user data. + * @param deleteDevice If set to true, will attempt to delete the registered device from Stream backend. For + * backwards compatibility, by default it's set to the value of [flushPersistence]. * * @return Executable async [Call] which performs the disconnection. */ @CheckResult - public fun disconnect(flushPersistence: Boolean): Call = + @JvmOverloads + public fun disconnect( + flushPersistence: Boolean, + deleteDevice: Boolean = flushPersistence, + ): Call = CoroutineCall(clientScope) { logger.d { "[disconnect] flushPersistence: $flushPersistence" } when (isUserSet()) { true -> { + if (deleteDevice) { + notifications.deleteDevice() + } disconnectSuspend(flushPersistence) Result.Success(Unit) } @@ -1483,7 +1493,7 @@ internal constructor( initializedUserId.set(null) logger.d { "[disconnectUserSuspend] userId: '$userId', flushPersistence: $flushPersistence" } - notifications.onLogout(flushPersistence) + notifications.onLogout() plugins.forEach { it.onUserDisconnected() } plugins = emptyList() userStateService.onLogout() diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt index f034920a221..dbc804c1987 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/ChatNotifications.kt @@ -29,22 +29,24 @@ import io.getstream.chat.android.client.notifications.handler.NotificationHandle import io.getstream.chat.android.core.internal.coroutines.DispatcherProvider import io.getstream.chat.android.models.Device import io.getstream.chat.android.models.PushMessage +import io.getstream.chat.android.models.User import io.getstream.log.taggedLogger import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch internal interface ChatNotifications { - fun onSetUser() + fun onSetUser(user: User) fun setDevice(device: Device) + suspend fun deleteDevice() fun onPushMessage(message: PushMessage, pushNotificationReceivedListener: PushNotificationReceivedListener) fun onChatEvent(event: ChatEvent) - suspend fun onLogout(flushPersistence: Boolean) + suspend fun onLogout() fun displayNotification(notification: ChatNotification) fun dismissChannelNotifications(channelType: String, channelId: String) } @Suppress("TooManyFunctions") -internal class ChatNotificationsImpl constructor( +internal class ChatNotificationsImpl( private val handler: NotificationHandler, private val notificationConfig: NotificationConfig, private val context: Context, @@ -52,7 +54,7 @@ internal class ChatNotificationsImpl constructor( ) : ChatNotifications { private val logger by taggedLogger("Chat:Notifications") - private val pushTokenUpdateHandler = PushTokenUpdateHandler(context) + private val pushTokenUpdateHandler = PushTokenUpdateHandler() private val showedMessages = mutableSetOf() private val permissionManager: NotificationPermissionManager = NotificationPermissionManager.createNotificationPermissionsManager( @@ -68,23 +70,28 @@ internal class ChatNotificationsImpl constructor( logger.i { " no args" } } - override fun onSetUser() { - logger.i { "[onSetUser] no args" } + override fun onSetUser(user: User) { + logger.i { "[onSetUser] user: $user" } permissionManager .takeIf { notificationConfig.requestPermissionOnAppLaunch() } ?.start() notificationConfig.pushDeviceGenerators.firstOrNull { it.isValidForThisDevice() } ?.let { it.onPushDeviceGeneratorSelected() - it.asyncGeneratePushDevice { setDevice(it.toDevice()) } + it.asyncGeneratePushDevice { pushDevice -> + setDeviceForUser(user, pushDevice.toDevice()) + } } } override fun setDevice(device: Device) { logger.i { "[setDevice] device: $device" } - scope.launch { - pushTokenUpdateHandler.updateDeviceIfNecessary(device) - } + // If no user is passed, we assume the device is NOT already registered + setDeviceForUser(null, device) + } + + override suspend fun deleteDevice() { + pushTokenUpdateHandler.deleteDevice() } override fun onPushMessage( @@ -111,14 +118,11 @@ internal class ChatNotificationsImpl constructor( } } - override suspend fun onLogout(flushPersistence: Boolean) { - logger.i { "[onLogout] flushPersistence: $flushPersistence" } + override suspend fun onLogout() { + logger.i { "[onLogout]" } permissionManager.stop() handler.dismissAllNotifications() cancelLoadDataWork() - if (flushPersistence) { - removeStoredDevice() - } } /** @@ -193,21 +197,25 @@ internal class ChatNotificationsImpl constructor( } } - private suspend fun removeStoredDevice() { - pushTokenUpdateHandler.removeStoredDevice() + private fun setDeviceForUser(user: User?, device: Device) { + logger.i { "[setDeviceForUser] userId: ${user?.id}, device: $device" } + scope.launch { + pushTokenUpdateHandler.addDevice(user, device) + } } } internal object NoOpChatNotifications : ChatNotifications { - override fun onSetUser() = Unit + override fun onSetUser(user: User) = Unit override fun setDevice(device: Device) = Unit + override suspend fun deleteDevice() = Unit override fun onPushMessage( message: PushMessage, pushNotificationReceivedListener: PushNotificationReceivedListener, ) = Unit override fun onChatEvent(event: ChatEvent) = Unit - override suspend fun onLogout(flushPersistence: Boolean) = Unit + override suspend fun onLogout() = Unit override fun displayNotification(notification: ChatNotification) = Unit override fun dismissChannelNotifications(channelType: String, channelId: String) = Unit } diff --git a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/PushTokenUpdateHandler.kt b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/PushTokenUpdateHandler.kt index e20176789f5..6ddd2289001 100644 --- a/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/PushTokenUpdateHandler.kt +++ b/stream-chat-android-client/src/main/java/io/getstream/chat/android/client/notifications/PushTokenUpdateHandler.kt @@ -16,119 +16,109 @@ package io.getstream.chat.android.client.notifications -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit import io.getstream.chat.android.client.ChatClient -import io.getstream.chat.android.client.errors.isPermanent -import io.getstream.chat.android.client.extensions.getNonNullString -import io.getstream.chat.android.core.utils.Debouncer import io.getstream.chat.android.models.Device -import io.getstream.chat.android.models.PushProvider +import io.getstream.chat.android.models.User import io.getstream.log.taggedLogger +import org.jetbrains.annotations.VisibleForTesting -internal class PushTokenUpdateHandler(context: Context) { - private val logger by taggedLogger("Chat:Notifications-UH") - - private val prefs: SharedPreferences = context.applicationContext.getSharedPreferences( - PREFS_NAME, - Context.MODE_PRIVATE, - ) - - private val chatClient: ChatClient get() = ChatClient.instance() +/** + * Manages the lifecycle of push notification devices for the current user. + * + * This handler is responsible for registering and unregistering push notification devices + * with the Stream Chat backend. It tracks the currently active device and ensures that + * device state stays synchronized with the server, avoiding duplicate registrations. + * + * The handler skips operations when: + * - A device is already registered for the user (during [addDevice]) + * - No current device exists (during [deleteDevice]) + * + * @param getChatClient A function that provides the current [ChatClient] instance. + */ +internal class PushTokenUpdateHandler( + private val getChatClient: () -> ChatClient = { ChatClient.instance() }, +) { - private val updateDebouncer = Debouncer(DEBOUNCE_TIMEOUT) + private val logger by taggedLogger("Chat:Notifications-UH") - private var userPushToken: UserPushToken - set(value) { - prefs.edit(true) { - putString(KEY_USER_ID, value.userId) - putString(KEY_TOKEN, value.token) - putString(KEY_PUSH_PROVIDER, value.pushProvider) - putString(KEY_PUSH_PROVIDER_NAME, value.providerName) - } - } - get() { - return UserPushToken( - userId = prefs.getNonNullString(KEY_USER_ID, ""), - token = prefs.getNonNullString(KEY_TOKEN, ""), - pushProvider = prefs.getNonNullString(KEY_PUSH_PROVIDER, ""), - providerName = prefs.getString(KEY_PUSH_PROVIDER_NAME, null), - ) - } + /** + * The registered device in this ChatClient session. + */ + @VisibleForTesting + internal var currentDevice: Device? = null /** - * Registers the current device on the server if necessary. Does no do - * anything if the token has already been sent to the server previously. + * Registers a new push notification device for the current user. + * + * This method attempts to add a device to the server if it is not already registered. + * Before sending the request, it checks whether the device is already in the user's + * registered devices list, and if so, skips the registration to avoid redundant operations. + * + * Upon successful registration, [currentDevice] is updated to track the newly added device. + * Upon failure, the operation is logged but does not rethrow the error. + * + * @param user The current user, or `null` if no user is logged in. Used to check if the + * device is already registered. If `null`, the device will be treated as + * unregistered. + * @param device The device to register. Must contain a valid token and push provider. + * + * **Behavior**: + * - If the device is already registered (found in [User.devices]), logs a message and returns early. + * - If not registered, sends an add device request to the server. + * - On success: updates [currentDevice] and logs the device token. + * - On error: logs the failure but does not propagate the exception. */ - suspend fun updateDeviceIfNecessary(device: Device) { - val userPushToken = device.toUserPushToken() - if (!device.isValid()) return - if (this.userPushToken == userPushToken) return - updateDebouncer.submitSuspendable { - logger.d { "[updateDeviceIfNecessary] device: $device" } - val removed = removeStoredDeviceInternal() - logger.v { "[updateDeviceIfNecessary] removed: $removed" } - val result = chatClient.addDevice(device).await() - if (result.isSuccess) { - this.userPushToken = userPushToken - val pushProvider = device.pushProvider.key - logger.i { "[updateDeviceIfNecessary] device registered with token($pushProvider): ${device.token}" } - } else { - logger.e { "[updateDeviceIfNecessary] failed registering device ${result.errorOrNull()?.message}" } - } + suspend fun addDevice(user: User?, device: Device) { + val isDeviceRegistered = isDeviceRegistered(user, device) + if (isDeviceRegistered) { + logger.d { "[addDevice] skip adding device: already registered on server" } + currentDevice = device + return } + getChatClient().addDevice(device).await() + .onSuccess { + currentDevice = device + logger.d { "[addDevice] successfully added ${device.pushProvider.key} device ${device.token}" } + } + .onError { + logger.d { "[addDevice] failed to add ${device.pushProvider.key} device ${device.token}" } + } } - suspend fun removeStoredDevice() { - logger.v { "[removeStoredDevice] no args" } - val removed = removeStoredDeviceInternal() - logger.i { "[removeStoredDevice] removed: $removed" } - } - - private suspend fun removeStoredDeviceInternal(): Boolean { - val device = userPushToken.toDevice() - .takeIf { it.isValid() } - ?: return false - userPushToken = UserPushToken("", "", "", null) - return chatClient.deleteDevice(device).await() + /** + * Unregisters the currently tracked push notification device from the server. + * + * This method attempts to delete the device that is stored in [currentDevice]. + * If no device is currently tracked, the operation is skipped. + * + * Upon successful deletion, [currentDevice] is cleared to reflect that no device + * is currently registered. Upon failure, the operation is logged but does not + * rethrow the error, and [currentDevice] remains unchanged. + * + * **Behavior**: + * - If [currentDevice] is `null`, logs a message and returns early. + * - If a device is tracked, sends a delete device request to the server. + * - On success: clears [currentDevice] and logs the device token. + * - On error: logs the failure but does not propagate the exception. + */ + suspend fun deleteDevice() { + val device = currentDevice + if (device == null) { + logger.d { "[deleteDevice] skip deleting device: no current device" } + return + } + getChatClient().deleteDevice(device).await() + .onSuccess { + currentDevice = null + logger.d { "[deleteDevice] successfully deleted ${device.pushProvider.key} device ${device.token}" } + } .onError { - if (!it.isPermanent()) { - userPushToken = device.toUserPushToken() - } - logger.e { "[removeStoredDeviceInternal] failed: $it" } + logger.d { "[deleteDevice] failed to delete ${device.pushProvider.key} device ${device.token}" } } - .isSuccess } - private data class UserPushToken( - val userId: String, - val token: String, - val pushProvider: String, - val providerName: String?, - ) - - companion object { - private const val PREFS_NAME = "stream_firebase_token_store" - private const val KEY_USER_ID = "user_id" - private const val KEY_TOKEN = "token" - private const val KEY_PUSH_PROVIDER = "push_provider" - private const val KEY_PUSH_PROVIDER_NAME = "push_provider_name" - private const val DEBOUNCE_TIMEOUT = 200L + private fun isDeviceRegistered(user: User?, device: Device): Boolean { + val registeredDevices = user?.devices ?: return false + return registeredDevices.any { it == device } } - - private fun Device.toUserPushToken() = UserPushToken( - userId = chatClient.getCurrentUser()?.id ?: "", - token = token, - pushProvider = pushProvider.key, - providerName = providerName, - ) - - private fun UserPushToken.toDevice() = Device( - token = token, - pushProvider = PushProvider.fromKey(pushProvider), - providerName = providerName, - ) - - private fun Device.isValid() = pushProvider != PushProvider.UNKNOWN } diff --git a/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/PushTokenUpdateHandlerTest.kt b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/PushTokenUpdateHandlerTest.kt new file mode 100644 index 00000000000..d679d3f79b1 --- /dev/null +++ b/stream-chat-android-client/src/test/java/io/getstream/chat/android/client/notifications/PushTokenUpdateHandlerTest.kt @@ -0,0 +1,164 @@ +/* + * Copyright (c) 2014-2025 Stream.io Inc. All rights reserved. + * + * Licensed under the Stream License; + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://github.com/GetStream/stream-chat-android/blob/main/LICENSE + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.getstream.chat.android.client.notifications + +import io.getstream.chat.android.client.ChatClient +import io.getstream.chat.android.client.utils.RetroError +import io.getstream.chat.android.client.utils.RetroSuccess +import io.getstream.chat.android.models.PushProvider +import io.getstream.chat.android.randomDevice +import io.getstream.chat.android.randomUser +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mock +import org.mockito.Mockito.never +import org.mockito.Mockito.verify +import org.mockito.kotlin.any +import org.mockito.kotlin.times +import org.mockito.kotlin.whenever + +@ExperimentalCoroutinesApi +internal class PushTokenUpdateHandlerTest { + + private val chatClientMock: ChatClient = mock() + private val handler = PushTokenUpdateHandler { chatClientMock } + + // ===== addDevice Tests ===== + + @Test + fun `addDevice should add device when device is not registered`() = runTest { + // Given + val device = randomDevice(token = "test_token_123", pushProvider = PushProvider.FIREBASE) + val user = randomUser(devices = emptyList()) + whenever(chatClientMock.addDevice(any())) + .thenReturn(RetroSuccess(Unit).toRetrofitCall()) + + // When + handler.addDevice(user, device) + + // Then + verify(chatClientMock, times(1)).addDevice(device) + Assertions.assertEquals(device, handler.currentDevice) + } + + @Test + fun `addDevice should skip adding device when device is already registered`() = runTest { + // Given + val device = randomDevice(token = "registered_token", pushProvider = PushProvider.FIREBASE) + val user = randomUser(devices = listOf(device)) + whenever(chatClientMock.addDevice(any())) + .thenReturn(RetroSuccess(Unit).toRetrofitCall()) + + // When + handler.addDevice(user, device) + + // Then + verify(chatClientMock, never()).addDevice(any()) + Assertions.assertEquals(device, handler.currentDevice) + } + + @Test + fun `addDevice should handle null user`() = runTest { + // Given + val device = randomDevice(token = "test_token", pushProvider = PushProvider.FIREBASE) + whenever(chatClientMock.addDevice(any())) + .thenReturn(RetroSuccess(Unit).toRetrofitCall()) + + // When + handler.addDevice(null, device) + + // Then + verify(chatClientMock, times(1)).addDevice(device) + Assertions.assertEquals(device, handler.currentDevice) + } + + @Test + fun `addDevice should not propagate error on failure`() = runTest { + // Given + val device = randomDevice(token = "test_token", pushProvider = PushProvider.FIREBASE) + val user = randomUser(devices = emptyList()) + whenever(chatClientMock.addDevice(any())) + .thenReturn(RetroError(500).toRetrofitCall()) + + // When & Then (should not throw) + handler.addDevice(user, device) + + verify(chatClientMock, times(1)).addDevice(device) + // CurrentDevice should not be set on error + Assertions.assertEquals(null, handler.currentDevice) + } + + // ===== deleteDevice Tests ===== + + @Test + fun `deleteDevice should delete device when currentDevice is set`() = runTest { + // Given + val device = randomDevice(token = "test_token", pushProvider = PushProvider.FIREBASE) + val user = randomUser(devices = emptyList()) + + // First, add the device + whenever(chatClientMock.addDevice(any())) + .thenReturn(RetroSuccess(Unit).toRetrofitCall()) + handler.addDevice(user, device) + + // Now setup mock for delete + whenever(chatClientMock.deleteDevice(any())) + .thenReturn(RetroSuccess(Unit).toRetrofitCall()) + + // When + handler.deleteDevice() + + // Then + verify(chatClientMock, times(1)).addDevice(device) + verify(chatClientMock, times(1)).deleteDevice(device) + Assertions.assertNull(handler.currentDevice) + } + + @Test + fun `deleteDevice should skip deleting when no currentDevice is set`() = runTest { + // When + handler.deleteDevice() + + // Then + verify(chatClientMock, never()).deleteDevice(any()) + } + + @Test + fun `deleteDevice should not propagate error on failure`() = runTest { + // Given + val device = randomDevice(token = "test_token", pushProvider = PushProvider.FIREBASE) + val user = randomUser(devices = listOf(device)) + + // First, add the device + whenever(chatClientMock.addDevice(any())) + .thenReturn(RetroSuccess(Unit).toRetrofitCall()) + handler.addDevice(user, device) + + // Now setup mock for failed delete + whenever(chatClientMock.deleteDevice(any())) + .thenReturn(RetroError(500).toRetrofitCall()) + + // When & Then (should not throw) + handler.deleteDevice() + + verify(chatClientMock, times(1)).deleteDevice(device) + // CurrentDevice should still be set after error + Assertions.assertEquals(device, handler.currentDevice) + } +}