Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:56:56 +01:00
parent 75dc487a7a
commit 39c29d175b
6317 changed files with 388324 additions and 2 deletions

View 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"
}

View file

@ -0,0 +1,6 @@
package net.thunderbird.feature.notification
import android.app.Notification
internal actual val NotificationLight.defaultColorInt: Int
get() = Notification.COLOR_DEFAULT

View file

@ -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"
}

View file

@ -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,
)
}
}

View file

@ -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,
)

View file

@ -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)
}
}
}

View file

@ -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,
)

View file

@ -0,0 +1,3 @@
package net.thunderbird.feature.notification.api.ui.icon
actual typealias SystemNotificationIcon = Int

Binary file not shown.

After

Width:  |  Height:  |  Size: 839 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 812 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 788 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 820 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 800 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 805 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 561 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 556 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 566 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 551 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 572 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1,017 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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>

View file

@ -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()
}
}

View file

@ -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>

View file

@ -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

View file

@ -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,
)

View file

@ -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
}
}
}

View file

@ -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")
}
}
}

View file

@ -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
}

View file

@ -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,
}

View file

@ -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,
)

View file

@ -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)

View file

@ -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

View file

@ -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)
}

View file

@ -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),
}

View file

@ -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
}

View file

@ -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)

View file

@ -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
}

View file

@ -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),
)
}
}

View file

@ -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),
)
}
}

View file

@ -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,
)
}
}

View file

@ -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,
)
}
}
}

View file

@ -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,
),
)

View file

@ -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
}

View file

@ -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()
}

View file

@ -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)
}
}

View file

@ -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>>>
}

View file

@ -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>>)
}
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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()
}
}

View file

@ -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) }
}

View file

@ -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?
}

View file

@ -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)
}
}

View file

@ -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."
}
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()
}

View file

@ -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

View file

@ -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()
}

View file

@ -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 screens 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 users 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 users 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 users 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"
}
}
}

View file

@ -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(),
)
}

View file

@ -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.")
}
}
}

View file

@ -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
}

View file

@ -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())
}
}

View file

@ -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()
}
}
}

View file

@ -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.",
)
}
}

View file

@ -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.")
}
}

View file

@ -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.")
}
}

View file

@ -0,0 +1,4 @@
package net.thunderbird.feature.notification
internal actual val NotificationLight.defaultColorInt: Int
get() = 0x00000000.toArgb()

View file

@ -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)

View file

@ -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)

View file

@ -0,0 +1,3 @@
package net.thunderbird.feature.notification.api.ui.icon
actual typealias SystemNotificationIcon = Int

View file

@ -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);
}
}
}

View 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"
}

View file

@ -0,0 +1,6 @@
<manifest
xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
</manifest>

View file

@ -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()
}
}

View file

@ -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."
}
}
}

View file

@ -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,
)
}
}

View file

@ -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
}

View file

@ -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
}
}
}

View file

@ -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?,
)

View file

@ -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,
)
}
}

Some files were not shown because too many files have changed in this diff Show more