Implémentation d'un composant de notification système en Kotlin Compose Multiplatform

Dans la partie précédente de ce guide, nous avons abordé la création d'un composant de notification au sein de l'application. Cette section se concentrera sur la gestion des notifications au niveau du système d'exploitation.

Sous Android, l'implémentation standard pour les notifications repose sur androidx.core.app.NotificationCompat. Pour les environnements de bureau, une approche courante consiste à faire clignoter l'icône dans la barre d'état système pour signaler une nouvelle notification. Il est également possible d'opter pour un système de notification plus traditionnel, similaire à celui d'Android, en utilisant des bibliothèques comme KMPNotifier.

Mise en œuvre

Pour la gestion des dépendances, nous utiliserons Koin. Les utilisateurs familiers avec Koin peuvent se référer à la section dédiée dans l'article sur la gestion de la navigation en Kotlin Compose Multiplatform. Alternativement, il est possible d'implémenter des singletons à l'aide d'objets statiques.

L'interface commune nécessitera trois fonctions principales :

  • sendAppNotification(title: String, content: String) : Pour envoyer une notification système.
  • clearAppNotification() : Pour effacer les notifications système.
  • CheckAppNotificationPermission(requestPermission: (()->Unit) -> Unit) : Pour vérifier les permissions de notification (spécifique aux plateformes mobiles).
expect fun sendAppNotification(title: String, content: String)
expect fun clearAppNotification()
@Composable
expect fun CheckAppNotificationPermission(requestPermission: (() -> Unit) -> Unit)

Plateforme Android

Initialisation sous Android

Sous Android, il est nécessaire d'initialiser le canal de notification avant toute utilisation :

override fun onCreate(savedInstanceState: Bundle?) {
    // ...
    super.onCreate(savedInstanceState)
    createAppNotificationChannel(this)
    setContent {
        MainApp(
        //...
        )
    }
}

fun createAppNotificationChannel(context: Context) {
    val channelId = "your_channel_id"
    val channelName = "Your Notification Channel"
    val channelDescription = "Description for your notification channel"
    val importance = NotificationManager.IMPORTANCE_DEFAULT
    val channel = NotificationChannel(channelId, channelName, importance).apply {
        description = channelDescription
    }
    val notificationManager: NotificationManager =
        context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    notificationManager.createNotificationChannel(channel)
}

Il est également requis d'ajouter la permission POST_NOTIFICATIONS dans le fichier AndroidManifest.xml :

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <!-- ... -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
    <!-- ... -->
</manifest>

Voici l'implémentation pour la vérification des permissions de notification :

@SuppressLint("PermissionLaunchedDuringComposition")
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
@OptIn(ExperimentalPermissionsApi::class)
@Composable
actual fun CheckAppNotificationPermission(
    requestPermission: (() -> Unit) -> Unit
) {
    val notificationPermission = rememberPermissionState(
        permission = Manifest.permission.POST_NOTIFICATIONS
    )

    if (!notificationPermission.status.isGranted) {
        requestPermission {
            notificationPermission.launchPermissionRequest()
        }
    }
}

La fonction requestPermission accepte une lambda qui sera appelée pour déclencher la demande de permission. Cette lambda contient la logique pour afficher une boîte de dialogue à l'utilisateur, lui expliquant la nécessité de la permission et lui permettant de l'accorder. La méthode NotificationManager.createDialogAlert, mentionnée dans la partie précédente, peut être utilisée à cet effet.

Exemple d'utilisation :

CheckAppNotificationPermission { permissionLauncher ->
    NotificationManager.createDialogAlert(
        MainDialogAlert(
            message = "Notification Permission Required",
            confirmOperationText = "Allow",
            confirmOperation = {
                permissionLauncher()
                NotificationManager.removeDialogAlert()
            },
        )
    )
}

Il est important de noter que ce code est un exemple simplifié. Une implémentation complète devrait gérer avec soin le moment et la fréquence d'appel de CheckAppNotificationPermission pour optimiser l'expérience utilisateur.

Envoi et suppression de notifications sous Android

Pour envoyer une notification :

