diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt index 15eabd80..f3fd4ef1 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkFcmModule.kt @@ -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") diff --git a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt index 3959e5b6..5ce3a2be 100644 --- a/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt +++ b/android/app/src/main/java/com/anonymous/bulwarkmobile/BulwarkMessagingService.kt @@ -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) } diff --git a/android/app/src/main/res/raw/keep.xml b/android/app/src/main/res/raw/keep.xml index 865f0412..c1083c93 100644 --- a/android/app/src/main/res/raw/keep.xml +++ b/android/app/src/main/res/raw/keep.xml @@ -1,6 +1,6 @@ - + diff --git a/app.config.js b/app.config.js index ee306356..aad9b209 100644 --- a/app.config.js +++ b/app.config.js @@ -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', { diff --git a/plugins/ios-push/NotificationService.swift b/plugins/ios-push/NotificationService.swift index 0557455f..e639d372 100644 --- a/plugins/ios-push/NotificationService.swift +++ b/plugins/ios-push/NotificationService.swift @@ -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 diff --git a/src/lib/__tests__/native-mail-sound.test.ts b/src/lib/__tests__/native-mail-sound.test.ts new file mode 100644 index 00000000..c84757d8 --- /dev/null +++ b/src/lib/__tests__/native-mail-sound.test.ts @@ -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')); + }); +});