phoenix icon indicating copy to clipboard operation
phoenix copied to clipboard

Adding notification-service-extension for iOS

Open robbiehanson opened this issue 2 years ago • 0 comments

This PR improves Phoenix's ability to receive payments while running in the background (or while not running at all).

Previously we used "silent" push notifications to wake up the phoenix app, and allow it to receive payments when in the background. However, there were multiple problems with this.

Apple regards "silent" push notifications as optional. For example, a notification that informs the app about new data on the server, which allows the app to pre-download the content. Therefore the app is "fresh" when the user opens it. (Think: CNN news app)

This is the use case that Apple had in mind for "silent" push notifications. And they've designed around this idea. Which means that silent push notifications don't get delivered if:

  • the device is in low power mode
  • the device has high CPU elsewhere
  • the OS doesn't think the user will launch your app soon

Alternatively, the notification service extension is an app extension that will run in response to a push notification. This allows the app extension to run some code, and then modify the contents of the push notification before displaying it to the user.

Note: App extensions run as a separate process.

As per the docs for UNNotificationServiceExtension, iOS will launch our notification-service-extension when it receives a push notification in which:

The remote notification’s aps dictionary includes the mutable-content key with the value set to 1.

In other words, we used to send a push notification that looks like this:

{
  "aps": {
    "content-available": 1
  },
  "whatever": {
    "we": "want",
    "goes": "here"
  }
}

And now we need to send this:

{
  "aps": {
    "mutable-content": 1,
    "alert": {
      "title": "must exist"
    }
  },
  "whatever": {
    "we": "want",
    "goes": "here"
  }
}

When iOS receives one of these "mutable" push notifications for our app, it will then launch our notification-service-extension. Our app extension process then has time to run code.

Our app extension simply launches phoenix-shared, which is compiled as a framework, and dynamically linked for both phoenix & phoenix-notifySrvExt. So we're not adding much weight here.

Also the app extension can detect if the main app is running. Upon detection, the app extension yields to the main app. So this prevents them from interfering with each other.

Architecture notes:

Memory optimizations:

One of the big challenges for a notification-service-extension is the memory limit imposed by iOS. This appears to be 24 MB for iOS 15.

We were hitting this limit, due to the CurrencyManager + that known bug in Ktor that I reported. So I had to add some memory optimizations:

  • the ability to disable automatic refresh for the CurrencyManager
  • the ability to disable the NetworkMonitor

Shared storage:

The app extension runs as a separate process, in it's own separate sandbox. So, to use shared storage, both the app & extension have been added to an "app group" which allows them to share a directory in the file-system. And a keychain group too.

All of the corresponding migration work has been done. (But may need one update, when we finalize the build number for this release.)

Shared database:

The phoenix & phoenix-notifySrvExt share the database file(s) now. This works well except for one detail in SQLDelight. We use code like this:

peer.database.someQuery.asFlow().collect { ... }

This is supposed to get called whenever the results of your query change. However it doesn't work if the database was changed in a separate process.

Internally, SQLDelight makes these flows work by calling:

TransactorImpl.notifyQueries(...)

So, basically, any query that modifies table1 will automatically invoke notifyQueries, and pass it a list of any other queries that may be affected. Hacking this process manually is a bit brittle because:

  • we would have to remember to update the list of affected queries everytime we make changes to our databases and/or queries
  • the internal API changes between versions

So I've gone with a simpler solution, which I think is less brittle:

  • if the main app detects that the app-extension was launched
  • it runs a transaction which:
    • deletes a non-existent payment from the database
    • delete a non-existent currency from the database
  • this, in turn, calls notifyQueries for all the flows that may need to be refreshed

General refactoring:

I did some general refactoring within the Swift codebase to separate the shared code from the main-phoenix-app-only code.

robbiehanson avatar May 23 '22 16:05 robbiehanson