From a109ec6cff570f0dc7b7be81444373b002bcedf3 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 10 Sep 2022 19:29:45 +0100 Subject: [PATCH 1/4] observe invite changes and render a ! badge on the profile bottom item --- .../kotlin/app/dapk/st/graph/AppModule.kt | 2 +- features/home/build.gradle | 1 + .../kotlin/app/dapk/st/home/HomeModule.kt | 3 + .../kotlin/app/dapk/st/home/HomeScreen.kt | 66 ++++++++++--------- .../main/kotlin/app/dapk/st/home/HomeState.kt | 2 +- .../kotlin/app/dapk/st/home/HomeViewModel.kt | 48 ++++++++++++-- .../kotlin/app/dapk/st/home/MainActivity.kt | 8 ++- 7 files changed, 92 insertions(+), 38 deletions(-) diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index ff26c53..d8874d9 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -181,7 +181,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, diff --git a/features/home/build.gradle b/features/home/build.gradle index 5507d8c..3265a1f 100644 --- a/features/home/build.gradle +++ b/features/home/build.gradle @@ -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") diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt index 1d33dd8..b49c5cc 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeModule.kt @@ -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, ) } diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt index 57f852a..4590076 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeScreen.kt @@ -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, diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt index ca25e6f..7bf0114 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeState.kt @@ -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), diff --git a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt index 877314a..334660b 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/HomeViewModel.kt @@ -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( 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() + } } diff --git a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt index cd9872b..609f52d 100644 --- a/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt +++ b/features/home/src/main/kotlin/app/dapk/st/home/MainActivity.kt @@ -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) + } } } } From 577e692e32e3c26d80b17d59b923dfcc1ec1c957 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sat, 10 Sep 2022 19:50:19 +0100 Subject: [PATCH 2/4] removing invites from the database after rejecting them and allowing them to fail with 403 (if the user no longer has access) --- .../kotlin/app/dapk/st/graph/AppModule.kt | 6 ++++- .../app/dapk/st/profile/ProfileViewModel.kt | 5 ++++- .../app/dapk/st/matrix/room/RoomService.kt | 10 ++++++++- .../room/internal/DefaultRoomService.kt | 22 ++++++++++++++++++- .../matrix/room/internal/RoomInviteRemover.kt | 7 ++++++ 5 files changed, 46 insertions(+), 4 deletions(-) create mode 100644 matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/RoomInviteRemover.kt diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index d8874d9..e56cf2e 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -303,6 +303,7 @@ internal class MatrixModules( } } + val overviewStore = store.overviewStore() installRoomService( storeModule.value.memberStore(), roomMessenger = { @@ -316,6 +317,9 @@ internal class MatrixModules( ) } } + }, + roomInviteRemover = { + overviewStore.removeInvites(listOf(it)) } ) @@ -323,7 +327,7 @@ internal class MatrixModules( installSyncService( credentialsStore, - store.overviewStore(), + overviewStore, store.roomStore(), store.syncStore(), store.filterStore(), diff --git a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt index ff4a017..ab7fb5b 100644 --- a/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt +++ b/features/profile/src/main/kotlin/app/dapk/st/profile/ProfileViewModel.kt @@ -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) + } ) } diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt index 92bc1d0..1f933a9 100644 --- a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/RoomService.kt @@ -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, + 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 + ) } } diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt index 4ad8ef5..2448e4c 100644 --- a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/DefaultRoomService.kt @@ -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 { 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) } } diff --git a/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/RoomInviteRemover.kt b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/RoomInviteRemover.kt new file mode 100644 index 0000000..e5da0a9 --- /dev/null +++ b/matrix/services/room/src/main/kotlin/app/dapk/st/matrix/room/internal/RoomInviteRemover.kt @@ -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) +} \ No newline at end of file From 514b25caea9c868315cb2617443645824c337705 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 11 Sep 2022 11:36:16 +0100 Subject: [PATCH 3/4] fixing memory _leak_ on android Q when exiting screens --- .../src/main/kotlin/app/dapk/st/core/DapkActivity.kt | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt index 59cec14..29ee41b 100644 --- a/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt +++ b/domains/android/compose-core/src/main/kotlin/app/dapk/st/core/DapkActivity.kt @@ -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() + } } From defabfbae51636d152358a743adfd1385380bab3 Mon Sep 17 00:00:00 2001 From: Adam Brown Date: Sun, 11 Sep 2022 11:41:28 +0100 Subject: [PATCH 4/4] rendering invites as notification which temporarily open the app on tap --- .../app/dapk/st/SmallTalkApplication.kt | 2 +- .../kotlin/app/dapk/st/graph/AppModule.kt | 1 + .../AndroidNotificationBuilder.kt | 4 ++ .../st/notifications/NotificationChannels.kt | 13 +++++ .../st/notifications/NotificationFactory.kt | 17 +++++++ .../NotificationInviteRenderer.kt | 25 ++++++++++ ...erer.kt => NotificationMessageRenderer.kt} | 2 +- .../st/notifications/NotificationsModule.kt | 45 ++++++++++------- .../ObserveInviteNotificationsUseCase.kt | 49 +++++++++++++++++++ .../RenderNotificationsUseCase.kt | 18 ++++--- .../notifications/NotificationFactoryTest.kt | 29 +++++++++++ .../notifications/NotificationRendererTest.kt | 2 +- .../RenderNotificationsUseCaseTest.kt | 26 ++++++---- .../fake/FakeNotificationInviteRenderer.kt | 8 +++ ....kt => FakeNotificationMessageRenderer.kt} | 8 +-- .../FakeObserveInviteNotificationsUseCase.kt | 10 ++++ .../src/test/kotlin/test/TestMatrix.kt | 3 +- tools/coverage.gradle | 9 ++++ 18 files changed, 229 insertions(+), 42 deletions(-) create mode 100644 features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationInviteRenderer.kt rename features/notifications/src/main/kotlin/app/dapk/st/notifications/{NotificationRenderer.kt => NotificationMessageRenderer.kt} (98%) create mode 100644 features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveInviteNotificationsUseCase.kt create mode 100644 features/notifications/src/test/kotlin/fake/FakeNotificationInviteRenderer.kt rename features/notifications/src/test/kotlin/fake/{FakeNotificationRenderer.kt => FakeNotificationMessageRenderer.kt} (81%) create mode 100644 features/notifications/src/test/kotlin/fake/FakeObserveInviteNotificationsUseCase.kt diff --git a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt index 1d15604..3fb4488 100644 --- a/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt +++ b/app/src/main/kotlin/app/dapk/st/SmallTalkApplication.kt @@ -60,7 +60,7 @@ class SmallTalkApplication : Application(), ModuleProvider { applicationScope.launch { val notificationsUseCase = notificationsModule.notificationsUseCase() - notificationsUseCase.listenForNotificationChanges() + notificationsUseCase.listenForNotificationChanges(this) } } diff --git a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt index e56cf2e..8b013f4 100644 --- a/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt +++ b/app/src/main/kotlin/app/dapk/st/graph/AppModule.kt @@ -199,6 +199,7 @@ internal class FeatureModules internal constructor( NotificationsModule( imageLoaderModule.iconLoader(), storeModule.value.roomStore(), + storeModule.value.overviewStore(), context, intentFactory = coreAndroidModule.intentFactory(), dispatchers = coroutineDispatchers, diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt index 6f49daa..5cfbd09 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/AndroidNotificationBuilder.kt @@ -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, diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationChannels.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationChannels.kt index 12dbd8d..0daa08c 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationChannels.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationChannels.kt @@ -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( diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt index 13d26a1..503e074 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationFactory.kt @@ -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.mostRecent() = this.sortedBy { it.notification.whenTimestamp }.first() diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationInviteRenderer.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationInviteRenderer.kt new file mode 100644 index 0000000..8987ea9 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationInviteRenderer.kt @@ -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) + ) + +} \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationMessageRenderer.kt similarity index 98% rename from features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt rename to features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationMessageRenderer.kt index 5a2cc7c..1a12ddb 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationRenderer.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationMessageRenderer.kt @@ -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, diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt index f3501b9..3368110 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/NotificationsModule.kt @@ -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 diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveInviteNotificationsUseCase.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveInviteNotificationsUseCase.kt new file mode 100644 index 0000000..802fb24 --- /dev/null +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/ObserveInviteNotificationsUseCase.kt @@ -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 + +class ObserveInviteNotificationsUseCaseImpl(private val overviewStore: OverviewStore) : ObserveInviteNotificationsUseCase { + + override suspend fun invoke(): Flow { + 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>.diff(): Flow> { + val previousInvites = mutableSetOf() + 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 Flow>.flatten() = this.flatMapConcat { items -> + flow { items.forEach { this.emit(it) } } +} + +data class InviteNotification( + val content: String, + val roomId: RoomId +) \ No newline at end of file diff --git a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt index 59128eb..d51f6e8 100644 --- a/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt +++ b/features/notifications/src/main/kotlin/app/dapk/st/notifications/RenderNotificationsUseCase.kt @@ -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>, diff: NotificationDiff) { diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt index 60d5e0e..014cce6 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationFactoryTest.kt @@ -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) diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt index ed128f2..0be9e2f 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/NotificationRendererTest.kt @@ -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, diff --git a/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt index 6cc7836..5175956 100644 --- a/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt +++ b/features/notifications/src/test/kotlin/app/dapk/st/notifications/RenderNotificationsUseCaseTest.kt @@ -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) } } diff --git a/features/notifications/src/test/kotlin/fake/FakeNotificationInviteRenderer.kt b/features/notifications/src/test/kotlin/fake/FakeNotificationInviteRenderer.kt new file mode 100644 index 0000000..aa9c662 --- /dev/null +++ b/features/notifications/src/test/kotlin/fake/FakeNotificationInviteRenderer.kt @@ -0,0 +1,8 @@ +package fake + +import app.dapk.st.notifications.NotificationInviteRenderer +import io.mockk.mockk + +class FakeNotificationInviteRenderer { + val instance = mockk() +} \ No newline at end of file diff --git a/features/notifications/src/test/kotlin/fake/FakeNotificationRenderer.kt b/features/notifications/src/test/kotlin/fake/FakeNotificationMessageRenderer.kt similarity index 81% rename from features/notifications/src/test/kotlin/fake/FakeNotificationRenderer.kt rename to features/notifications/src/test/kotlin/fake/FakeNotificationMessageRenderer.kt index fcdc118..288d694 100644 --- a/features/notifications/src/test/kotlin/fake/FakeNotificationRenderer.kt +++ b/features/notifications/src/test/kotlin/fake/FakeNotificationMessageRenderer.kt @@ -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() +class FakeNotificationMessageRenderer { + val instance = mockk() fun verifyRenders(vararg unreadNotifications: UnreadNotifications) { unreadNotifications.forEach { unread -> @@ -23,4 +23,4 @@ class FakeNotificationRenderer { } } } -} \ No newline at end of file +} diff --git a/features/notifications/src/test/kotlin/fake/FakeObserveInviteNotificationsUseCase.kt b/features/notifications/src/test/kotlin/fake/FakeObserveInviteNotificationsUseCase.kt new file mode 100644 index 0000000..fba079f --- /dev/null +++ b/features/notifications/src/test/kotlin/fake/FakeObserveInviteNotificationsUseCase.kt @@ -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() +} \ No newline at end of file diff --git a/test-harness/src/test/kotlin/test/TestMatrix.kt b/test-harness/src/test/kotlin/test/TestMatrix.kt index b758207..590056e 100644 --- a/test-harness/src/test/kotlin/test/TestMatrix.kt +++ b/test-harness/src/test/kotlin/test/TestMatrix.kt @@ -164,7 +164,8 @@ class TestMatrix( ) } } - } + }, + roomInviteRemover = { storeModule.overviewStore().removeInvites(listOf(it)) } ) installSyncService( diff --git a/tools/coverage.gradle b/tools/coverage.gradle index 009e835..c4685d5 100644 --- a/tools/coverage.gradle +++ b/tools/coverage.gradle @@ -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) +} +