Skip to content

Commit f531bda

Browse files
notif: Use Zulip's distinct notification sound on Android
Fixes: #340
1 parent ec2ace6 commit f531bda

File tree

6 files changed

+288
-4
lines changed

6 files changed

+288
-4
lines changed
8.62 KB
Binary file not shown.
8.28 KB
Binary file not shown.
8.66 KB
Binary file not shown.

android/app/src/main/res/raw/keep.xml

+1-1
Original file line numberDiff line numberDiff line change
@@ -12,5 +12,5 @@
1212
https://github.com/zulip/zulip-flutter/issues/528
1313
-->
1414
<resources xmlns:tools="http://schemas.android.com/tools"
15-
tools:keep="@drawable/zulip_notification"
15+
tools:keep="@drawable/zulip_notification,@raw/chime2,@raw/chime3,@raw/chime4"
1616
/>

lib/notifications/display.dart

+127-2
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,32 @@ import '../widgets/theme.dart';
2323

2424
AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNotificationHost;
2525

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+
2640
/// Service for configuring our Android "notification channel".
2741
class NotificationChannelManager {
2842
/// The channel ID we use for our one notification channel, which we use for
2943
/// all notifications.
3044
// TODO(launch) check this doesn't match zulip-mobile's current or previous
3145
// channel IDs
46+
// Previous values: 'messages-1'
47+
@visibleForTesting
48+
static const kChannelId = 'messages-2';
49+
3250
@visibleForTesting
33-
static const kChannelId = 'messages-1';
51+
static const kDefaultNotificationSound = NotificationSound.chime3;
3452

3553
/// The vibration pattern we set for notifications.
3654
// We try to set a vibration pattern that, with the phone in one's pocket,
@@ -39,6 +57,111 @@ class NotificationChannelManager {
3957
@visibleForTesting
4058
static final kVibrationPattern = Int64List.fromList([0, 125, 100, 450]);
4159

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 <= 28,
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+
109+
final soundsToAdd = NotificationSound.values.toList();
110+
111+
final List<StoredNotificationSound?> storedSounds;
112+
try {
113+
storedSounds = await _androidHost.listStoredSoundsInNotificationsDirectory();
114+
} catch (e, st) {
115+
assert(debugLog('$e\n$st')); // TODO(log)
116+
return defaultSoundUrl;
117+
}
118+
for (final storedSound in storedSounds) {
119+
assert(storedSound != null); // TODO(#942)
120+
121+
// If the file is one we put there, and has the name we give to our
122+
// default sound, then use it as the default sound.
123+
if (storedSound!.fileName == kDefaultNotificationSound.fileDisplayName
124+
&& storedSound.isOwned) {
125+
defaultSoundUrl = storedSound.contentUrl;
126+
}
127+
128+
// If it has the name of any of our sounds, then don't try to add
129+
// that sound. This applies even if we didn't put it there: the
130+
// name is taken, so if we tried adding it anyway it'd get some
131+
// other name (like "Zulip - Chime (1).m4a", with " (1)" added).
132+
// Which means the *next* launch would try to add it again ad infinitum.
133+
// We could avoid this given some other way to uniquely identify the
134+
// file, but haven't found an obvious one.
135+
//
136+
// This does mean it's possible the file isn't the one we would have
137+
// put there... but it probably is, just from a debug vs. release build
138+
// of the app (because those may have different package names). And anyway,
139+
// this is a file we're supplying for the user in case they want it, not
140+
// something where the app depends on it having specific content.
141+
soundsToAdd.removeWhere((v) => v.fileDisplayName == storedSound.fileName);
142+
}
143+
144+
// If that leaves any sounds we haven't yet put into shared storage
145+
// (e.g., because this is the first run after install, or after an
146+
// upgrade that added a sound), then store those.
147+
148+
for (final sound in soundsToAdd) {
149+
try {
150+
final url = await _androidHost.copySoundResourceToMediaStore(
151+
targetFileDisplayName: sound.fileDisplayName,
152+
sourceResourceName: sound.resourceName);
153+
154+
if (sound == kDefaultNotificationSound) {
155+
defaultSoundUrl = url;
156+
}
157+
} catch (e, st) {
158+
assert(debugLog("$e\n$st")); // TODO(log)
159+
}
160+
}
161+
162+
return defaultSoundUrl;
163+
}
164+
42165
/// Create our notification channel, if it doesn't already exist.
43166
///
44167
/// Deletes obsolete channels, if present, from old versions of the app.
@@ -80,13 +203,15 @@ class NotificationChannelManager {
80203

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

206+
final defaultSoundUrl = await _ensureInitNotificationSounds();
207+
83208
await _androidHost.createNotificationChannel(NotificationChannel(
84209
id: kChannelId,
85210
name: 'Messages', // TODO(i18n)
86211
importance: NotificationImportance.high,
87212
lightsEnabled: true,
213+
soundUrl: defaultSoundUrl,
88214
vibrationPattern: kVibrationPattern,
89-
// TODO(#340) sound
90215
));
91216
}
92217
}

