Skip to main content
Back to Blog

Communicating between an iOS App & Extensions using App Groups

When developing an application with some extension, a common issue is their intercommunication and reaction to changes made in one another.

Here at Fleksy, we are used to working around it as we develop device keyboards and want to provide all our help to those in need.

The main obstacle

Usually, when adding an app extension to your app project, there exists some one-way communication strictly related to the usage of the extension (i.e., for keyboards, you can access the proxy document settings about the text input), but that’s the best Apple can give to the developer user without getting into some more obscure documentation.

There is also the consideration that, even though an app extension’s bundle is nested within the hosting app’s bundle, they cannot access each other’s container directly, so sharing files and UserDefaults settings becomes troublesome.

Some tools in the shed

To be able to have proper communication between apps, we need at least two features:

  1. Transfer information
  2. Notify changes

Establishing this is the bare minimum for two-way communication.

Using App groups to share

You might be asking, what is an App Group? For those who haven’t got a clue, this is our first step to intertwine a hosting app and its extensions. Multiple apps and extensions can share a container or folder and other processes under the same app group.

We will first need to create an app group for both our targets, the hosting app, and the app extension. For this, go into Signing and Capabilities in your project settings and add an app group capability. After this, you can create one and add both to this group.

Now you can access your shared folder with the FileManager in the following way.

let sharedFolder: URL? = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: <Your-App-Group-Name>)

Over this sharedFolder we will be able to find anything we want our apps to have in common, even the UserDefaults settings dictionary they share, which is different from the one on each app. That’s right, and the standard UserDefaults can only be accessed by the app owning it. If we want to read on the shared one, we need to call it using the app group.

let localUserDefaults: UserDefaults = UserDefaults.standard
let sharedUserDefaults: UserDefaults? = UserDefaults(suiteName: <Your-App-Group-Name>)

This works similarly because the UserDefaults is no more than a .plist file saved on a container.

Establishing communication with Darwin notifications

We have one task remaining, to notify, and what’s better for that than a Notification Centre? I’m sure most of you know about the usual NotificafionCenter and how to use it to communicate different parts of your app that don’t have a direct connection.

The Darwin Notification Centre‘s main feature is to distribute notifications across the whole system, meaning you can communicate with other apps and, in our case, with app extensions. There are some differences between the two centers.

Default Notification CentreDarwin Notifications Centre
Notifies within the appNotifies across the system
Knows the object sending the notificationIs oblivious about who sends a notification
Knows who is observing the notification
Can send a userInfo dictionaryCannot send a userInfo dictionary
Can create and subclass multiple centersThere exists only one per App

As you can see, one major drawback in using Darwin notifications is the inability to send extra information when posting, but here is when we can use the shared folder for the App Group we mentioned earlier. Another one is the inability to see who is sending a notification, as other apps’ private information, such as classes and other objects, is only accessible to them.

You can use this center directly, but we recommend using a manager to ease this work and try to have a cleaner code. Of course, we will guide you a little bit with the job giving some basic direction on how to use and implement it.

Here is the minimum you need to set up the notification center

  • Notification structure
  • Notification center
    • Add notification observations.
    • Post notification to observers.
    • Remove notification from the observer.

Notifications

The notification should be able to recognize the object we are going to use as userInfo. In our case, we will call it a Payload. The payload will be saved as a string on a file we can read in our shared folder. Most structures and objects can be expressed as a dictionary, so they will not face many problems.

protocol DarwinPayload {
  // Read the Payload from a string
  init?(payloadString: String?)
  // Write the Payload to string
  func toString() -> String
}

struct DarwinNotification<Payload> where Payload: DarwinPayload {
  var name: Name
    
  struct Name: Equatable {
    fileprivate var rawValue: CFString
    var title: String { self.rawValue as String }
  }
    
  fileprivate init(_ name: Name) {
    self.name = name
  }
    
  init(_ rawValue: String) {
    self.name = Name(rawValue: rawValue as CFString)
  }
  
  init(_ cfNotificationName: CFNotificationName) {
    self.name = Name(rawValue: cfNotificationName.rawValue)
  }
  
  // Equatable is not applicable for generic class using different types
  func isEqual<Other>(to other: DarwinNotification<Other>) -> Bool {
    if Other.self == Payload.self,
      let otherAsPayload = other as? DarwinNotification<Payload> {
        return self.name == otherPlAsPayload.name
      }
    return false
  }
}

