Skip to content

Commit

Permalink
notif: Use Zulip's distinct notification sound on Android
Browse files Browse the repository at this point in the history
Fixes: zulip#340
  • Loading branch information
rajveermalviya committed Oct 30, 2024
1 parent 418c442 commit 13ba923
Show file tree
Hide file tree
Showing 6 changed files with 281 additions and 4 deletions.
Binary file added android/app/src/main/res/raw/chime2.m4a
Binary file not shown.
Binary file added android/app/src/main/res/raw/chime3.m4a
Binary file not shown.
Binary file added android/app/src/main/res/raw/chime4.m4a
Binary file not shown.
2 changes: 1 addition & 1 deletion android/app/src/main/res/raw/keep.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,5 @@
https://github.com/zulip/zulip-flutter/issues/528
-->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@drawable/zulip_notification"
tools:keep="@drawable/zulip_notification,@raw/chime2,@raw/chime3,@raw/chime4"
/>
122 changes: 120 additions & 2 deletions lib/notifications/display.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,32 @@ import '../widgets/theme.dart';

AndroidNotificationHostApi get _androidHost => ZulipBinding.instance.androidNotificationHost;

enum NotificationSound {
// Any new entry here must appear in `keep.xml` too, see #528.
chime2(resourceName: 'chime2', fileDisplayName: 'Zulip - Low Chime.m4a'),
chime3(resourceName: 'chime3', fileDisplayName: 'Zulip - Chime.m4a'),
chime4(resourceName: 'chime4', fileDisplayName: 'Zulip - High Chime.m4a');

const NotificationSound({
required this.resourceName,
required this.fileDisplayName,
});
final String resourceName;
final String fileDisplayName;
}

/// Service for configuring our Android "notification channel".
class NotificationChannelManager {
/// The channel ID we use for our one notification channel, which we use for
/// all notifications.
// TODO(launch) check this doesn't match zulip-mobile's current or previous
// channel IDs
// Previous values: 'messages-1'
@visibleForTesting
static const kChannelId = 'messages-2';

@visibleForTesting
static const kChannelId = 'messages-1';
static const kDefaultNotificationSound = NotificationSound.chime3;

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

/// Generates an Android resource URL for the given resource name and type.
///
/// For example, for a resource `@raw/chime3`, where `raw` would be the
/// resource type and `chime3` would be the resource name it generates the
/// following URL:
/// `android.resource://com.zulip.flutter/raw/chime3`
///
/// Based on: https://stackoverflow.com/a/38340580
static Uri _resourceUrlFromName({
required String resourceTypeName,
required String resourceEntryName,
}) {
const packageName = 'com.zulip.flutter'; // TODO(#407)

// URL scheme for Android resource url.
// See: https://developer.android.com/reference/android/content/ContentResolver#SCHEME_ANDROID_RESOURCE
const schemeAndroidResource = 'android.resource';

return Uri(
scheme: schemeAndroidResource,
host: packageName,
pathSegments: <String>[resourceTypeName, resourceEntryName],
);
}

/// Prepare our notification sounds; return a URL for our default sound.
///
/// Where possible, this copies each of our notification sounds into shared storage
/// so that the user can choose between them in the system notification settings.
///
/// Returns a URL for our default notification sound: either in shared storage
/// if we successfully copied it there, or else as our internal resource file.
static Future<String> _ensureInitNotificationSounds() async {
String defaultSoundUrl = _resourceUrlFromName(
resourceTypeName: 'raw',
resourceEntryName: kDefaultNotificationSound.resourceName).toString();

final shouldUseResourceFile = switch (await ZulipBinding.instance.deviceInfo) {
// Before Android 10 Q, we don't attempt to put the sounds in shared media storage.
// Just use the resource file directly.
// TODO(android-sdk-29): Simplify this away.
AndroidDeviceInfo(:var sdkInt) => sdkInt <= 28,
_ => true,
};
if (shouldUseResourceFile) return defaultSoundUrl;

// First, look to see what notification sounds we've already stored,
// and check against our list of sounds we have.

final soundsToAdd = NotificationSound.values.toList();
final storedSounds = await _androidHost.listStoredSoundsInNotificationsDirectory();
for (final storedSound in storedSounds) {
assert(storedSound != null); // TODO(#942)

// If the file is one we put there, and has the name we give to our
// default sound, then use it as the default sound.
if (storedSound!.fileName == kDefaultNotificationSound.fileDisplayName
&& storedSound.isOwner) {
defaultSoundUrl = storedSound.contentUrl;
}

// If it has the name of any of our sounds, then don't try to add
// that sound. This applies even if we didn't put it there: the
// name is taken, so if we tried adding it anyway it'd get some
// other name (like "Zulip - Chime (1).m4a", with " (1)" added).
// Which means the *next* launch would try to add it again ad infinitum.
// We could avoid this given some other way to uniquely identify the
// file, but haven't found an obvious one.
//
// This does mean it's possible the file isn't the one we would have
// put there... but it probably is, just from a debug vs. release build
// of the app (because those have different package names). And anyway,
// this is a file we're supplying for the user in case they want it, not
// something where the app depends on it having specific content.
soundsToAdd.removeWhere((v) => v.fileDisplayName == storedSound.fileName);
}

// If that leaves any sounds we haven't yet put into shared storage
// (e.g., because this is the first run after install, or after an
// upgrade that added a sound), then store those.

for (final sound in soundsToAdd) {
try {
final url = await _androidHost.copySoundResourceToMediaStore(
targetFileDisplayName: sound.fileDisplayName,
sourceResourceName: sound.resourceName);

if (sound == kDefaultNotificationSound) {
defaultSoundUrl = url;
}
} catch (e, st) {
assert(debugLog("$e\n$st")); // TODO(log)
}
}

return defaultSoundUrl;
}

/// Create our notification channel, if it doesn't already exist.
///
/// Deletes obsolete channels, if present, from old versions of the app.
Expand Down Expand Up @@ -80,13 +196,15 @@ class NotificationChannelManager {

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

final defaultSoundUrl = await _ensureInitNotificationSounds();

await _androidHost.createNotificationChannel(NotificationChannel(
id: kChannelId,
name: 'Messages', // TODO(i18n)
importance: NotificationImportance.high,
lightsEnabled: true,
soundUrl: defaultSoundUrl,
vibrationPattern: kVibrationPattern,
// TODO(#340) sound
));
}
}
Expand Down
161 changes: 160 additions & 1 deletion test/notifications/display_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import 'package:http/testing.dart' as http_testing;
import 'package:zulip/api/model/model.dart';
import 'package:zulip/api/notifications.dart';
import 'package:zulip/host/android_notifications.dart';
import 'package:zulip/model/binding.dart';
import 'package:zulip/model/localizations.dart';
import 'package:zulip/model/narrow.dart';
import 'package:zulip/model/store.dart';
Expand Down Expand Up @@ -129,7 +130,8 @@ void main() {
..name.equals('Messages')
..importance.equals(NotificationImportance.high)
..lightsEnabled.equals(true)
..soundUrl.isNull()
..soundUrl.equals(testBinding.androidNotificationHost.fakeStoredNotificationSoundUrl(
NotificationChannelManager.kDefaultNotificationSound.resourceName))
..vibrationPattern.isNotNull().deepEquals(
NotificationChannelManager.kVibrationPattern)
;
Expand Down Expand Up @@ -209,6 +211,158 @@ void main() {
..vibrationPattern.isNotNull().deepEquals(
NotificationChannelManager.kVibrationPattern);
});

test('on Android 28 (and lower) resource file is used for notification sound', () async {
addTearDown(testBinding.reset);
final androidNotificationHost = testBinding.androidNotificationHost;

// Override android version
testBinding.deviceInfoResult =
const AndroidDeviceInfo(sdkInt: 28, release: '10');

// Ensure that on Android 10, notification sounds aren't being copied to
// the media store, and resource file is used directly.
await NotificationChannelManager.ensureChannel();
check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()).length.equals(0);

final defaultSoundResourceName =
NotificationChannelManager.kDefaultNotificationSound.resourceName;
final soundUrl =
'android.resource://com.zulip.flutter/raw/$defaultSoundResourceName';
check(androidNotificationHost.takeCreatedChannels()).single
..id.equals(NotificationChannelManager.kChannelId)
..name.equals('Messages')
..importance.equals(NotificationImportance.high)
..lightsEnabled.equals(true)
..soundUrl.equals(soundUrl)
..vibrationPattern.isNotNull().deepEquals(
NotificationChannelManager.kVibrationPattern);
});

test('notification sound resource files are being copied to the media store', () async {
addTearDown(testBinding.reset);
final androidNotificationHost = testBinding.androidNotificationHost;

await NotificationChannelManager.ensureChannel();
check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls())
.deepEquals(NotificationSound.values.map((e) => (
sourceResourceName: e.resourceName,
targetFileDisplayName: e.fileDisplayName),
));

// Ensure the default source URL points to a file in the media store,
// rather than a resource file.
final defaultSoundResourceName =
NotificationChannelManager.kDefaultNotificationSound.resourceName;
final soundUrl =
androidNotificationHost.fakeStoredNotificationSoundUrl(defaultSoundResourceName);
check(androidNotificationHost.takeCreatedChannels()).single
..id.equals(NotificationChannelManager.kChannelId)
..name.equals('Messages')
..importance.equals(NotificationImportance.high)
..lightsEnabled.equals(true)
..soundUrl.equals(soundUrl)
..vibrationPattern.isNotNull().deepEquals(
NotificationChannelManager.kVibrationPattern);
});

