Merge pull request #138 from ouchadam/feature/invites-badge
Invite notification and badge
This commit is contained in:
commit
965ca92ac5
|
@ -60,7 +60,7 @@ class SmallTalkApplication : Application(), ModuleProvider {
|
|||
|
||||
applicationScope.launch {
|
||||
val notificationsUseCase = notificationsModule.notificationsUseCase()
|
||||
notificationsUseCase.listenForNotificationChanges()
|
||||
notificationsUseCase.listenForNotificationChanges(this)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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)
|
||||
)
|
||||
|
||||
}
|
|
@ -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,
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
|
@ -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) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,8 @@
|
|||
package fake
|
||||
|
||||
import app.dapk.st.notifications.NotificationInviteRenderer
|
||||
import io.mockk.mockk
|
||||
|
||||
class FakeNotificationInviteRenderer {
|
||||
val instance = mockk<NotificationInviteRenderer>()
|
||||
}
|
|
@ -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 {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -164,7 +164,8 @@ class TestMatrix(
|
|||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
roomInviteRemover = { storeModule.overviewStore().removeInvites(listOf(it)) }
|
||||
)
|
||||
|
||||
installSyncService(
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
Loading…
Reference in New Issue