Kotlin Multiplatform Notifications
Integrating android and desktop (and any other) notifications
Ok, so in previous episodes we figured out how to make android notifications and desktop notifications. How do we integrate these two platform dependent notifications into a platform independent notification system? We get back to expect/ actual or use interfaces and plug in implementations. The android notification system demands a context, so that rules out the expect/ actual
(unless we are willing to load the context into a singleton/ global which we are not). So we need to have an interface that we load up with platform dependent implementations.
Let us start in commonTest:
package notifications
import kotlin.test.Test
import io.mockk.mockk
import io.mockk.verify
class NotificationSendsTest {
@Test
fun `Notification System delegates to platform implementation`() {
val notificationImplementation = mockk<PlatformSpecificNotification>(relaxed = true)
val notificationSystem = NotificationSystem(notificationImplementation)
notificationSystem.send("Title", "Message")
verify(exactly = 1) {
notificationImplementation.send("Title", "Message")
}
}
}
Now I put this in commonTest
because that is where it makes sense to me. However, it complains about using mockk
in commonTest
because not all platforms can use mockk
. So, how can I change this? I don’t really need mockk
, I could make a fake PlatformSpecificNotification
:
class FakePlatformSpecificNotification : PlatformSpecificNotification {
val sentNotifications = mutableListOf<Pair<String, String>>()
override fun send(title: String, message: String) {
sentNotifications.add(title to message)
}
}
then my test looks like this:
class NotificationSendsTest {
@Test
fun `Notification System delegates to platform implementation`() {
val notificationImplementation = FakePlatformSpecificNotification()
val notificationSystem = NotificationSystem(notificationImplementation)
val title = "::TITLE::"
val message = "::NOTIFICATION MESSAGE::"
notificationSystem.send(title, message)
notificationImplementation.sentNotifications
.shouldHaveSize(1)
.shouldContain(title to message)
}
}
Is there anything else we need to do? Well we need our AndroidNotificationSystem
and our DesktopNotificationSystem
to implement PlatformNotificationSystem
. Do we need a test for that? The answer should always be “Of Course!” though sometimes I get lazy. Still, I think I’ll make an Android Unit Test and a Desktop Test that show the setup for future generations:
class NotificationDelegatesToAndroidTest {
@Test
fun delegatesToAndroid() {
val channelId = "::THE CHANNEL ID::"
val context = ApplicationProvider
.getApplicationContext<Context>()
val notificationSystem =
setupNotificationSystem(context, channelId)
val title = "::TITLE::"
val message = "::NOTIFICATION MESSAGE::"
notificationSystem.send(title, message)
verifyNotification(context, channelId, title, message)
}
private fun setupNotificationSystem(
context: Context,
channelId: String
): NotificationSystem {
val channel =
AndroidNotificationChannelBuilder.createNotificationChannel(
context,
channelId
)
val androidNotificationSystem = AndroidNotificationSystem(
context
)
androidNotificationSystem.register(channel)
return NotificationSystem(androidNotificationSystem)
}
private fun verifyNotification(
context: Context,
channelId: String,
title: String,
message: String
) {
val notificationReceived = shadowNotificationManager(context)
.allNotifications
.shouldHaveSize(1)
.first()
notificationReceived
.channelId
.shouldBe(channelId)
notificationReceived.shouldHaveTitle(title)
.shouldHaveMessage(message)
}
}
private fun Notification.shouldHaveMessage(message: String): Notification {
this.extras.getString(Notification.EXTRA_TEXT)
.shouldBe(message)
return(this)
}
private fun Notification.shouldHaveTitle(title: String): Notification {
this.extras.getCharSequence(Notification.EXTRA_TITLE)
.toString()
.shouldBe(title)
return this
}
simple test, complicated setup in the details but still, I like it. To compile (and pass) it needs one thing, AndroidNotificationSystem
to implement PlatformNotificationSystem
and the send(title,message) interface:
override fun send(title: String, message: String) {
val notification = AndroidNotificationBuilder(context)
.withTitle(title)
.withText(message)
.build()
send(notification)
}
Nope! We need to store the default channel otherwise our simple send(title, message)
goes nowhere. So here is our full AndroidNotificationSystem
:
class AndroidNotificationSystem(val context: Context?) :
PlatformSpecificNotification {
private lateinit var channel: NotificationChannel
fun send(notification: Notification) {
if(context == null) {
return
}
val notificationManager =
NotificationManagerCompat.from(context)
if (ActivityCompat.checkSelfPermission(
context,
Manifest.permission.POST_NOTIFICATIONS
) != PackageManager.PERMISSION_GRANTED
) {
return
}
notificationManager
.notify(notification.hashCode(), notification)
}
override fun send(title: String, message: String) {
if(context == null) {
return
}
val notification = AndroidNotificationBuilder(context)
.withTitle(title)
.withText(message)
.withChannelId(channel.id)
.withSmallIcon(com.ronnev.testing.R.drawable.icon)
.build()
send(notification)
}
fun register(channel: NotificationChannel) {
val notificationManager =
context?.getSystemService(Context.NOTIFICATION_SERVICE)
as? NotificationManager
notificationManager?.createNotificationChannel(channel)
this.channel = channel
}
}
great! working for android. Now for desktop:
class NotificationSystemDelegatesToDesktopTest {
@Test
fun `sends notification to desktop`() {
val desktopNotifier = mockk<DesktopNotifier>(relaxed = true)
val notificationSystem = NotificationSystem(desktopNotifier)
val title = "::TITLE::"
val message = "::NOTIFICATION MESSAGE::"
notificationSystem.send(title, message)
verify(exactly = 1) {
desktopNotifier.send(title, message)
}
}
}
which to compile needs DesktopNotifier
to implement PlatformNotificationSystem
(which it basically already did, just needed the override and the implements:
class DesktopNotifier (
private val platformIdentifier: PlatformIdentifier,
private val linuxGtkNotify: LinuxGtkNotify,
private val desktopTrayNotifier: DesktopTrayNotifier
) : PlatformSpecificNotification {
constructor() : this(
platformIdentifier = PlatformIdentifier(),
linuxGtkNotify = LinuxGtkNotify(),
desktopTrayNotifier = DesktopTrayNotifier()
)
override fun send(title: String, message: String) {
if (platformIdentifier.isLinux()) {
linuxGtkNotify.send(title, message)
} else {
desktopTrayNotifier.sendNotification(title, message)
}
}
}
now that passes! YAY, there was much rejoicing.
I think now I will take the example hello world app and add a notify button that triggers a notification. This will make a nice example. So I modify the commonMain/kotlin/App.kt
:
fun App() {
MaterialTheme {
var showContent by remember { mutableStateOf(false) }
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(onClick = {
notificationSystem.send("Notification!", "Boo!!!!!")
}) {
Text("Notify me!")
}
Button(onClick = { showContent = !showContent }) {
Text("Click me!")
}
AnimatedVisibility(showContent) {
val greeting = remember { Greeting().greet() }
Column(
Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
Image(
painterResource(Res.drawable.compose_multiplatform),
null
)
Text("Compose: $greeting")
}
}
}
}
}
this fails because we don’t have a notificationSystem
. We need it passed into the app:
fun App(notificationSystem: NotificationSystem) {
...
which makes us change all the calls for android thusly:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val notificationSystem = setupNotificationSystem()
setContent {
App(notificationSystem)
}
}
private fun setupNotificationSystem(): NotificationSystem {
val channel =
AndroidNotificationChannelBuilder.createNotificationChannel(
this,
"Kotlin Multiplatform Testing Channel"
)
val androidNotificationSystem = AndroidNotificationSystem(this)
androidNotificationSystem.register(channel)
val notificationSystem = NotificationSystem(
androidNotificationSystem
)
return notificationSystem
}
}
@Preview
@Composable
fun AppAndroidPreview() {
val notificationSystem = NotificationSystem(
AndroidNotificationSystem(null)
)
App(notificationSystem)
}
Note the setup necessary for the notification system. This works on my emulators, great!
So overall, I have a platform independent way of sending a notification from a Kotlin Multiplatform project based on the compose UI. Maybe we can get some real work done soon!