test/notifications/display_test.dart

+160-1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import 'package:http/testing.dart' as http_testing;
1313
import 'package:zulip/api/model/model.dart';
1414
import 'package:zulip/api/notifications.dart';
1515
import 'package:zulip/host/android_notifications.dart';
16+
import 'package:zulip/model/binding.dart';
1617
import 'package:zulip/model/localizations.dart';
1718
import 'package:zulip/model/narrow.dart';
1819
import 'package:zulip/model/store.dart';
@@ -129,7 +130,8 @@ void main() {
129130
..name.equals('Messages')
130131
..importance.equals(NotificationImportance.high)
131132
..lightsEnabled.equals(true)
132-
..soundUrl.isNull()
133+
..soundUrl.equals(testBinding.androidNotificationHost.fakeStoredNotificationSoundUrl(
134+
NotificationChannelManager.kDefaultNotificationSound.resourceName))
133135
..vibrationPattern.isNotNull().deepEquals(
134136
NotificationChannelManager.kVibrationPattern)
135137
;
@@ -209,6 +211,158 @@ void main() {
209211
..vibrationPattern.isNotNull().deepEquals(
210212
NotificationChannelManager.kVibrationPattern);
211213
});
214+
215+
test('on Android 28 (and lower) resource file is used for notification sound', () async {
216+
addTearDown(testBinding.reset);
217+
final androidNotificationHost = testBinding.androidNotificationHost;
218+
219+
// Override android version
220+
testBinding.deviceInfoResult =
221+
const AndroidDeviceInfo(sdkInt: 28, release: '10');
222+
223+
// Ensure that on Android 10, notification sounds aren't being copied to
224+
// the media store, and resource file is used directly.
225+
await NotificationChannelManager.ensureChannel();
226+
check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()).length.equals(0);
227+
228+
final defaultSoundResourceName =
229+
NotificationChannelManager.kDefaultNotificationSound.resourceName;
230+
final soundUrl =
231+
'android.resource://com.zulip.flutter/raw/$defaultSoundResourceName';
232+
check(androidNotificationHost.takeCreatedChannels()).single
233+
..id.equals(NotificationChannelManager.kChannelId)
234+
..name.equals('Messages')
235+
..importance.equals(NotificationImportance.high)
236+
..lightsEnabled.equals(true)
237+
..soundUrl.equals(soundUrl)
238+
..vibrationPattern.isNotNull().deepEquals(
239+
NotificationChannelManager.kVibrationPattern);
240+
});
241+
242+
test('notification sound resource files are being copied to the media store', () async {
243+
addTearDown(testBinding.reset);
244+
final androidNotificationHost = testBinding.androidNotificationHost;
245+
246+
await NotificationChannelManager.ensureChannel();
247+
check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls())
248+
.deepEquals(NotificationSound.values.map((e) => (
249+
sourceResourceName: e.resourceName,
250+
targetFileDisplayName: e.fileDisplayName),
251+
));
252+
253+
// Ensure the default source URL points to a file in the media store,
254+
// rather than a resource file.
255+
final defaultSoundResourceName =
256+
NotificationChannelManager.kDefaultNotificationSound.resourceName;
257+
final soundUrl =
258+
androidNotificationHost.fakeStoredNotificationSoundUrl(defaultSoundResourceName);
259+
check(androidNotificationHost.takeCreatedChannels()).single
260+
..id.equals(NotificationChannelManager.kChannelId)
261+
..name.equals('Messages')
262+
..importance.equals(NotificationImportance.high)
263+
..lightsEnabled.equals(true)
264+
..soundUrl.equals(soundUrl)
265+
..vibrationPattern.isNotNull().deepEquals(
266+
NotificationChannelManager.kVibrationPattern);
267+
});
268+
269+
test('notification sounds are not copied again if they were previously copied', () async {
270+
addTearDown(testBinding.reset);
271+
final androidNotificationHost = testBinding.androidNotificationHost;
272+
273+
// Emulate that all notifications sounds are already in the media store.
274+
androidNotificationHost.setupStoredNotificationSounds(
275+
NotificationSound.values.map((e) => StoredNotificationSound(
276+
fileName: e.fileDisplayName,
277+
isOwned: true,
278+
contentUrl: androidNotificationHost.fakeStoredNotificationSoundUrl(e.resourceName)),
279+
).toList(),
280+
);
281+
282+
await NotificationChannelManager.ensureChannel();
283+
check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()).length.equals(0);
284+
285+
final defaultSoundResourceName =
286+
NotificationChannelManager.kDefaultNotificationSound.resourceName;
287+
final soundUrl =
288+
androidNotificationHost.fakeStoredNotificationSoundUrl(defaultSoundResourceName);
289+
check(androidNotificationHost.takeCreatedChannels()).single
290+
..id.equals(NotificationChannelManager.kChannelId)
291+
..name.equals('Messages')
292+
..importance.equals(NotificationImportance.high)
293+
..lightsEnabled.equals(true)
294+
..soundUrl.equals(soundUrl)
295+
..vibrationPattern.isNotNull().deepEquals(
296+
NotificationChannelManager.kVibrationPattern);
297+
});
298+
299+
test('new notifications sounds are copied to media store', () async {
300+
addTearDown(testBinding.reset);
301+
final androidNotificationHost = testBinding.androidNotificationHost;
302+
303+
// Emulate that except one sound, all other sounds are already in
304+
// media store.
305+
androidNotificationHost.setupStoredNotificationSounds(
306+
NotificationSound.values.map((e) => StoredNotificationSound(
307+
fileName: e.fileDisplayName,
308+
isOwned: true,
309+
contentUrl: androidNotificationHost.fakeStoredNotificationSoundUrl(e.resourceName)),
310+
).skip(1).toList()
311+
);
312+
313+
await NotificationChannelManager.ensureChannel();
314+
final firstSound = NotificationSound.values.first;
315+
check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls())
316+
.single
317+
..sourceResourceName.equals(firstSound.resourceName)
318+
..targetFileDisplayName.equals(firstSound.fileDisplayName);
319+
320+
final defaultSoundResourceName =
321+
NotificationChannelManager.kDefaultNotificationSound.resourceName;
322+
final soundUrl =
323+
androidNotificationHost.fakeStoredNotificationSoundUrl(defaultSoundResourceName);
324+
check(androidNotificationHost.takeCreatedChannels()).single
325+
..id.equals(NotificationChannelManager.kChannelId)
326+
..name.equals('Messages')
327+
..importance.equals(NotificationImportance.high)
328+
..lightsEnabled.equals(true)
329+
..soundUrl.equals(soundUrl)
330+
..vibrationPattern.isNotNull().deepEquals(
331+
NotificationChannelManager.kVibrationPattern);
332+
});
333+
334+
test('no recopying of existing notification sounds in the media store; default sound URL points to resource file', () async {
335+
addTearDown(testBinding.reset);
336+
final androidNotificationHost = testBinding.androidNotificationHost;
337+
338+
androidNotificationHost.setupStoredNotificationSounds(
339+
NotificationSound.values.map((e) => StoredNotificationSound(
340+
fileName: e.fileDisplayName,
341+
isOwned: false,
342+
contentUrl: androidNotificationHost.fakeStoredNotificationSoundUrl(e.resourceName)),
343+
).toList()
344+
);
345+
346+
// Ensure that if a notification sound with the same name already exists
347+
// in the media store, but it wasn't copied by us, no recopying should
348+
// happen. Additionally, the default sound URL should point to the
349+
// resource file, not the version in the media store.
350+
await NotificationChannelManager.ensureChannel();
351+
check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()).length.equals(0);
352+
353+
final defaultSoundResourceName =
354+
NotificationChannelManager.kDefaultNotificationSound.resourceName;
355+
final soundUrl =
356+
'android.resource://com.zulip.flutter/raw/$defaultSoundResourceName';
357+
check(androidNotificationHost.takeCreatedChannels()).single
358+
..id.equals(NotificationChannelManager.kChannelId)
359+
..name.equals('Messages')
360+
..importance.equals(NotificationImportance.high)
361+
..lightsEnabled.equals(true)
362+
..soundUrl.equals(soundUrl)
363+
..vibrationPattern.isNotNull().deepEquals(
364+
NotificationChannelManager.kVibrationPattern);
365+
});
212366
});
213367

214368
group('NotificationDisplayManager show', () {
@@ -1182,6 +1336,11 @@ void main() {
11821336
});
11831337
}
11841338

1339+
extension on Subject<CopySoundResourceToMediaStoreCall> {
1340+
Subject<String> get targetFileDisplayName => has((x) => x.targetFileDisplayName, 'targetFileDisplayName');
1341+
Subject<String> get sourceResourceName => has((x) => x.sourceResourceName, 'sourceResourceName');
1342+
}
1343+
11851344
extension NotificationChannelChecks on Subject<NotificationChannel> {
11861345
Subject<String> get id => has((x) => x.id, 'id');
11871346
Subject<int> get importance => has((x) => x.importance, 'importance');

0 commit comments

Comments
 (0)