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¶
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¶
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"
}'