diff --git a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt index b3554185cab..aba33d8c01a 100644 --- a/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt +++ b/feature/debug-settings/src/main/kotlin/net/thunderbird/feature/debug/settings/notification/DebugNotificationSectionViewModel.kt @@ -201,20 +201,26 @@ internal class DebugNotificationSectionViewModel( state: State, ): Notification? = when (notificationType) { AuthenticationErrorNotification::class -> AuthenticationErrorNotification( + isIncomingServerError = true, accountUuid = selectedAccount.uuid, accountDisplayName = accountDisplay, + accountNumber = 0, ) CertificateErrorNotification::class -> CertificateErrorNotification( + isIncomingServerError = true, accountUuid = selectedAccount.uuid, accountDisplayName = accountDisplay, + accountNumber = 0, ) FailedToCreateNotification::class -> FailedToCreateNotification( accountUuid = selectedAccount.uuid, failedNotification = AuthenticationErrorNotification( + isIncomingServerError = true, accountUuid = selectedAccount.uuid, accountDisplayName = accountDisplay, + accountNumber = 0, ), ) diff --git a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationHost.kt b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationHost.kt index 6863d66e7a5..e55d54ac12b 100644 --- a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationHost.kt +++ b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/InAppNotificationHost.kt @@ -100,7 +100,7 @@ fun InAppNotificationHost( val state by hostStateHolder.currentInAppNotificationHostState.collectAsState() - LaunchedEffect(inAppNotificationEvents) { + LaunchedEffect(inAppNotificationEvents, eventFilter) { val event = inAppNotificationEvents if (event != null && eventFilter(event)) { when (event) { diff --git a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/InAppNotificationHostLayout.kt b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/InAppNotificationHostLayout.kt index 39d78fba6da..908654c259c 100644 --- a/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/InAppNotificationHostLayout.kt +++ b/feature/notification/api/src/androidMain/kotlin/net/thunderbird/feature/notification/api/ui/layout/InAppNotificationHostLayout.kt @@ -15,6 +15,7 @@ import androidx.compose.ui.layout.Placeable import androidx.compose.ui.layout.SubcomposeLayout import androidx.compose.ui.layout.SubcomposeMeasureScope import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.constrainHeight import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastMap import androidx.compose.ui.util.fastMaxBy @@ -73,10 +74,17 @@ internal fun InAppNotificationHostLayout( // In case the maxHeight is not defined (for example when no content is passed to the content lambda), // we manually calculate the layout height to avoid a crash caused by the pre-condition check // of the layout function. - val layoutHeight = if (constraints.maxHeight == Constraints.Infinity) { - bannerGlobalHeight + bannerInlineListHeight.roundToInt() + mainContentHeight - } else { - constraints.maxHeight + val layoutHeight = when { + constraints.minHeight == 0 || + constraints.maxHeight == Constraints.Infinity -> { + constraints.constrainHeight( + bannerGlobalHeight + bannerInlineListHeight.roundToInt() + mainContentHeight, + ) + } + + else -> { + constraints.maxHeight + } } layout(layoutWidth, layoutHeight) { diff --git a/feature/notification/api/src/commonMain/composeResources/values/strings.xml b/feature/notification/api/src/commonMain/composeResources/values/strings.xml index 62b41da29e6..14fea1b75db 100644 --- a/feature/notification/api/src/commonMain/composeResources/values/strings.xml +++ b/feature/notification/api/src/commonMain/composeResources/values/strings.xml @@ -15,23 +15,23 @@ An error has occurred while trying to create a system notification for a new message. The reason is most likely a missing notification sound.\n\nTap to open notification settings. Authentication failed - Authentication failed for %s. Update your server settings. + Authentication failed for %1$s. Update your server settings. Certificate error - Certificate error for %s + Certificate error for %1$s Check your server settings Checking mail: %1$s: %2$s Checking mail %1$s: %2$s - Sending mail: %s + Sending mail: %1$s Sending mail Failed to send some messages - %d new message - %d new messages + %1$d new message + %1$d new messages + %1$d more on %2$s diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AppNotification.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AppNotification.kt index ee791336678..b4df1ef0c25 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AppNotification.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AppNotification.kt @@ -30,6 +30,7 @@ import net.thunderbird.feature.notification.api.ui.style.SystemNotificationStyle * @see AppNotification */ sealed interface Notification { + val accountUuid: String? val title: String val accessibilityText: String val contentText: String? diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AuthenticationErrorNotification.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AuthenticationErrorNotification.kt index 0ce79c5d8a5..606c161c88b 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AuthenticationErrorNotification.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/AuthenticationErrorNotification.kt @@ -6,6 +6,7 @@ import net.thunderbird.feature.notification.api.ui.action.NotificationAction import net.thunderbird.feature.notification.api.ui.icon.AuthenticationError import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon import net.thunderbird.feature.notification.api.ui.icon.NotificationIcons +import net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyle import net.thunderbird.feature.notification.resources.api.Res import net.thunderbird.feature.notification.resources.api.notification_authentication_error_text import net.thunderbird.feature.notification.resources.api.notification_authentication_error_title @@ -18,16 +19,25 @@ import org.jetbrains.compose.resources.getString */ @ConsistentCopyVisibility data class AuthenticationErrorNotification private constructor( + val isIncomingServerError: Boolean, + override val accountUuid: String, + val accountNumber: Int, override val title: String, override val contentText: String?, override val channel: NotificationChannel, override val icon: NotificationIcon = NotificationIcons.AuthenticationError, ) : AppNotification(), SystemNotification, InAppNotification { override val severity: NotificationSeverity = NotificationSeverity.Fatal - override val actions: Set = setOf( - NotificationAction.Retry, - NotificationAction.UpdateServerSettings, - ) + override val actions: Set = buildSet { + val action = if (isIncomingServerError) { + NotificationAction.UpdateIncomingServerSettings(accountUuid, accountNumber) + } else { + NotificationAction.UpdateOutgoingServerSettings(accountUuid, accountNumber) + } + add(action) + add(NotificationAction.Tap(override = action)) + } + override val inAppNotificationStyle = inAppNotificationStyle { bannerInline() } override fun asLockscreenNotification(): SystemNotification.LockscreenNotification = SystemNotification.LockscreenNotification( @@ -45,12 +55,17 @@ data class AuthenticationErrorNotification private constructor( suspend operator fun invoke( accountUuid: String, accountDisplayName: String, + accountNumber: Int, + isIncomingServerError: Boolean, ): AuthenticationErrorNotification = AuthenticationErrorNotification( - title = getString( - resource = Res.string.notification_authentication_error_title, + isIncomingServerError = isIncomingServerError, + accountUuid = accountUuid, + accountNumber = accountNumber, + title = getString(resource = Res.string.notification_authentication_error_title), + contentText = getString( + resource = Res.string.notification_authentication_error_text, accountDisplayName, ), - contentText = getString(resource = Res.string.notification_authentication_error_text), channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid), ) } diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/CertificateErrorNotification.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/CertificateErrorNotification.kt index 61fc5458308..980b05c6d6a 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/CertificateErrorNotification.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/CertificateErrorNotification.kt @@ -20,6 +20,9 @@ import org.jetbrains.compose.resources.getString */ @ConsistentCopyVisibility data class CertificateErrorNotification private constructor( + val isIncomingServerError: Boolean, + override val accountUuid: String, + val accountNumber: Int, override val title: String, override val contentText: String, val lockScreenTitle: String, @@ -27,7 +30,13 @@ data class CertificateErrorNotification private constructor( override val icon: NotificationIcon = NotificationIcons.CertificateError, ) : AppNotification(), SystemNotification, InAppNotification { override val severity: NotificationSeverity = NotificationSeverity.Fatal - override val actions: Set = setOf(NotificationAction.UpdateServerSettings) + override val actions: Set = setOf( + if (isIncomingServerError) { + NotificationAction.UpdateIncomingServerSettings(accountUuid, accountNumber) + } else { + NotificationAction.UpdateOutgoingServerSettings(accountUuid, accountNumber) + }, + ) override fun asLockscreenNotification(): SystemNotification.LockscreenNotification = SystemNotification.LockscreenNotification( @@ -45,7 +54,12 @@ data class CertificateErrorNotification private constructor( suspend operator fun invoke( accountUuid: String, accountDisplayName: String, + accountNumber: Int, + isIncomingServerError: Boolean, ): CertificateErrorNotification = CertificateErrorNotification( + isIncomingServerError = isIncomingServerError, + accountUuid = accountUuid, + accountNumber = accountNumber, title = getString(resource = Res.string.notification_certificate_error_title, accountDisplayName), lockScreenTitle = getString(resource = Res.string.notification_certificate_error_public), contentText = getString(resource = Res.string.notification_certificate_error_text), diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/FailedToCreateNotification.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/FailedToCreateNotification.kt index 2df7d6e2dbd..404112edb09 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/FailedToCreateNotification.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/FailedToCreateNotification.kt @@ -18,6 +18,7 @@ import org.jetbrains.compose.resources.getString */ @ConsistentCopyVisibility data class FailedToCreateNotification private constructor( + override val accountUuid: String, override val title: String, override val contentText: String?, override val channel: NotificationChannel, @@ -38,6 +39,7 @@ data class FailedToCreateNotification private constructor( accountUuid: String, failedNotification: AppNotification, ): FailedToCreateNotification = FailedToCreateNotification( + accountUuid = accountUuid, title = getString(resource = Res.string.notification_notify_error_title), contentText = getString(resource = Res.string.notification_notify_error_text), channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid), diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/MailNotification.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/MailNotification.kt index 0f2220b2ba9..349c21cb5c8 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/MailNotification.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/MailNotification.kt @@ -34,6 +34,7 @@ sealed class MailNotification : AppNotification(), SystemNotification { override val severity: NotificationSeverity = NotificationSeverity.Information data class Fetching( + override val accountUuid: String, override val title: String, override val accessibilityText: String, override val contentText: String?, @@ -61,6 +62,7 @@ sealed class MailNotification : AppNotification(), SystemNotification { ): Fetching { val title = getString(resource = Res.string.notification_bg_sync_title) return Fetching( + accountUuid = accountUuid, title = title, accessibilityText = folderName?.let { folderName -> getString( @@ -83,6 +85,7 @@ sealed class MailNotification : AppNotification(), SystemNotification { } data class Sending( + override val accountUuid: String, override val title: String, override val accessibilityText: String, override val contentText: String?, @@ -106,6 +109,7 @@ sealed class MailNotification : AppNotification(), SystemNotification { accountUuid: String, accountDisplayName: String, ): Sending = Sending( + accountUuid = accountUuid, title = getString(resource = Res.string.notification_bg_send_title), accessibilityText = getString( resource = Res.string.notification_bg_send_ticker, @@ -118,6 +122,7 @@ sealed class MailNotification : AppNotification(), SystemNotification { } data class SendFailed( + override val accountUuid: String, override val title: String, override val contentText: String?, override val channel: NotificationChannel, @@ -143,6 +148,7 @@ sealed class MailNotification : AppNotification(), SystemNotification { accountUuid: String, exception: Exception, ): SendFailed = SendFailed( + accountUuid = accountUuid, title = getString(resource = Res.string.send_failure_subject), contentText = exception.rootCauseMassage, channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid), @@ -163,7 +169,7 @@ sealed class MailNotification : AppNotification(), SystemNotification { * @property group The notification group this notification belongs to, if any. */ data class NewMailSingleMail( - val accountUuid: String, + override val accountUuid: String, val accountName: String, val messagesNotificationChannelSuffix: String, val summary: String, @@ -208,7 +214,7 @@ sealed class MailNotification : AppNotification(), SystemNotification { */ @ConsistentCopyVisibility data class NewMailSummaryMail private constructor( - val accountUuid: String, + override val accountUuid: String, val accountName: String, val messagesNotificationChannelSuffix: String, override val title: String, diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/NotificationFactoryCoroutineCompat.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/NotificationFactoryCoroutineCompat.kt new file mode 100644 index 00000000000..6390aa06b75 --- /dev/null +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/NotificationFactoryCoroutineCompat.kt @@ -0,0 +1,10 @@ +package net.thunderbird.feature.notification.api.content + +import androidx.annotation.Discouraged +import kotlinx.coroutines.runBlocking + +object NotificationFactoryCoroutineCompat { + @JvmStatic + @Discouraged("Should not be used outside a Java class.") + fun create(builder: suspend () -> T): T = runBlocking { builder() } +} diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/PushServiceNotification.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/PushServiceNotification.kt index 60196f54890..170c01b0e1c 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/PushServiceNotification.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/content/PushServiceNotification.kt @@ -42,6 +42,8 @@ sealed class PushServiceNotification : AppNotification(), SystemNotification { override val actions: Set, override val icon: NotificationIcon = NotificationIcons.PushServiceInitializing, ) : PushServiceNotification() { + override val accountUuid: String? = null + companion object { /** * Creates an [Initializing] notification. @@ -67,6 +69,8 @@ sealed class PushServiceNotification : AppNotification(), SystemNotification { override val actions: Set, override val icon: NotificationIcon = NotificationIcons.PushServiceListening, ) : PushServiceNotification() { + override val accountUuid: String? = null + companion object { /** * Creates a new [Listening] push service notification. @@ -92,6 +96,8 @@ sealed class PushServiceNotification : AppNotification(), SystemNotification { override val actions: Set, override val icon: NotificationIcon = NotificationIcons.PushServiceWaitBackgroundSync, ) : PushServiceNotification() { + override val accountUuid: String? = null + companion object { /** * Creates a [WaitBackgroundSync] notification. @@ -117,6 +123,8 @@ sealed class PushServiceNotification : AppNotification(), SystemNotification { override val actions: Set, override val icon: NotificationIcon = NotificationIcons.PushServiceWaitNetwork, ) : PushServiceNotification() { + override val accountUuid: String? = null + companion object { /** * Creates a [WaitNetwork] notification. @@ -145,6 +153,7 @@ sealed class PushServiceNotification : AppNotification(), SystemNotification { override val contentText: String?, override val icon: NotificationIcon = NotificationIcons.AlarmPermissionMissing, ) : PushServiceNotification(), InAppNotification { + override val accountUuid: String? = null override val severity: NotificationSeverity = NotificationSeverity.Critical companion object { @@ -170,7 +179,7 @@ sealed class PushServiceNotification : AppNotification(), SystemNotification { * @return A set of [NotificationAction] instances. */ private suspend fun buildNotificationActions(): Set = setOf( - NotificationAction.Tap, + NotificationAction.Tap(), NotificationAction.CustomAction( title = getString(resource = Res.string.push_info_disable_push_action), icon = NotificationActionIcons.DisablePushAction, diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/InAppNotificationReceiver.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/InAppNotificationReceiver.kt index 0f021255f7c..8a5499c59ce 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/InAppNotificationReceiver.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/receiver/InAppNotificationReceiver.kt @@ -14,6 +14,8 @@ interface InAppNotificationReceiver { } sealed interface InAppNotificationEvent { - data class Show(val notification: InAppNotification) : InAppNotificationEvent - data class Dismiss(val notification: InAppNotification) : InAppNotificationEvent + val notification: InAppNotification + + data class Show(override val notification: InAppNotification) : InAppNotificationEvent + data class Dismiss(override val notification: InAppNotification) : InAppNotificationEvent } diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/sender/NotificationSender.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/sender/NotificationSender.kt index e09dd5e89ca..4cc4051c8fe 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/sender/NotificationSender.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/sender/NotificationSender.kt @@ -10,7 +10,7 @@ import net.thunderbird.feature.notification.api.content.Notification /** * Responsible for sending notifications by creating and executing the appropriate commands. */ -interface NotificationSender { +fun interface NotificationSender { /** * Sends a notification by creating and executing the appropriate commands. * diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/action/NotificationAction.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/action/NotificationAction.kt index 35111a9921d..cc6cde1f070 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/action/NotificationAction.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/action/NotificationAction.kt @@ -39,10 +39,12 @@ sealed class NotificationAction { * * All [SystemNotification] will have this action implicitly, even if not specified in the * [SystemNotification.actions] set. + * + * @property override The action that will override the tap action for this notification. */ - data object Tap : NotificationAction() { - override val icon: NotificationIcon? = null - override val titleResource: StringResource? = null + data class Tap(val override: NotificationAction? = null) : NotificationAction() { + override val icon: NotificationIcon? = override?.icon + override val titleResource: StringResource? = override?.titleResource } /** @@ -93,9 +95,16 @@ sealed class NotificationAction { /** * Action to prompt the user to update server settings, typically when authentication fails. */ - data object UpdateServerSettings : NotificationAction() { + data class UpdateIncomingServerSettings(val accountUuid: String, val accountNumber: Int) : NotificationAction() { override val icon: NotificationIcon = NotificationActionIcons.UpdateServerSettings + override val titleResource: StringResource = Res.string.notification_action_update_server_settings + } + /** + * Action to prompt the user to update server settings, typically when authentication fails. + */ + data class UpdateOutgoingServerSettings(val accountUuid: String, val accountNumber: Int) : NotificationAction() { + override val icon: NotificationIcon = NotificationActionIcons.UpdateServerSettings override val titleResource: StringResource = Res.string.notification_action_update_server_settings } diff --git a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/visual/InAppNotificationVisual.kt b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/visual/InAppNotificationVisual.kt index 835486c0591..5ad9a41d1ef 100644 --- a/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/visual/InAppNotificationVisual.kt +++ b/feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/ui/host/visual/InAppNotificationVisual.kt @@ -62,7 +62,7 @@ data class BannerGlobalVisual( }, severity = notification.severity, action = notification - .actions + .actionsWithoutTap .let { actions -> check(actions.size in 0..1) { "A notification with a BannerGlobalNotification style must have at zero or one action" @@ -125,7 +125,7 @@ data class BannerInlineVisual( supportingText = checkContentText(notification.contentText), severity = notification.severity, actions = notification - .actions + .actionsWithoutTap .let { actions -> check(actions.size in 1..2) { "A notification with a BannerInlineNotification style must have at one or two actions" @@ -197,7 +197,7 @@ data class SnackbarVisual( message = checkNotNull(notification.contentText) { "A notification with a SnackbarNotification style must have a contentText not null" }, - action = checkNotNull(notification.actions.singleOrNull()) { + action = checkNotNull(notification.actionsWithoutTap.singleOrNull()) { "A notification with a SnackbarNotification style must have exactly one action" }, duration = style.duration, @@ -219,3 +219,8 @@ private inline fun < transform(style) } } + +private val InAppNotification.actionsWithoutTap: Set + get() = actions + .filterNot { it is NotificationAction.Tap } + .toSet() diff --git a/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompatTest.kt b/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompatTest.kt index d281cfc78d1..8d8b2cfe5e8 100644 --- a/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompatTest.kt +++ b/feature/notification/api/src/commonTest/kotlin/net/thunderbird/feature/notification/api/receiver/compat/InAppNotificationReceiverCompatTest.kt @@ -1,7 +1,5 @@ package net.thunderbird.feature.notification.api.receiver.compat -import androidx.compose.ui.graphics.vector.ImageVector -import androidx.compose.ui.unit.dp import assertk.assertThat import assertk.assertions.containsExactlyInAnyOrder import dev.mokkery.matcher.any @@ -15,13 +13,10 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest -import net.thunderbird.feature.notification.api.NotificationSeverity -import net.thunderbird.feature.notification.api.content.AppNotification -import net.thunderbird.feature.notification.api.content.InAppNotification import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver import net.thunderbird.feature.notification.api.receiver.compat.InAppNotificationReceiverCompat.OnReceiveEventListener -import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon +import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification class InAppNotificationReceiverCompatTest { @OptIn(ExperimentalCoroutinesApi::class) @@ -34,10 +29,10 @@ class InAppNotificationReceiverCompatTest { val title = "notification $index" when { index % 2 == 0 -> { - InAppNotificationEvent.Show(FakeNotification(title = title)) + InAppNotificationEvent.Show(FakeInAppOnlyNotification(title = title)) } - else -> InAppNotificationEvent.Dismiss(FakeNotification(title = title)) + else -> InAppNotificationEvent.Dismiss(FakeInAppOnlyNotification(title = title)) } }.toTypedArray() val onReceiveEventListener = spy( @@ -69,18 +64,4 @@ class InAppNotificationReceiverCompatTest { _events.emit(event) } } - - data class FakeNotification( - override val title: String = "fake title", - override val contentText: String? = "fake content", - override val severity: NotificationSeverity = NotificationSeverity.Information, - override val icon: NotificationIcon = NotificationIcon( - inAppNotificationIcon = ImageVector.Builder( - defaultWidth = 0.dp, - defaultHeight = 0.dp, - viewportWidth = 0f, - viewportHeight = 0f, - ).build(), - ), - ) : AppNotification(), InAppNotification } diff --git a/feature/notification/impl/build.gradle.kts b/feature/notification/impl/build.gradle.kts index f5e5006b877..b28b48d4dc5 100644 --- a/feature/notification/impl/build.gradle.kts +++ b/feature/notification/impl/build.gradle.kts @@ -16,6 +16,10 @@ kotlin { implementation(projects.core.logging.testing) implementation(projects.feature.notification.testing) } + androidMain.dependencies { + // should split feature.launcher into api/impl? + implementation(projects.feature.launcher) + } androidUnitTest.dependencies { implementation(libs.androidx.test.core) implementation(libs.mockito.core) diff --git a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.android.kt b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.android.kt index 2fe43819ef5..25fac51a3c9 100644 --- a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.android.kt +++ b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.android.kt @@ -5,6 +5,7 @@ import net.thunderbird.feature.notification.api.receiver.NotificationNotifier import net.thunderbird.feature.notification.impl.intent.action.AlarmPermissionMissingNotificationTapActionIntentCreator import net.thunderbird.feature.notification.impl.intent.action.DefaultNotificationActionIntentCreator import net.thunderbird.feature.notification.impl.intent.action.NotificationActionIntentCreator +import net.thunderbird.feature.notification.impl.intent.action.UpdateServerSettingsNotificationActionIntentCreator import net.thunderbird.feature.notification.impl.receiver.AndroidSystemNotificationNotifier import net.thunderbird.feature.notification.impl.receiver.SystemNotificationNotifier import net.thunderbird.feature.notification.impl.ui.action.DefaultSystemNotificationActionCreator @@ -22,6 +23,10 @@ internal actual val platformFeatureNotificationModule: Module = module { context = androidApplication(), logger = get(), ), + UpdateServerSettingsNotificationActionIntentCreator( + context = androidApplication(), + logger = get(), + ), // The Default implementation must always be the last. DefaultNotificationActionIntentCreator( logger = get(), diff --git a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/UpdateServerSettingsNotificationActionIntentCreator.kt b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/UpdateServerSettingsNotificationActionIntentCreator.kt new file mode 100644 index 00000000000..beb0eec4d25 --- /dev/null +++ b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/UpdateServerSettingsNotificationActionIntentCreator.kt @@ -0,0 +1,58 @@ +package net.thunderbird.feature.notification.impl.intent.action + +import android.app.PendingIntent +import android.app.PendingIntent.FLAG_UPDATE_CURRENT +import android.content.Context +import androidx.core.app.PendingIntentCompat +import app.k9mail.feature.launcher.FeatureLauncherActivity +import app.k9mail.feature.launcher.FeatureLauncherTarget +import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.notification.api.content.AuthenticationErrorNotification +import net.thunderbird.feature.notification.api.content.Notification +import net.thunderbird.feature.notification.api.ui.action.NotificationAction + +private const val TAG = "UpdateServerSettingsNotificationActionIntentCreator" + +class UpdateServerSettingsNotificationActionIntentCreator( + private val context: Context, + private val logger: Logger, +) : NotificationActionIntentCreator { + override fun accept(notification: Notification, action: NotificationAction): Boolean = + notification is AuthenticationErrorNotification && + ( + action is NotificationAction.UpdateIncomingServerSettings || + action is NotificationAction.UpdateOutgoingServerSettings + ) + + override fun create( + notification: AuthenticationErrorNotification, + action: NotificationAction, + ): PendingIntent? { + logger.debug(TAG) { "create() called with: notification = $notification, action = $action" } + val (accountNumber, intent) = when (action) { + is NotificationAction.UpdateIncomingServerSettings -> { + action.accountNumber to FeatureLauncherActivity.getIntent( + context = context, + target = FeatureLauncherTarget.AccountEditIncomingSettings(action.accountUuid), + ) + } + + is NotificationAction.UpdateOutgoingServerSettings -> { + action.accountNumber to FeatureLauncherActivity.getIntent( + context = context, + target = FeatureLauncherTarget.AccountEditOutgoingSettings(action.accountUuid), + ) + } + + else -> error("Unsupported action: $action") + } + + return PendingIntentCompat.getActivity( + context, + accountNumber, + intent, + FLAG_UPDATE_CURRENT, + false, + ) + } +} diff --git a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/receiver/AndroidSystemNotificationNotifier.kt b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/receiver/AndroidSystemNotificationNotifier.kt index 7a1ac13f9e5..a36f33bc240 100644 --- a/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/receiver/AndroidSystemNotificationNotifier.kt +++ b/feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/receiver/AndroidSystemNotificationNotifier.kt @@ -60,16 +60,23 @@ internal class AndroidSystemNotificationNotifier( } } + val overrideTap = systemNotification + .actions + .firstOrNull { + it is NotificationAction.Tap && it.override != null + } as? NotificationAction.Tap + val tapAction = notificationActionCreator.create( notification = systemNotification, - action = NotificationAction.Tap, + action = overrideTap?.override ?: NotificationAction.Tap(), ) setContentIntent(tapAction.pendingIntent) setNotificationStyle(notification = systemNotification) - if (actions.isNotEmpty()) { - for (action in actions) { + val actionsWithoutTap = actions.filterNot { it is NotificationAction.Tap } + if (actionsWithoutTap.isNotEmpty()) { + for (action in actionsWithoutTap) { val notificationAction = notificationActionCreator .create(notification = systemNotification, action) diff --git a/feature/notification/impl/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreatorTest.kt b/feature/notification/impl/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreatorTest.kt index 36f8f600496..71dca5a0c33 100644 --- a/feature/notification/impl/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreatorTest.kt +++ b/feature/notification/impl/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreatorTest.kt @@ -34,13 +34,14 @@ class DefaultNotificationActionIntentCreatorTest { fun `accept should return true for any type of notification action`() { // Arrange val multipleActions = listOf( - NotificationAction.Tap, + NotificationAction.Tap(), NotificationAction.Reply, NotificationAction.MarkAsRead, NotificationAction.Delete, NotificationAction.MarkAsSpam, NotificationAction.Archive, - NotificationAction.UpdateServerSettings, + NotificationAction.UpdateIncomingServerSettings("uuid", 1), + NotificationAction.UpdateOutgoingServerSettings("uuid", 1), NotificationAction.Retry, NotificationAction.CustomAction(title = "Custom Action 1"), NotificationAction.CustomAction(title = "Custom Action 2"), @@ -96,7 +97,7 @@ class DefaultNotificationActionIntentCreatorTest { val testSubject = createTestSubject(context) // Act - testSubject.create(notification = FakeNotification(), action = NotificationAction.Tap) + testSubject.create(notification = FakeNotification(), action = NotificationAction.Tap()) // Assert pendingIntentCompat.verify { diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeInAppOnlyNotification.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeInAppOnlyNotification.kt index 6259db449fb..c7acce19f3e 100644 --- a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeInAppOnlyNotification.kt +++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeInAppOnlyNotification.kt @@ -10,6 +10,7 @@ import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle data class FakeInAppOnlyNotification( + override val accountUuid: String? = null, override val title: String = "fake title", override val contentText: String? = "fake content", override val severity: NotificationSeverity = NotificationSeverity.Information, diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotification.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotification.kt index e2c4ce02e9a..0bddb633c18 100644 --- a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotification.kt +++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeNotification.kt @@ -11,6 +11,7 @@ import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon import net.thunderbird.feature.notification.testing.fake.icon.EMPTY_SYSTEM_NOTIFICATION_ICON data class FakeNotification( + override val accountUuid: String? = null, override val title: String = "fake title", override val contentText: String? = "fake content", override val severity: NotificationSeverity = NotificationSeverity.Information, diff --git a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeSystemOnlyNotification.kt b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeSystemOnlyNotification.kt index 8136797291c..0d27b8e4f65 100644 --- a/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeSystemOnlyNotification.kt +++ b/feature/notification/testing/src/commonMain/kotlin/net/thunderbird/feature/notification/testing/fake/FakeSystemOnlyNotification.kt @@ -8,6 +8,7 @@ import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon import net.thunderbird.feature.notification.testing.fake.icon.EMPTY_SYSTEM_NOTIFICATION_ICON data class FakeSystemOnlyNotification( + override val accountUuid: String? = null, override val title: String = "fake title", override val contentText: String? = "fake content", override val severity: NotificationSeverity = NotificationSeverity.Information, diff --git a/legacy/core/build.gradle.kts b/legacy/core/build.gradle.kts index 2417ba6edbc..a9d4f9f1cd8 100644 --- a/legacy/core/build.gradle.kts +++ b/legacy/core/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { api(projects.core.logging.implFile) api(projects.core.logging.implComposite) api(projects.core.android.network) + api(projects.core.outcome) api(projects.feature.mail.folder.api) api(projects.feature.account.storage.legacy) @@ -62,6 +63,7 @@ dependencies { // test fakes testImplementation(projects.feature.account.fake) + testImplementation(projects.feature.notification.testing) } android { diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt b/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt index 8c73f80f390..05048efa803 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt +++ b/legacy/core/src/main/java/com/fsck/k9/controller/KoinModule.kt @@ -13,6 +13,7 @@ import com.fsck.k9.notification.NotificationController import com.fsck.k9.notification.NotificationStrategy import net.thunderbird.core.featureflag.FeatureFlagProvider import net.thunderbird.core.logging.Logger +import net.thunderbird.feature.notification.api.sender.NotificationSender import org.koin.core.qualifier.named import org.koin.dsl.module @@ -32,6 +33,7 @@ val controllerModule = module { get(named("controllerExtensions")), get(), get(named("syncDebug")), + get(), ) } diff --git a/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java b/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java index 0e4554c4be9..14974b24c61 100644 --- a/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java +++ b/legacy/core/src/main/java/com/fsck/k9/controller/MessagingController.java @@ -65,7 +65,6 @@ import com.fsck.k9.mail.Flag; import com.fsck.k9.mail.Message; import com.fsck.k9.mail.MessageDownloadState; -import net.thunderbird.core.common.exception.MessagingException; import com.fsck.k9.mail.Part; import com.fsck.k9.mail.ServerSettings; import com.fsck.k9.mail.power.PowerManager; @@ -84,9 +83,16 @@ import com.fsck.k9.notification.NotificationStrategy; import net.thunderbird.core.android.account.DeletePolicy; import net.thunderbird.core.android.account.LegacyAccountDto; +import net.thunderbird.core.common.exception.MessagingException; import net.thunderbird.core.featureflag.FeatureFlagProvider; +import net.thunderbird.core.featureflag.FeatureFlagResult.Enabled; +import net.thunderbird.core.featureflag.compat.FeatureFlagProviderCompat; import net.thunderbird.core.logging.Logger; import net.thunderbird.core.logging.legacy.Log; +import net.thunderbird.feature.notification.api.content.AuthenticationErrorNotification; +import net.thunderbird.feature.notification.api.content.NotificationFactoryCoroutineCompat; +import net.thunderbird.feature.notification.api.sender.NotificationSender; +import net.thunderbird.feature.notification.api.sender.compat.NotificationSenderCompat; import net.thunderbird.feature.search.legacy.LocalMessageSearch; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -135,7 +141,9 @@ public class MessagingController implements MessagingControllerRegistry, Messagi private final DraftOperations draftOperations; private final NotificationOperations notificationOperations; private final ArchiveOperations archiveOperations; + private final FeatureFlagProvider featureFlagProvider; private final Logger syncDebugLogger; + private final NotificationSenderCompat notificationSender; private volatile boolean stopped = false; @@ -159,7 +167,8 @@ public static MessagingController getInstance(Context context) { LocalDeleteOperationDecider localDeleteOperationDecider, List controllerExtensions, FeatureFlagProvider featureFlagProvider, - Logger syncDebugLogger + Logger syncDebugLogger, + NotificationSender notificationSender ) { this.context = context; this.notificationController = notificationController; @@ -171,7 +180,9 @@ public static MessagingController getInstance(Context context) { this.saveMessageDataCreator = saveMessageDataCreator; this.specialLocalFoldersCreator = specialLocalFoldersCreator; this.localDeleteOperationDecider = localDeleteOperationDecider; + this.featureFlagProvider = featureFlagProvider; this.syncDebugLogger = syncDebugLogger; + this.notificationSender = new NotificationSenderCompat(notificationSender); controllerThread = new Thread(new Runnable() { @Override @@ -691,7 +702,30 @@ public void handleAuthenticationFailure(LegacyAccountDto account, boolean incomi migrateAccountToOAuth(account); } - notificationController.showAuthenticationErrorNotification(account, incoming); + if (FeatureFlagProviderCompat.provide(featureFlagProvider, "display_in_app_notifications") == + Enabled.INSTANCE) { + Log.d("handleAuthenticationFailure: sending in-app notification"); + final AuthenticationErrorNotification notification = NotificationFactoryCoroutineCompat.create( + continuation -> + AuthenticationErrorNotification.Companion.invoke( + account.getUuid(), + account.getDisplayName(), + account.getAccountNumber(), + incoming, + continuation + ) + ); + + notificationSender.send(notification, outcome -> { + Log.v("notificationSender outcome = " + outcome); + }); + } + + if (FeatureFlagProviderCompat.provide(featureFlagProvider, + "use_notification_sender_for_system_notifications") != Enabled.INSTANCE) { + Log.d("handleAuthenticationFailure: sending system notification via old notification controller"); + notificationController.showAuthenticationErrorNotification(account, incoming); + } } private void migrateAccountToOAuth(LegacyAccountDto account) { @@ -2543,6 +2577,18 @@ public void notifyUserIfCertificateProblem(LegacyAccountDto account, Exception e } } + public void checkAuthenticationProblem(LegacyAccountDto account) { + // checking incoming server configuration + if (isAuthenticationProblem(account, true)) { + handleAuthenticationFailure(account, true); + return; + } + // checking outgoing server configuration + if (isAuthenticationProblem(account, false)) { + handleAuthenticationFailure(account, false); + } + } + private boolean isAuthenticationProblem(LegacyAccountDto account, boolean incoming) { ServerSettings serverSettings = incoming ? account.getIncomingServerSettings() : account.getOutgoingServerSettings(); diff --git a/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java b/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java index c33c2a33b28..fd561d4069c 100644 --- a/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java +++ b/legacy/core/src/test/java/com/fsck/k9/controller/MessagingControllerTest.java @@ -38,6 +38,9 @@ import com.fsck.k9.notification.NotificationStrategy; import net.thunderbird.core.common.mail.Protocols; import net.thunderbird.core.logging.Logger; +import net.thunderbird.core.outcome.Outcome; +import net.thunderbird.feature.notification.api.sender.NotificationSender; +import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification; import org.junit.After; import org.junit.Before; import org.junit.Test; @@ -127,6 +130,10 @@ public void setUp() throws MessagingException { preferences = Preferences.getPreferences(); featureFlagProvider = key -> Disabled.INSTANCE; + final NotificationSender notificationSender = notification -> + (flowCollector, continuation) -> + Outcome.Companion.success(new FakeInAppOnlyNotification()); + controller = new MessagingController( appContext, notificationController, @@ -140,7 +147,8 @@ public void setUp() throws MessagingException { new LocalDeleteOperationDecider(), Collections.emptyList(), featureFlagProvider, - syncLogger + syncLogger, + notificationSender ); configureAccount(); diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt index 0a41373b0de..4bc9b866037 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListAdapter.kt @@ -8,22 +8,32 @@ import android.view.View import android.view.View.OnClickListener import android.view.View.OnLongClickListener import android.view.ViewGroup +import androidx.compose.ui.platform.ComposeView import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.NO_POSITION +import app.k9mail.feature.launcher.FeatureLauncherActivity +import app.k9mail.feature.launcher.FeatureLauncherTarget import app.k9mail.legacy.message.controller.MessageReference import com.fsck.k9.contacts.ContactPictureLoader import com.fsck.k9.ui.helper.RelativeDateTimeFormatter +import com.fsck.k9.ui.messagelist.item.BannerInlineListInAppNotificationViewHolder import com.fsck.k9.ui.messagelist.item.FooterViewHolder import com.fsck.k9.ui.messagelist.item.MessageListViewHolder import com.fsck.k9.ui.messagelist.item.MessageViewHolder import com.fsck.k9.ui.messagelist.item.MessageViewHolderColors +import net.thunderbird.core.featureflag.FeatureFlagKey +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.featureflag.FeatureFlagResult +import net.thunderbird.feature.notification.api.ui.action.NotificationAction private const val FOOTER_ID = 1L private const val TYPE_MESSAGE = 0 private const val TYPE_FOOTER = 1 +private const val TYPE_IN_APP_NOTIFICATION_BANNER_INLINE_LIST = 2 +@Suppress("LongParameterList") class MessageListAdapter internal constructor( private val theme: Theme, private val res: Resources, @@ -32,6 +42,7 @@ class MessageListAdapter internal constructor( private val listItemListener: MessageListItemActionListener, private val appearance: MessageListAppearance, private val relativeDateTimeFormatter: RelativeDateTimeFormatter, + private val featureFlagProvider: FeatureFlagProvider, ) : RecyclerView.Adapter() { val colors: MessageViewHolderColors = MessageViewHolderColors.resolveColors(theme) @@ -42,6 +53,7 @@ class MessageListAdapter internal constructor( val oldMessageList = field field = value + accountUuids = value.map { it.account.uuid }.toSet() messagesMap = value.associateBy { it.uniqueId } if (selected.isNotEmpty()) { @@ -56,6 +68,7 @@ class MessageListAdapter internal constructor( } private var messagesMap = emptyMap() + private var accountUuids = emptySet() var activeMessage: MessageReference? = null set(value) { @@ -161,6 +174,9 @@ class MessageListAdapter internal constructor( listItemListener.onToggleMessageSelection(messageListItem) } + private val isInAppNotificationEnabled: Boolean + get() = featureFlagProvider.provide(FeatureFlagKey.DisplayInAppNotifications) == FeatureFlagResult.Enabled + init { setHasStableIds(true) } @@ -176,7 +192,11 @@ class MessageListAdapter internal constructor( } override fun getItemViewType(position: Int): Int { - return if (position <= lastMessagePosition) TYPE_MESSAGE else TYPE_FOOTER + return when { + position == 0 && isInAppNotificationEnabled -> TYPE_IN_APP_NOTIFICATION_BANNER_INLINE_LIST + position <= lastMessagePosition -> TYPE_MESSAGE + else -> TYPE_FOOTER + } } private fun getItem(position: Int): MessageListItem = messages[position] @@ -209,6 +229,32 @@ class MessageListAdapter internal constructor( return when (viewType) { TYPE_MESSAGE -> createMessageViewHolder(parent) TYPE_FOOTER -> FooterViewHolder.create(layoutInflater, parent, footerClickListener) + TYPE_IN_APP_NOTIFICATION_BANNER_INLINE_LIST if isInAppNotificationEnabled -> + BannerInlineListInAppNotificationViewHolder( + view = ComposeView(context = parent.context), + eventFilter = { event -> + val accountUuid = event.notification.accountUuid + accountUuid != null && accountUuid in accountUuids + }, + onNotificationActionClick = { action -> + when (action) { + is NotificationAction.UpdateIncomingServerSettings -> + FeatureLauncherActivity.launch( + context = parent.context, + target = FeatureLauncherTarget.AccountEditIncomingSettings(action.accountUuid), + ) + + is NotificationAction.UpdateOutgoingServerSettings -> + FeatureLauncherActivity.launch( + context = parent.context, + target = FeatureLauncherTarget.AccountEditOutgoingSettings(action.accountUuid), + ) + + else -> Unit + } + }, + ) + else -> error("Unsupported type: $viewType") } } @@ -231,6 +277,9 @@ class MessageListAdapter internal constructor( override fun onBindViewHolder(holder: MessageListViewHolder, position: Int) { when (val viewType = getItemViewType(position)) { + TYPE_IN_APP_NOTIFICATION_BANNER_INLINE_LIST if isInAppNotificationEnabled -> + (holder as BannerInlineListInAppNotificationViewHolder).bind() + TYPE_MESSAGE -> { val messageListItem = getItem(position) val messageViewHolder = holder as MessageViewHolder diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt index 9c49fc1f2be..03d91e85c74 100644 --- a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/MessageListFragment.kt @@ -5,7 +5,6 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.os.SystemClock -import android.util.TypedValue import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem @@ -15,6 +14,11 @@ import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.annotation.StringRes import androidx.appcompat.view.ActionMode +import androidx.compose.animation.animateContentSize +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.ComposeView +import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.os.bundleOf import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat.Type.navigationBars @@ -23,6 +27,7 @@ import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.setPadding import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePadding import androidx.fragment.app.Fragment import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.Observer @@ -61,6 +66,7 @@ import com.google.android.material.textview.MaterialTextView import java.util.concurrent.Future import kotlin.time.Clock import kotlin.time.ExperimentalTime +import kotlinx.collections.immutable.persistentSetOf import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.launchIn @@ -76,11 +82,19 @@ import net.thunderbird.core.android.network.ConnectivityManager import net.thunderbird.core.architecture.data.DataMapper import net.thunderbird.core.common.action.SwipeAction import net.thunderbird.core.common.exception.MessagingException +import net.thunderbird.core.featureflag.FeatureFlagKey +import net.thunderbird.core.featureflag.FeatureFlagProvider +import net.thunderbird.core.featureflag.FeatureFlagResult import net.thunderbird.core.logging.legacy.Log import net.thunderbird.core.preference.GeneralSettingsManager +import net.thunderbird.core.ui.theme.api.FeatureThemeProvider import net.thunderbird.feature.account.storage.legacy.mapper.DefaultLegacyAccountWrapperDataMapper import net.thunderbird.feature.mail.message.list.domain.DomainContract import net.thunderbird.feature.mail.message.list.ui.dialog.SetupArchiveFolderDialogFragmentFactory +import net.thunderbird.feature.notification.api.ui.InAppNotificationHost +import net.thunderbird.feature.notification.api.ui.host.DisplayInAppNotificationFlag +import net.thunderbird.feature.notification.api.ui.host.visual.SnackbarVisual +import net.thunderbird.feature.notification.api.ui.style.SnackbarDuration import net.thunderbird.feature.search.legacy.LocalMessageSearch import net.thunderbird.feature.search.legacy.SearchAccount import net.thunderbird.feature.search.legacy.serialization.LocalMessageSearchSerializer @@ -118,6 +132,8 @@ class MessageListFragment : private val buildSwipeActions: DomainContract.UseCase.BuildSwipeActions by inject { parametersOf(preferences.storage) } + private val featureFlagProvider: FeatureFlagProvider by inject() + private val featureThemeProvider: FeatureThemeProvider by inject() private val handler = MessageListHandler(this) private val activityListener = MessageListActivityListener() @@ -139,6 +155,7 @@ class MessageListFragment : private lateinit var fragmentListener: MessageListFragmentListener private lateinit var recentChangesSnackbar: Snackbar + private var coordinatorLayout: CoordinatorLayout? = null private var recyclerView: RecyclerView? = null private var itemTouchHelper: ItemTouchHelper? = null private var swipeRefreshLayout: SwipeRefreshLayout? = null @@ -317,6 +334,7 @@ class MessageListFragment : listItemListener = this, appearance = messageListAppearance, relativeDateTimeFormatter = RelativeDateTimeFormatter(requireContext(), clock), + featureFlagProvider = featureFlagProvider, ).apply { activeMessage = this@MessageListFragment.activeMessage } @@ -325,13 +343,6 @@ class MessageListFragment : override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { return if (error == null) { inflater.inflate(R.layout.message_list_fragment, container, false).also { view -> - val typedValued = TypedValue() - requireContext().theme.resolveAttribute( - com.google.android.material.R.attr.colorSurface, - typedValued, - true, - ) - setFragmentResultListener( SetupArchiveFolderDialogFragmentFactory.RESULT_CODE_DISMISS_REQUEST_KEY, ) { key, bundle -> @@ -474,10 +485,65 @@ class MessageListFragment : recyclerView.adapter = adapter + if (featureFlagProvider.provide(FeatureFlagKey.DisplayInAppNotifications) == FeatureFlagResult.Enabled) { + view.findViewById(R.id.banner_global_compose_view).apply { + setContent { + featureThemeProvider.WithTheme { + InAppNotificationHost( + onActionClick = { }, + enabled = persistentSetOf( + DisplayInAppNotificationFlag.BannerGlobalNotifications, + DisplayInAppNotificationFlag.SnackbarNotifications, + ), + onSnackbarNotificationEvent = ::onSnackbarInAppNotificationEvent, + eventFilter = { event -> + val accountUuid = event.notification.accountUuid + accountUuid != null && accounts.any { it.uuid == accountUuid } + }, + modifier = Modifier + .animateContentSize() + .onSizeChanged { size -> + recyclerView.updatePadding(top = size.height) + }, + ) + } + } + } + } + this.recyclerView = recyclerView this.itemTouchHelper = itemTouchHelper } + private fun requireCoordinatorLayout(): CoordinatorLayout { + val coordinatorLayout = coordinatorLayout + ?: requireView().findViewById(R.id.message_list_coordinator) + .also { coordinatorLayout = it } + + return coordinatorLayout ?: error("Coordinator layout not initialized") + } + + private suspend fun onSnackbarInAppNotificationEvent(visual: SnackbarVisual) { + val (message, action, duration) = visual + Snackbar.make( + requireCoordinatorLayout(), + message, + when (duration) { + SnackbarDuration.Short -> Snackbar.LENGTH_SHORT + SnackbarDuration.Long -> Snackbar.LENGTH_LONG + SnackbarDuration.Indefinite -> Snackbar.LENGTH_INDEFINITE + }, + ).apply { + if (action != null) { + setAction( + action.resolveTitle(), + ) { + // TODO. + } + } + }.show() + } + private val shouldShowRecentChangesHintObserver = Observer { showRecentChangesHint -> val recentChangesSnackbarVisible = recentChangesSnackbar.isShown if (showRecentChangesHint && !recentChangesSnackbarVisible) { @@ -488,7 +554,7 @@ class MessageListFragment : } private fun initializeRecentChangesSnackbar() { - val coordinatorLayout = requireView().findViewById(R.id.message_list_coordinator) + val coordinatorLayout = requireCoordinatorLayout() recentChangesSnackbar = Snackbar .make(coordinatorLayout, R.string.changelog_snackbar_text, RECENT_CHANGES_SNACKBAR_DURATION) @@ -668,6 +734,7 @@ class MessageListFragment : } override fun onDestroyView() { + coordinatorLayout = null recyclerView = null messageListSwipeCallback = null itemTouchHelper = null @@ -1618,6 +1685,11 @@ class MessageListFragment : adapter.restoreSelected(it) } + messageListItems + .map { it.account } + .toSet() + .forEach(messagingController::checkAuthenticationProblem) + resetActionMode() computeBatchDirection() diff --git a/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageListInAppNotificationViewHolder.kt b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageListInAppNotificationViewHolder.kt new file mode 100644 index 00000000000..c9b33db9092 --- /dev/null +++ b/legacy/ui/legacy/src/main/java/com/fsck/k9/ui/messagelist/item/MessageListInAppNotificationViewHolder.kt @@ -0,0 +1,43 @@ +package com.fsck.k9.ui.messagelist.item + +import androidx.compose.animation.animateContentSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.ComposeView +import kotlinx.collections.immutable.persistentSetOf +import net.thunderbird.core.ui.theme.api.FeatureThemeProvider +import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent +import net.thunderbird.feature.notification.api.ui.InAppNotificationHost +import net.thunderbird.feature.notification.api.ui.action.NotificationAction +import net.thunderbird.feature.notification.api.ui.host.DisplayInAppNotificationFlag +import org.koin.compose.koinInject + +abstract class MessageListInAppNotificationViewHolder(protected val view: ComposeView) : MessageListViewHolder(view) { + fun bind() { + view.setContent { + val themeProvider = koinInject() + themeProvider.WithTheme { + Content() + } + } + } + + @Composable + abstract fun Content() +} + +class BannerInlineListInAppNotificationViewHolder( + private val onNotificationActionClick: (NotificationAction) -> Unit, + private val eventFilter: (InAppNotificationEvent) -> Boolean, + view: ComposeView, +) : MessageListInAppNotificationViewHolder(view) { + @Composable + override fun Content() { + InAppNotificationHost( + onActionClick = onNotificationActionClick, + enabled = persistentSetOf(DisplayInAppNotificationFlag.BannerInlineNotifications), + eventFilter = eventFilter, + modifier = Modifier.animateContentSize(), + ) + } +} diff --git a/legacy/ui/legacy/src/main/res/layout/message_list_fragment.xml b/legacy/ui/legacy/src/main/res/layout/message_list_fragment.xml index 8b8dd3980c0..66ac02e7df6 100644 --- a/legacy/ui/legacy/src/main/res/layout/message_list_fragment.xml +++ b/legacy/ui/legacy/src/main/res/layout/message_list_fragment.xml @@ -43,6 +43,15 @@ android:scrollbars="vertical" tools:listitem="@layout/message_list_item" /> + + + diff --git a/legacy/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt b/legacy/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt index 9882e03ebf3..db22c48bd96 100644 --- a/legacy/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt +++ b/legacy/ui/legacy/src/test/java/com/fsck/k9/ui/messagelist/MessageListAdapterTest.kt @@ -27,6 +27,7 @@ import com.google.android.material.textview.MaterialTextView import kotlin.time.ExperimentalTime import net.thunderbird.core.android.account.LegacyAccountDto import net.thunderbird.core.android.testing.RobolectricTest +import net.thunderbird.core.featureflag.FeatureFlagResult import net.thunderbird.core.testing.TestClock import org.junit.Test import org.mockito.kotlin.mock @@ -423,6 +424,7 @@ class MessageListAdapterTest : RobolectricTest() { listItemListener = listItemListener, appearance = appearance, relativeDateTimeFormatter = RelativeDateTimeFormatter(context, TestClock()), + featureFlagProvider = { FeatureFlagResult.Disabled }, ) }