about summary refs log tree commit diff
diff options
context:
space:
mode:
authorHailey <me@haileyok.com>2024-05-15 11:49:07 -0700
committerGitHub <noreply@github.com>2024-05-15 11:49:07 -0700
commitbf7b66d5c1c0d7f7bdcc1c1aa43b6881d797d1e8 (patch)
treedfbbd3babc73b4251883b8dc786b1dbea7f08c1a
parent31868b255f7be001821e03033b3bdd1070ea28cf (diff)
downloadvoidsky-bf7b66d5c1c0d7f7bdcc1c1aa43b6881d797d1e8.tar.zst
Add push notification extensions (#4005)
* add wav

* add sound to config

* add extension to `updateExtensions.sh`

* add ios source files

* add a build extension

* add a new module

* use correct type on ios

* update the build plugin

* add android handler

* create a patch for expo-notifications

* basic android implementation

* add entitlements for notifications extension

* add some generic logic for ios

* add age check logic

* add extension to app config

* remove dash

* move directory

* rename again

* update privacy manifest

* add prefs storage ios

* better types

* create interface for setting and getting prefs

* add notifications prefs for android

* add functions to module

* add types to js

* add prefs context

* add web stub

* wrap the app

* fix types

* more preferences for ios

* add a test toggle

* swap vars

* update patch

* fix patch error

* fix typo

* sigh

* sigh

* get stored prefs on launch

* anotehr type

* simplify

* about finished

* comment

* adjust plugin

* use supported file types

* update NSE

* futureproof ios

* futureproof android

* update sound file name

* handle initialization

* more cleanup

* update js types

* strict js types

* set the notification channel

* rm

* add silent channel

* add mute logic

* update patch

* podfile

* adjust channels

* fix android channel

* update readme

* oreo or higher

* nit

* don't use getValue

* nit
-rw-r--r--app.config.js14
-rw-r--r--assets/blueskydm.wavbin33540 -> 0 bytes
-rw-r--r--assets/dm.aiffbin0 -> 184392 bytes
-rw-r--r--assets/dm.mp3bin0 -> 7935 bytes
-rw-r--r--modules/BlueskyNSE/BlueskyNSE.entitlements10
-rw-r--r--modules/BlueskyNSE/Info.plist29
-rw-r--r--modules/BlueskyNSE/NotificationService.swift51
-rw-r--r--modules/Share-with-Bluesky/Info.plist2
-rw-r--r--modules/Share-with-Bluesky/Share-with-Bluesky.entitlements2
-rw-r--r--modules/expo-background-notification-handler/android/build.gradle93
-rw-r--r--modules/expo-background-notification-handler/android/src/main/AndroidManifest.xml2
-rw-r--r--modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt39
-rw-r--r--modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandlerInterface.kt7
-rw-r--r--modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/ExpoBackgroundNotificationHandlerModule.kt70
-rw-r--r--modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/NotificationPrefs.kt134
-rw-r--r--modules/expo-background-notification-handler/expo-module.config.json9
-rw-r--r--modules/expo-background-notification-handler/index.ts2
-rw-r--r--modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandler.podspec21
-rw-r--r--modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandlerModule.swift116
-rw-r--r--modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx70
-rw-r--r--modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandler.types.ts40
-rw-r--r--modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.ts8
-rw-r--r--modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts27
-rw-r--r--patches/expo-notifications+0.27.6.patch197
-rw-r--r--patches/expo-notifications-0.27.6.patch.md9
-rw-r--r--plugins/notificationsExtension/README.md17
-rw-r--r--plugins/notificationsExtension/withAppEntitlements.js13
-rw-r--r--plugins/notificationsExtension/withExtensionEntitlements.js31
-rw-r--r--plugins/notificationsExtension/withExtensionInfoPlist.js39
-rw-r--r--plugins/notificationsExtension/withExtensionViewController.js31
-rw-r--r--plugins/notificationsExtension/withNotificationsExtension.js55
-rw-r--r--plugins/notificationsExtension/withSounds.js27
-rw-r--r--plugins/notificationsExtension/withXcodeTarget.js76
-rwxr-xr-xscripts/updateExtensions.sh8
-rw-r--r--src/App.native.tsx11
-rw-r--r--src/App.web.tsx9
-rw-r--r--src/lib/hooks/useNotificationHandler.ts25
-rw-r--r--src/screens/Messages/Settings.tsx15
38 files changed, 1297 insertions, 12 deletions
diff --git a/app.config.js b/app.config.js
index 4b54157c2..4ade9de31 100644
--- a/app.config.js
+++ b/app.config.js
@@ -110,7 +110,7 @@ module.exports = function (config) {
             {
               NSPrivacyAccessedAPIType:
                 'NSPrivacyAccessedAPICategoryUserDefaults',
-              NSPrivacyAccessedAPITypeReasons: ['CA92.1'],
+              NSPrivacyAccessedAPITypeReasons: ['CA92.1', '1C8F.1'],
             },
           ],
         },
@@ -200,7 +200,7 @@ module.exports = function (config) {
           {
             icon: './assets/icon-android-notification.png',
             color: '#1185fe',
-            sounds: ['assets/blueskydm.wav'],
+            sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
           },
         ],
         './plugins/withAndroidManifestPlugin.js',
@@ -209,6 +209,7 @@ module.exports = function (config) {
         './plugins/withAndroidStylesAccentColorPlugin.js',
         './plugins/withAndroidSplashScreenStatusBarTranslucentPlugin.js',
         './plugins/shareExtension/withShareExtensions.js',
+        './plugins/notificationsExtension/withNotificationsExtension.js',
       ].filter(Boolean),
       extra: {
         eas: {
@@ -225,6 +226,15 @@ module.exports = function (config) {
                       ],
                     },
                   },
+                  {
+                    targetName: 'BlueskyNSE',
+                    bundleIdentifier: 'xyz.blueskyweb.app.BlueskyNSE',
+                    entitlements: {
+                      'com.apple.security.application-groups': [
+                        'group.app.bsky',
+                      ],
+                    },
+                  },
                 ],
               },
             },
