BIT-1361 Setup GCM and Bitwarden push registration (#547)

This commit is contained in:
Sean Weiser 2024-01-09 14:06:15 -06:00 committed by Álison Fernandes
parent 7de13de856
commit 273b18118a
19 changed files with 865 additions and 0 deletions

View file

@ -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,

View file

@ -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?)
}

View file

@ -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) },
)
}
}

View file

@ -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(

View file

@ -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<Unit>
}

View file

@ -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()

View file

@ -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,
)

View file

@ -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<Unit>
}

View file

@ -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<Unit> =
pushApi
.putDeviceToken(
appId = appId,
body = body,
)
}

View file

@ -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()
}

View file

@ -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.
},
)
}
}

View file

@ -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,
)
}

View file

@ -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
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:tools="http://schemas.android.com/tools"
xmlns:android="http://schemas.android.com/apk/res/android">
<application tools:ignore="MissingApplicationIcon">
<service
android:name="com.x8bit.bitwarden.data.push.BitwardenFirebaseMessagingService"
android:exported="false">
<intent-filter>
<action android:name="com.google.firebase.MESSAGING_EVENT" />
</intent-filter>
</service>
</application>
</manifest>

View file

@ -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)
}
}

View file

@ -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))
}
}

View file

@ -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(),
)
}
}

View file

@ -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))
}
}
}
}
}

View file

@ -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))
}
}