@@ -23,14 +23,32 @@ import '../widgets/theme.dart';
23
23
24
24
AndroidNotificationHostApi get _androidHost => ZulipBinding .instance.androidNotificationHost;
25
25
26
+ enum NotificationSound {
27
+ // Any new entry here must appear in `keep.xml` too, see #528.
28
+ chime2 (resourceName: 'chime2' , fileDisplayName: 'Zulip - Low Chime.m4a' ),
29
+ chime3 (resourceName: 'chime3' , fileDisplayName: 'Zulip - Chime.m4a' ),
30
+ chime4 (resourceName: 'chime4' , fileDisplayName: 'Zulip - High Chime.m4a' );
31
+
32
+ const NotificationSound ({
33
+ required this .resourceName,
34
+ required this .fileDisplayName,
35
+ });
36
+ final String resourceName;
37
+ final String fileDisplayName;
38
+ }
39
+
26
40
/// Service for configuring our Android "notification channel".
27
41
class NotificationChannelManager {
28
42
/// The channel ID we use for our one notification channel, which we use for
29
43
/// all notifications.
30
44
// TODO(launch) check this doesn't match zulip-mobile's current or previous
31
45
// channel IDs
46
+ // Previous values: 'messages-1'
47
+ @visibleForTesting
48
+ static const kChannelId = 'messages-2' ;
49
+
32
50
@visibleForTesting
33
- static const kChannelId = 'messages-1' ;
51
+ static const kDefaultNotificationSound = NotificationSound .chime3 ;
34
52
35
53
/// The vibration pattern we set for notifications.
36
54
// We try to set a vibration pattern that, with the phone in one's pocket,
@@ -39,6 +57,110 @@ class NotificationChannelManager {
39
57
@visibleForTesting
40
58
static final kVibrationPattern = Int64List .fromList ([0 , 125 , 100 , 450 ]);
41
59
60
+ /// Generates an Android resource URL for the given resource name and type.
61
+ ///
62
+ /// For example, for a resource `@raw/chime3` , where `raw` would be the
63
+ /// resource type and `chime3` would be the resource name it generates the
64
+ /// following URL:
65
+ /// `android.resource://com.zulip.flutter/raw/chime3`
66
+ ///
67
+ /// Based on: https://stackoverflow.com/a/38340580
68
+ static Uri _resourceUrlFromName ({
69
+ required String resourceTypeName,
70
+ required String resourceEntryName,
71
+ }) {
72
+ const packageName = 'com.zulip.flutter' ; // TODO(#407)
73
+
74
+ // URL scheme for Android resource url.
75
+ // See: https://developer.android.com/reference/android/content/ContentResolver#SCHEME_ANDROID_RESOURCE
76
+ const schemeAndroidResource = 'android.resource' ;
77
+
78
+ return Uri (
79
+ scheme: schemeAndroidResource,
80
+ host: packageName,
81
+ pathSegments: < String > [resourceTypeName, resourceEntryName],
82
+ );
83
+ }
84
+
85
+ /// Prepare our notification sounds; return a URL for our default sound.
86
+ ///
87
+ /// Where possible, this copies each of our notification sounds into shared storage
88
+ /// so that the user can choose between them in the system notification settings.
89
+ ///
90
+ /// Returns a URL for our default notification sound: either in shared storage
91
+ /// if we successfully copied it there, or else as our internal resource file.
92
+ static Future <String > _ensureInitNotificationSounds () async {
93
+ String defaultSoundUrl = _resourceUrlFromName (
94
+ resourceTypeName: 'raw' ,
95
+ resourceEntryName: kDefaultNotificationSound.resourceName).toString ();
96
+
97
+ final shouldUseResourceFile = switch (await ZulipBinding .instance.deviceInfo) {
98
+ // Before Android 10 Q, we don't attempt to put the sounds in shared media storage.
99
+ // Just use the resource file directly.
100
+ // TODO(android-sdk-29): Simplify this away.
101
+ AndroidDeviceInfo (: var sdkInt) => sdkInt < 29 ,
102
+ _ => true ,
103
+ };
104
+ if (shouldUseResourceFile) return defaultSoundUrl;
105
+
106
+ // First, look to see what notification sounds we've already stored,
107
+ // and check against our list of sounds we have.
108
+ final soundsToAdd = NotificationSound .values.toList ();
109
+
110
+ final List <StoredNotificationSound ?> storedSounds;
111
+ try {
112
+ storedSounds = await _androidHost.listStoredSoundsInNotificationsDirectory ();
113
+ } catch (e, st) {
114
+ assert (debugLog ('$e \n $st ' )); // TODO(log)
115
+ return defaultSoundUrl;
116
+ }
117
+ for (final storedSound in storedSounds) {
118
+ assert (storedSound != null ); // TODO(#942)
119
+
120
+ // If the file is one we put there, and has the name we give to our
121
+ // default sound, then use it as the default sound.
122
+ if (storedSound! .fileName == kDefaultNotificationSound.fileDisplayName
123
+ && storedSound.isOwned) {
124
+ defaultSoundUrl = storedSound.contentUrl;
125
+ }
126
+
127
+ // If it has the name of any of our sounds, then don't try to add
128
+ // that sound. This applies even if we didn't put it there: the
129
+ // name is taken, so if we tried adding it anyway it'd get some
130
+ // other name (like "Zulip - Chime (1).m4a", with " (1)" added).
131
+ // Which means the *next* launch would try to add it again ad infinitum.
132
+ // We could avoid this given some other way to uniquely identify the
133
+ // file, but haven't found an obvious one.
134
+ //
135
+ // This does mean it's possible the file isn't the one we would have
136
+ // put there... but it probably is, just from a debug vs. release build
137
+ // of the app (because those may have different package names). And anyway,
138
+ // this is a file we're supplying for the user in case they want it, not
139
+ // something where the app depends on it having specific content.
140
+ soundsToAdd.removeWhere ((v) => v.fileDisplayName == storedSound.fileName);
141
+ }
142
+
143
+ // If that leaves any sounds we haven't yet put into shared storage
144
+ // (e.g., because this is the first run after install, or after an
145
+ // upgrade that added a sound), then store those.
146
+
147
+ for (final sound in soundsToAdd) {
148
+ try {
149
+ final url = await _androidHost.copySoundResourceToMediaStore (
150
+ targetFileDisplayName: sound.fileDisplayName,
151
+ sourceResourceName: sound.resourceName);
152
+
153
+ if (sound == kDefaultNotificationSound) {
154
+ defaultSoundUrl = url;
155
+ }
156
+ } catch (e, st) {
157
+ assert (debugLog ("$e \n $st " )); // TODO(log)
158
+ }
159
+ }
160
+
161
+ return defaultSoundUrl;
162
+ }
163
+
42
164
/// Create our notification channel, if it doesn't already exist.
43
165
///
44
166
/// Deletes obsolete channels, if present, from old versions of the app.
@@ -80,13 +202,15 @@ class NotificationChannelManager {
80
202
81
203
// The channel doesn't exist. Create it.
82
204
205
+ final defaultSoundUrl = await _ensureInitNotificationSounds ();
206
+
83
207
await _androidHost.createNotificationChannel (NotificationChannel (
84
208
id: kChannelId,
85
209
name: 'Messages' , // TODO(i18n)
86
210
importance: NotificationImportance .high,
87
211
lightsEnabled: true ,
212
+ soundUrl: defaultSoundUrl,
88
213
vibrationPattern: kVibrationPattern,
89
- // TODO(#340) sound
90
214
));
91
215
}
92
216
}
0 commit comments