Repo created
57
feature/notification/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.kmpCompose)
|
||||
alias(libs.plugins.dev.mokkery)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.outcome)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(projects.feature.notification.testing)
|
||||
}
|
||||
androidMain.dependencies {
|
||||
implementation(projects.core.ui.compose.designsystem)
|
||||
implementation(projects.core.ui.compose.theme2.common)
|
||||
}
|
||||
androidUnitTest.dependencies {
|
||||
implementation(projects.core.ui.compose.testing)
|
||||
implementation(libs.bundles.shared.jvm.test.compose)
|
||||
implementation(libs.bundles.shared.jvm.android.compose.debug)
|
||||
}
|
||||
jvmTest.dependencies {
|
||||
implementation(libs.kotlinx.coroutines.test)
|
||||
implementation(libs.bundles.shared.jvm.test)
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets.all {
|
||||
compilerOptions {
|
||||
freeCompilerArgs.addAll(
|
||||
"-Xexpect-actual-classes",
|
||||
"-Xwhen-guards",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "net.thunderbird.feature.notification.api"
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
java {
|
||||
sourceCompatibility = ThunderbirdProjectConfig.Compiler.javaVersion
|
||||
targetCompatibility = ThunderbirdProjectConfig.Compiler.javaVersion
|
||||
}
|
||||
|
||||
compose.resources {
|
||||
publicResClass = false
|
||||
packageOfResClass = "net.thunderbird.feature.notification.resources.api"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package net.thunderbird.feature.notification
|
||||
|
||||
import android.app.Notification
|
||||
|
||||
internal actual val NotificationLight.defaultColorInt: Int
|
||||
get() = Notification.COLOR_DEFAULT
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package net.thunderbird.feature.notification.api.ui
|
||||
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.movableContentOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import app.k9mail.core.ui.compose.designsystem.organism.banner.global.ErrorBannerGlobalNotificationCard
|
||||
import app.k9mail.core.ui.compose.designsystem.organism.banner.global.InfoBannerGlobalNotificationCard
|
||||
import app.k9mail.core.ui.compose.designsystem.organism.banner.global.WarningBannerGlobalNotificationCard
|
||||
import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId
|
||||
import net.thunderbird.feature.notification.api.NotificationSeverity
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
import net.thunderbird.feature.notification.api.ui.action.ResolvedNotificationActionButton
|
||||
import net.thunderbird.feature.notification.api.ui.animation.bannerSlideInSlideOutAnimationSpec
|
||||
import net.thunderbird.feature.notification.api.ui.host.InAppNotificationHostStateHolder
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.BannerGlobalVisual
|
||||
|
||||
/**
|
||||
* Displays global notifications as banners.
|
||||
*
|
||||
* This Composable observes the [InAppNotificationHostStateHolder] for changes in the
|
||||
* [BannerGlobalVisual] and displays the appropriate banner notification with an animation.
|
||||
*
|
||||
* @param hostStateHolder The [InAppNotificationHostStateHolder] that holds the current notification state.
|
||||
* @param onActionClick A callback that is invoked when a notification action button is clicked.
|
||||
* @param modifier Optional [Modifier] to be applied to the banner host.
|
||||
*/
|
||||
@Composable
|
||||
fun BannerGlobalNotificationHost(
|
||||
hostStateHolder: InAppNotificationHostStateHolder,
|
||||
onActionClick: (NotificationAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state by hostStateHolder.currentInAppNotificationHostState.collectAsState()
|
||||
val bannerGlobal = state.bannerGlobalVisual
|
||||
AnimatedContent(
|
||||
targetState = bannerGlobal,
|
||||
modifier = modifier.testTagAsResourceId(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST),
|
||||
transitionSpec = { bannerSlideInSlideOutAnimationSpec() },
|
||||
) { bannerGlobal ->
|
||||
if (bannerGlobal != null) {
|
||||
BannerGlobalNotificationHostLayout(
|
||||
visual = bannerGlobal,
|
||||
onActionClick = onActionClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BannerGlobalNotificationHostLayout(
|
||||
visual: BannerGlobalVisual,
|
||||
onActionClick: (NotificationAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val action = remember(visual.action) {
|
||||
movableContentOf {
|
||||
visual.action?.let { action ->
|
||||
ResolvedNotificationActionButton(
|
||||
action = action,
|
||||
onActionClick = onActionClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
when (visual.severity) {
|
||||
NotificationSeverity.Fatal, NotificationSeverity.Critical -> ErrorBannerGlobalNotificationCard(
|
||||
text = visual.message,
|
||||
action = action,
|
||||
modifier = modifier.testTagAsResourceId(BannerGlobalNotificationHostDefaults.TEST_TAG_ERROR_BANNER),
|
||||
)
|
||||
|
||||
NotificationSeverity.Warning -> WarningBannerGlobalNotificationCard(
|
||||
text = visual.message,
|
||||
action = action,
|
||||
modifier = modifier.testTagAsResourceId(BannerGlobalNotificationHostDefaults.TEST_TAG_WARNING_BANNER),
|
||||
)
|
||||
|
||||
NotificationSeverity.Temporary, NotificationSeverity.Information -> InfoBannerGlobalNotificationCard(
|
||||
text = visual.message,
|
||||
action = action,
|
||||
modifier = modifier.testTagAsResourceId(BannerGlobalNotificationHostDefaults.TEST_TAG_INFO_BANNER),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
object BannerGlobalNotificationHostDefaults {
|
||||
internal const val TEST_TAG_HOST = "banner_global_notification_host"
|
||||
internal const val TEST_TAG_ERROR_BANNER = "error_banner_global_notification"
|
||||
internal const val TEST_TAG_WARNING_BANNER = "warning_banner_global_notification"
|
||||
internal const val TEST_TAG_INFO_BANNER = "info_banner_global_notification"
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
package net.thunderbird.feature.notification.api.ui.action
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import app.k9mail.core.ui.compose.designsystem.molecule.notification.NotificationActionButton
|
||||
import kotlin.let
|
||||
|
||||
/**
|
||||
* Displays a [NotificationActionButton] with the title resolved from the [NotificationAction].
|
||||
*
|
||||
* This composable function takes a [NotificationAction] and resolves its title asynchronously.
|
||||
* While the title is being resolved, nothing is displayed. Once the title is available,
|
||||
* it renders a [NotificationActionButton] with the resolved title.
|
||||
*
|
||||
* @param action The [NotificationAction] to display.
|
||||
* @param onActionClick Callback invoked when the action button is clicked.
|
||||
* @param modifier Optional [Modifier] to be applied to the composable.
|
||||
*/
|
||||
@Composable
|
||||
internal fun ResolvedNotificationActionButton(
|
||||
action: NotificationAction,
|
||||
onActionClick: (NotificationAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
var text by remember(action) { mutableStateOf<String?>(value = null) }
|
||||
|
||||
LaunchedEffect(action) {
|
||||
text = action.resolveTitle()
|
||||
}
|
||||
|
||||
text?.let { text ->
|
||||
NotificationActionButton(
|
||||
text = text,
|
||||
onClick = {
|
||||
onActionClick(action)
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
package net.thunderbird.feature.notification.api.ui.action.icon
|
||||
|
||||
import net.thunderbird.feature.notification.api.R
|
||||
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon
|
||||
|
||||
internal actual val NotificationActionIcons.Reply: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_reply,
|
||||
)
|
||||
|
||||
internal actual val NotificationActionIcons.MarkAsRead: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_mark_email_read,
|
||||
)
|
||||
|
||||
internal actual val NotificationActionIcons.Delete: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_delete,
|
||||
)
|
||||
|
||||
internal actual val NotificationActionIcons.MarkAsSpam: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_report,
|
||||
)
|
||||
|
||||
internal actual val NotificationActionIcons.Archive: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_archive,
|
||||
)
|
||||
|
||||
internal actual val NotificationActionIcons.UpdateServerSettings: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_settings,
|
||||
)
|
||||
|
||||
internal actual val NotificationActionIcons.Retry: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_refresh,
|
||||
)
|
||||
|
||||
internal actual val NotificationActionIcons.DisablePushAction: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_settings,
|
||||
)
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
package net.thunderbird.feature.notification.api.ui.animation
|
||||
|
||||
import androidx.compose.animation.AnimatedContentTransitionScope
|
||||
import androidx.compose.animation.ContentTransform
|
||||
import androidx.compose.animation.SizeTransform
|
||||
import androidx.compose.animation.core.keyframes
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.slideInVertically
|
||||
import androidx.compose.animation.slideOutVertically
|
||||
import androidx.compose.animation.togetherWith
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
|
||||
private const val A_QUARTER = 4
|
||||
|
||||
/**
|
||||
* Defines a custom animation for a banner that slides in and out vertically.
|
||||
*
|
||||
* The banner fades in and slides in from the top when appearing,
|
||||
* and fades out and slides out towards the top when disappearing.
|
||||
*
|
||||
* The size transformation ensures that the width of the banner changes immediately to the target width,
|
||||
* while the height animates from the initial height to the target height.
|
||||
* The height animation is structured in keyframes:
|
||||
* - For the first quarter of the animation duration, the height remains the initial height.
|
||||
* - After the first quarter, the height transitions to the target height.
|
||||
*
|
||||
* @param T The type of the content being animated.
|
||||
* @return A [ContentTransform] object that specifies the enter and exit transitions,
|
||||
* as well as the size transformation.
|
||||
*/
|
||||
fun <T> AnimatedContentTransitionScope<T>.bannerSlideInSlideOutAnimationSpec(): ContentTransform {
|
||||
val enter = fadeIn() + slideInVertically()
|
||||
val exit = fadeOut() + slideOutVertically()
|
||||
return enter togetherWith exit using SizeTransform { initialSize, targetSize ->
|
||||
keyframes {
|
||||
IntSize(width = targetSize.width, height = initialSize.height) at durationMillis / A_QUARTER
|
||||
IntSize(width = targetSize.width, height = targetSize.height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,77 @@
|
|||
package net.thunderbird.feature.notification.api.ui.icon
|
||||
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.icon.Icons
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.icon.outlined.Warning
|
||||
import net.thunderbird.feature.notification.api.R
|
||||
import net.thunderbird.feature.notification.api.ui.icon.atom.Notification
|
||||
|
||||
private val Warning = Icons.Outlined.Warning
|
||||
internal actual val NotificationIcons.AuthenticationError: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_warning,
|
||||
inAppNotificationIcon = Warning,
|
||||
)
|
||||
|
||||
internal actual val NotificationIcons.CertificateError: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_warning,
|
||||
inAppNotificationIcon = Warning,
|
||||
)
|
||||
|
||||
internal actual val NotificationIcons.FailedToCreate: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_warning,
|
||||
inAppNotificationIcon = Warning,
|
||||
)
|
||||
|
||||
internal actual val NotificationIcons.MailFetching: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_sync_animated,
|
||||
)
|
||||
|
||||
internal actual val NotificationIcons.MailSending: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_sync_animated,
|
||||
)
|
||||
|
||||
internal actual val NotificationIcons.MailSendFailed: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_warning,
|
||||
inAppNotificationIcon = Warning,
|
||||
)
|
||||
|
||||
internal actual val NotificationIcons.NewMailSingleMail: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_new_email,
|
||||
)
|
||||
|
||||
internal actual val NotificationIcons.NewMailSummaryMail: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_new_email,
|
||||
)
|
||||
|
||||
internal actual val NotificationIcons.PushServiceInitializing: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_notification,
|
||||
)
|
||||
|
||||
internal actual val NotificationIcons.PushServiceListening: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_notification,
|
||||
)
|
||||
|
||||
internal actual val NotificationIcons.PushServiceWaitBackgroundSync: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_notification,
|
||||
)
|
||||
|
||||
internal actual val NotificationIcons.PushServiceWaitNetwork: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_notification,
|
||||
)
|
||||
|
||||
internal actual val NotificationIcons.AlarmPermissionMissing: NotificationIcon
|
||||
get() = NotificationIcon(
|
||||
systemNotificationIcon = R.drawable.ic_notification,
|
||||
inAppNotificationIcon = Notification,
|
||||
)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.thunderbird.feature.notification.api.ui.icon
|
||||
|
||||
actual typealias SystemNotificationIcon = Int
|
||||
|
After Width: | Height: | Size: 839 B |
|
After Width: | Height: | Size: 812 B |
|
After Width: | Height: | Size: 788 B |
|
After Width: | Height: | Size: 820 B |
|
After Width: | Height: | Size: 800 B |
|
After Width: | Height: | Size: 805 B |
|
After Width: | Height: | Size: 561 B |
|
After Width: | Height: | Size: 556 B |
|
After Width: | Height: | Size: 566 B |
|
After Width: | Height: | Size: 544 B |
|
After Width: | Height: | Size: 551 B |
|
After Width: | Height: | Size: 572 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1 KiB |
|
After Width: | Height: | Size: 1,017 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1 KiB |
|
|
@ -0,0 +1,13 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,720L640,560L584,504L520,568L520,400L440,400L440,568L376,504L320,560L480,720ZM200,320L200,760Q200,760 200,760Q200,760 200,760L760,760Q760,760 760,760Q760,760 760,760L760,320L200,320ZM200,840Q167,840 143.5,816.5Q120,793 120,760L120,261Q120,247 124.5,234Q129,221 138,210L188,149Q199,135 215.5,127.5Q232,120 250,120L710,120Q728,120 744.5,127.5Q761,135 772,149L822,210Q831,221 835.5,234Q840,247 840,261L840,760Q840,793 816.5,816.5Q793,840 760,840L200,840ZM216,240L744,240L710,200Q710,200 710,200Q710,200 710,200L250,200Q250,200 250,200Q250,200 250,200L216,240ZM480,540L480,540L480,540Q480,540 480,540Q480,540 480,540L480,540Q480,540 480,540Q480,540 480,540L480,540Z"
|
||||
/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M280,840Q247,840 223.5,816.5Q200,793 200,760L200,240L160,240L160,160L360,160L360,120L600,120L600,160L800,160L800,240L760,240L760,760Q760,793 736.5,816.5Q713,840 680,840L280,840ZM680,240L280,240L280,760Q280,760 280,760Q280,760 280,760L680,760Q680,760 680,760Q680,760 680,760L680,240ZM360,680L440,680L440,320L360,320L360,680ZM520,680L600,680L600,320L520,320L520,680ZM280,240L280,240L280,760Q280,760 280,760Q280,760 280,760L280,760Q280,760 280,760Q280,760 280,760L280,240Z"
|
||||
/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M638,880L468,710L524,654L638,768L864,542L920,598L638,880ZM480,440L800,240L160,240L480,440ZM480,520L160,320L160,720Q160,720 160,720Q160,720 160,720L366,720L446,800L160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L800,160Q833,160 856.5,183.5Q880,207 880,240L880,414L800,494L800,320L480,520ZM480,520L480,520L480,520L480,520L480,520L480,520L480,520L480,520L480,520Q480,520 480,520Q480,520 480,520L480,520ZM480,440L480,440L480,440L480,440ZM480,520L480,520L480,520L480,520L480,520L480,520L480,520Z"
|
||||
/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M160,800Q127,800 103.5,776.5Q80,753 80,720L80,240Q80,207 103.5,183.5Q127,160 160,160L564,160Q560,180 560,200Q560,220 564,240L160,240L480,440L626,349Q640,362 656.5,371.5Q673,381 691,388L480,520L160,320L160,720Q160,720 160,720Q160,720 160,720L800,720Q800,720 800,720Q800,720 800,720L800,396Q823,391 843,382Q863,373 880,360L880,720Q880,753 856.5,776.5Q833,800 800,800L160,800ZM160,240L160,240L160,240L160,720Q160,720 160,720Q160,720 160,720L160,720Q160,720 160,720Q160,720 160,720L160,240Q160,240 160,240Q160,240 160,240Q160,240 160,240Q160,240 160,240ZM760,320Q710,320 675,285Q640,250 640,200Q640,150 675,115Q710,80 760,80Q810,80 845,115Q880,150 880,200Q880,250 845,285Q810,320 760,320Z"
|
||||
/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:pathData="M12,3.5C8.953,3.5 6.5,5.953 6.5,9V12V13.5C5.392,13.5 4.5,14.392 4.5,15.5V17C4.5,17.277 4.723,17.5 5,17.5H6.5H7H17H17.5H19C19.277,17.5 19.5,17.277 19.5,17V15.5C19.5,14.392 18.608,13.5 17.5,13.5V10.5V9C17.5,5.953 15.047,3.5 12,3.5Z"
|
||||
android:strokeAlpha="0.2"
|
||||
android:fillColor="#1A202C"
|
||||
android:fillAlpha="0.2"/>
|
||||
<path
|
||||
android:pathData="M12,3C8.685,3 6,5.685 6,9V12V13.207C4.895,13.465 4,14.318 4,15.5V17C4,17.545 4.455,18 5,18H6.5H7H9C9,19.653 10.347,21 12,21C13.653,21 15,19.653 15,18H17H17.5H19C19.545,18 20,17.545 20,17V15.5C20,14.318 19.105,13.465 18,13.207V10.5V9C18,5.685 15.315,3 12,3ZM12,4C14.779,4 17,6.221 17,9V10.5V13.5C17,13.633 17.053,13.76 17.146,13.854C17.24,13.947 17.367,14 17.5,14C18.34,14 19,14.66 19,15.5V17H17.5H17H14.5H9.5H7H6.5H5V15.5C5,14.66 5.66,14 6.5,14C6.633,14 6.76,13.947 6.854,13.854C6.947,13.76 7,13.633 7,13.5V12V9C7,6.221 9.221,4 12,4ZM10,18H14C14,19.117 13.117,20 12,20C10.883,20 10,19.117 10,18Z"
|
||||
android:fillColor="#1A202C"/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,800Q346,800 253,707Q160,614 160,480Q160,346 253,253Q346,160 480,160Q549,160 612,188.5Q675,217 720,270L720,160L800,160L800,440L520,440L520,360L688,360Q656,304 600.5,272Q545,240 480,240Q380,240 310,310Q240,380 240,480Q240,580 310,650Q380,720 480,720Q557,720 619,676Q681,632 706,560L790,560Q762,666 676,733Q590,800 480,800Z"
|
||||
/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
android:autoMirrored="true"
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M760,760L760,600Q760,550 725,515Q690,480 640,480L273,480L417,624L360,680L120,440L360,200L417,256L273,400L640,400Q723,400 781.5,458.5Q840,517 840,600L840,760L760,760Z"
|
||||
/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M480,680Q497,680 508.5,668.5Q520,657 520,640Q520,623 508.5,611.5Q497,600 480,600Q463,600 451.5,611.5Q440,623 440,640Q440,657 451.5,668.5Q463,680 480,680ZM440,520L520,520L520,280L440,280L440,520ZM330,840L120,630L120,330L330,120L630,120L840,330L840,630L630,840L330,840ZM364,760L596,760L760,596L760,364L596,200L364,200L200,364L200,596L364,760ZM480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480L480,480Z"
|
||||
/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M370,880L354,752Q341,747 329.5,740Q318,733 307,725L188,775L78,585L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L78,375L188,185L307,235Q318,227 330,220Q342,213 354,208L370,80L590,80L606,208Q619,213 630.5,220Q642,227 653,235L772,185L882,375L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L881,585L771,775L653,725Q642,733 630,740Q618,747 606,752L590,880L370,880ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480Z"
|
||||
/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<animation-list
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:oneshot="false"
|
||||
>
|
||||
|
||||
<item
|
||||
android:drawable="@drawable/notification_icon_check_mail_anim_0"
|
||||
android:duration="100"
|
||||
/>
|
||||
<item
|
||||
android:drawable="@drawable/notification_icon_check_mail_anim_1"
|
||||
android:duration="100"
|
||||
/>
|
||||
<item
|
||||
android:drawable="@drawable/notification_icon_check_mail_anim_2"
|
||||
android:duration="100"
|
||||
/>
|
||||
<item
|
||||
android:drawable="@drawable/notification_icon_check_mail_anim_3"
|
||||
android:duration="100"
|
||||
/>
|
||||
<item
|
||||
android:drawable="@drawable/notification_icon_check_mail_anim_4"
|
||||
android:duration="100"
|
||||
/>
|
||||
<item
|
||||
android:drawable="@drawable/notification_icon_check_mail_anim_5"
|
||||
android:duration="100"
|
||||
/>
|
||||
|
||||
</animation-list>
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="960"
|
||||
android:viewportHeight="960"
|
||||
android:tint="?attr/colorControlNormal"
|
||||
>
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M40,840L480,80L920,840L40,840ZM178,760L782,760L480,240L178,760ZM480,720Q497,720 508.5,708.5Q520,697 520,680Q520,663 508.5,651.5Q497,640 480,640Q463,640 451.5,651.5Q440,663 440,680Q440,697 451.5,708.5Q463,720 480,720ZM440,600L520,600L520,400L440,400L440,600ZM480,500L480,500L480,500L480,500Z"
|
||||
/>
|
||||
</vector>
|
||||
|
|
@ -0,0 +1,395 @@
|
|||
package net.thunderbird.feature.notification.api.ui
|
||||
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.SemanticsProperties
|
||||
import androidx.compose.ui.test.SemanticsMatcher
|
||||
import androidx.compose.ui.test.SemanticsNodeInteraction
|
||||
import androidx.compose.ui.test.assert
|
||||
import androidx.compose.ui.test.assertCountEquals
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.assertTextEquals
|
||||
import androidx.compose.ui.test.filterToOne
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasNoClickAction
|
||||
import androidx.compose.ui.test.hasTextExactly
|
||||
import androidx.compose.ui.test.onChild
|
||||
import androidx.compose.ui.test.onChildren
|
||||
import androidx.compose.ui.test.onFirst
|
||||
import androidx.compose.ui.test.onRoot
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.printToString
|
||||
import app.k9mail.core.ui.compose.designsystem.atom.button.ButtonText
|
||||
import app.k9mail.core.ui.compose.testing.ComposeTest
|
||||
import app.k9mail.core.ui.compose.testing.onNodeWithTag
|
||||
import app.k9mail.core.ui.compose.testing.onNodeWithText
|
||||
import app.k9mail.core.ui.compose.testing.setContentWithTheme
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.isTrue
|
||||
import kotlin.test.Test
|
||||
import net.thunderbird.core.ui.compose.common.modifier.testTagAsResourceId
|
||||
import net.thunderbird.feature.notification.api.NotificationSeverity
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
import net.thunderbird.feature.notification.api.ui.host.rememberInAppNotificationHostState
|
||||
import net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyles
|
||||
import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification
|
||||
import net.thunderbird.feature.notification.testing.fake.ui.action.createFakeNotificationAction
|
||||
|
||||
const val BUTTON_NOTIFICATION_TEST_TAG = "button_notification_test_tag"
|
||||
|
||||
class BannerGlobalNotificationHostTest : ComposeTest() {
|
||||
@Test
|
||||
fun `should show error banner when severity is fatal`() = runComposeTest {
|
||||
// Arrange
|
||||
val title = "Notification"
|
||||
val contentText = "This is the content text of the notification"
|
||||
val notification = createNotification(
|
||||
title = title,
|
||||
contentText = contentText,
|
||||
severity = NotificationSeverity.Fatal,
|
||||
)
|
||||
mainClock.autoAdvance = false
|
||||
|
||||
setContentWithTheme {
|
||||
TestSubject(notification, onActionClick = {})
|
||||
}
|
||||
|
||||
// Act
|
||||
onNodeWithTag(BUTTON_NOTIFICATION_TEST_TAG).performClick()
|
||||
|
||||
// Assert
|
||||
printSemanticTree()
|
||||
mainClock.advanceTimeBy(1000L)
|
||||
printSemanticTree()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
|
||||
.assertIsDisplayed()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_ERROR_BANNER)
|
||||
.assertIsDisplayed()
|
||||
.onChild()
|
||||
.assertTextEquals(contentText)
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_WARNING_BANNER)
|
||||
.assertDoesNotExist()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_INFO_BANNER)
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show error banner when severity is critical`() = runComposeTest {
|
||||
// Arrange
|
||||
val title = "Notification"
|
||||
val contentText = "This is the content text of the notification"
|
||||
val notification = createNotification(
|
||||
title = title,
|
||||
contentText = contentText,
|
||||
severity = NotificationSeverity.Critical,
|
||||
)
|
||||
mainClock.autoAdvance = false
|
||||
|
||||
setContentWithTheme {
|
||||
TestSubject(notification, onActionClick = {})
|
||||
}
|
||||
|
||||
// Act
|
||||
onNodeWithTag(BUTTON_NOTIFICATION_TEST_TAG).performClick()
|
||||
printSemanticTree()
|
||||
mainClock.advanceTimeBy(1000L)
|
||||
printSemanticTree()
|
||||
|
||||
// Assert
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
|
||||
.assertIsDisplayed()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_ERROR_BANNER)
|
||||
.assertIsDisplayed()
|
||||
.onChild()
|
||||
.assertTextEquals(contentText)
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_WARNING_BANNER)
|
||||
.assertDoesNotExist()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_INFO_BANNER)
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show warning banner when severity is warning`() = runComposeTest {
|
||||
// Arrange
|
||||
val title = "Notification"
|
||||
val contentText = "This is the content text of the notification"
|
||||
val notification = createNotification(
|
||||
title = title,
|
||||
contentText = contentText,
|
||||
severity = NotificationSeverity.Warning,
|
||||
)
|
||||
mainClock.autoAdvance = false
|
||||
|
||||
setContentWithTheme {
|
||||
TestSubject(notification, onActionClick = {})
|
||||
}
|
||||
|
||||
// Act
|
||||
onNodeWithTag(BUTTON_NOTIFICATION_TEST_TAG).performClick()
|
||||
printSemanticTree()
|
||||
mainClock.advanceTimeBy(1000L)
|
||||
printSemanticTree()
|
||||
|
||||
// Assert
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
|
||||
.assertIsDisplayed()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_ERROR_BANNER)
|
||||
.assertDoesNotExist()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_WARNING_BANNER)
|
||||
.assertIsDisplayed()
|
||||
.onChild()
|
||||
.assertTextEquals(contentText)
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_INFO_BANNER)
|
||||
.assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show info banner when severity is temporary`() = runComposeTest {
|
||||
// Arrange
|
||||
val title = "Notification"
|
||||
val contentText = "This is the content text of the notification"
|
||||
val notification = createNotification(
|
||||
title = title,
|
||||
contentText = contentText,
|
||||
severity = NotificationSeverity.Temporary,
|
||||
)
|
||||
mainClock.autoAdvance = false
|
||||
|
||||
setContentWithTheme {
|
||||
TestSubject(notification, onActionClick = {})
|
||||
}
|
||||
|
||||
// Act
|
||||
onNodeWithTag(BUTTON_NOTIFICATION_TEST_TAG).performClick()
|
||||
printSemanticTree()
|
||||
mainClock.advanceTimeBy(1000L)
|
||||
printSemanticTree()
|
||||
|
||||
// Assert
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
|
||||
.assertIsDisplayed()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_ERROR_BANNER)
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_WARNING_BANNER)
|
||||
.assertDoesNotExist()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_INFO_BANNER)
|
||||
.assertIsDisplayed()
|
||||
.onChild()
|
||||
.assertTextEquals(contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show info banner when severity is information`() = runComposeTest {
|
||||
// Arrange
|
||||
val title = "Notification"
|
||||
val contentText = "This is the content text of the notification"
|
||||
val notification = createNotification(
|
||||
title = title,
|
||||
contentText = contentText,
|
||||
severity = NotificationSeverity.Information,
|
||||
)
|
||||
mainClock.autoAdvance = false
|
||||
|
||||
setContentWithTheme {
|
||||
TestSubject(notification, onActionClick = {})
|
||||
}
|
||||
|
||||
// Act
|
||||
onNodeWithTag(BUTTON_NOTIFICATION_TEST_TAG).performClick()
|
||||
printSemanticTree()
|
||||
mainClock.advanceTimeBy(1000L)
|
||||
printSemanticTree()
|
||||
|
||||
// Assert
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
|
||||
.assertIsDisplayed()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_ERROR_BANNER)
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_WARNING_BANNER)
|
||||
.assertDoesNotExist()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_INFO_BANNER)
|
||||
.assertIsDisplayed()
|
||||
.onChild()
|
||||
.assertTextEquals(contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should show action button when action is not null`() = runComposeTest {
|
||||
// Arrange
|
||||
val title = "Notification"
|
||||
val contentText = "This is the content text of the notification"
|
||||
val actionText = "Fake action"
|
||||
val notification = createNotification(
|
||||
title = title,
|
||||
contentText = contentText,
|
||||
severity = NotificationSeverity.Fatal,
|
||||
action = createFakeNotificationAction(title = actionText),
|
||||
)
|
||||
mainClock.autoAdvance = false
|
||||
|
||||
val actionClicked = mutableStateOf(false)
|
||||
|
||||
setContentWithTheme {
|
||||
TestSubject(notification, onActionClick = { actionClicked.value = true })
|
||||
}
|
||||
// Act
|
||||
onNodeWithTag(BUTTON_NOTIFICATION_TEST_TAG).performClick()
|
||||
printSemanticTree()
|
||||
mainClock.advanceTimeBy(1000L)
|
||||
printSemanticTree()
|
||||
|
||||
// Assert
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
|
||||
.assertIsDisplayed()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_ERROR_BANNER)
|
||||
.assertIsDisplayed()
|
||||
.also { printSemanticTree(it) }
|
||||
.onChildren()
|
||||
.run {
|
||||
filterToOne(hasTextExactly(contentText))
|
||||
.assertExists()
|
||||
filterToOne(hasTextExactly(actionText))
|
||||
.assertExists()
|
||||
.assert(hasClickAction())
|
||||
.assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should not show action button when action is null`() = runComposeTest {
|
||||
// Arrange
|
||||
val title = "Notification"
|
||||
val contentText = "This is the content text of the notification"
|
||||
val notification = createNotification(
|
||||
title = title,
|
||||
contentText = contentText,
|
||||
severity = NotificationSeverity.Fatal,
|
||||
action = null,
|
||||
)
|
||||
mainClock.autoAdvance = false
|
||||
|
||||
val actionClicked = mutableStateOf(false)
|
||||
|
||||
setContentWithTheme {
|
||||
TestSubject(notification, onActionClick = { actionClicked.value = true })
|
||||
}
|
||||
|
||||
// Act
|
||||
onNodeWithTag(BUTTON_NOTIFICATION_TEST_TAG).performClick()
|
||||
printSemanticTree()
|
||||
mainClock.advanceTimeBy(1000L)
|
||||
printSemanticTree()
|
||||
// Assert
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
|
||||
.assertIsDisplayed()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_ERROR_BANNER)
|
||||
.assertIsDisplayed()
|
||||
.also { printSemanticTree(it) }
|
||||
.assert(hasNoClickAction())
|
||||
.onChildren()
|
||||
.assertCountEquals(1)
|
||||
.onFirst()
|
||||
.assertTextEquals(contentText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `should call onActionClick when action is clicked`() = runComposeTest {
|
||||
// Arrange
|
||||
val title = "Notification"
|
||||
val contentText = "This is the content text of the notification"
|
||||
val actionText = "Fake action"
|
||||
val notification = createNotification(
|
||||
title = title,
|
||||
contentText = contentText,
|
||||
severity = NotificationSeverity.Fatal,
|
||||
action = createFakeNotificationAction(title = actionText),
|
||||
)
|
||||
mainClock.autoAdvance = false
|
||||
|
||||
val actionClicked = mutableStateOf(false)
|
||||
|
||||
setContentWithTheme {
|
||||
TestSubject(notification, onActionClick = { actionClicked.value = true })
|
||||
}
|
||||
|
||||
// Act
|
||||
onNodeWithTag(BUTTON_NOTIFICATION_TEST_TAG).performClick()
|
||||
printSemanticTree()
|
||||
mainClock.advanceTimeBy(1000L)
|
||||
printSemanticTree()
|
||||
|
||||
// Assert
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_HOST)
|
||||
.assertIsDisplayed()
|
||||
|
||||
onNodeWithTag(BannerGlobalNotificationHostDefaults.TEST_TAG_ERROR_BANNER)
|
||||
.assertIsDisplayed()
|
||||
.onChildren()
|
||||
.filterToOne(hasTextExactly(contentText))
|
||||
|
||||
printSemanticTree()
|
||||
onNodeWithText(actionText).performClick()
|
||||
assertThat(actionClicked.value)
|
||||
.isTrue()
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TestSubject(
|
||||
notification: FakeInAppOnlyNotification,
|
||||
onActionClick: (NotificationAction) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val state = rememberInAppNotificationHostState()
|
||||
Column(modifier = modifier) {
|
||||
ButtonText(
|
||||
text = "Trigger Notification",
|
||||
onClick = { state.showInAppNotification(notification) },
|
||||
modifier = Modifier.testTagAsResourceId(BUTTON_NOTIFICATION_TEST_TAG),
|
||||
)
|
||||
|
||||
BannerGlobalNotificationHost(
|
||||
hostStateHolder = state,
|
||||
onActionClick = onActionClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun createNotification(
|
||||
title: String,
|
||||
contentText: String,
|
||||
severity: NotificationSeverity,
|
||||
action: NotificationAction? = null,
|
||||
) = FakeInAppOnlyNotification(
|
||||
title = title,
|
||||
contentText = contentText,
|
||||
severity = severity,
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerGlobal() },
|
||||
actions = action?.let { setOf(it) }.orEmpty(),
|
||||
)
|
||||
|
||||
private fun printSemanticTree(root: SemanticsNodeInteraction = composeTestRule.onRoot(useUnmergedTree = true)) {
|
||||
println()
|
||||
println("Semantic tree:")
|
||||
println(root.printToString())
|
||||
println()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<resources>
|
||||
<!-- TODO: Perform a cleanup removing the other resources from the other modules with the same name once the notification system is consolidated. -->
|
||||
<!-- Channel configuration -->
|
||||
<string name="notification_channel_push_title">Synchronize (Push)</string>
|
||||
<string name="notification_channel_push_description">Displayed while waiting for new messages</string>
|
||||
<string name="notification_channel_messages_title">Messages</string>
|
||||
<string name="notification_channel_messages_description">Notifications related to messages</string>
|
||||
<string name="notification_channel_miscellaneous_title">Miscellaneous</string>
|
||||
<string name="notification_channel_miscellaneous_description">Miscellaneous notifications like errors etc.</string>
|
||||
|
||||
<!-- Title of an error notification that is displayed when creating a notification for a new message has failed -->
|
||||
<string name="notification_notify_error_title">Notification error</string>
|
||||
<!-- Body of an error notification that is displayed when creating a notification for a new message has failed -->
|
||||
<string name="notification_notify_error_text">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.</string>
|
||||
|
||||
<string name="notification_authentication_error_title">Authentication failed</string>
|
||||
<string name="notification_authentication_error_text">Authentication failed for %s. Update your server settings.</string>
|
||||
|
||||
<string name="notification_certificate_error_public">Certificate error</string>
|
||||
<string name="notification_certificate_error_title">Certificate error for %s</string>
|
||||
<string name="notification_certificate_error_text">Check your server settings</string>
|
||||
|
||||
<string name="notification_bg_sync_ticker">Checking mail: %1$s: %2$s</string>
|
||||
<string name="notification_bg_sync_title">Checking mail</string>
|
||||
<string name="notification_bg_sync_text">%1$s: %2$s</string>
|
||||
<string name="notification_bg_send_ticker">Sending mail: %s</string>
|
||||
<string name="notification_bg_send_title">Sending mail</string>
|
||||
|
||||
<string name="send_failure_subject">Failed to send some messages</string>
|
||||
|
||||
<plurals name="notification_new_messages_title">
|
||||
<item quantity="one">%d new message</item>
|
||||
<item quantity="other">%d new messages</item>
|
||||
</plurals>
|
||||
<string name="notification_additional_messages">+ %1$d more on %2$s</string>
|
||||
|
||||
<string name="push_notification_state_initializing">Initializing…</string>
|
||||
<string name="push_notification_state_listening">Waiting for new emails</string>
|
||||
<string name="push_notification_state_wait_background_sync">Sleeping until background sync is allowed</string>
|
||||
<string name="push_notification_state_wait_network">Sleeping until network is available</string>
|
||||
<string name="push_notification_state_alarm_permission_missing">Missing permission to schedule alarms</string>
|
||||
<string name="push_notification_info">Tap to learn more.</string>
|
||||
<string name="push_notification_grant_alarm_permission">Tap to grant permission.</string>
|
||||
|
||||
<string name="push_info_disable_push_action">Disable Push</string>
|
||||
|
||||
<string name="notification_action_reply">Reply</string>
|
||||
<string name="notification_action_mark_as_read">Mark Read</string>
|
||||
<string name="notification_action_mark_all_as_read">Mark All Read</string>
|
||||
<string name="notification_action_delete">Delete</string>
|
||||
<string name="notification_action_delete_all">Delete All</string>
|
||||
<string name="notification_action_archive">Archive</string>
|
||||
<string name="notification_action_archive_all">Archive All</string>
|
||||
<string name="notification_action_spam">Spam</string>
|
||||
<string name="notification_action_retry">Retry</string>
|
||||
<string name="notification_action_update_server_settings">Update Server Settings</string>
|
||||
|
||||
</resources>
|
||||
|
|
@ -0,0 +1,34 @@
|
|||
package net.thunderbird.feature.notification
|
||||
|
||||
enum class NotificationLight {
|
||||
Disabled,
|
||||
AccountColor,
|
||||
SystemDefaultColor,
|
||||
White,
|
||||
Red,
|
||||
Green,
|
||||
Blue,
|
||||
Yellow,
|
||||
Cyan,
|
||||
Magenta,
|
||||
;
|
||||
|
||||
fun toColor(accountColor: Int): Int? {
|
||||
return when (this) {
|
||||
Disabled -> null
|
||||
AccountColor -> accountColor.toArgb()
|
||||
SystemDefaultColor -> defaultColorInt
|
||||
White -> 0xFFFFFF.toArgb()
|
||||
Red -> 0xFF0000.toArgb()
|
||||
Green -> 0x00FF00.toArgb()
|
||||
Blue -> 0x0000FF.toArgb()
|
||||
Yellow -> 0xFFFF00.toArgb()
|
||||
Cyan -> 0x00FFFF.toArgb()
|
||||
Magenta -> 0xFF00FF.toArgb()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun Int.toArgb() = this or 0xFF000000L.toInt()
|
||||
}
|
||||
|
||||
internal expect val NotificationLight.defaultColorInt: Int
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
package net.thunderbird.feature.notification
|
||||
|
||||
/**
|
||||
* Describes how a notification should behave.
|
||||
*/
|
||||
data class NotificationSettings(
|
||||
val isRingEnabled: Boolean = false,
|
||||
val ringtone: String? = null,
|
||||
val light: NotificationLight = NotificationLight.Disabled,
|
||||
val vibration: NotificationVibration = NotificationVibration.DEFAULT,
|
||||
)
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package net.thunderbird.feature.notification
|
||||
|
||||
data class NotificationVibration(
|
||||
val isEnabled: Boolean,
|
||||
val pattern: VibratePattern,
|
||||
val repeatCount: Int,
|
||||
) {
|
||||
val systemPattern: LongArray
|
||||
get() = getSystemPattern(pattern, repeatCount)
|
||||
|
||||
companion object {
|
||||
val DEFAULT = NotificationVibration(isEnabled = false, pattern = VibratePattern.Default, repeatCount = 5)
|
||||
|
||||
fun getSystemPattern(vibratePattern: VibratePattern, repeatCount: Int): LongArray {
|
||||
val selectedPattern = vibratePattern.vibrationPattern
|
||||
val repeatedPattern = LongArray(selectedPattern.size * repeatCount)
|
||||
for (n in 0 until repeatCount) {
|
||||
selectedPattern.copyInto(
|
||||
destination = repeatedPattern,
|
||||
destinationOffset = n * selectedPattern.size,
|
||||
startIndex = 0,
|
||||
endIndex = selectedPattern.size,
|
||||
)
|
||||
}
|
||||
|
||||
// Do not wait before starting the vibration pattern.
|
||||
repeatedPattern[0] = 0
|
||||
|
||||
return repeatedPattern
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
package net.thunderbird.feature.notification
|
||||
|
||||
@Suppress("MagicNumber")
|
||||
enum class VibratePattern(
|
||||
/**
|
||||
* These are "off, on" patterns, specified in milliseconds.
|
||||
*/
|
||||
val vibrationPattern: LongArray,
|
||||
) {
|
||||
Default(vibrationPattern = longArrayOf(300, 200)),
|
||||
Pattern1(vibrationPattern = longArrayOf(100, 200)),
|
||||
Pattern2(vibrationPattern = longArrayOf(100, 500)),
|
||||
Pattern3(vibrationPattern = longArrayOf(200, 200)),
|
||||
Pattern4(vibrationPattern = longArrayOf(200, 500)),
|
||||
Pattern5(vibrationPattern = longArrayOf(500, 500)),
|
||||
;
|
||||
|
||||
fun serialize(): Int = when (this) {
|
||||
Default -> 0
|
||||
Pattern1 -> 1
|
||||
Pattern2 -> 2
|
||||
Pattern3 -> 3
|
||||
Pattern4 -> 4
|
||||
Pattern5 -> 5
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun deserialize(value: Int): VibratePattern = when (value) {
|
||||
0 -> Default
|
||||
1 -> Pattern1
|
||||
2 -> Pattern2
|
||||
3 -> Pattern3
|
||||
4 -> Pattern4
|
||||
5 -> Pattern5
|
||||
else -> error("Unknown VibratePattern value: $value")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
package net.thunderbird.feature.notification.api
|
||||
|
||||
/**
|
||||
* Defines the appearance of notifications on the lock screen.
|
||||
*/
|
||||
sealed interface LockscreenNotificationAppearance {
|
||||
/**
|
||||
* No notifications are shown on the lock screen.
|
||||
*/
|
||||
data object None : LockscreenNotificationAppearance
|
||||
|
||||
/**
|
||||
* Only the app name is shown on the lock screen for new messages.
|
||||
*/
|
||||
data object AppName : LockscreenNotificationAppearance
|
||||
|
||||
/**
|
||||
* All the notification content's is shown on the lock screen.
|
||||
*/
|
||||
data object Public : LockscreenNotificationAppearance
|
||||
|
||||
/**
|
||||
* The number of new messages is shown on the lock screen.
|
||||
*/
|
||||
data object MessageCount : LockscreenNotificationAppearance
|
||||
|
||||
/**
|
||||
* The names of the senders of new messages are shown on the lock screen.
|
||||
*/
|
||||
data class SenderNames(val senderNames: String) : LockscreenNotificationAppearance
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package net.thunderbird.feature.notification.api
|
||||
|
||||
import net.thunderbird.feature.notification.resources.api.Res
|
||||
import net.thunderbird.feature.notification.resources.api.notification_channel_messages_description
|
||||
import net.thunderbird.feature.notification.resources.api.notification_channel_messages_title
|
||||
import net.thunderbird.feature.notification.resources.api.notification_channel_miscellaneous_description
|
||||
import net.thunderbird.feature.notification.resources.api.notification_channel_miscellaneous_title
|
||||
import net.thunderbird.feature.notification.resources.api.notification_channel_push_description
|
||||
import net.thunderbird.feature.notification.resources.api.notification_channel_push_title
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/**
|
||||
* Represents the different notification channels used by the application.
|
||||
*
|
||||
* Each sealed class variant defines a specific type of notification channel with its unique [id].
|
||||
*
|
||||
* @property id The unique identifier for the notification channel.
|
||||
* @property name The user-visible name of the channel.
|
||||
* @property description The user-visible description of the channel.
|
||||
* @property importance The importance level of the channel.
|
||||
*/
|
||||
sealed class NotificationChannel(
|
||||
val id: String,
|
||||
val name: StringResource,
|
||||
val description: StringResource,
|
||||
val importance: NotificationChannelImportance,
|
||||
) {
|
||||
/**
|
||||
* Represents a notification channel for new messages.
|
||||
*
|
||||
* @property accountUuid The unique identifier of the account associated with these messages.
|
||||
* @property suffix An optional suffix to further differentiate the channel, e.g., for different folder types.
|
||||
*/
|
||||
data class Messages(
|
||||
val accountUuid: String,
|
||||
val suffix: String,
|
||||
) : NotificationChannel(
|
||||
id = "messages_channel_$accountUuid$suffix",
|
||||
name = Res.string.notification_channel_messages_title,
|
||||
description = Res.string.notification_channel_messages_description,
|
||||
importance = NotificationChannelImportance.Default,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a notification channel for miscellaneous notifications.
|
||||
*
|
||||
* This channel is used for notifications that don't fit into other specific categories.
|
||||
* The channel ID is "misc" if no account is specified, or "miscellaneous_channel_[accountUuid]" if an
|
||||
* account is provided.
|
||||
*
|
||||
* @property accountUuid The unique identifier of the account associated with these notifications, if applicable.
|
||||
*/
|
||||
data class Miscellaneous(
|
||||
val accountUuid: String? = null,
|
||||
) : NotificationChannel(
|
||||
id = if (accountUuid.isNullOrBlank()) {
|
||||
"misc"
|
||||
} else {
|
||||
"miscellaneous_channel_$accountUuid"
|
||||
},
|
||||
name = Res.string.notification_channel_miscellaneous_title,
|
||||
description = Res.string.notification_channel_miscellaneous_description,
|
||||
importance = NotificationChannelImportance.Low,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a notification channel for push service messages.
|
||||
*
|
||||
* This channel is used for notifications related to the background push service,
|
||||
* such as connection status or errors.
|
||||
*/
|
||||
data object PushService : NotificationChannel(
|
||||
id = "push",
|
||||
name = Res.string.notification_channel_push_title,
|
||||
description = Res.string.notification_channel_push_description,
|
||||
importance = NotificationChannelImportance.Low,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the importance level of a notification channel.
|
||||
*
|
||||
* These levels correspond to the Android notification channel importance constants.
|
||||
* @see NotificationChannelImportance.None
|
||||
* @see NotificationChannelImportance.Min
|
||||
* @see NotificationChannelImportance.Low
|
||||
* @see NotificationChannelImportance.Default
|
||||
* @see NotificationChannelImportance.High
|
||||
*/
|
||||
enum class NotificationChannelImportance {
|
||||
/**
|
||||
* A notification with no importance: does not show in the notification panel.
|
||||
*/
|
||||
None,
|
||||
|
||||
/**
|
||||
* Min notification importance: only shows in the notification panel, below the fold.
|
||||
*
|
||||
* **Android**: This should not be used with `Service.startForeground` since a foreground service is
|
||||
* supposed to be something the user cares about so it does not make semantic sense to mark its notification
|
||||
* as minimum importance.
|
||||
*
|
||||
* If you do this as of Android version `Build.VERSION_CODES.O`, the system will show a higher-priority
|
||||
* notification about your app running in the background.
|
||||
*/
|
||||
Min,
|
||||
|
||||
/**
|
||||
* Low notification importance: Shows in the shade, and potentially in the status bar, but is not
|
||||
* audibly intrusive.
|
||||
*/
|
||||
Low,
|
||||
|
||||
/**
|
||||
* Default notification importance: shows everywhere, makes noise, but does not visually intrude.
|
||||
*/
|
||||
Default,
|
||||
|
||||
/**
|
||||
* Higher notification importance: shows everywhere, makes noise and peeks. May use full screen intents.
|
||||
*/
|
||||
High,
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
package net.thunderbird.feature.notification.api
|
||||
|
||||
// TODO: Properly handle notification groups, adding summary, etc.
|
||||
data class NotificationGroup(
|
||||
val key: NotificationGroupKey,
|
||||
val summary: String,
|
||||
)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package net.thunderbird.feature.notification.api
|
||||
|
||||
/**
|
||||
* Represents a key for a notification group.
|
||||
*
|
||||
* This class is used to uniquely identify a group of notifications that should be displayed together.
|
||||
* For example, all notifications related to a specific account could be grouped together using a
|
||||
* NotificationGroupKey.
|
||||
*
|
||||
* @param value The string value of the notification group key.
|
||||
*/
|
||||
@JvmInline
|
||||
value class NotificationGroupKey(val value: String)
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
package net.thunderbird.feature.notification.api
|
||||
|
||||
/**
|
||||
* Represents a unique identifier for a notification.
|
||||
*
|
||||
* This value class wraps an [Int] to provide type safety for notification IDs.
|
||||
* It also implements [Comparable] by delegating to the underlying [Int] value,
|
||||
* allowing for natural comparison of notification IDs.
|
||||
*
|
||||
* @property value The integer value of the notification ID.
|
||||
*/
|
||||
@JvmInline
|
||||
value class NotificationId(val value: Int) : Comparable<Int> by value
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package net.thunderbird.feature.notification.api
|
||||
|
||||
import net.thunderbird.feature.notification.api.content.Notification
|
||||
|
||||
/**
|
||||
* A registry for managing notifications and their corresponding IDs.
|
||||
*
|
||||
* It establishes and maintains the correlation between a [Notification] object
|
||||
* and its unique [NotificationId].
|
||||
* This can also be used to track which notifications are currently being displayed
|
||||
* to the user.
|
||||
*/
|
||||
interface NotificationRegistry {
|
||||
/**
|
||||
* A [Map] off all the current notifications, associated with their IDs,
|
||||
* being displayed to the user.
|
||||
*/
|
||||
val registrar: Map<NotificationId, Notification>
|
||||
|
||||
/**
|
||||
* Retrieves a [Notification] object based on its [notificationId].
|
||||
*
|
||||
* @param notificationId The ID of the notification to retrieve.
|
||||
* @return The [Notification] object associated with the given [notificationId],
|
||||
* or `null` if no such notification exists.
|
||||
*/
|
||||
operator fun get(notificationId: NotificationId): Notification?
|
||||
|
||||
/**
|
||||
* Retrieves the [NotificationId] associated with the given [notification].
|
||||
*
|
||||
* @param notification The notification for which to retrieve the ID.
|
||||
* @return The [NotificationId] if the notification is registered, or `null` otherwise.
|
||||
*/
|
||||
operator fun get(notification: Notification): NotificationId?
|
||||
|
||||
/**
|
||||
* Registers a notification and returns its unique ID.
|
||||
*
|
||||
* If the provided [notification] is already registered, this function will effectively
|
||||
* return its known [NotificationId].
|
||||
*
|
||||
* @param notification The [Notification] object to register.
|
||||
* @return The unique [NotificationId] assigned to the registered notification.
|
||||
*/
|
||||
suspend fun register(notification: Notification): NotificationId
|
||||
|
||||
/**
|
||||
* Unregisters a [Notification] by its [NotificationId].
|
||||
*
|
||||
* @param notificationId The ID of the notification to unregister.
|
||||
*/
|
||||
fun unregister(notificationId: NotificationId)
|
||||
|
||||
/**
|
||||
* Unregisters a previously registered notification.
|
||||
*
|
||||
* @param notification The [Notification] object to unregister.
|
||||
*/
|
||||
fun unregister(notification: Notification)
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
package net.thunderbird.feature.notification.api
|
||||
|
||||
/**
|
||||
* Represents the severity level of a notification.
|
||||
*
|
||||
* This enum is used to categorize notifications based on their importance and the urgency of the action
|
||||
* required from the user.
|
||||
* When defining an [net.thunderbird.feature.notification.api.content.AppNotification] object, consider whether user
|
||||
* action is necessary.
|
||||
*
|
||||
* For severities like [Fatal] and [Critical], user action is typically required to resolve the issue.
|
||||
* For [Temporary] and [Warning], user action might be recommended or optional.
|
||||
* For [Information], no user action is usually needed.
|
||||
*/
|
||||
enum class NotificationSeverity(val dismissable: Boolean) {
|
||||
/**
|
||||
* Completely blocks the user from performing essential tasks or accessing core functionality.
|
||||
*
|
||||
* **User Action:** Typically requires immediate user intervention to resolve the issue.
|
||||
*
|
||||
* **Example:**
|
||||
* - **Notification Message:** Authentication Error
|
||||
* - **Notification Actions:**
|
||||
* - Retry
|
||||
* - Provide other credentials
|
||||
*/
|
||||
Fatal(dismissable = false),
|
||||
|
||||
/**
|
||||
* Prevents the user from completing specific core actions or causes significant disruption to functionality.
|
||||
*
|
||||
* **User Action:** Usually requires user action to fix or work around the problem.
|
||||
*
|
||||
* **Example:**
|
||||
* - **Notification Message:** Sending of the message "message subject" failed.
|
||||
* - **Notification Actions:**
|
||||
* - Retry
|
||||
*/
|
||||
Critical(dismissable = false),
|
||||
|
||||
/**
|
||||
* Causes a temporary disruption or delay to functionality, which may resolve on its own.
|
||||
*
|
||||
* **User Action:** User action might be optional or might involve waiting for the system to recover.
|
||||
* Informing the user about potential self-resolution is key.
|
||||
*
|
||||
* **Example:**
|
||||
* - **Notification Message:** You are offline, the message will be sent later.
|
||||
* - **Notification Actions:** N/A
|
||||
*/
|
||||
Temporary(dismissable = true),
|
||||
|
||||
/**
|
||||
* Alerts the user to a potential issue or limitation that may affect functionality if not addressed.
|
||||
*
|
||||
* **User Action:** User action is often recommended to prevent future problems or to mitigate current limitations.
|
||||
* The action might be to adjust settings, update information, or simply be aware of a condition.
|
||||
*
|
||||
* **Example:**
|
||||
* - **Notification Message:** Your mailbox is 90% full.
|
||||
* - **Notification Actions:**
|
||||
* - Manage Storage
|
||||
*/
|
||||
Warning(dismissable = true),
|
||||
|
||||
/**
|
||||
* Provides status or context without impacting functionality or requiring action.
|
||||
*
|
||||
* **User Action:** Generally, no action is required from the user. This is purely for informational purposes.
|
||||
*
|
||||
* **Example:**
|
||||
* - **Notification Message:** Last time email synchronization succeeded
|
||||
* - **Notification Actions:** N/A
|
||||
*/
|
||||
Information(dismissable = true),
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package net.thunderbird.feature.notification.api.command
|
||||
|
||||
import net.thunderbird.core.outcome.Outcome
|
||||
import net.thunderbird.feature.notification.api.content.Notification
|
||||
import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
|
||||
|
||||
/**
|
||||
* Represents a command that can be executed on a notification.
|
||||
*
|
||||
* This class is the base for all notification commands. It defines the basic structure
|
||||
* of a command and the possible outcomes of its execution.
|
||||
*
|
||||
* @param TNotification The type of notification this command operates on.
|
||||
* @property notification The notification instance this command will act upon.
|
||||
* @property notifier The notifier responsible for handling the notification.
|
||||
*/
|
||||
abstract class NotificationCommand<TNotification : Notification>(
|
||||
protected val notification: TNotification,
|
||||
protected val notifier: NotificationNotifier<TNotification>,
|
||||
) {
|
||||
/**
|
||||
* Executes the command.
|
||||
* @return The result of the execution.
|
||||
*/
|
||||
abstract suspend fun execute(): Outcome<Success<TNotification>, Failure<TNotification>>
|
||||
|
||||
/**
|
||||
* Represents the outcome of a command's execution.
|
||||
*/
|
||||
sealed interface CommandOutcome
|
||||
|
||||
/**
|
||||
* Represents a successful command execution.
|
||||
*
|
||||
* @param TNotification The type of notification associated with the command.
|
||||
* @property command The command that was executed successfully.
|
||||
*/
|
||||
data class Success<out TNotification : Notification>(
|
||||
val command: NotificationCommand<out TNotification>,
|
||||
) : CommandOutcome
|
||||
|
||||
/**
|
||||
* Represents a failed command execution.
|
||||
*
|
||||
* @param TNotification The type of notification associated with the command.
|
||||
* @property command The command that failed.
|
||||
* @property throwable The exception that caused the failure.
|
||||
*/
|
||||
data class Failure<out TNotification : Notification>(
|
||||
val command: NotificationCommand<out TNotification>,
|
||||
val throwable: Throwable,
|
||||
) : CommandOutcome
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
package net.thunderbird.feature.notification.api.command
|
||||
|
||||
class NotificationCommandException @JvmOverloads constructor(
|
||||
override val message: String?,
|
||||
override val cause: Throwable? = null,
|
||||
) : Exception(message, cause)
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
package net.thunderbird.feature.notification.api.content
|
||||
|
||||
import kotlin.time.Clock
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlinx.datetime.LocalDateTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import net.thunderbird.feature.notification.api.LockscreenNotificationAppearance
|
||||
import net.thunderbird.feature.notification.api.NotificationChannel
|
||||
import net.thunderbird.feature.notification.api.NotificationGroup
|
||||
import net.thunderbird.feature.notification.api.NotificationSeverity
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon
|
||||
import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle
|
||||
import net.thunderbird.feature.notification.api.ui.style.SystemNotificationStyle
|
||||
|
||||
/**
|
||||
* Represents a notification that can be displayed to the user.
|
||||
*
|
||||
* This interface defines the common properties that all notifications must have.
|
||||
* Must not be directly implemented. You must extend [AppNotification] instead.
|
||||
*
|
||||
* @property title The title of the notification.
|
||||
* @property accessibilityText The text to be used for accessibility purposes.
|
||||
* @property contentText The main content text of the notification, can be null.
|
||||
* @property severity The severity level of the notification.
|
||||
* @property createdAt The date and time when the notification was created.
|
||||
* @property actions A set of actions that can be performed on the notification.
|
||||
* @property icon The notification icon.
|
||||
* @see AppNotification
|
||||
*/
|
||||
sealed interface Notification {
|
||||
val title: String
|
||||
val accessibilityText: String
|
||||
val contentText: String?
|
||||
val severity: NotificationSeverity
|
||||
val createdAt: LocalDateTime
|
||||
val actions: Set<NotificationAction>
|
||||
val icon: NotificationIcon
|
||||
}
|
||||
|
||||
/**
|
||||
* The abstract implementation of [Notification], representing an app notification.
|
||||
* This abstraction is meant to provide default properties implementation to easy the app notification creation.
|
||||
*
|
||||
* @property accessibilityText The text that will be read by accessibility services.
|
||||
* Defaults to the notification's title.
|
||||
* @property createdAt The timestamp when the notification was created. Defaults to the current UTC time.
|
||||
* @property actions A set of actions that can be performed on the notification. Defaults to an empty set.
|
||||
* @see Notification
|
||||
*/
|
||||
abstract class AppNotification : Notification {
|
||||
override val accessibilityText: String = title
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
override val createdAt: LocalDateTime = Clock.System.now().toLocalDateTime(timeZone = TimeZone.UTC)
|
||||
override val actions: Set<NotificationAction> = emptySet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a notification displayed by the system, **requiring user permission**.
|
||||
* This type of notification can appear on the lock screen.
|
||||
*
|
||||
* @property subText Additional text displayed below the content text, can be null.
|
||||
* @property channel The notification channel to which this notification belongs.
|
||||
* @property group The notification group to which this notification belongs, can be null.
|
||||
* @property systemNotificationStyle The style of the system notification.
|
||||
* Defaults to [SystemNotificationStyle.Undefined].
|
||||
* @see LockscreenNotificationAppearance
|
||||
* @see SystemNotificationStyle
|
||||
* @see net.thunderbird.feature.notification.api.ui.style.systemNotificationStyle
|
||||
*/
|
||||
interface SystemNotification : Notification {
|
||||
val subText: String? get() = null
|
||||
val channel: NotificationChannel
|
||||
val group: NotificationGroup? get() = null
|
||||
val systemNotificationStyle: SystemNotificationStyle get() = SystemNotificationStyle.Undefined
|
||||
|
||||
/**
|
||||
* Converts this notification to a [LockscreenNotification].
|
||||
*
|
||||
* This function should be overridden by subclasses that can be displayed on the lockscreen.
|
||||
* If the notification should not be displayed on the lockscreen, this function should return `null`.
|
||||
*
|
||||
* @return The [LockscreenNotification] representation of this notification, or `null` if it should not be
|
||||
* displayed on the lockscreen.
|
||||
*/
|
||||
fun asLockscreenNotification(): LockscreenNotification? = null
|
||||
|
||||
/**
|
||||
* Represents a notification that can be displayed on the lock screen.
|
||||
*
|
||||
* @property notification The system notification to be displayed.
|
||||
* @property lockscreenNotificationAppearance The appearance of the notification on the lock screen.
|
||||
* Defaults to [LockscreenNotificationAppearance.Public].
|
||||
*/
|
||||
data class LockscreenNotification(
|
||||
val notification: SystemNotification,
|
||||
val lockscreenNotificationAppearance: LockscreenNotificationAppearance =
|
||||
LockscreenNotificationAppearance.Public,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a notification displayed within the application.
|
||||
*
|
||||
* @property inAppNotificationStyles The styles of the in-app notification.
|
||||
* Defaults to [InAppNotificationStyle.Undefined].
|
||||
* @see InAppNotificationStyle
|
||||
* @see net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyles
|
||||
*/
|
||||
interface InAppNotification : Notification {
|
||||
val inAppNotificationStyles: List<InAppNotificationStyle> get() = InAppNotificationStyle.Undefined
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
package net.thunderbird.feature.notification.api.content
|
||||
|
||||
import net.thunderbird.feature.notification.api.NotificationChannel
|
||||
import net.thunderbird.feature.notification.api.NotificationSeverity
|
||||
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.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
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
/**
|
||||
* Notification to be displayed when an authentication error occurs.
|
||||
*
|
||||
* This notification is both a [SystemNotification] and an [InAppNotification].
|
||||
*/
|
||||
@ConsistentCopyVisibility
|
||||
data class AuthenticationErrorNotification private constructor(
|
||||
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<NotificationAction> = setOf(
|
||||
NotificationAction.Retry,
|
||||
NotificationAction.UpdateServerSettings,
|
||||
)
|
||||
|
||||
override fun asLockscreenNotification(): SystemNotification.LockscreenNotification =
|
||||
SystemNotification.LockscreenNotification(
|
||||
notification = copy(contentText = null),
|
||||
)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates an [AuthenticationErrorNotification].
|
||||
*
|
||||
* @param accountUuid The UUID of the account associated with the authentication error.
|
||||
* @param accountDisplayName The display name of the account associated with the authentication error.
|
||||
* @return An [AuthenticationErrorNotification] instance.
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
accountUuid: String,
|
||||
accountDisplayName: String,
|
||||
): AuthenticationErrorNotification = AuthenticationErrorNotification(
|
||||
title = getString(
|
||||
resource = Res.string.notification_authentication_error_title,
|
||||
accountDisplayName,
|
||||
),
|
||||
contentText = getString(resource = Res.string.notification_authentication_error_text),
|
||||
channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package net.thunderbird.feature.notification.api.content
|
||||
|
||||
import net.thunderbird.feature.notification.api.NotificationChannel
|
||||
import net.thunderbird.feature.notification.api.NotificationSeverity
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
import net.thunderbird.feature.notification.api.ui.icon.CertificateError
|
||||
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon
|
||||
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcons
|
||||
import net.thunderbird.feature.notification.resources.api.Res
|
||||
import net.thunderbird.feature.notification.resources.api.notification_certificate_error_public
|
||||
import net.thunderbird.feature.notification.resources.api.notification_certificate_error_text
|
||||
import net.thunderbird.feature.notification.resources.api.notification_certificate_error_title
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
/**
|
||||
* Notification for certificate errors.
|
||||
*
|
||||
* This notification is shown when there's an issue with a server's certificate,
|
||||
* preventing secure communication. It prompts the user to update their server settings.
|
||||
*/
|
||||
@ConsistentCopyVisibility
|
||||
data class CertificateErrorNotification private constructor(
|
||||
override val title: String,
|
||||
override val contentText: String,
|
||||
val lockScreenTitle: String,
|
||||
override val channel: NotificationChannel,
|
||||
override val icon: NotificationIcon = NotificationIcons.CertificateError,
|
||||
) : AppNotification(), SystemNotification, InAppNotification {
|
||||
override val severity: NotificationSeverity = NotificationSeverity.Fatal
|
||||
override val actions: Set<NotificationAction> = setOf(NotificationAction.UpdateServerSettings)
|
||||
|
||||
override fun asLockscreenNotification(): SystemNotification.LockscreenNotification =
|
||||
SystemNotification.LockscreenNotification(
|
||||
notification = copy(contentText = lockScreenTitle),
|
||||
)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates a [CertificateErrorNotification].
|
||||
*
|
||||
* @param accountUuid The UUID of the account associated with the notification.
|
||||
* @param accountDisplayName The display name of the account associated with the notification.
|
||||
* @return A [CertificateErrorNotification] instance.
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
accountUuid: String,
|
||||
accountDisplayName: String,
|
||||
): CertificateErrorNotification = CertificateErrorNotification(
|
||||
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),
|
||||
channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
package net.thunderbird.feature.notification.api.content
|
||||
|
||||
import net.thunderbird.feature.notification.api.NotificationChannel
|
||||
import net.thunderbird.feature.notification.api.NotificationSeverity
|
||||
import net.thunderbird.feature.notification.api.ui.icon.FailedToCreate
|
||||
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon
|
||||
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcons
|
||||
import net.thunderbird.feature.notification.resources.api.Res
|
||||
import net.thunderbird.feature.notification.resources.api.notification_notify_error_text
|
||||
import net.thunderbird.feature.notification.resources.api.notification_notify_error_title
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
/**
|
||||
* Represents a notification indicating that the creation of another notification has failed.
|
||||
*
|
||||
* This notification is displayed both as a system notification and an in-app notification.
|
||||
* It has a critical severity level.
|
||||
*/
|
||||
@ConsistentCopyVisibility
|
||||
data class FailedToCreateNotification private constructor(
|
||||
override val title: String,
|
||||
override val contentText: String?,
|
||||
override val channel: NotificationChannel,
|
||||
val failedNotification: AppNotification,
|
||||
override val icon: NotificationIcon = NotificationIcons.FailedToCreate,
|
||||
) : AppNotification(), SystemNotification, InAppNotification {
|
||||
override val severity: NotificationSeverity = NotificationSeverity.Critical
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates a [FailedToCreateNotification] instance.
|
||||
*
|
||||
* @param accountUuid The UUID of the account associated with the failed notification.
|
||||
* @param failedNotification The original [AppNotification] that failed to be created.
|
||||
* @return A [FailedToCreateNotification] instance.
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
accountUuid: String,
|
||||
failedNotification: AppNotification,
|
||||
): FailedToCreateNotification = FailedToCreateNotification(
|
||||
title = getString(resource = Res.string.notification_notify_error_title),
|
||||
contentText = getString(resource = Res.string.notification_notify_error_text),
|
||||
channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid),
|
||||
failedNotification = failedNotification,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,274 @@
|
|||
package net.thunderbird.feature.notification.api.content
|
||||
|
||||
import net.thunderbird.core.common.exception.rootCauseMassage
|
||||
import net.thunderbird.feature.notification.api.NotificationChannel
|
||||
import net.thunderbird.feature.notification.api.NotificationGroup
|
||||
import net.thunderbird.feature.notification.api.NotificationSeverity
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
import net.thunderbird.feature.notification.api.ui.icon.MailFetching
|
||||
import net.thunderbird.feature.notification.api.ui.icon.MailSendFailed
|
||||
import net.thunderbird.feature.notification.api.ui.icon.MailSending
|
||||
import net.thunderbird.feature.notification.api.ui.icon.NewMailSingleMail
|
||||
import net.thunderbird.feature.notification.api.ui.icon.NewMailSummaryMail
|
||||
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.SystemNotificationStyle
|
||||
import net.thunderbird.feature.notification.api.ui.style.systemNotificationStyle
|
||||
import net.thunderbird.feature.notification.resources.api.Res
|
||||
import net.thunderbird.feature.notification.resources.api.notification_additional_messages
|
||||
import net.thunderbird.feature.notification.resources.api.notification_bg_send_ticker
|
||||
import net.thunderbird.feature.notification.resources.api.notification_bg_send_title
|
||||
import net.thunderbird.feature.notification.resources.api.notification_bg_sync_text
|
||||
import net.thunderbird.feature.notification.resources.api.notification_bg_sync_ticker
|
||||
import net.thunderbird.feature.notification.resources.api.notification_bg_sync_title
|
||||
import net.thunderbird.feature.notification.resources.api.notification_new_messages_title
|
||||
import net.thunderbird.feature.notification.resources.api.send_failure_subject
|
||||
import org.jetbrains.compose.resources.getPluralString
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
/**
|
||||
* Represents mail-related notifications. By default, all mail-related subclasses are [SystemNotification],
|
||||
* however they may also implement [InAppNotification] for more severe notifications.
|
||||
*/
|
||||
sealed class MailNotification : AppNotification(), SystemNotification {
|
||||
override val severity: NotificationSeverity = NotificationSeverity.Information
|
||||
|
||||
data class Fetching(
|
||||
override val title: String,
|
||||
override val accessibilityText: String,
|
||||
override val contentText: String?,
|
||||
override val channel: NotificationChannel,
|
||||
override val icon: NotificationIcon = NotificationIcons.MailFetching,
|
||||
) : MailNotification() {
|
||||
override fun asLockscreenNotification(): SystemNotification.LockscreenNotification =
|
||||
SystemNotification.LockscreenNotification(
|
||||
notification = copy(contentText = null),
|
||||
)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates a [Fetching] notification.
|
||||
*
|
||||
* @param accountUuid The UUID of the account being fetched.
|
||||
* @param accountDisplayName The display name of the account being fetched.
|
||||
* @param folderName The name of the folder being fetched, or null if fetching all folders.
|
||||
* @return A [Fetching] notification.
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
accountUuid: String,
|
||||
accountDisplayName: String,
|
||||
folderName: String?,
|
||||
): Fetching {
|
||||
val title = getString(resource = Res.string.notification_bg_sync_title)
|
||||
return Fetching(
|
||||
title = title,
|
||||
accessibilityText = folderName?.let { folderName ->
|
||||
getString(
|
||||
resource = Res.string.notification_bg_sync_ticker,
|
||||
accountDisplayName,
|
||||
folderName,
|
||||
)
|
||||
} ?: title,
|
||||
contentText = folderName?.let { folderName ->
|
||||
getString(
|
||||
resource = Res.string.notification_bg_sync_text,
|
||||
accountDisplayName,
|
||||
folderName,
|
||||
)
|
||||
} ?: accountDisplayName,
|
||||
channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class Sending(
|
||||
override val title: String,
|
||||
override val accessibilityText: String,
|
||||
override val contentText: String?,
|
||||
override val channel: NotificationChannel,
|
||||
override val icon: NotificationIcon = NotificationIcons.MailSending,
|
||||
) : MailNotification() {
|
||||
override fun asLockscreenNotification(): SystemNotification.LockscreenNotification =
|
||||
SystemNotification.LockscreenNotification(
|
||||
notification = copy(contentText = null),
|
||||
)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates a [Sending] notification.
|
||||
*
|
||||
* @param accountUuid The UUID of the account sending the message.
|
||||
* @param accountDisplayName The display name of the account sending the message.
|
||||
* @return A [Sending] notification.
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
accountUuid: String,
|
||||
accountDisplayName: String,
|
||||
): Sending = Sending(
|
||||
title = getString(resource = Res.string.notification_bg_send_title),
|
||||
accessibilityText = getString(
|
||||
resource = Res.string.notification_bg_send_ticker,
|
||||
accountDisplayName,
|
||||
),
|
||||
contentText = accountDisplayName,
|
||||
channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SendFailed(
|
||||
override val title: String,
|
||||
override val contentText: String?,
|
||||
override val channel: NotificationChannel,
|
||||
override val icon: NotificationIcon = NotificationIcons.MailSendFailed,
|
||||
) : MailNotification(), InAppNotification {
|
||||
override val severity: NotificationSeverity = NotificationSeverity.Critical
|
||||
override fun asLockscreenNotification(): SystemNotification.LockscreenNotification =
|
||||
SystemNotification.LockscreenNotification(
|
||||
notification = copy(contentText = null),
|
||||
)
|
||||
|
||||
override val actions: Set<NotificationAction> = setOf(NotificationAction.Retry)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates a [SendFailed] notification.
|
||||
*
|
||||
* @param accountUuid The UUID of the account sending the message.
|
||||
* @param exception The exception that occurred during sending.
|
||||
* @return A [SendFailed] notification.
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
accountUuid: String,
|
||||
exception: Exception,
|
||||
): SendFailed = SendFailed(
|
||||
title = getString(resource = Res.string.send_failure_subject),
|
||||
contentText = exception.rootCauseMassage,
|
||||
channel = NotificationChannel.Miscellaneous(accountUuid = accountUuid),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a notification for a single new email.
|
||||
*
|
||||
* @property accountUuid The UUID of the account that received the email.
|
||||
* @property accountName The display name of the account that received the email.
|
||||
* @property messagesNotificationChannelSuffix The suffix for the messages notification channel.
|
||||
* @property summary A short summary of the email content.
|
||||
* @property sender The sender of the email.
|
||||
* @property subject The subject of the email.
|
||||
* @property preview A preview of the email content.
|
||||
* @property group The notification group this notification belongs to, if any.
|
||||
*/
|
||||
data class NewMailSingleMail(
|
||||
val accountUuid: String,
|
||||
val accountName: String,
|
||||
val messagesNotificationChannelSuffix: String,
|
||||
val summary: String,
|
||||
val sender: String,
|
||||
val subject: String,
|
||||
val preview: String,
|
||||
override val group: NotificationGroup?,
|
||||
override val icon: NotificationIcon = NotificationIcons.NewMailSingleMail,
|
||||
) : MailNotification() {
|
||||
override val title: String = sender
|
||||
override val contentText: String = subject
|
||||
|
||||
override val channel: NotificationChannel = NotificationChannel.Messages(
|
||||
accountUuid = accountUuid,
|
||||
suffix = messagesNotificationChannelSuffix,
|
||||
)
|
||||
|
||||
override fun asLockscreenNotification(): SystemNotification.LockscreenNotification =
|
||||
SystemNotification.LockscreenNotification(notification = copy())
|
||||
|
||||
override val actions: Set<NotificationAction> = setOf(
|
||||
NotificationAction.Reply,
|
||||
NotificationAction.MarkAsRead,
|
||||
NotificationAction.Delete,
|
||||
NotificationAction.Archive,
|
||||
NotificationAction.MarkAsSpam,
|
||||
)
|
||||
override val systemNotificationStyle: SystemNotificationStyle = systemNotificationStyle {
|
||||
bigText(preview)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a summary notification for new mail.
|
||||
*
|
||||
* @property accountUuid The UUID of the account.
|
||||
* @property accountName The display name of the account.
|
||||
* @property messagesNotificationChannelSuffix The suffix for the messages notification channel.
|
||||
* @property title The title of the notification.
|
||||
* @property contentText The content text of the notification, or null if there is no content text.
|
||||
* @property group The notification group this summary belongs to.
|
||||
*/
|
||||
@ConsistentCopyVisibility
|
||||
data class NewMailSummaryMail private constructor(
|
||||
val accountUuid: String,
|
||||
val accountName: String,
|
||||
val messagesNotificationChannelSuffix: String,
|
||||
override val title: String,
|
||||
override val contentText: String?,
|
||||
override val group: NotificationGroup,
|
||||
override val icon: NotificationIcon = NotificationIcons.NewMailSummaryMail,
|
||||
) : MailNotification() {
|
||||
override val channel: NotificationChannel = NotificationChannel.Messages(
|
||||
accountUuid = accountUuid,
|
||||
suffix = messagesNotificationChannelSuffix,
|
||||
)
|
||||
|
||||
override val actions: Set<NotificationAction> = setOf(
|
||||
NotificationAction.Reply,
|
||||
NotificationAction.MarkAsRead,
|
||||
NotificationAction.Delete,
|
||||
NotificationAction.Archive,
|
||||
NotificationAction.MarkAsSpam,
|
||||
)
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates a [NewMailSummaryMail] notification.
|
||||
*
|
||||
* @param accountUuid The UUID of the account.
|
||||
* @param accountDisplayName The display name of the account.
|
||||
* @param messagesNotificationChannelSuffix The suffix for the messages notification channel.
|
||||
* @param newMessageCount The number of new messages.
|
||||
* @param additionalMessagesCount The number of additional messages (not shown in individual
|
||||
* notifications).
|
||||
* @param group The notification group this summary belongs to.
|
||||
* @return A [NewMailSummaryMail] notification.
|
||||
*/
|
||||
suspend operator fun invoke(
|
||||
accountUuid: String,
|
||||
accountDisplayName: String,
|
||||
messagesNotificationChannelSuffix: String,
|
||||
newMessageCount: Int,
|
||||
additionalMessagesCount: Int,
|
||||
group: NotificationGroup,
|
||||
): NewMailSummaryMail = NewMailSummaryMail(
|
||||
accountUuid = accountUuid,
|
||||
accountName = accountDisplayName,
|
||||
messagesNotificationChannelSuffix = messagesNotificationChannelSuffix,
|
||||
title = getPluralString(
|
||||
Res.plurals.notification_new_messages_title,
|
||||
newMessageCount,
|
||||
newMessageCount,
|
||||
),
|
||||
contentText = if (additionalMessagesCount > 0) {
|
||||
getString(
|
||||
Res.string.notification_additional_messages,
|
||||
additionalMessagesCount,
|
||||
accountDisplayName,
|
||||
)
|
||||
} else {
|
||||
accountDisplayName
|
||||
},
|
||||
group = group,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
package net.thunderbird.feature.notification.api.content
|
||||
|
||||
import net.thunderbird.feature.notification.api.NotificationChannel
|
||||
import net.thunderbird.feature.notification.api.NotificationSeverity
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
import net.thunderbird.feature.notification.api.ui.action.icon.DisablePushAction
|
||||
import net.thunderbird.feature.notification.api.ui.action.icon.NotificationActionIcons
|
||||
import net.thunderbird.feature.notification.api.ui.icon.AlarmPermissionMissing
|
||||
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.icon.PushServiceInitializing
|
||||
import net.thunderbird.feature.notification.api.ui.icon.PushServiceListening
|
||||
import net.thunderbird.feature.notification.api.ui.icon.PushServiceWaitBackgroundSync
|
||||
import net.thunderbird.feature.notification.api.ui.icon.PushServiceWaitNetwork
|
||||
import net.thunderbird.feature.notification.resources.api.Res
|
||||
import net.thunderbird.feature.notification.resources.api.push_info_disable_push_action
|
||||
import net.thunderbird.feature.notification.resources.api.push_notification_grant_alarm_permission
|
||||
import net.thunderbird.feature.notification.resources.api.push_notification_info
|
||||
import net.thunderbird.feature.notification.resources.api.push_notification_state_alarm_permission_missing
|
||||
import net.thunderbird.feature.notification.resources.api.push_notification_state_initializing
|
||||
import net.thunderbird.feature.notification.resources.api.push_notification_state_listening
|
||||
import net.thunderbird.feature.notification.resources.api.push_notification_state_wait_background_sync
|
||||
import net.thunderbird.feature.notification.resources.api.push_notification_state_wait_network
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
/**
|
||||
* Represents notifications related to the Push Notification Service.
|
||||
* Mostly used on Android.
|
||||
*/
|
||||
sealed class PushServiceNotification : AppNotification(), SystemNotification {
|
||||
override val severity: NotificationSeverity = NotificationSeverity.Information
|
||||
override val channel: NotificationChannel = NotificationChannel.PushService
|
||||
|
||||
/**
|
||||
* This notification is shown when the Push Notification Foreground Service is initializing.
|
||||
* @property severity The severity level is set to [NotificationSeverity.Information].
|
||||
*/
|
||||
@ConsistentCopyVisibility
|
||||
data class Initializing private constructor(
|
||||
override val title: String,
|
||||
override val contentText: String?,
|
||||
override val actions: Set<NotificationAction>,
|
||||
override val icon: NotificationIcon = NotificationIcons.PushServiceInitializing,
|
||||
) : PushServiceNotification() {
|
||||
companion object {
|
||||
/**
|
||||
* Creates an [Initializing] notification.
|
||||
*
|
||||
* @return An [Initializing] notification.
|
||||
*/
|
||||
suspend operator fun invoke(): Initializing = Initializing(
|
||||
title = getString(resource = Res.string.push_notification_state_initializing),
|
||||
contentText = getString(resource = Res.string.push_notification_info),
|
||||
actions = buildNotificationActions(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This notification is displayed when the push service is actively listening for new messages.
|
||||
* @property severity The severity level is set to [NotificationSeverity.Information].
|
||||
*/
|
||||
@ConsistentCopyVisibility
|
||||
data class Listening private constructor(
|
||||
override val title: String,
|
||||
override val contentText: String?,
|
||||
override val actions: Set<NotificationAction>,
|
||||
override val icon: NotificationIcon = NotificationIcons.PushServiceListening,
|
||||
) : PushServiceNotification() {
|
||||
companion object {
|
||||
/**
|
||||
* Creates a new [Listening] push service notification.
|
||||
*
|
||||
* @return A new [Listening] notification.
|
||||
*/
|
||||
suspend operator fun invoke(): Listening = Listening(
|
||||
title = getString(resource = Res.string.push_notification_state_listening),
|
||||
contentText = getString(resource = Res.string.push_notification_info),
|
||||
actions = buildNotificationActions(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This notification is displayed when the app is waiting for background synchronization to complete.
|
||||
* @property severity The severity level is set to [NotificationSeverity.Information].
|
||||
*/
|
||||
@ConsistentCopyVisibility
|
||||
data class WaitBackgroundSync private constructor(
|
||||
override val title: String,
|
||||
override val contentText: String?,
|
||||
override val actions: Set<NotificationAction>,
|
||||
override val icon: NotificationIcon = NotificationIcons.PushServiceWaitBackgroundSync,
|
||||
) : PushServiceNotification() {
|
||||
companion object {
|
||||
/**
|
||||
* Creates a [WaitBackgroundSync] notification.
|
||||
*
|
||||
* @return A [WaitBackgroundSync] notification.
|
||||
*/
|
||||
suspend operator fun invoke(): WaitBackgroundSync = WaitBackgroundSync(
|
||||
title = getString(resource = Res.string.push_notification_state_wait_background_sync),
|
||||
contentText = getString(resource = Res.string.push_notification_info),
|
||||
actions = buildNotificationActions(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This notification is shown when the push service is waiting for a network connection.
|
||||
* @property severity The severity level is set to [NotificationSeverity.Information].
|
||||
*/
|
||||
@ConsistentCopyVisibility
|
||||
data class WaitNetwork private constructor(
|
||||
override val title: String,
|
||||
override val contentText: String?,
|
||||
override val actions: Set<NotificationAction>,
|
||||
override val icon: NotificationIcon = NotificationIcons.PushServiceWaitNetwork,
|
||||
) : PushServiceNotification() {
|
||||
companion object {
|
||||
/**
|
||||
* Creates a [WaitNetwork] notification.
|
||||
*
|
||||
* @return A [WaitNetwork] notification.
|
||||
*/
|
||||
suspend operator fun invoke(): WaitNetwork = WaitNetwork(
|
||||
title = getString(resource = Res.string.push_notification_state_wait_network),
|
||||
contentText = getString(resource = Res.string.push_notification_info),
|
||||
actions = buildNotificationActions(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a notification indicating that the alarm permission is missing.
|
||||
*
|
||||
* This notification is displayed when the app is missing the permission to schedule exact alarms,
|
||||
* which is necessary for the push service to function correctly.
|
||||
*
|
||||
* @property severity The severity level is set to [NotificationSeverity.Critical].
|
||||
*/
|
||||
@ConsistentCopyVisibility
|
||||
data class AlarmPermissionMissing private constructor(
|
||||
override val title: String,
|
||||
override val contentText: String?,
|
||||
override val icon: NotificationIcon = NotificationIcons.AlarmPermissionMissing,
|
||||
) : PushServiceNotification(), InAppNotification {
|
||||
override val severity: NotificationSeverity = NotificationSeverity.Critical
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* Creates an [AlarmPermissionMissing] notification.
|
||||
*
|
||||
* @return An [AlarmPermissionMissing] instance.
|
||||
*/
|
||||
suspend operator fun invoke(): AlarmPermissionMissing = AlarmPermissionMissing(
|
||||
title = getString(resource = Res.string.push_notification_state_alarm_permission_missing),
|
||||
contentText = getString(resource = Res.string.push_notification_grant_alarm_permission),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a set of [NotificationAction] instances for the push service notification.
|
||||
*
|
||||
* This function is used to create the default actions that are displayed in the
|
||||
* push service notification.
|
||||
*
|
||||
* @return A set of [NotificationAction] instances.
|
||||
*/
|
||||
private suspend fun buildNotificationActions(): Set<NotificationAction> = setOf(
|
||||
NotificationAction.Tap,
|
||||
NotificationAction.CustomAction(
|
||||
title = getString(resource = Res.string.push_info_disable_push_action),
|
||||
icon = NotificationActionIcons.DisablePushAction,
|
||||
),
|
||||
)
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package net.thunderbird.feature.notification.api.receiver
|
||||
|
||||
import kotlinx.coroutines.flow.SharedFlow
|
||||
import net.thunderbird.feature.notification.api.content.InAppNotification
|
||||
|
||||
/**
|
||||
* Interface for receiving in-app notification events.
|
||||
*
|
||||
* This interface provides a [SharedFlow] of [InAppNotificationEvent]s that can be observed
|
||||
* by UI components or other parts of the application to react to in-app notifications.
|
||||
*/
|
||||
interface InAppNotificationReceiver {
|
||||
val events: SharedFlow<InAppNotificationEvent>
|
||||
}
|
||||
|
||||
sealed interface InAppNotificationEvent {
|
||||
data class Show(val notification: InAppNotification) : InAppNotificationEvent
|
||||
data class Dismiss(val notification: InAppNotification) : InAppNotificationEvent
|
||||
}
|
||||
|
|
@ -0,0 +1,28 @@
|
|||
package net.thunderbird.feature.notification.api.receiver
|
||||
|
||||
import net.thunderbird.feature.notification.api.NotificationId
|
||||
import net.thunderbird.feature.notification.api.content.Notification
|
||||
|
||||
/**
|
||||
* Interface for displaying notifications.
|
||||
*
|
||||
* This is a sealed interface, meaning that all implementations must be declared in this file.
|
||||
*
|
||||
* @param TNotification The type of notification to display.
|
||||
*/
|
||||
interface NotificationNotifier<in TNotification : Notification> {
|
||||
/**
|
||||
* Shows a notification to the user.
|
||||
*
|
||||
* @param id The notification id. Mostly used by System Notifications.
|
||||
* @param notification The notification to show.
|
||||
*/
|
||||
suspend fun show(id: NotificationId, notification: TNotification)
|
||||
|
||||
/**
|
||||
* Disposes of any resources used by the notifier.
|
||||
*
|
||||
* This should be called when the notifier is no longer needed to prevent memory leaks.
|
||||
*/
|
||||
fun dispose()
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
package net.thunderbird.feature.notification.api.receiver.compat
|
||||
|
||||
import androidx.annotation.Discouraged
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.DisposableHandle
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import net.thunderbird.feature.notification.api.receiver.InAppNotificationEvent
|
||||
import net.thunderbird.feature.notification.api.receiver.InAppNotificationReceiver
|
||||
|
||||
/**
|
||||
* A compatibility class for [InAppNotificationReceiver] that allows Java classes to observe notification events.
|
||||
*
|
||||
* This class is discouraged for use in Kotlin code. Use [InAppNotificationReceiver] directly instead.
|
||||
*
|
||||
* @param notificationReceiver The [InAppNotificationReceiver] instance to delegate to.
|
||||
* @param listener A callback function that will be invoked when a new [InAppNotificationEvent] is received.
|
||||
* @param mainImmediateDispatcher The [CoroutineDispatcher] to use for observing events.
|
||||
*/
|
||||
@Discouraged("Only for usage within a Java class. Use InAppNotificationReceiver instead.")
|
||||
class InAppNotificationReceiverCompat(
|
||||
private val notificationReceiver: InAppNotificationReceiver,
|
||||
listener: OnReceiveEventListener,
|
||||
mainImmediateDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
|
||||
) : InAppNotificationReceiver by notificationReceiver, DisposableHandle {
|
||||
private val scope = CoroutineScope(SupervisorJob() + mainImmediateDispatcher)
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
events.collect { event ->
|
||||
listener.onReceiveEvent(event)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
fun interface OnReceiveEventListener {
|
||||
fun onReceiveEvent(event: InAppNotificationEvent)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
package net.thunderbird.feature.notification.api.sender
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import net.thunderbird.core.outcome.Outcome
|
||||
import net.thunderbird.feature.notification.api.command.NotificationCommand
|
||||
import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure
|
||||
import net.thunderbird.feature.notification.api.command.NotificationCommand.Success
|
||||
import net.thunderbird.feature.notification.api.content.Notification
|
||||
|
||||
/**
|
||||
* Responsible for sending notifications by creating and executing the appropriate commands.
|
||||
*/
|
||||
interface NotificationSender {
|
||||
/**
|
||||
* Sends a notification by creating and executing the appropriate commands.
|
||||
*
|
||||
* @param notification The [Notification] to be sent.
|
||||
* @return A [Flow] that emits the [NotificationCommand.CommandOutcome] for each executed command.
|
||||
*/
|
||||
fun send(notification: Notification): Flow<Outcome<Success<Notification>, Failure<Notification>>>
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package net.thunderbird.feature.notification.api.sender.compat
|
||||
|
||||
import androidx.annotation.Discouraged
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.DisposableHandle
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import net.thunderbird.core.outcome.Outcome
|
||||
import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure
|
||||
import net.thunderbird.feature.notification.api.command.NotificationCommand.Success
|
||||
import net.thunderbird.feature.notification.api.content.Notification
|
||||
import net.thunderbird.feature.notification.api.sender.NotificationSender
|
||||
|
||||
/**
|
||||
* A compatibility layer for sending notifications from Java code.
|
||||
*
|
||||
* This class wraps [NotificationSender] and provides a Java-friendly API for sending notifications
|
||||
* and receiving results via a callback interface.
|
||||
*
|
||||
* It is marked as [Discouraged] because it is intended only for use within Java classes.
|
||||
* Kotlin code should use [NotificationSender] directly.
|
||||
*
|
||||
* @property notificationSender The underlying [NotificationSender] instance.
|
||||
* @property mainImmediateDispatcher The [CoroutineDispatcher] used for launching coroutines.
|
||||
*/
|
||||
@Discouraged("Only for usage within a Java class. Use NotificationSender instead.")
|
||||
class NotificationSenderCompat @JvmOverloads constructor(
|
||||
private val notificationSender: NotificationSender,
|
||||
mainImmediateDispatcher: CoroutineDispatcher = Dispatchers.Main.immediate,
|
||||
) : DisposableHandle {
|
||||
private val scope = CoroutineScope(SupervisorJob() + mainImmediateDispatcher)
|
||||
|
||||
fun send(notification: Notification, onResultListener: OnResultListener) {
|
||||
notificationSender.send(notification)
|
||||
.onEach { outcome -> onResultListener.onResult(outcome) }
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
scope.cancel()
|
||||
}
|
||||
|
||||
fun interface OnResultListener {
|
||||
fun onResult(outcome: Outcome<Success<Notification>, Failure<Notification>>)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
package net.thunderbird.feature.notification.api.ui.action
|
||||
|
||||
import net.thunderbird.feature.notification.api.content.SystemNotification
|
||||
import net.thunderbird.feature.notification.api.ui.action.icon.Archive
|
||||
import net.thunderbird.feature.notification.api.ui.action.icon.Delete
|
||||
import net.thunderbird.feature.notification.api.ui.action.icon.MarkAsRead
|
||||
import net.thunderbird.feature.notification.api.ui.action.icon.MarkAsSpam
|
||||
import net.thunderbird.feature.notification.api.ui.action.icon.NotificationActionIcons
|
||||
import net.thunderbird.feature.notification.api.ui.action.icon.Reply
|
||||
import net.thunderbird.feature.notification.api.ui.action.icon.Retry
|
||||
import net.thunderbird.feature.notification.api.ui.action.icon.UpdateServerSettings
|
||||
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon
|
||||
import net.thunderbird.feature.notification.resources.api.Res
|
||||
import net.thunderbird.feature.notification.resources.api.notification_action_archive
|
||||
import net.thunderbird.feature.notification.resources.api.notification_action_delete
|
||||
import net.thunderbird.feature.notification.resources.api.notification_action_mark_as_read
|
||||
import net.thunderbird.feature.notification.resources.api.notification_action_reply
|
||||
import net.thunderbird.feature.notification.resources.api.notification_action_retry
|
||||
import net.thunderbird.feature.notification.resources.api.notification_action_spam
|
||||
import net.thunderbird.feature.notification.resources.api.notification_action_update_server_settings
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import org.jetbrains.compose.resources.getString
|
||||
|
||||
/**
|
||||
* Represents the various actions that can be performed on a notification.
|
||||
*/
|
||||
sealed class NotificationAction {
|
||||
abstract val icon: NotificationIcon?
|
||||
protected abstract val titleResource: StringResource?
|
||||
|
||||
open suspend fun resolveTitle(): String? = titleResource?.let { getString(it) }
|
||||
|
||||
/**
|
||||
* Action to open the notification. This is the default action when a notification is tapped.
|
||||
*
|
||||
* This action typically does not have an icon or title displayed on the notification itself,
|
||||
* as it's implied by tapping the notification content.
|
||||
*
|
||||
* All [SystemNotification] will have this action implicitly, even if not specified in the
|
||||
* [SystemNotification.actions] set.
|
||||
*/
|
||||
data object Tap : NotificationAction() {
|
||||
override val icon: NotificationIcon? = null
|
||||
override val titleResource: StringResource? = null
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to reply to the email message associated with the notification.
|
||||
*/
|
||||
data object Reply : NotificationAction() {
|
||||
override val icon: NotificationIcon = NotificationActionIcons.Reply
|
||||
|
||||
override val titleResource: StringResource = Res.string.notification_action_reply
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to mark the email message associated with the notification as read.
|
||||
*/
|
||||
data object MarkAsRead : NotificationAction() {
|
||||
override val icon: NotificationIcon = NotificationActionIcons.MarkAsRead
|
||||
|
||||
override val titleResource: StringResource = Res.string.notification_action_mark_as_read
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to delete the email message associated with the notification.
|
||||
*/
|
||||
data object Delete : NotificationAction() {
|
||||
override val icon: NotificationIcon = NotificationActionIcons.Delete
|
||||
|
||||
override val titleResource: StringResource = Res.string.notification_action_delete
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to mark the email message associated with the notification as spam.
|
||||
*/
|
||||
data object MarkAsSpam : NotificationAction() {
|
||||
override val icon: NotificationIcon = NotificationActionIcons.MarkAsSpam
|
||||
|
||||
override val titleResource: StringResource = Res.string.notification_action_spam
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to archive the email message associated with the notification.
|
||||
*/
|
||||
data object Archive : NotificationAction() {
|
||||
override val icon: NotificationIcon = NotificationActionIcons.Archive
|
||||
|
||||
override val titleResource: StringResource = Res.string.notification_action_archive
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to prompt the user to update server settings, typically when authentication fails.
|
||||
*/
|
||||
data object UpdateServerSettings : NotificationAction() {
|
||||
override val icon: NotificationIcon = NotificationActionIcons.UpdateServerSettings
|
||||
|
||||
override val titleResource: StringResource = Res.string.notification_action_update_server_settings
|
||||
}
|
||||
|
||||
/**
|
||||
* Action to retry a failed operation, such as sending a message or fetching new messages.
|
||||
*/
|
||||
data object Retry : NotificationAction() {
|
||||
override val icon: NotificationIcon = NotificationActionIcons.Retry
|
||||
|
||||
override val titleResource: StringResource = Res.string.notification_action_retry
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents a custom notification action.
|
||||
*
|
||||
* This can be used for actions that are not predefined and require a specific message.
|
||||
*
|
||||
* @property title The text to be displayed for this custom action.
|
||||
*/
|
||||
data class CustomAction(
|
||||
val title: String,
|
||||
override val icon: NotificationIcon? = null,
|
||||
) : NotificationAction() {
|
||||
override val titleResource: StringResource get() = error("Custom Action must not supply a title resource")
|
||||
|
||||
override suspend fun resolveTitle(): String = title
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package net.thunderbird.feature.notification.api.ui.action.icon
|
||||
|
||||
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon
|
||||
|
||||
internal object NotificationActionIcons
|
||||
|
||||
internal expect val NotificationActionIcons.Reply: NotificationIcon
|
||||
internal expect val NotificationActionIcons.MarkAsRead: NotificationIcon
|
||||
internal expect val NotificationActionIcons.Delete: NotificationIcon
|
||||
internal expect val NotificationActionIcons.MarkAsSpam: NotificationIcon
|
||||
internal expect val NotificationActionIcons.Archive: NotificationIcon
|
||||
internal expect val NotificationActionIcons.UpdateServerSettings: NotificationIcon
|
||||
internal expect val NotificationActionIcons.Retry: NotificationIcon
|
||||
internal expect val NotificationActionIcons.DisablePushAction: NotificationIcon
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package net.thunderbird.feature.notification.api.ui.host
|
||||
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
|
||||
enum class DisplayInAppNotificationFlag {
|
||||
BannerGlobalNotifications,
|
||||
BannerInlineNotifications,
|
||||
SnackbarNotifications,
|
||||
;
|
||||
|
||||
companion object {
|
||||
val AllNotifications = DisplayInAppNotificationFlag.entries.toPersistentSet()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,141 @@
|
|||
package net.thunderbird.feature.notification.api.ui.host
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.runtime.remember
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.collections.immutable.toPersistentSet
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import net.thunderbird.feature.notification.api.content.InAppNotification
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.BannerGlobalVisual
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.InAppNotificationHostState
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.InAppNotificationVisual
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.SnackbarVisual
|
||||
|
||||
@Stable
|
||||
class InAppNotificationHostStateHolder(private val enabled: ImmutableSet<DisplayInAppNotificationFlag>) {
|
||||
private val internalState =
|
||||
MutableStateFlow<InAppNotificationHostStateImpl>(value = InAppNotificationHostStateImpl())
|
||||
internal val currentInAppNotificationHostState: StateFlow<InAppNotificationHostState> = internalState.asStateFlow()
|
||||
|
||||
fun showInAppNotification(
|
||||
notification: InAppNotification,
|
||||
) {
|
||||
val newData = notification.toInAppNotificationData()
|
||||
// TODO(#9572): If global is already present, show the one with the highest priority
|
||||
// show the previous one back once the higher priority has fixed and the
|
||||
// other wasn't
|
||||
internalState.update {
|
||||
newData.bannerGlobalVisual.showIfNeeded(
|
||||
ifFlagEnabled = DisplayInAppNotificationFlag.BannerGlobalNotifications,
|
||||
select = { bannerGlobalVisual },
|
||||
transformIfDifferent = { copy(bannerGlobalVisual = it) },
|
||||
)
|
||||
}
|
||||
internalState.update {
|
||||
newData.bannerInlineVisuals.showIfNeeded()
|
||||
}
|
||||
internalState.update {
|
||||
newData.snackbarVisual.showIfNeeded(
|
||||
ifFlagEnabled = DisplayInAppNotificationFlag.SnackbarNotifications,
|
||||
select = { snackbarVisual },
|
||||
transformIfDifferent = { copy(snackbarVisual = it) },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun ImmutableSet<BannerInlineVisual>.showIfNeeded(): InAppNotificationHostStateImpl {
|
||||
if (!isEnabled(flag = DisplayInAppNotificationFlag.BannerInlineNotifications)) {
|
||||
return internalState.value
|
||||
}
|
||||
val new = this
|
||||
val current = internalState.value
|
||||
|
||||
return if (!current.bannerInlineVisuals.containsAll(new)) {
|
||||
current.copy(
|
||||
bannerInlineVisuals = (current.bannerInlineVisuals + new).toPersistentSet(),
|
||||
)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <TVisual : InAppNotificationVisual> TVisual?.showIfNeeded(
|
||||
ifFlagEnabled: DisplayInAppNotificationFlag,
|
||||
select: InAppNotificationHostStateImpl.() -> TVisual?,
|
||||
transformIfDifferent: InAppNotificationHostStateImpl.(TVisual) -> InAppNotificationHostStateImpl,
|
||||
): InAppNotificationHostStateImpl {
|
||||
if (!isEnabled(ifFlagEnabled)) {
|
||||
return internalState.value
|
||||
}
|
||||
val new = this
|
||||
val current = internalState.value
|
||||
return if (new != null && new != current.select()) {
|
||||
current.transformIfDifferent(new)
|
||||
} else {
|
||||
current
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dismisses the given in-app notification visual.
|
||||
*
|
||||
* This function is responsible for removing the specified notification visual
|
||||
* from the display.
|
||||
*
|
||||
* @param visual The [InAppNotificationVisual] to dismiss.
|
||||
*/
|
||||
fun dismiss(visual: InAppNotificationVisual) {
|
||||
internalState.update { current ->
|
||||
current.copy(
|
||||
bannerGlobalVisual = visual.nullIfDifferent(otherwise = current.bannerGlobalVisual),
|
||||
bannerInlineVisuals = (visual as? BannerInlineVisual)?.let { bannerInlineVisual ->
|
||||
(current.bannerInlineVisuals - bannerInlineVisual).toPersistentSet()
|
||||
} ?: current.bannerInlineVisuals,
|
||||
snackbarVisual = visual.nullIfDifferent(otherwise = current.snackbarVisual),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <reified T : InAppNotificationVisual> InAppNotificationVisual?.nullIfDifferent(
|
||||
otherwise: T?,
|
||||
): T? {
|
||||
val current = (this as? T?) ?: otherwise
|
||||
return if (this == current) null else otherwise
|
||||
}
|
||||
|
||||
private fun isEnabled(flag: DisplayInAppNotificationFlag): Boolean {
|
||||
return enabled.any { it == flag } || enabled == DisplayInAppNotificationFlag.AllNotifications
|
||||
}
|
||||
|
||||
private data class InAppNotificationHostStateImpl(
|
||||
override val bannerGlobalVisual: BannerGlobalVisual? = null,
|
||||
override val bannerInlineVisuals: ImmutableSet<BannerInlineVisual> = persistentSetOf(),
|
||||
override val snackbarVisual: SnackbarVisual? = null,
|
||||
private val onDismissVisual: (InAppNotificationVisual) -> Unit = {},
|
||||
) : InAppNotificationHostState
|
||||
|
||||
private fun InAppNotification.toInAppNotificationData(): InAppNotificationHostState =
|
||||
InAppNotificationHostStateImpl(
|
||||
bannerGlobalVisual = BannerGlobalVisual.from(notification = this),
|
||||
bannerInlineVisuals = BannerInlineVisual.from(notification = this).toPersistentSet(),
|
||||
snackbarVisual = SnackbarVisual.from(notification = this),
|
||||
)
|
||||
|
||||
override fun toString(): String {
|
||||
return "InAppNotificationHostState(" +
|
||||
"enabled=$enabled, currentInAppNotificationData=${currentInAppNotificationHostState.value})"
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberInAppNotificationHostState(
|
||||
enabled: ImmutableSet<DisplayInAppNotificationFlag> = DisplayInAppNotificationFlag.AllNotifications,
|
||||
): InAppNotificationHostStateHolder {
|
||||
return remember { InAppNotificationHostStateHolder(enabled) }
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
package net.thunderbird.feature.notification.api.ui.host.visual
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
/**
|
||||
* Defines the visual representation of in-app notifications.
|
||||
*
|
||||
* This interface holds the visual data for different types of in-app notifications
|
||||
* that can be displayed to the user. It allows for a structured way to manage
|
||||
* and present notification information.
|
||||
*/
|
||||
@Stable
|
||||
internal interface InAppNotificationHostState {
|
||||
/**
|
||||
* The visual representation of a global banner notification.
|
||||
*
|
||||
* This property holds a [BannerGlobalVisual] object if a global banner is
|
||||
* currently active, or `null` if no global banner is being shown.
|
||||
*/
|
||||
val bannerGlobalVisual: BannerGlobalVisual?
|
||||
|
||||
/**
|
||||
* A set of inline banner visuals that are currently active.
|
||||
*/
|
||||
val bannerInlineVisuals: ImmutableSet<BannerInlineVisual>
|
||||
|
||||
/**
|
||||
* The visual representation of a snackbar notification.
|
||||
*
|
||||
* This property holds a [SnackbarVisual] object if a snackbar notification
|
||||
* is currently active, or `null` if no snackbar is being displayed.
|
||||
*/
|
||||
val snackbarVisual: SnackbarVisual?
|
||||
}
|
||||
|
|
@ -0,0 +1,221 @@
|
|||
package net.thunderbird.feature.notification.api.ui.host.visual
|
||||
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.util.fastFilter
|
||||
import kotlin.time.Duration
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
import net.thunderbird.feature.notification.api.NotificationSeverity
|
||||
import net.thunderbird.feature.notification.api.content.InAppNotification
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_SUPPORTING_TEXT_LENGTH
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_TITLE_LENGTH
|
||||
import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle
|
||||
|
||||
sealed interface InAppNotificationVisual
|
||||
|
||||
/**
|
||||
* Represents the visual appearance of a [InAppNotificationStyle.BannerGlobalNotification].
|
||||
*
|
||||
* @property message The text content to be displayed in the banner global. This can be any CharSequence,
|
||||
* allowing for formatted text.
|
||||
* @property severity The [NotificationSeverity] of the notification, indicating its importance or type.
|
||||
* Used to determine which BannerGlobal visual style to use.
|
||||
* @property action An optional [NotificationAction] that the user can perform in response to the notification.
|
||||
* If null, no action is available.
|
||||
* @property priority An integer representing the priority of the notification. Higher values typically indicate
|
||||
* higher priority. Used to determine which notification to display, in case of multiples
|
||||
* [InAppNotificationStyle.BannerGlobalNotification].
|
||||
* @see InAppNotificationVisual
|
||||
* @see InAppNotificationStyle.BannerGlobalNotification
|
||||
*/
|
||||
@Stable
|
||||
data class BannerGlobalVisual(
|
||||
val message: CharSequence,
|
||||
val severity: NotificationSeverity,
|
||||
val action: NotificationAction?,
|
||||
val priority: Int,
|
||||
) : InAppNotificationVisual {
|
||||
internal companion object {
|
||||
/**
|
||||
* Creates a [BannerGlobalVisual] from an [InAppNotification].
|
||||
*
|
||||
* This function attempts to convert an [InAppNotification] into a [BannerGlobalVisual].
|
||||
* It expects the notification to have a style of [InAppNotificationStyle.BannerGlobalNotification].
|
||||
*
|
||||
* It performs the following checks:
|
||||
* - The `contentText` of the notification must not be null.
|
||||
* - The notification must have zero or one action.
|
||||
*
|
||||
* If the notification has a matching style and passes all checks, a [BannerGlobalVisual] is created.
|
||||
* Otherwise, this function returns `null`.
|
||||
*
|
||||
* @param notification The [InAppNotification] to convert.
|
||||
* @return A [BannerGlobalVisual] if the conversion is successful, `null` otherwise.
|
||||
* @throws IllegalStateException fails the check validations.
|
||||
*/
|
||||
fun from(notification: InAppNotification): BannerGlobalVisual? =
|
||||
notification.toVisuals<InAppNotificationStyle.BannerGlobalNotification, BannerGlobalVisual> { style ->
|
||||
BannerGlobalVisual(
|
||||
message = checkNotNull(notification.contentText) {
|
||||
"A notification with a BannerGlobalNotification style must have a contentText not null"
|
||||
},
|
||||
severity = notification.severity,
|
||||
action = notification
|
||||
.actions
|
||||
.let { actions ->
|
||||
check(actions.size in 0..1) {
|
||||
"A notification with a BannerGlobalNotification style must have at zero or one action"
|
||||
}
|
||||
actions.toPersistentList()
|
||||
}
|
||||
.firstOrNull(),
|
||||
priority = style.priority,
|
||||
)
|
||||
}.singleOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the visual appearance of a [InAppNotificationStyle.BannerInlineNotification].
|
||||
*
|
||||
* @property title The title of the notification.
|
||||
* @property supportingText The main content/message of the notification.
|
||||
* @property severity The [NotificationSeverity] of the notification, indicating its importance or type.
|
||||
* Used to determine which BannerGlobal visual style to use.
|
||||
* @property actions An immutable list of [NotificationAction] objects representing actions
|
||||
* the user can take in response to the notification.
|
||||
* @see InAppNotificationVisual
|
||||
* @see InAppNotificationStyle.BannerInlineNotification
|
||||
*/
|
||||
@Stable
|
||||
data class BannerInlineVisual(
|
||||
val title: CharSequence,
|
||||
val supportingText: CharSequence,
|
||||
val severity: NotificationSeverity,
|
||||
val actions: ImmutableList<NotificationAction>,
|
||||
) : InAppNotificationVisual {
|
||||
internal companion object {
|
||||
internal const val MAX_TITLE_LENGTH = 100
|
||||
internal const val MAX_SUPPORTING_TEXT_LENGTH = 200
|
||||
|
||||
/**
|
||||
* Creates a [BannerInlineVisual] from an [InAppNotification].
|
||||
*
|
||||
* This function attempts to convert an [InAppNotification] into a [BannerInlineVisual].
|
||||
* It expects the notification to have a style of [InAppNotificationStyle.BannerInlineNotification].
|
||||
*
|
||||
* It performs the following checks:
|
||||
* - The `title` of the notification must have at least 1 and at most [MAX_TITLE_LENGTH] chars.
|
||||
* - The `contentText` of the notification must not be null and have at least 1 and at most
|
||||
* [MAX_SUPPORTING_TEXT_LENGTH] chars.
|
||||
* - The notification must have 1 or 2 actions.
|
||||
*
|
||||
* If the notification has a matching style and passes all checks, a [BannerInlineVisual] is created.
|
||||
* Otherwise, this function returns an empty list.
|
||||
*
|
||||
* @param notification The [InAppNotification] to convert.
|
||||
* @return A list containing a [BannerInlineVisual] if the conversion is successful, an empty list otherwise.
|
||||
* @throws IllegalStateException if any of the validation checks fail.
|
||||
*/
|
||||
fun from(notification: InAppNotification): List<BannerInlineVisual> =
|
||||
notification.toVisuals<InAppNotificationStyle.BannerInlineNotification, BannerInlineVisual> { style ->
|
||||
BannerInlineVisual(
|
||||
title = checkTitle(notification.title),
|
||||
supportingText = checkContentText(notification.contentText),
|
||||
severity = notification.severity,
|
||||
actions = notification
|
||||
.actions
|
||||
.let { actions ->
|
||||
check(actions.size in 1..2) {
|
||||
"A notification with a BannerInlineNotification style must have at one or two actions"
|
||||
}
|
||||
actions.toPersistentList()
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun checkTitle(title: String): String {
|
||||
check(title.length in 1..MAX_TITLE_LENGTH) {
|
||||
"A notification with a BannerInlineNotification style must have a title length of 1 to " +
|
||||
"$MAX_TITLE_LENGTH characters."
|
||||
}
|
||||
return title
|
||||
}
|
||||
|
||||
private fun checkContentText(contentText: String?): String {
|
||||
checkNotNull(contentText) {
|
||||
"A notification with a BannerInlineNotification style must have a contentText not null"
|
||||
}
|
||||
check(contentText.length in 1..MAX_SUPPORTING_TEXT_LENGTH) {
|
||||
"A notification with a BannerInlineNotification style must have a contentText length of 1 to " +
|
||||
"$MAX_SUPPORTING_TEXT_LENGTH characters."
|
||||
}
|
||||
return contentText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Represents the visual appearance of a [InAppNotificationStyle.SnackbarNotification].
|
||||
*
|
||||
* @property message The text message to be displayed in the snackbar.
|
||||
* @property action An optional [NotificationAction] that the user can perform. This is typically a
|
||||
* single action like "Undo" or "Dismiss". If null, no action button is shown.
|
||||
* @property duration The [Duration] for which the snackbar will be visible.
|
||||
* @see InAppNotificationVisual
|
||||
* @see InAppNotificationStyle.SnackbarNotification
|
||||
*/
|
||||
@Stable
|
||||
data class SnackbarVisual(
|
||||
val message: String,
|
||||
val action: NotificationAction?,
|
||||
val duration: Duration,
|
||||
) : InAppNotificationVisual {
|
||||
internal companion object {
|
||||
/**
|
||||
* Creates a [SnackbarVisual] from an [InAppNotification].
|
||||
*
|
||||
* This function attempts to convert an [InAppNotification] into a [SnackbarVisual].
|
||||
* It expects the notification to have a style of [InAppNotificationStyle.SnackbarNotification].
|
||||
*
|
||||
* It performs the following checks:
|
||||
* - The `contentText` of the notification must not be null.
|
||||
* - The notification must have exactly one action.
|
||||
*
|
||||
* If the notification has a matching style and passes all checks, a [SnackbarVisual] is created.
|
||||
* Otherwise, this function returns `null`.
|
||||
*
|
||||
* @param notification The [InAppNotification] to convert.
|
||||
* @return A [SnackbarVisual] if the conversion is successful, `null` otherwise.
|
||||
* @throws IllegalStateException if `contentText` is null or if the number of actions is not 1
|
||||
* when the style is [InAppNotificationStyle.SnackbarNotification].
|
||||
*/
|
||||
fun from(notification: InAppNotification): SnackbarVisual? =
|
||||
notification.toVisuals<InAppNotificationStyle.SnackbarNotification, SnackbarVisual> { style ->
|
||||
SnackbarVisual(
|
||||
message = checkNotNull(notification.contentText) {
|
||||
"A notification with a SnackbarNotification style must have a contentText not null"
|
||||
},
|
||||
action = checkNotNull(notification.actions.singleOrNull()) {
|
||||
"A notification with a SnackbarNotification style must have exactly one action"
|
||||
},
|
||||
duration = style.duration,
|
||||
)
|
||||
}.singleOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private inline fun <
|
||||
reified TStyle : InAppNotificationStyle,
|
||||
reified TVisual : InAppNotificationVisual,
|
||||
> InAppNotification.toVisuals(
|
||||
transform: (TStyle) -> TVisual,
|
||||
): List<TVisual> {
|
||||
return inAppNotificationStyles
|
||||
.fastFilter { style -> style is TStyle }
|
||||
.map { style ->
|
||||
check(style is TStyle)
|
||||
transform(style)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
package net.thunderbird.feature.notification.api.ui.icon
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
|
||||
/**
|
||||
* Represents the icon to be displayed for a notification.
|
||||
*
|
||||
* This class allows specifying different icons for system notifications and in-app notifications.
|
||||
* At least one type of icon must be provided.
|
||||
*
|
||||
* @property systemNotificationIcon The icon to be used for system notifications.
|
||||
* @property inAppNotificationIcon The icon to be used for in-app notifications.
|
||||
*/
|
||||
data class NotificationIcon(
|
||||
val systemNotificationIcon: SystemNotificationIcon? = null,
|
||||
val inAppNotificationIcon: ImageVector? = null,
|
||||
) {
|
||||
|
||||
init {
|
||||
check(systemNotificationIcon != null || inAppNotificationIcon != null) {
|
||||
"Both systemNotificationIcon and inAppNotificationIcon are null. " +
|
||||
"You must specify at least one type of icon."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
package net.thunderbird.feature.notification.api.ui.icon
|
||||
|
||||
/**
|
||||
* Represents a set of icons specifically designed for notifications within the application.
|
||||
*
|
||||
* This object serves as a namespace for various notification icons, allowing for easy access
|
||||
* and organization of these visual assets. Each property within this object is expected to
|
||||
* represent a specific notification icon.
|
||||
*/
|
||||
internal object NotificationIcons
|
||||
|
||||
/**
|
||||
* Represents the icon for authentication error notifications.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.AuthenticationErrorNotification
|
||||
*/
|
||||
internal expect val NotificationIcons.AuthenticationError: NotificationIcon
|
||||
|
||||
/**
|
||||
* Represents the icon for the "Certificate Error" notification.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.CertificateErrorNotification
|
||||
*/
|
||||
internal expect val NotificationIcons.CertificateError: NotificationIcon
|
||||
|
||||
/**
|
||||
* Represents the icon for the "Failed To Create notification" notification.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.FailedToCreateNotification
|
||||
*/
|
||||
internal expect val NotificationIcons.FailedToCreate: NotificationIcon
|
||||
|
||||
/**
|
||||
* Represents the icon for the "Mail Fetching" notification.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.MailNotification.Fetching
|
||||
*/
|
||||
internal expect val NotificationIcons.MailFetching: NotificationIcon
|
||||
|
||||
/**
|
||||
* Represents the icon for the "Mail Sending" notification.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.MailNotification.Sending
|
||||
*/
|
||||
internal expect val NotificationIcons.MailSending: NotificationIcon
|
||||
|
||||
/**
|
||||
* Represents the icon for the "Mail Send Failed" notification.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.MailNotification.SendFailed
|
||||
*/
|
||||
internal expect val NotificationIcons.MailSendFailed: NotificationIcon
|
||||
|
||||
/**
|
||||
* Represents the icon for the "New Mail (Single)" notification.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.MailNotification.NewMailSingleMail
|
||||
*/
|
||||
internal expect val NotificationIcons.NewMailSingleMail: NotificationIcon
|
||||
|
||||
/**
|
||||
* Represents the icon for the "New Mail Summary" notification.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.MailNotification.NewMailSummaryMail
|
||||
*/
|
||||
internal expect val NotificationIcons.NewMailSummaryMail: NotificationIcon
|
||||
|
||||
/**
|
||||
* Represents the icon for the "Push Service Initializing" notification.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.PushServiceNotification.Initializing
|
||||
*/
|
||||
internal expect val NotificationIcons.PushServiceInitializing: NotificationIcon
|
||||
|
||||
/**
|
||||
* Represents the icon for the "Push Service Listening" notification.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.PushServiceNotification.Listening
|
||||
*/
|
||||
internal expect val NotificationIcons.PushServiceListening: NotificationIcon
|
||||
|
||||
/**
|
||||
* Represents the icon for the "Push Service Wait Background Sync" notification.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.PushServiceNotification.WaitBackgroundSync
|
||||
*/
|
||||
internal expect val NotificationIcons.PushServiceWaitBackgroundSync: NotificationIcon
|
||||
|
||||
/**
|
||||
* Represents the icon for the "Push Service Wait Network" notification.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.PushServiceNotification.WaitNetwork
|
||||
*/
|
||||
internal expect val NotificationIcons.PushServiceWaitNetwork: NotificationIcon
|
||||
|
||||
/**
|
||||
* Represents the icon for the "Alarm Permission Missing" notification.
|
||||
*
|
||||
* @see net.thunderbird.feature.notification.api.content.PushServiceNotification.AlarmPermissionMissing
|
||||
*/
|
||||
internal expect val NotificationIcons.AlarmPermissionMissing: NotificationIcon
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
package net.thunderbird.feature.notification.api.ui.icon
|
||||
|
||||
/**
|
||||
* Represents an icon for a system notification.
|
||||
*
|
||||
* This is an expect class, meaning its actual implementation is provided by platform-specific modules.
|
||||
* On Android, this would typically wrap a drawable resource ID.
|
||||
* On other platforms, it might represent a file path or another platform-specific icon identifier.
|
||||
*/
|
||||
expect class SystemNotificationIcon
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
package net.thunderbird.feature.notification.api.ui.icon.atom
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Suppress("MagicNumber", "MaxLineLength")
|
||||
internal val NewEmail: ImageVector
|
||||
get() {
|
||||
val current = _newEmail
|
||||
if (current != null) return current
|
||||
|
||||
return ImageVector.Builder(
|
||||
name = "net.thunderbird.feature.notification.api.ui.icon.atom.NewEmail",
|
||||
defaultWidth = 24.0.dp,
|
||||
defaultHeight = 24.0.dp,
|
||||
viewportWidth = 960.0f,
|
||||
viewportHeight = 960.0f,
|
||||
).apply {
|
||||
path(
|
||||
fill = SolidColor(Color(0xFFFFFFFF)),
|
||||
) {
|
||||
moveTo(x = 160.0f, y = 800.0f)
|
||||
quadTo(x1 = 127.0f, y1 = 800.0f, x2 = 103.5f, y2 = 776.5f)
|
||||
quadTo(x1 = 80.0f, y1 = 753.0f, x2 = 80.0f, y2 = 720.0f)
|
||||
lineTo(x = 80.0f, y = 240.0f)
|
||||
quadTo(x1 = 80.0f, y1 = 207.0f, x2 = 103.5f, y2 = 183.5f)
|
||||
quadTo(x1 = 127.0f, y1 = 160.0f, x2 = 160.0f, y2 = 160.0f)
|
||||
lineTo(x = 564.0f, y = 160.0f)
|
||||
quadTo(x1 = 560.0f, y1 = 180.0f, x2 = 560.0f, y2 = 200.0f)
|
||||
quadTo(x1 = 560.0f, y1 = 220.0f, x2 = 564.0f, y2 = 240.0f)
|
||||
lineTo(x = 160.0f, y = 240.0f)
|
||||
lineTo(x = 480.0f, y = 440.0f)
|
||||
lineTo(x = 626.0f, y = 349.0f)
|
||||
quadTo(x1 = 640.0f, y1 = 362.0f, x2 = 656.5f, y2 = 371.5f)
|
||||
quadTo(x1 = 673.0f, y1 = 381.0f, x2 = 691.0f, y2 = 388.0f)
|
||||
lineTo(x = 480.0f, y = 520.0f)
|
||||
lineTo(x = 160.0f, y = 320.0f)
|
||||
lineTo(x = 160.0f, y = 720.0f)
|
||||
quadTo(x1 = 160.0f, y1 = 720.0f, x2 = 160.0f, y2 = 720.0f)
|
||||
quadTo(x1 = 160.0f, y1 = 720.0f, x2 = 160.0f, y2 = 720.0f)
|
||||
lineTo(x = 800.0f, y = 720.0f)
|
||||
quadTo(x1 = 800.0f, y1 = 720.0f, x2 = 800.0f, y2 = 720.0f)
|
||||
quadTo(x1 = 800.0f, y1 = 720.0f, x2 = 800.0f, y2 = 720.0f)
|
||||
lineTo(x = 800.0f, y = 396.0f)
|
||||
quadTo(x1 = 823.0f, y1 = 391.0f, x2 = 843.0f, y2 = 382.0f)
|
||||
quadTo(x1 = 863.0f, y1 = 373.0f, x2 = 880.0f, y2 = 360.0f)
|
||||
lineTo(x = 880.0f, y = 720.0f)
|
||||
quadTo(x1 = 880.0f, y1 = 753.0f, x2 = 856.5f, y2 = 776.5f)
|
||||
quadTo(x1 = 833.0f, y1 = 800.0f, x2 = 800.0f, y2 = 800.0f)
|
||||
lineTo(x = 160.0f, y = 800.0f)
|
||||
close()
|
||||
moveTo(x = 160.0f, y = 240.0f)
|
||||
lineTo(x = 160.0f, y = 240.0f)
|
||||
lineTo(x = 160.0f, y = 240.0f)
|
||||
lineTo(x = 160.0f, y = 720.0f)
|
||||
quadTo(x1 = 160.0f, y1 = 720.0f, x2 = 160.0f, y2 = 720.0f)
|
||||
quadTo(x1 = 160.0f, y1 = 720.0f, x2 = 160.0f, y2 = 720.0f)
|
||||
lineTo(x = 160.0f, y = 720.0f)
|
||||
quadTo(x1 = 160.0f, y1 = 720.0f, x2 = 160.0f, y2 = 720.0f)
|
||||
quadTo(x1 = 160.0f, y1 = 720.0f, x2 = 160.0f, y2 = 720.0f)
|
||||
lineTo(x = 160.0f, y = 240.0f)
|
||||
quadTo(x1 = 160.0f, y1 = 240.0f, x2 = 160.0f, y2 = 240.0f)
|
||||
quadTo(x1 = 160.0f, y1 = 240.0f, x2 = 160.0f, y2 = 240.0f)
|
||||
quadTo(x1 = 160.0f, y1 = 240.0f, x2 = 160.0f, y2 = 240.0f)
|
||||
quadTo(x1 = 160.0f, y1 = 240.0f, x2 = 160.0f, y2 = 240.0f)
|
||||
close()
|
||||
moveTo(x = 760.0f, y = 320.0f)
|
||||
quadTo(x1 = 710.0f, y1 = 320.0f, x2 = 675.0f, y2 = 285.0f)
|
||||
quadTo(x1 = 640.0f, y1 = 250.0f, x2 = 640.0f, y2 = 200.0f)
|
||||
quadTo(x1 = 640.0f, y1 = 150.0f, x2 = 675.0f, y2 = 115.0f)
|
||||
quadTo(x1 = 710.0f, y1 = 80.0f, x2 = 760.0f, y2 = 80.0f)
|
||||
quadTo(x1 = 810.0f, y1 = 80.0f, x2 = 845.0f, y2 = 115.0f)
|
||||
quadTo(x1 = 880.0f, y1 = 150.0f, x2 = 880.0f, y2 = 200.0f)
|
||||
quadTo(x1 = 880.0f, y1 = 250.0f, x2 = 845.0f, y2 = 285.0f)
|
||||
quadTo(x1 = 810.0f, y1 = 320.0f, x2 = 760.0f, y2 = 320.0f)
|
||||
close()
|
||||
}
|
||||
}.build().also { _newEmail = it }
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _newEmail: ImageVector? = null
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
package net.thunderbird.feature.notification.api.ui.icon.atom
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.SolidColor
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.path
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@Suppress("MagicNumber", "MaxLineLength")
|
||||
internal val Notification: ImageVector
|
||||
get() {
|
||||
val current = _notification
|
||||
if (current != null) return current
|
||||
|
||||
return ImageVector.Builder(
|
||||
name = "net.thunderbird.feature.notification.api.ui.icon.atom.Notification",
|
||||
defaultWidth = 24.0.dp,
|
||||
defaultHeight = 24.0.dp,
|
||||
viewportWidth = 24.0f,
|
||||
viewportHeight = 24.0f,
|
||||
).apply {
|
||||
path(
|
||||
fill = SolidColor(Color(0xFF1A202C)),
|
||||
fillAlpha = 0.2f,
|
||||
strokeAlpha = 0.2f,
|
||||
) {
|
||||
moveTo(x = 12.0f, y = 3.5f)
|
||||
curveTo(x1 = 8.953f, y1 = 3.5f, x2 = 6.5f, y2 = 5.953f, x3 = 6.5f, y3 = 9.0f)
|
||||
verticalLineTo(y = 12.0f)
|
||||
verticalLineTo(y = 13.5f)
|
||||
curveTo(x1 = 5.392f, y1 = 13.5f, x2 = 4.5f, y2 = 14.392f, x3 = 4.5f, y3 = 15.5f)
|
||||
verticalLineTo(y = 17.0f)
|
||||
curveTo(x1 = 4.5f, y1 = 17.277f, x2 = 4.723f, y2 = 17.5f, x3 = 5.0f, y3 = 17.5f)
|
||||
horizontalLineTo(x = 6.5f)
|
||||
horizontalLineTo(x = 7.0f)
|
||||
horizontalLineTo(x = 17.0f)
|
||||
horizontalLineTo(x = 17.5f)
|
||||
horizontalLineTo(x = 19.0f)
|
||||
curveTo(x1 = 19.277f, y1 = 17.5f, x2 = 19.5f, y2 = 17.277f, x3 = 19.5f, y3 = 17.0f)
|
||||
verticalLineTo(y = 15.5f)
|
||||
curveTo(x1 = 19.5f, y1 = 14.392f, x2 = 18.608f, y2 = 13.5f, x3 = 17.5f, y3 = 13.5f)
|
||||
verticalLineTo(y = 10.5f)
|
||||
verticalLineTo(y = 9.0f)
|
||||
curveTo(x1 = 17.5f, y1 = 5.953f, x2 = 15.047f, y2 = 3.5f, x3 = 12.0f, y3 = 3.5f)
|
||||
close()
|
||||
}
|
||||
path(
|
||||
fill = SolidColor(Color(0xFF1A202C)),
|
||||
) {
|
||||
moveTo(x = 12.0f, y = 3.0f)
|
||||
curveTo(x1 = 8.68466f, y1 = 3.0f, x2 = 6.0f, y2 = 5.68465f, x3 = 6.0f, y3 = 9.0f)
|
||||
verticalLineTo(y = 12.0f)
|
||||
verticalLineTo(y = 13.207f)
|
||||
curveTo(x1 = 4.89477f, y1 = 13.4651f, x2 = 4.0f, y2 = 14.3184f, x3 = 4.0f, y3 = 15.5f)
|
||||
verticalLineTo(y = 17.0f)
|
||||
curveTo(x1 = 4.0f, y1 = 17.5454f, x2 = 4.45465f, y2 = 18.0f, x3 = 5.0f, y3 = 18.0f)
|
||||
horizontalLineTo(x = 6.5f)
|
||||
horizontalLineTo(x = 7.0f)
|
||||
horizontalLineTo(x = 9.0f)
|
||||
curveTo(x1 = 9.0f, y1 = 19.6534f, x2 = 10.3467f, y2 = 21.0f, x3 = 12.0f, y3 = 21.0f)
|
||||
curveTo(x1 = 13.6533f, y1 = 21.0f, x2 = 15.0f, y2 = 19.6534f, x3 = 15.0f, y3 = 18.0f)
|
||||
horizontalLineTo(x = 17.0f)
|
||||
horizontalLineTo(x = 17.5f)
|
||||
horizontalLineTo(x = 19.0f)
|
||||
curveTo(x1 = 19.5454f, y1 = 18.0f, x2 = 20.0f, y2 = 17.5454f, x3 = 20.0f, y3 = 17.0f)
|
||||
verticalLineTo(y = 15.5f)
|
||||
curveTo(x1 = 20.0f, y1 = 14.3184f, x2 = 19.1052f, y2 = 13.4651f, x3 = 18.0f, y3 = 13.207f)
|
||||
verticalLineTo(y = 10.5f)
|
||||
verticalLineTo(y = 9.0f)
|
||||
curveTo(x1 = 18.0f, y1 = 5.68465f, x2 = 15.3153f, y2 = 3.0f, x3 = 12.0f, y3 = 3.0f)
|
||||
close()
|
||||
moveTo(x = 12.0f, y = 4.0f)
|
||||
curveTo(x1 = 14.7786f, y1 = 4.0f, x2 = 17.0f, y2 = 6.22136f, x3 = 17.0f, y3 = 9.0f)
|
||||
verticalLineTo(y = 10.5f)
|
||||
verticalLineTo(y = 13.5f)
|
||||
curveTo(x1 = 17.0f, y1 = 13.6326f, x2 = 17.0527f, y2 = 13.7598f, x3 = 17.1465f, y3 = 13.8535f)
|
||||
curveTo(x1 = 17.2402f, y1 = 13.9473f, x2 = 17.3674f, y2 = 14.0f, x3 = 17.5f, y3 = 14.0f)
|
||||
curveTo(x1 = 18.3396f, y1 = 14.0f, x2 = 19.0f, y2 = 14.6603f, x3 = 19.0f, y3 = 15.5f)
|
||||
verticalLineTo(y = 17.0f)
|
||||
horizontalLineTo(x = 17.5f)
|
||||
horizontalLineTo(x = 17.0f)
|
||||
horizontalLineTo(x = 14.5f)
|
||||
horizontalLineTo(x = 9.5f)
|
||||
horizontalLineTo(x = 7.0f)
|
||||
horizontalLineTo(x = 6.5f)
|
||||
horizontalLineTo(x = 5.0f)
|
||||
verticalLineTo(y = 15.5f)
|
||||
curveTo(x1 = 5.0f, y1 = 14.6603f, x2 = 5.66036f, y2 = 14.0f, x3 = 6.5f, y3 = 14.0f)
|
||||
curveTo(x1 = 6.6326f, y1 = 14.0f, x2 = 6.75977f, y2 = 13.9473f, x3 = 6.85354f, y3 = 13.8535f)
|
||||
curveTo(x1 = 6.9473f, y1 = 13.7598f, x2 = 6.99999f, y2 = 13.6326f, x3 = 7.0f, y3 = 13.5f)
|
||||
verticalLineTo(y = 12.0f)
|
||||
verticalLineTo(y = 9.0f)
|
||||
curveTo(x1 = 7.0f, y1 = 6.22136f, x2 = 9.22136f, y2 = 4.0f, x3 = 12.0f, y3 = 4.0f)
|
||||
close()
|
||||
moveTo(x = 10.0f, y = 18.0f)
|
||||
horizontalLineTo(x = 14.0f)
|
||||
curveTo(x1 = 14.0f, y1 = 19.1166f, x2 = 13.1166f, y2 = 20.0f, x3 = 12.0f, y3 = 20.0f)
|
||||
curveTo(x1 = 10.8834f, y1 = 20.0f, x2 = 10.0f, y2 = 19.1166f, x3 = 10.0f, y3 = 18.0f)
|
||||
close()
|
||||
}
|
||||
}.build().also { _notification = it }
|
||||
}
|
||||
|
||||
@Suppress("ObjectPropertyName")
|
||||
private var _notification: ImageVector? = null
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
package net.thunderbird.feature.notification.api.ui.style
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import net.thunderbird.feature.notification.api.ui.style.builder.InAppNotificationStyleBuilder
|
||||
|
||||
/**
|
||||
* Represents the style of an in-app notification.
|
||||
*
|
||||
* In-app notifications are displayed within the application itself to provide immediate
|
||||
* feedback or information.
|
||||
*/
|
||||
sealed interface InAppNotificationStyle {
|
||||
companion object {
|
||||
/**
|
||||
* Represents an undefined in-app notification style.
|
||||
* This can be used as a default or placeholder when no specific style is applicable.
|
||||
*/
|
||||
val Undefined: List<InAppNotificationStyle> = emptyList()
|
||||
}
|
||||
|
||||
/**
|
||||
* @see InAppNotificationStyleBuilder.bannerInline
|
||||
*/
|
||||
data object BannerInlineNotification : InAppNotificationStyle
|
||||
|
||||
/**
|
||||
* @see InAppNotificationStyleBuilder.bannerGlobal
|
||||
*/
|
||||
data class BannerGlobalNotification(
|
||||
val priority: Int,
|
||||
) : InAppNotificationStyle
|
||||
|
||||
/**
|
||||
* @see [InAppNotificationStyleBuilder.snackbar]
|
||||
*/
|
||||
data class SnackbarNotification(
|
||||
val duration: Duration = 10.seconds,
|
||||
) : InAppNotificationStyle
|
||||
|
||||
/**
|
||||
* @see [InAppNotificationStyleBuilder.dialog]
|
||||
*/
|
||||
data object DialogNotification : InAppNotificationStyle
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the in-app notification style.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* inAppNotificationStyles {
|
||||
* snackbar(duration = 30.seconds)
|
||||
* bottomSheet()
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param builder A lambda function with [InAppNotificationStyleBuilder] as its receiver,
|
||||
* used to configure the system notification style.
|
||||
* @return a list of [InAppNotificationStyle]
|
||||
*/
|
||||
@NotificationStyleMarker
|
||||
fun inAppNotificationStyles(
|
||||
builder: @NotificationStyleMarker InAppNotificationStyleBuilder.() -> Unit,
|
||||
): List<InAppNotificationStyle> {
|
||||
return InAppNotificationStyleBuilder().apply(builder).build()
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package net.thunderbird.feature.notification.api.ui.style
|
||||
|
||||
/**
|
||||
* A DSL marker for building notification styles.
|
||||
*
|
||||
* This annotation is used to restrict the scope of lambda receivers, ensuring that
|
||||
* methods belonging to an outer scope cannot be called from an inner scope.
|
||||
* This helps in creating a more structured and type-safe DSL for constructing
|
||||
* different notification styles.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* // OK:
|
||||
* val systemStyle = systemNotificationStyle {
|
||||
* bigText("This is a big text notification.")
|
||||
* }
|
||||
*
|
||||
* // Compile error:
|
||||
* val systemStyle = systemNotificationStyle {
|
||||
* inbox {
|
||||
* // bigText must be called within systemNotificationStyle and not within inbox configuration.
|
||||
* bigText("This is a big text notification.")
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@DslMarker
|
||||
@Target(AnnotationTarget.TYPE, AnnotationTarget.FUNCTION)
|
||||
internal annotation class NotificationStyleMarker
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
package net.thunderbird.feature.notification.api.ui.style
|
||||
|
||||
import net.thunderbird.feature.notification.api.ui.style.builder.SystemNotificationStyleBuilder
|
||||
import org.jetbrains.annotations.VisibleForTesting
|
||||
|
||||
/**
|
||||
* Represents the style of a system notification.
|
||||
*/
|
||||
sealed interface SystemNotificationStyle {
|
||||
/**
|
||||
* Represents an undefined notification style.
|
||||
* This can be used as a default or placeholder when no specific style is applicable.
|
||||
*/
|
||||
data object Undefined : SystemNotificationStyle
|
||||
|
||||
/**
|
||||
* Style for large-format notifications that include a lot of text.
|
||||
*
|
||||
* @property text The main text content of the notification.
|
||||
*/
|
||||
data class BigTextStyle @VisibleForTesting constructor(
|
||||
val text: String,
|
||||
) : SystemNotificationStyle
|
||||
|
||||
/**
|
||||
* Style for large-format notifications that include a list of (up to 5) strings.
|
||||
*
|
||||
* @property bigContentTitle Overrides the title of the notification.
|
||||
* @property summary Overrides the summary of the notification.
|
||||
* @property lines List of strings to display in the notification.
|
||||
*/
|
||||
data class InboxStyle @VisibleForTesting constructor(
|
||||
val bigContentTitle: String,
|
||||
val summary: String,
|
||||
val lines: List<CharSequence>,
|
||||
) : SystemNotificationStyle
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the system notification style.
|
||||
*
|
||||
* @param builder A lambda function with [SystemNotificationStyleBuilder] as its receiver,
|
||||
* used to configure the system notification style.
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* systemNotificationStyle {
|
||||
* bigText("This is a big text notification.")
|
||||
* // or
|
||||
* inbox {
|
||||
* // Add more inbox style configurations here
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
@NotificationStyleMarker
|
||||
fun systemNotificationStyle(
|
||||
builder: @NotificationStyleMarker SystemNotificationStyleBuilder.() -> Unit,
|
||||
): SystemNotificationStyle {
|
||||
return SystemNotificationStyleBuilder().apply(builder).build()
|
||||
}
|
||||
|
|
@ -0,0 +1,136 @@
|
|||
package net.thunderbird.feature.notification.api.ui.style.builder
|
||||
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle
|
||||
import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle.BannerGlobalNotification
|
||||
import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle.BannerInlineNotification
|
||||
import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle.DialogNotification
|
||||
import net.thunderbird.feature.notification.api.ui.style.InAppNotificationStyle.SnackbarNotification
|
||||
import net.thunderbird.feature.notification.api.ui.style.NotificationStyleMarker
|
||||
|
||||
/**
|
||||
* Builder for creating [InAppNotificationStyle] instances.
|
||||
* This interface defines the methods available for configuring the style of an in-app notification.
|
||||
*/
|
||||
class InAppNotificationStyleBuilder internal constructor() {
|
||||
private var styles = mutableListOf<InAppNotificationStyle>()
|
||||
|
||||
/**
|
||||
* Use inline error banners to surface issues that must be resolved before the user can continue
|
||||
* with the main task or content on the screen.
|
||||
*
|
||||
* ### USAGE GUIDELINES
|
||||
*
|
||||
* #### Use for:
|
||||
* - Critical errors that disrupts a function of the screen’s functionality
|
||||
* - Errors that require user attention but do not completely block their ability to continue
|
||||
* interacting with the app
|
||||
*
|
||||
* #### Do not use for:
|
||||
* - Blocking errors that must halt the user’s flow until resolved (consider using a
|
||||
* [DialogNotification] instead)
|
||||
* - Global or persistent application states that should be shown across all screens
|
||||
* (consider using a [BannerGlobalNotification])
|
||||
* - Secondary or surface-level errors caused by a deeper issue (e.g., inability to encrypt is a warning,
|
||||
* while the missing encryption key is the actual error)
|
||||
* - Non-error messages such as warnings, success confirmations, or informational notices (these will use a
|
||||
* different component and are not part of the in-app error banner pattern)
|
||||
*/
|
||||
@NotificationStyleMarker
|
||||
fun bannerInline() {
|
||||
checkSingleStyleEntry<BannerInlineNotification>()
|
||||
styles += BannerInlineNotification
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to maintain user awareness of a persistent, irregular state of the application without
|
||||
* interrupting the primary flow. This component is appropriate for warnings that apply globally
|
||||
* across the app.
|
||||
*
|
||||
* If the warning is caused by a critical error, an [BannerInlineNotification] should also be shown
|
||||
* in the relevant context (e.g., the message list) to guide direct resolution.
|
||||
*
|
||||
* ### USAGE GUIDELINES
|
||||
*
|
||||
* #### Use for:
|
||||
* - Persistent application states that affect the current screen
|
||||
* - In account configuration flows, to display:
|
||||
* - Errors, success, or informational messages that require a constant on-screen indicator
|
||||
* - Outside of account configuration, for global warnings such as:
|
||||
* - Being offline
|
||||
* - Encryption being unavailable
|
||||
*
|
||||
* #### Do not use for:
|
||||
* - Errors, success, or informational messages outside the account configuration flow (use
|
||||
* [BannerInlineNotification] or other transient messaging components instead)
|
||||
* - Warnings that must interrupt the user’s flow or require immediate action (consider using
|
||||
* a [DialogNotification] in these cases)
|
||||
*/
|
||||
@NotificationStyleMarker
|
||||
fun bannerGlobal(priority: Int = 0) {
|
||||
checkSingleStyleEntry<BannerGlobalNotification>()
|
||||
styles += BannerGlobalNotification(priority = priority)
|
||||
}
|
||||
|
||||
/**
|
||||
* Snackbars are used to inform the user of an error or process outcome, and may optionally offer
|
||||
* a related action. They appear temporarily without interrupting the user's current task.
|
||||
*
|
||||
* ### USAGE GUIDELINES
|
||||
*
|
||||
* #### Use for:
|
||||
* - Providing feedback when an action fails, with the option for the user to take corrective action
|
||||
*
|
||||
* #### Do not use for:
|
||||
* - Errors that must interrupt the user’s flow or block further interaction (use a [DialogNotification]
|
||||
* in these cases)
|
||||
* - Account sync error feedback in the Unified Inbox (use a [BannerInlineNotification] or
|
||||
* [BannerGlobalNotification] for that context)
|
||||
*/
|
||||
@NotificationStyleMarker
|
||||
fun snackbar(duration: Duration = 10.seconds) {
|
||||
checkSingleStyleEntry<SnackbarNotification>()
|
||||
styles += SnackbarNotification(duration)
|
||||
}
|
||||
|
||||
/**
|
||||
* Used to inform the user about a required permission needed to enable or complete a key feature of the app.
|
||||
* The dialog provides a concise explanation of the need for the permission and prompts the user to grant it.
|
||||
*
|
||||
* ### USAGE GUIDELINES
|
||||
*
|
||||
* #### Use for:
|
||||
* - Requesting notification permission from the user
|
||||
* - Clearly and succinctly explaining why the permission is needed and how it impacts the app experience
|
||||
*
|
||||
* #### Do not use for:
|
||||
* - Displaying errors
|
||||
* - Requesting contacts permission, as missing access does not critically affect app functionality
|
||||
* - Requesting background activity permission related to battery saver, since the app cannot reliably detect
|
||||
* the current permission state
|
||||
*/
|
||||
@NotificationStyleMarker
|
||||
fun dialog() {
|
||||
checkSingleStyleEntry<DialogNotification>()
|
||||
styles += DialogNotification
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the [InAppNotificationStyle] based on the provided parameters.
|
||||
*
|
||||
* @return The constructed [InAppNotificationStyle].
|
||||
*/
|
||||
fun build(): List<InAppNotificationStyle> {
|
||||
check(styles.isNotEmpty()) {
|
||||
"You must add at least one in-app notification style."
|
||||
}
|
||||
return styles.takeUnless { it.isEmpty() } ?: InAppNotificationStyle.Undefined
|
||||
}
|
||||
|
||||
private inline fun <reified T> checkSingleStyleEntry() {
|
||||
check(styles.none { it is T }) {
|
||||
"An in-app notification can only have at most one type of ${T::class.simpleName} style"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package net.thunderbird.feature.notification.api.ui.style.builder
|
||||
|
||||
import net.thunderbird.feature.notification.api.ui.style.SystemNotificationStyle
|
||||
import org.jetbrains.annotations.VisibleForTesting
|
||||
|
||||
@VisibleForTesting
|
||||
internal const val MAX_LINES = 5
|
||||
private const val MAX_LINES_ERROR_MESSAGE = "The maximum number of lines for a inbox notification is $MAX_LINES"
|
||||
|
||||
/**
|
||||
* Builder for [SystemNotificationStyle.InboxStyle].
|
||||
*
|
||||
* This style is used to display a list of items in the notification's content.
|
||||
* It is commonly used for email or messaging apps.
|
||||
*/
|
||||
class InboxSystemNotificationStyleBuilder internal constructor(
|
||||
private var bigContentTitle: String? = null,
|
||||
private var summary: String? = null,
|
||||
private val lines: MutableList<CharSequence> = mutableListOf(),
|
||||
) {
|
||||
/**
|
||||
* Sets the title for the notification's big content view.
|
||||
*
|
||||
* This method is used to specify the main title text that will be displayed
|
||||
* when the notification is expanded to show its detailed content.
|
||||
*
|
||||
* @param title The string to be used as the big content title.
|
||||
*/
|
||||
fun title(title: String) {
|
||||
bigContentTitle = title
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the summary of the item.
|
||||
*
|
||||
* @param summary The summary of the item.
|
||||
*/
|
||||
fun summary(summary: String) {
|
||||
this.summary = summary
|
||||
}
|
||||
|
||||
/**
|
||||
* Append a line to the digest section of the Inbox notification.
|
||||
*
|
||||
* @param line The line to add.
|
||||
*/
|
||||
fun line(line: CharSequence) {
|
||||
require(lines.size < MAX_LINES) { MAX_LINES_ERROR_MESSAGE }
|
||||
lines += line
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds one or more lines to the digest section of the Inbox notification.
|
||||
*
|
||||
* @param lines A variable number of CharSequence objects representing the lines to be added.
|
||||
*/
|
||||
fun lines(vararg lines: CharSequence) {
|
||||
require(lines.size < MAX_LINES) { MAX_LINES_ERROR_MESSAGE }
|
||||
this.lines += lines
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns a [SystemNotificationStyle.InboxStyle] object.
|
||||
*
|
||||
* This method performs checks to ensure that mandatory fields like the big content title
|
||||
* and summary are provided before creating the notification style object.
|
||||
*
|
||||
* @return A [SystemNotificationStyle.InboxStyle] object configured with the specified
|
||||
* title, summary, and lines.
|
||||
* @throws IllegalStateException if the big content title or summary is not set.
|
||||
*/
|
||||
@Suppress("VisibleForTests")
|
||||
internal fun build(): SystemNotificationStyle.InboxStyle = SystemNotificationStyle.InboxStyle(
|
||||
bigContentTitle = checkNotNull(bigContentTitle) {
|
||||
"The inbox notification's title is required"
|
||||
},
|
||||
summary = checkNotNull(summary) {
|
||||
"The inbox notification's summary is required"
|
||||
},
|
||||
lines = lines.toList(),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,95 @@
|
|||
package net.thunderbird.feature.notification.api.ui.style.builder
|
||||
|
||||
import kotlin.apply
|
||||
import net.thunderbird.feature.notification.api.ui.style.NotificationStyleMarker
|
||||
import net.thunderbird.feature.notification.api.ui.style.SystemNotificationStyle
|
||||
import net.thunderbird.feature.notification.api.ui.style.SystemNotificationStyle.BigTextStyle
|
||||
import net.thunderbird.feature.notification.api.ui.style.SystemNotificationStyle.InboxStyle
|
||||
|
||||
/**
|
||||
* A builder for creating system notification styles.
|
||||
*
|
||||
* This builder allows for the creation of either a [BigTextStyle] or an [InboxStyle] for a system notification.
|
||||
* It ensures that only one style type is set at a time, throwing an error if both are attempted.
|
||||
*
|
||||
* Example usage for [BigTextStyle]:
|
||||
* ```
|
||||
* val style = systemNotificationStyle {
|
||||
* bigText("This is a long piece of text that will be displayed in the expanded notification.")
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* Example usage for [InboxStyle]:
|
||||
* ```
|
||||
* val style = systemNotificationStyle {
|
||||
* inbox {
|
||||
* title("5 New Messages")
|
||||
* summary("You have new messages")
|
||||
* addLine("Alice: Hey, are you free later?")
|
||||
* addLine("Bob: Meeting reminder for 3 PM")
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
* @see net.thunderbird.feature.notification.api.ui.style.systemNotificationStyle
|
||||
*/
|
||||
class SystemNotificationStyleBuilder internal constructor() {
|
||||
private var bigText: BigTextStyle? = null
|
||||
private var inboxStyle: InboxStyle? = null
|
||||
|
||||
/**
|
||||
* Sets the style of the notification to [SystemNotificationStyle.BigTextStyle].
|
||||
*
|
||||
* This style displays a large block of text.
|
||||
*
|
||||
* **Note:** A system notification can either have a BigText or InboxStyle, not both.
|
||||
*
|
||||
* @param text The text to be displayed in the notification.
|
||||
*/
|
||||
fun bigText(text: String) {
|
||||
@Suppress("VisibleForTests")
|
||||
bigText = BigTextStyle(text = text)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the style of the notification to [SystemNotificationStyle.InboxStyle].
|
||||
*
|
||||
* This style is designed for aggregated notifications.
|
||||
*
|
||||
* **Note:** A system notification can either have a BigText or InboxStyle, not both.
|
||||
*
|
||||
* @param builder A lambda with [InboxSystemNotificationStyleBuilder] as its receiver,
|
||||
* used to configure the Inbox style.
|
||||
* @see InboxSystemNotificationStyleBuilder
|
||||
*/
|
||||
@NotificationStyleMarker
|
||||
fun inbox(builder: @NotificationStyleMarker InboxSystemNotificationStyleBuilder.() -> Unit) {
|
||||
inboxStyle = InboxSystemNotificationStyleBuilder().apply(builder).build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds and returns the configured [SystemNotificationStyle].
|
||||
*
|
||||
* This method validates that either a [BigTextStyle] or an [InboxStyle] has been set, but not both.
|
||||
* If both styles are set, or if neither style is set (which should be an unexpected state),
|
||||
* it will throw an [IllegalStateException].
|
||||
*
|
||||
* @return The configured [SystemNotificationStyle] which will be either a [BigTextStyle] or an [InboxStyle].
|
||||
* @throws IllegalStateException if both `bigText` and `inboxStyle` are set, or if neither are set.
|
||||
*/
|
||||
internal fun build(): SystemNotificationStyle {
|
||||
// shadowing properties to safely capture its value at the call time.
|
||||
val bigText = bigText
|
||||
val inboxStyle = inboxStyle
|
||||
return when {
|
||||
bigText != null && inboxStyle != null -> error(
|
||||
"A system notification can either have a BigText or InboxStyle, not both.",
|
||||
)
|
||||
|
||||
bigText != null -> bigText
|
||||
|
||||
inboxStyle != null -> inboxStyle
|
||||
|
||||
else -> error("You must configure at least one of the following styles: bigText or inbox.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
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
|
||||
import dev.mokkery.spy
|
||||
import dev.mokkery.verify
|
||||
import dev.mokkery.verify.VerifyMode.Companion.exactly
|
||||
import kotlin.test.Test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
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
|
||||
|
||||
class InAppNotificationReceiverCompatTest {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `onReceiveEvent should be triggered when an event is received into InAppNotificationReceiver`() = runTest {
|
||||
// Arrange
|
||||
val inAppNotificationReceiver = FakeInAppNotificationReceiver()
|
||||
val eventsTriggered = mutableListOf<InAppNotificationEvent>()
|
||||
val expectedEvents = List(size = 100) { index ->
|
||||
val title = "notification $index"
|
||||
when {
|
||||
index % 2 == 0 -> {
|
||||
InAppNotificationEvent.Show(FakeNotification(title = title))
|
||||
}
|
||||
|
||||
else -> InAppNotificationEvent.Dismiss(FakeNotification(title = title))
|
||||
}
|
||||
}.toTypedArray()
|
||||
val onReceiveEventListener = spy<OnReceiveEventListener>(
|
||||
OnReceiveEventListener { event ->
|
||||
eventsTriggered += event
|
||||
},
|
||||
)
|
||||
InAppNotificationReceiverCompat(
|
||||
notificationReceiver = inAppNotificationReceiver,
|
||||
listener = onReceiveEventListener,
|
||||
mainImmediateDispatcher = UnconfinedTestDispatcher(),
|
||||
)
|
||||
|
||||
// Act
|
||||
expectedEvents.forEach { event -> inAppNotificationReceiver.trigger(event) }
|
||||
|
||||
// Assert
|
||||
verify(exactly(100)) {
|
||||
onReceiveEventListener.onReceiveEvent(event = any())
|
||||
}
|
||||
assertThat(eventsTriggered).containsExactlyInAnyOrder(elements = expectedEvents)
|
||||
}
|
||||
|
||||
private class FakeInAppNotificationReceiver : InAppNotificationReceiver {
|
||||
private val _events = MutableSharedFlow<InAppNotificationEvent>()
|
||||
override val events: SharedFlow<InAppNotificationEvent> = _events.asSharedFlow()
|
||||
|
||||
suspend fun trigger(event: InAppNotificationEvent) {
|
||||
_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
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
package net.thunderbird.feature.notification.api.sender.compat
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactlyInAnyOrder
|
||||
import dev.mokkery.matcher.any
|
||||
import dev.mokkery.spy
|
||||
import dev.mokkery.verify
|
||||
import dev.mokkery.verify.VerifyMode.Companion.exactly
|
||||
import kotlin.test.Test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import net.thunderbird.core.outcome.Outcome
|
||||
import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure
|
||||
import net.thunderbird.feature.notification.api.command.NotificationCommand.Success
|
||||
import net.thunderbird.feature.notification.api.command.NotificationCommandException
|
||||
import net.thunderbird.feature.notification.api.content.Notification
|
||||
import net.thunderbird.feature.notification.testing.fake.FakeNotification
|
||||
import net.thunderbird.feature.notification.testing.fake.command.FakeInAppNotificationCommand
|
||||
import net.thunderbird.feature.notification.testing.fake.command.FakeSystemNotificationCommand
|
||||
import net.thunderbird.feature.notification.testing.fake.sender.FakeNotificationSender
|
||||
|
||||
class NotificationSenderCompatTest {
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `send should call listener callback whenever a result is received`() {
|
||||
// Arrange
|
||||
val expectedResults = listOf<Outcome<Success<Notification>, Failure<Notification>>>(
|
||||
Outcome.success(Success(FakeInAppNotificationCommand())),
|
||||
Outcome.success(Success(FakeSystemNotificationCommand())),
|
||||
Outcome.failure(
|
||||
error = Failure(
|
||||
command = FakeSystemNotificationCommand(),
|
||||
throwable = NotificationCommandException("What an issue?"),
|
||||
),
|
||||
),
|
||||
)
|
||||
val sender = FakeNotificationSender(results = expectedResults)
|
||||
val actualResults = mutableListOf<Outcome<Success<Notification>, Failure<Notification>>>()
|
||||
val listener = spy(
|
||||
NotificationSenderCompat.OnResultListener { outcome ->
|
||||
actualResults += outcome
|
||||
},
|
||||
)
|
||||
val testSubject = NotificationSenderCompat(
|
||||
notificationSender = sender,
|
||||
mainImmediateDispatcher = UnconfinedTestDispatcher(),
|
||||
)
|
||||
|
||||
// Act
|
||||
testSubject.send(notification = FakeNotification(), listener)
|
||||
|
||||
// Assert
|
||||
verify(exactly(expectedResults.size)) {
|
||||
listener.onResult(outcome = any())
|
||||
}
|
||||
assertThat(actualResults).containsExactlyInAnyOrder(elements = expectedResults.toTypedArray())
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,896 @@
|
|||
package net.thunderbird.feature.notification.api.ui.host
|
||||
|
||||
import app.cash.turbine.test
|
||||
import assertk.all
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.contains
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.hasSize
|
||||
import assertk.assertions.isEmpty
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.isNotEmpty
|
||||
import assertk.assertions.isNotNull
|
||||
import assertk.assertions.isNull
|
||||
import assertk.assertions.isTrue
|
||||
import assertk.assertions.prop
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFails
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import net.thunderbird.feature.notification.api.NotificationSeverity
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.BannerGlobalVisual
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_SUPPORTING_TEXT_LENGTH
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.BannerInlineVisual.Companion.MAX_TITLE_LENGTH
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.InAppNotificationHostState
|
||||
import net.thunderbird.feature.notification.api.ui.host.visual.SnackbarVisual
|
||||
import net.thunderbird.feature.notification.api.ui.style.inAppNotificationStyles
|
||||
import net.thunderbird.feature.notification.testing.fake.FakeInAppOnlyNotification
|
||||
import net.thunderbird.feature.notification.testing.fake.ui.action.createFakeNotificationAction
|
||||
|
||||
@Suppress("MaxLineLength", "LargeClass")
|
||||
class InAppNotificationHostStateHolderTest {
|
||||
@Test
|
||||
fun `showInAppNotification should show bannerGlobal when InAppNotification has BannerGlobalNotification style and BannerGlobalNotifications is enabled`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedContentText = "expected text"
|
||||
val expectedAction = createFakeNotificationAction()
|
||||
val expectedSeverity = NotificationSeverity.Warning
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
contentText = expectedContentText,
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerGlobal() },
|
||||
actions = setOf(expectedAction),
|
||||
severity = expectedSeverity,
|
||||
)
|
||||
val flags = persistentSetOf(DisplayInAppNotificationFlag.BannerGlobalNotifications)
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
testSubject.showInAppNotification(notification)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::bannerGlobalVisual)
|
||||
.isNotNull()
|
||||
.all {
|
||||
prop(BannerGlobalVisual::message)
|
||||
.isEqualTo(expectedContentText)
|
||||
prop(BannerGlobalVisual::action)
|
||||
.isEqualTo(expectedAction)
|
||||
prop(BannerGlobalVisual::severity)
|
||||
.isEqualTo(expectedSeverity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification should show bannerGlobal when InAppNotification has BannerGlobalNotification style and AllNotifications is enabled`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedContentText = "expected text"
|
||||
val expectedAction = createFakeNotificationAction()
|
||||
val expectedSeverity = NotificationSeverity.Warning
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
contentText = expectedContentText,
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerGlobal() },
|
||||
actions = setOf(expectedAction),
|
||||
severity = expectedSeverity,
|
||||
)
|
||||
val flags = DisplayInAppNotificationFlag.AllNotifications
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
testSubject.showInAppNotification(notification)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::bannerGlobalVisual)
|
||||
.isNotNull()
|
||||
.all {
|
||||
prop(BannerGlobalVisual::message)
|
||||
.isEqualTo(expectedContentText)
|
||||
prop(BannerGlobalVisual::action)
|
||||
.isEqualTo(expectedAction)
|
||||
prop(BannerGlobalVisual::severity)
|
||||
.isEqualTo(expectedSeverity)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification should not show bannerGlobal when InAppNotification has BannerGlobalNotification style but BannerGlobalNotifications is disabled`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerGlobal() },
|
||||
contentText = "not important in this test case",
|
||||
actions = setOf(createFakeNotificationAction()),
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
testSubject.showInAppNotification(notification)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::bannerGlobalVisual)
|
||||
.isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification throws IllegalStateException when InAppNotification has BannerGlobalNotification style but has multiple actions`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedMessage = "A notification with a BannerGlobalNotification style must have at zero or one action"
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerGlobal() },
|
||||
actions = setOf(
|
||||
createFakeNotificationAction(title = "fake action 1"),
|
||||
createFakeNotificationAction(title = "fake action 2"),
|
||||
),
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification throws IllegalStateException when InAppNotification has BannerGlobalNotification style but no contentText is configured`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedMessage =
|
||||
"A notification with a BannerGlobalNotification style must have a contentText not null"
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerGlobal() },
|
||||
contentText = null,
|
||||
actions = setOf(createFakeNotificationAction()),
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification should show bannerInline when InAppNotification has BannerInlineNotification style and BannerInlineNotifications is enabled`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedContentText = "expected text"
|
||||
val expectedAction = createFakeNotificationAction()
|
||||
val expectedSeverity = NotificationSeverity.Warning
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
contentText = expectedContentText,
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
actions = setOf(expectedAction),
|
||||
severity = expectedSeverity,
|
||||
)
|
||||
val flags = persistentSetOf(DisplayInAppNotificationFlag.BannerInlineNotifications)
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
testSubject.showInAppNotification(notification)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::bannerInlineVisuals)
|
||||
.all {
|
||||
isNotEmpty()
|
||||
hasSize(1)
|
||||
transform { it.single() }
|
||||
.all {
|
||||
prop(BannerInlineVisual::supportingText)
|
||||
.isEqualTo(expectedContentText)
|
||||
prop(BannerInlineVisual::actions).all {
|
||||
hasSize(1)
|
||||
contains(expectedAction)
|
||||
}
|
||||
prop(BannerInlineVisual::severity)
|
||||
.isEqualTo(expectedSeverity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification should show bannerInline when InAppNotification has BannerInlineNotification style and AllNotifications is enabled`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedContentText = "expected text"
|
||||
val expectedAction = createFakeNotificationAction()
|
||||
val expectedSeverity = NotificationSeverity.Warning
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
contentText = expectedContentText,
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
actions = setOf(expectedAction),
|
||||
severity = expectedSeverity,
|
||||
)
|
||||
val flags = DisplayInAppNotificationFlag.AllNotifications
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
testSubject.showInAppNotification(notification)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::bannerInlineVisuals)
|
||||
.all {
|
||||
isNotEmpty()
|
||||
hasSize(1)
|
||||
transform { it.single() }
|
||||
.all {
|
||||
prop(BannerInlineVisual::supportingText)
|
||||
.isEqualTo(expectedContentText)
|
||||
prop(BannerInlineVisual::actions).all {
|
||||
hasSize(1)
|
||||
contains(expectedAction)
|
||||
}
|
||||
prop(BannerInlineVisual::severity)
|
||||
.isEqualTo(expectedSeverity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification should not show bannerInline when InAppNotification has BannerInlineNotification style but BannerInlineNotifications is disabled`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
contentText = "not important in this test case",
|
||||
actions = setOf(createFakeNotificationAction()),
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
testSubject.showInAppNotification(notification)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::bannerInlineVisuals)
|
||||
.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification should not show duplicated banner inline notifications`() = runTest {
|
||||
// Arrange
|
||||
val expectedContentText = "expected text"
|
||||
val expectedAction = createFakeNotificationAction()
|
||||
val expectedSeverity = NotificationSeverity.Warning
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
contentText = expectedContentText,
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
actions = setOf(expectedAction),
|
||||
severity = expectedSeverity,
|
||||
)
|
||||
val flags = DisplayInAppNotificationFlag.AllNotifications
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
repeat(times = 100) {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::bannerInlineVisuals)
|
||||
.all {
|
||||
isNotEmpty()
|
||||
hasSize(1)
|
||||
transform { it.single() }
|
||||
.all {
|
||||
prop(BannerInlineVisual::supportingText)
|
||||
.isEqualTo(expectedContentText)
|
||||
prop(BannerInlineVisual::actions).all {
|
||||
hasSize(1)
|
||||
contains(expectedAction)
|
||||
}
|
||||
prop(BannerInlineVisual::severity)
|
||||
.isEqualTo(expectedSeverity)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification should show multiple bannerInlines when different BannerInlineNotification are triggered`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
fun getSeverity(index: Int): NotificationSeverity = when (index) {
|
||||
in 0..<25 -> NotificationSeverity.Critical
|
||||
in 25..<50 -> NotificationSeverity.Information
|
||||
in 50..<75 -> NotificationSeverity.Temporary
|
||||
else -> NotificationSeverity.Fatal
|
||||
}
|
||||
|
||||
fun getAction(index: Int): NotificationAction = when (index) {
|
||||
in 0..<25 -> createFakeNotificationAction(title = "fake action 1")
|
||||
in 25..<50 -> createFakeNotificationAction(title = "fake action 2")
|
||||
in 50..<75 -> createFakeNotificationAction(title = "fake action 3")
|
||||
else -> createFakeNotificationAction(title = "fake action 4")
|
||||
}
|
||||
|
||||
val expectedSize = 100
|
||||
val notifications = List(size = expectedSize) { index ->
|
||||
FakeInAppOnlyNotification(
|
||||
title = "fake title $index",
|
||||
contentText = "fake notification $index",
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
actions = setOf(getAction(index)),
|
||||
severity = getSeverity(index),
|
||||
)
|
||||
}
|
||||
val flags = DisplayInAppNotificationFlag.AllNotifications
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
notifications.forEach { notification ->
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::bannerInlineVisuals)
|
||||
.all {
|
||||
isNotEmpty()
|
||||
hasSize(expectedSize)
|
||||
given { visuals ->
|
||||
visuals.forEachIndexed { index, visual ->
|
||||
assertThat(visual).all {
|
||||
prop(BannerInlineVisual::title)
|
||||
.isEqualTo("fake title $index")
|
||||
prop(BannerInlineVisual::supportingText)
|
||||
.isEqualTo("fake notification $index")
|
||||
prop(BannerInlineVisual::severity)
|
||||
.isEqualTo(getSeverity(index))
|
||||
prop(BannerInlineVisual::actions).all {
|
||||
hasSize(1)
|
||||
containsExactly(getAction(index))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification throws IllegalStateException when InAppNotification has BannerInlineNotification style but no action is configured`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedMessage = "A notification with a BannerInlineNotification style must have at one or two actions"
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
contentText = "not important in this test case",
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification throws IllegalStateException when InAppNotification has BannerInlineNotification style with an empty title`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedMessage =
|
||||
"A notification with a BannerInlineNotification style must have a title length of 1 to " +
|
||||
"$MAX_TITLE_LENGTH characters."
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
title = "", // empty
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification throws IllegalStateException when InAppNotification has BannerInlineNotification style with a title longer than 100 chars`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedMessage =
|
||||
"A notification with a BannerInlineNotification style must have a title length of 1 to " +
|
||||
"$MAX_TITLE_LENGTH characters."
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
title = "*".repeat(101),
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification throws IllegalStateException when InAppNotification has BannerInlineNotification style with a null contentText`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedMessage =
|
||||
"A notification with a BannerInlineNotification style must have a contentText not null"
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
title = "fake title",
|
||||
contentText = null,
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification throws IllegalStateException when InAppNotification has BannerInlineNotification style with an empty contentText`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedMessage =
|
||||
"A notification with a BannerInlineNotification style must have a contentText length " +
|
||||
"of 1 to $MAX_SUPPORTING_TEXT_LENGTH characters."
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
title = "fake title",
|
||||
contentText = "", // empty
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification throws IllegalStateException when InAppNotification has BannerInlineNotification style with a contentText longer than 200 chars`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedMessage =
|
||||
"A notification with a BannerInlineNotification style must have a contentText length of " +
|
||||
"1 to $MAX_SUPPORTING_TEXT_LENGTH characters."
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
title = "fake title",
|
||||
contentText = "*".repeat(201),
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification throws IllegalStateException when InAppNotification has BannerInlineNotification style with more than 2 actions`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedMessage = "A notification with a BannerInlineNotification style must have at one or two actions"
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
contentText = "not important in this test case",
|
||||
actions = setOf(
|
||||
createFakeNotificationAction(title = "fake action 1"),
|
||||
createFakeNotificationAction(title = "fake action 2"),
|
||||
createFakeNotificationAction(title = "fake action 3"),
|
||||
),
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification should show snackbar when InAppNotification has SnackbarNotification style and SnackbarNotifications is enabled`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedContentText = "expected text"
|
||||
val expectedAction = createFakeNotificationAction()
|
||||
val expectedSeverity = NotificationSeverity.Warning
|
||||
val expectedDuration = 1.hours
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
contentText = expectedContentText,
|
||||
inAppNotificationStyles = inAppNotificationStyles { snackbar(expectedDuration) },
|
||||
actions = setOf(expectedAction),
|
||||
severity = expectedSeverity,
|
||||
)
|
||||
val flags = persistentSetOf(DisplayInAppNotificationFlag.SnackbarNotifications)
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
testSubject.showInAppNotification(notification)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::snackbarVisual)
|
||||
.isNotNull()
|
||||
.all {
|
||||
prop(SnackbarVisual::message)
|
||||
.isEqualTo(expectedContentText)
|
||||
prop(SnackbarVisual::action)
|
||||
.isEqualTo(expectedAction)
|
||||
prop(SnackbarVisual::duration)
|
||||
.isEqualTo(expectedDuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification should show snackbar when InAppNotification has SnackbarNotification style and AllNotifications is enabled`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedContentText = "expected text"
|
||||
val expectedAction = createFakeNotificationAction()
|
||||
val expectedSeverity = NotificationSeverity.Warning
|
||||
val expectedDuration = 1.hours
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
contentText = expectedContentText,
|
||||
inAppNotificationStyles = inAppNotificationStyles { snackbar(expectedDuration) },
|
||||
actions = setOf(expectedAction),
|
||||
severity = expectedSeverity,
|
||||
)
|
||||
val flags = DisplayInAppNotificationFlag.AllNotifications
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
testSubject.showInAppNotification(notification)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::snackbarVisual)
|
||||
.isNotNull()
|
||||
.all {
|
||||
prop(SnackbarVisual::message)
|
||||
.isEqualTo(expectedContentText)
|
||||
prop(SnackbarVisual::action)
|
||||
.isEqualTo(expectedAction)
|
||||
prop(SnackbarVisual::duration)
|
||||
.isEqualTo(expectedDuration)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification should not show snackbar when InAppNotification has SnackbarNotification style but SnackbarNotifications is disabled`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { snackbar() },
|
||||
contentText = "not important in this test case",
|
||||
actions = setOf(createFakeNotificationAction()),
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
testSubject.showInAppNotification(notification)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::snackbarVisual)
|
||||
.isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification throws IllegalStateException when InAppNotification has SnackbarNotification style but no action is configured`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedMessage = "A notification with a SnackbarNotification style must have exactly one action"
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { snackbar() },
|
||||
contentText = "not important in this test case",
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification throws IllegalStateException when InAppNotification has SnackbarNotification style but has multiple actions`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedMessage = "A notification with a SnackbarNotification style must have exactly one action"
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { snackbar() },
|
||||
actions = setOf(
|
||||
createFakeNotificationAction(title = "fake action 1"),
|
||||
createFakeNotificationAction(title = "fake action 2"),
|
||||
),
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showInAppNotification throws IllegalStateException when InAppNotification has SnackbarNotification style but no contentText is configured`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
val expectedMessage = "A notification with a SnackbarNotification style must have a contentText not null"
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { snackbar() },
|
||||
contentText = null,
|
||||
actions = setOf(createFakeNotificationAction()),
|
||||
)
|
||||
val flags = persistentSetOf<DisplayInAppNotificationFlag>()
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismiss should remove bannerGlobal notification given a BannerGlobalVisual`() = runTest {
|
||||
// Arrange
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerGlobal() },
|
||||
actions = setOf(createFakeNotificationAction()),
|
||||
)
|
||||
val visual = requireNotNull(BannerGlobalVisual.from(notification))
|
||||
val flags = persistentSetOf(DisplayInAppNotificationFlag.BannerGlobalNotifications)
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
testSubject.showInAppNotification(notification)
|
||||
|
||||
// Act
|
||||
testSubject.dismiss(visual)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::bannerGlobalVisual)
|
||||
.isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismiss should remove bannerInline notification given a BannerInlineVisual`() = runTest {
|
||||
// Arrange
|
||||
val expectedContentText = "expected text"
|
||||
val expectedAction = createFakeNotificationAction()
|
||||
val expectedSeverity = NotificationSeverity.Warning
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
contentText = expectedContentText,
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
actions = setOf(expectedAction),
|
||||
severity = expectedSeverity,
|
||||
)
|
||||
val visual = BannerInlineVisual.from(notification).first()
|
||||
val flags = DisplayInAppNotificationFlag.AllNotifications
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
testSubject.showInAppNotification(notification)
|
||||
|
||||
// Act
|
||||
testSubject.dismiss(visual)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::bannerInlineVisuals)
|
||||
.isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismiss should remove only one bannerInline notification when multiple bannerInline notifications are visible`() =
|
||||
runTest {
|
||||
// Arrange
|
||||
fun getSeverity(index: Int): NotificationSeverity = when (index) {
|
||||
in 0..<25 -> NotificationSeverity.Critical
|
||||
in 25..<50 -> NotificationSeverity.Information
|
||||
in 50..<75 -> NotificationSeverity.Temporary
|
||||
else -> NotificationSeverity.Fatal
|
||||
}
|
||||
|
||||
fun getAction(index: Int): NotificationAction = when (index) {
|
||||
in 0..<25 -> createFakeNotificationAction(title = "fake action 1")
|
||||
in 25..<50 -> createFakeNotificationAction(title = "fake action 2")
|
||||
in 50..<75 -> createFakeNotificationAction(title = "fake action 3")
|
||||
else -> createFakeNotificationAction(title = "fake action 4")
|
||||
}
|
||||
|
||||
val expectedSize = 99
|
||||
val notificationToDismiss = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
actions = setOf(createFakeNotificationAction()),
|
||||
)
|
||||
val visualToDismiss = BannerInlineVisual.from(notificationToDismiss).first()
|
||||
val notifications = List(size = expectedSize) { index ->
|
||||
FakeInAppOnlyNotification(
|
||||
title = "fake title $index",
|
||||
contentText = "fake notification $index",
|
||||
inAppNotificationStyles = inAppNotificationStyles { bannerInline() },
|
||||
actions = setOf(getAction(index)),
|
||||
severity = getSeverity(index),
|
||||
)
|
||||
} + notificationToDismiss
|
||||
val flags = DisplayInAppNotificationFlag.AllNotifications
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
notifications.forEach { notification ->
|
||||
testSubject.showInAppNotification(notification)
|
||||
}
|
||||
|
||||
// Act
|
||||
testSubject.dismiss(visualToDismiss)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::bannerInlineVisuals)
|
||||
.all {
|
||||
isNotEmpty()
|
||||
hasSize(expectedSize)
|
||||
given { visuals ->
|
||||
assertThat(visuals.none { it == visualToDismiss }).isTrue()
|
||||
visuals.forEachIndexed { index, visual ->
|
||||
assertThat(visual).all {
|
||||
prop(BannerInlineVisual::title)
|
||||
.isEqualTo("fake title $index")
|
||||
prop(BannerInlineVisual::supportingText)
|
||||
.isEqualTo("fake notification $index")
|
||||
prop(BannerInlineVisual::severity)
|
||||
.isEqualTo(getSeverity(index))
|
||||
prop(BannerInlineVisual::actions).all {
|
||||
hasSize(1)
|
||||
containsExactly(getAction(index))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dismiss should remove snackbar notification given a SnackbarVisual`() = runTest {
|
||||
// Arrange
|
||||
val notification = FakeInAppOnlyNotification(
|
||||
inAppNotificationStyles = inAppNotificationStyles { snackbar(10.seconds) },
|
||||
actions = setOf(createFakeNotificationAction()),
|
||||
)
|
||||
val visual = requireNotNull(SnackbarVisual.from(notification))
|
||||
val flags = persistentSetOf(DisplayInAppNotificationFlag.SnackbarNotifications)
|
||||
val testSubject = InAppNotificationHostStateHolder(enabled = flags)
|
||||
testSubject.showInAppNotification(notification)
|
||||
|
||||
// Act
|
||||
testSubject.dismiss(visual)
|
||||
|
||||
// Assert
|
||||
testSubject.currentInAppNotificationHostState.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state)
|
||||
.prop(InAppNotificationHostState::snackbarVisual)
|
||||
.isNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package net.thunderbird.feature.notification.api.ui.icon
|
||||
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isInstanceOf
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFails
|
||||
|
||||
class NotificationIconTest {
|
||||
@Test
|
||||
fun `NotificationIcon should throw IllegalStateException when both system and inApp icons are null`() {
|
||||
// Arrange
|
||||
val systemNotificationIcon: SystemNotificationIcon? = null
|
||||
val inAppNotificationIcon: ImageVector? = null
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
NotificationIcon(systemNotificationIcon, inAppNotificationIcon)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(
|
||||
"Both systemNotificationIcon and inAppNotificationIcon are null. " +
|
||||
"You must specify at least one type of icon.",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,193 @@
|
|||
package net.thunderbird.feature.notification.api.ui.style
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.containsExactly
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isInstanceOf
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFails
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.Duration.Companion.minutes
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
class InAppNotificationStyleTest {
|
||||
@Test
|
||||
fun `inAppNotificationStyle dsl should create a banner inline in-app notification style`() {
|
||||
// Arrange
|
||||
val expectedStyles = arrayOf<InAppNotificationStyle>(InAppNotificationStyle.BannerInlineNotification)
|
||||
|
||||
// Act
|
||||
val inAppStyles = inAppNotificationStyles {
|
||||
bannerInline()
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(inAppStyles).containsExactly(elements = expectedStyles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inAppNotificationStyle dsl should create a banner global in-app notification style`() {
|
||||
// Arrange
|
||||
val expectedStyles = arrayOf<InAppNotificationStyle>(
|
||||
InAppNotificationStyle.BannerGlobalNotification(priority = 0),
|
||||
)
|
||||
|
||||
// Act
|
||||
val inAppStyles = inAppNotificationStyles {
|
||||
bannerGlobal()
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(inAppStyles).containsExactly(elements = expectedStyles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inAppNotificationStyle dsl should create a snackbar in-app notification style`() {
|
||||
// Arrange
|
||||
val expectedStyles = arrayOf<InAppNotificationStyle>(InAppNotificationStyle.SnackbarNotification())
|
||||
|
||||
// Act
|
||||
val inAppStyles = inAppNotificationStyles {
|
||||
snackbar()
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(inAppStyles).containsExactly(elements = expectedStyles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inAppNotificationStyle dsl should create a snackbar with 30 seconds duration in-app notification style`() {
|
||||
// Arrange
|
||||
val duration = 30.seconds
|
||||
val expectedStyles = arrayOf<InAppNotificationStyle>(InAppNotificationStyle.SnackbarNotification(duration))
|
||||
|
||||
// Act
|
||||
val inAppStyles = inAppNotificationStyles {
|
||||
snackbar(duration = duration)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(inAppStyles).containsExactly(elements = expectedStyles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inAppNotificationStyle dsl should create a dialog in-app notification style`() {
|
||||
// Arrange
|
||||
val expectedStyles = arrayOf<InAppNotificationStyle>(InAppNotificationStyle.DialogNotification)
|
||||
|
||||
// Act
|
||||
val inAppStyles = inAppNotificationStyles {
|
||||
dialog()
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(inAppStyles).containsExactly(elements = expectedStyles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inAppNotificationStyle dsl should create multiple styles in-app notification style`() {
|
||||
// Arrange
|
||||
val expectedStyles = arrayOf(
|
||||
InAppNotificationStyle.BannerInlineNotification,
|
||||
InAppNotificationStyle.BannerGlobalNotification(priority = 0),
|
||||
InAppNotificationStyle.SnackbarNotification(),
|
||||
InAppNotificationStyle.DialogNotification,
|
||||
)
|
||||
|
||||
// Act
|
||||
val inAppStyles = inAppNotificationStyles {
|
||||
bannerInline()
|
||||
bannerGlobal()
|
||||
snackbar()
|
||||
dialog()
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(inAppStyles).containsExactly(elements = expectedStyles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inAppNotificationStyle dsl should throw IllegalStateException when bannerInline style is added multiple times`() {
|
||||
// Arrange
|
||||
val expectedErrorMessage =
|
||||
"An in-app notification can only have at most one type of ${
|
||||
InAppNotificationStyle.BannerInlineNotification::class.simpleName
|
||||
} style"
|
||||
|
||||
// Act
|
||||
val actual = assertFails {
|
||||
inAppNotificationStyles {
|
||||
bannerInline()
|
||||
bannerInline()
|
||||
bannerInline()
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(actual)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedErrorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inAppNotificationStyle dsl should throw IllegalStateException when bannerGlobal style is added multiple times`() {
|
||||
// Arrange
|
||||
val expectedErrorMessage =
|
||||
"An in-app notification can only have at most one type of ${
|
||||
InAppNotificationStyle.BannerGlobalNotification::class.simpleName
|
||||
} style"
|
||||
|
||||
// Act
|
||||
val actual = assertFails {
|
||||
inAppNotificationStyles {
|
||||
bannerGlobal()
|
||||
bannerGlobal()
|
||||
bannerGlobal()
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(actual)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedErrorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inAppNotificationStyle dsl should throw IllegalStateException when snackbar style is added multiple times`() {
|
||||
// Arrange
|
||||
val expectedErrorMessage =
|
||||
"An in-app notification can only have at most one type of ${
|
||||
InAppNotificationStyle.SnackbarNotification::class.simpleName
|
||||
} style"
|
||||
|
||||
// Act
|
||||
val actual = assertFails {
|
||||
inAppNotificationStyles {
|
||||
snackbar()
|
||||
snackbar(duration = 1.minutes)
|
||||
snackbar(duration = 1.hours)
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(actual)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage(expectedErrorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `inAppNotificationStyle dsl should throw IllegalStateException when in-app notification style is called without any style configuration`() {
|
||||
// Arrange & Act
|
||||
val exception = assertFails {
|
||||
inAppNotificationStyles {
|
||||
// intentionally empty.
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage("You must add at least one in-app notification style.")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
package net.thunderbird.feature.notification.api.ui.style
|
||||
|
||||
import assertk.assertThat
|
||||
import assertk.assertions.hasMessage
|
||||
import assertk.assertions.isEqualTo
|
||||
import assertk.assertions.isInstanceOf
|
||||
import assertk.assertions.prop
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertFails
|
||||
import net.thunderbird.feature.notification.api.ui.style.builder.MAX_LINES
|
||||
|
||||
@Suppress("MaxLineLength")
|
||||
class SystemNotificationStyleTest {
|
||||
@Test
|
||||
fun `systemNotificationStyle dsl should create inbox system notification style`() {
|
||||
// Arrange
|
||||
val title = "The title"
|
||||
val summary = "The summary"
|
||||
val expected = SystemNotificationStyle.InboxStyle(
|
||||
bigContentTitle = title,
|
||||
summary = summary,
|
||||
lines = listOf(),
|
||||
)
|
||||
|
||||
// Act
|
||||
val systemStyle = systemNotificationStyle {
|
||||
inbox {
|
||||
title(title)
|
||||
summary(summary)
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(systemStyle)
|
||||
.isInstanceOf<SystemNotificationStyle.InboxStyle>()
|
||||
.isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `systemNotificationStyle dsl should create inbox system notification style with multiple lines`() {
|
||||
// Arrange
|
||||
val title = "The title"
|
||||
val summary = "The summary"
|
||||
val contentLines = List(size = 5) {
|
||||
"line $it"
|
||||
}
|
||||
val expected = SystemNotificationStyle.InboxStyle(
|
||||
bigContentTitle = title,
|
||||
summary = summary,
|
||||
lines = contentLines,
|
||||
)
|
||||
|
||||
// Act
|
||||
val systemStyle = systemNotificationStyle {
|
||||
inbox {
|
||||
title(title)
|
||||
summary(summary)
|
||||
for (line in contentLines) {
|
||||
line(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(systemStyle)
|
||||
.isInstanceOf<SystemNotificationStyle.InboxStyle>()
|
||||
.isEqualTo(expected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `systemNotificationStyle dsl should create big text system notification style`() {
|
||||
// Arrange
|
||||
val bigText = "The ${"big ".repeat(n = 1000)}text"
|
||||
|
||||
// Act
|
||||
val systemStyle = systemNotificationStyle {
|
||||
bigText(bigText)
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(systemStyle)
|
||||
.isInstanceOf<SystemNotificationStyle.BigTextStyle>()
|
||||
.prop("text") { it.text }
|
||||
.isEqualTo(bigText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `systemNotificationStyle dsl should throw IllegalStateException when inbox system notification is missing title`() {
|
||||
// Arrange & Act
|
||||
val exception = assertFails {
|
||||
systemNotificationStyle {
|
||||
inbox {
|
||||
summary("summary")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage("The inbox notification's title is required")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `systemNotificationStyle dsl should throw IllegalStateException when inbox system notification is missing summary`() {
|
||||
// Arrange & Act
|
||||
val exception = assertFails {
|
||||
systemNotificationStyle {
|
||||
inbox {
|
||||
title("title")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage("The inbox notification's summary is required")
|
||||
}
|
||||
|
||||
@Suppress("VisibleForTests")
|
||||
@Test
|
||||
fun `systemNotificationStyle dsl should throw IllegalArgumentException when inbox system notification adds more then 5 lines`() {
|
||||
// Arrange
|
||||
val lines = List(size = MAX_LINES + 1) { "line $it" }
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
systemNotificationStyle {
|
||||
inbox {
|
||||
title("title")
|
||||
summary("summary")
|
||||
lines(lines = lines.toTypedArray())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalArgumentException>()
|
||||
.hasMessage("The maximum number of lines for a inbox notification is $MAX_LINES")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `systemNotificationStyle dsl should throw IllegalStateException when system notification style set both big text and inbox styles`() {
|
||||
// Arrange
|
||||
val bigText = "The ${"big ".repeat(n = 1000)}text"
|
||||
|
||||
// Act
|
||||
val exception = assertFails {
|
||||
systemNotificationStyle {
|
||||
bigText(bigText)
|
||||
inbox {
|
||||
title("title")
|
||||
summary("summary")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage("A system notification can either have a BigText or InboxStyle, not both.")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `systemNotificationStyle dsl should throw IllegalStateException when system notification style is called without any style configuration`() {
|
||||
// Arrange & Act
|
||||
val exception = assertFails {
|
||||
systemNotificationStyle {
|
||||
// intentionally empty.
|
||||
}
|
||||
}
|
||||
|
||||
// Assert
|
||||
assertThat(exception)
|
||||
.isInstanceOf<IllegalStateException>()
|
||||
.hasMessage("You must configure at least one of the following styles: bigText or inbox.")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
package net.thunderbird.feature.notification
|
||||
|
||||
internal actual val NotificationLight.defaultColorInt: Int
|
||||
get() = 0x00000000.toArgb()
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
package net.thunderbird.feature.notification.api.ui.action.icon
|
||||
|
||||
import net.thunderbird.feature.notification.api.ui.icon.NotificationIcon
|
||||
|
||||
private const val ERROR_MESSAGE = "Can't send notifications from a jvm library. Use android library or app instead."
|
||||
|
||||
internal actual val NotificationActionIcons.Reply: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationActionIcons.MarkAsRead: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationActionIcons.Delete: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationActionIcons.MarkAsSpam: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationActionIcons.Archive: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationActionIcons.UpdateServerSettings: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationActionIcons.Retry: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationActionIcons.DisablePushAction: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
package net.thunderbird.feature.notification.api.ui.icon
|
||||
|
||||
private const val ERROR_MESSAGE = "Can't send notifications from a jvm library. Use android library or app instead."
|
||||
|
||||
internal actual val NotificationIcons.AlarmPermissionMissing: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationIcons.AuthenticationError: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationIcons.CertificateError: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationIcons.FailedToCreate: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationIcons.MailFetching: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationIcons.MailSending: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationIcons.MailSendFailed: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationIcons.NewMailSingleMail: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationIcons.NewMailSummaryMail: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationIcons.PushServiceInitializing: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationIcons.PushServiceListening: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationIcons.PushServiceWaitBackgroundSync: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
internal actual val NotificationIcons.PushServiceWaitNetwork: NotificationIcon get() = error(ERROR_MESSAGE)
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package net.thunderbird.feature.notification.api.ui.icon
|
||||
|
||||
actual typealias SystemNotificationIcon = Int
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
package net.thunderbird.feature.notification.api.sender.compat;
|
||||
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
import kotlinx.coroutines.Dispatchers;
|
||||
import kotlinx.coroutines.test.TestCoroutineDispatchersKt;
|
||||
import kotlinx.coroutines.test.TestDispatcher;
|
||||
import kotlinx.coroutines.test.TestDispatchers;
|
||||
import net.thunderbird.core.outcome.Outcome;
|
||||
import net.thunderbird.feature.notification.api.command.NotificationCommand.Failure;
|
||||
import net.thunderbird.feature.notification.api.command.NotificationCommand.Success;
|
||||
import net.thunderbird.feature.notification.api.command.NotificationCommandException;
|
||||
import net.thunderbird.feature.notification.api.content.Notification;
|
||||
import net.thunderbird.feature.notification.testing.fake.FakeNotification;
|
||||
import net.thunderbird.feature.notification.testing.fake.command.FakeInAppNotificationCommand;
|
||||
import net.thunderbird.feature.notification.testing.fake.command.FakeSystemNotificationCommand;
|
||||
import net.thunderbird.feature.notification.testing.fake.sender.FakeNotificationSender;
|
||||
import org.jetbrains.annotations.NotNull;
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.spy;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
|
||||
public class NotificationSenderCompatJavaTest {
|
||||
private TestDispatcher testDispatcher;
|
||||
|
||||
@Before
|
||||
public void setUp() {
|
||||
testDispatcher = TestCoroutineDispatchersKt.UnconfinedTestDispatcher(null, null);
|
||||
TestDispatchers.setMain(Dispatchers.INSTANCE, testDispatcher);
|
||||
}
|
||||
|
||||
@After
|
||||
public void tearDown() {
|
||||
// restore original Main
|
||||
TestDispatchers.resetMain(Dispatchers.INSTANCE);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void send_shouldCallListenerCallback_wheneverAResultIsReceived() {
|
||||
// Arrange
|
||||
@SuppressWarnings("unchecked") final List<
|
||||
Outcome<
|
||||
? extends @NotNull Success<? extends @NotNull Notification>,
|
||||
? extends @NotNull Failure<? extends @NotNull Notification>
|
||||
>
|
||||
> expectedResults = List.of(
|
||||
Outcome.Companion.success(new Success<>(new FakeInAppNotificationCommand())),
|
||||
Outcome.Companion.success(new Success<>(new FakeSystemNotificationCommand())),
|
||||
Outcome.Companion.failure(
|
||||
new Failure<>(
|
||||
new FakeSystemNotificationCommand(),
|
||||
new NotificationCommandException("What an issue?")
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
|
||||
final FakeNotificationSender sender = new FakeNotificationSender(expectedResults);
|
||||
|
||||
final ResultListener listener = new ResultListener();
|
||||
final NotificationSenderCompat.OnResultListener spyListener = spy(listener);
|
||||
|
||||
final NotificationSenderCompat testSubject = new NotificationSenderCompat(sender, testDispatcher);
|
||||
|
||||
// Act
|
||||
testSubject.send(new FakeNotification(), spyListener);
|
||||
|
||||
// Assert
|
||||
verify(spyListener, times(expectedResults.size())).onResult(any());
|
||||
assertEquals(expectedResults, listener.actualResults);
|
||||
}
|
||||
|
||||
private static class ResultListener implements NotificationSenderCompat.OnResultListener {
|
||||
final ArrayList<
|
||||
Outcome<
|
||||
? extends @NotNull Success<? extends @NotNull Notification>,
|
||||
? extends @NotNull Failure<? extends @NotNull Notification>
|
||||
>
|
||||
> actualResults = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void onResult(
|
||||
@NotNull Outcome<? extends @NotNull Success<? extends @NotNull Notification>, ? extends @NotNull Failure<? extends @NotNull Notification>> outcome) {
|
||||
actualResults.add(outcome);
|
||||
}
|
||||
}
|
||||
}
|
||||
35
feature/notification/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
plugins {
|
||||
id(ThunderbirdPlugins.Library.kmpCompose)
|
||||
alias(libs.plugins.dev.mokkery)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(projects.core.common)
|
||||
implementation(projects.core.featureflag)
|
||||
implementation(projects.core.outcome)
|
||||
implementation(projects.core.logging.api)
|
||||
implementation(projects.feature.notification.api)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(projects.core.logging.testing)
|
||||
implementation(projects.feature.notification.testing)
|
||||
}
|
||||
androidUnitTest.dependencies {
|
||||
implementation(libs.androidx.test.core)
|
||||
implementation(libs.mockito.core)
|
||||
implementation(libs.mockito.kotlin)
|
||||
implementation(libs.robolectric)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "net.thunderbird.feature.notification"
|
||||
}
|
||||
|
||||
compose.resources {
|
||||
publicResClass = false
|
||||
packageOfResClass = "net.thunderbird.feature.notification.resources.impl"
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
<manifest
|
||||
xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
</manifest>
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
package net.thunderbird.feature.notification.impl.inject
|
||||
|
||||
import net.thunderbird.feature.notification.api.content.SystemNotification
|
||||
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.receiver.AndroidSystemNotificationNotifier
|
||||
import net.thunderbird.feature.notification.impl.receiver.SystemNotificationNotifier
|
||||
import net.thunderbird.feature.notification.impl.ui.action.DefaultSystemNotificationActionCreator
|
||||
import net.thunderbird.feature.notification.impl.ui.action.NotificationActionCreator
|
||||
import org.koin.android.ext.koin.androidApplication
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.qualifier.named
|
||||
import org.koin.dsl.module
|
||||
import org.koin.dsl.onClose
|
||||
|
||||
internal actual val platformFeatureNotificationModule: Module = module {
|
||||
single<List<NotificationActionIntentCreator<*, *>>>(named<NotificationActionIntentCreator.TypeQualifier>()) {
|
||||
listOf(
|
||||
AlarmPermissionMissingNotificationTapActionIntentCreator(
|
||||
context = androidApplication(),
|
||||
logger = get(),
|
||||
),
|
||||
// The Default implementation must always be the last.
|
||||
DefaultNotificationActionIntentCreator(
|
||||
logger = get(),
|
||||
applicationContext = androidApplication(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
single<NotificationActionCreator<SystemNotification>>(named(NotificationActionCreator.TypeQualifier.System)) {
|
||||
DefaultSystemNotificationActionCreator(
|
||||
logger = get(),
|
||||
actionIntentCreators = get(named<NotificationActionIntentCreator.TypeQualifier>()),
|
||||
)
|
||||
}
|
||||
|
||||
single<NotificationNotifier<SystemNotification>>(named<SystemNotificationNotifier>()) {
|
||||
AndroidSystemNotificationNotifier(
|
||||
logger = get(),
|
||||
applicationContext = androidApplication(),
|
||||
notificationActionCreator = get(named(NotificationActionCreator.TypeQualifier.System)),
|
||||
)
|
||||
}.onClose { notifier ->
|
||||
notifier?.dispose()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
package net.thunderbird.feature.notification.impl.intent.action
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import androidx.core.net.toUri
|
||||
import net.thunderbird.core.logging.Logger
|
||||
import net.thunderbird.feature.notification.api.content.Notification
|
||||
import net.thunderbird.feature.notification.api.content.PushServiceNotification
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
|
||||
private const val TAG = "AlarmPermissionMissingNotificationIntentCreator"
|
||||
|
||||
class AlarmPermissionMissingNotificationTapActionIntentCreator(
|
||||
private val context: Context,
|
||||
private val logger: Logger,
|
||||
) : NotificationActionIntentCreator<PushServiceNotification.AlarmPermissionMissing, NotificationAction.Tap> {
|
||||
override fun accept(notification: Notification, action: NotificationAction): Boolean =
|
||||
Build.VERSION.SDK_INT > Build.VERSION_CODES.S &&
|
||||
notification is PushServiceNotification.AlarmPermissionMissing
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.S)
|
||||
override fun create(
|
||||
notification: PushServiceNotification.AlarmPermissionMissing,
|
||||
action: NotificationAction.Tap,
|
||||
): PendingIntent {
|
||||
logger.debug(TAG) { "create() called with: notification = $notification" }
|
||||
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
|
||||
data = "package:${context.packageName}".toUri()
|
||||
}
|
||||
|
||||
return requireNotNull(
|
||||
PendingIntentCompat.getActivity(
|
||||
/* context = */
|
||||
context,
|
||||
/* requestCode = */
|
||||
1,
|
||||
/* intent = */
|
||||
intent,
|
||||
/* flags = */
|
||||
0,
|
||||
/* isMutable = */
|
||||
false,
|
||||
),
|
||||
) {
|
||||
"Could not create PendingIntent for AlarmPermissionMissing Notification."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
package net.thunderbird.feature.notification.impl.intent.action
|
||||
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import androidx.core.app.PendingIntentCompat
|
||||
import net.thunderbird.core.logging.Logger
|
||||
import net.thunderbird.feature.notification.api.content.Notification
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
|
||||
private const val TAG = "DefaultNotificationActionIntentCreator"
|
||||
|
||||
/**
|
||||
* A default implementation of [NotificationActionIntentCreator] that creates a [PendingIntent]
|
||||
* to launch the application when a notification action is triggered.
|
||||
*
|
||||
* This creator accepts any [NotificationAction] and always attempts to create a launch intent
|
||||
* for the current application.
|
||||
*
|
||||
* @property logger The logger instance for logging debug messages.
|
||||
* @property applicationContext The application context used to access system services like PackageManager.
|
||||
*/
|
||||
internal class DefaultNotificationActionIntentCreator(
|
||||
private val logger: Logger,
|
||||
private val applicationContext: Context,
|
||||
) : NotificationActionIntentCreator<Notification, NotificationAction> {
|
||||
override fun accept(notification: Notification, action: NotificationAction): Boolean = true
|
||||
|
||||
override fun create(notification: Notification, action: NotificationAction): PendingIntent? {
|
||||
logger.debug(TAG) { "create() called with: notification = $notification, action = $action" }
|
||||
val packageManager = applicationContext.packageManager
|
||||
val launchIntent = requireNotNull(
|
||||
packageManager.getLaunchIntentForPackage(applicationContext.packageName),
|
||||
) {
|
||||
"Could not retrieve the launch intent from ${applicationContext.packageName}"
|
||||
}
|
||||
|
||||
return PendingIntentCompat.getActivity(
|
||||
/* context = */
|
||||
applicationContext,
|
||||
/* requestCode = */
|
||||
1,
|
||||
/* intent = */
|
||||
launchIntent,
|
||||
/* flags = */
|
||||
0,
|
||||
/* isMutable = */
|
||||
false,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
package net.thunderbird.feature.notification.impl.intent.action
|
||||
|
||||
import android.app.PendingIntent
|
||||
import net.thunderbird.feature.notification.api.content.Notification
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
|
||||
/**
|
||||
* Interface for creating a [PendingIntent] for a given [NotificationAction].
|
||||
*
|
||||
* This interface is used to decouple the creation of [PendingIntent]s from the notification creation logic.
|
||||
* Implementations of this interface should be registered in the Koin graph using the [TypeQualifier].
|
||||
*
|
||||
* @param TNotificationAction The type of [NotificationAction] this creator can handle.
|
||||
*/
|
||||
internal interface NotificationActionIntentCreator<
|
||||
in TNotification : Notification,
|
||||
in TNotificationAction : NotificationAction,
|
||||
> {
|
||||
/**
|
||||
* Determines whether this [NotificationActionIntentCreator] can create an intent for the given [action].
|
||||
*
|
||||
* @param action The [NotificationAction] to check.
|
||||
* @return `true` if this creator can handle the [action], `false` otherwise.
|
||||
*/
|
||||
fun accept(notification: Notification, action: NotificationAction): Boolean
|
||||
|
||||
/**
|
||||
* Creates a [PendingIntent] for the given notification action.
|
||||
*
|
||||
* @param action The notification action to create an intent for.
|
||||
* @return The created [PendingIntent], or `null` if the action is not supported or an error occurs.
|
||||
*/
|
||||
fun create(notification: TNotification, action: TNotificationAction): PendingIntent?
|
||||
|
||||
object TypeQualifier
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
package net.thunderbird.feature.notification.impl.receiver
|
||||
|
||||
import android.app.Notification
|
||||
import android.content.Context
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import kotlin.time.ExperimentalTime
|
||||
import kotlinx.datetime.TimeZone
|
||||
import kotlinx.datetime.toInstant
|
||||
import net.thunderbird.core.logging.Logger
|
||||
import net.thunderbird.feature.notification.api.NotificationId
|
||||
import net.thunderbird.feature.notification.api.content.SystemNotification
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
import net.thunderbird.feature.notification.api.ui.style.SystemNotificationStyle
|
||||
import net.thunderbird.feature.notification.impl.ui.action.NotificationActionCreator
|
||||
|
||||
private const val TAG = "AndroidSystemNotificationNotifier"
|
||||
|
||||
@OptIn(ExperimentalTime::class)
|
||||
internal class AndroidSystemNotificationNotifier(
|
||||
private val logger: Logger,
|
||||
private val applicationContext: Context,
|
||||
private val notificationActionCreator: NotificationActionCreator<SystemNotification>,
|
||||
) : SystemNotificationNotifier {
|
||||
private val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(applicationContext)
|
||||
|
||||
override suspend fun show(
|
||||
id: NotificationId,
|
||||
notification: SystemNotification,
|
||||
) {
|
||||
logger.debug(TAG) { "show() called with: id = $id, notification = $notification" }
|
||||
val androidNotification = notification.toAndroidNotification()
|
||||
notificationManager.notify(id.value, androidNotification)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
logger.debug(TAG) { "dispose() called" }
|
||||
}
|
||||
|
||||
private suspend fun SystemNotification.toAndroidNotification(): Notification {
|
||||
logger.debug(TAG) { "toAndroidNotification() called with systemNotification = $this" }
|
||||
val systemNotification = this
|
||||
return NotificationCompat
|
||||
.Builder(applicationContext, channel.id)
|
||||
.apply {
|
||||
setSmallIcon(
|
||||
checkNotNull(icon.systemNotificationIcon) {
|
||||
"A icon is required to display a system notification"
|
||||
},
|
||||
)
|
||||
setContentTitle(title)
|
||||
setTicker(accessibilityText)
|
||||
contentText?.let(::setContentText)
|
||||
subText?.let(::setSubText)
|
||||
setOngoing(severity.dismissable.not())
|
||||
setWhen(createdAt.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds())
|
||||
asLockscreenNotification()?.let { lockscreenNotification ->
|
||||
if (lockscreenNotification.notification != systemNotification) {
|
||||
setPublicVersion(lockscreenNotification.notification.toAndroidNotification())
|
||||
}
|
||||
}
|
||||
|
||||
val tapAction = notificationActionCreator.create(
|
||||
notification = systemNotification,
|
||||
action = NotificationAction.Tap,
|
||||
)
|
||||
setContentIntent(tapAction.pendingIntent)
|
||||
|
||||
setNotificationStyle(notification = systemNotification)
|
||||
|
||||
if (actions.isNotEmpty()) {
|
||||
for (action in actions) {
|
||||
val notificationAction = notificationActionCreator
|
||||
.create(notification = systemNotification, action)
|
||||
|
||||
addAction(
|
||||
/* icon = */
|
||||
notificationAction.icon ?: 0,
|
||||
/* title = */
|
||||
notificationAction.title,
|
||||
/* intent = */
|
||||
notificationAction.pendingIntent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun NotificationCompat.Builder.setNotificationStyle(
|
||||
notification: SystemNotification,
|
||||
) {
|
||||
when (val style = notification.systemNotificationStyle) {
|
||||
is SystemNotificationStyle.BigTextStyle -> setStyle(
|
||||
NotificationCompat.BigTextStyle().bigText(style.text),
|
||||
)
|
||||
|
||||
is SystemNotificationStyle.InboxStyle -> {
|
||||
val inboxStyle = NotificationCompat.InboxStyle()
|
||||
.setBigContentTitle(style.bigContentTitle)
|
||||
.setSummaryText(style.summary)
|
||||
|
||||
style.lines.forEach(inboxStyle::addLine)
|
||||
|
||||
setStyle(inboxStyle)
|
||||
}
|
||||
|
||||
SystemNotificationStyle.Undefined -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
package net.thunderbird.feature.notification.impl.ui.action
|
||||
|
||||
import android.app.PendingIntent
|
||||
import androidx.annotation.DrawableRes
|
||||
|
||||
/**
|
||||
* Represents an action that can be performed on an Android notification.
|
||||
*
|
||||
* @property icon The drawable resource ID for the action's icon.
|
||||
* @property title The title of the action.
|
||||
* @property pendingIntent The [PendingIntent] to be executed when the action is triggered.
|
||||
*/
|
||||
data class AndroidNotificationAction(
|
||||
@param:DrawableRes
|
||||
val icon: Int?,
|
||||
val title: String?,
|
||||
val pendingIntent: PendingIntent?,
|
||||
)
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package net.thunderbird.feature.notification.impl.ui.action
|
||||
|
||||
import net.thunderbird.core.logging.Logger
|
||||
import net.thunderbird.feature.notification.api.content.Notification
|
||||
import net.thunderbird.feature.notification.api.content.SystemNotification
|
||||
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
|
||||
import net.thunderbird.feature.notification.impl.intent.action.NotificationActionIntentCreator
|
||||
|
||||
private const val TAG = "DefaultSystemNotificationActionCreator"
|
||||
|
||||
internal class DefaultSystemNotificationActionCreator(
|
||||
private val logger: Logger,
|
||||
private val actionIntentCreators: List<NotificationActionIntentCreator<Notification, NotificationAction>>,
|
||||
) : NotificationActionCreator<SystemNotification> {
|
||||
override suspend fun create(
|
||||
notification: SystemNotification,
|
||||
action: NotificationAction,
|
||||
): AndroidNotificationAction {
|
||||
logger.debug(TAG) { "create() called with: notification = $notification, action = $action" }
|
||||
val intent = actionIntentCreators
|
||||
.first { it.accept(notification, action) }
|
||||
.create(notification, action)
|
||||
|
||||
return AndroidNotificationAction(
|
||||
icon = action.icon?.systemNotificationIcon,
|
||||
title = action.resolveTitle(),
|
||||
pendingIntent = intent,
|
||||
)
|
||||
}
|
||||
}
|
||||