|
16 | 16 |
|
17 | 17 | package io.getstream.chat.android.client.notifications |
18 | 18 |
|
19 | | -import android.content.Context |
20 | | -import android.content.SharedPreferences |
21 | | -import androidx.core.content.edit |
22 | 19 | 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 |
26 | 20 | import io.getstream.chat.android.models.Device |
27 | | -import io.getstream.chat.android.models.PushProvider |
| 21 | +import io.getstream.chat.android.models.User |
28 | 22 | import io.getstream.log.taggedLogger |
| 23 | +import org.jetbrains.annotations.VisibleForTesting |
29 | 24 |
|
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 | +) { |
39 | 41 |
|
40 | | - private val updateDebouncer = Debouncer(DEBOUNCE_TIMEOUT) |
| 42 | + private val logger by taggedLogger("Chat:Notifications-UH") |
41 | 43 |
|
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 |
59 | 49 |
|
60 | 50 | /** |
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. |
63 | 70 | */ |
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 |
80 | 77 | } |
| 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 | + } |
81 | 86 | } |
82 | 87 |
|
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 | + } |
95 | 115 | .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}" } |
100 | 117 | } |
101 | | - .isSuccess |
102 | 118 | } |
103 | 119 |
|
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 } |
118 | 123 | } |
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 |
134 | 124 | } |
0 commit comments