Skip to content

Commit fc9d6f6

Browse files
notif: Use Zulip's distinct notification sound on Android
Fixes: #340
1 parent 5d072f6 commit fc9d6f6

File tree

6 files changed

+283
-4
lines changed

6 files changed

+283
-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

Lines changed: 1 addition & 1 deletion
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

Lines changed: 120 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,57 @@ import '../widgets/theme.dart';
2323

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

26+
/// Generates an Android resource uri for the given resource name and type.
27+
///
28+
/// For example, for a resource `@raw/chime3`, where `raw` would be the
29+
/// resource type and `chime3` would be the resource name it generates the
30+
/// following uri:
31+
/// `android.resource://com.zulip.flutter/raw/chime3`
32+
///
33+
/// Based on: https://stackoverflow.com/a/38340580
34+
Uri resourceUriFromName({
35+
required String resourceTypeName,
36+
required String resourceEntryName,
37+
}) {
38+
const packageName = 'com.zulip.flutter'; // TODO(#407)
39+
40+
// Uri scheme for Android resource url.
41+
// See: https://developer.android.com/reference/android/content/ContentResolver#SCHEME_ANDROID_RESOURCE
42+
const schemeAndroidResource = 'android.resource';
43+
44+
return Uri(
45+
scheme: schemeAndroidResource,
46+
host: packageName,
47+
pathSegments: <String>[resourceTypeName, resourceEntryName],
48+
);
49+
}
50+
51+
enum NotificationSound {
52+
// Any new entry here must appear in `keep.xml` too, see #528.
53+
chime2(resourceName: 'chime2', fileDisplayName: 'Zulip - Low Chime.m4a'),
54+
chime3(resourceName: 'chime3', fileDisplayName: 'Zulip - Chime.m4a'),
55+
chime4(resourceName: 'chime4', fileDisplayName: 'Zulip - High Chime.m4a');
56+
57+
const NotificationSound({
58+
required this.resourceName,
59+
required this.fileDisplayName,
60+
});
61+
final String resourceName;
62+
final String fileDisplayName;
63+
}
64+
2665
/// Service for configuring our Android "notification channel".
2766
class NotificationChannelManager {
2867
/// The channel ID we use for our one notification channel, which we use for
2968
/// all notifications.
3069
// TODO(launch) check this doesn't match zulip-mobile's current or previous
3170
// channel IDs
71+
// Previous values: 'messages-1'
72+
@visibleForTesting
73+
static const kChannelId = 'messages-2';
74+
3275
@visibleForTesting
33-
static const kChannelId = 'messages-1';
76+
static const kDefaultNotificationSound = NotificationSound.chime3;
3477

3578
/// The vibration pattern we set for notifications.
3679
// We try to set a vibration pattern that, with the phone in one's pocket,
@@ -39,6 +82,79 @@ class NotificationChannelManager {
3982
@visibleForTesting
4083
static final kVibrationPattern = Int64List.fromList([0, 125, 100, 450]);
4184

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

81197
// The channel doesn't exist. Create it.
82198

199+
final defaultSoundUrl = await _ensureInitNotificationSounds();
200+
83201
await _androidHost.createNotificationChannel(NotificationChannel(
84202
id: kChannelId,
85203
name: 'Messages', // TODO(i18n)
86204
importance: NotificationImportance.high,
87205
lightsEnabled: true,
206+
soundUri: defaultSoundUrl,
88207
vibrationPattern: kVibrationPattern,
89-
// TODO(#340) sound
90208
));
91209
}
92210
}

test/notifications/display_test.dart

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

212368
group('NotificationDisplayManager show', () {
@@ -946,6 +1102,11 @@ void main() {
9461102
});
9471103
}
9481104

1105+
extension on Subject<CopySoundResourceToMediaStoreCall> {
1106+
Subject<String> get targetFileDisplayName => has((x) => x.targetFileDisplayName, 'targetFileDisplayName');
1107+
Subject<String> get sourceResourceName => has((x) => x.sourceResourceName, 'sourceResourceName');
1108+
}
1109+
9491110
extension NotificationChannelChecks on Subject<NotificationChannel> {
9501111
Subject<String> get id => has((x) => x.id, 'id');
9511112
Subject<int> get importance => has((x) => x.importance, 'importance');

0 commit comments

Comments
 (0)