From 08fd7477cdb3d1cdbe0c49d7b2d3357adf3b8241 Mon Sep 17 00:00:00 2001 From: Sean Weiser <125889608+sean-livefront@users.noreply.github.com> Date: Tue, 9 Jan 2024 14:06:15 -0600 Subject: [PATCH] BIT-1361 Setup GCM and Bitwarden push registration (#547) --- .../datasource/disk/BaseDiskSource.kt | 31 +++ .../datasource/disk/PushDiskSource.kt | 34 +++ .../datasource/disk/PushDiskSourceImpl.kt | 54 +++++ .../datasource/disk/di/PlatformDiskModule.kt | 11 + .../datasource/network/api/PushApi.kt | 17 ++ .../network/di/PlatformNetworkModule.kt | 13 ++ .../network/model/PushTokenRequest.kt | 12 + .../datasource/network/service/PushService.kt | 15 ++ .../network/service/PushServiceImpl.kt | 18 ++ .../data/platform/manager/PushManager.kt | 16 ++ .../data/platform/manager/PushManagerImpl.kt | 104 +++++++++ .../platform/manager/di/PushManagerModule.kt | 38 +++ .../data/platform/util/ZonedDateTimeUtils.kt | 44 ++++ app/src/standard/AndroidManifest.xml | 15 ++ .../push/BitwardenFirebaseMessagingService.kt | 24 ++ .../datasource/disk/PushDiskSourceTest.kt | 135 +++++++++++ .../network/service/PushServiceTest.kt | 36 +++ .../data/platform/manager/PushManagerTest.kt | 221 ++++++++++++++++++ .../platform/util/ZonedDateTimeUtilsTest.kt | 27 +++ 19 files changed, 865 insertions(+) create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSource.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/api/PushApi.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/model/PushTokenRequest.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/service/PushService.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/service/PushServiceImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/PushManager.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PushManagerModule.kt create mode 100644 app/src/main/java/com/x8bit/bitwarden/data/platform/util/ZonedDateTimeUtils.kt create mode 100644 app/src/standard/AndroidManifest.xml create mode 100644 app/src/standard/java/com/x8bit/bitwarden/data/push/BitwardenFirebaseMessagingService.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/service/PushServiceTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt create mode 100644 app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ZonedDateTimeUtilsTest.kt diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/BaseDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/BaseDiskSource.kt index 2741d01fc6..10e48aafc8 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/BaseDiskSource.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/BaseDiskSource.kt @@ -41,6 +41,37 @@ abstract class BaseDiskSource( } } + /** + * Gets the [Long] for the given [key] from [SharedPreferences], or return the [default] value + * if that key is not present. + */ + protected fun getLong( + key: String, + default: Long? = null, + ): Long? = + if (sharedPreferences.contains(key)) { + sharedPreferences.getLong(key, 0) + } else { + // Make sure we can return a null value as a default if necessary + default + } + + /** + * Puts the [value] in [SharedPreferences] for the given [key] (or removes the key when the + * value is `null`). + */ + protected fun putLong( + key: String, + value: Long?, + ): Unit = + sharedPreferences.edit { + if (value != null) { + putLong(key, value) + } else { + remove(key) + } + } + protected fun getString( key: String, default: String? = null, diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSource.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSource.kt new file mode 100644 index 0000000000..cd7498c8f5 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSource.kt @@ -0,0 +1,34 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import java.time.ZonedDateTime + +/** + * Primary access point for push notification information. + */ +interface PushDiskSource { + /** + * The currently registered GCM push token. A single token will be registered for the device, + * regardless of the user. + */ + var registeredPushToken: String? + + /** + * Retrieves the last stored token for a user. + */ + fun getCurrentPushToken(userId: String): String? + + /** + * Retrieves the last time a push token was registered for a user. + */ + fun getLastPushTokenRegistrationDate(userId: String): ZonedDateTime? + + /** + * Sets the current token for a user. + */ + fun storeCurrentPushToken(userId: String, pushToken: String?) + + /** + * Sets the last push token registration date for a user. + */ + fun storeLastPushTokenRegistrationDate(userId: String, registrationDate: ZonedDateTime?) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceImpl.kt new file mode 100644 index 0000000000..5ae2e28b09 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceImpl.kt @@ -0,0 +1,54 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import android.content.SharedPreferences +import com.x8bit.bitwarden.data.platform.datasource.disk.BaseDiskSource.Companion.BASE_KEY +import com.x8bit.bitwarden.data.platform.util.getBinaryLongFromZoneDateTime +import com.x8bit.bitwarden.data.platform.util.getZoneDateTimeFromBinaryLong +import java.time.ZonedDateTime + +private const val CURRENT_PUSH_TOKEN_KEY = "${BASE_KEY}:pushCurrentToken" +private const val LAST_REGISTRATION_DATE_KEY = "${BASE_KEY}:pushLastRegistrationDate" +private const val REGISTERED_PUSH_TOKEN_KEY = "${BASE_KEY}:pushRegisteredToken" + +/** + * Primary implementation of [PushDiskSource]. + */ +class PushDiskSourceImpl( + sharedPreferences: SharedPreferences, +) : BaseDiskSource(sharedPreferences = sharedPreferences), + PushDiskSource { + override var registeredPushToken: String? + get() = getString(key = REGISTERED_PUSH_TOKEN_KEY) + set(value) { + putString( + key = REGISTERED_PUSH_TOKEN_KEY, + value = value, + ) + } + + override fun getCurrentPushToken(userId: String): String? { + return getString("${CURRENT_PUSH_TOKEN_KEY}_$userId") + } + + override fun getLastPushTokenRegistrationDate(userId: String): ZonedDateTime? { + return getLong("${LAST_REGISTRATION_DATE_KEY}_$userId", null) + ?.let { getZoneDateTimeFromBinaryLong(it) } + } + + override fun storeCurrentPushToken(userId: String, pushToken: String?) { + putString( + key = "${CURRENT_PUSH_TOKEN_KEY}_$userId", + value = pushToken, + ) + } + + override fun storeLastPushTokenRegistrationDate( + userId: String, + registrationDate: ZonedDateTime?, + ) { + putLong( + key = "${LAST_REGISTRATION_DATE_KEY}_$userId", + value = registrationDate?.let { getBinaryLongFromZoneDateTime(registrationDate) }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt index b901bf716d..e831a97116 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/disk/di/PlatformDiskModule.kt @@ -4,6 +4,8 @@ import android.content.SharedPreferences import com.x8bit.bitwarden.data.platform.datasource.di.UnencryptedPreferences import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.EnvironmentDiskSourceImpl +import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSourceImpl import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSource import com.x8bit.bitwarden.data.platform.datasource.disk.SettingsDiskSourceImpl import dagger.Module @@ -31,6 +33,15 @@ object PlatformDiskModule { json = json, ) + @Provides + @Singleton + fun providePushDiskSource( + @UnencryptedPreferences sharedPreferences: SharedPreferences, + ): PushDiskSource = + PushDiskSourceImpl( + sharedPreferences = sharedPreferences, + ) + @Provides @Singleton fun provideSettingsDiskSource( diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/api/PushApi.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/api/PushApi.kt new file mode 100644 index 0000000000..b6567163c2 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/api/PushApi.kt @@ -0,0 +1,17 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.api + +import com.x8bit.bitwarden.data.platform.datasource.network.model.PushTokenRequest +import retrofit2.http.Body +import retrofit2.http.PUT +import retrofit2.http.Path + +/** + * Defines API calls for push tokens. + */ +interface PushApi { + @PUT("/devices/identifier/{appId}/token") + suspend fun putDeviceToken( + @Path("appId") appId: String, + @Body body: PushTokenRequest, + ): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt index d08b9eba6e..9b0e93fe16 100644 --- a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/di/PlatformNetworkModule.kt @@ -1,5 +1,6 @@ package com.x8bit.bitwarden.data.platform.datasource.network.di +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource import com.x8bit.bitwarden.data.platform.datasource.network.authenticator.RefreshAuthenticator import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.AuthTokenInterceptor import com.x8bit.bitwarden.data.platform.datasource.network.interceptor.BaseUrlInterceptors @@ -9,6 +10,8 @@ import com.x8bit.bitwarden.data.platform.datasource.network.retrofit.RetrofitsIm import com.x8bit.bitwarden.data.platform.datasource.network.serializer.ZonedDateTimeSerializer import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigService import com.x8bit.bitwarden.data.platform.datasource.network.service.ConfigServiceImpl +import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService +import com.x8bit.bitwarden.data.platform.datasource.network.service.PushServiceImpl import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -34,6 +37,16 @@ object PlatformNetworkModule { retrofits: Retrofits, ): ConfigService = ConfigServiceImpl(retrofits.unauthenticatedApiRetrofit.create()) + @Provides + @Singleton + fun providePushService( + retrofits: Retrofits, + authDiskSource: AuthDiskSource, + ): PushService = PushServiceImpl( + pushApi = retrofits.authenticatedApiRetrofit.create(), + appId = authDiskSource.uniqueAppId, + ) + @Provides @Singleton fun providesAuthTokenInterceptor(): AuthTokenInterceptor = AuthTokenInterceptor() diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/model/PushTokenRequest.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/model/PushTokenRequest.kt new file mode 100644 index 0000000000..05ff3808d1 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/model/PushTokenRequest.kt @@ -0,0 +1,12 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** + * Request body needed to PUT a GCM [pushToken] to Bitwarden's server. + */ +@Serializable +data class PushTokenRequest( + @SerialName("pushToken") val pushToken: String, +) diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/service/PushService.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/service/PushService.kt new file mode 100644 index 0000000000..1291b5c330 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/service/PushService.kt @@ -0,0 +1,15 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.service + +import com.x8bit.bitwarden.data.platform.datasource.network.model.PushTokenRequest + +/** + * Provides an API for push tokens. + */ +interface PushService { + /** + * Updates the user's push token. + */ + suspend fun putDeviceToken( + body: PushTokenRequest, + ): Result +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/service/PushServiceImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/service/PushServiceImpl.kt new file mode 100644 index 0000000000..9916029c62 --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/datasource/network/service/PushServiceImpl.kt @@ -0,0 +1,18 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.service + +import com.x8bit.bitwarden.data.platform.datasource.network.api.PushApi +import com.x8bit.bitwarden.data.platform.datasource.network.model.PushTokenRequest + +class PushServiceImpl( + private val pushApi: PushApi, + private val appId: String, +) : PushService { + override suspend fun putDeviceToken( + body: PushTokenRequest, + ): Result = + pushApi + .putDeviceToken( + appId = appId, + body = body, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/PushManager.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/PushManager.kt new file mode 100644 index 0000000000..8740706b3b --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/PushManager.kt @@ -0,0 +1,16 @@ +package com.x8bit.bitwarden.data.platform.manager + +/** + * Manager to handle push notification registration. + */ +interface PushManager { + /** + * Registers a [token] for the current user with Bitwarden's server if needed. + */ + fun registerPushTokenIfNecessary(token: String) + + /** + * Attempts to register a push token for the current user retrieved from storage if needed. + */ + fun registerStoredPushTokenIfNecessary() +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt new file mode 100644 index 0000000000..2e9364f20c --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/PushManagerImpl.kt @@ -0,0 +1,104 @@ +package com.x8bit.bitwarden.data.platform.manager + +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource +import com.x8bit.bitwarden.data.platform.datasource.network.model.PushTokenRequest +import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import java.time.Clock +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +/** + * Primary implementation of [PushManager]. + */ +class PushManagerImpl @Inject constructor( + private val authDiskSource: AuthDiskSource, + private val pushDiskSource: PushDiskSource, + private val pushService: PushService, + private val clock: Clock, + dispatcherManager: DispatcherManager, +) : PushManager { + private val ioScope = CoroutineScope(dispatcherManager.io) + private val unconfinedScope = CoroutineScope(dispatcherManager.unconfined) + + init { + authDiskSource + .userStateFlow + .mapNotNull { it?.activeUserId } + .distinctUntilChanged() + .onEach { registerStoredPushTokenIfNecessary() } + .launchIn(unconfinedScope) + } + + override fun registerPushTokenIfNecessary(token: String) { + pushDiskSource.registeredPushToken = token + val userId = authDiskSource.userState?.activeUserId ?: return + ioScope.launch { + registerPushTokenIfNecessaryInternal( + userId = userId, + token = token, + ) + } + } + + override fun registerStoredPushTokenIfNecessary() { + val userId = authDiskSource.userState?.activeUserId ?: return + + // If the last registered token is from less than a day before, skip this for now + val lastRegistration = pushDiskSource.getLastPushTokenRegistrationDate(userId)?.toInstant() + val dayBefore = clock.instant().minus(1, ChronoUnit.DAYS) + if (lastRegistration?.isAfter(dayBefore) == true) return + + ioScope.launch { + pushDiskSource.registeredPushToken?.let { + registerPushTokenIfNecessaryInternal( + userId = userId, + token = it, + ) + } + } + } + + private suspend fun registerPushTokenIfNecessaryInternal(userId: String, token: String) { + val currentToken = pushDiskSource.getCurrentPushToken(userId) + + if (token == currentToken) { + // Our token is up-to-date, so just update the last registration date + pushDiskSource.storeLastPushTokenRegistrationDate( + userId, + ZonedDateTime.ofInstant(clock.instant(), ZoneOffset.UTC), + ) + return + } + + pushService + .putDeviceToken( + PushTokenRequest(token), + ) + .fold( + onSuccess = { + pushDiskSource.storeLastPushTokenRegistrationDate( + userId, + ZonedDateTime.ofInstant(clock.instant(), ZoneOffset.UTC), + ) + pushDiskSource.storeCurrentPushToken( + userId = userId, + pushToken = token, + ) + }, + onFailure = { + // Silently fail. This call will be attempted again the next time the token + // registration is done. + }, + ) + } +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PushManagerModule.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PushManagerModule.kt new file mode 100644 index 0000000000..fd69732d1d --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/manager/di/PushManagerModule.kt @@ -0,0 +1,38 @@ +package com.x8bit.bitwarden.data.platform.manager.di + +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource +import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService +import com.x8bit.bitwarden.data.platform.manager.PushManager +import com.x8bit.bitwarden.data.platform.manager.PushManagerImpl +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import java.time.Clock +import javax.inject.Singleton + +/** + * Provides repositories in the push package. + */ +@Module +@InstallIn(SingletonComponent::class) +object PushManagerModule { + + @Provides + @Singleton + fun providePushManager( + authDiskSource: AuthDiskSource, + pushDiskSource: PushDiskSource, + pushService: PushService, + dispatcherManager: DispatcherManager, + clock: Clock, + ): PushManager = PushManagerImpl( + authDiskSource = authDiskSource, + pushDiskSource = pushDiskSource, + pushService = pushService, + dispatcherManager = dispatcherManager, + clock = clock, + ) +} diff --git a/app/src/main/java/com/x8bit/bitwarden/data/platform/util/ZonedDateTimeUtils.kt b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/ZonedDateTimeUtils.kt new file mode 100644 index 0000000000..290875808a --- /dev/null +++ b/app/src/main/java/com/x8bit/bitwarden/data/platform/util/ZonedDateTimeUtils.kt @@ -0,0 +1,44 @@ +package com.x8bit.bitwarden.data.platform.util + +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime + +private const val NANOS_PER_TICK = 100L +private const val TICKS_PER_SECOND = 1000000000L / NANOS_PER_TICK + +/** + * Seconds offset from 01/01/1970 to 01/01/0001. + */ +private const val YEAR_OFFSET = -62135596800L + +/** + * Returns the [ZonedDateTime] of the binary [Long] [value]. This is needed to remain consistent + * with how `DateTime`s were stored when using C#. + * + * This functionality is based on the https://stackoverflow.com/questions/65315060/how-to-convert-net-datetime-tobinary-to-java-date + */ +@Suppress("MagicNumber") +fun getZoneDateTimeFromBinaryLong(value: Long): ZonedDateTime { + // Shift the bits to eliminate the "Kind" property since we know it was stored as UTC and leave + // us with ticks + val ticks = value and (1L shl 62) - 1 + val instant = Instant.ofEpochSecond( + ticks / TICKS_PER_SECOND + YEAR_OFFSET, + ticks % TICKS_PER_SECOND * NANOS_PER_TICK, + ) + return ZonedDateTime.ofInstant(instant, ZoneOffset.UTC) +} + +/** + * Returns the [ZonedDateTime] [value] converted to a binary [Long]. This is needed to remain + * consistent with how `DateTime`s were stored when using C#. + * + * This functionality is based on the https://stackoverflow.com/questions/65315060/how-to-convert-net-datetime-tobinary-to-java-date + */ +@Suppress("MagicNumber") +fun getBinaryLongFromZoneDateTime(value: ZonedDateTime): Long { + val nanoAdjustment = value.nano / NANOS_PER_TICK + val ticks = (value.toEpochSecond() - YEAR_OFFSET) * TICKS_PER_SECOND + nanoAdjustment + return 1L shl 62 or ticks +} diff --git a/app/src/standard/AndroidManifest.xml b/app/src/standard/AndroidManifest.xml new file mode 100644 index 0000000000..d8b75d0a51 --- /dev/null +++ b/app/src/standard/AndroidManifest.xml @@ -0,0 +1,15 @@ + + + + + + + + + + + + diff --git a/app/src/standard/java/com/x8bit/bitwarden/data/push/BitwardenFirebaseMessagingService.kt b/app/src/standard/java/com/x8bit/bitwarden/data/push/BitwardenFirebaseMessagingService.kt new file mode 100644 index 0000000000..dc79962c54 --- /dev/null +++ b/app/src/standard/java/com/x8bit/bitwarden/data/push/BitwardenFirebaseMessagingService.kt @@ -0,0 +1,24 @@ +package com.x8bit.bitwarden.data.push + +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage +import com.x8bit.bitwarden.data.platform.manager.PushManager +import dagger.hilt.android.AndroidEntryPoint +import javax.inject.Inject + +/** + * Handles setup and receiving of push notifications. + */ +@AndroidEntryPoint +class BitwardenFirebaseMessagingService : FirebaseMessagingService() { + @Inject + lateinit var pushManager: PushManager + + override fun onMessageReceived(message: RemoteMessage) { + // TODO handle new messages. See BIT-1362. + } + + override fun onNewToken(token: String) { + pushManager.registerPushTokenIfNecessary(token) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceTest.kt new file mode 100644 index 0000000000..72936af924 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/disk/PushDiskSourceTest.kt @@ -0,0 +1,135 @@ +package com.x8bit.bitwarden.data.platform.datasource.disk + +import androidx.core.content.edit +import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences +import com.x8bit.bitwarden.data.platform.util.getBinaryLongFromZoneDateTime +import com.x8bit.bitwarden.data.platform.util.getZoneDateTimeFromBinaryLong +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.time.ZonedDateTime + +class PushDiskSourceTest { + private val fakeSharedPreferences = FakeSharedPreferences() + + private val pushDiskSource = PushDiskSourceImpl( + sharedPreferences = fakeSharedPreferences, + ) + + @Test + fun `registeredPushToken should pull from and update SharedPreferences`() { + val registeredPushTokenKey = "bwPreferencesStorage:pushRegisteredToken" + + // Shared preferences and the repository start with the same value. + assertNull(pushDiskSource.registeredPushToken) + assertNull(fakeSharedPreferences.getString(registeredPushTokenKey, null)) + + // Updating the repository updates shared preferences + pushDiskSource.registeredPushToken = "abcd" + assertEquals( + "abcd", + fakeSharedPreferences.getString(registeredPushTokenKey, null), + ) + + // Update SharedPreferences updates the repository + fakeSharedPreferences.edit().putString(registeredPushTokenKey, null).apply() + assertNull(pushDiskSource.registeredPushToken) + } + + @Test + fun `getCurrentPushToken should pull from SharedPreferences`() { + val currentPushTokenBaseKey = "bwPreferencesStorage:pushCurrentToken" + val mockUserId = "mockUserId" + val mockCurrentPushToken = "abcd" + fakeSharedPreferences + .edit() + .putString( + "${currentPushTokenBaseKey}_$mockUserId", + mockCurrentPushToken, + ) + .apply() + val actual = pushDiskSource.getCurrentPushToken(userId = mockUserId) + assertEquals( + mockCurrentPushToken, + actual, + ) + } + + @Test + fun `storeCurrentPushToken should update SharedPreferences`() { + val currentPushTokenBaseKey = "bwPreferencesStorage:pushCurrentToken" + val mockUserId = "mockUserId" + val mockCurrentPushToken = "abcd" + pushDiskSource.storeCurrentPushToken( + userId = mockUserId, + pushToken = mockCurrentPushToken, + ) + val actual = fakeSharedPreferences + .getString( + "${currentPushTokenBaseKey}_$mockUserId", + null, + ) + assertEquals( + mockCurrentPushToken, + actual, + ) + } + + @Test + fun `getLastPushTokenRegistrationDate should pull from SharedPreferences`() { + val lastPushTokenBaseKey = "bwPreferencesStorage:pushLastRegistrationDate" + val mockUserId = "mockUserId" + val mockLastPushTokenRegistration = ZonedDateTime.parse("2024-01-06T22:27:45.904314Z") + fakeSharedPreferences + .edit() + .putLong( + "${lastPushTokenBaseKey}_$mockUserId", + getBinaryLongFromZoneDateTime(mockLastPushTokenRegistration), + ) + .apply() + val actual = pushDiskSource.getLastPushTokenRegistrationDate(userId = mockUserId)!! + assertEquals( + mockLastPushTokenRegistration, + actual, + ) + } + + @Test + fun `storeLastPushTokenRegistrationDate for non-null values should update SharedPreferences`() { + val lastPushTokenBaseKey = "bwPreferencesStorage:pushLastRegistrationDate" + val mockUserId = "mockUserId" + val mockLastPushTokenRegistration = ZonedDateTime.parse("2024-01-06T22:27:45.904314Z") + pushDiskSource.storeLastPushTokenRegistrationDate( + userId = mockUserId, + registrationDate = mockLastPushTokenRegistration, + ) + val actual = fakeSharedPreferences + .getLong( + "${lastPushTokenBaseKey}_$mockUserId", + 0, + ) + assertEquals( + mockLastPushTokenRegistration, + getZoneDateTimeFromBinaryLong(actual), + ) + } + + @Test + fun `storeLastPushTokenRegistrationDate for null values should clear SharedPreferences`() { + val lastPushTokenBaseKey = "bwPreferencesStorage:pushLastRegistrationDate" + val mockUserId = "mockUserId" + val mockLastPushTokenRegistration = ZonedDateTime.now() + val lastPushTokenKey = "${lastPushTokenBaseKey}_$mockUserId" + fakeSharedPreferences.edit { + putLong(lastPushTokenKey, mockLastPushTokenRegistration.toEpochSecond()) + } + assertTrue(fakeSharedPreferences.contains(lastPushTokenKey)) + pushDiskSource.storeLastPushTokenRegistrationDate( + userId = mockUserId, + registrationDate = null, + ) + assertFalse(fakeSharedPreferences.contains(lastPushTokenKey)) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/service/PushServiceTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/service/PushServiceTest.kt new file mode 100644 index 0000000000..82a55dc91f --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/datasource/network/service/PushServiceTest.kt @@ -0,0 +1,36 @@ +package com.x8bit.bitwarden.data.platform.datasource.network.service + +import com.x8bit.bitwarden.data.platform.base.BaseServiceTest +import com.x8bit.bitwarden.data.platform.datasource.network.api.PushApi +import com.x8bit.bitwarden.data.platform.datasource.network.model.PushTokenRequest +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import org.junit.Test +import org.junit.jupiter.api.Assertions.assertEquals +import retrofit2.create +import java.util.UUID + +class PushServiceTest : BaseServiceTest() { + private val mockAppId = UUID.randomUUID().toString() + private val pushApi: PushApi = retrofit.create() + + private val pushService: PushService = PushServiceImpl( + pushApi = pushApi, + appId = mockAppId, + ) + + @Test + fun `putDeviceToken should return the correct response`() = runTest { + val pushToken = UUID.randomUUID().toString() + server.enqueue(MockResponse()) + val result = pushService.putDeviceToken( + body = PushTokenRequest( + pushToken = pushToken, + ), + ) + assertEquals( + Unit, + result.getOrThrow(), + ) + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt new file mode 100644 index 0000000000..305425a151 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/data/platform/manager/PushManagerTest.kt @@ -0,0 +1,221 @@ +package com.x8bit.bitwarden.data.platform.manager + +import com.x8bit.bitwarden.data.auth.datasource.disk.AuthDiskSource +import com.x8bit.bitwarden.data.auth.datasource.disk.model.UserStateJson +import com.x8bit.bitwarden.data.auth.datasource.disk.util.FakeAuthDiskSource +import com.x8bit.bitwarden.data.platform.base.FakeDispatcherManager +import com.x8bit.bitwarden.data.platform.base.FakeSharedPreferences +import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSource +import com.x8bit.bitwarden.data.platform.datasource.disk.PushDiskSourceImpl +import com.x8bit.bitwarden.data.platform.datasource.network.model.PushTokenRequest +import com.x8bit.bitwarden.data.platform.datasource.network.service.PushService +import com.x8bit.bitwarden.data.platform.manager.dispatcher.DispatcherManager +import com.x8bit.bitwarden.data.platform.util.asFailure +import com.x8bit.bitwarden.data.platform.util.asSuccess +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Nested +import org.junit.jupiter.api.Test +import java.time.Clock +import java.time.Instant +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.time.temporal.ChronoUnit +import java.util.TimeZone + +class PushManagerTest { + private val clock = Clock.fixed( + Instant.parse("2023-10-27T12:00:00Z"), + TimeZone.getTimeZone("UTC").toZoneId(), + ) + + private val dispatcherManager: DispatcherManager = FakeDispatcherManager() + + private val authDiskSource: AuthDiskSource = FakeAuthDiskSource() + + private val pushDiskSource: PushDiskSource = PushDiskSourceImpl(FakeSharedPreferences()) + + private val pushService: PushService = mockk() + + private lateinit var pushManager: PushManager + + @BeforeEach + fun setUp() { + pushManager = PushManagerImpl( + authDiskSource = authDiskSource, + pushDiskSource = pushDiskSource, + pushService = pushService, + dispatcherManager = dispatcherManager, + clock = clock, + ) + } + + @Nested + inner class NullUserState { + @BeforeEach + fun setUp() { + authDiskSource.userState = null + } + + @Test + fun `registerPushTokenIfNecessary should update registeredPushToken`() { + assertEquals(null, pushDiskSource.registeredPushToken) + + val token = "token" + pushManager.registerPushTokenIfNecessary(token) + + assertEquals(token, pushDiskSource.registeredPushToken) + } + + @Test + fun `registerStoredPushTokenIfNecessary should do nothing`() { + pushManager.registerStoredPushTokenIfNecessary() + + assertNull(pushDiskSource.registeredPushToken) + } + } + + @Nested + inner class NonNullUserState { + private val existingToken = "existingToken" + private val userId = "userId" + + @BeforeEach + fun setUp() { + pushDiskSource.storeCurrentPushToken(userId, existingToken) + authDiskSource.userState = UserStateJson(userId, mapOf(userId to mockk())) + } + + @Suppress("MaxLineLength") + @Test + fun `registerStoredPushTokenIfNecessary should do nothing if registered less than a day before`() { + val lastRegistration = ZonedDateTime.ofInstant( + clock.instant().minus(23, ChronoUnit.HOURS), + ZoneOffset.UTC, + ) + pushDiskSource.registeredPushToken = existingToken + pushDiskSource.storeLastPushTokenRegistrationDate( + userId, + lastRegistration, + ) + pushManager.registerStoredPushTokenIfNecessary() + + // Assert the last registration value has not changed + assertEquals( + lastRegistration.toEpochSecond(), + pushDiskSource.getLastPushTokenRegistrationDate(userId)!!.toEpochSecond(), + ) + } + + @Nested + inner class MatchingToken { + private val newToken = "existingToken" + + @Suppress("MaxLineLength") + @Test + fun `registerPushTokenIfNecessary should update registeredPushToken and lastPushTokenRegistrationDate`() { + pushManager.registerPushTokenIfNecessary(newToken) + + coVerify(exactly = 0) { pushService.putDeviceToken(any()) } + assertEquals(newToken, pushDiskSource.registeredPushToken) + assertEquals( + clock.instant().epochSecond, + pushDiskSource.getLastPushTokenRegistrationDate(userId)?.toEpochSecond(), + ) + } + + @Suppress("MaxLineLength") + @Test + fun `registerStoredPushTokenIfNecessary should update registeredPushToken and lastPushTokenRegistrationDate`() { + pushDiskSource.registeredPushToken = newToken + pushManager.registerStoredPushTokenIfNecessary() + + coVerify(exactly = 0) { pushService.putDeviceToken(any()) } + assertEquals(newToken, pushDiskSource.registeredPushToken) + assertEquals( + clock.instant().epochSecond, + pushDiskSource.getLastPushTokenRegistrationDate(userId)?.toEpochSecond(), + ) + } + } + + @Nested + inner class DifferentToken { + private val newToken = "newToken" + + @Nested + inner class SuccessfulRequest { + @BeforeEach + fun setUp() { + coEvery { + pushService.putDeviceToken(any()) + } returns Unit.asSuccess() + } + + @Suppress("MaxLineLength") + @Test + fun `registerPushTokenIfNecessary should update registeredPushToken, lastPushTokenRegistrationDate and currentPushToken`() { + pushManager.registerPushTokenIfNecessary(newToken) + + coVerify(exactly = 1) { pushService.putDeviceToken(PushTokenRequest(newToken)) } + assertEquals( + clock.instant().epochSecond, + pushDiskSource.getLastPushTokenRegistrationDate(userId)?.toEpochSecond(), + ) + assertEquals(newToken, pushDiskSource.registeredPushToken) + assertEquals(newToken, pushDiskSource.getCurrentPushToken(userId)) + } + + @Suppress("MaxLineLength") + @Test + fun `registerStoredPushTokenIfNecessary should update registeredPushToken, lastPushTokenRegistrationDate and currentPushToken`() { + pushDiskSource.registeredPushToken = newToken + pushManager.registerStoredPushTokenIfNecessary() + + coVerify(exactly = 1) { pushService.putDeviceToken(PushTokenRequest(newToken)) } + assertEquals( + clock.instant().epochSecond, + pushDiskSource.getLastPushTokenRegistrationDate(userId)?.toEpochSecond(), + ) + assertEquals(newToken, pushDiskSource.registeredPushToken) + assertEquals(newToken, pushDiskSource.getCurrentPushToken(userId)) + } + } + + @Nested + inner class FailedRequest { + @BeforeEach + fun setUp() { + coEvery { + pushService.putDeviceToken(any()) + } returns Throwable().asFailure() + } + + @Test + fun `registerPushTokenIfNecessary should update registeredPushToken`() { + pushManager.registerPushTokenIfNecessary(newToken) + + coVerify(exactly = 1) { pushService.putDeviceToken(PushTokenRequest(newToken)) } + assertNull(pushDiskSource.getLastPushTokenRegistrationDate(userId)) + assertEquals(newToken, pushDiskSource.registeredPushToken) + assertEquals(existingToken, pushDiskSource.getCurrentPushToken(userId)) + } + + @Test + fun `registerStoredPushTokenIfNecessary should update registeredPushToken`() { + pushDiskSource.registeredPushToken = newToken + pushManager.registerStoredPushTokenIfNecessary() + + coVerify(exactly = 1) { pushService.putDeviceToken(PushTokenRequest(newToken)) } + assertNull(pushDiskSource.getLastPushTokenRegistrationDate(userId)) + assertEquals(newToken, pushDiskSource.registeredPushToken) + assertEquals(existingToken, pushDiskSource.getCurrentPushToken(userId)) + } + } + } + } +} diff --git a/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ZonedDateTimeUtilsTest.kt b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ZonedDateTimeUtilsTest.kt new file mode 100644 index 0000000000..d487faa849 --- /dev/null +++ b/app/src/test/java/com/x8bit/bitwarden/ui/platform/util/ZonedDateTimeUtilsTest.kt @@ -0,0 +1,27 @@ +package com.x8bit.bitwarden.ui.platform.util + +import com.x8bit.bitwarden.data.platform.util.getBinaryLongFromZoneDateTime +import com.x8bit.bitwarden.data.platform.util.getZoneDateTimeFromBinaryLong +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.ZonedDateTime + +class ZonedDateTimeUtilsTest { + @Test + fun `getZoneDateTimeFromBinaryLong should correctly convert a Long to a ZonedDateTime`() { + val binaryLong = 5250087787086431044L + val expectedDateTime = ZonedDateTime.parse("2024-01-06T22:27:45.904314Z") + assertEquals(expectedDateTime, getZoneDateTimeFromBinaryLong(binaryLong)) + + val a = getZoneDateTimeFromBinaryLong(binaryLong) + val b = getBinaryLongFromZoneDateTime(a) + assertEquals(binaryLong, b) + } + + @Test + fun `getBinaryLongFromZoneDateTime should correctly convert a ZonedDateTime to a Long`() { + val dateTime = ZonedDateTime.parse("2024-01-06T22:27:45.904314Z") + val expectedBinaryLong = 5250087787086431044L + assertEquals(expectedBinaryLong, getBinaryLongFromZoneDateTime(dateTime)) + } +}