Beeper iOS Implements Notifications with On-Device Connections

1 month ago 5

Last year, after Beeper was acquired by Automattic, we decided that we needed to dramatically improve our iOS experience. The previous Beeper iOS app was slow, buggy, and hard to add new features to. Additionally, we knew we wanted to support On-Device connections, which would require the app to be rebuilt anyway to match the architecture we implemented with our new Beeper Android app. We built a team out of the engineers that were working on the early version of a Texts iOS app as well as the Beeper iOS team and got to work.

One of the biggest challenges in building a great iOS experience is the strict limitations Apple places on app developers. Unlike Android, iOS apps aren’t allowed to run in the background for long periods, presumably to preserve battery life. Our app needs to do two main things in the background. The first is to sync your chats so that when you open the app, all your messages are already there and you don’t have to wait for us to reach out to each of the networks to get your latest messages. The second, and more importantly, we need to show notifications when you receive a new message. Notifications are especially difficult because of our privacy-first architecture: our servers can’t read your messages, they can only tell you that some new encrypted payload has arrived, and the only thing that has the keys to actually decrypt the message content to show you is the app running on your phone.

Apple has anticipated this problem and provides a mechanism called the UNNotificationServiceExtension (NSE). This API allows you to run a second process in conjunction with your main application that can only process notifications. Importantly, this is a completely separate process: it cannot share in-memory state with your main app, has a different lifecycle, and may run independently or even simultaneously with your main app. This is quite different from Android, where push notifications trigger Intents that wake up your main application to give you full access to all your application state and functionality in the background.

We’ve used this NSE mechanism on iOS to implement notifications for both our cloud connections and our new On-Device connections. For cloud connections, your accounts are always being synced by our backend and stored as Matrix events, leveraging Matrix’s encrypted architecture to preserve privacy. When we receive a message we think you should be notified for, we send a push notification to the application. The NSE receives this notification, and if the main application currently isn’t in the foreground, it takes over and processes the notification, including doing additional key fetches as necessary to decrypt the notification.

For On-Device connections, the implementation is slightly more complicated. Since our backend doesn’t have the ability to receive your messages, we need a different way of being notified when there might be a new message received on one of your networks. To implement this, we’ve implemented what the original clients use to be woken up by their own backends, and registered for those same pushes. This method preserves privacy, as we’re only seeing the push content that the networks are already sharing with Apple and Google in order to use their push notification networks. Our backend registers for these pushes (as our backend is the only thing that’s always available) and forwards that encrypted content down to our mobile apps in a new push originating from our backend. The NSE will be woken up by this push, and then can do the required processing to show the decrypted notification to the user.

While this approach is complex, it preserves our goals of having real-time notifications for our On-Device connections while preserving equal privacy to the native apps on the network. However, it still does push the boundaries on what’s possible on iOS. One challenge is that we can’t share memory state with the main application, so in order to share state with the main app (such as the database containing the decryption keys), a careful locking design had to be implemented where only the main application or the NSE is allowed to be processing data at once. The bigger issue though is that iOS provides some pretty strict runtime requirements on the NSE. You’re only allowed to run for a small amount of time to process a notification, and you’re only allowed to use a small amount of memory.

We at Beeper have been always using this NSE mechanism to display notifications, even with our original application, and have always struggled with these limitations. At the time, iOS limited memory usage to only 15 MB for most devices, and if you used more memory than that the operating system would kill your NSE, resulting in “blank notifications” where only the app name is shown and not the content (apologies to long-time Beeper users over the years that have experienced this issue). We struggled with this issue when we were only using the NSE with our cloud bridges and only had to support one protocol (Matrix), but now with On-Device bridges we need enough memory to support all the protocols we support. The NSE would need to execute Signal key fetching and decrypting for Signal messages and WhatsApp key fetching and decrypting for WhatsApp messages, as it’s the NSE itself now connecting to those networks, not our backend.

Thankfully, regulators are starting to become increasingly interested in the limitations that Apple has been placing on their developers unnecessarily, especially when their own applications are not subject to those same limitations. Thanks to a piece of regulation in the EU named the Digital Markets Act, app developers like ourselves can now make requests to Apple to expand what app developers can do on their platform. In early 2024, we made a request for this memory limit to be raised, and over a year later, we got what we asked for.

After updating to the beta, we discovered that they’ve raised the memory limit from 15 MB to 50 MB. While still not a huge amount of memory, it’s enough for our optimized On-Device connections to run and implement all the networks we plan on building. Additionally, it appears that any iPhone that supports Apple Intelligence (iPhone 15 Pro, all iPhone 16 and iPhone 17 models) already had their memory limits increased to 150 MB, presumably corresponding to the larger amounts of memory available on those device models.

The relaunch of Beeper iOS represents a new chapter for us: a modern foundation that supports On-Device connections, preserves privacy, and still allows us to provide real-time and reliable notifications. It wasn’t easy, as the iOS platform makes this one of the toughest technical challenges we’ve faced. However, with these improvements in place, we’re excited to keep building toward our vision of a single, secure, and seamless inbox for all your chats.

Brad Murray

Read Entire Article