1
0
Fork 0
mirror of https://github.com/anyproto/anytype-kotlin.git synced 2025-06-07 21:37:02 +09:00

DROID-3634 Notifications | Handle pushKeyUpdates and store (#2379)

This commit is contained in:
Konstantin Ivanov 2025-05-06 11:28:59 +02:00 committed by GitHub
parent c0ba062b81
commit 1ef93d72db
Signed by: github
GPG key ID: B5690EEEBB952194
8 changed files with 619 additions and 0 deletions

View file

@ -1,16 +1,24 @@
package com.anytypeio.anytype.di.main
import android.content.Context
import android.content.SharedPreferences
import com.anytypeio.anytype.app.AnytypeNotificationService
import com.anytypeio.anytype.data.auth.event.NotificationsDateChannel
import com.anytypeio.anytype.data.auth.event.NotificationsRemoteChannel
import com.anytypeio.anytype.data.auth.event.PushKeyDataChannel
import com.anytypeio.anytype.data.auth.event.PushKeyRemoteChannel
import com.anytypeio.anytype.domain.account.AwaitAccountStartManager
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.chats.PushKeyChannel
import com.anytypeio.anytype.domain.notifications.SystemNotificationService
import com.anytypeio.anytype.domain.workspace.NotificationsChannel
import com.anytypeio.anytype.middleware.EventProxy
import com.anytypeio.anytype.middleware.interactor.EventHandlerChannel
import com.anytypeio.anytype.middleware.interactor.NotificationsMiddlewareChannel
import com.anytypeio.anytype.middleware.interactor.events.PushKeyMiddlewareChannel
import com.anytypeio.anytype.presentation.notifications.NotificationsProvider
import com.anytypeio.anytype.presentation.notifications.PushKeyProvider
import com.anytypeio.anytype.presentation.notifications.PushKeyProviderImpl
import dagger.Module
import dagger.Provides
import javax.inject.Named
@ -59,4 +67,43 @@ object NotificationsModule {
notificationsChannel = notificationsChannel,
awaitAccountStartManager = awaitAccountStartManager,
)
@JvmStatic
@Provides
@Singleton
fun providePushKeyProvider(
@Named("encrypted") sharedPreferences: SharedPreferences,
dispatchers: AppCoroutineDispatchers,
@Named(ConfigModule.DEFAULT_APP_COROUTINE_SCOPE) scope: CoroutineScope,
channel: PushKeyChannel
): PushKeyProvider {
return PushKeyProviderImpl(
sharedPreferences = sharedPreferences,
dispatchers = dispatchers,
scope = scope,
channel = channel
)
}
@JvmStatic
@Provides
@Singleton
fun providePushKeyRemoteChannel(
channel: EventHandlerChannel,
@Named(ConfigModule.DEFAULT_APP_COROUTINE_SCOPE) scope: CoroutineScope,
dispatchers: AppCoroutineDispatchers
): PushKeyRemoteChannel = PushKeyMiddlewareChannel(
channel = channel,
scope = scope,
dispatcher = dispatchers.io
)
@JvmStatic
@Provides
@Singleton
fun providePushKeyChannel(
channel: PushKeyRemoteChannel
): PushKeyChannel = PushKeyDataChannel(
channel = channel
)
}

View file

@ -0,0 +1,13 @@
package com.anytypeio.anytype.core_models.chats
data class PushKeyUpdate(
val encryptionKeyId: String,
val encryptionKey: String
) {
companion object {
val EMPTY = PushKeyUpdate(
encryptionKeyId = "",
encryptionKey = ""
)
}
}

View file

@ -0,0 +1,20 @@
package com.anytypeio.anytype.data.auth.event
import com.anytypeio.anytype.core_models.chats.PushKeyUpdate
import com.anytypeio.anytype.domain.chats.PushKeyChannel
import kotlinx.coroutines.flow.Flow
interface PushKeyRemoteChannel {
fun start()
fun stop()
fun observe(): Flow<PushKeyUpdate>
}
class PushKeyDataChannel(
private val channel: PushKeyRemoteChannel
) : PushKeyChannel {
override fun observe(): Flow<PushKeyUpdate> {
return channel.observe()
}
}

View file

@ -0,0 +1,8 @@
package com.anytypeio.anytype.domain.chats
import com.anytypeio.anytype.core_models.chats.PushKeyUpdate
import kotlinx.coroutines.flow.Flow
interface PushKeyChannel {
fun observe(): Flow<PushKeyUpdate>
}

View file

