Feat: Implémentation Notifications Périodiques via Service

- Ajout permission POST_NOTIFICATIONS et déclaration NotificationService dans Manifest.
- Création NotificationHelper pour centraliser création canal et affichage notifications.
- Création NotificationService utilisant Handler/postDelayed pour simuler l'envoi
  périodique de rappels (HighScore, Inactivité) - NOTE: WorkManager recommandé en prod.
- Modification MainActivity:
  - Crée le canal via NotificationHelper.
  - Gère la demande de permission POST_NOTIFICATIONS (Android 13+) via ActivityResultLauncher,
    déclenchée par le switch dans les paramètres.
  - Démarre/Arrête NotificationService en fonction de l'état du switch Notifications.
  - Sauvegarde le timestamp de dernière partie jouée dans onPause.
  - Utilise NotificationHelper pour afficher la notification d'accomplissement (2048).
  - Suppression des méthodes/boutons de test de notification.
This commit is contained in:
Augustin ROUX 2025-04-04 15:58:02 +02:00
parent 7dc7360e14
commit bf914f291d
10 changed files with 264 additions and 209 deletions

View File

@ -32,6 +32,10 @@
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<service
android:name=".NotificationService"
android:enabled="true"
android:exported="false" />
</application> </application>
</manifest> </manifest>

View File

@ -61,6 +61,7 @@ public class MainActivity extends AppCompatActivity {
private static final int BOARD_SIZE = 4; private static final int BOARD_SIZE = 4;
private static final String NOTIFICATION_CHANNEL_ID = "BEST_2048_CHANNEL"; private static final String NOTIFICATION_CHANNEL_ID = "BEST_2048_CHANNEL";
private boolean notificationsEnabled = true; private boolean notificationsEnabled = true;
private static final String LAST_PLAYED_TIME_KEY = "last_played_time";
// --- State Management --- // --- State Management ---
private boolean statisticsVisible = false; private boolean statisticsVisible = false;
@ -100,10 +101,14 @@ public class MainActivity extends AppCompatActivity {
EdgeToEdge.enable(this); EdgeToEdge.enable(this);
super.onCreate(savedInstanceState); super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main); setContentView(R.layout.activity_main);
NotificationHelper.createNotificationChannel(this);
createNotificationChannel(); createNotificationChannel();
findViews(); findViews();
initializeGameAndStats(); initializeGameAndStats();
setupListeners(); setupListeners();
if (notificationsEnabled) {
startNotificationService();
}
} }
@Override @Override
@ -140,6 +145,13 @@ public class MainActivity extends AppCompatActivity {
} }
} }
/** Sauvegarde le timestamp actuel comme dernier moment joué. */
private void saveLastPlayedTime() {
if (preferences != null) {
preferences.edit().putLong(LAST_PLAYED_TIME_KEY, System.currentTimeMillis()).apply();
}
}
// --- Initialisation --- // --- Initialisation ---
/** /**
@ -692,12 +704,27 @@ public class MainActivity extends AppCompatActivity {
notificationManager.notify(notificationId, builder.build()); notificationManager.notify(notificationId, builder.build());
} }
/** Affiche la notification d'accomplissement (ex: tuile 2048 atteinte). */ /** Démarre le NotificationService s'il n'est pas déjà lancé. */
private void startNotificationService() {
Intent serviceIntent = new Intent(this, NotificationService.class);
// Utiliser startForegroundService pour API 26+ si le service doit faire qqch rapidement
// mais pour une tâche périodique simple startService suffit.
startService(serviceIntent);
}
/** Arrête le NotificationService. */
private void stopNotificationService() {
Intent serviceIntent = new Intent(this, NotificationService.class);
stopService(serviceIntent);
}
/** Affiche la notification d'accomplissement via le NotificationHelper. */
private void showAchievementNotification(int tileValue) { private void showAchievementNotification(int tileValue) {
// Vérifie l'état global avant d'envoyer (au cas désactivé entre-temps)
if (!notificationsEnabled) return;
String title = getString(R.string.notification_title_achievement); String title = getString(R.string.notification_title_achievement);
String message = getString(R.string.notification_text_achievement, tileValue); String message = getString(R.string.notification_text_achievement, tileValue);
// Utiliser un ID spécifique pour ce type de notif (ex: 1) NotificationHelper.showNotification(this, title, message, 1); // ID 1 pour achievement
showNotification(this, title, message, 1);
} }
/** Affiche la notification de rappel du meilleur score (pour test). */ /** Affiche la notification de rappel du meilleur score (pour test). */

View File

