diff --git a/flutter_local_notifications/README.md b/flutter_local_notifications/README.md index b3eca84eb..246e6de12 100644 --- a/flutter_local_notifications/README.md +++ b/flutter_local_notifications/README.md @@ -38,6 +38,7 @@ A cross platform plugin for displaying local notifications. - [Handling notifications whilst the app is in the foreground](#handling-notifications-whilst-the-app-is-in-the-foreground) - **[❓ Usage](#-usage)** - [Notification Actions](#notification-actions) + - [[Android] Title text styling (API 24+)](#android-title-text-styling-api-24) - [Example app](#example-app) - [API reference](#api-reference) - **[Initialisation](#initialisation)** @@ -630,6 +631,36 @@ Future _showNotificationWithActions() async { Each notification will have a internal ID & an public action title. +### [Android] Title text styling (API 24+) + +Style only the notification title on Android 7.0+ using a decorated custom +layout. Older Android versions will silently fall back to the default system +template. + +```dart +final androidDetails = AndroidNotificationDetails( + 'reminders', + 'Reminders', + titleStyle: const AndroidNotificationTitleStyle( + color: 0xFF58CC02, + sizeSp: 16, + bold: true, + italic: false, + iconSpacing: 2, + ), + descriptionStyle: const AndroidNotificationDescriptionStyle( + color: 0xFFFF0000, + sizeSp: 14, + bold: false, + italic: true, + ), +); +``` + +> [!NOTE] +> These options affect the title and body text respectively. Ensure sufficient contrast for +> accessibility. + ### Example app The [`example`](https://github.com/MaikuB/flutter_local_notifications/tree/master/flutter_local_notifications/example) directory has a sample application that demonstrates the features of this plugin. diff --git a/flutter_local_notifications/android/build.gradle b/flutter_local_notifications/android/build.gradle index 5d4b5e697..4991fbd72 100644 --- a/flutter_local_notifications/android/build.gradle +++ b/flutter_local_notifications/android/build.gradle @@ -9,6 +9,7 @@ buildscript { dependencies { classpath 'com.android.tools.build:gradle:8.6.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.0" } } @@ -20,20 +21,26 @@ rootProject.allprojects { } apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' android { namespace 'com.dexterous.flutterlocalnotifications' - compileSdk 35 - compileOptions { - coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 - } + compileSdk 36 defaultConfig { - multiDexEnabled true minSdkVersion 21 testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + multiDexEnabled true + } + + compileOptions { + coreLibraryDesugaringEnabled true + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" } lintOptions { @@ -46,6 +53,7 @@ dependencies { implementation "androidx.core:core:1.3.0" implementation "androidx.media:media:1.1.0" implementation "com.google.code.gson:gson:2.12.0" + implementation "org.jetbrains.kotlin:kotlin-stdlib:2.1.0" testImplementation 'junit:junit:4.12' testImplementation 'org.mockito:mockito-core:3.10.0' diff --git a/flutter_local_notifications/android/gradle/wrapper/gradle-wrapper.properties b/flutter_local_notifications/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..128196a7a --- /dev/null +++ b/flutter_local_notifications/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0-milestone-1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/FlutterLocalNotificationsPlugin.java b/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/FlutterLocalNotificationsPlugin.java index 56cd787b2..153b39d47 100644 --- a/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/FlutterLocalNotificationsPlugin.java +++ b/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/FlutterLocalNotificationsPlugin.java @@ -35,6 +35,7 @@ import android.text.TextUtils; import android.text.style.ForegroundColorSpan; import android.util.Log; +import android.widget.RemoteViews; import androidx.annotation.Keep; import androidx.annotation.NonNull; @@ -74,6 +75,7 @@ import com.dexterous.flutterlocalnotifications.utils.BooleanUtils; import com.dexterous.flutterlocalnotifications.utils.LongUtils; import com.dexterous.flutterlocalnotifications.utils.StringUtils; +import com.dexterous.flutterlocalnotifications.TitleStyler; import com.google.gson.Gson; import com.google.gson.GsonBuilder; import com.google.gson.reflect.TypeToken; @@ -114,11 +116,11 @@ interface PermissionRequestListener { @Keep public class FlutterLocalNotificationsPlugin implements MethodCallHandler, - PluginRegistry.NewIntentListener, - PluginRegistry.RequestPermissionsResultListener, - PluginRegistry.ActivityResultListener, - FlutterPlugin, - ActivityAware { + PluginRegistry.NewIntentListener, + PluginRegistry.RequestPermissionsResultListener, + PluginRegistry.ActivityResultListener, + FlutterPlugin, + ActivityAware { static final String PAYLOAD = "payload"; static final String NOTIFICATION_ID = "notificationId"; @@ -133,22 +135,17 @@ public class FlutterLocalNotificationsPlugin private static final String DRAWABLE = "drawable"; private static final String DEFAULT_ICON = "defaultIcon"; private static final String SELECT_NOTIFICATION = "SELECT_NOTIFICATION"; - private static final String SELECT_FOREGROUND_NOTIFICATION_ACTION = - "SELECT_FOREGROUND_NOTIFICATION"; + private static final String SELECT_FOREGROUND_NOTIFICATION_ACTION = "SELECT_FOREGROUND_NOTIFICATION"; private static final String SCHEDULED_NOTIFICATIONS = "scheduled_notifications"; private static final String INITIALIZE_METHOD = "initialize"; private static final String GET_CALLBACK_HANDLE_METHOD = "getCallbackHandle"; private static final String ARE_NOTIFICATIONS_ENABLED_METHOD = "areNotificationsEnabled"; - private static final String CAN_SCHEDULE_EXACT_NOTIFICATIONS_METHOD = - "canScheduleExactNotifications"; - private static final String CREATE_NOTIFICATION_CHANNEL_GROUP_METHOD = - "createNotificationChannelGroup"; - private static final String DELETE_NOTIFICATION_CHANNEL_GROUP_METHOD = - "deleteNotificationChannelGroup"; + private static final String CAN_SCHEDULE_EXACT_NOTIFICATIONS_METHOD = "canScheduleExactNotifications"; + private static final String CREATE_NOTIFICATION_CHANNEL_GROUP_METHOD = "createNotificationChannelGroup"; + private static final String DELETE_NOTIFICATION_CHANNEL_GROUP_METHOD = "deleteNotificationChannelGroup"; private static final String CREATE_NOTIFICATION_CHANNEL_METHOD = "createNotificationChannel"; private static final String DELETE_NOTIFICATION_CHANNEL_METHOD = "deleteNotificationChannel"; - private static final String GET_ACTIVE_NOTIFICATION_MESSAGING_STYLE_METHOD = - "getActiveNotificationMessagingStyle"; + private static final String GET_ACTIVE_NOTIFICATION_MESSAGING_STYLE_METHOD = "getActiveNotificationMessagingStyle"; private static final String GET_NOTIFICATION_CHANNELS_METHOD = "getNotificationChannels"; private static final String START_FOREGROUND_SERVICE = "startForegroundService"; private static final String STOP_FOREGROUND_SERVICE = "stopForegroundService"; @@ -157,23 +154,16 @@ public class FlutterLocalNotificationsPlugin private static final String SHOW_METHOD = "show"; private static final String CANCEL_METHOD = "cancel"; private static final String CANCEL_ALL_METHOD = "cancelAll"; - private static final String CANCEL_ALL_PENDING_NOTIFICATIONS_METHOD = - "cancelAllPendingNotifications"; + private static final String CANCEL_ALL_PENDING_NOTIFICATIONS_METHOD = "cancelAllPendingNotifications"; private static final String ZONED_SCHEDULE_METHOD = "zonedSchedule"; private static final String PERIODICALLY_SHOW_METHOD = "periodicallyShow"; - private static final String PERIODICALLY_SHOW_WITH_DURATION_METHOD = - "periodicallyShowWithDuration"; - private static final String GET_NOTIFICATION_APP_LAUNCH_DETAILS_METHOD = - "getNotificationAppLaunchDetails"; - private static final String REQUEST_NOTIFICATIONS_PERMISSION_METHOD = - "requestNotificationsPermission"; - private static final String REQUEST_EXACT_ALARMS_PERMISSION_METHOD = - "requestExactAlarmsPermission"; - - private static final String REQUEST_FULL_SCREEN_INTENT_PERMISSION_METHOD = - "requestFullScreenIntentPermission"; - private static final String REQUEST_NOTIFICATION_POLICY_ACCESS_METHOD = - "requestNotificationPolicyAccess"; + private static final String PERIODICALLY_SHOW_WITH_DURATION_METHOD = "periodicallyShowWithDuration"; + private static final String GET_NOTIFICATION_APP_LAUNCH_DETAILS_METHOD = "getNotificationAppLaunchDetails"; + private static final String REQUEST_NOTIFICATIONS_PERMISSION_METHOD = "requestNotificationsPermission"; + private static final String REQUEST_EXACT_ALARMS_PERMISSION_METHOD = "requestExactAlarmsPermission"; + + private static final String REQUEST_FULL_SCREEN_INTENT_PERMISSION_METHOD = "requestFullScreenIntentPermission"; + private static final String REQUEST_NOTIFICATION_POLICY_ACCESS_METHOD = "requestNotificationPolicyAccess"; private static final String HAS_NOTIFICATION_POLICY_ACCESS_METHOD = "hasNotificationPolicyAccess"; private static final String METHOD_CHANNEL = "dexterous.com/flutter/local_notifications"; private static final String INVALID_ICON_ERROR_CODE = "invalid_icon"; @@ -182,25 +172,18 @@ public class FlutterLocalNotificationsPlugin private static final String INVALID_SOUND_ERROR_CODE = "invalid_sound"; private static final String INVALID_LED_DETAILS_ERROR_CODE = "invalid_led_details"; private static final String UNSUPPORTED_OS_VERSION_ERROR_CODE = "unsupported_os_version"; - private static final String GET_ACTIVE_NOTIFICATIONS_ERROR_MESSAGE = - "Android version must be 6.0 or newer to use getActiveNotifications"; + private static final String GET_ACTIVE_NOTIFICATIONS_ERROR_MESSAGE = "Android version must be 6.0 or newer to use getActiveNotifications"; private static final String GET_NOTIFICATION_CHANNELS_ERROR_CODE = "getNotificationChannelsError"; - private static final String GET_ACTIVE_NOTIFICATION_MESSAGING_STYLE_ERROR_CODE = - "getActiveNotificationMessagingStyleError"; - private static final String INVALID_LED_DETAILS_ERROR_MESSAGE = - "Must specify both ledOnMs and ledOffMs to configure the blink cycle on older versions of" - + " Android before Oreo"; + private static final String GET_ACTIVE_NOTIFICATION_MESSAGING_STYLE_ERROR_CODE = "getActiveNotificationMessagingStyleError"; + private static final String INVALID_LED_DETAILS_ERROR_MESSAGE = "Must specify both ledOnMs and ledOffMs to configure the blink cycle on older versions of" + + " Android before Oreo"; private static final String NOTIFICATION_LAUNCHED_APP = "notificationLaunchedApp"; - private static final String INVALID_DRAWABLE_RESOURCE_ERROR_MESSAGE = - "The resource %s could not be found. Please make sure it has been added as a drawable" - + " resource to your Android head project."; - private static final String INVALID_RAW_RESOURCE_ERROR_MESSAGE = - "The resource %s could not be found. Please make sure it has been added as a raw resource to" - + " your Android head project."; - private static final String PERMISSION_REQUEST_IN_PROGRESS_ERROR_CODE = - "permissionRequestInProgress"; - private static final String PERMISSION_REQUEST_IN_PROGRESS_ERROR_MESSAGE = - "Another permission request is already in progress"; + private static final String INVALID_DRAWABLE_RESOURCE_ERROR_MESSAGE = "The resource %s could not be found. Please make sure it has been added as a drawable" + + " resource to your Android head project."; + private static final String INVALID_RAW_RESOURCE_ERROR_MESSAGE = "The resource %s could not be found. Please make sure it has been added as a raw resource to" + + " your Android head project."; + private static final String PERMISSION_REQUEST_IN_PROGRESS_ERROR_CODE = "permissionRequestInProgress"; + private static final String PERMISSION_REQUEST_IN_PROGRESS_ERROR_MESSAGE = "Another permission request is already in progress"; private static final String EXACT_ALARMS_PERMISSION_ERROR_CODE = "exact_alarms_not_permitted"; private static final String CANCEL_ID = "id"; private static final String CANCEL_TAG = "tag"; @@ -224,6 +207,23 @@ public class FlutterLocalNotificationsPlugin private PermissionRequestProgress permissionRequestProgress = PermissionRequestProgress.None; + private static String fmtTitleStyle(com.dexterous.flutterlocalnotifications.models.TitleStyle ts) { + if (ts == null) + return ""; + return "color=" + ts.color + " sizeSp=" + ts.sizeSp + " bold=" + ts.bold + " italic=" + ts.italic + + " iconSpacingDp=" + ts.iconSpacingDp; + } + + private static com.dexterous.flutterlocalnotifications.models.TitleStyle debugFallbackStyle() { + com.dexterous.flutterlocalnotifications.models.TitleStyle ts = new com.dexterous.flutterlocalnotifications.models.TitleStyle(); + ts.color = 0xFF0000FF; // BLUE (ARGB) – TEMPORARY + ts.sizeSp = 18.0; + ts.bold = Boolean.TRUE; + ts.italic = Boolean.FALSE; + ts.iconSpacingDp = 0d; + return ts; + } + static void rescheduleNotifications(Context context) { ArrayList scheduledNotifications = loadScheduledNotifications(context); for (NotificationDetails notificationDetails : scheduledNotifications) { @@ -263,8 +263,8 @@ static void scheduleNextNotification(Context context, NotificationDetails notifi protected static Notification createNotification( Context context, NotificationDetails notificationDetails) { - NotificationChannelDetails notificationChannelDetails = - NotificationChannelDetails.fromNotificationDetails(notificationDetails); + NotificationChannelDetails notificationChannelDetails = NotificationChannelDetails + .fromNotificationDetails(notificationDetails); if (canCreateNotificationChannel(context, notificationChannelDetails)) { setupNotificationChannel(context, notificationChannelDetails); } @@ -276,27 +276,31 @@ protected static Notification createNotification( if (VERSION.SDK_INT >= VERSION_CODES.M) { flags |= PendingIntent.FLAG_IMMUTABLE; } - PendingIntent pendingIntent = - PendingIntent.getActivity(context, notificationDetails.id, intent, flags); - DefaultStyleInformation defaultStyleInformation = - (DefaultStyleInformation) notificationDetails.styleInformation; - NotificationCompat.Builder builder = - new NotificationCompat.Builder(context, notificationDetails.channelId) - .setContentTitle( - defaultStyleInformation.htmlFormatTitle - ? fromHtml(notificationDetails.title) - : notificationDetails.title) - .setContentText( - defaultStyleInformation.htmlFormatBody - ? fromHtml(notificationDetails.body) - : notificationDetails.body) - .setTicker(notificationDetails.ticker) - .setAutoCancel(BooleanUtils.getValue(notificationDetails.autoCancel)) - .setContentIntent(pendingIntent) - .setPriority(notificationDetails.priority) - .setOngoing(BooleanUtils.getValue(notificationDetails.ongoing)) - .setSilent(BooleanUtils.getValue(notificationDetails.silent)) - .setOnlyAlertOnce(BooleanUtils.getValue(notificationDetails.onlyAlertOnce)); + PendingIntent pendingIntent = PendingIntent.getActivity(context, notificationDetails.id, intent, flags); + DefaultStyleInformation defaultStyleInformation = (DefaultStyleInformation) notificationDetails.styleInformation; + final boolean canCustom = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.N; + final com.dexterous.flutterlocalnotifications.models.TitleStyle receivedTitle = notificationDetails.titleStyle; + final com.dexterous.flutterlocalnotifications.models.DescriptionStyle receivedDesc = notificationDetails.descriptionStyle; + + + + final com.dexterous.flutterlocalnotifications.models.TitleStyle effectiveTitle = (canCustom && receivedTitle == null && receivedDesc != null) ? debugFallbackStyle() : receivedTitle; + + final boolean useCustom = canCustom && (effectiveTitle != null || receivedDesc != null); + final RemoteViews customView = + useCustom + ? TitleStyler.INSTANCE.build( + context, notificationDetails.title, notificationDetails.body, effectiveTitle, receivedDesc) + : null; + + NotificationCompat.Builder builder = new NotificationCompat.Builder(context, notificationDetails.channelId) + .setTicker(notificationDetails.ticker) + .setAutoCancel(BooleanUtils.getValue(notificationDetails.autoCancel)) + .setContentIntent(pendingIntent) + .setPriority(notificationDetails.priority) + .setOngoing(BooleanUtils.getValue(notificationDetails.ongoing)) + .setSilent(BooleanUtils.getValue(notificationDetails.silent)) + .setOnlyAlertOnce(BooleanUtils.getValue(notificationDetails.onlyAlertOnce)); if (notificationDetails.actions != null) { // Space out request codes by 16 so even with 16 actions they won't clash @@ -334,10 +338,9 @@ protected static Notification createNotification( } @SuppressLint("UnspecifiedImmutableFlag") - final PendingIntent actionPendingIntent = - action.showsUserInterface != null && action.showsUserInterface - ? PendingIntent.getActivity(context, requestCode++, actionIntent, actionFlags) - : PendingIntent.getBroadcast(context, requestCode++, actionIntent, actionFlags); + final PendingIntent actionPendingIntent = action.showsUserInterface != null && action.showsUserInterface + ? PendingIntent.getActivity(context, requestCode++, actionIntent, actionFlags) + : PendingIntent.getBroadcast(context, requestCode++, actionIntent, actionFlags); final Spannable actionTitleSpannable = new SpannableString(action.title); if (action.titleColor != null) { @@ -363,8 +366,7 @@ protected static Notification createNotification( if (action.actionInputs != null) { for (NotificationActionInput input : action.actionInputs) { - RemoteInput.Builder remoteInput = - new RemoteInput.Builder(INPUT_RESULT).setLabel(input.label); + RemoteInput.Builder remoteInput = new RemoteInput.Builder(INPUT_RESULT).setLabel(input.label); if (input.allowFreeFormInput != null) { remoteInput.setAllowFreeFormInput(input.allowFreeFormInput); } @@ -426,7 +428,7 @@ protected static Notification createNotification( builder.setShortcutId(notificationDetails.shortcutId); } - if (!StringUtils.isNullOrEmpty(notificationDetails.subText)) { + if (customView == null && !StringUtils.isNullOrEmpty(notificationDetails.subText)) { builder.setSubText(notificationDetails.subText); } @@ -439,13 +441,34 @@ protected static Notification createNotification( setSound(context, notificationDetails, builder); setVibrationPattern(notificationDetails, builder); setLights(notificationDetails, builder); - setStyle(context, notificationDetails, builder); setProgress(notificationDetails, builder); setCategory(notificationDetails, builder); setTimeoutAfter(notificationDetails, builder); + if (customView == null) { + setStyle(context, notificationDetails, builder); + } + if (customView != null) { + // Attach our custom layout in ALL paths and suppress any system text + builder.setCustomContentView(customView); + builder.setCustomBigContentView(customView); + builder.setCustomHeadsUpContentView(customView); + builder.setStyle(new NotificationCompat.DecoratedCustomViewStyle()); + builder.setContentTitle((CharSequence) null); + builder.setContentText((CharSequence) null); + builder.setSubText((CharSequence) null); + } else { + // Fallback to system template + builder.setContentTitle( + defaultStyleInformation.htmlFormatTitle + ? fromHtml(notificationDetails.title) + : notificationDetails.title); + builder.setContentText( + defaultStyleInformation.htmlFormatBody + ? fromHtml(notificationDetails.body) + : notificationDetails.body); + } Notification notification = builder.build(); - if (notificationDetails.additionalFlags != null - && notificationDetails.additionalFlags.length > 0) { + if (notificationDetails.additionalFlags != null && notificationDetails.additionalFlags.length > 0) { for (int additionalFlag : notificationDetails.additionalFlags) { notification.flags |= additionalFlag; } @@ -456,18 +479,19 @@ protected static Notification createNotification( private static Boolean canCreateNotificationChannel( Context context, NotificationChannelDetails notificationChannelDetails) { if (VERSION.SDK_INT >= VERSION_CODES.O) { - NotificationManager notificationManager = - (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - NotificationChannel notificationChannel = - notificationManager.getNotificationChannel(notificationChannelDetails.id); - // only create/update the channel when needed/specified. Allow this happen to when + NotificationManager notificationManager = (NotificationManager) context + .getSystemService(Context.NOTIFICATION_SERVICE); + NotificationChannel notificationChannel = notificationManager + .getNotificationChannel(notificationChannelDetails.id); + // only create/update the channel when needed/specified. Allow this happen to + // when // channelAction may be null to support cases where notifications had been - // created on older versions of the plugin where channel management options weren't available + // created on older versions of the plugin where channel management options + // weren't available // back then return ((notificationChannel == null - && (notificationChannelDetails.channelAction == null - || notificationChannelDetails.channelAction - == NotificationChannelAction.CreateIfNotExists)) + && (notificationChannelDetails.channelAction == null + || notificationChannelDetails.channelAction == NotificationChannelAction.CreateIfNotExists)) || (notificationChannel != null && notificationChannelDetails.channelAction == NotificationChannelAction.Update)); } @@ -481,11 +505,11 @@ private static void setSmallIcon( if (!StringUtils.isNullOrEmpty(notificationDetails.icon)) { builder.setSmallIcon(getDrawableResourceId(context, notificationDetails.icon)); } else { - SharedPreferences sharedPreferences = - context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE); + SharedPreferences sharedPreferences = context.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE); String defaultIcon = sharedPreferences.getString(DEFAULT_ICON, null); if (StringUtils.isNullOrEmpty(defaultIcon)) { - // for backwards compatibility: this is for handling the old way references to the icon used + // for backwards compatibility: this is for handling the old way references to + // the icon used // to be kept but should be removed in future builder.setSmallIcon(notificationDetails.iconResourceId); @@ -498,17 +522,16 @@ private static void setSmallIcon( @NonNull static Gson buildGson() { if (gson == null) { - RuntimeTypeAdapterFactory styleInformationAdapter = - RuntimeTypeAdapterFactory.of(StyleInformation.class) - .registerSubtype(DefaultStyleInformation.class) - .registerSubtype(BigTextStyleInformation.class) - .registerSubtype(BigPictureStyleInformation.class) - .registerSubtype(InboxStyleInformation.class) - .registerSubtype(MessagingStyleInformation.class); - GsonBuilder builder = - new GsonBuilder() - .registerTypeAdapter(ScheduleMode.class, new ScheduleMode.Deserializer()) - .registerTypeAdapterFactory(styleInformationAdapter); + RuntimeTypeAdapterFactory styleInformationAdapter = RuntimeTypeAdapterFactory + .of(StyleInformation.class) + .registerSubtype(DefaultStyleInformation.class) + .registerSubtype(BigTextStyleInformation.class) + .registerSubtype(BigPictureStyleInformation.class) + .registerSubtype(InboxStyleInformation.class) + .registerSubtype(MessagingStyleInformation.class); + GsonBuilder builder = new GsonBuilder() + .registerTypeAdapter(ScheduleMode.class, new ScheduleMode.Deserializer()) + .registerTypeAdapterFactory(styleInformationAdapter); gson = builder.create(); } return gson; @@ -516,12 +539,12 @@ static Gson buildGson() { private static ArrayList loadScheduledNotifications(Context context) { ArrayList scheduledNotifications = new ArrayList<>(); - SharedPreferences sharedPreferences = - context.getSharedPreferences(SCHEDULED_NOTIFICATIONS, Context.MODE_PRIVATE); + SharedPreferences sharedPreferences = context.getSharedPreferences(SCHEDULED_NOTIFICATIONS, Context.MODE_PRIVATE); String json = sharedPreferences.getString(SCHEDULED_NOTIFICATIONS, null); if (json != null) { Gson gson = buildGson(); - Type type = new TypeToken>() {}.getType(); + Type type = new TypeToken>() { + }.getType(); scheduledNotifications = gson.fromJson(json, type); } return scheduledNotifications; @@ -531,15 +554,14 @@ private static void saveScheduledNotifications( Context context, ArrayList scheduledNotifications) { Gson gson = buildGson(); String json = gson.toJson(scheduledNotifications); - SharedPreferences sharedPreferences = - context.getSharedPreferences(SCHEDULED_NOTIFICATIONS, Context.MODE_PRIVATE); + SharedPreferences sharedPreferences = context.getSharedPreferences(SCHEDULED_NOTIFICATIONS, Context.MODE_PRIVATE); SharedPreferences.Editor editor = sharedPreferences.edit(); editor.putString(SCHEDULED_NOTIFICATIONS, json).apply(); } static void removeNotificationFromCache(Context context, Integer notificationId) { ArrayList scheduledNotifications = loadScheduledNotifications(context); - for (Iterator it = scheduledNotifications.iterator(); it.hasNext(); ) { + for (Iterator it = scheduledNotifications.iterator(); it.hasNext();) { NotificationDetails notificationDetails = it.next(); if (notificationDetails.id.equals(notificationId)) { it.remove(); @@ -561,7 +583,8 @@ private static Spanned fromHtml(String html) { } } - // This is left to support old apps need this done when a notification is rescheduled and used the + // This is left to support old apps need this done when a notification is + // rescheduled and used the // deprecated schedule() method of the plugin private static void scheduleNotification( Context context, @@ -571,8 +594,7 @@ private static void scheduleNotification( String notificationDetailsJson = gson.toJson(notificationDetails); Intent notificationIntent = new Intent(context, ScheduledNotificationReceiver.class); notificationIntent.putExtra(NOTIFICATION_DETAILS, notificationDetailsJson); - PendingIntent pendingIntent = - getBroadcastPendingIntent(context, notificationDetails.id, notificationIntent); + PendingIntent pendingIntent = getBroadcastPendingIntent(context, notificationDetails.id, notificationIntent); AlarmManager alarmManager = getAlarmManager(context); setupAlarm( @@ -594,15 +616,13 @@ private static void zonedScheduleNotification( String notificationDetailsJson = gson.toJson(notificationDetails); Intent notificationIntent = new Intent(context, ScheduledNotificationReceiver.class); notificationIntent.putExtra(NOTIFICATION_DETAILS, notificationDetailsJson); - PendingIntent pendingIntent = - getBroadcastPendingIntent(context, notificationDetails.id, notificationIntent); + PendingIntent pendingIntent = getBroadcastPendingIntent(context, notificationDetails.id, notificationIntent); AlarmManager alarmManager = getAlarmManager(context); - long epochMilli = - ZonedDateTime.of( - LocalDateTime.parse(notificationDetails.scheduledDateTime), - ZoneId.of(notificationDetails.timeZoneName)) - .toInstant() - .toEpochMilli(); + long epochMilli = ZonedDateTime.of( + LocalDateTime.parse(notificationDetails.scheduledDateTime), + ZoneId.of(notificationDetails.timeZoneName)) + .toInstant() + .toEpochMilli(); setupAlarm(notificationDetails, alarmManager, epochMilli, pendingIntent); @@ -614,17 +634,16 @@ private static void zonedScheduleNotification( private static void scheduleNextRepeatingNotification( Context context, NotificationDetails notificationDetails) { long repeatInterval = calculateRepeatIntervalMilliseconds(notificationDetails); - long notificationTriggerTime = - calculateNextNotificationTrigger(notificationDetails.calledAt, repeatInterval); + long notificationTriggerTime = calculateNextNotificationTrigger(notificationDetails.calledAt, repeatInterval); Gson gson = buildGson(); String notificationDetailsJson = gson.toJson(notificationDetails); Intent notificationIntent = new Intent(context, ScheduledNotificationReceiver.class); notificationIntent.putExtra(NOTIFICATION_DETAILS, notificationDetailsJson); - PendingIntent pendingIntent = - getBroadcastPendingIntent(context, notificationDetails.id, notificationIntent); + PendingIntent pendingIntent = getBroadcastPendingIntent(context, notificationDetails.id, notificationIntent); AlarmManager alarmManager = getAlarmManager(context); if (notificationDetails.scheduleMode == null) { - // This is to account for notifications created in older versions prior to allowWhileIdle + // This is to account for notifications created in older versions prior to + // allowWhileIdle // being added to the deserialiser. // Reference to old behaviour: // https://github.com/MaikuB/flutter_local_notifications/blob/4b723e750d1371206520b10a122a444c4bba7475/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/FlutterLocalNotificationsPlugin.java#L569C37-L569C37 @@ -691,19 +710,18 @@ private static void repeatNotification( notificationTriggerTime = calendar.getTimeInMillis(); } - notificationTriggerTime = - calculateNextNotificationTrigger(notificationTriggerTime, repeatInterval); + notificationTriggerTime = calculateNextNotificationTrigger(notificationTriggerTime, repeatInterval); Gson gson = buildGson(); String notificationDetailsJson = gson.toJson(notificationDetails); Intent notificationIntent = new Intent(context, ScheduledNotificationReceiver.class); notificationIntent.putExtra(NOTIFICATION_DETAILS, notificationDetailsJson); - PendingIntent pendingIntent = - getBroadcastPendingIntent(context, notificationDetails.id, notificationIntent); + PendingIntent pendingIntent = getBroadcastPendingIntent(context, notificationDetails.id, notificationIntent); AlarmManager alarmManager = getAlarmManager(context); if (notificationDetails.scheduleMode == null) { - // This is to account for notifications created in older versions prior to allowWhileIdle + // This is to account for notifications created in older versions prior to + // allowWhileIdle // being added to the deserialiser. // Reference to old behaviour: // https://github.com/MaikuB/flutter_local_notifications/blob/4b723e750d1371206520b10a122a444c4bba7475/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/FlutterLocalNotificationsPlugin.java#L642 @@ -730,7 +748,8 @@ private static void setupAlarm( PendingIntent pendingIntent) { if (notificationDetails.scheduleMode == null) { - // This is to account for notifications created in older versions prior to allowWhileIdle + // This is to account for notifications created in older versions prior to + // allowWhileIdle // being added to the deserialiser. // Reference to old behaviour: // https://github.com/MaikuB/flutter_local_notifications/blob/4b723e750d1371206520b10a122a444c4bba7475/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/FlutterLocalNotificationsPlugin.java#L515 @@ -835,7 +854,8 @@ private static int getDrawableResourceId(Context context, String name) { @SuppressWarnings("unchecked") private static byte[] castObjectToByteArray(Object data) { byte[] byteArray; - // if data is deserialized by gson, it is of the wrong type and we have to convert it + // if data is deserialized by gson, it is of the wrong type and we have to + // convert it if (data instanceof ArrayList) { List l = (ArrayList) data; byteArray = new byte[l.size()]; @@ -852,9 +872,8 @@ private static Bitmap getBitmapFromSource( Context context, Object data, BitmapSource bitmapSource) { Bitmap bitmap = null; if (bitmapSource == BitmapSource.DrawableResource) { - bitmap = - BitmapFactory.decodeResource( - context.getResources(), getDrawableResourceId(context, (String) data)); + bitmap = BitmapFactory.decodeResource( + context.getResources(), getDrawableResourceId(context, (String) data)); } else if (bitmapSource == BitmapSource.FilePath) { bitmap = BitmapFactory.decodeFile((String) data); } else if (bitmapSource == BitmapSource.ByteArray) { @@ -869,8 +888,7 @@ private static IconCompat getIconFromSource(Context context, Object data, IconSo IconCompat icon = null; switch (iconSource) { case DrawableResource: - icon = - IconCompat.createWithResource(context, getDrawableResourceId(context, (String) data)); + icon = IconCompat.createWithResource(context, getDrawableResourceId(context, (String) data)); break; case BitmapFilePath: icon = IconCompat.createWithBitmap(BitmapFactory.decodeFile((String) data)); @@ -881,8 +899,8 @@ private static IconCompat getIconFromSource(Context context, Object data, IconSo case FlutterBitmapAsset: try { FlutterLoader flutterLoader = FlutterInjector.instance().flutterLoader(); - AssetFileDescriptor assetFileDescriptor = - context.getAssets().openFd(flutterLoader.getLookupKeyForAsset((String) data)); + AssetFileDescriptor assetFileDescriptor = context.getAssets() + .openFd(flutterLoader.getLookupKeyForAsset((String) data)); FileInputStream fileInputStream = assetFileDescriptor.createInputStream(); icon = IconCompat.createWithBitmap(BitmapFactory.decodeStream(fileInputStream)); fileInputStream.close(); @@ -903,8 +921,9 @@ private static IconCompat getIconFromSource(Context context, Object data, IconSo /** * Sets the visibility property to the input Notification Builder * - * @throws IllegalArgumentException If `notificationDetails.visibility` is not null but also not - * matches any known index. + * @throws IllegalArgumentException If `notificationDetails.visibility` is not + * null but also not + * matches any known index. */ private static void setVisibility( NotificationDetails notificationDetails, NotificationCompat.Builder builder) { @@ -956,7 +975,7 @@ private static void setVibrationPattern( builder.setVibrate(notificationDetails.vibrationPattern); } } else { - builder.setVibrate(new long[] {0}); + builder.setVibrate(new long[] { 0 }); } } @@ -975,9 +994,8 @@ private static void setSound( NotificationDetails notificationDetails, NotificationCompat.Builder builder) { if (BooleanUtils.getValue(notificationDetails.playSound)) { - Uri uri = - retrieveSoundResourceUri( - context, notificationDetails.sound, notificationDetails.soundSource); + Uri uri = retrieveSoundResourceUri( + context, notificationDetails.sound, notificationDetails.soundSource); builder.setSound(uri); } else { builder.setSound(null); @@ -1045,21 +1063,18 @@ private static void setBigPictureStyle( Context context, NotificationDetails notificationDetails, NotificationCompat.Builder builder) { - BigPictureStyleInformation bigPictureStyleInformation = - (BigPictureStyleInformation) notificationDetails.styleInformation; + BigPictureStyleInformation bigPictureStyleInformation = (BigPictureStyleInformation) notificationDetails.styleInformation; NotificationCompat.BigPictureStyle bigPictureStyle = new NotificationCompat.BigPictureStyle(); if (bigPictureStyleInformation.contentTitle != null) { - CharSequence contentTitle = - bigPictureStyleInformation.htmlFormatContentTitle - ? fromHtml(bigPictureStyleInformation.contentTitle) - : bigPictureStyleInformation.contentTitle; + CharSequence contentTitle = bigPictureStyleInformation.htmlFormatContentTitle + ? fromHtml(bigPictureStyleInformation.contentTitle) + : bigPictureStyleInformation.contentTitle; bigPictureStyle.setBigContentTitle(contentTitle); } if (bigPictureStyleInformation.summaryText != null) { - CharSequence summaryText = - bigPictureStyleInformation.htmlFormatSummaryText - ? fromHtml(bigPictureStyleInformation.summaryText) - : bigPictureStyleInformation.summaryText; + CharSequence summaryText = bigPictureStyleInformation.htmlFormatSummaryText + ? fromHtml(bigPictureStyleInformation.summaryText) + : bigPictureStyleInformation.summaryText; bigPictureStyle.setSummaryText(summaryText); } @@ -1084,21 +1099,18 @@ private static void setBigPictureStyle( private static void setInboxStyle( NotificationDetails notificationDetails, NotificationCompat.Builder builder) { - InboxStyleInformation inboxStyleInformation = - (InboxStyleInformation) notificationDetails.styleInformation; + InboxStyleInformation inboxStyleInformation = (InboxStyleInformation) notificationDetails.styleInformation; NotificationCompat.InboxStyle inboxStyle = new NotificationCompat.InboxStyle(); if (inboxStyleInformation.contentTitle != null) { - CharSequence contentTitle = - inboxStyleInformation.htmlFormatContentTitle - ? fromHtml(inboxStyleInformation.contentTitle) - : inboxStyleInformation.contentTitle; + CharSequence contentTitle = inboxStyleInformation.htmlFormatContentTitle + ? fromHtml(inboxStyleInformation.contentTitle) + : inboxStyleInformation.contentTitle; inboxStyle.setBigContentTitle(contentTitle); } if (inboxStyleInformation.summaryText != null) { - CharSequence summaryText = - inboxStyleInformation.htmlFormatSummaryText - ? fromHtml(inboxStyleInformation.summaryText) - : inboxStyleInformation.summaryText; + CharSequence summaryText = inboxStyleInformation.htmlFormatSummaryText + ? fromHtml(inboxStyleInformation.summaryText) + : inboxStyleInformation.summaryText; inboxStyle.setSummaryText(summaryText); } if (inboxStyleInformation.lines != null) { @@ -1110,8 +1122,7 @@ private static void setInboxStyle( } private static void setMediaStyle(NotificationCompat.Builder builder) { - androidx.media.app.NotificationCompat.MediaStyle mediaStyle = - new androidx.media.app.NotificationCompat.MediaStyle(); + androidx.media.app.NotificationCompat.MediaStyle mediaStyle = new androidx.media.app.NotificationCompat.MediaStyle(); builder.setStyle(mediaStyle); } @@ -1119,11 +1130,9 @@ private static void setMessagingStyle( Context context, NotificationDetails notificationDetails, NotificationCompat.Builder builder) { - MessagingStyleInformation messagingStyleInformation = - (MessagingStyleInformation) notificationDetails.styleInformation; + MessagingStyleInformation messagingStyleInformation = (MessagingStyleInformation) notificationDetails.styleInformation; Person person = buildPerson(context, messagingStyleInformation.person); - NotificationCompat.MessagingStyle messagingStyle = - new NotificationCompat.MessagingStyle(person); + NotificationCompat.MessagingStyle messagingStyle = new NotificationCompat.MessagingStyle(person); messagingStyle.setGroupConversation( BooleanUtils.getValue(messagingStyleInformation.groupConversation)); if (messagingStyleInformation.conversationTitle != null) { @@ -1141,11 +1150,10 @@ private static void setMessagingStyle( private static NotificationCompat.MessagingStyle.Message createMessage( Context context, MessageDetails messageDetails) { - NotificationCompat.MessagingStyle.Message message = - new NotificationCompat.MessagingStyle.Message( - messageDetails.text, - messageDetails.timestamp, - buildPerson(context, messageDetails.person)); + NotificationCompat.MessagingStyle.Message message = new NotificationCompat.MessagingStyle.Message( + messageDetails.text, + messageDetails.timestamp, + buildPerson(context, messageDetails.person)); if (messageDetails.dataUri != null && messageDetails.dataMimeType != null) { message.setData(messageDetails.dataMimeType, Uri.parse(messageDetails.dataUri)); } @@ -1178,88 +1186,130 @@ private static Person buildPerson(Context context, PersonDetails personDetails) private static void setBigTextStyle( NotificationDetails notificationDetails, NotificationCompat.Builder builder) { - BigTextStyleInformation bigTextStyleInformation = - (BigTextStyleInformation) notificationDetails.styleInformation; + BigTextStyleInformation bigTextStyleInformation = (BigTextStyleInformation) notificationDetails.styleInformation; NotificationCompat.BigTextStyle bigTextStyle = new NotificationCompat.BigTextStyle(); if (bigTextStyleInformation.bigText != null) { - CharSequence bigText = - bigTextStyleInformation.htmlFormatBigText - ? fromHtml(bigTextStyleInformation.bigText) - : bigTextStyleInformation.bigText; + CharSequence bigText = bigTextStyleInformation.htmlFormatBigText + ? fromHtml(bigTextStyleInformation.bigText) + : bigTextStyleInformation.bigText; bigTextStyle.bigText(bigText); } if (bigTextStyleInformation.contentTitle != null) { - CharSequence contentTitle = - bigTextStyleInformation.htmlFormatContentTitle - ? fromHtml(bigTextStyleInformation.contentTitle) - : bigTextStyleInformation.contentTitle; + CharSequence contentTitle = bigTextStyleInformation.htmlFormatContentTitle + ? fromHtml(bigTextStyleInformation.contentTitle) + : bigTextStyleInformation.contentTitle; bigTextStyle.setBigContentTitle(contentTitle); } if (bigTextStyleInformation.summaryText != null) { - CharSequence summaryText = - bigTextStyleInformation.htmlFormatSummaryText - ? fromHtml(bigTextStyleInformation.summaryText) - : bigTextStyleInformation.summaryText; + CharSequence summaryText = bigTextStyleInformation.htmlFormatSummaryText + ? fromHtml(bigTextStyleInformation.summaryText) + : bigTextStyleInformation.summaryText; bigTextStyle.setSummaryText(summaryText); } builder.setStyle(bigTextStyle); } private static void setupNotificationChannel( - Context context, NotificationChannelDetails notificationChannelDetails) { - if (VERSION.SDK_INT >= VERSION_CODES.O) { - NotificationManager notificationManager = - (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - NotificationChannel notificationChannel = - new NotificationChannel( - notificationChannelDetails.id, - notificationChannelDetails.name, - notificationChannelDetails.importance); - notificationChannel.setDescription(notificationChannelDetails.description); - notificationChannel.setGroup(notificationChannelDetails.groupId); - if (notificationChannelDetails.playSound) { - Integer audioAttributesUsage = - notificationChannelDetails.audioAttributesUsage != null - ? notificationChannelDetails.audioAttributesUsage - : AudioAttributes.USAGE_NOTIFICATION; - AudioAttributes audioAttributes = - new AudioAttributes.Builder().setUsage(audioAttributesUsage).build(); - Uri uri = - retrieveSoundResourceUri( - context, notificationChannelDetails.sound, notificationChannelDetails.soundSource); - notificationChannel.setSound(uri, audioAttributes); - } else { - notificationChannel.setSound(null, null); - } - - if (BooleanUtils.getValue(notificationChannelDetails.bypassDnd)) { - boolean isAccessGranted = notificationManager.isNotificationPolicyAccessGranted(); + Context context, NotificationChannelDetails d) { + if (VERSION.SDK_INT < VERSION_CODES.O) + return; - if (isAccessGranted) { - notificationChannel.setBypassDnd(true); - } else { - Log.w( - TAG, - "Channel '" - + notificationChannelDetails.name - + "' was set to bypass Do Not Disturb but the OS prevents it."); + final NotificationManager nm = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); + + // -------- Sanitize & defaults -------- + // Channel ID must be non-empty + final String channelId = (d.id != null && !d.id.trim().isEmpty()) ? d.id.trim() : "default"; + + // Channel name must be non-empty (system enforces) + final String channelName = (d.name != null && !d.name.trim().isEmpty()) ? d.name.trim() : "General"; + + // Importance must be in allowed range; default if null/out-of-range + int imp = (d.importance != null) ? d.importance : NotificationManager.IMPORTANCE_DEFAULT; + // clamp to [NONE..HIGH] (O+) + if (imp < NotificationManager.IMPORTANCE_NONE) + imp = NotificationManager.IMPORTANCE_NONE; + if (imp > NotificationManager.IMPORTANCE_HIGH) + imp = NotificationManager.IMPORTANCE_HIGH; + + final boolean playSound = BooleanUtils.getValue(d.playSound); + final int usage = (d.audioAttributesUsage != null) + ? d.audioAttributesUsage + : AudioAttributes.USAGE_NOTIFICATION; + + final boolean enableVibration = BooleanUtils.getValue(d.enableVibration); + final boolean enableLights = BooleanUtils.getValue(d.enableLights); + final boolean showBadge = BooleanUtils.getValue(d.showBadge); + + // Vibration pattern: filter invalid/negative values + long[] vib = d.vibrationPattern; + if (vib != null && vib.length > 0) { + boolean bad = false; + for (int i = 0; i < vib.length; i++) { + if (vib[i] < 0) { + bad = true; + break; } } + if (bad) + vib = null; + } - notificationChannel.enableVibration( - BooleanUtils.getValue(notificationChannelDetails.enableVibration)); - if (notificationChannelDetails.vibrationPattern != null - && notificationChannelDetails.vibrationPattern.length > 0) { - notificationChannel.setVibrationPattern(notificationChannelDetails.vibrationPattern); - } - boolean enableLights = BooleanUtils.getValue(notificationChannelDetails.enableLights); - notificationChannel.enableLights(enableLights); - if (enableLights && notificationChannelDetails.ledColor != null) { - notificationChannel.setLightColor(notificationChannelDetails.ledColor); + // -------- DIAGNOSTIC LOGS -------- + Log.d("FLN-Channel", + "creating channel id=" + channelId + + " name=" + channelName + + " importance=" + imp + + " playSound=" + playSound + + " enableVibration=" + enableVibration + + " enableLights=" + enableLights + + " showBadge=" + showBadge); + + // -------- Create channel -------- + final NotificationChannel ch = new NotificationChannel(channelId, channelName, imp); + + // Description (nullable OK) + ch.setDescription(d.description); + + // Group (guard null/empty) + if (d.groupId != null && !d.groupId.trim().isEmpty()) { + ch.setGroup(d.groupId.trim()); + } + + // Sound + if (playSound) { + final AudioAttributes aa = new AudioAttributes.Builder().setUsage(usage).build(); + final Uri soundUri = retrieveSoundResourceUri(context, d.sound, d.soundSource); + ch.setSound(soundUri, aa); + } else { + ch.setSound(null, null); + } + + // Bypass DND (only if policy granted) + if (BooleanUtils.getValue(d.bypassDnd)) { + if (nm.isNotificationPolicyAccessGranted()) { + ch.setBypassDnd(true); + } else { + Log.w("FLN-Channel", "bypassDnd requested but policy access not granted"); } - notificationChannel.setShowBadge(BooleanUtils.getValue(notificationChannelDetails.showBadge)); - notificationManager.createNotificationChannel(notificationChannel); } + + // Vibration + ch.enableVibration(enableVibration); + if (enableVibration && vib != null && vib.length > 0) { + ch.setVibrationPattern(vib); + } + + // Lights + ch.enableLights(enableLights); + if (enableLights && d.ledColor != null) { + ch.setLightColor(d.ledColor); + } + + ch.setShowBadge(showBadge); + + // Final diagnostic before creating + Log.d("FLN-Channel", "createNotificationChannel() now..."); + nm.createNotificationChannel(ch); } private static Uri retrieveSoundResourceUri( @@ -1268,7 +1318,8 @@ private static Uri retrieveSoundResourceUri( if (StringUtils.isNullOrEmpty(sound)) { uri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION); } else { - // allow null as soundSource was added later and prior to that, it was assumed to be a raw + // allow null as soundSource was added later and prior to that, it was assumed + // to be a raw // resource if (soundSource == null || soundSource == SoundSource.RawResource) { uri = Uri.parse("android.resource://" + context.getPackageName() + "/raw/" + sound); @@ -1326,15 +1377,11 @@ private static void zonedScheduleNextNotificationMatchingDateComponents( } private static String getNextFireDate(NotificationDetails notificationDetails) { - if (notificationDetails.scheduledNotificationRepeatFrequency - == ScheduledNotificationRepeatFrequency.Daily) { - LocalDateTime localDateTime = - LocalDateTime.parse(notificationDetails.scheduledDateTime).plusDays(1); + if (notificationDetails.scheduledNotificationRepeatFrequency == ScheduledNotificationRepeatFrequency.Daily) { + LocalDateTime localDateTime = LocalDateTime.parse(notificationDetails.scheduledDateTime).plusDays(1); return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(localDateTime); - } else if (notificationDetails.scheduledNotificationRepeatFrequency - == ScheduledNotificationRepeatFrequency.Weekly) { - LocalDateTime localDateTime = - LocalDateTime.parse(notificationDetails.scheduledDateTime).plusWeeks(1); + } else if (notificationDetails.scheduledNotificationRepeatFrequency == ScheduledNotificationRepeatFrequency.Weekly) { + LocalDateTime localDateTime = LocalDateTime.parse(notificationDetails.scheduledDateTime).plusWeeks(1); return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(localDateTime); } return null; @@ -1343,19 +1390,18 @@ private static String getNextFireDate(NotificationDetails notificationDetails) { private static String getNextFireDateMatchingDateTimeComponents( NotificationDetails notificationDetails) { ZoneId zoneId = ZoneId.of(notificationDetails.timeZoneName); - ZonedDateTime scheduledDateTime = - ZonedDateTime.of(LocalDateTime.parse(notificationDetails.scheduledDateTime), zoneId); + ZonedDateTime scheduledDateTime = ZonedDateTime.of(LocalDateTime.parse(notificationDetails.scheduledDateTime), + zoneId); ZonedDateTime now = ZonedDateTime.now(zoneId); - ZonedDateTime nextFireDate = - ZonedDateTime.of( - now.getYear(), - now.getMonthValue(), - now.getDayOfMonth(), - scheduledDateTime.getHour(), - scheduledDateTime.getMinute(), - scheduledDateTime.getSecond(), - scheduledDateTime.getNano(), - zoneId); + ZonedDateTime nextFireDate = ZonedDateTime.of( + now.getYear(), + now.getMonthValue(), + now.getDayOfMonth(), + scheduledDateTime.getHour(), + scheduledDateTime.getMinute(), + scheduledDateTime.getSecond(), + scheduledDateTime.getNano(), + zoneId); while (nextFireDate.isBefore(now)) { // adjust to be a date in the future that matches the time nextFireDate = nextFireDate.plusDays(1); @@ -1367,8 +1413,7 @@ private static String getNextFireDateMatchingDateTimeComponents( nextFireDate = nextFireDate.plusDays(1); } return DateTimeFormatter.ISO_LOCAL_DATE_TIME.format(nextFireDate); - } else if (notificationDetails.matchDateTimeComponents - == DateTimeComponents.DayOfMonthAndTime) { + } else if (notificationDetails.matchDateTimeComponents == DateTimeComponents.DayOfMonthAndTime) { while (nextFireDate.getDayOfMonth() != scheduledDateTime.getDayOfMonth()) { nextFireDate = nextFireDate.plusDays(1); } @@ -1389,8 +1434,8 @@ private static NotificationManagerCompat getNotificationManager(Context context) private static boolean launchedActivityFromHistory(Intent intent) { return intent != null - && (intent.getFlags() & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) - == Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY; + && (intent.getFlags() + & Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY) == Intent.FLAG_ACTIVITY_LAUNCHED_FROM_HISTORY; } private void setActivity(Activity flutterActivity) { @@ -1421,8 +1466,7 @@ public void onAttachedToActivity(ActivityPluginBinding binding) { Intent mainActivityIntent = mainActivity.getIntent(); if (!launchedActivityFromHistory(mainActivityIntent)) { if (SELECT_FOREGROUND_NOTIFICATION_ACTION.equals(mainActivityIntent.getAction())) { - Map notificationResponse = - extractNotificationResponseMap(mainActivityIntent); + Map notificationResponse = extractNotificationResponseMap(mainActivityIntent); processForegroundNotificationAction(mainActivityIntent, notificationResponse); } } @@ -1581,8 +1625,7 @@ public void fail(String message) { } private void pendingNotificationRequests(Result result) { - ArrayList scheduledNotifications = - loadScheduledNotifications(applicationContext); + ArrayList scheduledNotifications = loadScheduledNotifications(applicationContext); List> pendingNotifications = new ArrayList<>(); for (NotificationDetails scheduledNotification : scheduledNotifications) { @@ -1601,8 +1644,8 @@ private void getActiveNotifications(Result result) { result.error(UNSUPPORTED_OS_VERSION_ERROR_CODE, GET_ACTIVE_NOTIFICATIONS_ERROR_MESSAGE, null); return; } - NotificationManager notificationManager = - (NotificationManager) applicationContext.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationManager notificationManager = (NotificationManager) applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE); try { StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications(); List> activeNotificationsPayload = new ArrayList<>(); @@ -1658,8 +1701,7 @@ private void zonedSchedule(MethodCall call, Result result) { NotificationDetails notificationDetails = extractNotificationDetails(result, call.arguments()); if (notificationDetails != null) { if (notificationDetails.matchDateTimeComponents != null) { - notificationDetails.scheduledDateTime = - getNextFireDateMatchingDateTimeComponents(notificationDetails); + notificationDetails.scheduledDateTime = getNextFireDateMatchingDateTimeComponents(notificationDetails); } try { zonedScheduleNotification(applicationContext, notificationDetails, true); @@ -1684,11 +1726,10 @@ private void getNotificationAppLaunchDetails(Result result) { Boolean notificationLaunchedApp = false; if (mainActivity != null) { Intent launchIntent = mainActivity.getIntent(); - notificationLaunchedApp = - launchIntent != null - && (SELECT_NOTIFICATION.equals(launchIntent.getAction()) - || SELECT_FOREGROUND_NOTIFICATION_ACTION.equals(launchIntent.getAction())) - && !launchedActivityFromHistory(launchIntent); + notificationLaunchedApp = launchIntent != null + && (SELECT_NOTIFICATION.equals(launchIntent.getAction()) + || SELECT_FOREGROUND_NOTIFICATION_ACTION.equals(launchIntent.getAction())) + && !launchedActivityFromHistory(launchIntent); if (notificationLaunchedApp) { notificationAppLaunchDetails.put( "notificationResponse", extractNotificationResponseMap(launchIntent)); @@ -1713,8 +1754,8 @@ private void initialize(MethodCall call, Result result) { new IsolatePreferences(applicationContext).saveCallbackKeys(dispatcherHandle, callbackHandle); } - SharedPreferences sharedPreferences = - applicationContext.getSharedPreferences(SHARED_PREFERENCES_KEY, Context.MODE_PRIVATE); + SharedPreferences sharedPreferences = applicationContext.getSharedPreferences(SHARED_PREFERENCES_KEY, + Context.MODE_PRIVATE); SharedPreferences.Editor editor = sharedPreferences.edit(); editor.putString(DEFAULT_ICON, defaultIcon).apply(); result.success(true); @@ -1725,7 +1766,8 @@ private void getCallbackHandle(Result result) { result.success(handle); } - // Extracts the details of the notifications passed from the Flutter side and also validates that + // Extracts the details of the notifications passed from the Flutter side and + // also validates that // some of the details (especially resources) passed are valid private NotificationDetails extractNotificationDetails( Result result, Map arguments) { @@ -1756,10 +1798,9 @@ private boolean hasInvalidRawSoundResource( if (!StringUtils.isNullOrEmpty(notificationDetails.sound) && (notificationDetails.soundSource == null || notificationDetails.soundSource == SoundSource.RawResource)) { - int soundResourceId = - applicationContext - .getResources() - .getIdentifier(notificationDetails.sound, "raw", applicationContext.getPackageName()); + int soundResourceId = applicationContext + .getResources() + .getIdentifier(notificationDetails.sound, "raw", applicationContext.getPackageName()); if (soundResourceId == 0) { result.error( INVALID_SOUND_ERROR_CODE, @@ -1774,12 +1815,12 @@ private boolean hasInvalidRawSoundResource( private boolean hasInvalidBigPictureResources( Result result, NotificationDetails notificationDetails) { if (notificationDetails.style == NotificationStyle.BigPicture) { - BigPictureStyleInformation bigPictureStyleInformation = - (BigPictureStyleInformation) notificationDetails.styleInformation; + BigPictureStyleInformation bigPictureStyleInformation = (BigPictureStyleInformation) notificationDetails.styleInformation; if (hasInvalidLargeIcon( result, bigPictureStyleInformation.largeIcon, - bigPictureStyleInformation.largeIconBitmapSource)) return true; + bigPictureStyleInformation.largeIconBitmapSource)) + return true; if (bigPictureStyleInformation.bigPictureBitmapSource == BitmapSource.DrawableResource) { String bigPictureResourceName = (String) bigPictureStyleInformation.bigPicture; @@ -1835,8 +1876,7 @@ private void cancelNotification(Integer id, String tag) { private void cancelAllNotifications(Result result) { NotificationManagerCompat notificationManager = getNotificationManager(applicationContext); notificationManager.cancelAll(); - ArrayList scheduledNotifications = - loadScheduledNotifications(applicationContext); + ArrayList scheduledNotifications = loadScheduledNotifications(applicationContext); if (scheduledNotifications == null || scheduledNotifications.isEmpty()) { result.success(null); return; @@ -1844,8 +1884,7 @@ private void cancelAllNotifications(Result result) { Intent intent = new Intent(applicationContext, ScheduledNotificationReceiver.class); for (NotificationDetails scheduledNotification : scheduledNotifications) { - PendingIntent pendingIntent = - getBroadcastPendingIntent(applicationContext, scheduledNotification.id, intent); + PendingIntent pendingIntent = getBroadcastPendingIntent(applicationContext, scheduledNotification.id, intent); AlarmManager alarmManager = getAlarmManager(applicationContext); alarmManager.cancel(pendingIntent); } @@ -1855,8 +1894,7 @@ private void cancelAllNotifications(Result result) { } private void cancelAllPendingNotifications(Result result) { - ArrayList scheduledNotifications = - loadScheduledNotifications(applicationContext); + ArrayList scheduledNotifications = loadScheduledNotifications(applicationContext); if (scheduledNotifications == null || scheduledNotifications.isEmpty()) { result.success(null); @@ -1867,8 +1905,7 @@ private void cancelAllPendingNotifications(Result result) { Intent intent = new Intent(applicationContext, ScheduledNotificationReceiver.class); for (NotificationDetails scheduledNotification : scheduledNotifications) { - PendingIntent pendingIntent = - getBroadcastPendingIntent(applicationContext, scheduledNotification.id, intent); + PendingIntent pendingIntent = getBroadcastPendingIntent(applicationContext, scheduledNotification.id, intent); alarmManager.cancel(pendingIntent); } @@ -1886,14 +1923,13 @@ public void requestNotificationsPermission(@NonNull PermissionRequestListener ca if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { String permission = Manifest.permission.POST_NOTIFICATIONS; - boolean permissionGranted = - ContextCompat.checkSelfPermission(mainActivity, permission) - == PackageManager.PERMISSION_GRANTED; + boolean permissionGranted = ContextCompat.checkSelfPermission(mainActivity, + permission) == PackageManager.PERMISSION_GRANTED; if (!permissionGranted) { permissionRequestProgress = PermissionRequestProgress.RequestingNotificationPermission; ActivityCompat.requestPermissions( - mainActivity, new String[] {permission}, NOTIFICATION_PERMISSION_REQUEST_CODE); + mainActivity, new String[] { permission }, NOTIFICATION_PERMISSION_REQUEST_CODE); } else { this.callback.complete(true); permissionRequestProgress = PermissionRequestProgress.None; @@ -1941,8 +1977,8 @@ public void requestFullScreenIntentPermission(@NonNull PermissionRequestListener this.callback = callback; if (Build.VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE) { - NotificationManager notificationManager = - (NotificationManager) applicationContext.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationManager notificationManager = (NotificationManager) applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE); AlarmManager alarmManager = getAlarmManager(applicationContext); boolean permissionGranted = notificationManager.canUseFullScreenIntent(); @@ -1975,8 +2011,8 @@ public void requestNotificationPolicyAccess(@NonNull PermissionRequestListener c this.callback = callback; - NotificationManager notificationManager = - (NotificationManager) applicationContext.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationManager notificationManager = (NotificationManager) applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE); boolean permissionGranted = notificationManager.isNotificationPolicyAccessGranted(); if (permissionGranted) { @@ -1996,8 +2032,8 @@ public void hasNotificationPolicyAccess(Result result) { return; } - NotificationManager notificationManager = - (NotificationManager) applicationContext.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationManager notificationManager = (NotificationManager) applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE); boolean isGranted = notificationManager.isNotificationPolicyAccessGranted(); result.success(isGranted); } @@ -2007,8 +2043,7 @@ public boolean onRequestPermissionsResult( int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { if (permissionRequestProgress == PermissionRequestProgress.RequestingNotificationPermission && requestCode == NOTIFICATION_PERMISSION_REQUEST_CODE) { - boolean granted = - grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED; + boolean granted = grantResults.length > 0 && grantResults[0] == PackageManager.PERMISSION_GRANTED; callback.complete(granted); permissionRequestProgress = PermissionRequestProgress.None; return granted; @@ -2051,13 +2086,11 @@ private void processForegroundNotificationAction( private void createNotificationChannelGroup(MethodCall call, Result result) { if (VERSION.SDK_INT >= VERSION_CODES.O) { Map arguments = call.arguments(); - NotificationChannelGroupDetails notificationChannelGroupDetails = - NotificationChannelGroupDetails.from(arguments); - NotificationManager notificationManager = - (NotificationManager) applicationContext.getSystemService(Context.NOTIFICATION_SERVICE); - NotificationChannelGroup notificationChannelGroup = - new NotificationChannelGroup( - notificationChannelGroupDetails.id, notificationChannelGroupDetails.name); + NotificationChannelGroupDetails notificationChannelGroupDetails = NotificationChannelGroupDetails.from(arguments); + NotificationManager notificationManager = (NotificationManager) applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE); + NotificationChannelGroup notificationChannelGroup = new NotificationChannelGroup( + notificationChannelGroupDetails.id, notificationChannelGroupDetails.name); if (VERSION.SDK_INT >= VERSION_CODES.P) { notificationChannelGroup.setDescription(notificationChannelGroupDetails.description); } @@ -2068,8 +2101,8 @@ private void createNotificationChannelGroup(MethodCall call, Result result) { private void deleteNotificationChannelGroup(MethodCall call, Result result) { if (VERSION.SDK_INT >= VERSION_CODES.O) { - NotificationManager notificationManager = - (NotificationManager) applicationContext.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationManager notificationManager = (NotificationManager) applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE); String groupId = call.arguments(); notificationManager.deleteNotificationChannelGroup(groupId); } @@ -2078,16 +2111,15 @@ private void deleteNotificationChannelGroup(MethodCall call, Result result) { private void createNotificationChannel(MethodCall call, Result result) { Map arguments = call.arguments(); - NotificationChannelDetails notificationChannelDetails = - NotificationChannelDetails.from(arguments); + NotificationChannelDetails notificationChannelDetails = NotificationChannelDetails.from(arguments); setupNotificationChannel(applicationContext, notificationChannelDetails); result.success(null); } private void deleteNotificationChannel(MethodCall call, Result result) { if (VERSION.SDK_INT >= VERSION_CODES.O) { - NotificationManager notificationManager = - (NotificationManager) applicationContext.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationManager notificationManager = (NotificationManager) applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE); String channelId = call.arguments(); notificationManager.deleteNotificationChannel(channelId); } @@ -2102,8 +2134,8 @@ private void getActiveNotificationMessagingStyle(MethodCall call, Result result) null); return; } - NotificationManager notificationManager = - (NotificationManager) applicationContext.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationManager notificationManager = (NotificationManager) applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE); try { Map arguments = call.arguments(); int id = (int) arguments.get("id"); @@ -2124,8 +2156,8 @@ private void getActiveNotificationMessagingStyle(MethodCall call, Result result) return; } - NotificationCompat.MessagingStyle messagingStyle = - NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(notification); + NotificationCompat.MessagingStyle messagingStyle = NotificationCompat.MessagingStyle + .extractMessagingStyleFromNotification(notification); if (messagingStyle == null) { result.success(null); return; @@ -2208,8 +2240,7 @@ private Map describeIcon(IconCompat icon) { private void getNotificationChannels(Result result) { try { - NotificationManagerCompat notificationManagerCompat = - getNotificationManager(applicationContext); + NotificationManagerCompat notificationManagerCompat = getNotificationManager(applicationContext); List channels = notificationManagerCompat.getNotificationChannels(); List> channelsPayload = new ArrayList<>(); for (NotificationChannel channel : channels) { @@ -2249,10 +2280,10 @@ private HashMap getMappedNotificationChannel(NotificationChannel channelPayload.put("soundSource", soundSources.indexOf(SoundSource.RawResource)); channelPayload.put("sound", resource); } else { - // Kept for backwards compatibility when the source resource used to be based on id + // Kept for backwards compatibility when the source resource used to be based on + // id try { - String resourceName = - applicationContext.getResources().getResourceEntryName(resourceId); + String resourceName = applicationContext.getResources().getResourceEntryName(resourceId); if (resourceName != null) { channelPayload.put("soundSource", soundSources.indexOf(SoundSource.RawResource)); channelPayload.put("sound", resourceName); @@ -2296,13 +2327,11 @@ private void startForegroundService(MethodCall call, Result result) { ArrayList foregroundServiceTypes = call.argument("foregroundServiceTypes"); if (foregroundServiceTypes == null || foregroundServiceTypes.size() != 0) { if (notificationData != null && startType != null) { - NotificationDetails notificationDetails = - extractNotificationDetails(result, notificationData); + NotificationDetails notificationDetails = extractNotificationDetails(result, notificationData); if (notificationDetails != null) { if (notificationDetails.id != 0) { - ForegroundServiceStartParameter parameter = - new ForegroundServiceStartParameter( - notificationDetails, startType, foregroundServiceTypes); + ForegroundServiceStartParameter parameter = new ForegroundServiceStartParameter( + notificationDetails, startType, foregroundServiceTypes); Intent intent = new Intent(applicationContext, ForegroundService.class); intent.putExtra(ForegroundServiceStartParameter.EXTRA, parameter); ContextCompat.startForegroundService(applicationContext, intent); @@ -2363,8 +2392,8 @@ public boolean onActivityResult(int requestCode, int resultCode, @Nullable Inten if (permissionRequestProgress == PermissionRequestProgress.RequestingFullScreenIntentPermission && requestCode == FULL_SCREEN_INTENT_PERMISSION_REQUEST_CODE && VERSION.SDK_INT >= VERSION_CODES.UPSIDE_DOWN_CAKE) { - NotificationManager notificationManager = - (NotificationManager) applicationContext.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationManager notificationManager = (NotificationManager) applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE); this.callback.complete(notificationManager.canUseFullScreenIntent()); permissionRequestProgress = PermissionRequestProgress.None; } @@ -2372,8 +2401,8 @@ public boolean onActivityResult(int requestCode, int resultCode, @Nullable Inten if (permissionRequestProgress == PermissionRequestProgress.RequestingNotificationPolicyAccess && requestCode == NOTIFICATION_POLICY_ACCESS_REQUEST_CODE && VERSION.SDK_INT >= VERSION_CODES.M) { - NotificationManager notificationManager = - (NotificationManager) applicationContext.getSystemService(Context.NOTIFICATION_SERVICE); + NotificationManager notificationManager = (NotificationManager) applicationContext + .getSystemService(Context.NOTIFICATION_SERVICE); this.callback.complete(notificationManager.isNotificationPolicyAccessGranted()); permissionRequestProgress = PermissionRequestProgress.None; } diff --git a/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/models/DescriptionStyle.java b/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/models/DescriptionStyle.java new file mode 100644 index 000000000..24ad2e68b --- /dev/null +++ b/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/models/DescriptionStyle.java @@ -0,0 +1,20 @@ +package com.dexterous.flutterlocalnotifications.models; + +import androidx.annotation.Keep; +import com.google.gson.annotations.SerializedName; +import java.io.Serializable; + +@Keep +public class DescriptionStyle implements Serializable { + @SerializedName("color") + public Integer color; + + @SerializedName("sizeSp") + public Double sizeSp; + + @SerializedName("bold") + public Boolean bold; + + @SerializedName("italic") + public Boolean italic; +} \ No newline at end of file diff --git a/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/models/NotificationDetails.java b/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/models/NotificationDetails.java index 47c086da6..bb897237c 100644 --- a/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/models/NotificationDetails.java +++ b/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/models/NotificationDetails.java @@ -6,6 +6,7 @@ import androidx.annotation.Keep; import androidx.annotation.Nullable; +import android.util.Log; import com.dexterous.flutterlocalnotifications.models.styles.BigPictureStyleInformation; import com.dexterous.flutterlocalnotifications.models.styles.BigTextStyleInformation; @@ -15,6 +16,8 @@ import com.dexterous.flutterlocalnotifications.models.styles.StyleInformation; import com.dexterous.flutterlocalnotifications.utils.LongUtils; import com.google.gson.annotations.SerializedName; +import com.dexterous.flutterlocalnotifications.models.TitleStyle; +import com.dexterous.flutterlocalnotifications.models.DescriptionStyle; import java.io.Serializable; import java.util.ArrayList; @@ -117,8 +120,7 @@ public class NotificationDetails implements Serializable { private static final String SCHEDULED_DATE_TIME = "scheduledDateTime"; private static final String TIME_ZONE_NAME = "timeZoneName"; - private static final String SCHEDULED_NOTIFICATION_REPEAT_FREQUENCY = - "scheduledNotificationRepeatFrequency"; + private static final String SCHEDULED_NOTIFICATION_REPEAT_FREQUENCY = "scheduledNotificationRepeatFrequency"; private static final String MATCH_DATE_TIME_COMPONENTS = "matchDateTimeComponents"; private static final String FULL_SCREEN_INTENT = "fullScreenIntent"; @@ -128,6 +130,8 @@ public class NotificationDetails implements Serializable { private static final String COLORIZED = "colorized"; private static final String NUMBER = "number"; private static final String AUDIO_ATTRIBUTES_USAGE = "audioAttributesUsage"; + private static final String TITLE_STYLE = "titleStyle"; + private static final String DESCRIPTION_STYLE = "descriptionStyle"; public Integer id; public String title; @@ -198,8 +202,11 @@ public class NotificationDetails implements Serializable { public Boolean colorized; public Integer number; public Integer audioAttributesUsage; + public TitleStyle titleStyle; + public DescriptionStyle descriptionStyle; - // Note: this is set on the Android to save details about the icon that should be used when + // Note: this is set on the Android to save details about the icon that should + // be used when // re-hydrating scheduled notifications when a device has been restarted. public Integer iconResourceId; @@ -212,13 +219,12 @@ public static NotificationDetails from(Map arguments) { notificationDetails.scheduledDateTime = (String) arguments.get(SCHEDULED_DATE_TIME); notificationDetails.timeZoneName = (String) arguments.get(TIME_ZONE_NAME); if (arguments.containsKey(SCHEDULED_NOTIFICATION_REPEAT_FREQUENCY)) { - notificationDetails.scheduledNotificationRepeatFrequency = - ScheduledNotificationRepeatFrequency.values()[ - (Integer) arguments.get(SCHEDULED_NOTIFICATION_REPEAT_FREQUENCY)]; + notificationDetails.scheduledNotificationRepeatFrequency = ScheduledNotificationRepeatFrequency + .values()[(Integer) arguments.get(SCHEDULED_NOTIFICATION_REPEAT_FREQUENCY)]; } if (arguments.containsKey(MATCH_DATE_TIME_COMPONENTS)) { - notificationDetails.matchDateTimeComponents = - DateTimeComponents.values()[(Integer) arguments.get(MATCH_DATE_TIME_COMPONENTS)]; + notificationDetails.matchDateTimeComponents = DateTimeComponents + .values()[(Integer) arguments.get(MATCH_DATE_TIME_COMPONENTS)]; } if (arguments.containsKey(MILLISECONDS_SINCE_EPOCH)) { notificationDetails.millisecondsSinceEpoch = (Long) arguments.get(MILLISECONDS_SINCE_EPOCH); @@ -227,12 +233,10 @@ public static NotificationDetails from(Map arguments) { notificationDetails.calledAt = (Long) arguments.get(CALLED_AT); } if (arguments.containsKey(REPEAT_INTERVAL)) { - notificationDetails.repeatInterval = - RepeatInterval.values()[(Integer) arguments.get(REPEAT_INTERVAL)]; + notificationDetails.repeatInterval = RepeatInterval.values()[(Integer) arguments.get(REPEAT_INTERVAL)]; } if (arguments.containsKey(REPEAT_INTERVAL_MILLISECONDS)) { - notificationDetails.repeatIntervalMilliseconds = - (Integer) arguments.get(REPEAT_INTERVAL_MILLISECONDS); + notificationDetails.repeatIntervalMilliseconds = (Integer) arguments.get(REPEAT_INTERVAL_MILLISECONDS); } if (arguments.containsKey(REPEAT_TIME)) { @SuppressWarnings("unchecked") @@ -250,30 +254,68 @@ public static NotificationDetails from(Map arguments) { private static void readPlatformSpecifics( Map arguments, NotificationDetails notificationDetails) { @SuppressWarnings("unchecked") - Map platformChannelSpecifics = - (Map) arguments.get(PLATFORM_SPECIFICS); + Map platformChannelSpecifics = (Map) arguments.get(PLATFORM_SPECIFICS); if (platformChannelSpecifics != null) { + Object rawTitle = platformChannelSpecifics.get(TITLE_STYLE); + if (rawTitle instanceof Map) { + @SuppressWarnings("unchecked") + Map m = (Map) rawTitle; + TitleStyle ts = new TitleStyle(); + Object c = m.get("color"); + Object s = m.get("sizeSp"); + Object b = m.get("bold"); + Object i = m.get("italic"); + Object p = m.get("iconSpacingDp"); + if (c instanceof Number) + ts.color = ((Number) c).intValue(); + if (s instanceof Number) + ts.sizeSp = ((Number) s).doubleValue(); + if (b instanceof Boolean) + ts.bold = (Boolean) b; + if (i instanceof Boolean) + ts.italic = (Boolean) i; + if (p instanceof Number){ + ts.iconSpacingDp = ((Number) p).doubleValue(); + } else { + ts.iconSpacingDp = 0d; + } + notificationDetails.titleStyle = ts; + } + Object rawDesc = platformChannelSpecifics.get(DESCRIPTION_STYLE); + if (rawDesc instanceof Map) { + @SuppressWarnings("unchecked") + Map m = (Map) rawDesc; + DescriptionStyle ds = new DescriptionStyle(); + Object c = m.get("color"); + Object s = m.get("sizeSp"); + Object b = m.get("bold"); + Object i = m.get("italic"); + if (c instanceof Number) + ds.color = ((Number) c).intValue(); + if (s instanceof Number) + ds.sizeSp = ((Number) s).doubleValue(); + if (b instanceof Boolean) + ds.bold = (Boolean) b; + if (i instanceof Boolean) + ds.italic = (Boolean) i; + notificationDetails.descriptionStyle = ds; + } notificationDetails.autoCancel = (Boolean) platformChannelSpecifics.get(AUTO_CANCEL); notificationDetails.ongoing = (Boolean) platformChannelSpecifics.get(ONGOING); notificationDetails.silent = (Boolean) platformChannelSpecifics.get(SILENT); - notificationDetails.style = - NotificationStyle.values()[(Integer) platformChannelSpecifics.get(STYLE)]; + notificationDetails.style = NotificationStyle.values()[(Integer) platformChannelSpecifics.get(STYLE)]; readStyleInformation(notificationDetails, platformChannelSpecifics); notificationDetails.icon = (String) platformChannelSpecifics.get(ICON); notificationDetails.priority = (Integer) platformChannelSpecifics.get(PRIORITY); readSoundInformation(notificationDetails, platformChannelSpecifics); - notificationDetails.enableVibration = - (Boolean) platformChannelSpecifics.get(ENABLE_VIBRATION); - notificationDetails.vibrationPattern = - (long[]) platformChannelSpecifics.get(VIBRATION_PATTERN); + notificationDetails.enableVibration = (Boolean) platformChannelSpecifics.get(ENABLE_VIBRATION); + notificationDetails.vibrationPattern = (long[]) platformChannelSpecifics.get(VIBRATION_PATTERN); readGroupingInformation(notificationDetails, platformChannelSpecifics); notificationDetails.onlyAlertOnce = (Boolean) platformChannelSpecifics.get(ONLY_ALERT_ONCE); notificationDetails.showWhen = (Boolean) platformChannelSpecifics.get(SHOW_WHEN); notificationDetails.when = LongUtils.parseLong(platformChannelSpecifics.get(WHEN)); - notificationDetails.usesChronometer = - (Boolean) platformChannelSpecifics.get(USES_CHRONOMETER); - notificationDetails.chronometerCountDown = - (Boolean) platformChannelSpecifics.get(CHRONOMETER_COUNT_DOWN); + notificationDetails.usesChronometer = (Boolean) platformChannelSpecifics.get(USES_CHRONOMETER); + notificationDetails.chronometerCountDown = (Boolean) platformChannelSpecifics.get(CHRONOMETER_COUNT_DOWN); readProgressInformation(notificationDetails, platformChannelSpecifics); readColor(notificationDetails, platformChannelSpecifics); readChannelInformation(notificationDetails, platformChannelSpecifics); @@ -282,27 +324,22 @@ private static void readPlatformSpecifics( notificationDetails.ticker = (String) platformChannelSpecifics.get(TICKER); notificationDetails.visibility = (Integer) platformChannelSpecifics.get(VISIBILITY); if (platformChannelSpecifics.containsKey(SCHEDULE_MODE)) { - notificationDetails.scheduleMode = - ScheduleMode.valueOf((String) platformChannelSpecifics.get(SCHEDULE_MODE)); + notificationDetails.scheduleMode = ScheduleMode.valueOf((String) platformChannelSpecifics.get(SCHEDULE_MODE)); } - notificationDetails.timeoutAfter = - LongUtils.parseLong(platformChannelSpecifics.get(TIMEOUT_AFTER)); + notificationDetails.timeoutAfter = LongUtils.parseLong(platformChannelSpecifics.get(TIMEOUT_AFTER)); notificationDetails.category = (String) platformChannelSpecifics.get(CATEGORY); - notificationDetails.fullScreenIntent = - (Boolean) platformChannelSpecifics.get((FULL_SCREEN_INTENT)); + notificationDetails.fullScreenIntent = (Boolean) platformChannelSpecifics.get((FULL_SCREEN_INTENT)); notificationDetails.shortcutId = (String) platformChannelSpecifics.get(SHORTCUT_ID); notificationDetails.additionalFlags = (int[]) platformChannelSpecifics.get(ADDITIONAL_FLAGS); notificationDetails.subText = (String) platformChannelSpecifics.get(SUB_TEXT); notificationDetails.tag = (String) platformChannelSpecifics.get(TAG); notificationDetails.colorized = (Boolean) platformChannelSpecifics.get(COLORIZED); notificationDetails.number = (Integer) platformChannelSpecifics.get(NUMBER); - notificationDetails.audioAttributesUsage = - (Integer) platformChannelSpecifics.get(AUDIO_ATTRIBUTES_USAGE); + notificationDetails.audioAttributesUsage = (Integer) platformChannelSpecifics.get(AUDIO_ATTRIBUTES_USAGE); if (platformChannelSpecifics.containsKey(ACTIONS)) { @SuppressWarnings("unchecked") - List> inputActions = - (List>) platformChannelSpecifics.get(ACTIONS); + List> inputActions = (List>) platformChannelSpecifics.get(ACTIONS); if (inputActions != null && !inputActions.isEmpty()) { notificationDetails.actions = new ArrayList<>(); for (Map input : inputActions) { @@ -344,10 +381,8 @@ private static void readLargeIconInformation( private static void readGroupingInformation( NotificationDetails notificationDetails, Map platformChannelSpecifics) { notificationDetails.groupKey = (String) platformChannelSpecifics.get(GROUP_KEY); - notificationDetails.setAsGroupSummary = - (Boolean) platformChannelSpecifics.get(SET_AS_GROUP_SUMMARY); - notificationDetails.groupAlertBehavior = - (Integer) platformChannelSpecifics.get(GROUP_ALERT_BEHAVIOR); + notificationDetails.setAsGroupSummary = (Boolean) platformChannelSpecifics.get(SET_AS_GROUP_SUMMARY); + notificationDetails.groupAlertBehavior = (Integer) platformChannelSpecifics.get(GROUP_ALERT_BEHAVIOR); } private static void readSoundInformation( @@ -390,24 +425,19 @@ private static void readChannelInformation( if (VERSION.SDK_INT >= VERSION_CODES.O) { notificationDetails.channelId = (String) platformChannelSpecifics.get(CHANNEL_ID); notificationDetails.channelName = (String) platformChannelSpecifics.get(CHANNEL_NAME); - notificationDetails.channelDescription = - (String) platformChannelSpecifics.get(CHANNEL_DESCRIPTION); + notificationDetails.channelDescription = (String) platformChannelSpecifics.get(CHANNEL_DESCRIPTION); notificationDetails.importance = (Integer) platformChannelSpecifics.get(IMPORTANCE); - notificationDetails.channelBypassDnd = - (Boolean) platformChannelSpecifics.get(CHANNEL_BYPASS_DND); - notificationDetails.channelShowBadge = - (Boolean) platformChannelSpecifics.get(CHANNEL_SHOW_BADGE); - notificationDetails.channelAction = - NotificationChannelAction.values()[ - (Integer) platformChannelSpecifics.get(CHANNEL_ACTION)]; + notificationDetails.channelBypassDnd = (Boolean) platformChannelSpecifics.get(CHANNEL_BYPASS_DND); + notificationDetails.channelShowBadge = (Boolean) platformChannelSpecifics.get(CHANNEL_SHOW_BADGE); + notificationDetails.channelAction = NotificationChannelAction + .values()[(Integer) platformChannelSpecifics.get(CHANNEL_ACTION)]; } } @SuppressWarnings("unchecked") private static void readStyleInformation( NotificationDetails notificationDetails, Map platformSpecifics) { - Map styleInformation = - (Map) platformSpecifics.get(STYLE_INFORMATION); + Map styleInformation = (Map) platformSpecifics.get(STYLE_INFORMATION); DefaultStyleInformation defaultStyleInformation = getDefaultStyleInformation(styleInformation); if (notificationDetails.style == NotificationStyle.Default) { notificationDetails.styleInformation = defaultStyleInformation; @@ -433,16 +463,14 @@ private static void readMessagingStyleInformation( String conversationTitle = (String) styleInformation.get(CONVERSATION_TITLE); Boolean groupConversation = (Boolean) styleInformation.get(GROUP_CONVERSATION); PersonDetails person = readPersonDetails((Map) styleInformation.get(PERSON)); - ArrayList messages = - readMessages((ArrayList>) styleInformation.get(MESSAGES)); - notificationDetails.styleInformation = - new MessagingStyleInformation( - person, - conversationTitle, - groupConversation, - messages, - defaultStyleInformation.htmlFormatTitle, - defaultStyleInformation.htmlFormatBody); + ArrayList messages = readMessages((ArrayList>) styleInformation.get(MESSAGES)); + notificationDetails.styleInformation = new MessagingStyleInformation( + person, + conversationTitle, + groupConversation, + messages, + defaultStyleInformation.htmlFormatTitle, + defaultStyleInformation.htmlFormatBody); } private static PersonDetails readPersonDetails(Map person) { @@ -488,16 +516,15 @@ private static void readInboxStyleInformation( @SuppressWarnings("unchecked") ArrayList lines = (ArrayList) styleInformation.get(LINES); Boolean htmlFormatLines = (Boolean) styleInformation.get(HTML_FORMAT_LINES); - notificationDetails.styleInformation = - new InboxStyleInformation( - defaultStyleInformation.htmlFormatTitle, - defaultStyleInformation.htmlFormatBody, - contentTitle, - htmlFormatContentTitle, - summaryText, - htmlFormatSummaryText, - lines, - htmlFormatLines); + notificationDetails.styleInformation = new InboxStyleInformation( + defaultStyleInformation.htmlFormatTitle, + defaultStyleInformation.htmlFormatBody, + contentTitle, + htmlFormatContentTitle, + summaryText, + htmlFormatSummaryText, + lines, + htmlFormatLines); } private static void readBigTextStyleInformation( @@ -510,16 +537,15 @@ private static void readBigTextStyleInformation( Boolean htmlFormatContentTitle = (Boolean) styleInformation.get(HTML_FORMAT_CONTENT_TITLE); String summaryText = (String) styleInformation.get(SUMMARY_TEXT); Boolean htmlFormatSummaryText = (Boolean) styleInformation.get(HTML_FORMAT_SUMMARY_TEXT); - notificationDetails.styleInformation = - new BigTextStyleInformation( - defaultStyleInformation.htmlFormatTitle, - defaultStyleInformation.htmlFormatBody, - bigText, - htmlFormatBigText, - contentTitle, - htmlFormatContentTitle, - summaryText, - htmlFormatSummaryText); + notificationDetails.styleInformation = new BigTextStyleInformation( + defaultStyleInformation.htmlFormatTitle, + defaultStyleInformation.htmlFormatBody, + bigText, + htmlFormatBigText, + contentTitle, + htmlFormatContentTitle, + summaryText, + htmlFormatSummaryText); } private static void readBigPictureStyleInformation( @@ -533,28 +559,25 @@ private static void readBigPictureStyleInformation( Object largeIcon = styleInformation.get(LARGE_ICON); BitmapSource largeIconBitmapSource = null; if (styleInformation.containsKey(LARGE_ICON_BITMAP_SOURCE)) { - Integer largeIconBitmapSourceArgument = - (Integer) styleInformation.get(LARGE_ICON_BITMAP_SOURCE); + Integer largeIconBitmapSourceArgument = (Integer) styleInformation.get(LARGE_ICON_BITMAP_SOURCE); largeIconBitmapSource = BitmapSource.values()[largeIconBitmapSourceArgument]; } Object bigPicture = styleInformation.get(BIG_PICTURE); - Integer bigPictureBitmapSourceArgument = - (Integer) styleInformation.get(BIG_PICTURE_BITMAP_SOURCE); + Integer bigPictureBitmapSourceArgument = (Integer) styleInformation.get(BIG_PICTURE_BITMAP_SOURCE); BitmapSource bigPictureBitmapSource = BitmapSource.values()[bigPictureBitmapSourceArgument]; Boolean showThumbnail = (Boolean) styleInformation.get(HIDE_EXPANDED_LARGE_ICON); - notificationDetails.styleInformation = - new BigPictureStyleInformation( - defaultStyleInformation.htmlFormatTitle, - defaultStyleInformation.htmlFormatBody, - contentTitle, - htmlFormatContentTitle, - summaryText, - htmlFormatSummaryText, - largeIcon, - largeIconBitmapSource, - bigPicture, - bigPictureBitmapSource, - showThumbnail); + notificationDetails.styleInformation = new BigPictureStyleInformation( + defaultStyleInformation.htmlFormatTitle, + defaultStyleInformation.htmlFormatBody, + contentTitle, + htmlFormatContentTitle, + summaryText, + htmlFormatSummaryText, + largeIcon, + largeIconBitmapSource, + bigPicture, + bigPictureBitmapSource, + showThumbnail); } private static DefaultStyleInformation getDefaultStyleInformation( diff --git a/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/models/TitleStyle.java b/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/models/TitleStyle.java new file mode 100644 index 000000000..f33df5371 --- /dev/null +++ b/flutter_local_notifications/android/src/main/java/com/dexterous/flutterlocalnotifications/models/TitleStyle.java @@ -0,0 +1,24 @@ +package com.dexterous.flutterlocalnotifications.models; + +import androidx.annotation.Keep; +import com.google.gson.annotations.SerializedName; +import java.io.Serializable; + +@Keep +public class TitleStyle implements Serializable { + @SerializedName("color") + public Integer color; + + @SerializedName("sizeSp") + public Double sizeSp; + + @SerializedName("bold") + public Boolean bold; + + @SerializedName("italic") + public Boolean italic; + + // Distance in DP between the notification's icon and the title/body. + @SerializedName("iconSpacingDp") + public Double iconSpacingDp; +} diff --git a/flutter_local_notifications/android/src/main/kotlin/com/dexterous/flutterlocalnotifications/TitleStyler.kt b/flutter_local_notifications/android/src/main/kotlin/com/dexterous/flutterlocalnotifications/TitleStyler.kt new file mode 100644 index 000000000..0e6fc5bb0 --- /dev/null +++ b/flutter_local_notifications/android/src/main/kotlin/com/dexterous/flutterlocalnotifications/TitleStyler.kt @@ -0,0 +1,132 @@ +package com.dexterous.flutterlocalnotifications + +import android.content.Context +import android.graphics.Typeface +import android.os.Build +import android.text.SpannableString +import android.text.Spanned +import android.text.style.StyleSpan +import android.util.Log +import android.util.TypedValue +import android.view.View +import android.widget.RemoteViews +import com.dexterous.flutterlocalnotifications.models.TitleStyle +import com.dexterous.flutterlocalnotifications.models.DescriptionStyle + +internal object TitleStyler { + private const val TAG = "TitleStyler" + private const val MAX_SIZE_SP = 26f + private const val MIN_SIZE_SP = 8f + + + // Builds a RemoteViews that renders a styled title (and optional body). + // API 24+ only. Returns null if title is empty or style is null. + + fun build( + context: Context, + title: CharSequence?, + body: CharSequence?, + titleStyle: TitleStyle?, + descriptionStyle: DescriptionStyle? + ): RemoteViews? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) return null + if (title.isNullOrEmpty()) return null + if (titleStyle == null && descriptionStyle == null) return null + + val rv = RemoteViews(context.packageName, R.layout.fln_notif_title_only) + val titleId = R.id.fln_title + val bodyId = R.id.fln_body + val rootId = R.id.fln_root + + // Build styled title (bold/italic via spans) + val styledTitle: CharSequence = if ((titleStyle?.bold == true) || (titleStyle?.italic == true)) { + val s = SpannableString(title) + when { + titleStyle.bold == true && titleStyle.italic == true -> + s.setSpan(StyleSpan(Typeface.BOLD_ITALIC), 0, s.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + titleStyle.bold == true -> + s.setSpan(StyleSpan(Typeface.BOLD), 0, s.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + titleStyle.italic == true -> + s.setSpan(StyleSpan(Typeface.ITALIC), 0, s.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + s + } else { + title + } + + // Apply title color/size + titleStyle?.color?.let { rv.setTextColor(titleId, it) } + titleStyle?.sizeSp?.let { + if (it > 0.0) { + val sp = it.toFloat().coerceIn(MIN_SIZE_SP, MAX_SIZE_SP) + rv.setTextViewTextSize(titleId, TypedValue.COMPLEX_UNIT_SP, sp) + } else { + Log.d(TAG, "Ignoring non-positive sizeSp: $it") + } + } + + // Set title last (ensures spans + color/size stick) + rv.setTextViewText(titleId, styledTitle) + +// Adjust spacing between icon and text if requested (RTL-safe) +titleStyle?.iconSpacingDp?.let { spacing -> + if (spacing >= 0) { + val metrics = context.resources.displayMetrics + val startPx = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + spacing.toFloat(), + metrics + ).toInt() + + // Defaults from resources (match XML) + val defaultStart = context.resources.getDimensionPixelSize(R.dimen.fln_padding_start) + val defaultTop = context.resources.getDimensionPixelSize(R.dimen.fln_padding_top) + val defaultEnd = context.resources.getDimensionPixelSize(R.dimen.fln_padding_end) + val defaultBottom = context.resources.getDimensionPixelSize(R.dimen.fln_padding_bottom) + + val isRtl = context.resources.configuration.layoutDirection == View.LAYOUT_DIRECTION_RTL + + val left = if (isRtl) defaultStart else startPx + val right = if (isRtl) startPx else defaultEnd + + rv.setViewPadding(rootId, left, defaultTop, right, defaultBottom) + } else { + Log.d(TAG, "Ignoring negative iconSpacingDp: $spacing") + } +} + + // Body/description styled if provided + if (!body.isNullOrEmpty()) { + val styledBody: CharSequence = if ((descriptionStyle?.bold == true) || (descriptionStyle?.italic == true)) { + val s = SpannableString(body) + when { + descriptionStyle.bold == true && descriptionStyle.italic == true -> + s.setSpan(StyleSpan(Typeface.BOLD_ITALIC), 0, s.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + descriptionStyle.bold == true -> + s.setSpan(StyleSpan(Typeface.BOLD), 0, s.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + descriptionStyle.italic == true -> + s.setSpan(StyleSpan(Typeface.ITALIC), 0, s.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + s + } else { + body + } + + descriptionStyle?.color?.let { rv.setTextColor(bodyId, it) } + descriptionStyle?.sizeSp?.let { + if (it > 0.0) { + val sp = it.toFloat().coerceIn(MIN_SIZE_SP, MAX_SIZE_SP) + rv.setTextViewTextSize(bodyId, TypedValue.COMPLEX_UNIT_SP, sp) + } else { + Log.d(TAG, "Ignoring non-positive sizeSp: $it") + } + } + + rv.setViewVisibility(bodyId, View.VISIBLE) + rv.setTextViewText(bodyId, styledBody) + } else { + rv.setViewVisibility(bodyId, View.GONE) + } + return rv + } +} diff --git a/flutter_local_notifications/android/src/main/res/layout/fln_notif_title_only.xml b/flutter_local_notifications/android/src/main/res/layout/fln_notif_title_only.xml new file mode 100644 index 000000000..dfdf8c166 --- /dev/null +++ b/flutter_local_notifications/android/src/main/res/layout/fln_notif_title_only.xml @@ -0,0 +1,33 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/flutter_local_notifications/android/src/main/res/values/dimens.xml b/flutter_local_notifications/android/src/main/res/values/dimens.xml new file mode 100644 index 000000000..181efeb79 --- /dev/null +++ b/flutter_local_notifications/android/src/main/res/values/dimens.xml @@ -0,0 +1,7 @@ + + + 16dp + 16dp + 8dp + 8dp + \ No newline at end of file diff --git a/flutter_local_notifications/android/src/test/kotlin/com/dexterous/flutterlocalnotifications/TitleStylerTest.kt b/flutter_local_notifications/android/src/test/kotlin/com/dexterous/flutterlocalnotifications/TitleStylerTest.kt new file mode 100644 index 000000000..294d3dcc2 --- /dev/null +++ b/flutter_local_notifications/android/src/test/kotlin/com/dexterous/flutterlocalnotifications/TitleStylerTest.kt @@ -0,0 +1,159 @@ +package com.dexterous.flutterlocalnotifications + +import android.graphics.Color +import android.graphics.Typeface +import android.os.Build +import android.util.TypedValue +import android.view.View +import android.widget.TextView +import androidx.test.core.app.ApplicationProvider +import com.dexterous.flutterlocalnotifications.models.DescriptionStyle +import com.dexterous.flutterlocalnotifications.models.TitleStyle +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +class TitleStylerTest { + private lateinit var context: android.content.Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + } + + @Test + @Config(sdk = [Build.VERSION_CODES.M]) + fun build_returnsNullOnPreN_andWhenStyleMissing() { + val style = TitleStyle().apply { bold = true } + val rvPreN = TitleStyler.build(context, "t", null, style, null) + val rvNoStyle = TitleStyler.build(context, "t", null, null, null) + assertNull(rvPreN) + assertNull(rvNoStyle) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun build_returnsNullWhenTitleMissing_orEmpty() { + val style = TitleStyle() + val rvNull = TitleStyler.build(context, null, null, style, null) + val rvEmpty = TitleStyler.build(context, "", null, style, null) + assertNull(rvNull) + assertNull(rvEmpty) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun build_appliesBoldItalicColorAndSize() { + val style = TitleStyle().apply { + bold = true + italic = true + color = Color.GREEN + sizeSp = 40.0 + } + val rv = TitleStyler.build(context, "Title", "Body", style, null) + val root = rv!!.apply(context, null) + val title = root.findViewById(R.id.fln_title) + val expectedSize = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + 26f, + context.resources.displayMetrics + ) + assertEquals(Typeface.BOLD_ITALIC, title.typeface.style) + assertEquals(Color.GREEN, title.currentTextColor) + assertEquals(expectedSize, title.textSize, 0.1f) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun build_handlesBodyVisibility() { + val style = TitleStyle() + val withBody = TitleStyler.build(context, "t", "body", style, null) + val noBody = TitleStyler.build(context, "t", null, style, null) + val bodyView1 = withBody!!.apply(context, null).findViewById(R.id.fln_body) + val bodyView2 = noBody!!.apply(context, null).findViewById(R.id.fln_body) + assertEquals(View.VISIBLE, bodyView1.visibility) + assertEquals(View.GONE, bodyView2.visibility) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun build_clampsSizeSpWithinBounds() { + val big = TitleStyle().apply { sizeSp = 1000.0 } + val small = TitleStyle().apply { sizeSp = 1.0 } + val bigView = TitleStyler.build(context, "t", null, big, null)!!.apply(context, null) + val smallView = TitleStyler.build(context, "t", null, small, null)!!.apply(context, null) + val titleBig = bigView.findViewById(R.id.fln_title) + val titleSmall = smallView.findViewById(R.id.fln_title) + val maxPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 26f, context.resources.displayMetrics) + val minPx = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP, 8f, context.resources.displayMetrics) + assertEquals(maxPx, titleBig.textSize, 0.1f) + assertEquals(minPx, titleSmall.textSize, 0.1f) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun build_appliesBoldOnly_andItalicOnly() { + val boldStyle = TitleStyle().apply { bold = true } + val italicStyle = TitleStyle().apply { italic = true } + val boldView = TitleStyler.build(context, "t", null, boldStyle, null)!!.apply(context, null) + val italicView = TitleStyler.build(context, "t", null, italicStyle, null)!!.apply(context, null) + val titleBold = boldView.findViewById(R.id.fln_title) + val titleItalic = italicView.findViewById(R.id.fln_title) + assertEquals(Typeface.BOLD, titleBold.typeface.style) + assertEquals(Typeface.ITALIC, titleItalic.typeface.style) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun build_ignoresNonPositiveSizeSp() { + val zero = TitleStyle().apply { sizeSp = 0.0 } + val negative = TitleStyle().apply { sizeSp = -5.0 } + val zeroTitle = TitleStyler.build(context, "t", null, zero, null)!!.apply(context, null) + .findViewById(R.id.fln_title) + val negativeTitle = TitleStyler.build(context, "t", null, negative, null)!!.apply(context, null) + .findViewById(R.id.fln_title) + assertEquals(zeroTitle.textSize, negativeTitle.textSize, 0.1f) + assertTrue(zeroTitle.textSize > 0f) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun build_appliesDescriptionBoldItalicColorAndSize() { + val style = DescriptionStyle().apply { + bold = true + italic = true + color = Color.RED + sizeSp = 20.0 + } + val rv = TitleStyler.build(context, "Title", "Body", null, style) + val root = rv!!.apply(context, null) + val body = root.findViewById(R.id.fln_body) + val expectedSize = TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_SP, + 20f, + context.resources.displayMetrics + ) + assertEquals(Typeface.BOLD_ITALIC, body.typeface.style) + assertEquals(Color.RED, body.currentTextColor) + assertEquals(expectedSize, body.textSize, 0.1f) + } + + @Test + @Config(sdk = [Build.VERSION_CODES.N]) + fun build_ignoresNonPositiveDescriptionSizeSp() { + val zero = DescriptionStyle().apply { sizeSp = 0.0 } + val negative = DescriptionStyle().apply { sizeSp = -5.0 } + val zeroBody = TitleStyler.build(context, "t", "body", null, zero)!!.apply(context, null) + .findViewById(R.id.fln_body) + val negativeBody = TitleStyler.build(context, "t", "body", null, negative)!!.apply(context, null) + .findViewById(R.id.fln_body) + assertEquals(zeroBody.textSize, negativeBody.textSize, 0.1f) + assertTrue(zeroBody.textSize > 0f) + } +} \ No newline at end of file diff --git a/flutter_local_notifications/example/android/app/build.gradle b/flutter_local_notifications/example/android/app/build.gradle index 2bcbe588c..5528ed968 100644 --- a/flutter_local_notifications/example/android/app/build.gradle +++ b/flutter_local_notifications/example/android/app/build.gradle @@ -24,7 +24,7 @@ if (flutterVersionName == null) { android { namespace 'com.dexterous.flutter_local_notifications_example' - compileSdk 35 + compileSdk 36 ndkVersion = flutter.ndkVersion sourceSets { @@ -33,20 +33,20 @@ android { compileOptions { coreLibraryDesugaringEnabled true - sourceCompatibility JavaVersion.VERSION_11 - targetCompatibility JavaVersion.VERSION_11 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } kotlinOptions { - jvmTarget = "11" + jvmTarget = "17" } defaultConfig { multiDexEnabled true applicationId "com.dexterous.flutter_local_notifications_example" - minSdkVersion flutter.minSdkVersion - targetSdkVersion 35 - versionCode flutterVersionCode.toInteger() - versionName flutterVersionName + minSdkVersion 21 + targetSdkVersion 36 + versionCode 1 + versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } diff --git a/flutter_local_notifications/example/android/settings.gradle b/flutter_local_notifications/example/android/settings.gradle index 0949d2b9f..eb782a2f8 100644 --- a/flutter_local_notifications/example/android/settings.gradle +++ b/flutter_local_notifications/example/android/settings.gradle @@ -19,7 +19,7 @@ pluginManagement { plugins { id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "com.android.application" version '8.6.0' apply false - id "org.jetbrains.kotlin.android" version "1.9.10" apply false + id "org.jetbrains.kotlin.android" version "2.1.0" apply false } include ":app" \ No newline at end of file diff --git a/flutter_local_notifications/example/macos/Flutter/GeneratedPluginRegistrant.swift b/flutter_local_notifications/example/macos/Flutter/GeneratedPluginRegistrant.swift index 2232efa5d..8f554cc47 100644 --- a/flutter_local_notifications/example/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/flutter_local_notifications/example/macos/Flutter/GeneratedPluginRegistrant.swift @@ -9,10 +9,12 @@ import device_info_plus import flutter_local_notifications import flutter_timezone import path_provider_foundation +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) FlutterTimezonePlugin.register(with: registry.registrar(forPlugin: "FlutterTimezonePlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/flutter_local_notifications/lib/src/platform_specifics/android/method_channel_mappers.dart b/flutter_local_notifications/lib/src/platform_specifics/android/method_channel_mappers.dart index bd4bbabb3..4a0dbc6e6 100644 --- a/flutter_local_notifications/lib/src/platform_specifics/android/method_channel_mappers.dart +++ b/flutter_local_notifications/lib/src/platform_specifics/android/method_channel_mappers.dart @@ -1,3 +1,6 @@ +// ignore_for_file: deprecated_member_use + +import 'dart:ui'; import 'enums.dart'; import 'initialization_settings.dart'; import 'message.dart'; @@ -38,26 +41,90 @@ extension AndroidNotificationChannelGroupMapper } extension AndroidNotificationChannelMapper on AndroidNotificationChannel { - Map toMap() => { - 'id': id, - 'name': name, - 'description': description, - 'groupId': groupId, - 'showBadge': showBadge, - 'importance': importance.value, - 'bypassDnd': bypassDnd, - 'playSound': playSound, - 'enableVibration': enableVibration, - 'vibrationPattern': vibrationPattern, - 'enableLights': enableLights, - 'ledColorAlpha': ledColor?.alpha, - 'ledColorRed': ledColor?.red, - 'ledColorGreen': ledColor?.green, - 'ledColorBlue': ledColor?.blue, - 'audioAttributesUsage': audioAttributesUsage.value, - 'channelAction': - AndroidNotificationChannelAction.createIfNotExists.index, - }..addAll(_convertNotificationSoundToMap(sound)); + Map toMap() { + final Color? color = ledColor; + + // Convert normalized channels (0.0–1.0) to 8-bit ints (0–255). + final int? alpha8 = + color != null ? ((color.alpha * 255.0).round() & 0xff) : null; + final int? red8 = + color != null ? ((color.red * 255.0).round() & 0xff) : null; + final int? green8 = + color != null ? ((color.green * 255.0).round() & 0xff) : null; + final int? blue8 = + color != null ? ((color.blue * 255.0).round() & 0xff) : null; + + final Map map = { + 'id': id, + 'name': name, + 'description': description, + 'groupId': groupId, + 'showBadge': showBadge, + 'importance': importance.value, + 'bypassDnd': bypassDnd, + 'playSound': playSound, + 'enableVibration': enableVibration, + 'vibrationPattern': vibrationPattern, + 'enableLights': enableLights, + 'ledColorAlpha': alpha8, + 'ledColorRed': red8, + 'ledColorGreen': green8, + 'ledColorBlue': blue8, + 'audioAttributesUsage': audioAttributesUsage.value, + 'channelAction': AndroidNotificationChannelAction.createIfNotExists.index, + }..addAll(_convertNotificationSoundToMap(sound)); + + return map; + } +} + +extension AndroidNotificationTitleStyleMapper on AndroidNotificationTitleStyle { + Map toMap() { + final Map map = {}; + if (color != null) { + assert(color! >= 0 && color! <= 0xFFFFFFFF); + map['color'] = color; + } + if (sizeSp != null) { + map['sizeSp'] = sizeSp; + } + if (bold != null) { + map['bold'] = bold; + } + if (italic != null) { + map['italic'] = italic; + } + if (iconSpacing != null) { + assert(iconSpacing! >= 0); + map['iconSpacingDp'] = iconSpacing; + } + assert(map.keys.toSet().length == map.length); + assert(map.values.every((Object? v) => v != null)); + return map; + } +} + +extension AndroidNotificationDescriptionStyleMapper + on AndroidNotificationDescriptionStyle { + Map toMap() { + final Map map = {}; + if (color != null) { + assert(color! >= 0 && color! <= 0xFFFFFFFF); + map['color'] = color; + } + if (sizeSp != null) { + map['sizeSp'] = sizeSp; + } + if (bold != null) { + map['bold'] = bold; + } + if (italic != null) { + map['italic'] = italic; + } + assert(map.keys.toSet().length == map.length); + assert(map.values.every((Object? v) => v != null)); + return map; + } } Map _convertNotificationSoundToMap( @@ -173,62 +240,73 @@ extension MessagingStyleInformationMapper on MessagingStyleInformation { } extension AndroidNotificationDetailsMapper on AndroidNotificationDetails { - Map toMap() => { - 'icon': icon, - 'channelId': channelId, - 'channelName': channelName, - 'channelDescription': channelDescription, - 'channelShowBadge': channelShowBadge, - 'channelAction': channelAction.index, - 'importance': importance.value, - 'channelBypassDnd': channelBypassDnd, - 'priority': priority.value, - 'playSound': playSound, - 'enableVibration': enableVibration, - 'vibrationPattern': vibrationPattern, - 'groupKey': groupKey, - 'setAsGroupSummary': setAsGroupSummary, - 'groupAlertBehavior': groupAlertBehavior.index, - 'autoCancel': autoCancel, - 'ongoing': ongoing, - 'silent': silent, - 'colorAlpha': color?.alpha, - 'colorRed': color?.red, - 'colorGreen': color?.green, - 'colorBlue': color?.blue, - 'onlyAlertOnce': onlyAlertOnce, - 'showWhen': showWhen, - 'when': when, - 'usesChronometer': usesChronometer, - 'chronometerCountDown': chronometerCountDown, - 'showProgress': showProgress, - 'maxProgress': maxProgress, - 'progress': progress, - 'indeterminate': indeterminate, - 'enableLights': enableLights, - 'ledColorAlpha': ledColor?.alpha, - 'ledColorRed': ledColor?.red, - 'ledColorGreen': ledColor?.green, - 'ledColorBlue': ledColor?.blue, - 'ledOnMs': ledOnMs, - 'ledOffMs': ledOffMs, - 'ticker': ticker, - 'visibility': visibility?.index, - 'timeoutAfter': timeoutAfter, - 'category': category?.name, - 'fullScreenIntent': fullScreenIntent, - 'shortcutId': shortcutId, - 'additionalFlags': additionalFlags, - 'subText': subText, - 'tag': tag, - 'colorized': colorized, - 'number': number, - 'audioAttributesUsage': audioAttributesUsage.value, - } - ..addAll(_convertActionsToMap(actions)) - ..addAll(_convertStyleInformationToMap()) - ..addAll(_convertNotificationSoundToMap(sound)) - ..addAll(_convertLargeIconToMap()); + Map toMap() { + final Color? c = color, lc = ledColor; // cache to enable promotion + return { + 'icon': icon, + 'channelId': channelId, + 'channelName': channelName, + 'channelDescription': channelDescription, + 'channelShowBadge': channelShowBadge, + 'channelAction': channelAction.index, + 'importance': importance.value, + 'channelBypassDnd': channelBypassDnd, + 'priority': priority.value, + 'playSound': playSound, + 'enableVibration': enableVibration, + 'vibrationPattern': vibrationPattern, + 'groupKey': groupKey, + 'setAsGroupSummary': setAsGroupSummary, + 'groupAlertBehavior': groupAlertBehavior.index, + 'autoCancel': autoCancel, + 'ongoing': ongoing, + 'silent': silent, + + // color (nullable) — use a/r/g/b doubles -> 0–255 ints + 'colorAlpha': c != null ? ((c.alpha * 255.0).round() & 0xff) : null, + 'colorRed': c != null ? ((c.red * 255.0).round() & 0xff) : null, + 'colorGreen': c != null ? ((c.green * 255.0).round() & 0xff) : null, + 'colorBlue': c != null ? ((c.blue * 255.0).round() & 0xff) : null, + + 'onlyAlertOnce': onlyAlertOnce, + 'showWhen': showWhen, + 'when': when, + 'usesChronometer': usesChronometer, + 'chronometerCountDown': chronometerCountDown, + 'showProgress': showProgress, + 'maxProgress': maxProgress, + 'progress': progress, + 'indeterminate': indeterminate, + 'enableLights': enableLights, + + // ledColor (nullable) — also switch off deprecated getters + 'ledColorAlpha': lc != null ? ((lc.alpha * 255.0).round() & 0xff) : null, + 'ledColorRed': lc != null ? ((lc.red * 255.0).round() & 0xff) : null, + 'ledColorGreen': lc != null ? ((lc.green * 255.0).round() & 0xff) : null, + 'ledColorBlue': lc != null ? ((lc.blue * 255.0).round() & 0xff) : null, + + 'ledOnMs': ledOnMs, + 'ledOffMs': ledOffMs, + 'ticker': ticker, + 'visibility': visibility?.index, + 'timeoutAfter': timeoutAfter, + 'category': category?.name, + 'fullScreenIntent': fullScreenIntent, + 'shortcutId': shortcutId, + 'additionalFlags': additionalFlags, + 'subText': subText, + 'tag': tag, + 'colorized': colorized, + 'number': number, + 'audioAttributesUsage': audioAttributesUsage.value, + 'titleStyle': titleStyle?.toMap(), + 'descriptionStyle': descriptionStyle?.toMap(), + } + ..addAll(_convertActionsToMap(actions)) + ..addAll(_convertStyleInformationToMap()) + ..addAll(_convertNotificationSoundToMap(sound)) + ..addAll(_convertLargeIconToMap()); + } Map _convertStyleInformationToMap() { if (styleInformation is BigPictureStyleInformation) { @@ -296,10 +374,18 @@ extension AndroidNotificationDetailsMapper on AndroidNotificationDetails { (AndroidNotificationAction e) => { 'id': e.id, 'title': e.title, - 'titleColorAlpha': e.titleColor?.alpha, - 'titleColorRed': e.titleColor?.red, - 'titleColorGreen': e.titleColor?.green, - 'titleColorBlue': e.titleColor?.blue, + 'titleColorAlpha': e.titleColor != null + ? ((e.titleColor!.alpha * 255.0).round() & 0xff) + : null, + 'titleColorRed': e.titleColor != null + ? ((e.titleColor!.red * 255.0).round() & 0xff) + : null, + 'titleColorGreen': e.titleColor != null + ? ((e.titleColor!.green * 255.0).round() & 0xff) + : null, + 'titleColorBlue': e.titleColor != null + ? ((e.titleColor!.blue * 255.0).round() & 0xff) + : null, if (e.icon != null) ...{ 'icon': e.icon!.data, 'iconBitmapSource': e.icon!.source.index, diff --git a/flutter_local_notifications/lib/src/platform_specifics/android/notification_details.dart b/flutter_local_notifications/lib/src/platform_specifics/android/notification_details.dart index e4c9c95c9..03f5ac857 100644 --- a/flutter_local_notifications/lib/src/platform_specifics/android/notification_details.dart +++ b/flutter_local_notifications/lib/src/platform_specifics/android/notification_details.dart @@ -104,6 +104,69 @@ class AndroidNotificationAction { final bool invisible; } +/// Android-only options to style the *title* text using a custom layout. +/// +/// Effective on Android API 24+; ignored below that. +class AndroidNotificationTitleStyle { + /// Constructs an instance of [AndroidNotificationTitleStyle]. + const AndroidNotificationTitleStyle({ + this.color, + this.sizeSp, + this.bold, + this.italic, + this.iconSpacing = 0, + }) : assert(sizeSp == null || sizeSp > 0), + assert(color == null || (color >= 0 && color <= 0xFFFFFFFF)), + assert(iconSpacing == null || iconSpacing >= 0); + + /// 32-bit ARGB color (e.g., 0xFF58CC02). Null => platform default. + final int? color; + + /// Font size in SP (logical scaled pixels). Negative or zero values are + /// ignored on Android. + final double? sizeSp; + + /// Whether to render title in bold. Defaults to null (platform default). + final bool? bold; + + /// Whether to render title in italic. Defaults to null (platform default). + final bool? italic; + + /// Distance in dp between the notification's icon and the title/body. + /// Defaults to 0. + final double? iconSpacing; +} + +/// Android-only options to style the notification body text using a custom +/// layout. +/// +/// Effective on Android API 24+; ignored below that. +class AndroidNotificationDescriptionStyle { + /// Constructs an instance of [AndroidNotificationDescriptionStyle]. + const AndroidNotificationDescriptionStyle({ + this.color, + this.sizeSp, + this.bold, + this.italic, + }) : assert(sizeSp == null || sizeSp > 0), + assert(color == null || (color >= 0 && color <= 0xFFFFFFFF)); + + /// 32-bit ARGB color (e.g., 0xFF58CC02). Null => platform default. + final int? color; + + /// Font size in SP (logical scaled pixels). Negative or zero values are + /// ignored on Android. + final double? sizeSp; + + /// Whether to render the description in bold. Defaults to null (platform + /// default). + final bool? bold; + + /// Whether to render the description in italic. Defaults to null (platform + /// default). + final bool? italic; +} + /// Contains notification details specific to Android. class AndroidNotificationDetails { /// Constructs an instance of [AndroidNotificationDetails]. @@ -156,6 +219,8 @@ class AndroidNotificationDetails { this.colorized = false, this.number, this.audioAttributesUsage = AudioAttributesUsage.notification, + this.titleStyle, + this.descriptionStyle, }); /// The icon that should be used when displaying the notification. @@ -426,4 +491,11 @@ class AndroidNotificationDetails { /// such as alarm or ringtone set in [`AudioAttributes.Builder`](https://developer.android.com/reference/android/media/AudioAttributes.Builder#setUsage(int)). /// https://developer.android.com/reference/android/media/AudioAttributes final AudioAttributesUsage audioAttributesUsage; + + /// If set, uses a `DecoratedCustomViewStyle` with a custom `RemoteViews` + /// to render the title with the given style (API 24+ only). + final AndroidNotificationTitleStyle? titleStyle; + + /// Android-only options to style the notification body/description text. + final AndroidNotificationDescriptionStyle? descriptionStyle; } diff --git a/flutter_local_notifications/test/android_flutter_local_notifications_test.dart b/flutter_local_notifications/test/android_flutter_local_notifications_test.dart index f45e8f90e..88bfaa368 100644 --- a/flutter_local_notifications/test/android_flutter_local_notifications_test.dart +++ b/flutter_local_notifications/test/android_flutter_local_notifications_test.dart @@ -42,10 +42,265 @@ void main() { }); }); + test('show with Android title style', () async { + const AndroidNotificationDetails androidNotificationDetails = + AndroidNotificationDetails( + 'channelId', + 'channelName', + titleStyle: AndroidNotificationTitleStyle( + color: 0xFF58CC02, + sizeSp: 16, + bold: true, + italic: true, + iconSpacing: 10, + ), + ); + + await flutterLocalNotificationsPlugin.show( + 1, + 'notification title', + 'notification body', + const NotificationDetails(android: androidNotificationDetails), + ); + + final Map arguments = + log.last.arguments as Map; + final Map platformSpecifics = Map.from( + arguments['platformSpecifics'] as Map, + ); + expect(platformSpecifics['titleStyle'], { + 'color': 0xFF58CC02, + 'sizeSp': 16, + 'bold': true, + 'italic': true, + 'iconSpacingDp': 10, + }); + }); + tearDown(() { log.clear(); }); + test('show with Android title style color only', () async { + const AndroidNotificationDetails androidNotificationDetails = + AndroidNotificationDetails( + 'channelId', + 'channelName', + titleStyle: AndroidNotificationTitleStyle( + color: 0xFF0000FF, + ), + ); + + await flutterLocalNotificationsPlugin.show( + 2, + 'notification title', + 'notification body', + const NotificationDetails(android: androidNotificationDetails), + ); + + final Map arguments = + log.last.arguments as Map; + final Map platformSpecifics = Map.from( + arguments['platformSpecifics'] as Map, + ); + expect(platformSpecifics['titleStyle'], { + 'color': 0xFF0000FF, + 'iconSpacingDp': 0, + }); + expect(platformSpecifics['channelId'], 'channelId'); + }); + + test('show with Android description style', () async { + const AndroidNotificationDetails androidNotificationDetails = + AndroidNotificationDetails( + 'channelId', + 'channelName', + descriptionStyle: AndroidNotificationDescriptionStyle( + color: 0xFF58CC02, + sizeSp: 16, + bold: true, + italic: true, + ), + ); + + await flutterLocalNotificationsPlugin.show( + 6, + 'notification title', + 'notification body', + const NotificationDetails(android: androidNotificationDetails), + ); + + final Map arguments = + log.last.arguments as Map; + final Map platformSpecifics = Map.from( + arguments['platformSpecifics'] as Map, + ); + expect(platformSpecifics['descriptionStyle'], { + 'color': 0xFF58CC02, + 'sizeSp': 16, + 'bold': true, + 'italic': true, + }); + }); + + test('show with Android description style color only', () async { + const AndroidNotificationDetails androidNotificationDetails = + AndroidNotificationDetails( + 'channelId', + 'channelName', + descriptionStyle: AndroidNotificationDescriptionStyle( + color: 0xFF0000FF, + ), + ); + + await flutterLocalNotificationsPlugin.show( + 7, + 'notification title', + 'notification body', + const NotificationDetails(android: androidNotificationDetails), + ); + + final Map arguments = + log.last.arguments as Map; + final Map platformSpecifics = Map.from( + arguments['platformSpecifics'] as Map, + ); + expect(platformSpecifics['descriptionStyle'], { + 'color': 0xFF0000FF, + }); + expect(platformSpecifics['channelId'], 'channelId'); + }); + + test('show with Android description style negative size', () { + expect( + () => flutterLocalNotificationsPlugin.show( + 8, + 'notification title', + 'notification body', + NotificationDetails( + android: AndroidNotificationDetails( + 'channelId', + 'channelName', + descriptionStyle: AndroidNotificationDescriptionStyle( + sizeSp: -10, + ), + ), + ), + ), + throwsAssertionError, + ); + }); + + test('show with Android description style bold and italic separately', + () async { + const AndroidNotificationDetails boldDetails = AndroidNotificationDetails( + 'channelId', + 'channelName', + descriptionStyle: AndroidNotificationDescriptionStyle(bold: true), + ); + const AndroidNotificationDetails italicDetails = + AndroidNotificationDetails( + 'channelId', + 'channelName', + descriptionStyle: AndroidNotificationDescriptionStyle(italic: true), + ); + + await flutterLocalNotificationsPlugin.show( + 9, + 'title', + 'body', + const NotificationDetails(android: boldDetails), + ); + final Map boldArgs = + log.last.arguments as Map; + final Map boldSpecifics = Map.from( + boldArgs['platformSpecifics'] as Map, + ); + expect(boldSpecifics['descriptionStyle'], { + 'bold': true, + }); + expect(boldSpecifics['channelId'], 'channelId'); + + await flutterLocalNotificationsPlugin.show( + 10, + 'title', + 'body', + const NotificationDetails(android: italicDetails), + ); + final Map italicArgs = + log.last.arguments as Map; + final Map italicSpecifics = Map.from( + italicArgs['platformSpecifics'] as Map, + ); + expect(italicSpecifics['descriptionStyle'], { + 'italic': true, + }); + expect(italicSpecifics['channelId'], 'channelId'); + }); + + test('show with Android title style negative size', () { + expect( + () => flutterLocalNotificationsPlugin.show( + 3, + 'notification title', + 'notification body', + NotificationDetails( + android: AndroidNotificationDetails( + 'channelId', + 'channelName', + titleStyle: AndroidNotificationTitleStyle( + sizeSp: -10, + ), + ), + ), + ), + throwsAssertionError, + ); + }); + + test('show with Android title style bold and italic separately', () async { + const AndroidNotificationDetails boldDetails = AndroidNotificationDetails( + 'channelId', + 'channelName', + titleStyle: AndroidNotificationTitleStyle(bold: true), + ); + const AndroidNotificationDetails italicDetails = + AndroidNotificationDetails( + 'channelId', + 'channelName', + titleStyle: AndroidNotificationTitleStyle(italic: true), + ); + + await flutterLocalNotificationsPlugin.show( + 4, + 'title', + 'body', + const NotificationDetails(android: boldDetails), + ); + final Map boldArgs = + log.last.arguments as Map; + final Map boldSpecifics = Map.from( + boldArgs['platformSpecifics'] as Map, + ); + expect(boldSpecifics['titleStyle'], + {'bold': true, 'iconSpacingDp': 0.0}); + expect(boldSpecifics['channelId'], 'channelId'); + + await flutterLocalNotificationsPlugin.show( + 5, + 'title', + 'body', + const NotificationDetails(android: italicDetails), + ); + final Map italicArgs = + log.last.arguments as Map; + final Map italicSpecifics = Map.from( + italicArgs['platformSpecifics'] as Map, + ); + expect(italicSpecifics['titleStyle'], + {'italic': true, 'iconSpacingDp': 0.0}); + expect(italicSpecifics['channelId'], 'channelId'); + }); test('initialize', () async { const AndroidInitializationSettings androidInitializationSettings = AndroidInitializationSettings('app_icon'); @@ -183,6 +438,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, 'actions': >[ { 'id': 'action1', @@ -310,6 +567,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -395,6 +654,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -481,6 +742,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -568,6 +831,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -659,6 +924,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -749,6 +1016,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -837,6 +1106,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -926,6 +1197,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -1024,6 +1297,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -1132,6 +1407,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -1230,6 +1507,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -1338,6 +1617,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -1433,6 +1714,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -1535,6 +1818,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -1622,6 +1907,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -1712,6 +1999,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -1827,6 +2116,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -1955,6 +2246,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -2050,6 +2343,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -2185,6 +2480,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -2282,6 +2579,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -2378,6 +2677,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -2475,6 +2776,8 @@ void main() { 'colorized': false, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, })); }); @@ -2786,6 +3089,8 @@ void main() { 'colorized': true, 'number': null, 'audioAttributesUsage': 5, + 'titleStyle': null, + 'descriptionStyle': null, }, }, 'startType': AndroidServiceStartType.startSticky.index,