@ -0,0 +1,58 @@
package com.anytypeio.anytype.middleware.interactor.events
import com.anytypeio.anytype.core_models.chats.PushKeyUpdate
import com.anytypeio.anytype.core_utils.ext.cancel
import com.anytypeio.anytype.data.auth.event.PushKeyRemoteChannel
import com.anytypeio.anytype.middleware.interactor.EventHandlerChannel
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.launch
import timber.log.Timber
class PushKeyMiddlewareChannel(
private val scope: CoroutineScope,
private val channel: EventHandlerChannel,
private val dispatcher: CoroutineDispatcher
) : PushKeyRemoteChannel {
private val jobs = mutableListOf<Job>()
private val _pushKeyStatus = MutableStateFlow<PushKeyUpdate>(PushKeyUpdate.EMPTY)
val pushKeyStatus: Flow<PushKeyUpdate> = _pushKeyStatus.asStateFlow()
override fun start() {
Timber.i("PushKeyMiddlewareChannel start")
jobs.cancel()
jobs += scope.launch(dispatcher) {
channel.flow()
.catch {
Timber.w(it, "Error collecting push key updates")
}
.collect { emission ->
emission.messages.forEach { message ->
message.pushEncryptionKeyUpdate?.let {
val pushKeyUpdate = PushKeyUpdate(
encryptionKeyId = it.encryptionKeyId,
encryptionKey = it.encryptionKey
)
_pushKeyStatus.value = pushKeyUpdate
}
}
}
}
}
override fun stop() {
Timber.i("PushKeyMiddlewareChannel stop")
jobs.cancel()
}
override fun observe(): Flow<PushKeyUpdate> {
return pushKeyStatus
}
}

View file

@ -0,0 +1,227 @@
package com.anytypeio.anytype.middleware.interactor
import app.cash.turbine.test
import app.cash.turbine.turbineScope
import com.anytypeio.anytype.core_models.chats.PushKeyUpdate
import com.anytypeio.anytype.middleware.interactor.events.PushKeyMiddlewareChannel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import net.bytebuddy.utility.RandomString
import org.junit.Assert.assertEquals
import org.junit.Before
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class PushKeyMiddlewareChannelTest {
private val dispatcher = StandardTestDispatcher(name = "Default test dispatcher")
private val testScope = TestScope(dispatcher)
lateinit var eventHandlerChannel: EventHandlerChannel
private lateinit var channel: PushKeyMiddlewareChannel
@Before
fun setup() {
eventHandlerChannel = EventHandlerChannelImpl()
channel = PushKeyMiddlewareChannel(
scope = testScope,
channel = eventHandlerChannel,
dispatcher = dispatcher
)
}
@Test
fun `should emit empty initially`() = runTest {
// Given
val initialValue = channel.observe().first()
// Then
assertEquals(PushKeyUpdate.EMPTY, initialValue)
}
@Test
fun `should emit push key update when receiving two valid events`() = runTest(dispatcher) {
turbineScope {
// Given
val expectedUpdate1 = PushKeyUpdate(
encryptionKeyId = RandomString.make(),
encryptionKey = RandomString.make(),
)
val event1 = anytype.Event(
messages = listOf(
anytype.Event.Message(
pushEncryptionKeyUpdate = anytype.Event.PushEncryptionKey.Update(
encryptionKeyId = expectedUpdate1.encryptionKeyId,
encryptionKey = expectedUpdate1.encryptionKey
)
)
)
)
val expectedUpdate2 = PushKeyUpdate(
encryptionKeyId = RandomString.make(),
encryptionKey = RandomString.make()
)
val event2 = anytype.Event(
messages = listOf(
anytype.Event.Message(
pushEncryptionKeyUpdate = anytype.Event.PushEncryptionKey.Update(
encryptionKeyId = expectedUpdate2.encryptionKeyId,
encryptionKey = expectedUpdate2.encryptionKey
)
)
)
)
// When
channel.start()
dispatcher.scheduler.advanceUntilIdle()
// Then
channel.observe().test {
val emittedValue = awaitItem()
assertEquals(PushKeyUpdate.EMPTY, emittedValue)
eventHandlerChannel.emit(event1)
dispatcher.scheduler.advanceUntilIdle()
val update1 = awaitItem()
assertEquals(expectedUpdate1, update1)
eventHandlerChannel.emit(event2)
dispatcher.scheduler.advanceUntilIdle()
val update2 = awaitItem()
assertEquals(expectedUpdate2, update2)
ensureAllEventsConsumed()
}
}
}
@Test
fun `should not emit update when receiving invalid event`() = runTest(dispatcher) {
turbineScope {
// Given
val event = anytype.Event(
messages = listOf(
anytype.Event.Message(
p2pStatusUpdate = anytype.Event.P2PStatus.Update(
spaceId = RandomString.make()
)
)
)
)
// When
channel.start()
dispatcher.scheduler.advanceUntilIdle()
// Then
channel.observe().test {
val emittedValue = awaitItem()
assertEquals(PushKeyUpdate.EMPTY, emittedValue)
eventHandlerChannel.emit(event)
dispatcher.scheduler.advanceUntilIdle()
ensureAllEventsConsumed()
}
}
}
@Test
fun `should stop processing events after stop is called`() = runTest(dispatcher) {
turbineScope {
// Given
val expectedUpdate = PushKeyUpdate(
encryptionKeyId = RandomString.make(),
encryptionKey = RandomString.make(),
)
val event = anytype.Event(
messages = listOf(
anytype.Event.Message(
pushEncryptionKeyUpdate = anytype.Event.PushEncryptionKey.Update(
encryptionKeyId = expectedUpdate.encryptionKeyId,
encryptionKey = expectedUpdate.encryptionKey
)
)
)
)
// When
channel.start()
channel.stop()
dispatcher.scheduler.advanceUntilIdle()
// Then
channel.observe().test {
val emittedValue = awaitItem()
assertEquals(PushKeyUpdate.EMPTY, emittedValue)
eventHandlerChannel.emit(event)
dispatcher.scheduler.advanceUntilIdle()
ensureAllEventsConsumed()
}
}
}
@Test
fun `should handle multiple messages in single emission`() = runTest {
turbineScope {
turbineScope {
// Given
val expectedUpdate1 = PushKeyUpdate(
encryptionKeyId = RandomString.make(),
encryptionKey = RandomString.make(),
)
val event1 = anytype.Event(
messages = listOf(
anytype.Event.Message(pushEncryptionKeyUpdate = null),
anytype.Event.Message(
pushEncryptionKeyUpdate = anytype.Event.PushEncryptionKey.Update(
encryptionKeyId = expectedUpdate1.encryptionKeyId,
encryptionKey = expectedUpdate1.encryptionKey
)
),
anytype.Event.Message(pushEncryptionKeyUpdate = null),
)
)
// When
channel.start()
dispatcher.scheduler.advanceUntilIdle()
// Then
channel.observe().test {
val emittedValue = awaitItem()
assertEquals(PushKeyUpdate.EMPTY, emittedValue)
eventHandlerChannel.emit(event1)
dispatcher.scheduler.advanceUntilIdle()
val update1 = awaitItem()
assertEquals(expectedUpdate1, update1)
ensureAllEventsConsumed()
}
}
}
}
}