@ -0,0 +1,91 @@
// Fichier NotificationHelper.java
package legion.muyue.best2048;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.os.Build;
import androidx.core.app.NotificationCompat;
import androidx.core.app.NotificationManagerCompat;
import androidx.core.content.ContextCompat; // Pour checkSelfPermission
/**
* Classe utilitaire pour simplifier la création et l'affichage des notifications
* et la gestion du canal de notification pour l'application Best 2048.
*/
public class NotificationHelper {
/** Identifiant unique du canal de notification pour cette application. */
public static final String CHANNEL_ID = "BEST_2048_CHANNEL"; // Doit correspondre à celui utilisé avant
/**
* Crée le canal de notification requis pour Android 8.0 (API 26) et supérieur.
* Cette méthode est idempotente (l'appeler plusieurs fois n'a pas d'effet négatif).
* Doit être appelée avant d'afficher la première notification sur API 26+.
*
* @param context Contexte applicatif.
*/
public static void createNotificationChannel(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
CharSequence name = context.getString(R.string.notification_channel_name);
String description = context.getString(R.string.notification_channel_description);
int importance = NotificationManager.IMPORTANCE_DEFAULT; // Importance par défaut
NotificationChannel channel = new NotificationChannel(CHANNEL_ID, name, importance);
channel.setDescription(description);
// Enregistre le canal auprès du système.
NotificationManager notificationManager = context.getSystemService(NotificationManager.class);
if (notificationManager != null) {
notificationManager.createNotificationChannel(channel);
}
}
}
/**
* Construit et affiche une notification.
* Vérifie la permission POST_NOTIFICATIONS sur Android 13+ avant d'essayer d'afficher.
*
* @param context Contexte (peut être une Activity ou un Service).
* @param title Titre de la notification.
* @param message Contenu texte de la notification.
* @param notificationId ID unique pour cette notification (permet de la mettre à jour ou l'annuler).
*/
public static void showNotification(Context context, String title, String message, int notificationId) {
// Vérification de la permission pour Android 13+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(context, android.Manifest.permission.POST_NOTIFICATIONS) != android.content.pm.PackageManager.PERMISSION_GRANTED) {
// Si la permission n'est pas accordée, ne pas tenter d'afficher la notification.
// L'application devrait idéalement gérer la demande de permission avant d'appeler cette méthode
// si elle sait que l'utilisateur a activé les notifications dans les paramètres.
System.err.println("Permission POST_NOTIFICATIONS manquante. Notification non affichée.");
return;
}
}
// Intent pour ouvrir MainActivity au clic
Intent intent = new Intent(context, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK);
int flags = (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) ? PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE : PendingIntent.FLAG_UPDATE_CURRENT;
PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, flags);
// Construction de la notification via NotificationCompat pour la compatibilité
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, CHANNEL_ID)
.setSmallIcon(R.drawable.ic_stat_notification_2048) // Votre icône de notification
.setContentTitle(title)
.setContentText(message)
.setPriority(NotificationCompat.PRIORITY_DEFAULT) // Priorité normale
.setContentIntent(pendingIntent) // Action au clic
.setAutoCancel(true); // Ferme la notification après le clic
// Affichage de la notification
NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context);
try {
notificationManager.notify(notificationId, builder.build());
} catch (SecurityException e){
// Gérer l'exception de sécurité qui peut survenir même avec la vérification ci-dessus dans certains cas limites
System.err.println("Erreur de sécurité lors de l'affichage de la notification : " + e.getMessage());
}
}
}

View File

