Skip to content

Commit c2a3d9b

Browse files
committed
feat(notification): trigger system notification to the system
1 parent ed604ff commit c2a3d9b

File tree

16 files changed

+224
-87
lines changed

16 files changed

+224
-87
lines changed

feature/notification/api/src/commonMain/kotlin/net/thunderbird/feature/notification/api/NotificationSeverity.kt

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ package net.thunderbird.feature.notification.api
1212
* For [Temporary] and [Warning], user action might be recommended or optional.
1313
* For [Information], no user action is usually needed.
1414
*/
15-
enum class NotificationSeverity {
15+
enum class NotificationSeverity(val dismissable: Boolean) {
1616
/**
1717
* Completely blocks the user from performing essential tasks or accessing core functionality.
1818
*
@@ -24,7 +24,7 @@ enum class NotificationSeverity {
2424
* - Retry
2525
* - Provide other credentials
2626
*/
27-
Fatal,
27+
Fatal(dismissable = false),
2828

2929
/**
3030
* Prevents the user from completing specific core actions or causes significant disruption to functionality.
@@ -36,7 +36,7 @@ enum class NotificationSeverity {
3636
* - **Notification Actions:**
3737
* - Retry
3838
*/
39-
Critical,
39+
Critical(dismissable = false),
4040

4141
/**
4242
* Causes a temporary disruption or delay to functionality, which may resolve on its own.
@@ -48,7 +48,7 @@ enum class NotificationSeverity {
4848
* - **Notification Message:** You are offline, the message will be sent later.
4949
* - **Notification Actions:** N/A
5050
*/
51-
Temporary,
51+
Temporary(dismissable = true),
5252

5353
/**
5454
* Alerts the user to a potential issue or limitation that may affect functionality if not addressed.
@@ -61,7 +61,7 @@ enum class NotificationSeverity {
6161
* - **Notification Actions:**
6262
* - Manage Storage
6363
*/
64-
Warning,
64+
Warning(dismissable = true),
6565

6666
/**
6767
* Provides status or context without impacting functionality or requiring action.
@@ -72,5 +72,5 @@ enum class NotificationSeverity {
7272
* - **Notification Message:** Last time email synchronization succeeded
7373
* - **Notification Actions:** N/A
7474
*/
75-
Information,
75+
Information(dismissable = true),
7676
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<manifest
2+
xmlns:android="http://schemas.android.com/apk/res/android">
3+
4+
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
5+
6+
</manifest>

feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/inject/NotificationModule.android.kt

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,28 @@
11
package net.thunderbird.feature.notification.impl.inject
22

33
import net.thunderbird.feature.notification.api.content.SystemNotification
4+
import net.thunderbird.feature.notification.api.receiver.NotificationNotifier
5+
import net.thunderbird.feature.notification.impl.intent.action.AlarmPermissionMissingNotificationTapActionIntentCreator
46
import net.thunderbird.feature.notification.impl.intent.action.DefaultNotificationActionIntentCreator
57
import net.thunderbird.feature.notification.impl.intent.action.NotificationActionIntentCreator
8+
import net.thunderbird.feature.notification.impl.receiver.AndroidSystemNotificationNotifier
9+
import net.thunderbird.feature.notification.impl.receiver.SystemNotificationNotifier
610
import net.thunderbird.feature.notification.impl.ui.action.DefaultSystemNotificationActionCreator
711
import net.thunderbird.feature.notification.impl.ui.action.NotificationActionCreator
812
import org.koin.android.ext.koin.androidApplication
913
import org.koin.core.module.Module
1014
import org.koin.core.qualifier.named
1115
import org.koin.dsl.module
16+
import org.koin.dsl.onClose
1217

1318
internal actual val platformFeatureNotificationModule: Module = module {
14-
single<List<NotificationActionIntentCreator<*>>>(named<NotificationActionIntentCreator.TypeQualifier>()) {
19+
single<List<NotificationActionIntentCreator<*, *>>>(named<NotificationActionIntentCreator.TypeQualifier>()) {
1520
listOf(
21+
AlarmPermissionMissingNotificationTapActionIntentCreator(
22+
context = androidApplication(),
23+
logger = get(),
24+
),
25+
// The Default implementation must always be the last.
1626
DefaultNotificationActionIntentCreator(
1727
logger = get(),
1828
applicationContext = androidApplication(),
@@ -26,4 +36,14 @@ internal actual val platformFeatureNotificationModule: Module = module {
2636
actionIntentCreators = get(named<NotificationActionIntentCreator.TypeQualifier>()),
2737
)
2838
}
39+
40+
single<NotificationNotifier<SystemNotification>>(named<SystemNotificationNotifier>()) {
41+
AndroidSystemNotificationNotifier(
42+
logger = get(),
43+
applicationContext = androidApplication(),
44+
notificationActionCreator = get(named(NotificationActionCreator.TypeQualifier.System)),
45+
)
46+
}.onClose { notifier ->
47+
notifier?.dispose()
48+
}
2949
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package net.thunderbird.feature.notification.impl.intent.action
2+
3+
import android.app.PendingIntent
4+
import android.content.Context
5+
import android.content.Intent
6+
import android.os.Build
7+
import android.provider.Settings
8+
import androidx.annotation.RequiresApi
9+
import androidx.core.app.PendingIntentCompat
10+
import androidx.core.net.toUri
11+
import net.thunderbird.core.logging.Logger
12+
import net.thunderbird.feature.notification.api.content.Notification
13+
import net.thunderbird.feature.notification.api.content.PushServiceNotification
14+
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
15+
16+
private const val TAG = "AlarmPermissionMissingNotificationIntentCreator"
17+
18+
class AlarmPermissionMissingNotificationTapActionIntentCreator(
19+
private val context: Context,
20+
private val logger: Logger,
21+
) : NotificationActionIntentCreator<PushServiceNotification.AlarmPermissionMissing, NotificationAction.Tap> {
22+
override fun accept(notification: Notification, action: NotificationAction): Boolean =
23+
Build.VERSION.SDK_INT > Build.VERSION_CODES.S &&
24+
notification is PushServiceNotification.AlarmPermissionMissing
25+
26+
@RequiresApi(Build.VERSION_CODES.S)
27+
override fun create(
28+
notification: PushServiceNotification.AlarmPermissionMissing,
29+
action: NotificationAction.Tap,
30+
): PendingIntent {
31+
logger.debug(TAG) { "create() called with: notification = $notification" }
32+
val intent = Intent(Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM).apply {
33+
data = "package:${context.packageName}".toUri()
34+
}
35+
36+
return requireNotNull(
37+
PendingIntentCompat.getActivity(
38+
/* context = */
39+
context,
40+
/* requestCode = */
41+
1,
42+
/* intent = */
43+
intent,
44+
/* flags = */
45+
0,
46+
/* isMutable = */
47+
false,
48+
),
49+
) {
50+
"Could not create PendingIntent for AlarmPermissionMissing Notification."
51+
}
52+
}
53+
}

feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreator.kt

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.app.PendingIntent
44
import android.content.Context
55
import androidx.core.app.PendingIntentCompat
66
import net.thunderbird.core.logging.Logger
7+
import net.thunderbird.feature.notification.api.content.Notification
78
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
89

910
private const val TAG = "DefaultNotificationActionIntentCreator"
@@ -21,11 +22,11 @@ private const val TAG = "DefaultNotificationActionIntentCreator"
2122
internal class DefaultNotificationActionIntentCreator(
2223
private val logger: Logger,
2324
private val applicationContext: Context,
24-
) : NotificationActionIntentCreator<NotificationAction> {
25-
override fun accept(action: NotificationAction): Boolean = true
25+
) : NotificationActionIntentCreator<Notification, NotificationAction> {
26+
override fun accept(notification: Notification, action: NotificationAction): Boolean = true
2627

27-
override fun create(action: NotificationAction): PendingIntent? {
28-
logger.debug(TAG) { "create() called with: action = $action" }
28+
override fun create(notification: Notification, action: NotificationAction): PendingIntent? {
29+
logger.debug(TAG) { "create() called with: notification = $notification, action = $action" }
2930
val packageManager = applicationContext.packageManager
3031
val launchIntent = requireNotNull(
3132
packageManager.getLaunchIntentForPackage(applicationContext.packageName),
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package net.thunderbird.feature.notification.impl.intent.action
22

33
import android.app.PendingIntent
4+
import net.thunderbird.feature.notification.api.content.Notification
45
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
56

67
/**
@@ -11,22 +12,25 @@ import net.thunderbird.feature.notification.api.ui.action.NotificationAction
1112
*
1213
* @param TNotificationAction The type of [NotificationAction] this creator can handle.
1314
*/
14-
internal interface NotificationActionIntentCreator<in TNotificationAction : NotificationAction> {
15+
internal interface NotificationActionIntentCreator<
16+
in TNotification : Notification,
17+
in TNotificationAction : NotificationAction,
18+
> {
1519
/**
1620
* Determines whether this [NotificationActionIntentCreator] can create an intent for the given [action].
1721
*
1822
* @param action The [NotificationAction] to check.
1923
* @return `true` if this creator can handle the [action], `false` otherwise.
2024
*/
21-
fun accept(action: NotificationAction): Boolean
25+
fun accept(notification: Notification, action: NotificationAction): Boolean
2226

2327
/**
2428
* Creates a [PendingIntent] for the given notification action.
2529
*
2630
* @param action The notification action to create an intent for.
2731
* @return The created [PendingIntent], or `null` if the action is not supported or an error occurs.
2832
*/
29-
fun create(action: TNotificationAction): PendingIntent?
33+
fun create(notification: TNotification, action: TNotificationAction): PendingIntent?
3034

3135
object TypeQualifier
3236
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
package net.thunderbird.feature.notification.impl.receiver
2+
3+
import android.app.Notification
4+
import android.content.Context
5+
import androidx.core.app.NotificationCompat
6+
import androidx.core.app.NotificationManagerCompat
7+
import kotlin.time.ExperimentalTime
8+
import kotlinx.datetime.TimeZone
9+
import kotlinx.datetime.toInstant
10+
import net.thunderbird.core.logging.Logger
11+
import net.thunderbird.feature.notification.api.NotificationId
12+
import net.thunderbird.feature.notification.api.content.SystemNotification
13+
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
14+
import net.thunderbird.feature.notification.api.ui.style.SystemNotificationStyle
15+
import net.thunderbird.feature.notification.impl.ui.action.NotificationActionCreator
16+
17+
private const val TAG = "AndroidSystemNotificationNotifier"
18+
19+
@OptIn(ExperimentalTime::class)
20+
internal class AndroidSystemNotificationNotifier(
21+
private val logger: Logger,
22+
private val applicationContext: Context,
23+
private val notificationActionCreator: NotificationActionCreator<SystemNotification>,
24+
) : SystemNotificationNotifier {
25+
private val notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(applicationContext)
26+
27+
override suspend fun show(
28+
id: NotificationId,
29+
notification: SystemNotification,
30+
) {
31+
logger.debug(TAG) { "show() called with: id = $id, notification = $notification" }
32+
val androidNotification = notification.toAndroidNotification()
33+
notificationManager.notify(id.value, androidNotification)
34+
}
35+
36+
override fun dispose() {
37+
logger.debug(TAG) { "dispose() called" }
38+
}
39+
40+
private suspend fun SystemNotification.toAndroidNotification(): Notification {
41+
logger.debug(TAG) { "toAndroidNotification() called with systemNotification = $this" }
42+
val systemNotification = this
43+
return NotificationCompat
44+
.Builder(applicationContext, channel.id)
45+
.apply {
46+
setSmallIcon(
47+
checkNotNull(icon.systemNotificationIcon) {
48+
"A icon is required to display a system notification"
49+
},
50+
)
51+
setContentTitle(title)
52+
setTicker(accessibilityText)
53+
contentText?.let(::setContentText)
54+
subText?.let(::setSubText)
55+
setOngoing(severity.dismissable.not())
56+
setWhen(createdAt.toInstant(TimeZone.currentSystemDefault()).toEpochMilliseconds())
57+
asLockscreenNotification()?.let { lockscreenNotification ->
58+
if (lockscreenNotification.notification != systemNotification) {
59+
setPublicVersion(lockscreenNotification.notification.toAndroidNotification())
60+
}
61+
}
62+
63+
val tapAction = notificationActionCreator.create(
64+
notification = systemNotification,
65+
action = NotificationAction.Tap,
66+
)
67+
setContentIntent(tapAction.pendingIntent)
68+
69+
setNotificationStyle(notification = systemNotification)
70+
71+
if (actions.isNotEmpty()) {
72+
for (action in actions) {
73+
val notificationAction = notificationActionCreator
74+
.create(notification = systemNotification, action)
75+
76+
addAction(
77+
/* icon = */
78+
notificationAction.icon ?: 0,
79+
/* title = */
80+
notificationAction.title,
81+
/* intent = */
82+
notificationAction.pendingIntent,
83+
)
84+
}
85+
}
86+
}
87+
.build()
88+
}
89+
90+
private fun NotificationCompat.Builder.setNotificationStyle(
91+
notification: SystemNotification,
92+
) {
93+
when (val style = notification.systemNotificationStyle) {
94+
is SystemNotificationStyle.BigTextStyle -> setStyle(
95+
NotificationCompat.BigTextStyle().bigText(style.text),
96+
)
97+
98+
is SystemNotificationStyle.InboxStyle -> {
99+
val inboxStyle = NotificationCompat.InboxStyle()
100+
.setBigContentTitle(style.bigContentTitle)
101+
.setSummaryText(style.summary)
102+
103+
style.lines.forEach(inboxStyle::addLine)
104+
105+
setStyle(inboxStyle)
106+
}
107+
108+
SystemNotificationStyle.Undefined -> Unit
109+
}
110+
}
111+
}

feature/notification/impl/src/androidMain/kotlin/net/thunderbird/feature/notification/impl/ui/action/DefaultSystemNotificationActionCreator.kt

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package net.thunderbird.feature.notification.impl.ui.action
22

33
import net.thunderbird.core.logging.Logger
4+
import net.thunderbird.feature.notification.api.content.Notification
45
import net.thunderbird.feature.notification.api.content.SystemNotification
56
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
67
import net.thunderbird.feature.notification.impl.intent.action.NotificationActionIntentCreator
@@ -9,16 +10,16 @@ private const val TAG = "DefaultSystemNotificationActionCreator"
910

1011
internal class DefaultSystemNotificationActionCreator(
1112
private val logger: Logger,
12-
private val actionIntentCreators: List<NotificationActionIntentCreator<NotificationAction>>,
13+
private val actionIntentCreators: List<NotificationActionIntentCreator<Notification, NotificationAction>>,
1314
) : NotificationActionCreator<SystemNotification> {
1415
override suspend fun create(
1516
notification: SystemNotification,
1617
action: NotificationAction,
1718
): AndroidNotificationAction {
1819
logger.debug(TAG) { "create() called with: notification = $notification, action = $action" }
1920
val intent = actionIntentCreators
20-
.first { it.accept(action) }
21-
.create(action)
21+
.first { it.accept(notification, action) }
22+
.create(notification, action)
2223

2324
return AndroidNotificationAction(
2425
icon = action.icon?.systemNotificationIcon,

feature/notification/impl/src/androidUnitTest/kotlin/net/thunderbird/feature/notification/impl/intent/action/DefaultNotificationActionIntentCreatorTest.kt

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import assertk.assertions.isTrue
1414
import assertk.assertions.prop
1515
import net.thunderbird.core.logging.testing.TestLogger
1616
import net.thunderbird.feature.notification.api.ui.action.NotificationAction
17+
import net.thunderbird.feature.notification.testing.fake.FakeNotification
1718
import org.junit.Test
1819
import org.junit.runner.RunWith
1920
import org.mockito.Mockito.mockStatic
@@ -49,7 +50,7 @@ class DefaultNotificationActionIntentCreatorTest {
4950

5051
// Act
5152
val accepted = multipleActions.fold(initial = true) { accepted, action ->
52-
accepted and testSubject.accept(action)
53+
accepted and testSubject.accept(notification = FakeNotification(), action)
5354
}
5455

5556
// Assert
@@ -95,7 +96,7 @@ class DefaultNotificationActionIntentCreatorTest {
9596
val testSubject = createTestSubject(context)
9697

9798
// Act
98-
testSubject.create(NotificationAction.Tap)
99+
testSubject.create(notification = FakeNotification(), action = NotificationAction.Tap)
99100

100101
// Assert
101102
pendingIntentCompat.verify {

0 commit comments

Comments
 (0)