Skip to content

Commit f5c398b

Browse files
pigeon: Add helper binding for copySoundResourceToMediaStore
1 parent cf7d1ab commit f5c398b

File tree

5 files changed

+177
-5
lines changed

5 files changed

+177
-5
lines changed

android/app/src/main/kotlin/com/zulip/flutter/Notifications.g.kt

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -473,6 +473,23 @@ interface AndroidNotificationHostApi {
473473
* 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)
474474
*/
475475
fun listStoredSoundsInNotificationsDirectory(): List<StoredNotificationsSound>
476+
/**
477+
* Wraps `android.content.ContentResolver.insert` combined with
478+
* `android.content.ContentResolver.openOutputStream` and
479+
* `android.content.res.Resources.openRawResource`.
480+
*
481+
* Copies a raw resource audio file to `Notifications/Zulip/`
482+
* directory in device's shared media storage. Returns the uri
483+
* of the target file in media store.
484+
*
485+
* Requires minimum of Android 10 (API 29) or higher.
486+
*
487+
* See:
488+
* https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues)
489+
* https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri)
490+
* https://developer.android.com/reference/android/content/res/Resources#openRawResource(int)
491+
*/
492+
fun copySoundResourceToMediaStore(targetFileDisplayName: String, sourceResourceName: String): String
476493
/**
477494
* Corresponds to `android.app.NotificationManager.notify`,
478495
* combined with `androidx.core.app.NotificationCompat.Builder`.
@@ -600,6 +617,24 @@ interface AndroidNotificationHostApi {
600617
channel.setMessageHandler(null)
601618
}
602619
}
620+
run {
621+
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.copySoundResourceToMediaStore$separatedMessageChannelSuffix", codec)
622+
if (api != null) {
623+
channel.setMessageHandler { message, reply ->
624+
val args = message as List<Any?>
625+
val targetFileDisplayNameArg = args[0] as String
626+
val sourceResourceNameArg = args[1] as String
627+
val wrapped: List<Any?> = try {
628+
listOf(api.copySoundResourceToMediaStore(targetFileDisplayNameArg, sourceResourceNameArg))
629+
} catch (exception: Throwable) {
630+
wrapError(exception)
631+
}
632+
reply.reply(wrapped)
633+
}
634+
} else {
635+
channel.setMessageHandler(null)
636+
}
637+
}
603638
run {
604639
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.notify$separatedMessageChannelSuffix", codec)
605640
if (api != null) {

android/app/src/main/kotlin/com/zulip/flutter/ZulipPlugin.kt

Lines changed: 46 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package com.zulip.flutter
22

33
import android.annotation.SuppressLint
44
import android.content.ContentUris
5+
import android.content.ContentValues
56
import android.content.Context
67
import android.content.Intent
78
import android.os.Build
@@ -48,6 +49,11 @@ fun toPigeonPerson(person: androidx.core.app.Person): Person {
4849

4950
private class AndroidNotificationHost(val context: Context)
5051
: AndroidNotificationHostApi {
52+
// The directory we store our notification sounds into,
53+
// expressed as a relative path suitable for:
54+
// https://developer.android.com/reference/kotlin/android/provider/MediaStore.MediaColumns#RELATIVE_PATH:kotlin.String
55+
private val notificationSoundsDirectoryPath = "${Environment.DIRECTORY_NOTIFICATIONS}/Zulip/"
56+
5157
override fun createNotificationChannel(channel: NotificationChannel) {
5258
val notificationChannel = NotificationChannelCompat
5359
.Builder(channel.id, channel.importance.toInt()).apply {
@@ -78,11 +84,6 @@ private class AndroidNotificationHost(val context: Context)
7884
throw UnsupportedOperationException()
7985
}
8086

81-
// The directory we store our notification sounds into,
82-
// expressed as a relative path suitable for:
83-
// https://developer.android.com/reference/kotlin/android/provider/MediaStore.MediaColumns#RELATIVE_PATH:kotlin.String
84-
val notificationSoundsDirectoryPath = "${Environment.DIRECTORY_NOTIFICATIONS}/Zulip/"
85-
8687
// Query and cursor-loop based on:
8788
// https://developer.android.com/training/data-storage/shared/media#query-collection
8889
val collection = AudioStore.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
@@ -119,6 +120,46 @@ private class AndroidNotificationHost(val context: Context)
119120
return sounds
120121
}
121122

123+
@SuppressLint(
124+
// For `getIdentifier`. TODO make a cleaner API.
125+
"DiscouragedApi")
126+
override fun copySoundResourceToMediaStore(
127+
targetFileDisplayName: String,
128+
sourceResourceName: String
129+
): String {
130+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
131+
throw UnsupportedOperationException()
132+
}
133+
134+
class ResolverFailedException(msg: String) : RuntimeException(msg)
135+
136+
val resolver = context.contentResolver
137+
val collection = AudioStore.getContentUri(MediaStore.VOLUME_EXTERNAL_PRIMARY)
138+
139+
// Based on: https://developer.android.com/training/data-storage/shared/media#add-item
140+
val url = resolver.insert(collection, ContentValues().apply {
141+
put(AudioStore.DISPLAY_NAME, targetFileDisplayName)
142+
put(AudioStore.RELATIVE_PATH, notificationSoundsDirectoryPath)
143+
put(AudioStore.IS_NOTIFICATION, 1)
144+
put(AudioStore.IS_PENDING, 1)
145+
}) ?: throw ResolverFailedException("resolver.insert failed")
146+
147+
(resolver.openOutputStream(url, "wt")
148+
?: throw ResolverFailedException("resolver.open… failed"))
149+
.use { outputStream ->
150+
val resourceId = context.resources.getIdentifier(
151+
sourceResourceName, "raw", context.packageName)
152+
context.resources.openRawResource(resourceId)
153+
.use { it.copyTo(outputStream) }
154+
}
155+
156+
resolver.update(
157+
url, ContentValues().apply { put(AudioStore.IS_PENDING, 0) },
158+
null, null)
159+
160+
return url.toString()
161+
}
162+
122163
@SuppressLint(
123164
// If permission is missing, `notify` will throw an exception.
124165
// Which hopefully will propagate to Dart, and then it's up to Dart code to handle it.

lib/host/android_notifications.g.dart

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,47 @@ class AndroidNotificationHostApi {
531531
}
532532
}
533533

534+
/// Wraps `android.content.ContentResolver.insert` combined with
535+
/// `android.content.ContentResolver.openOutputStream` and
536+
/// `android.content.res.Resources.openRawResource`.
537+
///
538+
/// Copies a raw resource audio file to `Notifications/Zulip/`
539+
/// directory in device's shared media storage. Returns the uri
540+
/// of the target file in media store.
541+
///
542+
/// Requires minimum of Android 10 (API 29) or higher.
543+
///
544+
/// See:
545+
/// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues)
546+
/// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri)
547+
/// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int)
548+
Future<String> copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}) async {
549+
final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.copySoundResourceToMediaStore$__pigeon_messageChannelSuffix';
550+
final BasicMessageChannel<Object?> __pigeon_channel = BasicMessageChannel<Object?>(
551+
__pigeon_channelName,
552+
pigeonChannelCodec,
553+
binaryMessenger: __pigeon_binaryMessenger,
554+
);
555+
final List<Object?>? __pigeon_replyList =
556+
await __pigeon_channel.send(<Object?>[targetFileDisplayName, sourceResourceName]) as List<Object?>?;
557+
if (__pigeon_replyList == null) {
558+
throw _createConnectionError(__pigeon_channelName);
559+
} else if (__pigeon_replyList.length > 1) {
560+
throw PlatformException(
561+
code: __pigeon_replyList[0]! as String,
562+
message: __pigeon_replyList[1] as String?,
563+
details: __pigeon_replyList[2],
564+
);
565+
} else if (__pigeon_replyList[0] == null) {
566+
throw PlatformException(
567+
code: 'null-error',
568+
message: 'Host platform returned null value for non-null return value.',
569+
);
570+
} else {
571+
return (__pigeon_replyList[0] as String?)!;
572+
}
573+
}
574+
534575
/// Corresponds to `android.app.NotificationManager.notify`,
535576
/// combined with `androidx.core.app.NotificationCompat.Builder`.
536577
///

pigeon/notifications.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,22 @@ abstract class AndroidNotificationHostApi {
197197
/// 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)
198198
List<StoredNotificationsSound> listStoredSoundsInNotificationsDirectory();
199199

200+
/// Wraps `android.content.ContentResolver.insert` combined with
201+
/// `android.content.ContentResolver.openOutputStream` and
202+
/// `android.content.res.Resources.openRawResource`.
203+
///
204+
/// Copies a raw resource audio file to `Notifications/Zulip/`
205+
/// directory in device's shared media storage. Returns the uri
206+
/// of the target file in media store.
207+
///
208+
/// Requires minimum of Android 10 (API 29) or higher.
209+
///
210+
/// See:
211+
/// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues)
212+
/// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri)
213+
/// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int)
214+
String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName});
215+
200216
/// Corresponds to `android.app.NotificationManager.notify`,
201217
/// combined with `androidx.core.app.NotificationCompat.Builder`.
202218
///

test/model/binding.dart

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,8 +588,14 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi {
588588
_activeChannels.remove(channelId);
589589
}
590590

591+
/// Generates a fake uri for a notification sound present in media store.
592+
String fakeStoredNotificationSoundUri(String resourceName) {
593+
return 'content://media/external_primary/audio/media/$resourceName';
594+
}
595+
591596
final _storedNotificationSounds = <StoredNotificationsSound>[];
592597

598+
/// Populates the media store with the provided entries.
593599
void setupStoredNotificationSounds(List<StoredNotificationsSound> sounds) {
594600
_storedNotificationSounds.addAll(sounds);
595601
}
@@ -599,6 +605,34 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi {
599605
return _storedNotificationSounds.toList(growable: false);
600606
}
601607

608+
/// Consume the log of calls made to [copySoundResourceToMediaStore].
609+
///
610+
/// This returns a list of the arguments to all calls made
611+
/// to [copySoundResourceToMediaStore] since the last call to this method.
612+
List<CopySoundResourceToMediaStoreCall> takeCopySoundResourceToMediaStoreCalls() {
613+
final result = _copySoundResourceToMediaStoreCalls;
614+
_copySoundResourceToMediaStoreCalls = [];
615+
return result;
616+
}
617+
List<CopySoundResourceToMediaStoreCall> _copySoundResourceToMediaStoreCalls = [];
618+
619+
@override
620+
Future<String> copySoundResourceToMediaStore({
621+
required String targetFileDisplayName,
622+
required String sourceResourceName,
623+
}) async {
624+
_copySoundResourceToMediaStoreCalls.add((
625+
targetFileDisplayName: targetFileDisplayName,
626+
sourceResourceName: sourceResourceName));
627+
628+
final uri = fakeStoredNotificationSoundUri(sourceResourceName);
629+
_storedNotificationSounds.add(StoredNotificationsSound(
630+
fileName: targetFileDisplayName,
631+
isOwner: true,
632+
uri: uri));
633+
return uri;
634+
}
635+
602636
/// Consume the log of calls made to [notify].
603637
///
604638
/// This returns a list of the arguments to all calls made
@@ -721,3 +755,8 @@ typedef AndroidNotificationHostApiNotifyCall = ({
721755
int? number,
722756
String? smallIconResourceName,
723757
});
758+
759+
typedef CopySoundResourceToMediaStoreCall = ({
760+
String targetFileDisplayName,
761+
String sourceResourceName,
762+
});

0 commit comments

Comments
 (0)