MobiWeb Maintainer Help

Notification Sounds

Custom notification sounds are "drop the file in and release": put a .wav file in the repo-root sounds/ folder, rebuild, and a push or local notification with that sound plays it. No payload changes, no channel setup in the web app.

Add a Sound

  1. Copy the file to sounds/<name>.wav, for example sounds/order_alert.wav.

  2. Rebuild and release the app.

  3. Send sound: "order_alert" (or "order_alert.wav") in the notification data.

Rules, checked by both the Android and the iOS build:

Rule

Build result

Only .wav files (lowercase extension)

Fails on any other file

Name: lowercase a-z, 0-9, _, starting with a letter

Fails on an invalid name

Not default, none, or a Java keyword

Fails on a reserved name

No subfolders

Fails on a subfolder

Not also present in android/app/src/main/res/raw/ (any extension)

Android build fails on the duplicate

Linear PCM, µ-law or A-law audio (what iOS can play)

iOS build fails on another format or an unreadable file

30 seconds or shorter

iOS build warns

README.md and hidden files (such as .gitkeep) in sounds/ are ignored. sounds/sounds.json holds optional channel overrides, see Channel Overrides.

Keep sounds short. iOS plays the default sound for sounds longer than 30 seconds.

Sounds already in res/raw

Sound files placed directly in android/app/src/main/res/raw/ (.wav, .ogg, .mp3, .m4a, .aac, .flac, .opus) count as bundled sounds too: they get a channel and routing exactly like files in sounds/. Existing app branches that ship sounds this way keep working with no changes.

Sounds already in the iOS App target

Sound files added to the App target directly in Xcode (.wav, .caf, .aiff) keep working, with or without the extension in sound. If the same file name is also in sounds/, the sounds/ copy wins and the iOS build warns; keep only one of them.

Channel Overrides (sounds.json)

By default each sound's channel is named after the file (ity_emergency becomes "Ity emergency"), has no description and high importance. An optional sounds/sounds.json overrides these per sound:

{ "ity_emergency": { "name": "Emergency alerts", "description": "Code red and evacuation notices", "importance": "max" }, "ity_notification": { "importance": "default" } }
  • Keys are sound names: a file in sounds/ (without .wav) or a sound in res/raw.

  • Every field is optional. Sounds without an entry, and fields left out, keep the defaults. No sounds.json at all means defaults for every sound.

  • name: non-empty string shown in the system notification settings.

  • description: string shown under the channel name in the system settings.

  • importance: min, low, default, high or max (any case), or the number 1 to 5. These are the values AndroidNotification.createNotificationChannel accepts (none/0 is not allowed, it would create a blocked channel).

The build fails on invalid JSON (strict: no comments, trailing commas or duplicate keys), a root that is not an object, an unknown key, an invalid importance, a name that is empty, or an entry for a sound that is not bundled.

Removing an entry (or a field) restores the default name and clears the description on the next app start.

How It Works (Android)

On Android 8+ the channel's sound plays, not the sound set on the notification. A custom sound set on a notification posted to default_channel is ignored. So the app gives each bundled sound its own channel.

Build