@ -0,0 +1,135 @@
// Fichier NotificationService.java
package legion.muyue.best2048;
import android.app.Service;
import android.content.Intent;
import android.content.SharedPreferences;
import android.os.Handler;
import android.os.IBinder;
import android.os.Looper; // Important pour créer un Handler sur le Main Thread
import androidx.annotation.Nullable;
import java.util.concurrent.TimeUnit;
/**
* Service exécuté en arrière-plan pour envoyer des notifications périodiques
* (rappel de meilleur score, rappel d'inactivité).
* Utilise un Handler pour planifier les tâches répétitives.
* NOTE : Pour une robustesse accrue (garantie d'exécution même si l'app est tuée),
* WorkManager serait préférable en production.
*/
public class NotificationService extends Service {
private static final int NOTIFICATION_ID_HIGHSCORE = 2; // Doit être différent des autres notifs
private static final int NOTIFICATION_ID_INACTIVITY = 3;
// Intervalles (exemples : 1 jour pour HS, 3 jours pour inactivité)
private static final long HIGHSCORE_INTERVAL_MS = TimeUnit.DAYS.toMillis(1);
private static final long INACTIVITY_INTERVAL_MS = TimeUnit.DAYS.toMillis(3);
private static final long CHECK_INTERVAL_MS = TimeUnit.HOURS.toMillis(6); // Intervalle de vérification plus fréquent
private Handler handler;
private Runnable periodicTaskRunnable;
// Clés SharedPreferences (doivent correspondre à celles utilisées ailleurs)
private static final String PREFS_NAME = "Best2048_Prefs";
private static final String HIGH_SCORE_KEY = "high_score";
private static final String LAST_PLAYED_TIME_KEY = "last_played_time"; // Nouvelle clé
@Override
public void onCreate() {
super.onCreate();
// Utilise le Looper principal pour le Handler (simple, mais bloque si tâche longue)
// Pour des tâches plus lourdes, utiliser HandlerThread
handler = new Handler(Looper.getMainLooper());
// Pas besoin de créer le canal ici si MainActivity le fait déjà au démarrage
// NotificationHelper.createNotificationChannel(this);
periodicTaskRunnable = new Runnable() {
@Override
public void run() {
// Vérifie périodiquement s'il faut envoyer une notification
checkAndSendNotifications();
// Replanifie la tâche
handler.postDelayed(this, CHECK_INTERVAL_MS); // Vérifie toutes les X heures
}
};
}
@Override
public int onStartCommand(Intent intent, int flags, int startId) {
// Lance la tâche périodique lors du démarrage du service
handler.removeCallbacks(periodicTaskRunnable); // Assure qu'il n'y a pas de doublon
handler.post(periodicTaskRunnable); // Lance immédiatement la première vérification
// START_STICKY : Le système essaiera de recréer le service s'il est tué.
return START_STICKY;
}
@Override
public void onDestroy() {
super.onDestroy();
// Arrête la planification des tâches lorsque le service est détruit
if (handler != null && periodicTaskRunnable != null) {
handler.removeCallbacks(periodicTaskRunnable);
}
}
@Nullable
@Override
public IBinder onBind(Intent intent) {
// Service non lié (Started Service)
return null;
}
/**
* Vérifie les conditions et envoie les notifications périodiques si nécessaire.
*/
private void checkAndSendNotifications() {
SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE);
boolean notificationsEnabled = prefs.getBoolean("notifications_enabled", true); // Vérifie si activé
if (!notificationsEnabled) {
// Si désactivé dans les prefs, on arrête potentiellement le service?
// Ou juste ne rien envoyer. Pour l'instant, ne rien envoyer.
// stopSelf(); // Arrêterait le service
return;
}
// --- Notification High Score (Exemple: envoyer une fois par jour si non joué depuis ?) ---
// Logique simplifiée: on envoie juste le rappel basé sur un flag ou temps (pas implémenté ici)
// Pour une vraie app, il faudrait une logique pour ne pas spammer.
// Exemple: Envoyer si le dernier envoi date de plus de HIGHSCORE_INTERVAL_MS ?
int highScore = prefs.getInt(HIGH_SCORE_KEY, 0);
// Temporairement on l'envoie à chaque check pour test (à modifier!)
// if (shouldSendHighScoreNotification()) {
showHighScoreNotificationNow(highScore);
// }
// --- Notification d'Inactivité ---
long lastPlayedTime = prefs.getLong(LAST_PLAYED_TIME_KEY, 0);
if (lastPlayedTime > 0 && (System.currentTimeMillis() - lastPlayedTime > INACTIVITY_INTERVAL_MS)) {
// Si l'inactivité dépasse le seuil
showInactivityNotificationNow();
// Optionnel: Mettre à jour lastPlayedTime pour ne pas renvoyer immédiatement ?
// Ou attendre que l'utilisateur rejoue pour mettre à jour lastPlayedTime dans onPause.
}
}
/** Affiche la notification High Score */
private void showHighScoreNotificationNow(int highScore) {
String title = getString(R.string.notification_title_highscore);
String message = getString(R.string.notification_text_highscore, highScore);
NotificationHelper.showNotification(this, title, message, NOTIFICATION_ID_HIGHSCORE);
}
/** Affiche la notification d'Inactivité */
private void showInactivityNotificationNow() {
String title = getString(R.string.notification_title_inactivity);
String message = getString(R.string.notification_text_inactivity);
NotificationHelper.showNotification(this, title, message, NOTIFICATION_ID_INACTIVITY);
}
// Ajouter ici une logique plus fine si nécessaire pour savoir QUAND envoyer les notifs périodiques
// private boolean shouldSendHighScoreNotification() { ... }
}

View File

@ -1,170 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>

View File

@ -1,30 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 427 KiB

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <foreground android:drawable="@drawable/logo" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@drawable/logo" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>

View File

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" /> <foreground android:drawable="@drawable/logo" />
<foreground android:drawable="@drawable/ic_launcher_foreground" /> <monochrome android:drawable="@drawable/logo" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon> </adaptive-icon>