mirror of
https://github.com/bitwarden/android.git
synced 2025-03-15 10:48:47 +03:00
BIT-1361 Setup GCM and Bitwarden push registration (#547)
This commit is contained in:
parent
7de13de856
commit
273b18118a
19 changed files with 865 additions and 0 deletions
|
@ -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,
|
||||
|
|
|
@ -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?)
|
||||
}
|
|
@ -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) },
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
}
|
|
@ -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()
|
||||
|
|
|
@ -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,
|
||||
)
|
|
@ -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>
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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.
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
|
@ -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
|
||||
}
|
15
app/src/standard/AndroidManifest.xml
Normal file
15
app/src/standard/AndroidManifest.xml
Normal 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>
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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))
|
||||
}
|
||||
}
|
Loading…
Add table
Reference in a new issue