diff --git a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt b/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt index 490c367beb..2fb36634e6 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt @@ -62,6 +62,7 @@ data class NotificationChannel ( val importance: Long, val name: String? = null, val lightsEnabled: Boolean? = null, + val soundUrl: String? = null, val vibrationPattern: LongArray? = null ) { @@ -72,8 +73,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 soundUrl = __pigeon_list[4] as String? + val vibrationPattern = __pigeon_list[5] as LongArray? + return NotificationChannel(id, importance, name, lightsEnabled, soundUrl, vibrationPattern) } } fun toList(): List { @@ -82,6 +84,7 @@ data class NotificationChannel ( importance, name, lightsEnabled, + soundUrl, vibrationPattern, ) } @@ -345,6 +348,47 @@ data class StatusBarNotification ( ) } } + +/** + * Represents details about a notification sound stored in the + * shared media store. + * + * Returned as a list entry by + * [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. + * + * Generated class from Pigeon that represents data sent in messages. + */ +data class StoredNotificationSound ( + /** The display name of the sound file. */ + val fileName: String, + /** + * Specifies whether this file was created by the app. + * + * It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the + * metadata matches the app's package name. + */ + val isOwned: Boolean, + /** A `content://…` URL pointing to the sound file. */ + val contentUrl: String + +) { + companion object { + @Suppress("LocalVariableName") + fun fromList(__pigeon_list: List): StoredNotificationSound { + val fileName = __pigeon_list[0] as String + val isOwned = __pigeon_list[1] as Boolean + val contentUrl = __pigeon_list[2] as String + return StoredNotificationSound(fileName, isOwned, contentUrl) + } + } + fun toList(): List { + return listOf( + fileName, + isOwned, + contentUrl, + ) + } +} private object NotificationsPigeonCodec : StandardMessageCodec() { override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { return when (type) { @@ -393,6 +437,11 @@ private object NotificationsPigeonCodec : StandardMessageCodec() { StatusBarNotification.fromList(it) } } + 138.toByte() -> { + return (readValue(buffer) as? List)?.let { + StoredNotificationSound.fromList(it) + } + } else -> super.readValueOfType(type, buffer) } } @@ -434,6 +483,10 @@ private object NotificationsPigeonCodec : StandardMessageCodec() { stream.write(137) writeValue(stream, value.toList()) } + is StoredNotificationSound -> { + stream.write(138) + writeValue(stream, value.toList()) + } else -> super.writeValue(stream, value) } } @@ -459,6 +512,36 @@ interface AndroidNotificationHostApi { * See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) */ fun deleteNotificationChannel(channelId: String) + /** + * The list of notification sound files present under `Notifications/Zulip/` + * in the device's shared media storage, + * found with `android.content.ContentResolver.query`. + * + * This is a complex ad-hoc method. + * For detailed behavior, see its implementation. + * + * Requires minimum of Android 10 (API 29) or higher. + * + * See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) + */ + fun listStoredSoundsInNotificationsDirectory(): List + /** + * Wraps `android.content.ContentResolver.insert` combined with + * `android.content.ContentResolver.openOutputStream` and + * `android.content.res.Resources.openRawResource`. + * + * Copies a raw resource audio file to `Notifications/Zulip/` + * directory in device's shared media storage. Returns the URL + * of the target file in media store. + * + * Requires minimum of Android 10 (API 29) or higher. + * + * See: + * https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) + * https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) + * https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) + */ + fun copySoundResourceToMediaStore(targetFileDisplayName: String, sourceResourceName: String): String /** * Corresponds to `android.app.NotificationManager.notify`, * combined with `androidx.core.app.NotificationCompat.Builder`. @@ -571,6 +654,39 @@ interface AndroidNotificationHostApi { channel.setMessageHandler(null) } } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.listStoredSoundsInNotificationsDirectory()) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.copySoundResourceToMediaStore$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val targetFileDisplayNameArg = args[0] as String + val sourceResourceNameArg = args[1] as String + val wrapped: List = try { + listOf(api.copySoundResourceToMediaStore(targetFileDisplayNameArg, sourceResourceNameArg)) + } catch (exception: Throwable) { + wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } run { val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.notify$separatedMessageChannelSuffix", codec) if (api != null) { diff --git a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt index c0104153f4..bae5ad43ab 100644 --- a/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt +++ b/android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt @@ -1,10 +1,17 @@ package com.zulip.flutter import android.annotation.SuppressLint +import android.content.ContentUris +import android.content.ContentValues import android.content.Context import android.content.Intent +import android.media.AudioAttributes import android.net.Uri +import android.os.Build import android.os.Bundle +import android.os.Environment +import android.provider.MediaStore +import android.provider.MediaStore.Audio.Media as AudioStore import android.util.Log import androidx.annotation.Keep import androidx.core.app.NotificationChannelCompat @@ -44,11 +51,22 @@ fun toPigeonPerson(person: androidx.core.app.Person): Person { private class AndroidNotificationHost(val context: Context) : AndroidNotificationHostApi { + // The directory we store our notification sounds into, + // expressed as a relative path suitable for: + // https://developer.android.com/reference/kotlin/android/provider/MediaStore.MediaColumns#RELATIVE_PATH:kotlin.String + val notificationSoundsDirectoryPath = "${Environment.DIRECTORY_NOTIFICATIONS}/Zulip/" + + class ResolverFailedException(msg: String) : RuntimeException(msg) + 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.soundUrl?.let { + setSound(Uri.parse(it), + AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_NOTIFICATION).build()) + } channel.vibrationPattern?.let { setVibrationPattern(it) } }.build() NotificationManagerCompat.from(context).createNotificationChannel(notificationChannel) @@ -69,6 +87,87 @@ private class AndroidNotificationHost(val context: Context) NotificationManagerCompat.from(context).deleteNotificationChannel(channelId) } + override fun listStoredSoundsInNotificationsDirectory(): List { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + throw UnsupportedOperationException() + } + + // Query and cursor-loop based on: + // https://developer.android.com/training/data-storage/shared/media#query-collection + val collection = AudioStore.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + val projection = arrayOf(AudioStore._ID, AudioStore.DISPLAY_NAME, AudioStore.OWNER_PACKAGE_NAME) + val selection = "${AudioStore.RELATIVE_PATH}=?" + val selectionArgs = arrayOf(notificationSoundsDirectoryPath) + val sortOrder = "${AudioStore._ID} ASC" + + val sounds = mutableListOf() + val cursor = context.contentResolver.query( + collection, + projection, + selection, + selectionArgs, + sortOrder, + ) ?: throw ResolverFailedException("resolver.query failed") + cursor.use { + val idColumn = cursor.getColumnIndexOrThrow(AudioStore._ID) + val nameColumn = cursor.getColumnIndexOrThrow(AudioStore.DISPLAY_NAME) + val ownerColumn = cursor.getColumnIndexOrThrow(AudioStore.OWNER_PACKAGE_NAME) + while (cursor.moveToNext()) { + val id = cursor.getLong(idColumn) + val fileName = cursor.getString(nameColumn) + val ownerPackageName = cursor.getString(ownerColumn) + + val contentUrl = ContentUris.withAppendedId(collection, id) + sounds.add(StoredNotificationSound( + fileName = fileName, + isOwned = context.packageName == ownerPackageName, + contentUrl = contentUrl.toString() + )) + } + } + return sounds + } + + @SuppressLint( + // For `getIdentifier`. TODO make a cleaner API. + "DiscouragedApi") + override fun copySoundResourceToMediaStore( + targetFileDisplayName: String, + sourceResourceName: String + ): String { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + throw UnsupportedOperationException() + } + + class ResolverFailedException(msg: String) : RuntimeException(msg) + + val resolver = context.contentResolver + val collection = AudioStore.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY) + + // Based on: https://developer.android.com/training/data-storage/shared/media#add-item + val url = resolver.insert(collection, ContentValues().apply { + put(AudioStore.DISPLAY_NAME, targetFileDisplayName) + put(AudioStore.RELATIVE_PATH, notificationSoundsDirectoryPath) + put(AudioStore.IS_NOTIFICATION, 1) + put(AudioStore.IS_PENDING, 1) + }) ?: throw ResolverFailedException("resolver.insert failed") + + (resolver.openOutputStream(url, "wt") + ?: throw ResolverFailedException("resolver.open… failed")) + .use { outputStream -> + val resourceId = context.resources.getIdentifier( + sourceResourceName, "raw", context.packageName) + context.resources.openRawResource(resourceId) + .use { it.copyTo(outputStream) } + } + + resolver.update( + url, ContentValues().apply { put(AudioStore.IS_PENDING, 0) }, + null, null) + + return url.toString() + } + @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. diff --git a/android/app/src/main/res/raw/chime2.m4a b/android/app/src/main/res/raw/chime2.m4a new file mode 100644 index 0000000000..77824b1670 Binary files /dev/null and b/android/app/src/main/res/raw/chime2.m4a differ diff --git a/android/app/src/main/res/raw/chime3.m4a b/android/app/src/main/res/raw/chime3.m4a new file mode 100644 index 0000000000..271a9858cb Binary files /dev/null and b/android/app/src/main/res/raw/chime3.m4a differ diff --git a/android/app/src/main/res/raw/chime4.m4a b/android/app/src/main/res/raw/chime4.m4a new file mode 100644 index 0000000000..63fda26a96 Binary files /dev/null and b/android/app/src/main/res/raw/chime4.m4a differ diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml index 2a75152d2e..6d3e4d56c6 100644 --- a/android/app/src/main/res/raw/keep.xml +++ b/android/app/src/main/res/raw/keep.xml @@ -12,5 +12,5 @@ https://github.com/zulip/zulip-flutter/issues/528 --> diff --git a/lib/host/android_notifications.g.dart b/lib/host/android_notifications.g.dart index ed3800ce69..54825750ca 100644 --- a/lib/host/android_notifications.g.dart +++ b/lib/host/android_notifications.g.dart @@ -24,6 +24,7 @@ class NotificationChannel { required this.importance, this.name, this.lightsEnabled, + this.soundUrl, this.vibrationPattern, }); @@ -39,6 +40,8 @@ class NotificationChannel { bool? lightsEnabled; + String? soundUrl; + Int64List? vibrationPattern; Object encode() { @@ -47,6 +50,7 @@ class NotificationChannel { importance, name, lightsEnabled, + soundUrl, vibrationPattern, ]; } @@ -58,7 +62,8 @@ class NotificationChannel { importance: result[1]! as int, name: result[2] as String?, lightsEnabled: result[3] as bool?, - vibrationPattern: result[4] as Int64List?, + soundUrl: result[4] as String?, + vibrationPattern: result[5] as Int64List?, ); } } @@ -338,6 +343,48 @@ class StatusBarNotification { } } +/// Represents details about a notification sound stored in the +/// shared media store. +/// +/// Returned as a list entry by +/// [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. +class StoredNotificationSound { + StoredNotificationSound({ + required this.fileName, + required this.isOwned, + required this.contentUrl, + }); + + /// The display name of the sound file. + String fileName; + + /// Specifies whether this file was created by the app. + /// + /// It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the + /// metadata matches the app's package name. + bool isOwned; + + /// A `content://…` URL pointing to the sound file. + String contentUrl; + + Object encode() { + return [ + fileName, + isOwned, + contentUrl, + ]; + } + + static StoredNotificationSound decode(Object result) { + result as List; + return StoredNotificationSound( + fileName: result[0]! as String, + isOwned: result[1]! as bool, + contentUrl: result[2]! as String, + ); + } +} + class _PigeonCodec extends StandardMessageCodec { const _PigeonCodec(); @@ -370,6 +417,9 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is StatusBarNotification) { buffer.putUint8(137); writeValue(buffer, value.encode()); + } else if (value is StoredNotificationSound) { + buffer.putUint8(138); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -396,6 +446,8 @@ class _PigeonCodec extends StandardMessageCodec { return Notification.decode(readValue(buffer)!); case 137: return StatusBarNotification.decode(readValue(buffer)!); + case 138: + return StoredNotificationSound.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); } @@ -495,6 +547,84 @@ class AndroidNotificationHostApi { } } + /// The list of notification sound files present under `Notifications/Zulip/` + /// in the device's shared media storage, + /// found with `android.content.ContentResolver.query`. + /// + /// This is a complex ad-hoc method. + /// For detailed behavior, see its implementation. + /// + /// Requires minimum of Android 10 (API 29) or higher. + /// + /// See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) + Future> listStoredSoundsInNotificationsDirectory() async { + final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory$__pigeon_messageChannelSuffix'; + final BasicMessageChannel __pigeon_channel = BasicMessageChannel( + __pigeon_channelName, + pigeonChannelCodec, + binaryMessenger: __pigeon_binaryMessenger, + ); + final List? __pigeon_replyList = + await __pigeon_channel.send(null) as List?; + 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?)!.cast(); + } + } + + /// Wraps `android.content.ContentResolver.insert` combined with + /// `android.content.ContentResolver.openOutputStream` and + /// `android.content.res.Resources.openRawResource`. + /// + /// Copies a raw resource audio file to `Notifications/Zulip/` + /// directory in device's shared media storage. Returns the URL + /// of the target file in media store. + /// + /// Requires minimum of Android 10 (API 29) or higher. + /// + /// See: + /// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) + /// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) + /// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) + Future copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}) async { + final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.copySoundResourceToMediaStore$__pigeon_messageChannelSuffix'; + final BasicMessageChannel __pigeon_channel = BasicMessageChannel( + __pigeon_channelName, + pigeonChannelCodec, + binaryMessenger: __pigeon_binaryMessenger, + ); + final List? __pigeon_replyList = + await __pigeon_channel.send([targetFileDisplayName, sourceResourceName]) as List?; + 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 String?)!; + } + } + /// Corresponds to `android.app.NotificationManager.notify`, /// combined with `androidx.core.app.NotificationCompat.Builder`. /// diff --git a/lib/notifications/display.dart b/lib/notifications/display.dart index 64b183e6dd..88f715ae44 100644 --- a/lib/notifications/display.dart +++ b/lib/notifications/display.dart @@ -23,14 +23,32 @@ import '../widgets/theme.dart'; AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNotificationHost; +enum NotificationSound { + // Any new entry here must appear in `keep.xml` too, see #528. + chime2(resourceName: 'chime2', fileDisplayName: 'Zulip - Low Chime.m4a'), + chime3(resourceName: 'chime3', fileDisplayName: 'Zulip - Chime.m4a'), + chime4(resourceName: 'chime4', fileDisplayName: 'Zulip - High Chime.m4a'); + + const NotificationSound({ + required this.resourceName, + required this.fileDisplayName, + }); + final String resourceName; + final String fileDisplayName; +} + /// 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 + // Previous values: 'messages-1' + @visibleForTesting + static const kChannelId = 'messages-2'; + @visibleForTesting - static const kChannelId = 'messages-1'; + static const kDefaultNotificationSound = NotificationSound.chime3; /// The vibration pattern we set for notifications. // We try to set a vibration pattern that, with the phone in one's pocket, @@ -39,6 +57,110 @@ class NotificationChannelManager { @visibleForTesting static final kVibrationPattern = Int64List.fromList([0, 125, 100, 450]); + /// Generates an Android resource URL for the given resource name and type. + /// + /// For example, for a resource `@raw/chime3`, where `raw` would be the + /// resource type and `chime3` would be the resource name it generates the + /// following URL: + /// `android.resource://com.zulip.flutter/raw/chime3` + /// + /// Based on: https://stackoverflow.com/a/38340580 + static Uri _resourceUrlFromName({ + required String resourceTypeName, + required String resourceEntryName, + }) { + const packageName = 'com.zulip.flutter'; // TODO(#407) + + // URL scheme for Android resource url. + // See: https://developer.android.com/reference/android/content/ContentResolver#SCHEME_ANDROID_RESOURCE + const schemeAndroidResource = 'android.resource'; + + return Uri( + scheme: schemeAndroidResource, + host: packageName, + pathSegments: [resourceTypeName, resourceEntryName], + ); + } + + /// Prepare our notification sounds; return a URL for our default sound. + /// + /// Where possible, this copies each of our notification sounds into shared storage + /// so that the user can choose between them in the system notification settings. + /// + /// Returns a URL for our default notification sound: either in shared storage + /// if we successfully copied it there, or else as our internal resource file. + static Future _ensureInitNotificationSounds() async { + String defaultSoundUrl = _resourceUrlFromName( + resourceTypeName: 'raw', + resourceEntryName: kDefaultNotificationSound.resourceName).toString(); + + final shouldUseResourceFile = switch (await ZulipBinding.instance.deviceInfo) { + // Before Android 10 Q, we don't attempt to put the sounds in shared media storage. + // Just use the resource file directly. + // TODO(android-sdk-29): Simplify this away. + AndroidDeviceInfo(:var sdkInt) => sdkInt < 29, + _ => true, + }; + if (shouldUseResourceFile) return defaultSoundUrl; + + // First, look to see what notification sounds we've already stored, + // and check against our list of sounds we have. + final soundsToAdd = NotificationSound.values.toList(); + + final List storedSounds; + try { + storedSounds = await _androidHost.listStoredSoundsInNotificationsDirectory(); + } catch (e, st) { + assert(debugLog('$e\n$st')); // TODO(log) + return defaultSoundUrl; + } + for (final storedSound in storedSounds) { + assert(storedSound != null); // TODO(#942) + + // If the file is one we put there, and has the name we give to our + // default sound, then use it as the default sound. + if (storedSound!.fileName == kDefaultNotificationSound.fileDisplayName + && storedSound.isOwned) { + defaultSoundUrl = storedSound.contentUrl; + } + + // If it has the name of any of our sounds, then don't try to add + // that sound. This applies even if we didn't put it there: the + // name is taken, so if we tried adding it anyway it'd get some + // other name (like "Zulip - Chime (1).m4a", with " (1)" added). + // Which means the *next* launch would try to add it again ad infinitum. + // We could avoid this given some other way to uniquely identify the + // file, but haven't found an obvious one. + // + // This does mean it's possible the file isn't the one we would have + // put there... but it probably is, just from a debug vs. release build + // of the app (because those may have different package names). And anyway, + // this is a file we're supplying for the user in case they want it, not + // something where the app depends on it having specific content. + soundsToAdd.removeWhere((v) => v.fileDisplayName == storedSound.fileName); + } + + // If that leaves any sounds we haven't yet put into shared storage + // (e.g., because this is the first run after install, or after an + // upgrade that added a sound), then store those. + + for (final sound in soundsToAdd) { + try { + final url = await _androidHost.copySoundResourceToMediaStore( + targetFileDisplayName: sound.fileDisplayName, + sourceResourceName: sound.resourceName); + + if (sound == kDefaultNotificationSound) { + defaultSoundUrl = url; + } + } catch (e, st) { + assert(debugLog("$e\n$st")); // TODO(log) + } + } + + return defaultSoundUrl; + } + /// Create our notification channel, if it doesn't already exist. /// /// Deletes obsolete channels, if present, from old versions of the app. @@ -80,13 +202,15 @@ class NotificationChannelManager { // The channel doesn't exist. Create it. + final defaultSoundUrl = await _ensureInitNotificationSounds(); + await _androidHost.createNotificationChannel(NotificationChannel( id: kChannelId, name: 'Messages', // TODO(i18n) importance: NotificationImportance.high, lightsEnabled: true, + soundUrl: defaultSoundUrl, vibrationPattern: kVibrationPattern, - // TODO(#340) sound )); } } diff --git a/pigeon/notifications.dart b/pigeon/notifications.dart index 55eec1b986..3976899279 100644 --- a/pigeon/notifications.dart +++ b/pigeon/notifications.dart @@ -20,6 +20,7 @@ class NotificationChannel { required this.importance, this.name, this.lightsEnabled, + this.soundUrl, this.vibrationPattern, }); @@ -33,6 +34,7 @@ class NotificationChannel { final String? name; final bool? lightsEnabled; + final String? soundUrl; final Int64List? vibrationPattern; } @@ -164,6 +166,31 @@ class StatusBarNotification { // Various other properties too; add them if needed. } +/// Represents details about a notification sound stored in the +/// shared media store. +/// +/// Returned as a list entry by +/// [AndroidNotificationHostApi.listStoredSoundsInNotificationsDirectory]. +class StoredNotificationSound { + StoredNotificationSound({ + required this.fileName, + required this.isOwned, + required this.contentUrl, + }); + + /// The display name of the sound file. + final String fileName; + + /// Specifies whether this file was created by the app. + /// + /// It is true if the `MediaStore.Audio.Media.OWNER_PACKAGE_NAME` key in the + /// metadata matches the app's package name. + final bool isOwned; + + /// A `content://…` URL pointing to the sound file. + final String contentUrl; +} + @HostApi() abstract class AndroidNotificationHostApi { /// Corresponds to `androidx.core.app.NotificationManagerCompat.createNotificationChannel`. @@ -181,6 +208,34 @@ abstract class AndroidNotificationHostApi { /// See: https://developer.android.com/reference/kotlin/androidx/core/app/NotificationManagerCompat#deleteNotificationChannel(java.lang.String) void deleteNotificationChannel(String channelId); + /// The list of notification sound files present under `Notifications/Zulip/` + /// in the device's shared media storage, + /// found with `android.content.ContentResolver.query`. + /// + /// This is a complex ad-hoc method. + /// For detailed behavior, see its implementation. + /// + /// Requires minimum of Android 10 (API 29) or higher. + /// + /// See: https://developer.android.com/reference/android/content/ContentResolver#query(android.net.Uri,%20java.lang.String[],%20java.lang.String,%20java.lang.String[],%20java.lang.String) + List listStoredSoundsInNotificationsDirectory(); + + /// Wraps `android.content.ContentResolver.insert` combined with + /// `android.content.ContentResolver.openOutputStream` and + /// `android.content.res.Resources.openRawResource`. + /// + /// Copies a raw resource audio file to `Notifications/Zulip/` + /// directory in device's shared media storage. Returns the URL + /// of the target file in media store. + /// + /// Requires minimum of Android 10 (API 29) or higher. + /// + /// See: + /// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues) + /// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri) + /// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int) + String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}); + /// Corresponds to `android.app.NotificationManager.notify`, /// combined with `androidx.core.app.NotificationCompat.Builder`. /// diff --git a/test/model/binding.dart b/test/model/binding.dart index 93e5fb346b..badbbcf7e0 100644 --- a/test/model/binding.dart +++ b/test/model/binding.dart @@ -553,6 +553,52 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi { _activeChannels.remove(channelId); } + /// A URL that the fake [copySoundResourceToMediaStore] would produce + /// for a resource with the given name. + String fakeStoredNotificationSoundUrl(String resourceName) { + return 'content://media/external_primary/audio/media/$resourceName'; + } + + final _storedNotificationSounds = []; + + /// Populates the media store with the provided entries. + void setupStoredNotificationSounds(List sounds) { + _storedNotificationSounds.addAll(sounds); + } + + @override + Future> listStoredSoundsInNotificationsDirectory() async { + return _storedNotificationSounds.toList(growable: false); + } + + /// Consume the log of calls made to [copySoundResourceToMediaStore]. + /// + /// This returns a list of the arguments to all calls made + /// to [copySoundResourceToMediaStore] since the last call to this method. + List takeCopySoundResourceToMediaStoreCalls() { + final result = _copySoundResourceToMediaStoreCalls; + _copySoundResourceToMediaStoreCalls = []; + return result; + } + List _copySoundResourceToMediaStoreCalls = []; + + @override + Future copySoundResourceToMediaStore({ + required String targetFileDisplayName, + required String sourceResourceName, + }) async { + _copySoundResourceToMediaStoreCalls.add(( + targetFileDisplayName: targetFileDisplayName, + sourceResourceName: sourceResourceName)); + + final url = fakeStoredNotificationSoundUrl(sourceResourceName); + _storedNotificationSounds.add(StoredNotificationSound( + fileName: targetFileDisplayName, + isOwned: true, + contentUrl: url)); + return url; + } + /// Consume the log of calls made to [notify]. /// /// This returns a list of the arguments to all calls made @@ -675,3 +721,8 @@ typedef AndroidNotificationHostApiNotifyCall = ({ int? number, String? smallIconResourceName, }); + +typedef CopySoundResourceToMediaStoreCall = ({ + String targetFileDisplayName, + String sourceResourceName, +}); diff --git a/test/notifications/display_test.dart b/test/notifications/display_test.dart index bd39e78ac9..23b6fd8357 100644 --- a/test/notifications/display_test.dart +++ b/test/notifications/display_test.dart @@ -13,6 +13,7 @@ import 'package:http/testing.dart' as http_testing; import 'package:zulip/api/model/model.dart'; import 'package:zulip/api/notifications.dart'; import 'package:zulip/host/android_notifications.dart'; +import 'package:zulip/model/binding.dart'; import 'package:zulip/model/localizations.dart'; import 'package:zulip/model/narrow.dart'; import 'package:zulip/model/store.dart'; @@ -121,7 +122,7 @@ void main() { await NotificationService.instance.start(); } - group('NotificationChannelManager', () { + group('NotificationChannelManager create channel', () { test('smoke', () async { await init(); check(testBinding.androidNotificationHost.takeCreatedChannels()).single @@ -129,6 +130,8 @@ void main() { ..name.equals('Messages') ..importance.equals(NotificationImportance.high) ..lightsEnabled.equals(true) + ..soundUrl.equals(testBinding.androidNotificationHost.fakeStoredNotificationSoundUrl( + NotificationChannelManager.kDefaultNotificationSound.resourceName)) ..vibrationPattern.isNotNull().deepEquals( NotificationChannelManager.kVibrationPattern) ; @@ -210,6 +213,120 @@ void main() { }); }); + group('NotificationChannelManager sounds', () { + final defaultSoundResourceName = + NotificationChannelManager.kDefaultNotificationSound.resourceName; + String fakeStoredUrl(String resourceName) => + testBinding.androidNotificationHost.fakeStoredNotificationSoundUrl(resourceName); + String fakeResourceUrl(String resourceName) => + 'android.resource://com.zulip.flutter/raw/$resourceName'; + + test('on Android 28 (and lower) resource file is used for notification sound', () async { + addTearDown(testBinding.reset); + final androidNotificationHost = testBinding.androidNotificationHost; + + testBinding.deviceInfoResult = + const AndroidDeviceInfo(sdkInt: 28, release: '9'); + + // Ensure that on Android 10, notification sounds aren't being copied to + // the media store, and resource file is used directly. + await NotificationChannelManager.ensureChannel(); + check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()) + .isEmpty(); + check(androidNotificationHost.takeCreatedChannels()) + .single + .soundUrl.equals(fakeResourceUrl(defaultSoundResourceName)); + }); + + test('notification sound resource files are being copied to the media store', () async { + addTearDown(testBinding.reset); + final androidNotificationHost = testBinding.androidNotificationHost; + + await NotificationChannelManager.ensureChannel(); + check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()) + .deepEquals(NotificationSound.values.map((e) => ( + sourceResourceName: e.resourceName, + targetFileDisplayName: e.fileDisplayName), + )); + + // Ensure the default source URL points to a file in the media store, + // rather than a resource file. + check(androidNotificationHost.takeCreatedChannels()) + .single + .soundUrl.equals(fakeStoredUrl(defaultSoundResourceName)); + }); + + test('notification sounds are not copied again if they were previously copied', () async { + addTearDown(testBinding.reset); + final androidNotificationHost = testBinding.androidNotificationHost; + + // Emulate that all notifications sounds are already in the media store. + androidNotificationHost.setupStoredNotificationSounds( + NotificationSound.values.map((e) => StoredNotificationSound( + fileName: e.fileDisplayName, + isOwned: true, + contentUrl: fakeStoredUrl(e.resourceName)), + ).toList(), + ); + + await NotificationChannelManager.ensureChannel(); + check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()) + .isEmpty(); + check(androidNotificationHost.takeCreatedChannels()) + .single + .soundUrl.equals(fakeStoredUrl(defaultSoundResourceName)); + }); + + test('new notification sounds are copied to media store', () async { + addTearDown(testBinding.reset); + final androidNotificationHost = testBinding.androidNotificationHost; + + // Emulate that except one sound, all other sounds are already in + // media store. + androidNotificationHost.setupStoredNotificationSounds( + NotificationSound.values.skip(1).map((e) => StoredNotificationSound( + fileName: e.fileDisplayName, + isOwned: true, + contentUrl: fakeStoredUrl(e.resourceName)), + ).toList() + ); + + await NotificationChannelManager.ensureChannel(); + final firstSound = NotificationSound.values.first; + check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()) + .single + ..sourceResourceName.equals(firstSound.resourceName) + ..targetFileDisplayName.equals(firstSound.fileDisplayName); + check(androidNotificationHost.takeCreatedChannels()) + .single + .soundUrl.equals(fakeStoredUrl(defaultSoundResourceName)); + }); + + test('no recopying of existing notification sounds in the media store; default sound URL points to resource file', () async { + addTearDown(testBinding.reset); + final androidNotificationHost = testBinding.androidNotificationHost; + + androidNotificationHost.setupStoredNotificationSounds( + NotificationSound.values.map((e) => StoredNotificationSound( + fileName: e.fileDisplayName, + isOwned: false, + contentUrl: fakeStoredUrl(e.resourceName)), + ).toList() + ); + + // Ensure that if a notification sound with the same name already exists + // in the media store, but it wasn't copied by us, no recopying should + // happen. Additionally, the default sound URL should point to the + // resource file, not the version in the media store. + await NotificationChannelManager.ensureChannel(); + check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()) + .isEmpty(); + check(androidNotificationHost.takeCreatedChannels()) + .single + .soundUrl.equals(fakeResourceUrl(defaultSoundResourceName)); + }); + }); + group('NotificationDisplayManager show', () { void checkNotification(MessageFcmMessage data, { required List messageStyleMessages, @@ -1181,11 +1298,17 @@ void main() { }); } +extension on Subject { + Subject get targetFileDisplayName => has((x) => x.targetFileDisplayName, 'targetFileDisplayName'); + Subject get sourceResourceName => has((x) => x.sourceResourceName, 'sourceResourceName'); +} + extension NotificationChannelChecks on Subject { Subject get id => has((x) => x.id, 'id'); Subject get importance => has((x) => x.importance, 'importance'); Subject get name => has((x) => x.name, 'name'); Subject get lightsEnabled => has((x) => x.lightsEnabled, 'lightsEnabled'); + Subject get soundUrl => has((x) => x.soundUrl, 'soundUrl'); Subject get vibrationPattern => has((x) => x.vibrationPattern, 'vibrationPattern'); }