View file

@ -0,0 +1,65 @@
package com.anytypeio.anytype.presentation.notifications
import android.content.SharedPreferences
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.chats.PushKeyChannel
import javax.inject.Inject
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
interface PushKeyProvider {
fun getPushKey(): PushKey
}
data class PushKey(
val key: String,
val id: String
) {
companion object {
val EMPTY = PushKey(key = "", id = "")
}
}
class PushKeyProviderImpl @Inject constructor(
private val sharedPreferences: SharedPreferences,
private val channel: PushKeyChannel,
dispatchers: AppCoroutineDispatchers,
scope: CoroutineScope
) : PushKeyProvider {
init {
Timber.d("PushKeyProvider initialized")
scope.launch(dispatchers.io) {
channel
.observe()
.collect { event ->
Timber.d("New push key updates: $event")
savePushKey(
pushKey = event.encryptionKey,
pushKeyId = event.encryptionKeyId
)
}
}
}
private fun savePushKey(pushKey: String?, pushKeyId: String?) {
sharedPreferences.edit().apply {
putString(PREF_PUSH_KEY, pushKey)
putString(PREF_PUSH_KEY_ID, pushKeyId)
apply()
}
}
override fun getPushKey(): PushKey {
val pushKey = sharedPreferences.getString(PREF_PUSH_KEY, "") ?: ""
val pushKeyId = sharedPreferences.getString(PREF_PUSH_KEY_ID, "") ?: ""
Timber.d("PushKeyProvider getPushKey: $pushKey, $pushKeyId")
return PushKey(key = pushKey, id = pushKeyId)
}
companion object {
const val PREF_PUSH_KEY = "pref.push_key"
const val PREF_PUSH_KEY_ID = "pref.push_key_id"
}
}

View file

