It is very often necessary to display notifications to users. I’ve shown how to do that on android here. So how do we do it on windows and linux? That is the topic of today’s post.
So for starters, when dealing with something like this, do you start with TDD? I don’t. I start with a spike to understand how the system works. So a little research brings me to the SystemTray
functionality. I write a test to use it to display a message:
@Test
fun `should create a notification using SystemTray`() {
val title = "::Title::"
val message = "::Message::"
val trayIcon = TrayIcon(
Toolkit.getDefaultToolkit().createImage(""), title)
.apply {
isImageAutoSize = true
}
val tray = SystemTray.getSystemTray()
try {
tray.add(trayIcon)
trayIcon.displayMessage(title, message, TrayIcon.MessageType.INFO)
} catch (e: Exception) {
println(e)
}
sleep(10000)
}
This isn’t a real test, it is just a convenient way to test that I can actually display a notification and that this method will work. Interestingly, it is supposed to work on linux, but support varies a lot. On my LQXT it does not work. It is supposed to work on Mac as well, I am not currently in a position to test that. It does work on windows though.
So how do I go from spike to TDD? Start by throwing it away of course! Although really, it does give me some ideas of how to test it using some mocking. So first thing is to include mockk
in the desktop tests and mock the trayIcon
and the tray
, and verify that the icon gets added to the tray and that the message gets displayed:
@Test
fun `should create a notification using SystemTray`() {
val title = "::Title::"
val message = "::Message::"
val tray = mockk<SystemTray>(relaxed = true)
val trayIcon = mockk<TrayIcon>(relaxed = true)
try {
tray.add(trayIcon)
trayIcon.displayMessage(
title,
message,
TrayIcon.MessageType.INFO
)
} catch (e: Exception) {
println(e)
}
verifyOrder {
tray.add(trayIcon)
trayIcon.displayMessage(
title,
message,
TrayIcon.MessageType.INFO
)
}
verify(exactly = 1) { tray.add(trayIcon)}
verify(exactly = 1) {
trayIcon.displayMessage(
title,
message,
TrayIcon.MessageType.INFO
)
}
}
This passes, now I delete the spike try/catch
block and of course it fails. Now though, I can see that I have two tests. One, that sometime during startup I add the tray icon to the tray. The other is that when I send a message I display the message. So lets break that into two tests. Yes I will let two tests exist at once - go ahead, burn me on the cross. So here are my two tests:
@Test
fun `DesktopTrayNotifier adds icon to tray in setup`() {
val tray = mockk<SystemTray>(relaxed = true)
val trayIcon = mockk<TrayIcon>(relaxed = true)
val notifier = DesktopTrayNotifier()
verify(exactly = 1) { tray.add(trayIcon) }
}
@Test
fun `DesktopTrayNotifier sends notification`() {
val title = "::Title::"
val message = "::Message::"
val tray = mockk<SystemTray>(relaxed = true)
val trayIcon = mockk<TrayIcon>(relaxed = true)
val notifier = DesktopTrayNotifier()
notifier.sendNotification(title, message)
verify(exactly = 1) {
trayIcon.displayMessage(
title,
message,
TrayIcon.MessageType.INFO
)
}
}
Note I don’t need to verify the order anymore because I verify we get added to the tray during setup. Simpler test, simpler code. So lets add the init
to make the first test pass:
class DesktopTrayNotifier(
tray: SystemTray = SystemTray.getSystemTray(),
title: String = "App Notify",
trayImageResourcePath: String? = null
) {
private val image = createImageFromResource(trayImageResourcePath)
?: makeDefaultImage()
val trayIcon: TrayIcon = TrayIcon(image, title)
init {
tray.add(trayIcon)
}
fun sendNotification(title: String, message: String) {
}
companion object {
private fun createImageFromResource(
imageResourcePath: String?
): Image? {
return imageResourcePath?.let {
Toolkit.getDefaultToolkit().createImage(it)
}
}
private fun makeDefaultImage(): Image {
return Toolkit.getDefaultToolkit()
.createImage(
TrayIcon::class.java.getResource("icon.png")
)
}
}
}
The init
ended up leading to the creation of an icon, and a few other minor things that I could have written individual tests for. I didn’t, so sue me. In actuality, I was this close to reaching the added complexity that raises your fear to the point where you back up and slow down. One more tiny detail and I would have.
Now we will make the notification send:
fun sendNotification(title: String, message: String) {
trayIcon.displayMessage(
title,
message,
TrayIcon.MessageType.INFO
)
}
There we are, we have a desktop notification that works on windows, and theoretically on linux and mac. However since the linux notification system doesn’t work on my computer, I think I will make a special one that uses gtk’s libnotify. It is a fairly common notification library in linux. So I test the command is built properly but not that it actually is sent - I test that once and call it good.
@Test
fun `should create a notification using notify-send`() {
val title = "::Title::"
val message = "::Message::"
val notifySend = LinuxGtkNotify(title, message)
val command: List<String> = notifySend.makeCommand()
command.shouldHaveSize(3)
command.shouldContainInOrder(
"/usr/bin/notify-send",
title,
message
)
// --to actually test the send method, uncomment the following line
// notifySend.send()
}
here is the class:
class LinuxGtkNotify(private val title: String, private val message: String) {
fun makeCommand(): List<String> {
return listOf(
"/usr/bin/notify-send",
title,
message
)
}
fun send() {
ProcessBuilder(makeCommand())
.start()
.waitFor()
}
}
So now I need an overall desktop notify class that checks the OS and uses the linux one if it is linux. Tests:
@Test
fun `if on linux notify is sent through LinuxGtkNotify`() {
val title = "::Title::"
val message = "::Message::"
val platformIdentifier =
mockk<PlatformIdentifier>(relaxed = true)
val linuxGtkNotify = mockk<LinuxGtkNotify>(relaxed = true)
val desktopTrayNotifier =
mockk<DesktopTrayNotifier>(relaxed = true)
every { platformIdentifier.isLinux() } returns true
DesktopNotifier(
platformIdentifier,
linuxGtkNotify,
desktopTrayNotifier
).send(title, message)
verify(exactly = 1) {
linuxGtkNotify.send(title, message)
}
verify(exactly = 0) {
desktopTrayNotifier.sendNotification(title, message)
}
}
@Test
fun `if not on linux notify is sent through DesktopTrayNotifier`() {
val title = "::Title::"
val message = "::Message::"
val platformIdentifier =
mockk<PlatformIdentifier>(relaxed = true)
val linuxGtkNotify = mockk<LinuxGtkNotify>(relaxed = true)
val desktopTrayNotifier =
mockk<DesktopTrayNotifier>(relaxed = true)
every { platformIdentifier.isLinux() } returns false
DesktopNotifier(
platformIdentifier,
linuxGtkNotify,
desktopTrayNotifier
).send(title, message)
verify(exactly = 0) {
linuxGtkNotify.send(title, message)
}
verify(exactly = 1) {
desktopTrayNotifier.sendNotification(title, message)
}
}
DesktopNotifier.kt:
class DesktopNotifier(
private val platformIdentifier: PlatformIdentifier,
private val linuxGtkNotify: LinuxGtkNotify,
private val desktopTrayNotifier: DesktopTrayNotifier
) {
constructor() : this(
platformIdentifier = PlatformIdentifier(),
linuxGtkNotify = LinuxGtkNotify(),
desktopTrayNotifier = DesktopTrayNotifier()
)
fun send(title: String, message: String) {
if (platformIdentifier.isLinux()) {
linuxGtkNotify.send(title, message)
} else {
desktopTrayNotifier.sendNotification(title, message)
}
}
}
So now we have a notifier that will work on the desktop, regardless of OS (not currently tested for OSX desktop, that will have to be done later).
In the next little blog we will unify the android and desktop notifications.
Happy Coding!