Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<Unit> =
@JvmOverloads
public fun disconnect(
flushPersistence: Boolean,
deleteDevice: Boolean = flushPersistence,
): Call<Unit> =
CoroutineCall(clientScope) {
logger.d { "[disconnect] flushPersistence: $flushPersistence" }
when (isUserSet()) {
true -> {
if (deleteDevice) {
notifications.deleteDevice()
}
disconnectSuspend(flushPersistence)
Result.Success(Unit)
}
Expand All @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,30 +29,32 @@ 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,
private val scope: CoroutineScope = CoroutineScope(DispatcherProvider.IO),
) : ChatNotifications {
private val logger by taggedLogger("Chat:Notifications")

private val pushTokenUpdateHandler = PushTokenUpdateHandler(context)
private val pushTokenUpdateHandler = PushTokenUpdateHandler()
private val showedMessages = mutableSetOf<String>()
private val permissionManager: NotificationPermissionManager =
NotificationPermissionManager.createNotificationPermissionsManager(
Expand All @@ -68,23 +70,28 @@ internal class ChatNotificationsImpl constructor(
logger.i { "<init> 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(
Expand All @@ -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()
}
}

/**
Expand Down Expand Up @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Loading
Loading