Aller au contenu

Notifications Push - UnifiedPush

Télécharger cette page en Markdown


Vue d'ensemble

Urbafix utilise UnifiedPush comme alternative privacy-first à Firebase Cloud Messaging (FCM).

Avantages

Critère FCM UnifiedPush
Vie privée Google collecte les données Aucune collecte
Open source Non Oui
Self-hosted Non Oui (ntfy)
Systèmes de-Googlés Non Oui
Dépendance Google Play Services Aucune

Architecture

Flux d'enregistrement et de diffusion des notifications UnifiedPush


Backend PHP

UnifiedPushNotificationService

Fichier : /srv/urbafix/public/src/UnifiedPushNotificationService.php

<?php
class UnifiedPushNotificationService
{
    /**
     * Envoie une notification à un endpoint spécifique
     */
    public function sendToEndpoint(
        string $endpoint,
        string $title,
        string $body,
        ?string $infoId = null
    ): bool;

    /**
     * Envoie à tous les endpoints dans une zone géographique
     */
    public function sendToGeoZone(
        float $latitude,
        float $longitude,
        int $rayonMetres,
        string $title,
        string $body,
        string $typeInfo,
        ?string $infoId = null
    ): array;

    /**
     * Envoie à tous les endpoints enregistrés
     */
    public function sendToAll(
        string $title,
        string $body,
        string $typeInfo,
        ?string $infoId = null
    ): array;
}

Format du payload

{
  "title": "🚧 Travaux rue Victor Hugo",
  "body": "Fermeture de la voie du 25 au 30 janvier",
  "info_id": "123"
}

Ciblage géographique

La méthode sendToGeoZone() utilise la formule de Haversine pour filtrer les endpoints :

$sql = "
    SELECT endpoint
    FROM push_endpoints
    WHERE (
        6371000 * acos(
            LEAST(1, GREATEST(-1,
                cos(radians(:lat)) * cos(radians(last_latitude)) *
                cos(radians(last_longitude) - radians(:lng)) +
                sin(radians(:lat)) * sin(radians(last_latitude))
            ))
        )
    ) <= :rayon
";

Filtrage par préférences

// Ajouter le filtre selon le type d'info
switch ($typeInfo) {
    case 'TRAVAUX':
        $sql .= " AND notify_travaux = TRUE";
        break;
    case 'EVENEMENT':
        $sql .= " AND notify_evenements = TRUE";
        break;
    case 'ALERTE':
        $sql .= " AND notify_alertes = TRUE";
        break;
    case 'ANNONCE':
        $sql .= " AND notify_annonces = TRUE";
        break;
}

Cron Job

Fichier : /srv/urbafix/public/cron/send_scheduled_notifications.php

Envoie les notifications pour les infos dont la publication vient d'être activée.

<?php
// Exécuté toutes les minutes via cron
// Trouve les infos PUBLIE avec notification_envoyee = FALSE

$sql = "
    SELECT * FROM informations_officielles
    WHERE statut = 'PUBLIE'
    AND notification_envoyee = FALSE
    AND date_publication IS NOT NULL
";

foreach ($infos as $info) {
    $result = $pushService->sendToGeoZone(
        $info['latitude'],
        $info['longitude'],
        $info['rayon_metres'],
        $emojis[$info['type_info']] . ' ' . $info['titre'],
        substr($info['description'], 0, 100),
        $info['type_info'],
        $info['id']
    );

    // Marquer comme envoyée
    $db->execute(
        "UPDATE informations_officielles
         SET notification_envoyee = TRUE, date_notification = NOW()
         WHERE id = ?",
        [$info['id']]
    );
}

Configuration cron

# /etc/cron.d/urbafix_push
* * * * * www-data php /srv/urbafix/public/cron/send_scheduled_notifications.php

Android

Dépendances

// build.gradle.kts
implementation("com.github.UnifiedPush:android-connector:2.4.0")

// settings.gradle.kts (JitPack requis)
dependencyResolutionManagement {
    repositories {
        maven { url = uri("https://jitpack.io") }
    }
}

UrbaFixPushReceiver

Fichier : app/src/main/java/fr/urbafix/android/push/UrbaFixPushReceiver.kt

class UrbaFixPushReceiver : MessagingReceiver() {

    override fun onNewEndpoint(context: Context, endpoint: String, instance: String) {
        // Sauvegarder localement
        context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
            .edit()
            .putString(KEY_ENDPOINT, endpoint)
            .apply()

        // Envoyer au backend
        CoroutineScope(Dispatchers.IO).launch {
            sendEndpointToBackend(context, endpoint)
        }
    }

