Merge pull request #138 from ouchadam/feature/invites-badge

Invite notification and badge
This commit is contained in:
Adam Brown 2022-09-11 22:11:15 +01:00 committed by GitHub
commit 965ca92ac5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 378 additions and 84 deletions

View File

@ -60,7 +60,7 @@ class SmallTalkApplication : Application(), ModuleProvider {
applicationScope.launch {
val notificationsUseCase = notificationsModule.notificationsUseCase()
notificationsUseCase.listenForNotificationChanges()
notificationsUseCase.listenForNotificationChanges(this)
}
}

View File

@ -184,7 +184,7 @@ internal class FeatureModules internal constructor(
clock
)
}
val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile, buildMeta) }
val homeModule by unsafeLazy { HomeModule(storeModule.value, matrixModules.profile, matrixModules.sync, buildMeta) }
val settingsModule by unsafeLazy {
SettingsModule(
storeModule.value,
@ -203,6 +203,7 @@ internal class FeatureModules internal constructor(
NotificationsModule(
imageLoaderModule.iconLoader(),
storeModule.value.roomStore(),
storeModule.value.overviewStore(),
context,
intentFactory = coreAndroidModule.intentFactory(),
dispatchers = coroutineDispatchers,
@ -307,6 +308,7 @@ internal class MatrixModules(
}
}
val overviewStore = store.overviewStore()
installRoomService(
storeModule.value.memberStore(),
roomMessenger = {
@ -320,6 +322,9 @@ internal class MatrixModules(
)
}
}
},
roomInviteRemover = {
overviewStore.removeInvites(listOf(it))
}
)
@ -327,7 +332,7 @@ internal class MatrixModules(
installSyncService(
credentialsStore,
store.overviewStore(),
overviewStore,
store.roomStore(),
store.syncStore(),
store.filterStore(),

View File

@ -1,5 +1,6 @@
package app.dapk.st.core
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.ComponentActivity
@ -20,10 +21,13 @@ abstract class DapkActivity : ComponentActivity(), EffectScope {
private lateinit var themeConfig: ThemeConfig
private val needsBackLeakWorkaround = Build.VERSION.SDK_INT == Build.VERSION_CODES.Q
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
this.themeConfig = ThemeConfig(themeStore.isMaterialYouEnabled())
window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
}
@ -53,4 +57,11 @@ abstract class DapkActivity : ComponentActivity(), EffectScope {
}
}
}
override fun onBackPressed() {
if (needsBackLeakWorkaround && !onBackPressedDispatcher.hasEnabledCallbacks()) {
finishAfterTransition()
} else
super.onBackPressed()
}
}

View File

@ -3,6 +3,7 @@ applyAndroidComposeLibraryModule(project)
dependencies {
implementation project(":matrix:services:profile")
implementation project(":matrix:services:crypto")
implementation project(":matrix:services:sync")
implementation project(":features:directory")
implementation project(":features:login")
implementation project(":features:settings")

View File

@ -6,11 +6,13 @@ import app.dapk.st.directory.DirectoryViewModel
import app.dapk.st.domain.StoreModule
import app.dapk.st.login.LoginViewModel
import app.dapk.st.matrix.room.ProfileService
import app.dapk.st.matrix.sync.SyncService
import app.dapk.st.profile.ProfileViewModel
class HomeModule(
private val storeModule: StoreModule,
private val profileService: ProfileService,
private val syncService: SyncService,
private val buildMeta: BuildMeta,
) : ProvidableModule {
@ -26,6 +28,7 @@ class HomeModule(
storeModule.applicationStore(),
buildMeta,
),
syncService,
)
}

View File

