Skip to content

notif: Use Zulip's notification sound on Android #717

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

Closed
wants to merge 5 commits into from
Closed
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
60 changes: 58 additions & 2 deletions android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ data class NotificationChannel (
val importance: Long,
val name: String? = null,
val lightsEnabled: Boolean? = null,
/**
* The name of the sound file resource.
*
* The resource ID is retrieved using
* `android.content.res.Resources.getIdentifier`, which is then used to
* generate an `android.resource://` URI. This URI is then passed
* to the `NotificationChannelCompat` builder.
*/
val soundResourceName: String? = null,
val vibrationPattern: LongArray? = null

) {
Expand All @@ -72,8 +81,9 @@ data class NotificationChannel (
val importance = __pigeon_list[1].let { num -> if (num is Int) num.toLong() else num as Long }
val name = __pigeon_list[2] as String?
val lightsEnabled = __pigeon_list[3] as Boolean?
val vibrationPattern = __pigeon_list[4] as LongArray?
return NotificationChannel(id, importance, name, lightsEnabled, vibrationPattern)
val soundResourceName = __pigeon_list[4] as String?
val vibrationPattern = __pigeon_list[5] as LongArray?
return NotificationChannel(id, importance, name, lightsEnabled, soundResourceName, vibrationPattern)
}
}
fun toList(): List<Any?> {
Expand All @@ -82,6 +92,7 @@ data class NotificationChannel (
importance,
name,
lightsEnabled,
soundResourceName,
vibrationPattern,
)
}
Expand Down Expand Up @@ -402,6 +413,18 @@ private object NotificationsPigeonCodec : StandardMessageCodec() {

/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface AndroidNotificationHostApi {
/**
* 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 `androidx.core.app.NotificationManagerCompat.createNotificationChannel`.
*
Expand Down Expand Up @@ -469,6 +492,39 @@ interface AndroidNotificationHostApi {
@JvmOverloads
fun setUp(binaryMessenger: BinaryMessenger, api: AndroidNotificationHostApi?, messageChannelSuffix: String = "") {
val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else ""
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.createNotificationChannel$separatedMessageChannelSuffix", codec)
if (api != null) {
Expand Down
42 changes: 41 additions & 1 deletion android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
package com.zulip.flutter

import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.annotation.Keep
Expand Down Expand Up @@ -41,13 +44,50 @@ fun toPigeonPerson(person: androidx.core.app.Person): Person {
)
}

/** The Android resource URL for the given resource. */
// Based on: https://stackoverflow.com/a/38340580
fun Context.resourceUrl(resourceId: Int): Uri = with(resources) {
Uri.Builder()
.scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
.authority(getResourcePackageName(resourceId))
.appendPath(getResourceTypeName(resourceId))
.appendPath(getResourceEntryName(resourceId))
.build()
}

private class AndroidNotificationHost(val context: Context)
: AndroidNotificationHostApi {
: AndroidNotificationHostApi {
override fun getNotificationChannels(): List<NotificationChannel> {
return NotificationManagerCompat.from(context)
.notificationChannelsCompat
.map { NotificationChannel(
it.id,
it.importance.toLong(),
it.name?.toString(),
it.shouldShowLights(),
it.sound?.toString(),
it.vibrationPattern,
) }
}

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

@SuppressLint(
// For `getIdentifier`. TODO make a cleaner API.
"DiscouragedApi")
override fun createNotificationChannel(channel: NotificationChannel) {
val notificationChannel = NotificationChannelCompat
.Builder(channel.id, channel.importance.toInt()).apply {
channel.name?.let { setName(it) }
channel.lightsEnabled?.let { setLightsEnabled(it) }
channel.soundResourceName?.let {
val resourceId = context.resources.getIdentifier(
it, "raw", context.packageName)
setSound(context.resourceUrl(resourceId),
AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION).build())
}
channel.vibrationPattern?.let { setVibrationPattern(it) }
}.build()
NotificationManagerCompat.from(context).createNotificationChannel(notificationChannel)
Expand Down
Binary file added android/app/src/main/res/raw/chime3.m4a
Binary file not shown.
2 changes: 1 addition & 1 deletion android/app/src/main/res/raw/keep.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
https://github.com/zulip/zulip-flutter/issues/528
-->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/zulip_notification"
tools:keep="@drawable/zulip_notification,@raw/chime3"
/>
68 changes: 67 additions & 1 deletion lib/host/android_notifications.g.dart
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ class NotificationChannel {
required this.importance,
this.name,
this.lightsEnabled,
this.soundResourceName,
this.vibrationPattern,
});

Expand All @@ -39,6 +40,14 @@ class NotificationChannel {

bool? lightsEnabled;

/// The name of the sound file resource.
///
/// The resource ID is retrieved using
/// `android.content.res.Resources.getIdentifier`, which is then used to
/// generate an `android.resource://` URI. This URI is then passed
/// to the `NotificationChannelCompat` builder.
String? soundResourceName;

Int64List? vibrationPattern;

Object encode() {
Expand All @@ -47,6 +56,7 @@ class NotificationChannel {
importance,
name,
lightsEnabled,
soundResourceName,
vibrationPattern,
];
}
Expand All @@ -58,7 +68,8 @@ class NotificationChannel {
importance: result[1]! as int,
name: result[2] as String?,
lightsEnabled: result[3] as bool?,
vibrationPattern: result[4] as Int64List?,
soundResourceName: result[4] as String?,
vibrationPattern: result[5] as Int64List?,
);
}
}
Expand Down Expand Up @@ -375,6 +386,61 @@ class AndroidNotificationHostApi {

final String __pigeon_messageChannelSuffix;

/// 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 `androidx.core.app.NotificationManagerCompat.createNotificationChannel`.
///
/// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat)
Expand Down
31 changes: 25 additions & 6 deletions lib/notifications/display.dart
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNoti
/// Service for configuring our Android "notification channel".
class NotificationChannelManager {
@visibleForTesting
static const kChannelId = 'messages-1';
static const kChannelId = 'messages-2';

@visibleForTesting
static const kDefaultSoundResourceName = 'chime3'; // 'Zulip - Chime.m4a'
Copy link
Member

Choose a reason for hiding this comment

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

We should add a comment similar to the one in 785df22.


/// The vibration pattern we set for notifications.
// We try to set a vibration pattern that, with the phone in one's pocket,
Expand All @@ -52,18 +55,34 @@ 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 {
// 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(flutter#97848)
Copy link
Member

Choose a reason for hiding this comment

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

nit: we can refer to #942 here

if (channel!.id == kChannelId) {
found = true;
} else {
await _androidHost.deleteNotificationChannel(channel.id);
}
}

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

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

Copy link
Member

Choose a reason for hiding this comment

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

nit: remove empty line

await _androidHost.createNotificationChannel(NotificationChannel(
id: kChannelId,
name: 'Messages', // TODO(i18n)
importance: NotificationImportance.high,
lightsEnabled: true,
soundResourceName: kDefaultSoundResourceName,
vibrationPattern: kVibrationPattern,
// TODO(#340) sound
));
}
}
Expand Down
20 changes: 20 additions & 0 deletions pigeon/notifications.dart
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ class NotificationChannel {
required this.importance,
this.name,
this.lightsEnabled,
this.soundResourceName,
this.vibrationPattern,
});

Expand All @@ -33,6 +34,15 @@ class NotificationChannel {

final String? name;
final bool? lightsEnabled;

/// The name of the sound file resource.
///
/// The resource ID is retrieved using
/// `android.content.res.Resources.getIdentifier`, which is then used to
/// generate an `android.resource://` URI. This URI is then passed
/// to the `NotificationChannelCompat` builder.
Copy link
Member

Choose a reason for hiding this comment

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

We could rephrase this to something similar to

/// The `smallIconResourceName` is passed to `android.content.res.Resources.getIdentifier`
/// to get a resource ID to pass to `Builder.setSmallIcon`.
/// Whatever name is passed there must appear in keep.xml too:
/// see https://github.com/zulip/zulip-flutter/issues/528 .
, mentioning how soundResourceName is expected to be known in keep.xml.

final String? soundResourceName;

final Int64List? vibrationPattern;
}

Expand Down Expand Up @@ -155,6 +165,16 @@ class StatusBarNotification {

@HostApi()
abstract class AndroidNotificationHostApi {
/// 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 `androidx.core.app.NotificationManagerCompat.createNotificationChannel`.
///
/// See: https://developer.android.com/reference/androidx/core/app/NotificationManagerCompat#createNotificationChannel(androidx.core.app.NotificationChannelCompat)
Expand Down
10 changes: 10 additions & 0 deletions test/model/binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -554,6 +554,16 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi {
}
List<NotificationChannel> _createdChannels = [];

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

@override
Future<void> deleteNotificationChannel(String channelId) async {
_createdChannels.removeWhere((e) => e.id == channelId);
}

@override
Future<void> createNotificationChannel(NotificationChannel channel) async {
_createdChannels.add(channel);
Expand Down
2 changes: 2 additions & 0 deletions test/notifications/display_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ void main() {
..name.equals('Messages')
..importance.equals(NotificationImportance.high)
..lightsEnabled.equals(true)
..soundResourceName.equals(NotificationChannelManager.kDefaultSoundResourceName)
..vibrationPattern.isNotNull().deepEquals(
NotificationChannelManager.kVibrationPattern)
;
Expand Down Expand Up @@ -875,6 +876,7 @@ extension NotificationChannelChecks on Subject<NotificationChannel> {
Subject<int> get importance => has((x) => x.importance, 'importance');
Subject<String?> get name => has((x) => x.name, 'name');
Subject<bool?> get lightsEnabled => has((x) => x.lightsEnabled, 'lightsEnabled');
Subject<String?> get soundResourceName => has((x) => x.soundResourceName, 'soundResourceName');
Subject<Int64List?> get vibrationPattern => has((x) => x.vibrationPattern, 'vibrationPattern');
}

Expand Down