    override fun onMessage(context: Context, message: ByteArray, instance: String) {
        val payload = String(message, Charsets.UTF_8)
        val json = JSONObject(payload)

        val title = json.optString("title", "UrbaFix")
        val body = json.optString("body", "")
        val infoId = if (json.has("info_id")) json.getString("info_id") else null

        showNotification(context, title, body, infoId)
    }

    override fun onRegistrationFailed(context: Context, instance: String) {
        // Nettoyer l'endpoint
        context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
            .edit()
            .remove(KEY_ENDPOINT)
            .apply()
    }

    override fun onUnregistered(context: Context, instance: String) {
        // Nettoyer l'endpoint
    }
}

UnifiedPushManager

Fichier : app/src/main/java/fr/urbafix/android/push/UnifiedPushManager.kt

object UnifiedPushManager {

    fun hasDistributor(context: Context): Boolean {
        return UnifiedPush.getDistributor(context).isNotEmpty()
    }

    fun getAvailableDistributors(context: Context): List<String> {
        return UnifiedPush.getDistributors(context)
    }

    fun registerForPush(context: Context, instance: String = "default") {
        UnifiedPush.registerApp(context, instance)
    }

    fun isRegistered(context: Context): Boolean {
        val prefs = context.getSharedPreferences("urbafix_push_prefs", Context.MODE_PRIVATE)
        return prefs.getString("push_endpoint", null) != null
    }
}

PushInitializer

Fichier : app/src/main/java/fr/urbafix/android/push/PushInitializer.kt

sealed class PushState {
    object Ready : PushState()           // Configuré
    object NotRegistered : PushState()   // Distributeur dispo, pas enregistré
    object NoDistributor : PushState()   // Pas de distributeur
    object SetupSkipped : PushState()    // Utilisateur a ignoré
}

object PushInitializer {

    fun checkPushState(context: Context): PushState {
        if (UnifiedPushManager.isRegistered(context)) {
            return PushState.Ready
        }
        if (hasSkippedSetup(context)) {
            return PushState.SetupSkipped
        }
        if (UnifiedPushManager.hasDistributor(context)) {
            return PushState.NotRegistered
        }
        return PushState.NoDistributor
    }

    fun tryAutoRegister(context: Context): Boolean {
        if (UnifiedPushManager.hasDistributor(context)) {
            UnifiedPushManager.registerForPush(context)
            return true
        }
        return false
    }
}

Manifest

<receiver
    android:name=".push.UrbaFixPushReceiver"
    android:exported="true">
    <intent-filter>
        <action android:name="org.unifiedpush.android.connector.MESSAGE" />
        <action android:name="org.unifiedpush.android.connector.UNREGISTERED" />
        <action android:name="org.unifiedpush.android.connector.NEW_ENDPOINT" />
        <action android:name="org.unifiedpush.android.connector.REGISTRATION_FAILED" />
    </intent-filter>
</receiver>

Dialog d'installation

Quand aucun distributeur n'est installé, un dialog propose d'installer ntfy.

@Composable
fun UnifiedPushSetupDialog(
    onInstallClick: () -> Unit,
    onDismiss: () -> Unit
) {
    AlertDialog(
        title = { Text("Notifications") },
        text = {
            Text(
                "Pour recevoir les alertes et informations de votre commune, " +
                "installez l'application ntfy (gratuite et open source)."
            )
        },
        confirmButton = {
            Button(onClick = onInstallClick) {
                Text("Installer ntfy")
            }
        },
        dismissButton = {
            TextButton(onClick = onDismiss) {
                Text("Plus tard")
            }
        }
    )
}

Distributeurs compatibles

Application Play Store F-Droid Self-hosted
ntfy ✅ ✅ ✅
NextPush ❌ ✅ ✅
UP-FCM ✅ ❌ ❌

Recommandation

ntfy est le distributeur recommandé pour sa simplicité et sa compatibilité universelle.


Conformité RGPD

Donnée Collectée Stockée Usage
Endpoint URL Oui Oui Envoi notifications
Device ID Optionnel Oui Liaison incidents
Position GPS Optionnel Oui Ciblage géographique
IMEI Non Non -
Android ID Non Non -
Nom/Email Non Non -

Privacy by Design

Le système est conçu pour fonctionner avec le minimum de données personnelles.


Debugging

Logs Android

adb logcat | grep -E "UrbaFixPush"

Test envoi notification

# Via ntfy directement
curl -X POST https://ntfy.sh/upXXXXXXXX \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Test notification",
    "body": "Message de test",
    "info_id": "123"
  }'

Vérifier endpoints enregistrés

SELECT
    id,
    SUBSTRING(endpoint, 1, 50) as endpoint_preview,
    device_id,
    notify_travaux,
    notify_alertes,
    created_at
FROM push_endpoints
ORDER BY created_at DESC
LIMIT 10;