android: sync FCM token with backend notifications API

This commit is contained in:
Codex
2026-03-09 23:12:29 +03:00
parent 74b086b9c8
commit b1b54896a7
7 changed files with 161 additions and 0 deletions

View File

@@ -8,11 +8,17 @@ import coil.memory.MemoryCache
import com.google.firebase.crashlytics.FirebaseCrashlytics
import dagger.hilt.android.HiltAndroidApp
import ru.daemonlord.messenger.core.notifications.NotificationChannels
import ru.daemonlord.messenger.push.PushTokenSyncManager
import java.io.File
import timber.log.Timber
import javax.inject.Inject
@HiltAndroidApp
class MessengerApplication : Application(), ImageLoaderFactory {
@Inject
lateinit var pushTokenSyncManager: PushTokenSyncManager
override fun onCreate() {
super.onCreate()
if (BuildConfig.DEBUG) {
@@ -20,6 +26,7 @@ class MessengerApplication : Application(), ImageLoaderFactory {
}
FirebaseCrashlytics.getInstance().setCrashlyticsCollectionEnabled(!BuildConfig.DEBUG)
NotificationChannels.ensureCreated(this)
pushTokenSyncManager.triggerBestEffortSync()
}
override fun newImageLoader(): ImageLoader {

View File

@@ -17,6 +17,7 @@ import ru.daemonlord.messenger.domain.auth.model.AuthSession
import ru.daemonlord.messenger.domain.auth.repository.AuthRepository
import ru.daemonlord.messenger.domain.common.AppError
import ru.daemonlord.messenger.domain.common.AppResult
import ru.daemonlord.messenger.push.PushTokenSyncManager
import javax.inject.Inject
import javax.inject.Singleton
@@ -24,6 +25,7 @@ import javax.inject.Singleton
class NetworkAuthRepository @Inject constructor(
private val authApiService: AuthApiService,
private val tokenRepository: TokenRepository,
private val pushTokenSyncManager: PushTokenSyncManager,
@IoDispatcher private val ioDispatcher: CoroutineDispatcher,
) : AuthRepository {
@@ -42,6 +44,7 @@ class NetworkAuthRepository @Inject constructor(
savedAtMillis = System.currentTimeMillis(),
)
)
pushTokenSyncManager.triggerBestEffortSync()
getMe()
} catch (error: Throwable) {
AppResult.Error(error.toAppError(mode = ApiErrorMode.LOGIN))
@@ -62,6 +65,7 @@ class NetworkAuthRepository @Inject constructor(
savedAtMillis = System.currentTimeMillis(),
)
)
pushTokenSyncManager.triggerBestEffortSync()
AppResult.Success(Unit)
} catch (error: Throwable) {
tokenRepository.clearTokens()
@@ -125,6 +129,7 @@ class NetworkAuthRepository @Inject constructor(
}
override suspend fun logout() {
pushTokenSyncManager.unregisterCurrentTokenOnLogout()
tokenRepository.clearTokens()
}

View File

@@ -0,0 +1,15 @@
package ru.daemonlord.messenger.data.notifications.api
import retrofit2.http.Body
import retrofit2.http.HTTP
import retrofit2.http.POST
import ru.daemonlord.messenger.data.notifications.dto.PushTokenDeleteRequestDto
import ru.daemonlord.messenger.data.notifications.dto.PushTokenUpsertRequestDto
interface PushTokenApiService {
@POST("/api/v1/notifications/push-token")
suspend fun upsert(@Body request: PushTokenUpsertRequestDto)
@HTTP(method = "DELETE", path = "/api/v1/notifications/push-token", hasBody = true)
suspend fun delete(@Body request: PushTokenDeleteRequestDto)
}

View File

@@ -0,0 +1,24 @@
package ru.daemonlord.messenger.data.notifications.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class PushTokenUpsertRequestDto(
@SerialName("platform")
val platform: String,
@SerialName("token")
val token: String,
@SerialName("device_id")
val deviceId: String? = null,
@SerialName("app_version")
val appVersion: String? = null,
)
@Serializable
data class PushTokenDeleteRequestDto(
@SerialName("platform")
val platform: String,
@SerialName("token")
val token: String,
)

View File

@@ -19,6 +19,7 @@ import ru.daemonlord.messenger.data.auth.api.AuthApiService
import ru.daemonlord.messenger.data.chat.api.ChatApiService
import ru.daemonlord.messenger.data.media.api.MediaApiService
import ru.daemonlord.messenger.data.message.api.MessageApiService
import ru.daemonlord.messenger.data.notifications.api.PushTokenApiService
import ru.daemonlord.messenger.data.search.api.SearchApiService
import ru.daemonlord.messenger.data.user.api.UserApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
@@ -154,4 +155,10 @@ object NetworkModule {
fun provideSearchApiService(retrofit: Retrofit): SearchApiService {
return retrofit.create(SearchApiService::class.java)
}
@Provides
@Singleton
fun providePushTokenApiService(retrofit: Retrofit): PushTokenApiService {
return retrofit.create(PushTokenApiService::class.java)
}
}

View File

@@ -13,6 +13,9 @@ class MessengerFirebaseMessagingService : FirebaseMessagingService() {
@Inject
lateinit var notificationDispatcher: NotificationDispatcher
@Inject
lateinit var pushTokenSyncManager: PushTokenSyncManager
override fun onMessageReceived(message: RemoteMessage) {
val payload = PushPayloadParser.parse(message) ?: return
notificationDispatcher.showChatMessage(payload)
@@ -20,5 +23,6 @@ class MessengerFirebaseMessagingService : FirebaseMessagingService() {
override fun onNewToken(token: String) {
Log.i("MessengerPush", "FCM token refreshed (${token.take(10)}...)")
pushTokenSyncManager.onNewToken(token)
}
}

View File

@@ -0,0 +1,99 @@
package ru.daemonlord.messenger.push
import android.content.SharedPreferences
import com.google.firebase.messaging.FirebaseMessaging
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import ru.daemonlord.messenger.BuildConfig
import ru.daemonlord.messenger.core.token.TokenRepository
import ru.daemonlord.messenger.data.notifications.api.PushTokenApiService
import ru.daemonlord.messenger.data.notifications.dto.PushTokenDeleteRequestDto
import ru.daemonlord.messenger.data.notifications.dto.PushTokenUpsertRequestDto
import ru.daemonlord.messenger.di.IoDispatcher
import ru.daemonlord.messenger.di.TokenPrefs
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class PushTokenSyncManager @Inject constructor(
private val tokenRepository: TokenRepository,
private val pushTokenApiService: PushTokenApiService,
@TokenPrefs private val securePrefs: SharedPreferences,
@IoDispatcher ioDispatcher: CoroutineDispatcher,
) {
private val scope = CoroutineScope(SupervisorJob() + ioDispatcher)
fun onNewToken(token: String) {
val cleaned = token.trim()
if (cleaned.isBlank()) {
return
}
securePrefs.edit().putString(KEY_LAST_FCM_TOKEN, cleaned).apply()
scope.launch {
registerTokenIfPossible(cleaned)
}
}
fun triggerBestEffortSync() {
val cached = securePrefs.getString(KEY_LAST_FCM_TOKEN, null)?.trim().orEmpty()
if (cached.isNotBlank()) {
scope.launch {
registerTokenIfPossible(cached)
}
}
FirebaseMessaging.getInstance().token
.addOnSuccessListener { token -> onNewToken(token) }
.addOnFailureListener { error ->
Timber.w(error, "Failed to fetch FCM token for sync")
}
}
suspend fun unregisterCurrentTokenOnLogout() {
val token = securePrefs.getString(KEY_LAST_FCM_TOKEN, null)?.trim().orEmpty()
if (token.isBlank()) {
return
}
val hasTokens = tokenRepository.getTokens() != null
if (!hasTokens) {
return
}
runCatching {
pushTokenApiService.delete(
request = PushTokenDeleteRequestDto(
platform = PLATFORM_ANDROID,
token = token,
)
)
}.onFailure { error ->
Timber.w(error, "Failed to unregister push token on logout")
}
}
private suspend fun registerTokenIfPossible(token: String) {
val hasTokens = tokenRepository.getTokens() != null
if (!hasTokens) {
return
}
runCatching {
pushTokenApiService.upsert(
request = PushTokenUpsertRequestDto(
platform = PLATFORM_ANDROID,
token = token,
deviceId = null,
appVersion = BuildConfig.VERSION_NAME,
)
)
}.onFailure { error ->
Timber.w(error, "Failed to sync push token")
}
}
private companion object {
const val KEY_LAST_FCM_TOKEN = "last_fcm_token"
const val PLATFORM_ANDROID = "android"
}
}