diff --git a/assets/blueskydm.wav b/assets/blueskydm.wav
deleted file mode 100644
index 8d35258dd..000000000
--- a/assets/blueskydm.wav
+++ /dev/null
Binary files differdiff --git a/assets/dm.aiff b/assets/dm.aiff
new file mode 100644
index 000000000..364b814b7
--- /dev/null
+++ b/assets/dm.aiff
Binary files differdiff --git a/assets/dm.mp3 b/assets/dm.mp3
new file mode 100644
index 000000000..acb5728ee
--- /dev/null
+++ b/assets/dm.mp3
Binary files differdiff --git a/modules/BlueskyNSE/BlueskyNSE.entitlements b/modules/BlueskyNSE/BlueskyNSE.entitlements
new file mode 100644
index 000000000..4954bdb33
--- /dev/null
+++ b/modules/BlueskyNSE/BlueskyNSE.entitlements
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>com.apple.security.application-groups</key>
+    <array>
+      <string>group.app.bsky</string>
+    </array>
+  </dict>
+</plist>
\ No newline at end of file
diff --git a/modules/BlueskyNSE/Info.plist b/modules/BlueskyNSE/Info.plist
new file mode 100644
index 000000000..c2dd7eda6
--- /dev/null
+++ b/modules/BlueskyNSE/Info.plist
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+  <dict>
+    <key>NSExtension</key>
+    <dict>
+      <key>NSExtensionPointIdentifier</key>
+      <string>com.apple.usernotifications.service</string>
+      <key>NSExtensionPrincipalClass</key>
+      <string>$(PRODUCT_MODULE_NAME).NotificationService</string>
+    </dict>
+    <key>MainAppScheme</key>
+    <string>bluesky</string>
+    <key>CFBundleName</key>
+    <string>$(PRODUCT_NAME)</string>
+    <key>CFBundleDisplayName</key>
+    <string>Bluesky Notifications</string>
+    <key>CFBundleIdentifier</key>
+    <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+    <key>CFBundleVersion</key>
+    <string>$(CURRENT_PROJECT_VERSION)</string>
+    <key>CFBundleExecutable</key>
+    <string>$(EXECUTABLE_NAME)</string>
+    <key>CFBundlePackageType</key>
+    <string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
+    <key>CFBundleShortVersionString</key>
+    <string>$(MARKETING_VERSION)</string>
+  </dict>
+</plist>
\ No newline at end of file
diff --git a/modules/BlueskyNSE/NotificationService.swift b/modules/BlueskyNSE/NotificationService.swift
new file mode 100644
index 000000000..c6f391e00
--- /dev/null
+++ b/modules/BlueskyNSE/NotificationService.swift
@@ -0,0 +1,51 @@
+import UserNotifications
+
+let APP_GROUP = "group.app.bsky"
+
+class NotificationService: UNNotificationServiceExtension {
+  var prefs = UserDefaults(suiteName: APP_GROUP)
+
+  override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
+    guard var bestAttempt = createCopy(request.content),
+          let reason = request.content.userInfo["reason"] as? String
+    else {
+      contentHandler(request.content)
+      return
+    }
+    
+    if reason == "chat-message" {
+      mutateWithChatMessage(bestAttempt)
+    }
+    
+    // The badge should always be incremented when in the background
+    mutateWithBadge(bestAttempt)
+    
+    contentHandler(bestAttempt)
+  }
+  
+  override func serviceExtensionTimeWillExpire() {
+    // If for some reason the alloted time expires, we don't actually want to display a notification
+  }
+  
+  func createCopy(_ content: UNNotificationContent) -> UNMutableNotificationContent? {
+    return content.mutableCopy() as? UNMutableNotificationContent
+  }
+  
+  func mutateWithBadge(_ content: UNMutableNotificationContent) {
+    content.badge = 1
+  }
+  
+  func mutateWithChatMessage(_ content: UNMutableNotificationContent) {
+    if self.prefs?.bool(forKey: "playSoundChat") == true {
+      mutateWithDmSound(content)
+    }
+  }
+  
+  func mutateWithDefaultSound(_ content: UNMutableNotificationContent) {
+    content.sound = UNNotificationSound.default
+  }
+  
+  func mutateWithDmSound(_ content: UNMutableNotificationContent) {
+    content.sound = UNNotificationSound(named: UNNotificationSoundName(rawValue: "dm.aiff"))
+  }
+}
diff --git a/modules/Share-with-Bluesky/Info.plist b/modules/Share-with-Bluesky/Info.plist
index 90fe92345..421abb3c4 100644
--- a/modules/Share-with-Bluesky/Info.plist
+++ b/modules/Share-with-Bluesky/Info.plist
@@ -38,4 +38,4 @@
     <key>CFBundleShortVersionString</key>
     <string>$(MARKETING_VERSION)</string>
   </dict>
-</plist>
+</plist>
\ No newline at end of file
diff --git a/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements b/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements
index d2253d31f..4954bdb33 100644
--- a/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements
+++ b/modules/Share-with-Bluesky/Share-with-Bluesky.entitlements
@@ -7,4 +7,4 @@
       <string>group.app.bsky</string>
     </array>
   </dict>