actual fun sendAppNotification(title: String, content: String) {
    val context = MainActivity.mainContext!! // Assurez-vous que mainContext est correctement initialisé
    val channelId = "your_channel_id" // Utilisez le même channelId que lors de la création du canal

    val builder = NotificationCompat.Builder(context, channelId)
        .setSmallIcon(android.R.drawable.ic_dialog_info) // Remplacez par votre icône
        .setContentTitle(title)
        .setContentText(content)
        .setPriority(NotificationCompat.PRIORITY_DEFAULT)

    val notificationManager: NotificationManager =
        context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager

    // Utilisez un ID unique pour chaque notification, ici incrémenté
    notificationManager.notify(notificationId++, builder.build())
}

Pour supprimer toutes les notifications :

actual fun clearAppNotification() {
    val context = MainActivity.mainContext!! // Assurez-vous que mainContext est correctement initialisé
    val notificationManager =
        context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    notificationManager.cancelAll()
}

L'identifiant de notification notificationId peut être géré en interne ou récupéré depuis un serveur, selon l'architecture de votre application.

Plateforme de bureau

Sous macOS, Windows et Linux, les notifications système sont souvent signalées par le clignotement de l'icône dans la barre d'état système (system tray). Une autre approche consiste à afficher un badge rouge sur l'icône. Nous allons illustrer ici la méthode du clignotement de l'icône.

Initialisation sous Windows/macOS/Linux

L'interface java.awt.SystemTray est généralement utilisée pour interagir avec la barre d'état système. L'initialisation de Koin et l'obtention de l'instance SystemTray peuvent être faites globalement :

lateinit var koin: Koin
val tray: SystemTray = SystemTray.getSystemTray()

Dans la fonction main :

fun main() {
    koin = KoinInit().init()
    // ...

    val showIcon: BufferedImage = koin.get(named("showIcon"))
    val trayIcon = TrayIcon(showIcon, "Application Name") // Remplacez "Application Name"
    tray.add(trayIcon)

    application {
        // ...
        trayIcon.apply {
            isImageAutoSize = true
            addMouseListener(
                // ... gestion des événements souris
            )
        }

        Window(
            // ...
        ) {
            // ...
        }
    }
}

L'initialisation spécifique à la plateforme, incluant le chargement des icônes, est gérée via Koin :

class KoinInit {
    fun init(appDeclaration: KoinAppDeclaration = {}): Koin {
        return startKoin {
            modules(
                listOf(
                    // ... autres modules
                    platformModule(),
                ),
            )
            appDeclaration()
        }.koin
    }
}

expect fun platformModule(): Module

Le module de plateforme pour les environnements de bureau charge deux images pour le clignotement :

@OptIn(ExperimentalSettingsApi::class)
actual fun platformModule(): Module = module {
    // ...
    single<bufferedimage>(qualifier = named("showIcon")) {
        ImageIO.read(
            Thread.currentThread().contextClassLoader
                .getResource("tray_icon_normal.png") // Assurez-vous que le chemin est correct
        )
    }

    single<bufferedimage>(qualifier = named("hideIcon")) {
        ImageIO.read(
            Thread.currentThread().contextClassLoader
                .getResource("tray_icon_notification.png") // Assurez-vous que le chemin est correct
        )
    }
}
</bufferedimage></bufferedimage>

Un Timer est utilisé pour alterner entre les icônes et créer l'effet de clignotement. Si l'option de badge rouge est préférée, le timer ne sera pas nécessaire.

val notificationTimer = Timer(500, object : ActionListener {
    var showState = true
    val normalIcon: BufferedImage = koin.get(named("showIcon"))
    val notificationIcon: BufferedImage = koin.get(named("hideIcon"))

    override fun actionPerformed(e: java.awt.event.ActionEvent?) {
        tray.trayIcons.firstOrNull()?.image = if (showState) normalIcon else notificationIcon
        showState = !showState
    }
})

Envoi et suppression de notifications sous Windows/macOS/Linux

Pour envoyer une notification, le timer est démarré :

actual fun sendAppNotification(title: String, content: String) {
    // Le titre et le contenu peuvent être utilisés pour une notification plus complexe si le système le supporte
    // Ici, nous nous concentrons sur le clignotement de l'icône
    notificationTimer.start()
}

Pour supprimer les notifications, le timer est arrêté et l'icône normale est restaurée :

actual fun clearAppNotification() {
    notificationTimer.stop()
    val normalIcon: BufferedImage = koin.get(named("showIcon"))
    tray.trayIcons.firstOrNull()?.image = normalIcon
}

Code source

Le projet complet est disponible ici : Tomoyo

Ressources

Étiquettes: Kotlin Multiplatform Jetpack Compose Android Bureau Notifications

Publié le 23 juillet à 18h17