@ -0,0 +1,181 @@
package com.anytypeio.anytype.presentation.notifications
import android.content.Context
import android.content.SharedPreferences
import android.os.Build
import androidx.test.core.app.ApplicationProvider
import com.anytypeio.anytype.core_models.chats.PushKeyUpdate
import com.anytypeio.anytype.domain.base.AppCoroutineDispatchers
import com.anytypeio.anytype.domain.chats.PushKeyChannel
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.mockito.Mock
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.doReturn
import org.mockito.kotlin.stub
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@ExperimentalCoroutinesApi
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [Build.VERSION_CODES.P])
class PushKeyProviderImplTest {
@Mock
private lateinit var mockChannel: PushKeyChannel
private lateinit var sharedPreferences: SharedPreferences
private val dispatcher = StandardTestDispatcher(name = "Default test dispatcher")
private val testScope = TestScope(dispatcher)
@Before
fun setUp() {
MockitoAnnotations.openMocks(this)
// Use a real SharedPreferences from Robolectric
val context = ApplicationProvider.getApplicationContext<Context>()
sharedPreferences = context.getSharedPreferences("test_prefs", Context.MODE_PRIVATE)
// Clear shared preferences before each test
sharedPreferences.edit().clear().apply()
}
@After
fun tearDown() {
// Clean up coroutines
testScope.cancel()
// Clear shared preferences after each test
sharedPreferences.edit().clear().apply()
}
@Test
fun `getPushKey should return empty PushKey when no key is set`() = runTest {
val pushKeyProvider = createPushKeyProvider()
val pushKey = pushKeyProvider.getPushKey()
assertTrue(pushKey == PushKey.EMPTY)
}
@Test
fun `savePushKey should store the key and keyId in shared preferences when channel emits`() =
runTest(dispatcher) {
val pushKey = "test_push_key"
val pushKeyId = "test_push_key_id"
// Simulate the event emission from the channel
val channelFlow = MutableSharedFlow<PushKeyUpdate>(replay = 0)
mockChannel.stub {
on { observe() } doReturn channelFlow
}
createPushKeyProvider() // This will start observing the channel
dispatcher.scheduler.advanceUntilIdle() // Allow the observation coroutine to run
// Emit the event
channelFlow.emit(PushKeyUpdate(encryptionKey = pushKey, encryptionKeyId = pushKeyId))
dispatcher.scheduler.advanceUntilIdle() // Allow the processing coroutine to run
// Verify the stored values in SharedPreferences
val storedKey = sharedPreferences.getString(PushKeyProviderImpl.PREF_PUSH_KEY, null)
val storedKeyId =
sharedPreferences.getString(PushKeyProviderImpl.PREF_PUSH_KEY_ID, null)
assertEquals(pushKey, storedKey)
assertEquals(pushKeyId, storedKeyId)
}
@Test
fun `getPushKey should return the stored key when a key is set`() = runTest {
val pushKey = PushKey(key = "test_push_key", id = "test_push_key_id")
sharedPreferences.edit()
.putString(PushKeyProviderImpl.PREF_PUSH_KEY_ID, pushKey.id)
.apply()
sharedPreferences.edit()
.putString(PushKeyProviderImpl.PREF_PUSH_KEY, pushKey.key)
.apply()
val pushKeyProvider = createPushKeyProvider()
val retrievedKey = pushKeyProvider.getPushKey()
assertEquals(pushKey, retrievedKey)
}
@Test
fun `observation should update shared preferences on subsequent channel emissions`() =
runTest(dispatcher) {
val initialKey = "initial_key"
val initialKeyId = "initial_key_id"
val updatedKey = "updated_key"
val updatedKeyId = "updated_key_id"
// Manually control the flow emission
val channelFlow = MutableSharedFlow<PushKeyUpdate>(replay = 0)
mockChannel.stub {
on { observe() } doReturn channelFlow
}
createPushKeyProvider() // Start observing
dispatcher.scheduler.advanceUntilIdle() // Ensure observation is set up
// Emit initial event
channelFlow.emit(
PushKeyUpdate(
encryptionKey = initialKey,
encryptionKeyId = initialKeyId
)
)
dispatcher.scheduler.advanceUntilIdle() // Process emission
// Verify initial storage
assertEquals(
initialKey,
sharedPreferences.getString(PushKeyProviderImpl.PREF_PUSH_KEY, null)
)
assertEquals(
initialKeyId,
sharedPreferences.getString(PushKeyProviderImpl.PREF_PUSH_KEY_ID, null)
)
// Emit updated event
channelFlow.emit(
PushKeyUpdate(
encryptionKey = updatedKey,
encryptionKeyId = updatedKeyId
)
)
dispatcher.scheduler.advanceUntilIdle() // Process emission
// Verify updated storage
assertEquals(
updatedKey,
sharedPreferences.getString(PushKeyProviderImpl.PREF_PUSH_KEY, null)
)
assertEquals(
updatedKeyId,
sharedPreferences.getString(PushKeyProviderImpl.PREF_PUSH_KEY_ID, null)
)
}
private fun createPushKeyProvider(): PushKeyProviderImpl {
return PushKeyProviderImpl(
sharedPreferences = sharedPreferences,
channel = mockChannel,
dispatchers = AppCoroutineDispatchers(
io = dispatcher,
main = dispatcher,
computation = dispatcher
),
scope = testScope
)
}
}