As an example, we will implement it like this:

extension String: DarwinPayload {
  init?(payloadString: String?) {
    guard let payloadString = payloadString else { return nil }
    self = payloadString
  }
  
  func toString() -> String {
    return self
  }
}

extension DarwinNotification {
  static var exampleDarwinNotification: DarwinNotification<String> {
    DarwinNotification<String>("EXAMPLE_DARWIN_NOTIFICATION")
  }
}

Notification Centre

The notification center should be able to handle all the observations we pass, read and write in the shared folder to send the payloads we saved. We recommend having all payloads under a subfolder to avoid conflicts. We will also have a specific queue for these notifications, so we don’t overload the main thread.

final class DarwinNotificationCenter {
  static let shared = DarwinNotificationCenter()
    
  private let center = CFNotificationCenterGetDarwinNotifyCenter()
    
  private let queue = DispatchQueue(label: "Darwin_queue", qos: .default, autoreleaseFrequency: .workItem)
    
  private typealias NotificationHandler = (String?) -> Void
    
  private var handlers = [String : NotificationHandler]()
    
  private init() {}
    
  private func getNotificationsFolder() -> URL? {
    // Return your payload folder under the shared one
  }
    
  private func payloadPath(forNotification name: String) -> String? {
    // Return the path for the payload assoicated with a notification
  }
    
  func addObserver<Payload: DarwinPayload>(_ notification: DarwinNotification<Payload>, using completion: @escaping ((Payload?) -> Void)) {
    self.queue.async {
      let name = notification.name
      let handler: NotificationHandler = {
        let payload = Payload(payloadString: $0)
        completion(payload)
      }
      self.handlers[name.title] = handler
      
      CFNotificationCenterAddObserver(self.center, Unmanaged.passRetained(self).toOpaque(), self.notificationHandler, name.rawValue, nil, .coalesce)
    }
  }
  
  func post<Payload: DarwinPayload>(_ notification: DarwinNotification<Payload>, payload: Payload) {
    self.queue.async {
      let name = notification.name
      guard let payloadFile = self.payloadPath(forNotification: name.title)
      else { return }
      do {
        try payload.toString().write(toFile: payloadFile, atomically: true, encoding: .utf8)
      } catch {
        // Manage your errors
      }
      CFNotificationCenterPostNotification(self.center, CFNotificationName(name.rawValue), nil, nil, false)
    }
  }
  
  func removeObserver<Payload: DarwinPayload>(_ notification: DarwinNotification<Payload>) {
    self.queue.async {
      let name = notification.name
      self.handlers[name.title] = nil
      guard let payloadPath = self.payloadPath(forNotification: name.title)
      else { return }
      
      if FileManager.default.fileExists(atPath: payloadPath) {
        do {
          try FileManager.default.removeItem(atPath: payloadPath)
        } catch {
          // Manage your errors
        }
      }
    }
  }
  
  // This is the actual callback when we register a notification
  private var notificationHandler: CFNotificationCallback {
    return { (_, observer, notfName, _, _) in
      guard let name = notfName?.rawValue as String?,
        let observer = observer
      else { return }
      
      let observedSelf = Unmanaged<DarwinNotificationCenter>.fromOpaque(observer).takeUnretainedValue()
      if let handler = observedSelf.handlers[name],
        let payloadPath = observedSelf.payloadPath(forNotification: name) {
        let content = try? String(contentsOfFile: payloadPath, encoding: .utf8)
        handler(content)
      }
    }
  }
  ...
}

Now all you need is to enable the different targets on the file inspector and try for yourself in the following way:

DarwinNotificationCenter.shared.addObserver(.exampleDarwinNotification) { _ in
  // Do something
}

DarwinNotificationCenter.shared.post(.exampleDarwinNotification, payload: "Posted")

DarwinNotificationCenter.shared.removeObserver(.exampleDarwinNotification)

As we stated earlier, this is a basic implementation, and there is much more to do when managing these notifications. We used the same manager as the observer for our notifications to simplify the example. Still, one can try and handle the different observers for a notification, ceasing the observation when deallocated. We leave research work up to you to improve and expand your knowledge and skills. If you want to discuss this article or need help with your project, don’t hesitate to contact us directly or on our Discord server. Our team will be happy to help you.

Did you like it? Spread the word: