feat: play the Involved sound for new mail

This commit is contained in:
Linus Rath
2026-10-31 15:10:44 +01:00
parent 10fb7ab758
commit 6a27c59672
6 changed files with 102 additions and 8 deletions
@@ -153,8 +153,10 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
}
}
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
val builder = NotificationCompat.Builder(ctx, BulwarkMessagingService.channelId(ctx))
.setSmallIcon(R.drawable.ic_notification)
// Android 7 has no channels: the notification names its sound.
.setSound(BulwarkMessagingService.soundUri(ctx))
.setLargeIcon(largeIcon)
.setContentTitle(title)
// body is the subject with the "(No subject)" fallback; an empty
@@ -284,7 +286,7 @@ class BulwarkFcmModule(reactContext: ReactApplicationContext)
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.CHANNEL_ID)
val summary = NotificationCompat.Builder(ctx, BulwarkMessagingService.channelId(ctx))
.setSmallIcon(R.drawable.ic_notification)
.setContentTitle(groupTitle)
.setContentText(if (count == 1) "1 new message" else "$count new messages")
@@ -6,6 +6,8 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.media.AudioAttributes
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.PowerManager
@@ -49,7 +51,10 @@ class BulwarkMessagingService : FirebaseMessagingService() {
}
companion object {
const val CHANNEL_ID = "bulwark_mail"
// Plays the Involved sound; see channelId().
const val CHANNEL_ID = "bulwark_mail_involved"
// The first mail channel, with the system's default sound.
const val LEGACY_CHANNEL_ID = "bulwark_mail"
private const val TAG = "BulwarkPush"
private const val START_WAKE_LOCK_MS = 10_000L
@@ -103,10 +108,39 @@ class BulwarkMessagingService : FirebaseMessagingService() {
startWakeLock = null
}
/**
* The channel new-mail notifications go to. A channel's sound can't
* change after it is created, so the Involved sound (the webmail's
* default, lib/notification-sound.ts) needs a channel of its own; the
* old one stays as the user left it. Someone who turned mail
* notifications off in the old channel keeps them off: their
* notifications keep going there.
*/
fun channelId(context: Context): String {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return CHANNEL_ID
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val legacy = manager.getNotificationChannel(LEGACY_CHANNEL_ID)
return if (legacy != null && legacy.importance == NotificationManager.IMPORTANCE_NONE) {
LEGACY_CHANNEL_ID
} else {
CHANNEL_ID
}
}
/** The Involved sound (res/raw, kept from the shrinker by res/raw/keep.xml). */
fun soundUri(context: Context): Uri =
Uri.parse("android.resource://${context.packageName}/raw/involved_notification")
fun ensureChannel(context: Context) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (channelId(context) != CHANNEL_ID) return
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
val sound = soundUri(context)
val attributes = AudioAttributes.Builder()
.setUsage(AudioAttributes.USAGE_NOTIFICATION)
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.build()
val channel = NotificationChannel(
CHANNEL_ID,
"Mail notifications",
@@ -114,6 +148,7 @@ class BulwarkMessagingService : FirebaseMessagingService() {
).apply {
description = "Incoming email alerts"
enableVibration(true)
setSound(sound, attributes)
}
manager.createNotificationChannel(channel)
}
+3 -3
View File
@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- The calendar reminder channel names its sound by resource name from JS
(src/lib/calendar-notifications.ts), which the release resource shrinker
can't see. -->
<!-- The calendar reminder channel (src/lib/calendar-notifications.ts) and the
mail channel (BulwarkMessagingService.kt) name their sound by resource
name, which the release resource shrinker can't see. -->
<resources xmlns:tools="http://schemas.android.com/tools"
tools:keep="@raw/involved_notification" />
+3 -2
View File
@@ -106,8 +106,9 @@ module.exports = {
},
],
['./plugins/ios-push', { apsEnvironment: APS_ENVIRONMENT, teamId: APPLE_TEAM_ID }],
// Bundles the Involved sound calendar reminders play on iOS
// (src/lib/calendar-notifications.ts). Android has it in res/raw.
// Bundles the Involved sound calendar reminders (src/lib/calendar-
// notifications.ts) and new mail (plugins/ios-push) play on iOS.
// Android has it in res/raw.
[
'expo-notifications',
{
@@ -30,6 +30,10 @@ final class NotificationService: UNNotificationServiceExtension {
contentHandler(request.content)
return
}
// The webmail's default new-mail sound. The app bundles the file
// (expo-notifications in app.config.js); iOS looks it up in the app's
// bundle, so the placeholder plays it too.
content.sound = UNNotificationSound(named: UNNotificationSoundName("involved_notification.wav"))
placeholder = content
let push = request.content.userInfo["body"] as? [String: Any]
work = Task { [weak self] in
@@ -0,0 +1,52 @@
import { describe, expect, it } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
// New mail plays the Involved sound, the webmail's default
// (lib/notification-sound.ts). Native code, so these read the sources: on
// Android a channel of its own (a channel's sound is fixed once created), on
// iOS the notification service extension names the bundled file.
const ROOT = path.resolve(__dirname, '../../..');
const read = (p: string) => fs.readFileSync(path.join(ROOT, p), 'utf8');
const KOTLIN = 'android/app/src/main/java/com/anonymous/bulwarkmobile';
describe('Involved sound for new mail', () => {
const service = read(`${KOTLIN}/BulwarkMessagingService.kt`);
const fcm = read(`${KOTLIN}/BulwarkFcmModule.kt`);
it('creates a new Android channel with the Involved sound and keeps the old id', () => {
expect(service).toMatch(/const val CHANNEL_ID = "bulwark_mail_involved"/);
expect(service).toMatch(/const val LEGACY_CHANNEL_ID = "bulwark_mail"/);
expect(service).toContain('android.resource://${context.packageName}/raw/involved_notification');
expect(service).toMatch(/setSound\(sound, attributes\)/);
expect(service).toContain('AudioAttributes.USAGE_NOTIFICATION');
});
it('keeps using the old channel when the user turned it off', () => {
const fn = service.slice(service.indexOf('fun channelId('), service.indexOf('fun soundUri('));
expect(fn).toContain('getNotificationChannel(LEGACY_CHANNEL_ID)');
expect(fn).toContain('IMPORTANCE_NONE');
// No new channel is created for such a user.
const ensure = service.slice(service.indexOf('fun ensureChannel('));
expect(ensure).toMatch(/if \(channelId\(context\) != CHANNEL_ID\) return/);
});
it('posts mail notifications to the channel channelId() picks', () => {
expect(fcm).not.toContain('BulwarkMessagingService.CHANNEL_ID');
expect(fcm.match(/NotificationCompat\.Builder\(ctx, BulwarkMessagingService\.channelId\(ctx\)\)/g)).toHaveLength(2);
});
it('ships the sound on both platforms', () => {
expect(fs.existsSync(path.join(ROOT, 'android/app/src/main/res/raw/involved_notification.mp3'))).toBe(true);
expect(fs.existsSync(path.join(ROOT, 'assets/sounds/involved_notification.wav'))).toBe(true);
expect(read('app.config.js')).toContain("sounds: ['./assets/sounds/involved_notification.wav']");
});
it('names the bundled sound in the iOS notification extension, placeholder included', () => {
const nse = read('plugins/ios-push/NotificationService.swift');
const sound = nse.indexOf('content.sound = UNNotificationSound(named: UNNotificationSoundName("involved_notification.wav"))');
expect(sound).toBeGreaterThan(-1);
expect(sound).toBeLessThan(nse.indexOf('placeholder = content'));
});
});