Skip to content

Commit dcbc02a

Browse files
pigeon: Add helper binding for copySoundResourceToMediaStore
1 parent 60896f8 commit dcbc02a

File tree

5 files changed

+176
-5
lines changed

5 files changed

+176
-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
@@ -520,6 +520,23 @@ interface AndroidNotificationHostApi {
520520
* 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)
521521
*/
522522
fun listStoredSoundsInNotificationsDirectory(): List<StoredNotificationsSound>
523+
/**
524+
* Wraps `android.content.ContentResolver.insert` combined with
525+
* `android.content.ContentResolver.openOutputStream` and
526+
* `android.content.res.Resources.openRawResource`.
527+
*
528+
* Copies a raw resource audio file to `Notifications/Zulip/`
529+
* directory in device's shared media storage. Returns the URL
530+
* of the target file in media store.
531+
*
532+
* Requires minimum of Android 10 (API 29) or higher.
533+
*
534+
* See:
535+
* https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues)
536+
* https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri)
537+
* https://developer.android.com/reference/android/content/res/Resources#openRawResource(int)
538+
*/
539+
fun copySoundResourceToMediaStore(targetFileDisplayName: String, sourceResourceName: String): String
523540
/**
524541
* Corresponds to `android.app.NotificationManager.notify`,
525542
* combined with `androidx.core.app.NotificationCompat.Builder`.
@@ -647,6 +664,24 @@ interface AndroidNotificationHostApi {
647664
channel.setMessageHandler(null)
648665
}
649666
}
667+
run {
668+
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.copySoundResourceToMediaStore$separatedMessageChannelSuffix", codec)
669+
if (api != null) {
670+
channel.setMessageHandler { message, reply ->
671+
val args = message as List<Any?>
672+
val targetFileDisplayNameArg = args[0] as String
673+
val sourceResourceNameArg = args[1] as String
674+
val wrapped: List<Any?> = try {
675+
listOf(api.copySoundResourceToMediaStore(targetFileDisplayNameArg, sourceResourceNameArg))
676+
} catch (exception: Throwable) {
677+
wrapError(exception)
678+
}
679+
reply.reply(wrapped)
680+
}
681+
} else {
682+
channel.setMessageHandler(null)
683+
}
684+
}
650685
run {
651686
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.zulip.AndroidNotificationHostApi.notify$separatedMessageChannelSuffix", codec)
652687
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.net.Uri
@@ -49,6 +50,11 @@ fun toPigeonPerson(person: androidx.core.app.Person): Person {
4950

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

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

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

580+
/// Wraps `android.content.ContentResolver.insert` combined with
581+
/// `android.content.ContentResolver.openOutputStream` and
582+
/// `android.content.res.Resources.openRawResource`.
583+
///
584+
/// Copies a raw resource audio file to `Notifications/Zulip/`
585+
/// directory in device's shared media storage. Returns the URL
586+
/// of the target file in media store.
587+
///
588+
/// Requires minimum of Android 10 (API 29) or higher.
589+
///
590+
/// See:
591+
/// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues)
592+
/// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri)
593+
/// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int)
594+
Future<String> copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName}) async {
595+
final String __pigeon_channelName = 'dev.flutter.pigeon.zulip.AndroidNotificationHostApi.copySoundResourceToMediaStore$__pigeon_messageChannelSuffix';
596+
final BasicMessageChannel<Object?> __pigeon_channel = BasicMessageChannel<Object?>(
597+
__pigeon_channelName,
598+
pigeonChannelCodec,
599+
binaryMessenger: __pigeon_binaryMessenger,
600+
);
601+
final List<Object?>? __pigeon_replyList =
602+
await __pigeon_channel.send(<Object?>[targetFileDisplayName, sourceResourceName]) as List<Object?>?;
603+
if (__pigeon_replyList == null) {
604+
throw _createConnectionError(__pigeon_channelName);
605+
} else if (__pigeon_replyList.length > 1) {
606+
throw PlatformException(
607+
code: __pigeon_replyList[0]! as String,
608+
message: __pigeon_replyList[1] as String?,
609+
details: __pigeon_replyList[2],
610+
);
611+
} else if (__pigeon_replyList[0] == null) {
612+
throw PlatformException(
613+
code: 'null-error',
614+
message: 'Host platform returned null value for non-null return value.',
615+
);
616+
} else {
617+
return (__pigeon_replyList[0] as String?)!;
618+
}
619+
}
620+
580621
/// Corresponds to `android.app.NotificationManager.notify`,
581622
/// combined with `androidx.core.app.NotificationCompat.Builder`.
582623
///

pigeon/notifications.dart

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

219+
/// Wraps `android.content.ContentResolver.insert` combined with
220+
/// `android.content.ContentResolver.openOutputStream` and
221+
/// `android.content.res.Resources.openRawResource`.
222+
///
223+
/// Copies a raw resource audio file to `Notifications/Zulip/`
224+
/// directory in device's shared media storage. Returns the URL
225+
/// of the target file in media store.
226+
///
227+
/// Requires minimum of Android 10 (API 29) or higher.
228+
///
229+
/// See:
230+
/// https://developer.android.com/reference/android/content/ContentResolver#insert(android.net.Uri,%20android.content.ContentValues)
231+
/// https://developer.android.com/reference/android/content/ContentResolver#openOutputStream(android.net.Uri)
232+
/// https://developer.android.com/reference/android/content/res/Resources#openRawResource(int)
233+
String copySoundResourceToMediaStore({required String targetFileDisplayName, required String sourceResourceName});
234+
219235
/// Corresponds to `android.app.NotificationManager.notify`,
220236
/// combined with `androidx.core.app.NotificationCompat.Builder`.
221237
///

test/model/binding.dart

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -549,6 +549,11 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi {
549549
_activeChannels.remove(channelId);
550550
}
551551

552+
/// Generates a fake URL for a notification sound present in media store.
553+
String fakeStoredNotificationSoundUrl(String resourceName) {
554+
return 'content://media/external_primary/audio/media/$resourceName';
555+
}
556+
552557
final _storedNotificationSounds = <StoredNotificationsSound>[];
553558

554559
/// Populates the media store with the provided entries.
@@ -561,6 +566,34 @@ class FakeAndroidNotificationHostApi implements AndroidNotificationHostApi {
561566
return _storedNotificationSounds.toList(growable: false);
562567
}
563568

569+
/// Consume the log of calls made to [copySoundResourceToMediaStore].
570+
///
571+
/// This returns a list of the arguments to all calls made
572+
/// to [copySoundResourceToMediaStore] since the last call to this method.
573+
List<CopySoundResourceToMediaStoreCall> takeCopySoundResourceToMediaStoreCalls() {
574+
final result = _copySoundResourceToMediaStoreCalls;
575+
_copySoundResourceToMediaStoreCalls = [];
576+
return result;
577+
}
578+
List<CopySoundResourceToMediaStoreCall> _copySoundResourceToMediaStoreCalls = [];
579+
580+
@override
581+
Future<String> copySoundResourceToMediaStore({
582+
required String targetFileDisplayName,
583+
required String sourceResourceName,
584+
}) async {
585+
_copySoundResourceToMediaStoreCalls.add((
586+
targetFileDisplayName: targetFileDisplayName,
587+
sourceResourceName: sourceResourceName));
588+
589+
final url = fakeStoredNotificationSoundUrl(sourceResourceName);
590+
_storedNotificationSounds.add(StoredNotificationsSound(
591+
fileName: targetFileDisplayName,
592+
isOwner: true,
593+
contentUrl: url));
594+
return url;
595+
}
596+
564597
/// Consume the log of calls made to [notify].
565598
///
566599
/// This returns a list of the arguments to all calls made
@@ -683,3 +716,8 @@ typedef AndroidNotificationHostApiNotifyCall = ({
683716
int? number,
684717
String? smallIconResourceName,
685718
});
719+
720+
typedef CopySoundResourceToMediaStoreCall = ({
721+
String targetFileDisplayName,
722+
String sourceResourceName,
723+
});

0 commit comments

Comments
 (0)