@ -3,13 +3,12 @@ package app.dapk.st.home
import androidx.compose.foundation.layout.*
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import app.dapk.st.core.LifecycleEffect
import app.dapk.st.core.components.CenteredLoading
import app.dapk.st.design.components.CircleishAvatar
import app.dapk.st.design.components.SmallTalkTheme
import app.dapk.st.directory.DirectoryScreen
import app.dapk.st.home.HomeScreenState.*
import app.dapk.st.home.HomeScreenState.Page.Directory
@ -20,41 +19,42 @@ import app.dapk.st.profile.ProfileScreen
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun HomeScreen(homeViewModel: HomeViewModel) {
Surface(Modifier.fillMaxSize()) {
LaunchedEffect(true) {
homeViewModel.start()
}
LifecycleEffect(
onStart = { homeViewModel.start() },
onStop = { homeViewModel.stop() }
)
when (val state = homeViewModel.state) {
Loading -> CenteredLoading()
is SignedIn -> {
Scaffold(
bottomBar = {
BottomBar(state, homeViewModel)
},
content = { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
when (state.page) {
Directory -> DirectoryScreen(homeViewModel.directory())
Profile -> {
ProfileScreen(homeViewModel.profile()) {
homeViewModel.changePage(Directory)
}
}
when (val state = homeViewModel.state) {
Loading -> CenteredLoading()
is SignedIn -> {
Scaffold(
bottomBar = {
BottomBar(state, homeViewModel)
},
content = { innerPadding ->
Box(modifier = Modifier.padding(innerPadding)) {
when (state.page) {
Directory -> DirectoryScreen(homeViewModel.directory())
Profile -> {
ProfileScreen(homeViewModel.profile()) {
homeViewModel.changePage(Directory)
}
}
}
)
}
SignedOut -> {
LoginScreen(homeViewModel.login()) {
homeViewModel.loggedIn()
}
}
)
}
SignedOut -> {
LoginScreen(homeViewModel.login()) {
homeViewModel.loggedIn()
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun BottomBar(state: SignedIn, homeViewModel: HomeViewModel) {
Column {
@ -72,11 +72,17 @@ private fun BottomBar(state: SignedIn, homeViewModel: HomeViewModel) {
}
},
)
Profile -> NavigationBarItem(
Profile -> NavigationBarItem(
icon = {
Box {
CircleishAvatar(state.me.avatarUrl?.value, state.me.displayName ?: state.me.userId.value, size = 25.dp)
BadgedBox(badge = {
if (state.invites > 0) {
Badge(containerColor = MaterialTheme.colorScheme.primary) { Text("!", color = MaterialTheme.colorScheme.onPrimary) }
}
}) {
Box {
CircleishAvatar(state.me.avatarUrl?.value, state.me.displayName ?: state.me.userId.value, size = 25.dp)
}
}
},
selected = state.page == page,

View File

@ -10,7 +10,7 @@ sealed interface HomeScreenState {
object Loading : HomeScreenState
object SignedOut : HomeScreenState
data class SignedIn(val page: Page, val me: ProfileService.Me) : HomeScreenState
data class SignedIn(val page: Page, val me: ProfileService.Me, val invites: Int) : HomeScreenState
enum class Page(val icon: ImageVector) {
Directory(Icons.Filled.Menu),

View File

@ -8,8 +8,15 @@ import app.dapk.st.login.LoginViewModel
import app.dapk.st.matrix.common.CredentialsStore
import app.dapk.st.matrix.common.isSignedIn
import app.dapk.st.matrix.room.ProfileService
import app.dapk.st.matrix.sync.SyncService
import app.dapk.st.profile.ProfileViewModel
import app.dapk.st.viewmodel.DapkViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
class HomeViewModel(
@ -20,10 +27,13 @@ class HomeViewModel(
private val profileService: ProfileService,
private val cacheCleaner: StoreCleaner,
private val betaVersionUpgradeUseCase: BetaVersionUpgradeUseCase,
private val syncService: SyncService,
) : DapkViewModel<HomeScreenState, HomeEvent>(
initialState = Loading
) {
private var listenForInvitesJob: Job? = null
fun directory() = directoryViewModel
fun login() = loginViewModel
fun profile() = profileViewModel
@ -31,21 +41,47 @@ class HomeViewModel(
fun start() {
viewModelScope.launch {
state = if (credentialsProvider.isSignedIn()) {
val me = profileService.me(forceRefresh = false)
SignedIn(Page.Directory, me)
initialHomeContent()
} else {
SignedOut
}
}
viewModelScope.launch {
if (credentialsProvider.isSignedIn()) {
listenForInviteChanges()
}
}
}
private suspend fun initialHomeContent(): SignedIn {
val me = profileService.me(forceRefresh = false)
val initialInvites = syncService.invites().first().size
return SignedIn(Page.Directory, me, invites = initialInvites)
}
fun loggedIn() {
viewModelScope.launch {
val me = profileService.me(forceRefresh = false)
state = SignedIn(Page.Directory, me)
state = initialHomeContent()
listenForInviteChanges()
}
}
private fun CoroutineScope.listenForInviteChanges() {
listenForInvitesJob?.cancel()
listenForInvitesJob = syncService.invites()
.onEach { invites ->
when (val currentState = state) {
is SignedIn -> updateState { currentState.copy(invites = invites.size) }
Loading,
SignedOut -> {
// do nothing
}
}
}.launchIn(this)
}
fun hasVersionChanged() = betaVersionUpgradeUseCase.hasVersionChanged()
fun clearCache() {
@ -66,4 +102,8 @@ class HomeViewModel(
SignedOut -> current
}
}
fun stop() {
viewModelScope.cancel()
}
}

View File

@ -1,11 +1,13 @@
package app.dapk.st.home
import android.os.Bundle
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
import app.dapk.st.core.DapkActivity
import app.dapk.st.core.module
@ -35,7 +37,9 @@ class MainActivity : DapkActivity() {
if (homeViewModel.hasVersionChanged()) {
BetaUpgradeDialog()
} else {
HomeScreen(homeViewModel)
Surface(Modifier.fillMaxSize()) {
HomeScreen(homeViewModel)
}
}
}
}

View File

@ -32,6 +32,8 @@ class AndroidNotificationBuilder(
.apply { setGroupSummary(notification.isGroupSummary) }
.ifNotNull(notification.groupId) { setGroup(it) }
.ifNotNull(notification.messageStyle) { style = it.build(notificationStyleBuilder) }
.ifNotNull(notification.contentTitle) { setContentTitle(it) }
.ifNotNull(notification.contentText) { setContentText(it) }
.ifNotNull(notification.contentIntent) { setContentIntent(it) }
.ifNotNull(notification.whenTimestamp) {
setShowWhen(true)
@ -65,6 +67,8 @@ data class AndroidNotification(
val shortcutId: String? = null,
val alertMoreThanOnce: Boolean,
val contentIntent: PendingIntent? = null,
val contentTitle: String? = null,
val contentText: String? = null,
val messageStyle: AndroidNotificationStyle? = null,
val category: String? = null,
val smallIcon: Int? = null,

View File

@ -8,6 +8,7 @@ import android.os.Build
const val DIRECT_CHANNEL_ID = "direct_channel_id"
const val GROUP_CHANNEL_ID = "group_channel_id"
const val SUMMARY_CHANNEL_ID = "summary_channel_id"
const val INVITE_CHANNEL_ID = "invite_channel_id"
private const val CHATS_NOTIFICATION_GROUP_ID = "chats_notification_group"
@ -45,6 +46,18 @@ class NotificationChannels(
)
}
if (notificationManager.getNotificationChannel(INVITE_CHANNEL_ID) == null) {
notificationManager.createNotificationChannel(
NotificationChannel(
INVITE_CHANNEL_ID,
"Invite notifications",
NotificationManager.IMPORTANCE_DEFAULT,
).also {
it.group = CHATS_NOTIFICATION_GROUP_ID
}
)
}
if (notificationManager.getNotificationChannel(SUMMARY_CHANNEL_ID) == null) {
notificationManager.createNotificationChannel(
NotificationChannel(

View File

@ -8,6 +8,7 @@ import app.dapk.st.imageloader.IconLoader
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.RoomOverview
import app.dapk.st.navigator.IntentFactory
import java.time.Clock
private const val GROUP_ID = "st"
@ -17,6 +18,7 @@ class NotificationFactory(
private val intentFactory: IntentFactory,
private val iconLoader: IconLoader,
private val deviceMeta: DeviceMeta,
private val clock: Clock,
) {
private val shouldAlwaysAlertDms = true
@ -84,6 +86,21 @@ class NotificationFactory(
category = Notification.CATEGORY_MESSAGE,
)
}
fun createInvite(inviteNotification: InviteNotification): AndroidNotification {
val openAppIntent = intentFactory.notificationOpenApp(context)
return AndroidNotification(
channelId = INVITE_CHANNEL_ID,
smallIcon = R.drawable.ic_notification_small_icon,
whenTimestamp = clock.millis(),
alertMoreThanOnce = true,
contentTitle = "Invite",
contentText = inviteNotification.content,
contentIntent = openAppIntent,
category = Notification.CATEGORY_EVENT,
autoCancel = true,
)
}
}
private fun List<NotificationTypes.Room>.mostRecent() = this.sortedBy { it.notification.whenTimestamp }.first()

View File

@ -0,0 +1,25 @@
package app.dapk.st.notifications
import android.app.NotificationManager
private const val INVITE_NOTIFICATION_ID = 103
class NotificationInviteRenderer(
private val notificationManager: NotificationManager,
private val notificationFactory: NotificationFactory,
private val androidNotificationBuilder: AndroidNotificationBuilder,
) {
fun render(inviteNotification: InviteNotification) {
notificationManager.notify(
inviteNotification.roomId.value,
INVITE_NOTIFICATION_ID,
inviteNotification.toAndroidNotification()
)
}
private fun InviteNotification.toAndroidNotification() = androidNotificationBuilder.build(
notificationFactory.createInvite(this)
)
}

View File

@ -13,7 +13,7 @@ import kotlinx.coroutines.withContext
private const val SUMMARY_NOTIFICATION_ID = 101
private const val MESSAGE_NOTIFICATION_ID = 100
class NotificationRenderer(
class NotificationMessageRenderer(
private val notificationManager: NotificationManager,
private val notificationStateMapper: NotificationStateMapper,
private val androidNotificationBuilder: AndroidNotificationBuilder,

View File

@ -6,37 +6,46 @@ import app.dapk.st.core.CoroutineDispatchers
import app.dapk.st.core.DeviceMeta
import app.dapk.st.core.ProvidableModule
import app.dapk.st.imageloader.IconLoader
import app.dapk.st.matrix.sync.OverviewStore
import app.dapk.st.matrix.sync.RoomStore
import app.dapk.st.navigator.IntentFactory
import java.time.Clock
class NotificationsModule(
private val iconLoader: IconLoader,
private val roomStore: RoomStore,
private val overviewStore: OverviewStore,
private val context: Context,
private val intentFactory: IntentFactory,
private val dispatchers: CoroutineDispatchers,
private val deviceMeta: DeviceMeta,
) : ProvidableModule {
fun notificationsUseCase() = RenderNotificationsUseCase(
notificationRenderer = NotificationRenderer(
notificationManager(),
NotificationStateMapper(
RoomEventsToNotifiableMapper(),
NotificationFactory(
context,
NotificationStyleFactory(iconLoader, deviceMeta),
intentFactory,
iconLoader,
deviceMeta,
)
),
AndroidNotificationBuilder(context, deviceMeta, AndroidNotificationStyleBuilder()),
fun notificationsUseCase(): RenderNotificationsUseCase {
val notificationManager = notificationManager()
val androidNotificationBuilder = AndroidNotificationBuilder(context, deviceMeta, AndroidNotificationStyleBuilder())
val notificationFactory = NotificationFactory(
context,
NotificationStyleFactory(iconLoader, deviceMeta),
intentFactory,
iconLoader,
deviceMeta,
Clock.systemUTC(),
)
val notificationMessageRenderer = NotificationMessageRenderer(
notificationManager,
NotificationStateMapper(RoomEventsToNotifiableMapper(), notificationFactory),
androidNotificationBuilder,
dispatchers
),
observeRenderableUnreadEventsUseCase = ObserveUnreadNotificationsUseCaseImpl(roomStore),
notificationChannels = NotificationChannels(notificationManager()),
)
)
return RenderNotificationsUseCase(
notificationRenderer = notificationMessageRenderer,
observeRenderableUnreadEventsUseCase = ObserveUnreadNotificationsUseCaseImpl(roomStore),
notificationChannels = NotificationChannels(notificationManager),
observeInviteNotificationsUseCase = ObserveInviteNotificationsUseCaseImpl(overviewStore),
inviteRenderer = NotificationInviteRenderer(notificationManager, notificationFactory, androidNotificationBuilder)
)
}
private fun notificationManager() = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

View File

@ -0,0 +1,49 @@
package app.dapk.st.notifications
import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.sync.InviteMeta
import app.dapk.st.matrix.sync.OverviewStore
import app.dapk.st.matrix.sync.RoomInvite
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.*
internal typealias ObserveInviteNotificationsUseCase = suspend () -> Flow<InviteNotification>
class ObserveInviteNotificationsUseCaseImpl(private val overviewStore: OverviewStore) : ObserveInviteNotificationsUseCase {
override suspend fun invoke(): Flow<InviteNotification> {
return overviewStore.latestInvites()
.diff()
.flatten()
.map {
val text = when (val meta = it.inviteMeta) {
InviteMeta.DirectMessage -> "${it.inviterName()} has invited you to chat"
is InviteMeta.Room -> "${it.inviterName()} has invited you to ${meta.roomName ?: "unnamed room"}"
}
InviteNotification(content = text, roomId = it.roomId)
}
}
private fun Flow<List<RoomInvite>>.diff(): Flow<Set<RoomInvite>> {
val previousInvites = mutableSetOf<RoomInvite>()
return this.distinctUntilChanged()
.map {
val diff = it.toSet() - previousInvites
previousInvites.clear()
previousInvites.addAll(it)
diff
}
}
private fun RoomInvite.inviterName() = this.from.displayName?.let { "$it (${this.from.id.value})" } ?: this.from.id.value
}
@OptIn(FlowPreview::class)
private fun <T> Flow<Set<T>>.flatten() = this.flatMapConcat { items ->
flow { items.forEach { this.emit(it) } }
}
data class InviteNotification(
val content: String,
val roomId: RoomId
)

View File

@ -2,21 +2,27 @@ package app.dapk.st.notifications
import app.dapk.st.matrix.sync.RoomEvent
import app.dapk.st.matrix.sync.RoomOverview
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
class RenderNotificationsUseCase(
private val notificationRenderer: NotificationRenderer,
private val notificationRenderer: NotificationMessageRenderer,
private val inviteRenderer: NotificationInviteRenderer,
private val observeRenderableUnreadEventsUseCase: ObserveUnreadNotificationsUseCase,
private val observeInviteNotificationsUseCase: ObserveInviteNotificationsUseCase,
private val notificationChannels: NotificationChannels,
) {
suspend fun listenForNotificationChanges() {
suspend fun listenForNotificationChanges(scope: CoroutineScope) {
notificationChannels.initChannels()
observeRenderableUnreadEventsUseCase()
.onStart { notificationChannels.initChannels() }
.onEach { (each, diff) -> renderUnreadChange(each, diff) }
.collect()
.launchIn(scope)
observeInviteNotificationsUseCase()
.onEach { inviteRenderer.render(it) }
.launchIn(scope)
}
private suspend fun renderUnreadChange(allUnread: Map<RoomOverview, List<RoomEvent>>, diff: NotificationDiff) {

View File

@ -16,6 +16,9 @@ import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.amshove.kluent.shouldBeEqualTo
import org.junit.Test
import java.time.Clock
import java.time.Instant
import java.time.ZoneOffset
private const val A_CHANNEL_ID = "a channel id"
private val AN_OPEN_APP_INTENT = aPendingIntent()
@ -38,6 +41,7 @@ class NotificationFactoryTest {
private val fakeNotificationStyleFactory = FakeNotificationStyleFactory()
private val fakeIntentFactory = FakeIntentFactory()
private val fakeIconLoader = FakeIconLoader()
private val fixedClock = Clock.fixed(Instant.ofEpochMilli(0), ZoneOffset.UTC)
private val notificationFactory = NotificationFactory(
fakeContext.instance,
@ -45,6 +49,7 @@ class NotificationFactoryTest {
fakeIntentFactory,
fakeIconLoader,
DeviceMeta(26),
fixedClock
)
@Test
@ -127,6 +132,30 @@ class NotificationFactoryTest {
)
}
@Test
fun `given invite, then creates expected`() {
fakeIntentFactory.givenNotificationOpenApp(fakeContext.instance).returns(AN_OPEN_APP_INTENT)
val content = "Content message"
val result = notificationFactory.createInvite(
InviteNotification(
content = content,
A_ROOM_ID,
)
)
result shouldBeEqualTo AndroidNotification(
channelId = INVITE_CHANNEL_ID,
whenTimestamp = fixedClock.millis(),
alertMoreThanOnce = true,
smallIcon = R.drawable.ic_notification_small_icon,
contentIntent = AN_OPEN_APP_INTENT,
category = Notification.CATEGORY_EVENT,
autoCancel = true,
contentTitle = "Invite",
contentText = content,
)
}
private fun givenEventsFor(roomOverview: RoomOverview) {
fakeIntentFactory.givenNotificationOpenMessage(fakeContext.instance, roomOverview.roomId).returns(AN_OPEN_ROOM_INTENT)
fakeNotificationStyleFactory.givenMessage(EVENTS.sortedBy { it.utcTimestamp }, roomOverview).returns(A_NOTIFICATION_STYLE)

View File

@ -35,7 +35,7 @@ class NotificationRendererTest {
private val fakeNotificationFactory = FakeNotificationFactory()
private val fakeAndroidNotificationBuilder = FakeAndroidNotificationBuilder()
private val notificationRenderer = NotificationRenderer(
private val notificationRenderer = NotificationMessageRenderer(
fakeNotificationManager.instance,
fakeNotificationFactory.instance,
fakeAndroidNotificationBuilder.instance,

View File

@ -1,9 +1,9 @@
package app.dapk.st.notifications
import fake.FakeNotificationChannels
import fake.FakeNotificationRenderer
import fake.FakeObserveUnreadNotificationsUseCase
import fake.*
import fixture.NotificationDiffFixtures.aNotificationDiff
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import org.junit.Test
import test.expect
@ -12,35 +12,41 @@ private val AN_UNREAD_NOTIFICATIONS = UnreadNotifications(emptyMap(), aNotificat
class RenderNotificationsUseCaseTest {
private val fakeNotificationRenderer = FakeNotificationRenderer()
private val fakeNotificationMessageRenderer = FakeNotificationMessageRenderer()
private val fakeNotificationInviteRenderer = FakeNotificationInviteRenderer()
private val fakeObserveUnreadNotificationsUseCase = FakeObserveUnreadNotificationsUseCase()
private val fakeObserveInviteNotificationsUseCase = FakeObserveInviteNotificationsUseCase()
private val fakeNotificationChannels = FakeNotificationChannels().also {
it.instance.expect { it.initChannels() }
}
private val renderNotificationsUseCase = RenderNotificationsUseCase(
fakeNotificationRenderer.instance,
fakeNotificationMessageRenderer.instance,
fakeNotificationInviteRenderer.instance,
fakeObserveUnreadNotificationsUseCase,
fakeObserveInviteNotificationsUseCase,
fakeNotificationChannels.instance,
)
@Test
fun `given events, when listening for changes then initiates channels once`() = runTest {
fakeNotificationRenderer.instance.expect { it.render(any()) }
fakeNotificationMessageRenderer.instance.expect { it.render(any()) }
fakeObserveUnreadNotificationsUseCase.given().emits(AN_UNREAD_NOTIFICATIONS)
fakeObserveInviteNotificationsUseCase.given().emits()
renderNotificationsUseCase.listenForNotificationChanges()
renderNotificationsUseCase.listenForNotificationChanges(TestScope(UnconfinedTestDispatcher()))
fakeNotificationChannels.verifyInitiated()
}
@Test
fun `given renderable unread events, when listening for changes, then renders change`() = runTest {
fakeNotificationRenderer.instance.expect { it.render(any()) }
fakeNotificationMessageRenderer.instance.expect { it.render(any()) }
fakeObserveUnreadNotificationsUseCase.given().emits(AN_UNREAD_NOTIFICATIONS)
fakeObserveInviteNotificationsUseCase.given().emits()
renderNotificationsUseCase.listenForNotificationChanges()
renderNotificationsUseCase.listenForNotificationChanges(TestScope(UnconfinedTestDispatcher()))
fakeNotificationRenderer.verifyRenders(AN_UNREAD_NOTIFICATIONS)
fakeNotificationMessageRenderer.verifyRenders(AN_UNREAD_NOTIFICATIONS)
}
}

View File

@ -0,0 +1,8 @@
package fake
import app.dapk.st.notifications.NotificationInviteRenderer
import io.mockk.mockk
class FakeNotificationInviteRenderer {
val instance = mockk<NotificationInviteRenderer>()
}

View File

@ -1,13 +1,13 @@
package fake
import app.dapk.st.notifications.NotificationRenderer
import app.dapk.st.notifications.NotificationMessageRenderer
import app.dapk.st.notifications.NotificationState
import app.dapk.st.notifications.UnreadNotifications
import io.mockk.coVerify
import io.mockk.mockk
class FakeNotificationRenderer {
val instance = mockk<NotificationRenderer>()
class FakeNotificationMessageRenderer {
val instance = mockk<NotificationMessageRenderer>()
fun verifyRenders(vararg unreadNotifications: UnreadNotifications) {
unreadNotifications.forEach { unread ->
@ -23,4 +23,4 @@ class FakeNotificationRenderer {
}
}
}
}
}

View File

@ -0,0 +1,10 @@
package fake
import app.dapk.st.notifications.ObserveInviteNotificationsUseCase
import io.mockk.coEvery
import io.mockk.mockk
import test.delegateEmit
class FakeObserveInviteNotificationsUseCase : ObserveInviteNotificationsUseCase by mockk() {
fun given() = coEvery { this@FakeObserveInviteNotificationsUseCase.invoke() }.delegateEmit()
}

View File

@ -1,5 +1,6 @@
package app.dapk.st.profile
import android.util.Log
import androidx.lifecycle.viewModelScope
import app.dapk.st.core.Lce
import app.dapk.st.core.extensions.ErrorTracker
@ -95,7 +96,9 @@ class ProfileViewModel(
fun rejectRoomInvite(roomId: RoomId) {
launchCatching { roomService.rejectJoinRoom(roomId) }.fold(
onError = {}
onError = {
Log.e("!!!", it.message, it)
}
)
}

View File

@ -9,6 +9,7 @@ import app.dapk.st.matrix.common.RoomId
import app.dapk.st.matrix.common.RoomMember
import app.dapk.st.matrix.common.UserId
import app.dapk.st.matrix.room.internal.DefaultRoomService
import app.dapk.st.matrix.room.internal.RoomInviteRemover
import app.dapk.st.matrix.room.internal.RoomMembers
import app.dapk.st.matrix.room.internal.RoomMembersCache
@ -40,9 +41,16 @@ interface RoomService : MatrixService {
fun MatrixServiceInstaller.installRoomService(
memberStore: MemberStore,
roomMessenger: ServiceDepFactory<RoomMessenger>,
roomInviteRemover: RoomInviteRemover,
) {
this.install { (httpClient, _, services, logger) ->
SERVICE_KEY to DefaultRoomService(httpClient, logger, RoomMembers(memberStore, RoomMembersCache()), roomMessenger.create(services))
SERVICE_KEY to DefaultRoomService(
httpClient,
logger,
RoomMembers(memberStore, RoomMembersCache()),
roomMessenger.create(services),
roomInviteRemover
)
}
}

View File

@ -8,6 +8,8 @@ import app.dapk.st.matrix.http.emptyJsonBody
import app.dapk.st.matrix.http.jsonBody
import app.dapk.st.matrix.room.RoomMessenger
import app.dapk.st.matrix.room.RoomService
import io.ktor.client.plugins.*
import io.ktor.http.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@ -16,7 +18,9 @@ class DefaultRoomService(
private val logger: MatrixLogger,
private val roomMembers: RoomMembers,
private val roomMessenger: RoomMessenger,
private val roomInviteRemover: RoomInviteRemover,
) : RoomService {
override suspend fun joinedMembers(roomId: RoomId): List<RoomService.JoinedMember> {
val response = httpClient.execute(joinedMembersRequest(roomId))
return response.joined.map { (userId, member) ->
@ -68,7 +72,23 @@ class DefaultRoomService(
}
override suspend fun rejectJoinRoom(roomId: RoomId) {
httpClient.execute(rejectJoinRoomRequest(roomId))
runCatching { httpClient.execute(rejectJoinRoomRequest(roomId)) }.fold(
onSuccess = {},
onFailure = {
when (it) {
is ClientRequestException -> {
if (it.response.status == HttpStatusCode.Forbidden) {
// allow error
} else {
throw it
}
}
else -> throw it
}
}
)
roomInviteRemover.remove(roomId)
}
}

View File

@ -0,0 +1,7 @@
package app.dapk.st.matrix.room.internal
import app.dapk.st.matrix.common.RoomId
fun interface RoomInviteRemover {
suspend fun remove(roomId: RoomId)
}

View File

@ -164,7 +164,8 @@ class TestMatrix(
)
}
}
}
},
roomInviteRemover = { storeModule.overviewStore().removeInvites(listOf(it)) }
)
installSyncService(

View File

@ -74,3 +74,12 @@ task allCodeCoverageReport(type: JacocoReport) {
dependsOn { ["app:assembleDebug"] + projects*.test }
initializeReport(it, projects, excludes)
}
task unitTestCodeCoverageReport(type: JacocoReport) {
outputs.upToDateWhen { false }
rootProject.apply plugin: 'jacoco'
def projects = collectProjects { !it.name.contains("test-harness") && !it.name.contains("stub") && !it.name.contains("-noop") }
dependsOn { ["app:assembleDebug"] + projects*.test }
initializeReport(it, projects, excludes)
}