A Gradle task (generate<Variant>NotificationSounds, logic in android/buildSrc/) runs before resources are merged:

  • copies sounds/*.wav into a generated res directory (app/build/generated/res/...), registered as an extra res source. Nothing is written into src/main/res;

  • writes keep rules (tools:keep) for every bundled sound, including those in res/raw, so release resource shrinking never removes them;

  • writes the list of bundled sound names (R.array.mobiweb_bundled_sounds), which the app reads at runtime, plus parallel arrays with the sounds.json channel name, description and importance.

Channels

Wherever default_channel is created (app start and before each notification is shown), the app also:

  • creates one channel per bundled sound, id mobiweb_sound_<name>, with that sound, high importance, and a name derived from the file name (ity_emergency becomes "Ity emergency"), unless sounds.json overrides them. On an existing channel only the name and description are updated;

  • deletes channels whose id starts with mobiweb_sound_ and whose sound is no longer bundled.

No other channel is ever touched: default_channel and channels created by the web app (AndroidNotification.createNotificationChannel) are left alone.

Removing a sound deletes its channel on the next app start (or the next notification). Users lose any per-channel settings they changed for it. If the same sound is added back later, Android restores the old channel together with the user's settings for it.

Channel ids beginning with mobiweb_sound_ are reserved for these auto channels. Don't create web channels with that prefix: they would be deleted.

Routing

A notification (push or local) is posted on mobiweb_sound_<name> only when all of these hold:

  • sound is custom: not empty, not default, not none;

  • no channel_id was given (neither in data nor in the FCM notification block);

  • the notification is not silent;

  • the sound name, with any path and extension stripped, is a bundled sound (ity_emergency, ity_emergency.wav and android.resource://.../raw/ity_emergency all match).

In every other case the notification renders exactly as before:

sound

channel_id

Channel used

Sound

ity_emergency (bundled)

none

mobiweb_sound_ity_emergency

ity_emergency

ity_emergency.wav (bundled)

none

mobiweb_sound_ity_emergency

ity_emergency

ity_emergency (bundled)

orders

orders

Whatever orders has (explicit wins)

other_sound (not bundled)

none

default_channel

Channel default (as before)

default/ missing

none

default_channel

System default

none

none

default_channel

As before

a URI (not a bundled name)

none

default_channel

As before (URI fallback)

Below Android 8 there are no channels; the sound set on the notification plays, as before.

To route a bundled sound and pick the channel yourself, send channel_id: "mobiweb_sound_<name>".

App in the background

When the app is in the background, a push with an FCM notification block is shown by the system, not by the app. The paired fcm-proxy change (Dwigoric/fcm-proxy#9) therefore sends pushes with a custom sound as data-only Android messages, so the app always renders them and routes them to the sound's channel. As with other data-only pushes, a force-stopped app only receives them after it is launched again.

How It Works (iOS)

iOS has no channels: the sound set on the notification plays, from a file in the app bundle.

Build

The App target has a Copy Notification Sounds Run Script phase (after Copy Bundle Resources) that runs ios/App/scripts/copy-notification-sounds.sh:

  • validates sounds/ with the rules above, and checks each file with afinfo;

  • copies every sounds/*.wav into the root of the app bundle, next to sounds added to the App target directly;

  • removes sounds it copied in an earlier build that are no longer in sounds/.

Adding or removing a sound needs no Xcode project change. The phase writes an Xcode dependency file listing sounds/ and every file in it, so it only re-runs when a sound is added, removed or edited. An empty sounds/ (only README.md) builds as before. Tests: yarn test:ios-sounds.

Name Resolution

Pushes (Notification Service Extension), critical alerts, local notifications and foreground presentation resolve sound the same way (ios/App/Shared/NotificationSoundResolver.swift):

sound

Plays

default or missing

System default (defaultCritical for critical alerts)

none

Nothing

ity_emergency.wav

That file, exactly as given (default sound if the file doesn't exist)

ity_emergency

The first of ity_emergency.wav, .caf, .aiff in the app bundle

other_sound (no file)

System default

default and none match in any case. The extension looks sounds up in the containing app bundle, not in its own .appex. The proxy sends aps.sound exactly as the sender gave it; the extension replaces it with the resolved sound. If the extension doesn't run (it has about 30 seconds), iOS plays aps.sound as given, so a name without an extension falls back to the default sound in that case.

Payload Examples

{ "title": "Emergency", "subtitle": "Code red in building A", "data": { "sound": "ity_emergency" } }
{ "title": "Order update", "subtitle": "Plays the orders channel's sound, not the custom one", "data": { "sound": "ity_emergency.wav", "channel_id": "orders" } }
15 November 2026