-</plist>
+</plist>
\ No newline at end of file
diff --git a/modules/expo-background-notification-handler/android/build.gradle b/modules/expo-background-notification-handler/android/build.gradle
new file mode 100644
index 000000000..e18eee934
--- /dev/null
+++ b/modules/expo-background-notification-handler/android/build.gradle
@@ -0,0 +1,93 @@
+apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
+apply plugin: 'maven-publish'
+
+group = 'expo.modules.backgroundnotificationhandler'
+version = '0.5.0'
+
+buildscript {
+  def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
+  if (expoModulesCorePlugin.exists()) {
+    apply from: expoModulesCorePlugin
+    applyKotlinExpoModulesCorePlugin()
+  }
+
+  // Simple helper that allows the root project to override versions declared by this library.
+  ext.safeExtGet = { prop, fallback ->
+    rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
+  }
+
+  // Ensures backward compatibility
+  ext.getKotlinVersion = {
+    if (ext.has("kotlinVersion")) {
+      ext.kotlinVersion()
+    } else {
+      ext.safeExtGet("kotlinVersion", "1.8.10")
+    }
+  }
+
+  repositories {
+    mavenCentral()
+  }
+
+  dependencies {
+    classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${getKotlinVersion()}")
+  }
+}
+
+afterEvaluate {
+  publishing {
+    publications {
+      release(MavenPublication) {
+        from components.release
+      }
+    }
+    repositories {
+      maven {
+        url = mavenLocal().url
+      }
+    }
+  }
+}
+
+android {
+  compileSdkVersion safeExtGet("compileSdkVersion", 33)
+
+  def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
+  if (agpVersion.tokenize('.')[0].toInteger() < 8) {
+    compileOptions {
+      sourceCompatibility JavaVersion.VERSION_11
+      targetCompatibility JavaVersion.VERSION_11
+    }
+
+    kotlinOptions {
+      jvmTarget = JavaVersion.VERSION_11.majorVersion
+    }
+  }
+
+  namespace "expo.modules.backgroundnotificationhandler"
+  defaultConfig {
+    minSdkVersion safeExtGet("minSdkVersion", 21)
+    targetSdkVersion safeExtGet("targetSdkVersion", 34)
+    versionCode 1
+    versionName "0.5.0"
+  }
+  lintOptions {
+    abortOnError false
+  }
+  publishing {
+    singleVariant("release") {
+      withSourcesJar()
+    }
+  }
+}
+
+repositories {
+  mavenCentral()
+}
+
+dependencies {
+  implementation project(':expo-modules-core')
+  implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:${getKotlinVersion()}"
+  implementation 'com.google.firebase:firebase-messaging-ktx:24.0.0'
+}
diff --git a/modules/expo-background-notification-handler/android/src/main/AndroidManifest.xml b/modules/expo-background-notification-handler/android/src/main/AndroidManifest.xml
new file mode 100644
index 000000000..bdae66c8f
--- /dev/null
+++ b/modules/expo-background-notification-handler/android/src/main/AndroidManifest.xml
@@ -0,0 +1,2 @@
+<manifest>
+</manifest>
diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
new file mode 100644
index 000000000..344508523
--- /dev/null
+++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandler.kt
@@ -0,0 +1,39 @@
+package expo.modules.backgroundnotificationhandler
+
+import android.content.Context
+import com.google.firebase.messaging.RemoteMessage
+
+class BackgroundNotificationHandler(
+  private val context: Context,
+  private val notifInterface: BackgroundNotificationHandlerInterface
+) {
+  fun handleMessage(remoteMessage: RemoteMessage) {
+    if (ExpoBackgroundNotificationHandlerModule.isForegrounded) {
+      // We'll let expo-notifications handle the notification if the app is foregrounded
+      return
+    }
+
+    if (remoteMessage.data["reason"] == "chat-message") {
+      mutateWithChatMessage(remoteMessage)
+    }
+
+    notifInterface.showMessage(remoteMessage)
+  }
+
+  private fun mutateWithChatMessage(remoteMessage: RemoteMessage) {
+    if (NotificationPrefs(context).getBoolean("playSoundChat")) {
+      // If oreo or higher
+      if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+        remoteMessage.data["channelId"] = "chat-messages"
+      } else {
+        remoteMessage.data["sound"] = "dm.mp3"
+      }
+    } else {
+      if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
+        remoteMessage.data["channelId"] = "chat-messages-muted"
+      } else {
+        remoteMessage.data["sound"] = null
+      }
+    }
+  }
+}
diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandlerInterface.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandlerInterface.kt
new file mode 100644
index 000000000..41fb65eb6
--- /dev/null
+++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/BackgroundNotificationHandlerInterface.kt
@@ -0,0 +1,7 @@
+package expo.modules.backgroundnotificationhandler
+
+import com.google.firebase.messaging.RemoteMessage
+
+interface BackgroundNotificationHandlerInterface {
+  fun showMessage(remoteMessage: RemoteMessage)
+}
diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/ExpoBackgroundNotificationHandlerModule.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/ExpoBackgroundNotificationHandlerModule.kt
new file mode 100644
index 000000000..083ff1223
--- /dev/null
+++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/ExpoBackgroundNotificationHandlerModule.kt
@@ -0,0 +1,70 @@
+package expo.modules.backgroundnotificationhandler
+
+import expo.modules.kotlin.modules.Module
+import expo.modules.kotlin.modules.ModuleDefinition
+
+class ExpoBackgroundNotificationHandlerModule : Module() {
+  companion object {
+    var isForegrounded = false
+  }
+
+  override fun definition() = ModuleDefinition {
+    Name("ExpoBackgroundNotificationHandler")
+
+    OnCreate {
+      NotificationPrefs(appContext.reactContext).initialize()
+    }
+
+    OnActivityEntersForeground {
+      isForegrounded = true
+    }
+
+    OnActivityEntersBackground {
+      isForegrounded = false
+    }
+
+    AsyncFunction("getAllPrefsAsync") {
+      return@AsyncFunction NotificationPrefs(appContext.reactContext).getAllPrefs()
+    }
+
+    AsyncFunction("getBoolAsync") { forKey: String ->
+      return@AsyncFunction NotificationPrefs(appContext.reactContext).getBoolean(forKey)
+    }
+
+    AsyncFunction("getStringAsync") { forKey: String ->
+      return@AsyncFunction NotificationPrefs(appContext.reactContext).getString(forKey)
+    }
+
+    AsyncFunction("getStringArrayAsync") { forKey: String ->
+      return@AsyncFunction NotificationPrefs(appContext.reactContext).getStringArray(forKey)
+    }
+
+    AsyncFunction("setBoolAsync") { forKey: String, value: Boolean ->
+      NotificationPrefs(appContext.reactContext).setBoolean(forKey, value)
+    }
+
+    AsyncFunction("setStringAsync") { forKey: String, value: String ->
+      NotificationPrefs(appContext.reactContext).setString(forKey, value)
+    }
+
+    AsyncFunction("setStringArrayAsync") { forKey: String, value: Array<String> ->
+      NotificationPrefs(appContext.reactContext).setStringArray(forKey, value)
+    }
+
+    AsyncFunction("addToStringArrayAsync") { forKey: String, string: String ->
+      NotificationPrefs(appContext.reactContext).addToStringArray(forKey, string)
+    }
+
+    AsyncFunction("removeFromStringArrayAsync") { forKey: String, string: String ->
+      NotificationPrefs(appContext.reactContext).removeFromStringArray(forKey, string)
+    }
+
+    AsyncFunction("addManyToStringArrayAsync") { forKey: String, strings: Array<String> ->
+      NotificationPrefs(appContext.reactContext).addManyToStringArray(forKey, strings)
+    }
+
+    AsyncFunction("removeManyFromStringArrayAsync") { forKey: String, strings: Array<String> ->
+      NotificationPrefs(appContext.reactContext).removeManyFromStringArray(forKey, strings)
+    }
+  }
+}
diff --git a/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/NotificationPrefs.kt b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/NotificationPrefs.kt
new file mode 100644
index 000000000..17ef9205e
--- /dev/null
+++ b/modules/expo-background-notification-handler/android/src/main/java/expo/modules/backgroundnotificationhandler/NotificationPrefs.kt
@@ -0,0 +1,134 @@
+package expo.modules.backgroundnotificationhandler
+
+import android.content.Context
+
+val DEFAULTS = mapOf<String, Any>(
+  "playSoundChat" to true,
+  "playSoundFollow" to false,
+  "playSoundLike" to false,
+  "playSoundMention" to false,
+  "playSoundQuote" to false,
+  "playSoundReply" to false,
+  "playSoundRepost" to false,
+  "mutedThreads" to mapOf<String, List<String>>()
+)
+
+class NotificationPrefs (private val context: Context?) {
+  private val prefs = context?.getSharedPreferences("xyz.blueskyweb.app", Context.MODE_PRIVATE)
+    ?: throw Error("Context is null")
+
+  fun initialize() {
+    prefs
+      .edit()
+      .apply {
+        DEFAULTS.forEach { (key, value) ->
+          if (prefs.contains(key)) {
+            return@forEach
+          }
+
+          when (value) {
+            is Boolean -> {
+              putBoolean(key, value)
+            }
+            is String -> {
+              putString(key, value)
+            }
+            is Array<*> -> {
+              putStringSet(key, value.map { it.toString() }.toSet())
+            }
+            is Map<*, *> -> {
+              putStringSet(key, value.map { it.toString() }.toSet())
+            }
+          }
+        }
+      }
+      .apply()
+  }
+
+  fun getAllPrefs(): MutableMap<String, *> {
+    return prefs.all
+  }
+
+  fun getBoolean(key: String): Boolean {
+    return prefs.getBoolean(key, false)
+  }
+
+  fun getString(key: String): String? {
+    return prefs.getString(key, null)
+  }
+
+  fun getStringArray(key: String): Array<String>? {
+    return prefs.getStringSet(key, null)?.toTypedArray()
+  }
+
+  fun setBoolean(key: String, value: Boolean) {
+    prefs
+      .edit()
+      .apply {
+        putBoolean(key, value)
+      }
+      .apply()
+  }
+
+  fun setString(key: String, value: String) {
+    prefs
+      .edit()
+      .apply {
+        putString(key, value)
+      }
+      .apply()
+  }
+
+  fun setStringArray(key: String, value: Array<String>) {
+    prefs
+      .edit()
+      .apply {
+        putStringSet(key, value.toSet())
+      }
+      .apply()
+  }
+
+  fun addToStringArray(key: String, string: String) {
+    prefs
+      .edit()
+      .apply {
+        val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
+        set.add(string)
+        putStringSet(key, set)
+      }
+      .apply()
+  }
+
+  fun removeFromStringArray(key: String, string: String) {
+    prefs
+      .edit()
+      .apply {
+        val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
+        set.remove(string)
+        putStringSet(key, set)
+      }
+      .apply()
+  }
+
+  fun addManyToStringArray(key: String, strings: Array<String>) {
+    prefs
+      .edit()
+      .apply {
+        val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
+        set.addAll(strings.toSet())
+        putStringSet(key, set)
+      }
+      .apply()
+  }
+
+  fun removeManyFromStringArray(key: String, strings: Array<String>) {
+    prefs
+      .edit()
+      .apply {
+        val set = prefs.getStringSet(key, null)?.toMutableSet() ?: mutableSetOf()
+        set.removeAll(strings.toSet())
+        putStringSet(key, set)
+      }
+      .apply()
+  }
+}
\ No newline at end of file
diff --git a/modules/expo-background-notification-handler/expo-module.config.json b/modules/expo-background-notification-handler/expo-module.config.json
new file mode 100644
index 000000000..9e5c9d550
--- /dev/null
+++ b/modules/expo-background-notification-handler/expo-module.config.json
@@ -0,0 +1,9 @@
+{
+  "platforms": ["ios", "android"],
+  "ios": {
+    "modules": ["ExpoBackgroundNotificationHandlerModule"]
+  },
+  "android": {
+    "modules": ["expo.modules.backgroundnotificationhandler.ExpoBackgroundNotificationHandlerModule"]
+  }
+}
diff --git a/modules/expo-background-notification-handler/index.ts b/modules/expo-background-notification-handler/index.ts
new file mode 100644
index 000000000..680c6c13f
--- /dev/null
+++ b/modules/expo-background-notification-handler/index.ts
@@ -0,0 +1,2 @@
+import {BackgroundNotificationHandler} from './src/ExpoBackgroundNotificationHandlerModule'
+export default BackgroundNotificationHandler
diff --git a/modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandler.podspec b/modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandler.podspec
new file mode 100644
index 000000000..363c7b5e6
--- /dev/null
+++ b/modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandler.podspec
@@ -0,0 +1,21 @@
+Pod::Spec.new do |s|
+  s.name           = 'ExpoBackgroundNotificationHandler'
+  s.version        = '1.0.0'
+  s.summary        = 'Interface for BlueskyNSE preferences'
+  s.description    = 'Interface for BlueskyNSE preferenes'
+  s.author         = ''
+  s.homepage       = 'https://github.com/bluesky-social/social-app'
+  s.platforms      = { :ios => '13.4', :tvos => '13.4' }
+  s.source         = { git: '' }
+  s.static_framework = true
+
+  s.dependency 'ExpoModulesCore'
+
+  # Swift/Objective-C compatibility
+  s.pod_target_xcconfig = {
+    'DEFINES_MODULE' => 'YES',
+    'SWIFT_COMPILATION_MODE' => 'wholemodule'
+  }
+
+  s.source_files = "**/*.{h,m,mm,swift,hpp,cpp}"
+end
diff --git a/modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandlerModule.swift b/modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandlerModule.swift
new file mode 100644
index 000000000..08972a04c
--- /dev/null
+++ b/modules/expo-background-notification-handler/ios/ExpoBackgroundNotificationHandlerModule.swift
@@ -0,0 +1,116 @@
+import ExpoModulesCore
+
+let APP_GROUP = "group.app.bsky"
+
+let DEFAULTS: [String:Any] = [
+  "playSoundChat" : true,
+  "playSoundFollow": false,
+  "playSoundLike": false,
+  "playSoundMention": false,
+  "playSoundQuote": false,
+  "playSoundReply": false,
+  "playSoundRepost": false,
+  "mutedThreads": [:] as! [String:[String]]
+]
+
+/*
+ * The purpose of this module is to store values that are needed by the notification service
+ * extension. Since we would rather get and store values such as age or user mute state
+ * while the app is foregrounded, we should use this module liberally. We should aim to keep
+ * background fetches to a minimum (two or three times per hour) while the app is backgrounded
+ * or killed
+ */
+public class ExpoBackgroundNotificationHandlerModule: Module {
+  let userDefaults = UserDefaults(suiteName: APP_GROUP)
+  
+  public func definition() -> ModuleDefinition {
+    Name("ExpoBackgroundNotificationHandler")
+    
+    OnCreate {
+      DEFAULTS.forEach { p in
+        if userDefaults?.value(forKey: p.key) == nil {
+          userDefaults?.setValue(p.value, forKey: p.key)
+        }
+      }
+    }
+    
+    AsyncFunction("getAllPrefsAsync") { () -> [String:Any]? in
+      var keys: [String] = []
+      DEFAULTS.forEach { p in
+        keys.append(p.key)
+      }
+      return userDefaults?.dictionaryWithValues(forKeys: keys)
+    }
+    
+    AsyncFunction("getBoolAsync") { (forKey: String) -> Bool in
+      if let pref = userDefaults?.bool(forKey: forKey) {
+        return pref
+      }
+      return false
+    }
+    
+    AsyncFunction("getStringAsync") { (forKey: String) -> String? in
+      if let pref = userDefaults?.string(forKey: forKey) {
+        return pref
+      }
+      return nil
+    }
+    
+    AsyncFunction("getStringArrayAsync") { (forKey: String) -> [String]? in
+      if let pref = userDefaults?.stringArray(forKey: forKey) {
+        return pref
+      }
+      return nil
+    }
+    
+    AsyncFunction("setBoolAsync") { (forKey: String, value: Bool) -> Void in
+      userDefaults?.setValue(value, forKey: forKey)
+    }
+    
+    AsyncFunction("setStringAsync") { (forKey: String, value: String) -> Void in
+      userDefaults?.setValue(value, forKey: forKey)
+    }
+    
+    AsyncFunction("setStringArrayAsync") { (forKey: String, value: [String]) -> Void in
+      userDefaults?.setValue(value, forKey: forKey)
+    }
+    
+    AsyncFunction("addToStringArrayAsync") { (forKey: String, string: String) in
+      if var curr = userDefaults?.stringArray(forKey: forKey),
+         !curr.contains(string)
+      {
+        curr.append(string)
+        userDefaults?.setValue(curr, forKey: forKey)
+      }
+    }
+    
+    AsyncFunction("removeFromStringArrayAsync") { (forKey: String, string: String) in
+      if var curr = userDefaults?.stringArray(forKey: forKey) {
+        curr.removeAll { s in
+          return s == string
+        }
+        userDefaults?.setValue(curr, forKey: forKey)
+      }
+    }
+    
+    AsyncFunction("addManyToStringArrayAsync") { (forKey: String, strings: [String]) in
+      if var curr = userDefaults?.stringArray(forKey: forKey) {
+        strings.forEach { s in
+          if !curr.contains(s) {
+            curr.append(s)
+          }
+        }
+        userDefaults?.setValue(curr, forKey: forKey)
+      }
+    }
+    
+    AsyncFunction("removeManyFromStringArrayAsync") { (forKey: String, strings: [String]) in
+      if var curr = userDefaults?.stringArray(forKey: forKey) {
+        strings.forEach { s in
+          curr.removeAll(where: { $0 == s })
+        }
+        userDefaults?.setValue(curr, forKey: forKey)
+      }
+    }
+  }
+}
diff --git a/modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx b/modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx
new file mode 100644
index 000000000..6ecdd1d47
--- /dev/null
+++ b/modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider.tsx
@@ -0,0 +1,70 @@
+import React from 'react'
+
+import {BackgroundNotificationHandlerPreferences} from './ExpoBackgroundNotificationHandler.types'
+import {BackgroundNotificationHandler} from './ExpoBackgroundNotificationHandlerModule'
+
+interface BackgroundNotificationPreferencesContext {
+  preferences: BackgroundNotificationHandlerPreferences
+  setPref: <Key extends keyof BackgroundNotificationHandlerPreferences>(
+    key: Key,
+    value: BackgroundNotificationHandlerPreferences[Key],
+  ) => void
+}
+
+const Context = React.createContext<BackgroundNotificationPreferencesContext>(
+  {} as BackgroundNotificationPreferencesContext,
+)
+export const useBackgroundNotificationPreferences = () =>
+  React.useContext(Context)
+
+export function BackgroundNotificationPreferencesProvider({
+  children,
+}: {
+  children: React.ReactNode
+}) {
+  const [preferences, setPreferences] =
+    React.useState<BackgroundNotificationHandlerPreferences>({
+      playSoundChat: true,
+    })
+
+  React.useEffect(() => {
+    ;(async () => {
+      const prefs = await BackgroundNotificationHandler.getAllPrefsAsync()
+      setPreferences(prefs)
+    })()
+  }, [])
+
+  const value = React.useMemo(
+    () => ({
+      preferences,
+      setPref: async <
+        Key extends keyof BackgroundNotificationHandlerPreferences,
+      >(
+        k: Key,
+        v: BackgroundNotificationHandlerPreferences[Key],
+      ) => {
+        switch (typeof v) {
+          case 'boolean': {
+            await BackgroundNotificationHandler.setBoolAsync(k, v)
+            break
+          }
+          case 'string': {
+            await BackgroundNotificationHandler.setStringAsync(k, v)
+            break
+          }
+          default: {
+            throw new Error(`Invalid type for value: ${typeof v}`)
+          }
+        }
+
+        setPreferences(prev => ({
+          ...prev,
+          [k]: v,
+        }))
+      },
+    }),
+    [preferences],
+  )
+
+  return <Context.Provider value={value}>{children}</Context.Provider>
+}
diff --git a/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandler.types.ts b/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandler.types.ts
new file mode 100644
index 000000000..5fbd302da
--- /dev/null
+++ b/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandler.types.ts
@@ -0,0 +1,40 @@
+export type ExpoBackgroundNotificationHandlerModule = {
+  getAllPrefsAsync: () => Promise<BackgroundNotificationHandlerPreferences>
+  getBoolAsync: (forKey: string) => Promise<boolean>
+  getStringAsync: (forKey: string) => Promise<string>
+  getStringArrayAsync: (forKey: string) => Promise<string[]>
+  setBoolAsync: (
+    forKey: keyof BackgroundNotificationHandlerPreferences,
+    value: boolean,
+  ) => Promise<void>
+  setStringAsync: (
+    forKey: keyof BackgroundNotificationHandlerPreferences,
+    value: string,
+  ) => Promise<void>
+  setStringArrayAsync: (
+    forKey: keyof BackgroundNotificationHandlerPreferences,
+    value: string[],
+  ) => Promise<void>
+  addToStringArrayAsync: (
+    forKey: keyof BackgroundNotificationHandlerPreferences,
+    value: string,
+  ) => Promise<void>
+  removeFromStringArrayAsync: (
+    forKey: keyof BackgroundNotificationHandlerPreferences,
+    value: string,
+  ) => Promise<void>
+  addManyToStringArrayAsync: (
+    forKey: keyof BackgroundNotificationHandlerPreferences,
+    value: string[],
+  ) => Promise<void>
+  removeManyFromStringArrayAsync: (
+    forKey: keyof BackgroundNotificationHandlerPreferences,
+    value: string[],
+  ) => Promise<void>
+}
+
+// TODO there are more preferences in the native code, however they have not been added here yet.
+// Don't add them until the native logic also handles the notifications for those preference types.
+export type BackgroundNotificationHandlerPreferences = {
+  playSoundChat: boolean
+}
diff --git a/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.ts b/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.ts
new file mode 100644
index 000000000..d6517893a
--- /dev/null
+++ b/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.ts
@@ -0,0 +1,8 @@
+import {requireNativeModule} from 'expo-modules-core'
+
+import {ExpoBackgroundNotificationHandlerModule} from './ExpoBackgroundNotificationHandler.types'
+
+export const BackgroundNotificationHandler =
+  requireNativeModule<ExpoBackgroundNotificationHandlerModule>(
+    'ExpoBackgroundNotificationHandler',
+  )
diff --git a/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts b/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts
new file mode 100644
index 000000000..29e27fd0f
--- /dev/null
+++ b/modules/expo-background-notification-handler/src/ExpoBackgroundNotificationHandlerModule.web.ts
@@ -0,0 +1,27 @@
+import {
+  BackgroundNotificationHandlerPreferences,
+  ExpoBackgroundNotificationHandlerModule,
+} from './ExpoBackgroundNotificationHandler.types'
+
+// Stub for web
+export const BackgroundNotificationHandler = {
+  getAllPrefsAsync: async () => {
+    return {} as BackgroundNotificationHandlerPreferences
+  },
+  getBoolAsync: async (_: string) => {
+    return false
+  },
+  getStringAsync: async (_: string) => {
+    return ''
+  },
+  getStringArrayAsync: async (_: string) => {
+    return []
+  },
+  setBoolAsync: async (_: string, __: boolean) => {},
+  setStringAsync: async (_: string, __: string) => {},
+  setStringArrayAsync: async (_: string, __: string[]) => {},
+  addToStringArrayAsync: async (_: string, __: string) => {},
+  removeFromStringArrayAsync: async (_: string, __: string) => {},
+  addManyToStringArrayAsync: async (_: string, __: string[]) => {},
+  removeManyFromStringArrayAsync: async (_: string, __: string[]) => {},
+} as ExpoBackgroundNotificationHandlerModule
diff --git a/patches/expo-notifications+0.27.6.patch b/patches/expo-notifications+0.27.6.patch
new file mode 100644
index 000000000..ba196eca0
--- /dev/null
+++ b/patches/expo-notifications+0.27.6.patch
@@ -0,0 +1,197 @@
+diff --git a/node_modules/expo-notifications/android/build.gradle b/node_modules/expo-notifications/android/build.gradle
+index 97bf4f4..6e9d427 100644
+--- a/node_modules/expo-notifications/android/build.gradle
++++ b/node_modules/expo-notifications/android/build.gradle
+@@ -118,6 +118,7 @@ dependencies {
+   api 'com.google.firebase:firebase-messaging:22.0.0'
+
+   api 'me.leolin:ShortcutBadger:1.1.22@aar'
++  implementation project(':expo-background-notification-handler')
+
+   if (project.findProject(':expo-modules-test-core')) {
+     testImplementation project(':expo-modules-test-core')
+diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java
+index 0af7fe0..8f2c8d8 100644
+--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java
++++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/JSONNotificationContentBuilder.java
+@@ -14,6 +14,7 @@ import expo.modules.notifications.notifications.enums.NotificationPriority;
+ import expo.modules.notifications.notifications.model.NotificationContent;
+
+ public class JSONNotificationContentBuilder extends NotificationContent.Builder {
++  private static final String CHANNEL_ID_KEY = "channelId";
+   private static final String TITLE_KEY = "title";
+   private static final String TEXT_KEY = "message";
+   private static final String SUBTITLE_KEY = "subtitle";
+@@ -36,6 +37,7 @@ public class JSONNotificationContentBuilder extends NotificationContent.Builder
+
+   public NotificationContent.Builder setPayload(JSONObject payload) {
+     this.setTitle(getTitle(payload))
++      .setChannelId(getChannelId(payload))
+       .setSubtitle(getSubtitle(payload))
+       .setText(getText(payload))
+       .setBody(getBody(payload))
+@@ -60,6 +62,14 @@ public class JSONNotificationContentBuilder extends NotificationContent.Builder
+     return this;
+   }
+
++  protected String getChannelId(JSONObject payload) {
++    try {
++      return payload.getString(CHANNEL_ID_KEY);
++    } catch (JSONException e) {
++      return null;
++    }
++  }
++
+   protected String getTitle(JSONObject payload) {
+     try {
+       return payload.getString(TITLE_KEY);
+diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
+index f1fed19..1619f59 100644
+--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
++++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/model/NotificationContent.java
+@@ -20,6 +20,7 @@ import expo.modules.notifications.notifications.enums.NotificationPriority;
+  * should be created using {@link NotificationContent.Builder}.
+  */
+ public class NotificationContent implements Parcelable, Serializable {
++  private String mChannelId;
+   private String mTitle;
+   private String mText;
+   private String mSubtitle;
+@@ -50,6 +51,9 @@ public class NotificationContent implements Parcelable, Serializable {
+     }
+   };
+
++  @Nullable
++  public String getChannelId() { return mChannelId; }
++
+   @Nullable
+   public String getTitle() {
+     return mTitle;
+@@ -121,6 +125,7 @@ public class NotificationContent implements Parcelable, Serializable {
+   }
+
+   protected NotificationContent(Parcel in) {
++    mChannelId = in.readString();
+     mTitle = in.readString();
+     mText = in.readString();
+     mSubtitle = in.readString();
+@@ -146,6 +151,7 @@ public class NotificationContent implements Parcelable, Serializable {
+
+   @Override
+   public void writeToParcel(Parcel dest, int flags) {
++    dest.writeString(mChannelId);
+     dest.writeString(mTitle);
+     dest.writeString(mText);
+     dest.writeString(mSubtitle);
+@@ -166,6 +172,7 @@ public class NotificationContent implements Parcelable, Serializable {
+   private static final long serialVersionUID = 397666843266836802L;
+
+   private void writeObject(java.io.ObjectOutputStream out) throws IOException {
++    out.writeObject(mChannelId);
+     out.writeObject(mTitle);
+     out.writeObject(mText);
+     out.writeObject(mSubtitle);
+@@ -190,6 +197,7 @@ public class NotificationContent implements Parcelable, Serializable {
+   }
+
+   private void readObject(java.io.ObjectInputStream in) throws IOException, ClassNotFoundException {
++    mChannelId = (String) in.readObject();
+     mTitle = (String) in.readObject();
+     mText = (String) in.readObject();
+     mSubtitle = (String) in.readObject();
+@@ -240,6 +248,7 @@ public class NotificationContent implements Parcelable, Serializable {
+   }
+
+   public static class Builder {
++    private String mChannelId;
+     private String mTitle;
+     private String mText;
+     private String mSubtitle;
+@@ -260,6 +269,11 @@ public class NotificationContent implements Parcelable, Serializable {
+       useDefaultVibrationPattern();
+     }
+
++    public Builder setChannelId(String channelId) {
++      mChannelId = channelId;
++      return this;
++    }
++
+     public Builder setTitle(String title) {
+       mTitle = title;
+       return this;
+@@ -336,6 +350,7 @@ public class NotificationContent implements Parcelable, Serializable {
+
+     public NotificationContent build() {
+       NotificationContent content = new NotificationContent();
++      content.mChannelId = mChannelId;
+       content.mTitle = mTitle;
+       content.mSubtitle = mSubtitle;
+       content.mText = mText;
+diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.java b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.java
+index 6bd9928..aab71ea 100644
+--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.java
++++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/notifications/presentation/builders/ExpoNotificationBuilder.java
+@@ -7,7 +7,6 @@ import android.content.pm.PackageManager;
+ import android.content.res.Resources;
+ import android.graphics.Bitmap;
+ import android.graphics.BitmapFactory;
+-import android.os.Build;
+ import android.os.Bundle;
+ import android.os.Parcel;
+ import android.provider.Settings;
+@@ -48,6 +47,10 @@ public class ExpoNotificationBuilder extends ChannelAwareNotificationBuilder {
+
+     NotificationContent content = getNotificationContent();
+
++    if (content.getChannelId() != null) {
++      builder.setChannelId(content.getChannelId());
++    }
++
+     builder.setAutoCancel(content.isAutoDismiss());
+     builder.setOngoing(content.isSticky());
+
+diff --git a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
+index 55b3a8d..1b99d5b 100644
+--- a/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
++++ b/node_modules/expo-notifications/android/src/main/java/expo/modules/notifications/service/delegates/FirebaseMessagingDelegate.kt
+@@ -12,11 +12,14 @@ import expo.modules.notifications.notifications.model.triggers.FirebaseNotificat
+ import expo.modules.notifications.service.NotificationsService
+ import expo.modules.notifications.service.interfaces.FirebaseMessagingDelegate
+ import expo.modules.notifications.tokens.interfaces.FirebaseTokenListener
++import expo.modules.backgroundnotificationhandler.BackgroundNotificationHandler
++import expo.modules.backgroundnotificationhandler.BackgroundNotificationHandlerInterface
++import expo.modules.backgroundnotificationhandler.ExpoBackgroundNotificationHandlerModule
+ import org.json.JSONObject
+ import java.lang.ref.WeakReference
+ import java.util.*
+
+-open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate {
++open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseMessagingDelegate, BackgroundNotificationHandlerInterface {
+   companion object {
+     // Unfortunately we cannot save state between instances of a service other way
+     // than by static properties. Fortunately, using weak references we can
+@@ -89,12 +92,21 @@ open class FirebaseMessagingDelegate(protected val context: Context) : FirebaseM
+   fun getBackgroundTasks() = sBackgroundTaskConsumerReferences.values.mapNotNull { it.get() }
+
+   override fun onMessageReceived(remoteMessage: RemoteMessage) {
+-    NotificationsService.receive(context, createNotification(remoteMessage))
+-    getBackgroundTasks().forEach {
+-      it.scheduleJob(RemoteMessageSerializer.toBundle(remoteMessage))
++    if (!ExpoBackgroundNotificationHandlerModule.isForegrounded) {
++      BackgroundNotificationHandler(context, this).handleMessage(remoteMessage)
++      return
++    } else {
++      showMessage(remoteMessage)
++      getBackgroundTasks().forEach {
++        it.scheduleJob(RemoteMessageSerializer.toBundle(remoteMessage))
++      }
+     }
+   }
+
++  override fun showMessage(remoteMessage: RemoteMessage) {
++    NotificationsService.receive(context, createNotification(remoteMessage))
++  }
++
+   protected fun createNotification(remoteMessage: RemoteMessage): Notification {
+     val identifier = getNotificationIdentifier(remoteMessage)
+     val payload = JSONObject(remoteMessage.data as Map<*, *>)
diff --git a/patches/expo-notifications-0.27.6.patch.md b/patches/expo-notifications-0.27.6.patch.md
new file mode 100644
index 000000000..59b2598f3
--- /dev/null
+++ b/patches/expo-notifications-0.27.6.patch.md
@@ -0,0 +1,9 @@
+## LOAD BEARING PATCH, DO NOT REMOVE
+
+## Expo-Notifications Patch
+
+This patch supports the Android background notification handling module. Incoming messages
+in `onMessageReceived` are sent to the module for handling.
+
+It also allows us to set the Android notification channel ID from the notification `data`, rather
+than the `notification` object in the payload.
diff --git a/plugins/notificationsExtension/README.md b/plugins/notificationsExtension/README.md
new file mode 100644
index 000000000..31b8bfe7d
--- /dev/null
+++ b/plugins/notificationsExtension/README.md
@@ -0,0 +1,17 @@
+# Notifications extension plugin for Expo
+
+This plugin handles moving the necessary files into their respective iOS directories
+
+## Steps
+
+### ios
+
+1. Update entitlements
+2. Set the app group to group.<identifier>
+3. Add the extension plist
+4. Add the view controller
+5. Update the xcode project's build phases
+
+## Credits
+
+Adapted from https://github.com/andrew-levy/react-native-safari-extension and https://github.com/timedtext/expo-config-plugin-ios-share-extension/blob/master/src/withShareExtensionXcodeTarget.ts
diff --git a/plugins/notificationsExtension/withAppEntitlements.js b/plugins/notificationsExtension/withAppEntitlements.js
new file mode 100644
index 000000000..4ce81ea61
--- /dev/null
+++ b/plugins/notificationsExtension/withAppEntitlements.js
@@ -0,0 +1,13 @@
+const {withEntitlementsPlist} = require('@expo/config-plugins')
+
+const withAppEntitlements = config => {
+  // eslint-disable-next-line no-shadow
+  return withEntitlementsPlist(config, async config => {
+    config.modResults['com.apple.security.application-groups'] = [
+      `group.app.bsky`,
+    ]
+    return config
+  })
+}
+
+module.exports = {withAppEntitlements}
diff --git a/plugins/notificationsExtension/withExtensionEntitlements.js b/plugins/notificationsExtension/withExtensionEntitlements.js
new file mode 100644
index 000000000..0cc1c4ca8
--- /dev/null
+++ b/plugins/notificationsExtension/withExtensionEntitlements.js
@@ -0,0 +1,31 @@
+const {withInfoPlist} = require('@expo/config-plugins')
+const plist = require('@expo/plist')
+const path = require('path')
+const fs = require('fs')
+
+const withExtensionEntitlements = (config, {extensionName}) => {
+  // eslint-disable-next-line no-shadow
+  return withInfoPlist(config, config => {
+    const extensionEntitlementsPath = path.join(
+      config.modRequest.platformProjectRoot,
+      extensionName,
+      `${extensionName}.entitlements`,
+    )
+
+    const notificationsExtensionEntitlements = {
+      'com.apple.security.application-groups': [`group.app.bsky`],
+    }
+
+    fs.mkdirSync(path.dirname(extensionEntitlementsPath), {
+      recursive: true,
+    })
+    fs.writeFileSync(
+      extensionEntitlementsPath,
+      plist.default.build(notificationsExtensionEntitlements),
+    )
+
+    return config
+  })
+}
+
+module.exports = {withExtensionEntitlements}
diff --git a/plugins/notificationsExtension/withExtensionInfoPlist.js b/plugins/notificationsExtension/withExtensionInfoPlist.js
new file mode 100644
index 000000000..b0c6cfa89
--- /dev/null
+++ b/plugins/notificationsExtension/withExtensionInfoPlist.js
@@ -0,0 +1,39 @@
+const {withInfoPlist} = require('@expo/config-plugins')
+const plist = require('@expo/plist')
+const path = require('path')
+const fs = require('fs')
+
+const withExtensionInfoPlist = (config, {extensionName}) => {
+  // eslint-disable-next-line no-shadow
+  return withInfoPlist(config, config => {
+    const plistPath = path.join(
+      config.modRequest.projectRoot,
+      'modules',
+      extensionName,
+      'Info.plist',
+    )
+    const targetPath = path.join(
+      config.modRequest.platformProjectRoot,
+      extensionName,
+      'Info.plist',
+    )
+
+    const extPlist = plist.default.parse(fs.readFileSync(plistPath).toString())
+
+    extPlist.MainAppScheme = config.scheme
+    extPlist.CFBundleName = '$(PRODUCT_NAME)'
+    extPlist.CFBundleDisplayName = 'Bluesky Notifications'
+    extPlist.CFBundleIdentifier = '$(PRODUCT_BUNDLE_IDENTIFIER)'
+    extPlist.CFBundleVersion = '$(CURRENT_PROJECT_VERSION)'
+    extPlist.CFBundleExecutable = '$(EXECUTABLE_NAME)'
+    extPlist.CFBundlePackageType = '$(PRODUCT_BUNDLE_PACKAGE_TYPE)'
+    extPlist.CFBundleShortVersionString = '$(MARKETING_VERSION)'
+
+    fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+    fs.writeFileSync(targetPath, plist.default.build(extPlist))
+
+    return config
+  })
+}
+
+module.exports = {withExtensionInfoPlist}
diff --git a/plugins/notificationsExtension/withExtensionViewController.js b/plugins/notificationsExtension/withExtensionViewController.js
new file mode 100644
index 000000000..cd29bea7d
--- /dev/null
+++ b/plugins/notificationsExtension/withExtensionViewController.js
@@ -0,0 +1,31 @@
+const {withXcodeProject} = require('@expo/config-plugins')
+const path = require('path')
+const fs = require('fs')
+
+const withExtensionViewController = (
+  config,
+  {controllerName, extensionName},
+) => {
+  // eslint-disable-next-line no-shadow
+  return withXcodeProject(config, config => {
+    const controllerPath = path.join(
+      config.modRequest.projectRoot,
+      'modules',
+      extensionName,
+      `${controllerName}.swift`,
+    )
+
+    const targetPath = path.join(
+      config.modRequest.platformProjectRoot,
+      extensionName,
+      `${controllerName}.swift`,
+    )
+
+    fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+    fs.copyFileSync(controllerPath, targetPath)
+
+    return config
+  })
+}
+
+module.exports = {withExtensionViewController}
diff --git a/plugins/notificationsExtension/withNotificationsExtension.js b/plugins/notificationsExtension/withNotificationsExtension.js
new file mode 100644
index 000000000..6a00cfd23
--- /dev/null
+++ b/plugins/notificationsExtension/withNotificationsExtension.js
@@ -0,0 +1,55 @@
+const {withPlugins} = require('@expo/config-plugins')
+const {withAppEntitlements} = require('./withAppEntitlements')
+const {withXcodeTarget} = require('./withXcodeTarget')
+const {withExtensionEntitlements} = require('./withExtensionEntitlements')
+const {withExtensionInfoPlist} = require('./withExtensionInfoPlist')
+const {withExtensionViewController} = require('./withExtensionViewController')
+const {withSounds} = require('./withSounds')
+
+const EXTENSION_NAME = 'BlueskyNSE'
+const EXTENSION_CONTROLLER_NAME = 'NotificationService'
+
+const withNotificationsExtension = config => {
+  const soundFiles = ['dm.aiff']
+
+  return withPlugins(config, [
+    // IOS
+    withAppEntitlements,
+    [
+      withExtensionEntitlements,
+      {
+        extensionName: EXTENSION_NAME,
+      },
+    ],
+    [
+      withExtensionInfoPlist,
+      {
+        extensionName: EXTENSION_NAME,
+      },
+    ],
+    [
+      withExtensionViewController,
+      {
+        extensionName: EXTENSION_NAME,
+        controllerName: EXTENSION_CONTROLLER_NAME,
+      },
+    ],
+    [
+      withSounds,
+      {
+        extensionName: EXTENSION_NAME,
+        soundFiles,
+      },
+    ],
+    [
+      withXcodeTarget,
+      {
+        extensionName: EXTENSION_NAME,
+        controllerName: EXTENSION_CONTROLLER_NAME,
+        soundFiles,
+      },
+    ],
+  ])
+}
+
+module.exports = withNotificationsExtension
diff --git a/plugins/notificationsExtension/withSounds.js b/plugins/notificationsExtension/withSounds.js
new file mode 100644
index 000000000..652afd545
--- /dev/null
+++ b/plugins/notificationsExtension/withSounds.js
@@ -0,0 +1,27 @@
+const {withXcodeProject} = require('@expo/config-plugins')
+const path = require('path')
+const fs = require('fs')
+
+const withSounds = (config, {extensionName, soundFiles}) => {
+  // eslint-disable-next-line no-shadow
+  return withXcodeProject(config, config => {
+    for (const file of soundFiles) {
+      const soundPath = path.join(config.modRequest.projectRoot, 'assets', file)
+
+      const targetPath = path.join(
+        config.modRequest.platformProjectRoot,
+        extensionName,
+        file,
+      )
+
+      if (!fs.existsSync(path.dirname(targetPath))) {
+        fs.mkdirSync(path.dirname(targetPath), {recursive: true})
+      }
+      fs.copyFileSync(soundPath, targetPath)
+    }
+
+    return config
+  })
+}
+
+module.exports = {withSounds}
diff --git a/plugins/notificationsExtension/withXcodeTarget.js b/plugins/notificationsExtension/withXcodeTarget.js
new file mode 100644
index 000000000..e9c7dae39
--- /dev/null
+++ b/plugins/notificationsExtension/withXcodeTarget.js
@@ -0,0 +1,76 @@
+const {withXcodeProject, IOSConfig} = require('@expo/config-plugins')
+const path = require('path')
+const PBXFile = require('xcode/lib/pbxFile')
+
+const withXcodeTarget = (
+  config,
+  {extensionName, controllerName, soundFiles},
+) => {
+  // eslint-disable-next-line no-shadow
+  return withXcodeProject(config, config => {
+    let pbxProject = config.modResults
+
+    const target = pbxProject.addTarget(
+      extensionName,
+      'app_extension',
+      extensionName,
+    )
+    pbxProject.addBuildPhase([], 'PBXSourcesBuildPhase', 'Sources', target.uuid)
+    pbxProject.addBuildPhase(
+      [],
+      'PBXResourcesBuildPhase',
+      'Resources',
+      target.uuid,
+    )
+    const pbxGroupKey = pbxProject.pbxCreateGroup(extensionName, extensionName)
+    pbxProject.addFile(`${extensionName}/Info.plist`, pbxGroupKey)
+    pbxProject.addSourceFile(
+      `${extensionName}/${controllerName}.swift`,
+      {target: target.uuid},
+      pbxGroupKey,
+    )
+
+    for (const file of soundFiles) {
+      pbxProject.addSourceFile(
+        `${extensionName}/${file}`,
+        {target: target.uuid},
+        pbxGroupKey,
+      )
+    }
+
+    var configurations = pbxProject.pbxXCBuildConfigurationSection()
+    for (var key in configurations) {
+      if (typeof configurations[key].buildSettings !== 'undefined') {
+        var buildSettingsObj = configurations[key].buildSettings
+        if (
+          typeof buildSettingsObj.PRODUCT_NAME !== 'undefined' &&
+          buildSettingsObj.PRODUCT_NAME === `"${extensionName}"`
+        ) {
+          buildSettingsObj.CLANG_ENABLE_MODULES = 'YES'
+          buildSettingsObj.INFOPLIST_FILE = `"${extensionName}/Info.plist"`
+          buildSettingsObj.CODE_SIGN_ENTITLEMENTS = `"${extensionName}/${extensionName}.entitlements"`
+          buildSettingsObj.CODE_SIGN_STYLE = 'Automatic'
+          buildSettingsObj.CURRENT_PROJECT_VERSION = `"${config.ios?.buildNumber}"`
+          buildSettingsObj.GENERATE_INFOPLIST_FILE = 'YES'
+          buildSettingsObj.MARKETING_VERSION = `"${config.version}"`
+          buildSettingsObj.PRODUCT_BUNDLE_IDENTIFIER = `"${config.ios?.bundleIdentifier}.${extensionName}"`
+          buildSettingsObj.SWIFT_EMIT_LOC_STRINGS = 'YES'
+          buildSettingsObj.SWIFT_VERSION = '5.0'
+          buildSettingsObj.TARGETED_DEVICE_FAMILY = `"1,2"`
+          buildSettingsObj.DEVELOPMENT_TEAM = 'B3LX46C5HS'
+        }
+      }
+    }
+
+    pbxProject.addTargetAttribute(
+      'DevelopmentTeam',
+      'B3LX46C5HS',
+      extensionName,
+    )
+    pbxProject.addTargetAttribute('DevelopmentTeam', 'B3LX46C5HS')
+
+    return config
+  })
+}
+
+module.exports = {withXcodeTarget}
diff --git a/scripts/updateExtensions.sh b/scripts/updateExtensions.sh
index f4e462b74..f3e972aa7 100755
--- a/scripts/updateExtensions.sh
+++ b/scripts/updateExtensions.sh
@@ -1,5 +1,6 @@
 #!/bin/bash
 IOS_SHARE_EXTENSION_DIRECTORY="./ios/Share-with-Bluesky"
+IOS_NOTIFICATION_EXTENSION_DIRECTORY="./ios/BlueskyNSE"
 MODULES_DIRECTORY="./modules"
 
 if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
@@ -8,3 +9,10 @@ if [ ! -d $IOS_SHARE_EXTENSION_DIRECTORY ]; then
 else
   cp -R $IOS_SHARE_EXTENSION_DIRECTORY $MODULES_DIRECTORY
 fi
+
+if [ ! -d $IOS_NOTIFICATION_EXTENSION_DIRECTORY ]; then
+  echo "$IOS_NOTIFICATION_EXTENSION_DIRECTORY not found inside of your iOS project."
+  exit 1
+else
+  cp -R $IOS_NOTIFICATION_EXTENSION_DIRECTORY $MODULES_DIRECTORY
+fi
diff --git a/src/App.native.tsx b/src/App.native.tsx
index 9356be7a7..425d6ac6e 100644
--- a/src/App.native.tsx
+++ b/src/App.native.tsx
@@ -47,6 +47,7 @@ import {ThemeProvider as Alf} from '#/alf'
 import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
 import {Provider as PortalProvider} from '#/components/Portal'
 import {Splash} from '#/Splash'
+import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
 import I18nProvider from './locale/i18nProvider'
 import {listenSessionDropped} from './state/events'
 
@@ -102,10 +103,12 @@ function InnerApp() {
                           <LoggedOutViewProvider>
                             <SelectedFeedProvider>
                               <UnreadNotifsProvider>
-                                <GestureHandlerRootView style={s.h100pct}>
-                                  <TestCtrls />
-                                  <Shell />
-                                </GestureHandlerRootView>
+                                <BackgroundNotificationPreferencesProvider>
+                                  <GestureHandlerRootView style={s.h100pct}>
+                                    <TestCtrls />
+                                    <Shell />
+                                  </GestureHandlerRootView>
+                                </BackgroundNotificationPreferencesProvider>
                               </UnreadNotifsProvider>
                             </SelectedFeedProvider>
                           </LoggedOutViewProvider>
diff --git a/src/App.web.tsx b/src/App.web.tsx
index 40ceb6942..900ceefd7 100644
--- a/src/App.web.tsx
+++ b/src/App.web.tsx
@@ -39,6 +39,7 @@ import {Shell} from 'view/shell/index'
 import {ThemeProvider as Alf} from '#/alf'
 import {useColorModeTheme} from '#/alf/util/useColorModeTheme'
 import {Provider as PortalProvider} from '#/components/Portal'
+import {BackgroundNotificationPreferencesProvider} from '../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
 import I18nProvider from './locale/i18nProvider'
 import {listenSessionDropped} from './state/events'
 
@@ -92,9 +93,11 @@ function InnerApp() {
                       <LoggedOutViewProvider>
                         <SelectedFeedProvider>
                           <UnreadNotifsProvider>
-                            <SafeAreaProvider>
-                              <Shell />
-                            </SafeAreaProvider>
+                            <BackgroundNotificationPreferencesProvider>
+                              <SafeAreaProvider>
+                                <Shell />
+                              </SafeAreaProvider>
+                            </BackgroundNotificationPreferencesProvider>
                           </UnreadNotifsProvider>
                         </SelectedFeedProvider>
                       </LoggedOutViewProvider>
diff --git a/src/lib/hooks/useNotificationHandler.ts b/src/lib/hooks/useNotificationHandler.ts
index 3240a4854..6f5fbd66b 100644
--- a/src/lib/hooks/useNotificationHandler.ts
+++ b/src/lib/hooks/useNotificationHandler.ts
@@ -8,6 +8,7 @@ import {track} from 'lib/analytics/analytics'
 import {useAccountSwitcher} from 'lib/hooks/useAccountSwitcher'
 import {NavigationProp} from 'lib/routes/types'
 import {logEvent} from 'lib/statsig/statsig'
+import {isAndroid} from 'platform/detection'
 import {useCurrentConvoId} from 'state/messages/current-convo-id'
 import {RQKEY as RQKEY_NOTIFS} from 'state/queries/notifications/feed'
 import {invalidateCachedUnreadPage} from 'state/queries/notifications/unread'
@@ -40,7 +41,7 @@ type NotificationPayload =
     }
 
 const DEFAULT_HANDLER_OPTIONS = {
-  shouldShowAlert: false,
+  shouldShowAlert: true,
   shouldPlaySound: false,
   shouldSetBadge: true,
 }
@@ -61,6 +62,28 @@ export function useNotificationsHandler() {
   const prevDate = React.useRef(0)
 
   React.useEffect(() => {
+    if (!isAndroid) return
+
+    Notifications.setNotificationChannelAsync('chat-messages', {
+      name: 'Chat',
+      importance: Notifications.AndroidImportance.MAX,
+      sound: 'dm.mp3',
+      showBadge: true,
+      vibrationPattern: [250],
+      lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
+    })
+
+    Notifications.setNotificationChannelAsync('chat-messages-muted', {
+      name: 'Chat - Muted',
+      importance: Notifications.AndroidImportance.MAX,
+      sound: null,
+      showBadge: true,
+      vibrationPattern: [250],
+      lockscreenVisibility: Notifications.AndroidNotificationVisibility.PRIVATE,
+    })
+  }, [])
+
+  React.useEffect(() => {
     const handleNotification = (payload?: NotificationPayload) => {
       if (!payload) return
 
diff --git a/src/screens/Messages/Settings.tsx b/src/screens/Messages/Settings.tsx
index e4fff1251..7ad21b400 100644
--- a/src/screens/Messages/Settings.tsx
+++ b/src/screens/Messages/Settings.tsx
@@ -15,8 +15,10 @@ import * as Toast from '#/view/com/util/Toast'
 import {ViewHeader} from '#/view/com/util/ViewHeader'
 import {CenteredView} from '#/view/com/util/Views'
 import {atoms as a} from '#/alf'
+import * as Toggle from '#/components/forms/Toggle'
 import {RadioGroup} from '#/components/RadioGroup'
 import {Text} from '#/components/Typography'
+import {useBackgroundNotificationPreferences} from '../../../modules/expo-background-notification-handler/src/BackgroundNotificationHandlerProvider'
 import {ClipClopGate} from './gate'
 
 type AllowIncoming = 'all' | 'none' | 'following'
@@ -28,6 +30,7 @@ export function MessagesSettingsScreen({}: Props) {
   const {data: profile} = useProfileQuery({
     did: currentAccount!.did,
   }) as UseQueryResult<AppBskyActorDefs.ProfileViewDetailed, Error>
+  const {preferences, setPref} = useBackgroundNotificationPreferences()
 
   const {mutate: updateDeclaration} = useUpdateActorDeclaration({
     onError: () => {
@@ -65,6 +68,18 @@ export function MessagesSettingsScreen({}: Props) {
           onSelect={onSelectItem}
         />
       </View>
+      <View style={[a.px_md, a.py_lg, a.gap_md]}>
+        <Toggle.Item
+          name="a"
+          label="Click me"
+          value={preferences.playSoundChat}
+          onChange={() => {
+            setPref('playSoundChat', !preferences.playSoundChat)
+          }}>
+          <Toggle.Checkbox />
+          <Toggle.LabelText>Notification Sounds</Toggle.LabelText>
+        </Toggle.Item>
+      </View>
     </CenteredView>
   )
 }