diff --git a/android/app/src/main/java/ru/daemonlord/messenger/MessengerApplication.kt b/android/app/src/main/java/ru/daemonlord/messenger/MessengerApplication.kt index 42d201e..19c2238 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/MessengerApplication.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/MessengerApplication.kt @@ -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 { diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt index 30bc270..f7fbc19 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/auth/repository/NetworkAuthRepository.kt @@ -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() } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/api/PushTokenApiService.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/api/PushTokenApiService.kt new file mode 100644 index 0000000..644848d --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/api/PushTokenApiService.kt @@ -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) +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/dto/PushTokenDtos.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/dto/PushTokenDtos.kt new file mode 100644 index 0000000..6d579ae --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/notifications/dto/PushTokenDtos.kt @@ -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, +) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt b/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt index b49ed87..cb74d78 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/di/NetworkModule.kt @@ -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) + } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/push/MessengerFirebaseMessagingService.kt b/android/app/src/main/java/ru/daemonlord/messenger/push/MessengerFirebaseMessagingService.kt index d035ce0..4789b1d 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/push/MessengerFirebaseMessagingService.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/push/MessengerFirebaseMessagingService.kt @@ -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) } } diff --git a/android/app/src/main/java/ru/daemonlord/messenger/push/PushTokenSyncManager.kt b/android/app/src/main/java/ru/daemonlord/messenger/push/PushTokenSyncManager.kt new file mode 100644 index 0000000..a568314 --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/push/PushTokenSyncManager.kt @@ -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" + } +}