test('notification sounds are not copied again if they were previously copied', () async {
addTearDown(testBinding.reset);
final androidNotificationHost = testBinding.androidNotificationHost;

// Emulate that all notifications sounds are already in the media store.
androidNotificationHost.setupStoredNotificationSounds(
NotificationSound.values.map((e) => StoredNotificationsSound(
fileName: e.fileDisplayName,
isOwner: true,
contentUrl: androidNotificationHost.fakeStoredNotificationSoundUrl(e.resourceName)),
).toList(),
);

await NotificationChannelManager.ensureChannel();
check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()).length.equals(0);

final defaultSoundResourceName =
NotificationChannelManager.kDefaultNotificationSound.resourceName;
final soundUrl =
androidNotificationHost.fakeStoredNotificationSoundUrl(defaultSoundResourceName);
check(androidNotificationHost.takeCreatedChannels()).single
..id.equals(NotificationChannelManager.kChannelId)
..name.equals('Messages')
..importance.equals(NotificationImportance.high)
..lightsEnabled.equals(true)
..soundUrl.equals(soundUrl)
..vibrationPattern.isNotNull().deepEquals(
NotificationChannelManager.kVibrationPattern);
});

test('new notifications sounds are copied to media store', () async {
addTearDown(testBinding.reset);
final androidNotificationHost = testBinding.androidNotificationHost;

// Emulate that except one sound, all other sounds are already in
// media store.
androidNotificationHost.setupStoredNotificationSounds(
NotificationSound.values.map((e) => StoredNotificationsSound(
fileName: e.fileDisplayName,
isOwner: true,
contentUrl: androidNotificationHost.fakeStoredNotificationSoundUrl(e.resourceName)),
).skip(1).toList()
);

await NotificationChannelManager.ensureChannel();
final firstSound = NotificationSound.values.first;
check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls())
.single
..sourceResourceName.equals(firstSound.resourceName)
..targetFileDisplayName.equals(firstSound.fileDisplayName);

final defaultSoundResourceName =
NotificationChannelManager.kDefaultNotificationSound.resourceName;
final soundUrl =
androidNotificationHost.fakeStoredNotificationSoundUrl(defaultSoundResourceName);
check(androidNotificationHost.takeCreatedChannels()).single
..id.equals(NotificationChannelManager.kChannelId)
..name.equals('Messages')
..importance.equals(NotificationImportance.high)
..lightsEnabled.equals(true)
..soundUrl.equals(soundUrl)
..vibrationPattern.isNotNull().deepEquals(
NotificationChannelManager.kVibrationPattern);
});

test('no recopying of existing notification sounds in the media store; default sound URL points to resource file', () async {
addTearDown(testBinding.reset);
final androidNotificationHost = testBinding.androidNotificationHost;

androidNotificationHost.setupStoredNotificationSounds(
NotificationSound.values.map((e) => StoredNotificationsSound(
fileName: e.fileDisplayName,
isOwner: false,
contentUrl: androidNotificationHost.fakeStoredNotificationSoundUrl(e.resourceName)),
).toList()
);

// Ensure that if a notification sound with the same name already exists
// in the media store, but it wasn't copied by us, no recopying should
// happen. Additionally, the default sound URL should point to the
// resource file, not the version in the media store.
await NotificationChannelManager.ensureChannel();
check(androidNotificationHost.takeCopySoundResourceToMediaStoreCalls()).length.equals(0);

final defaultSoundResourceName =
NotificationChannelManager.kDefaultNotificationSound.resourceName;
final soundUrl =
'android.resource://com.zulip.flutter/raw/$defaultSoundResourceName';
check(androidNotificationHost.takeCreatedChannels()).single
..id.equals(NotificationChannelManager.kChannelId)
..name.equals('Messages')
..importance.equals(NotificationImportance.high)
..lightsEnabled.equals(true)
..soundUrl.equals(soundUrl)
..vibrationPattern.isNotNull().deepEquals(
NotificationChannelManager.kVibrationPattern);
});
});

group('NotificationDisplayManager show', () {
Expand Down Expand Up @@ -1182,6 +1336,11 @@ void main() {
});
}

extension on Subject<CopySoundResourceToMediaStoreCall> {
Subject<String> get targetFileDisplayName => has((x) => x.targetFileDisplayName, 'targetFileDisplayName');
Subject<String> get sourceResourceName => has((x) => x.sourceResourceName, 'sourceResourceName');
}

extension NotificationChannelChecks on Subject<NotificationChannel> {
Subject<String> get id => has((x) => x.id, 'id');
Subject<int> get importance => has((x) => x.importance, 'importance');
Expand Down

0 comments on commit 13ba923

Please sign in to comment.