Skip to content

notif: Support migration of Android notification channels #981

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Oct 8, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt
Original file line number Diff line number Diff line change
Expand Up @@ -408,6 +408,18 @@ interface AndroidNotificationHostApi {
* See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat)
*/
fun createNotificationChannel(channel: NotificationChannel)
/**
* Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`.
*
* See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat()
*/
fun getNotificationChannels(): List<NotificationChannel>
/**
* Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel`
*
* See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String)
*/
fun deleteNotificationChannel(channelId: String)
/**
* Corresponds to `android.app.NotificationManager.notify`,
* combined with `androidx.core.app.NotificationCompat.Builder`.
Expand Down Expand Up @@ -487,6 +499,39 @@ interface AndroidNotificationHostApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getNotificationChannels$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
val wrapped: List<Any?> = try {
listOf(api.getNotificationChannels())
} catch (exception: Throwable) {
wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.deleteNotificationChannel$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val channelIdArg = args[0] as String
val wrapped: List<Any?> = try {
api.deleteNotificationChannel(channelIdArg)
listOf(null)
} catch (exception: Throwable) {
wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.notify$separatedMessageChannelSuffix", codec)
if (api != null) {
Expand Down
15 changes: 15 additions & 0 deletions android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,21 @@ private class AndroidNotificationHost(val context: Context)
NotificationManagerCompat.from(context).createNotificationChannel(notificationChannel)
}

override fun getNotificationChannels(): List<NotificationChannel> {
return NotificationManagerCompat.from(context)
.notificationChannelsCompat
.map { NotificationChannel(
id = it.id,
importance = it.importance.toLong(),
name = it.name?.toString(),
lightsEnabled = it.shouldShowLights()
) }
}

override fun deleteNotificationChannel(channelId: String) {
NotificationManagerCompat.from(context).deleteNotificationChannel(channelId)
}

@SuppressLint(
// If permission is missing, `notify` will throw an exception.
// Which hopefully will propagate to Dart, and then it's up to Dart code to handle it.
Expand Down
55 changes: 55 additions & 0 deletions lib/host/android_notifications.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -400,6 +400,61 @@ class AndroidNotificationHostApi {
}
}

/// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`.
///
/// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat()
Future<List<NotificationChannel?>> getNotificationChannels() async {
final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.getNotificationChannels$__pigeon_messageChannelSuffix';
final BasicMessageChannel<Object?> __pigeon_channel = BasicMessageChannel<Object?>(
__pigeon_channelName,
pigeonChannelCodec,
binaryMessenger: __pigeon_binaryMessenger,
);
final List<Object?>? __pigeon_replyList =
await __pigeon_channel.send(null) as List<Object?>?;
if (__pigeon_replyList == null) {
throw _createConnectionError(__pigeon_channelName);
} else if (__pigeon_replyList.length > 1) {
throw PlatformException(
code: __pigeon_replyList[0]! as String,
message: __pigeon_replyList[1] as String?,
details: __pigeon_replyList[2],
);
} else if (__pigeon_replyList[0] == null) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
} else {
return (__pigeon_replyList[0] as List<Object?>?)!.cast<NotificationChannel?>();
}
}

/// Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel`
///
/// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String)
Future<void> deleteNotificationChannel(String channelId) async {
final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.deleteNotificationChannel$__pigeon_messageChannelSuffix';
final BasicMessageChannel<Object?> __pigeon_channel = BasicMessageChannel<Object?>(
__pigeon_channelName,
pigeonChannelCodec,
binaryMessenger: __pigeon_binaryMessenger,
);
final List<Object?>? __pigeon_replyList =
await __pigeon_channel.send(<Object?>[channelId]) as List<Object?>?;
if (__pigeon_replyList == null) {
throw _createConnectionError(__pigeon_channelName);
} else if (__pigeon_replyList.length > 1) {
throw PlatformException(
code: __pigeon_replyList[0]! as String,
message: __pigeon_replyList[1] as String?,
details: __pigeon_replyList[2],
);
} else {
return;
}
}

/// Corresponds to `android.app.NotificationManager.notify`,
/// combined with `androidx.core.app.NotificationCompat.Builder`.
///
Expand Down
35 changes: 29 additions & 6 deletions lib/notifications/display.dart
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNoti

/// Service for configuring our Android "notification channel".
class NotificationChannelManager {
/// The channel ID we use for our one notification channel, which we use for
/// all notifications.
// TODO(launch) check this doesn't match zulip-mobile's current or previous
// channel IDs
@visibleForTesting
static const kChannelId = 'messages-1';

Expand All @@ -36,6 +40,8 @@ class NotificationChannelManager {
static final kVibrationPattern = Int64List.fromList([0, 125, 100, 450]);

/// Create our notification channel, if it doesn't already exist.
///
/// Deletes obsolete channels, if present, from old versions of the app.
//
// NOTE when changing anything here: the changes will not take effect
// for existing installs of the app! That's because we'll have already
Expand All @@ -52,11 +58,28 @@ class NotificationChannelManager {
// settings for the channel -- like "override Do Not Disturb", or "use
// a different sound", or "don't pop on screen" -- their changes get
// reset. So this has to be done sparingly.
//
// If we do this, we should also look for any channel with the old
// channel ID and delete it. See zulip-mobile's `createNotificationChannel`
// in android/app/src/main/java/com/zulipmobile/notifications/NotificationChannelManager.kt .
static Future<void> _ensureChannel() async {
@visibleForTesting
static Future<void> ensureChannel() async {
// See if our current-version channel already exists; delete any obsolete
// previous channels.
var found = false;
final channels = await _androidHost.getNotificationChannels();
for (final channel in channels) {
assert(channel != null); // TODO(#942)
if (channel!.id == kChannelId) {
found = true;
} else {
await _androidHost.deleteNotificationChannel(channel.id);
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It sounds like it would be helpful to mention this in the dartdoc; something like:

  /// Create our notification channel, if it doesn't already exist.
  /// 
  /// Deletes obsolete channels, if present, from old versions of the app.

}
}

if (found) {
// The channel already exists; nothing to do.
return;
}

// The channel doesn't exist. Create it.

await _androidHost.createNotificationChannel(NotificationChannel(
id: kChannelId,
name: 'Messages', // TODO(i18n)
Expand All @@ -81,7 +104,7 @@ class NotificationDisplayManager {
if (launchDetails?.didNotificationLaunchApp ?? false) {
_handleNotificationAppLaunch(launchDetails!.notificationResponse);
}
await NotificationChannelManager._ensureChannel();
await NotificationChannelManager.ensureChannel();
}

static void onFcmMessage(FcmMessage data, Map<String, dynamic> dataJson) {
Expand Down
10 changes: 10 additions & 0 deletions pigeon/notifications.dart
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ abstract class AndroidNotificationHostApi {
/// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat)
void createNotificationChannel(NotificationChannel channel);

/// Corresponds to `androidx.core.app.NotificationManagerCompat.getNotificationChannelsCompat`.
///
/// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#getNotificationChannelsCompat()
List<NotificationChannel> getNotificationChannels();

/// Corresponds to `androidx.core.app.NotificationManagerCompat.deleteNotificationChannel`
///
/// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String)
void deleteNotificationChannel(String channelId);

/// Corresponds to `android.app.NotificationManager.notify`,
/// combined with `androidx.core.app.NotificationCompat.Builder`.
///
Expand Down
29 changes: 29 additions & 0 deletions test/model/binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,12 @@ class FakeFlutterLocalNotificationsPlugin extends Fake implements FlutterLocalNo
}

class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi {
/// Lists currently active channels, result is aggregated from calls made to
/// [createNotificationChannel] and [deleteNotificationChannel],
/// order of creation is preserved.
Iterable<NotificationChannel> get activeChannels => _activeChannels.values;
final Map<String, NotificationChannel> _activeChannels = {};

/// Consume the log of calls made to [createNotificationChannel].
///
/// This returns a list of the arguments to all calls made
Expand All @@ -557,6 +563,29 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi {
@override
Future<void> createNotificationChannel(NotificationChannel channel) async {
_createdChannels.add(channel);
_activeChannels[channel.id] = channel;
}

@override
Future<List<NotificationChannel?>> getNotificationChannels() async {
return _activeChannels.values.toList(growable: false);
}

/// Consume the log of calls made to [deleteNotificationChannel].
///
/// This returns a list of the arguments to all calls made
/// to [deleteNotificationChannel] since the last call to this method.
List<String> takeDeletedChannels() {
final result = _deletedChannels;
_deletedChannels = [];
return result;
}
List<String> _deletedChannels = [];

@override
Future<void> deleteNotificationChannel(String channelId) async {
_deletedChannels.add(channelId);
_activeChannels.remove(channelId);
}

/// Consume the log of calls made to [notify].
Expand Down
75 changes: 75 additions & 0 deletions test/notifications/display_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,81 @@ void main() {
NotificationChannelManager.kVibrationPattern)
;
});

test('channel is not recreated if one with same id already exists', () async {
addTearDown(testBinding.reset);

// Setup initial channel.
await testBinding.androidNotificationHost.createNotificationChannel(
NotificationChannel(
id: NotificationChannelManager.kChannelId,
name: 'Messages',
importance: NotificationImportance.high,
lightsEnabled: true,
vibrationPattern: NotificationChannelManager.kVibrationPattern));
// Clear the log.
check(testBinding.androidNotificationHost.takeCreatedChannels())
.length.equals(1);

// Ensure that no calls were made to the deleteChannel or createChannel
// functions.
await NotificationChannelManager.ensureChannel();
check(testBinding.androidNotificationHost.takeDeletedChannels())
.isEmpty();
check(testBinding.androidNotificationHost.takeCreatedChannels())
.isEmpty();
check(testBinding.androidNotificationHost.activeChannels).single
..id.equals(NotificationChannelManager.kChannelId)
..name.equals('Messages')
..importance.equals(NotificationImportance.high)
..lightsEnabled.equals(true)
..vibrationPattern.isNotNull().deepEquals(
NotificationChannelManager.kVibrationPattern);
});

test('obsolete channels are removed', () async {
addTearDown(testBinding.reset);

// Setup initial channels.
await testBinding.androidNotificationHost.createNotificationChannel(
NotificationChannel(
id: 'obsolete-1',
name: 'Obsolete 1',
importance: NotificationImportance.high,
lightsEnabled: true,
vibrationPattern: NotificationChannelManager.kVibrationPattern));
await testBinding.androidNotificationHost.createNotificationChannel(
NotificationChannel(
id: 'obsolete-2',
name: 'Obsolete 2',
importance: NotificationImportance.high,
lightsEnabled: true,
vibrationPattern: NotificationChannelManager.kVibrationPattern));
// Clear the log.
check(testBinding.androidNotificationHost.takeCreatedChannels())
.length.equals(2);

// Ensure that any channel whose channel-id differs from the desired
// channel-id (NotificationChannelManager.kChannelId) is deleted, and a
// new one with the desired channel-id is created.
await NotificationChannelManager.ensureChannel();
check(testBinding.androidNotificationHost.takeDeletedChannels())
.deepEquals(['obsolete-1', 'obsolete-2']);
check(testBinding.androidNotificationHost.takeCreatedChannels()).single
..id.equals(NotificationChannelManager.kChannelId)
..name.equals('Messages')
..importance.equals(NotificationImportance.high)
..lightsEnabled.equals(true)
..vibrationPattern.isNotNull().deepEquals(
NotificationChannelManager.kVibrationPattern);
check(testBinding.androidNotificationHost.activeChannels).single
..id.equals(NotificationChannelManager.kChannelId)
..name.equals('Messages')
..importance.equals(NotificationImportance.high)
..lightsEnabled.equals(true)
..vibrationPattern.isNotNull().deepEquals(
NotificationChannelManager.kVibrationPattern);
});
});

group('NotificationDisplayManager show', () {
Expand Down