Cześć 👊

Dziś widzimy się w formacie jakiego na naszym blogu jeszcze nie było. Chcę podzielić się z Wami naszymi przeżyciami z pola bitwy, jakim była implementacja rozbudowanych notyfikacji przy wykorzystaniu Capacitora.

UWAGA: Materiał będzie miał charakter mocno instruktażowy, więc jeśli nie planujecie podobnej funkcjonalności w Waszych aplikacjach, to prawdopodobnie nie znajdziecie tu dziś wiele ciekawego. Pozostałych zapraszam do lektury 😇

I niech moc będzie z Wami!

Wstęp

W grudniu 2020 roku zakończyliśmy pracę nad piękniejszym i bardziej funkcjonalnym Keep Upem. Nowe funkcjonalności pachniały jeszcze jak świeże bułeczki, ale zdawaliśmy sobie już sprawę, że przed nami jeszcze jedna góra do pokonania. Jak wynika z naszych statystyk, ruch w Keep Upie pochodzi głównie z push notyfikacji.

W czym więc problem? Z wywiadów, które przeprowadziliśmy z naszymi najwierniejszymi użytkownikami wynikało, że mała, generyczna notyfikacja często gubiła się w tłumie i nie zachęcała w szczególny sposób do interakcji. Ciężko oszacować, ilu dokładnie użytkowników polubiło Keep Up i ze względu na słabe notyfikacje porzuciło nawyk czytania artykułów, ale te same statystyki pozwalały sądzić, że jest ich całkiem sporo. Nie mogliśmy tego tak zostawić i przeszliśmy do działania. W efekcie powstał nowy design notyfikacji i pomysł na cotygodniowy raport, który czytających zmotywuje do utrzymania nawyku, a zapominalskim przypomni o istnieniu aplikacji.

Już na etapie wstępnej analizy i dzielenia zadania na mniejsze kawałki zorientowaliśmy się, że sprawa nie będzie prosta, a do pokonania mamy zarówno problemy po stronie Frontendu jak i Backendu. W komunikacji z Firebase korzystaliśmy z starego REST API (które sami nazwaliśmy v0 i tak będę go tytułował dalej), które nie umożliwiało personalizacji notyfikacji w zależności od urządzenia, na które ma ona trafić. Sprawa była o tyle istotna, że nazwy niektórych pól kolidowały ze sobą, a próba wysłania notyfikacji na urządzenie z Androidem, przy dodaniu pól potrzebnych na iOS, zazwyczaj kończyła się błędem. Migracja do REST API v1 wydawała się krokiem w odpowiednią stronę. Pozwalała nam w końcu wycofać się z legacy API i dawała dużo większą elastyczność. Jak się później okazało, nowe API miało też swoje wady: mechanizm device group nie był jeszcze wspierany i stan ten utrzymywał się co najmniej od roku. Oprócz migracji do nowego API, musieliśmy więc zmigrować jeszcze model device group na cięższy w utrzymaniu model z tokenami… No cóż, może kiedyś Google postanowi dodać wsparcie dla Device Group do API v1. My tymczasem wróćmy do clue, czyli frontendowej strony implementacji.

Moja reakcja kiedy pierwszy raz zobaczyłem dokumentację FCM v0

Tutaj również czekało na nas kilka pułapek. Capacitor posiada absolutnie świetne API do obsługi notyfikacji (swego czasu zmuszony byłem do testowania różnych wtyczek do Cordovy, które próbowały osiągnąć ten sam cel więc wiem, o czym mówię), jest to jednak API dość prymitywne i nie spełniające naszych potrzeb. Poszukiwania odpowiedniego Pluginu również zakończyły się fiaskiem, a to oznaczało, że przed nami implementacja kawałka natywnego kodu - sprawa prawdopodobnie błaha, jeśli dysponujecie zespołem Android i iOS deweloperów, ale jest to spore wyzwanie dla Fullstack Developerów z podstawową wiedzą o Mobile Developmencie.

Kończąc ten przydługi wstęp przejdźmy do meritum, czyli tutoriala dla wszystkich tych, którzy podobne notyfikacje chcieliby zaimplementować w swoich aplikacjach. Odpalajcie terminal i zaczynamy 👨🏽‍💻

Tutorial

Wymagania początkowe

W poniższym tutorialu skupimy się na rozbudowie notyfikacji. Oznacza to, że pominiemy w nim konfigurację podstawowych notyfikacji i jest to zabieg celowy. Świetny tutorial dotyczący natywnych aplikacji znajdziecie w dokumentacji Capacitora. Jeśli zaś chodzi o część webową, to polecam Wam te artykuły: Angular 8 + Firebase Cloud Messaging Push Notifications i Push notifications with React and Firebase

Jeśli w Waszej aplikacji macie już skonfigurowane podstawowe notyfikacje, to możemy ruszać dalej, a jeśli nie, to nie zwlekajcie dłużej - My tu na Was cierpliwie poczekamy 😉.

Plugin interface

Zarówno implementację Androida jak i iOSa* oprzemy o własny plugin. Oba pluginy będą współdzielić jeden interfejs, więc możemy zacząć od przygotowania jego szkieletu.

*o ten sam interfejs możecie oprzeć też webową implementację, ale wymagać to będzie napisania odpowiedniego adaptera przekształcającego interfejs Firebase na ten zasugerowany poniżej.  

import { Plugins } from '@capacitor/core';
import { PluginListenerHandle } from '@capacitor/core';

export type NotificationData = {
  data: {
    url: string;
    vivedId?: string;
  };
  action: 'TAP' | 'KEEP_UP_READ';
};

export type ExtendedPushNotificationsPlugin = {
  addListener(
    eventName: 'notificationClick',
    listenerFunc: (info: NotificationData) => void
  ): PluginListenerHandle;
};

export const ExtendedPushNotificationsPlugin = Plugins.VivedExtendedPushNotificationsPlugin as ExtendedPushNotificationsPlugin;

Android

Zanim przejdziemy do implementacji, zatrzymajmy się na chwilę przy stronie teoretycznej. Sposób, w jaki Android obsługuje notyfikacje zależy od tego, czy aplikacja jest aktualnie uruchomiona oraz od formatu danych, jaki został przekazany do notyfikacji po stronie serwera.

Notyfikacje podzielić możemy na dwa typy: standardowe push notyfikacje i data notyfikacje. Te pierwsze to notyfikacje, które chcemy wyświetlić użytkownikowi, natomiast te drugie służą do przekazania aplikacji danych, niekoniecznie wyświetlając samą notyfikacją. Jak Android obsługuje poszczególne sytuacje, najlepiej opisuje zaczerpnięta z dokumentacji tabela.

W standardowym przypadku, jeśli aplikacja jest zamknięta, to tracimy możliwość manipulowania nią. Niestety oznacza to, że jeśli chcemy pokazać użytkownikom ładne notyfikacje, nawet kiedy aplikacja jest wyłączona, to zmuszeni będziemy wykorzystać mechanizm data notifications i trochę namieszać.

Android posiada rozbudowany wachlarz dostępnych typów notyfikacji, więc zanim przejdziecie do tworzenia własnej spersonalizowanej implementacji sprawdźcie, czy jedna z dostępnych nie spełnia Waszych wymagań. Warto zauważyć, że nie możecie wymieszać ze sobą różnych typów notyfikacji. Oznacza to, że jeśli zdecydujecie się na duży obrazek, to braknie Wam już miejsca na duży tekst. Na tę zasadę nie pomoże próba stworzenia swojego interfejsu notyfikacji, bo Android wprowadza odgórny limit wysokości notyfikacji.


Przejdźmy teraz do części praktycznej. Zgodnie ze sztuką, aby obsłużyć push notyfikacje, musimy stworzyć implementację interfejsu FirebaseMessagingService i zarejestrować go w AndroidManifest.xml (będąc dokładnym należałoby powiedzieć przechwycimy intent, ale przejdziemy do tego jeszcze w dalszej części tutoriala). Uwaga: stworzenie własnej implementacji serwisu spowoduje, że metody do obsługi notyfikacji z Capacitora przestaną działać, więc sami będziecie musieli zaimplementować brakujące funkcjonalności. Nie przejmujcie się natomiast błędem dotyczącym braku obsługi rejestracji tokenu. Ta część jest świetnie obsługiwana przez Capacitora i możecie pozostawić ją nietkniętą.

Schemat działania Androidowej części obsługi notyfikacji
// Note: onNewToken is handled by Capacitor's Push Notifications plugin
class PushNotificationsService : FirebaseMessagingService() {

  override fun onMessageReceived(message: RemoteMessage) {
    val rawData = RawNotificationData.parse(message)
    val notificationData = rawData.enrich() 
    val notification = buildNotification(notificationData)
    sendNotification(rowData.id, notification)
  }
}
<application>
    <service
      android:name="com.virtuslab.vived.PushNotifications"
      android:stopWithTask="false">
      <intent-filter>
        <action android:name="com.google.firebase.MESSAGING_EVENT" />
      </intent-filter>
    </service>
</application>

Metoda `onMessageReceived` wypełniona jest jeszcze niezaimplementowanymi metodami, które pokazują schemat jej działania. Przyjrzymy się teraz dokładnie każdej z nich, zaczynając od (1). Jako że nasze dane przekazujemy za pomocą JSONa, musimy je odpowiednio sparsować do klasy, która będzie zrozumiana dla naszej aplikacji. Schemat JSONa z góry narzucony jest przez firebase. Wyjątkiem w tej kwestii jest pole `data`, które możemy dowolnie personalizować, więc możecie puścić wodze fantazji. My staraliśmy się przygotować API jak najbardziej elastyczne i kompatybilne z przyszłymi zmianami.

method: POST
path: "https://fcm.googleapis.com/v1/projects/${firebaseProjectName}/messages:send"
body: {
  "message": {
    "name": "69ab68da-1dae-4c25-9386-cca6346123a8",
    "token": "firebase-token",
    "notification": null,
    "data": {
      "url": "/hub/keep-up",
      "vivedId": "vived-id"
    },
    "android": {
      "data": {
        "title": "Keep Up with IT World 🚀",
        "body": "Read all articles selected for you today:\n• \"Easily create web extensions for Safari\"\n•...",
        "imageUrl": "https://uploads.vived.io/images/static/notifications/image/rocket.png",
        "iconUrl": "https://uploads.vived.io/images/static/notifications/icon/rocket.png",
        "type": "BigText",
        "actions": [
          {
            "action": "KEEP_UP_READ",
            "title": "Read now"
          }
        ]
      },
    },
    "apns": { ... },
    "webpush": { ... },
}
Zapytanie wysyłane po strone backendu do Firebase (okrojone tylko do niezbędnej części)
enum class NotificationType {
  Image, BigText, Default
}

data class NotificationAction(
  val id: String,
  val title: String
) {
  companion object {
    fun parseJson(json: JSONObject): NotificationAction = 
Gson().fromJson(str , Array<NotificationAction>::class.java)
  }
}

data class RawNotificationData(
  val id: Int = Random.nextInt(1, 1000000),
  val title: String?,
  val body: String?,
  val actions: List<NotificationAction>,
  val imageUrl: String?,
  val iconUrl: String?,
  val type: NotificationType?,
  val vivedId: String?,
  val url: String?
) 
  companion object {
    fun parse(message: RemoteMessage): RawNotificationData =
      RawNotificationData(
        title = message.data["title"] ?: message.notification?.title,
        body = message.data["body"] ?: message.notification?.body,
        actions = message.data["actions"]?.let { NotificationAction.parseJsonArray(JSONArray(it)) } ?: emptyList(),
        imageUrl = message.data["imageUrl"],
        iconUrl = message.data["iconUrl"],
        type = NotificationType.values().find { it.name == message.data["type"] } ?: NotificationType.Default,
        vivedId = message.data["vivedId"],
        url = message.data["url"]
      )
  }
}

W kroku (2) odczytane dane wzbogacamy o dodatkowe informacje. W naszym przypadku jest to np. obrazek pobrany z sieci. Jeśli wystarczą Wam suche dane, to spokojnie możecie pominąć ten krok.

data class NotificationData(
  val id: Int,
  val title: String,
  val body: String,
  val actions: List<NotificationAction>,
  val image: Bitmap?,
  val icon: Bitmap?,
  val type: NotificationType?,
  val vivedId: String?,
  val url: String
)

data class RawNotificationData(
  ...
) {
  private fun downloadBitmap(url: String): Bitmap? {
    return try {
      val input = URL(url).openStream()
      BitmapFactory.decodeStream(input)
    } catch (e: IOException) {
      null
    }
  }

  fun enrich(): NotificationData =
    NotificationData(
      id = id,
      title = title ?: "",
      body = body ?: "",
      actions = actions,
      image = imageUrl?.let { downloadBitmap(it) },
      icon = iconUrl?.let { downloadBitmap(it) },
      type = type,
      vivedId = vivedId,
      url = url ?: "/"
    )
}

Na tym etapie mamy już wszystkie potrzebne dane i możemy przejść do zbudowania notyfikacji (3). Ponownie nasza implementacja stara się być maksymalnie elastyczna, ale jeśli chcecie wprowadzić swoje modyfikacje, to najlepiej będzie jeśli zagłębicie się w możliwości, jakie daje Android w dokumentacji.

  private fun buildPendingIntent(data: NotificationData, actionId: String): PendingIntent {
    val intent = PushNotificationIntent.from(data, actionId)
    return PendingIntent.getActivity(this, Random.nextInt(), intent.toIntent(this), PendingIntent.FLAG_CANCEL_CURRENT)
  }

  private fun buildNotification(data: NotificationData): Notification {
    val notificationBuilder = NotificationCompat.Builder(this, PushNotificationsPlugin.DEFAULT_CHANNEL_ID)
      .setContentTitle(data.title)
      .setContentText(data.body)
      .setAutoCancel(true)
      .setSmallIcon(R.drawable.ic_stat_notification)
      .setContentIntent(buildPendingIntent(data, "TAP"))

    data.actions.forEach { action ->
      notificationBuilder.addAction(
        R.drawable.ic_stat_notification,
        action.title,
        buildPendingIntent(data, action.id)
      )
    }

    return notificationBuilder.build()
  }

W powyższym kodzie znajdują się dwie zupełnie nowe rzeczy, nad którymi warto się pochylić. Zacznijmy od tajemniczych `PendingIntet.` Kiedy użytkownik kliknie w notyfikację lub jedną z akcji, to do naszej aplikacji zostanie wysłany obiekt PendingIntent, który należy przechwycić i obsłużyć. Odpowiedzialny będzie za to `PushNotificationsPlugin`. Jako że logikę chcemy przesunąć w stronę JavaScriptu, to jedynym zadaniem Pluginu będzie zamknięcie notyfikacji i przekazanie eventu do WebView.

data class PushNotificationIntent(
  val notificationId: Int,
  val vivedNotificationId: String?,
  val actionId: String,
  val url: String
) {
  companion object {
    const val IS_FROM_PLUGIN_EXTRA = "isFromPushNotificationPlugin"
    const val NOTIFICATION_ID_EXTRA = "notificationId"
    const val VIVED_NOTIFICATION_ID_EXTRA = "vivedNotificationId"
    const val ACTION_ID_EXTRA = "actionId"
    const val URL_EXTRA = "url"

    fun from(intent: Intent): PushNotificationIntent =
      PushNotificationIntent(
        notificationId = intent.extras?.getInt(NOTIFICATION_ID_EXTRA)!!,
        vivedNotificationId = intent.extras?.getString(VIVED_NOTIFICATION_ID_EXTRA),
        actionId = intent.extras?.getString(ACTION_ID_EXTRA)!!,
        url = intent.extras?.getString(URL_EXTRA)!!
      )

    fun from(data: NotificationData, actionId: String): PushNotificationIntent =
      PushNotificationIntent(
        notificationId = data.id,
        vivedNotificationId = data.vivedId,
        actionId = actionId,
        url = data.url
      )
  }

  fun toIntent(context: Context): Intent =
    Intent(context, MainActivity::class.java)
      .putExtra(IS_FROM_PLUGIN_EXTRA, true)
      .putExtra(ACTION_ID_EXTRA, actionId)
      .putExtra(NOTIFICATION_ID_EXTRA, notificationId)
      .putExtra(VIVED_NOTIFICATION_ID_EXTRA, vivedNotificationId)
      .putExtra(URL_EXTRA, url)
}


@NativePlugin(name = "VivedPushNotificationsPlugin")
class PushNotificationsPlugin : Plugin() {

  private fun shouldHandleIntent(intent: Intent?): Boolean =
    intent?.extras?.getBoolean(PushNotificationIntent.IS_FROM_PLUGIN_EXTRA) == true

  private fun notifyJS(intent: PushNotificationIntent) {
    val dataObject = JSObject()
    dataObject.put("url", intent.url)
    dataObject.put("vivedId", intent.vivedNotificationId)

    val returnObject = JSObject()
    returnObject.put("data", dataObject)
    returnObject.put("action", intent.actionId)

    notifyListeners("notificationClick", returnObject, true)
  }

  private fun cancelNotification(id: Int) {
    val notificationManager = activity.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
    notificationManager.cancel(id)
  }

  override fun handleOnNewIntent(intent: Intent?) {
    super.handleOnNewIntent(intent)
    if (shouldHandleIntent(intent)) {
      val pushNotificationIntent = PushNotificationIntent.from(intent!!)
      notifyJS(pushNotificationIntent)
      cancelNotification(pushNotificationIntent.notificationId)
    }
  }

Przejdźmy teraz do drugiej tajemniczej funkcjonalności, czyli `PushNotificationsPlugin.DEFAULT_CHANNEL_ID`. Android od wersji 8.0 (API level 26) wprowadził kanały notyfikacji, którymi można sterować z poziomu ustawień aplikacji.

Tworzenie notyfikacji bez podania kanału, jest co prawda możliwe, ale jest już oznaczone jako deprecated. Aby mieć pewność, że kanał notyfikacji zostanie stworzony, funkcjonalność ta zostanie dodana do odpowiedniego hook’a w stworzonym wcześniej Pluginie

@NativePlugin(name = "VivedPushNotificationsPlugin")
class PushNotificationsPlugin : Plugin() {
  companion object {
    const val DEFAULT_CHANNEL_ID = "vived_default_notifications_channel"
  }

  private fun createNotificationChannel() {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
      val importance = NotificationManager.IMPORTANCE_HIGH
      val notificationChannel = NotificationChannel(DEFAULT_CHANNEL_ID, "Default", importance)
      val notificationManager = this.activity.getSystemService(FirebaseMessagingService.NOTIFICATION_SERVICE) as NotificationManager
      notificationManager.createNotificationChannel(notificationChannel)
    }
  }

  override fun load() {
    createNotificationChannel()
  }

  ....
}

Tak jak wspomniałem na początku, wersje notyfikacji dostarczone przez Androida w zupełności nam wystarczyły, ale dla wszystkich, którzy oczekują więcej, mam małe preview takiej funkcjonalności.

Jeśli pójdziecie w całkowicie personalizowaną notyfikację, to pamiętajcie o ograniczeniu wysokości widoku i możliwości skorzystania tylko z wybranych elementów UI, a także o odgórnym ograniczeniu wysokości notyfikacji.

W celach edukacyjnych nasza notyfikacja będzie wyświetlać tylko prosty tekst. Prawdopodobnie od swojej notyfikacji oczekiwać będziecie jednak trochę więcej, ale w tym celu będziecie musieli zanurzyć się w niuansach tworzenia androidowych widoków.

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:orientation="vertical" android:layout_width="match_parent"
  android:layout_height="match_parent">
  <TextView
    android:id="@+id/text_view_id"
    android:layout_height="wrap_content"
    android:layout_width="wrap_content"
    android:text="Hello there Obi-Wan Kenobi" />
</LinearLayout>
// Get the layouts to use in the custom notification
val notificationLayoutExpanded = RemoteViews(packageName, R.layout.notification_large)

// Apply the layouts to the notification
val customNotification = NotificationCompat.Builder(context, CHANNEL_ID)
        .setSmallIcon(R.drawable.notification_icon)
        .setStyle(NotificationCompat.DecoratedCustomViewStyle())
        .setCustomBigContentView(notificationLayoutExpanded)
        .build()

Ufff… Udało nam się zaimplementować część androidową, więc możemy ruszać dalej.

Web/PWA

Jeśli chodzi o push notyfikacje na webie, to niestety wciąż możemy mówić tutaj tylko o Webowym Androidzie. Pomimo niezliczonych próśb i wniosków nic nie wskazuje na to, że Apple zacznie patrzeć na tą funkcjonalność chociaż odrobinę przychylniej.

Schemat działania Webowej części obsługi notyfikacji

Jeśli mówimy natomiast o Webowym Androidzie, to niesamowicie ważne jest uświadomienie sobie, że obowiązują tutaj te same zasady, co przy natywnych androidowych notyfikacjach. Mamy więc te same możliwości konfiguracji, ale opakowane w zdecydowanie elastyczniejsze API. Wszystkie parametry możemy przekazywać w zapytaniu po stronie backendu. Aż dziwne, że Android nie wspiera podobnego API.

Niestety web push notifications nie wspierają aktualnie żadnych dodatkowych możliwości personalizacji.

method: POST
path: "https://fcm.googleapis.com/v1/projects/${firebaseProjectName}/messages:send"
body: {
  "message": {
    "name": "69ab68da-1dae-4c25-9386-cca6346123a8",
    "token": "firebase-token",
    "notification": null,
    "data": {
      "url": "/hub/keep-up",
      "vivedId": "vived-id"
    },
    "android": { ... },
    "apns": { ... },
    "webpush": {
      "notification": {
        "title": "Keep Up with IT World 🚀",
        "body": "Read all articles selected for you today:\n• \"Easily create web extensions for Safari\"\n•...",
        "actions": [
          {
            "action": "KEEP_UP_READ",
            "title": "Read now"
          }
        ],
        "badge": "https://uploads.vived.io/images/static/notifications/badge/vived-badge.png",
        "icon": "https://uploads.vived.io/images/static/notifications/icon/rocket.png",
        "image": "https://uploads.vived.io/images/static/notifications/icon/rocket.png"
      }
    },
}

Zapytanie wysyłane po strone backendu do Firebase (okrojone tylko do niezbędnej części)

Warto zwrócić uwagę, że typem notyfikacji sterujemy, poprzez to które pola pozostawimy wypełnione.

declare const self: ServiceWorkerGlobalScope;

const openUrl = async (url: string): Promise<void> => {
  const windowClients = (await self.clients.matchAll({
    type: 'window',
  })) as WindowClient[];
  const activeClient = windowClients.find(
    it => it.visibilityState === 'visible'
  );
  if (activeClient) {
    await activeClient.navigate(url);
  } else if (self.clients.openWindow) {
    await self.clients.openWindow(url);
  }
};

export const initializePushNotifications = (): void => {
  self.addEventListener('notificationclick', event => {
    const notification: MessagePayload = event.notification.data.FCM_MSG;
    const data = notification.data as PushNotificationData;

    event.notification.close(); // Android needs explicit close.
    event.waitUntil(openUrl(data.url));
  });
};

iOS

Dobrnęliśmy do ostatniej wspieranej przez nas platformy i jednocześnie tej, która sprawiła nam najwięcej problemów. Na szczęście nie ze względu na słabe API, a na nasze skromne umiejętności :).

Podstawowe notyfikacje na iOS wspierają tylko nagłówek i tekst. Dodając pewną modyfikację po stronie natywnej aplikacji, funkcjonalność tą możemy rozszerzyć o obrazek.

Jeśli to dla Was za mało, to mamy też możliwość dowolnej personalizacji rozwiniętej notyfikacji przez dostarczenie natywnego widoku. I w tym przypadku mówimy o naprawdę dowolnej personalizacji, bo nie mamy tutaj ograniczeń podobnych do tych z Androida.

Schemat działania iOS-owej części obsługi notyfikacji
method: POST
path: "https://fcm.googleapis.com/v1/projects/${firebaseProjectName}/messages:send"
body: {
  "message": {
    "name": "69ab68da-1dae-4c25-9386-cca6346123a8",
    "token": "firebase-token",
    "notification": null,
    "data": {
      "url": "/hub/keep-up",
      "vivedId": "vived-id"
    },
    "android": { ... },
    "apns": {
      "payload": {
        "aps": {
          "alert": {
            "title": "Keep Up with IT World 🚀",
            "subtitle": null,
            "body": "Read all articles selected for you today:\n• \"Easily create web extensions for Safari\"\n•..."
          },
          "category": "KEEP_UP",
          "mutableContent": 1
        },
        "imageUrl": "https://uploads.vived.io/images/static/notifications/image/rocket.png",
        "body": "Read all articles selected for you today:\n• \"Easily create web extensions for Safari\"\n•..."
      }
    },
    "webpush": { ... },
}

Zacznijmy od nadpisania obsługi notyfikacji dostarczonej przez Capacitora. W tym celu musimy stworzyć własny plugin i zarejestrować go jako instancję obsługującą notyfikacje.

@objc(ExtendedPushNotificationsPlugin)
public class ExtendedPushNotificationsPlugin: CAPPlugin, UNUserNotificationCenterDelegate {
    private func notifyJS(response: UNNotificationResponse) {
        var notificationAction: String;
        switch(response.actionIdentifier) {
        case "KEEP_UP_READ":
            notificationAction = "KEEP_UP_READ"
        default:
            notificationAction = "TAP"
        }
        
        let notificationData = [
            "url": response.notification.request.content.userInfo["url"],
            "vivedId": response.notification.request.content.userInfo["vivedId"]
        ];
        
        self.notifyListeners("notificationClick", data: [
            "data": notificationData,
            "action": notificationAction
        ], retainUntilConsumed: true);
    }
    
    private func registerCustomNotificationCategories() {
        let readNowAction = UNNotificationAction(
            identifier: "KEEP_UP_READ",
            title: "Read Now",
            options: [UNNotificationActionOptions.foreground]
        )
        
        // Define the notification type
        let dailyKeepUpNotificationCategory = UNNotificationCategory(
            identifier: "KEEP_UP",
            actions: [readNowAction],
            intentIdentifiers: [],
            hiddenPreviewsBodyPlaceholder: "",
            options: []
        )
        
        // Register the notification type.
        let notificationCenter = UNUserNotificationCenter.current()
        notificationCenter.setNotificationCategories([dailyKeepUpNotificationCategory])
    }
    
    @objc override public func load() {
        UNUserNotificationCenter.current().delegate = self
        registerCustomNotificationCategories()
    }
    
    public func userNotificationCenter(_ center: UNUserNotificationCenter,
                                       didReceive response: UNNotificationResponse,
                                       withCompletionHandler completionHandler:
                                        @escaping () -> Void) {
        completionHandler()
        notifyJS(response: response)
    }
    
    @objc public func userNotificationCenter(_ center: UNUserNotificationCenter,
                                             willPresent notification: UNNotification,
                                             withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
        completionHandler([
            .badge,
            .sound,
            .alert
        ])
    }

Rzeczą, która może wzbudzać Wasze wątpliwości, jest ręczne tworzenie akcji. W odróżnienia od Androida i Weba tutaj nie mamy możliwości spersonalizowania akcji po stronie serwera i należy dokonać tego po stronie klienta. Jest to rozwiązanie, które niestety blokuje nam możliwość dodania kolejnych przycisków po wypuszczeniu aplikacji, ale z drugiej strony pozwala nam całkowicie uniknąć niedziałających przycisków (ich brak uważam za lepsze zachowanie).

Nasz plugin obsługuje już notyfikacje, więc możemy przejść do wzbogacenia ich o obrazek. W tym celu musimy stworzyć NotificationServiceExtension, który będzie modyfikował notyfikację zanim ta zostanie wyświetlona.

Podobnie jak w przypadku Androida, tak i tutaj będziemy chcieli wzbogacić dane otrzymane od backendu. Tak samo jak poprzednio, będzie to pobranie zdjęcia, ale nic nie stoi na przeszkodzie, aby było to np. dodanie tekstu do notyfikacji. Za modyfikację odpowiedzialna będzie metoda `didReceive` z `NotificationService`.

Note: Przekazanie notyfikacji do UNNotificationServiceExtension zależy od tego, czy po stronie serwera zdefiniujemy parametr `mutableContent`: 1.

import UserNotifications

class NotificationService: UNNotificationServiceExtension {
    var contentHandler: ((UNNotificationContent) -> Void)?
    var bestAttemptContent: UNMutableNotificationContent?
    
    override func didReceive(_ request: UNNotificationRequest, withContentHandler contentHandler: @escaping (UNNotificationContent) -> Void) {
        self.contentHandler = contentHandler
        bestAttemptContent = (request.content.mutableCopy() as? UNMutableNotificationContent)
        
        guard let bestAttemptContent = bestAttemptContent,
              let attachmentURLAsString = bestAttemptContent.userInfo["imageUrl"] as? String,
              let attachmentURL = URL(string: attachmentURLAsString) else {
            return
        }
        
        addImageToAttachments(url: attachmentURL) { (attachment) in
            if let attachment = attachment {
                bestAttemptContent.attachments = [attachment]
                contentHandler(bestAttemptContent)
            }
        }
    }
}

W pierwszym kroku odpakowujemy otrzymane dane, korzystając z mechanizmu guard statement.  Następnie pobieramy asynchronicznie obrazek i dodajemy go do załączników naszej notyfikacji. Na koniec korzystając z mechanizmu if-let, odpakowujemy wynik operacji i przekazujemy naszą notyfikację do kolejnego handlera.

    private func addImageToAttachments(url: URL, with completitionHandler: @escaping (UNNotificationAttachment?) -> Void) {
        let task = URLSession.shared.downloadTask(with: url) { (downloadedUrl, response, error) in
            guard let downloadedUrl = downloadedUrl else {
                completitionHandler(nil)
                return
            }
            
            let uniqueURLEnding = ProcessInfo.processInfo.globallyUniqueString + ".jpg"
            let urlPath = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(uniqueURLEnding)
            
            try? FileManager.default.moveItem(at: downloadedUrl, to: urlPath)
            
            do {
                let attachment = try UNNotificationAttachment(identifier: "picture", url: urlPath, options: nil)
                completitionHandler(attachment)
            }
            catch {
                completitionHandler(nil)
            }
        }
        task.resume()
    }

Pobieranie zaczynamy od wywołania właściwego zasobu z sieci. Następnie odczytujemy adres, do jakiego pobrany został obraz i kopiujemy go do tymczasowego pliku. Na koniec wywołujemy completitionHandler.

Tip: Jeśli nie możecie załapać koncepcji `completitionHandler`, to potraktujcie go jako odpowiednik Promise.resolve().

Jeśli na tym etapie wyślecie notyfikację, to powinna ona zawierać pobrany przez nas obrazek. Jeśli natomiast na tym nie kończą się Wasze wymagania, to iOS umożliwia nam przygotowanie dowolnego widoku dla rozwiniętej notyfikacji. W tym celu musimy stworzyć kolejny Application Target: Notification Content Extension.

Zanim zaczniemy implementację widoku, należy dodać do pliku info.plist UNNotificationExtensionCategory zgodne z tym, które definiujemy po stronie backendu.

Tworzenie natywnych widoków przypomina trochę czarną magię, polegającą na łączeniu między sobą różnych elementów i definiowaniu marginesów. Jest to proces na tyle skomplikowany, że gdybym chciał dobrze go opisać, to musiałbym kilkukrotnie rozszerzyć ten tutorial. Z tego względu wszystkich zainteresowanych odsyłam do innego tutoriala, który zrobił to dobrze. W tym miejscu podzielę się natomiast samym kodem definiującym logikę (tej na szczęście nie ma dużo, bo ustawiamy tylko wartości poszczególnym elementom interfejsu).

import UIKit
import UserNotifications
import UserNotificationsUI

class NotificationViewController: UIViewController, UNNotificationContentExtension {
    
    @IBOutlet weak var titleLabel: UILabel!
    @IBOutlet weak var bodyLabel: UILabel!
    @IBOutlet weak var imageView: UIImageView!
    
    override func viewDidLoad() {
        super.viewDidLoad()
    }
    
    func didReceive(_ notification: UNNotification) {
        self.titleLabel.text = notification.request.content.title
        self.bodyLabel.text = notification.request.content.userInfo["longBody"] as? String ?? notification.request.content.body
        
        let attachments = notification.request.content.attachments
        for attachment in attachments {
            if attachment.identifier == "picture" && attachment.url.startAccessingSecurityScopedResource() {
                guard let data = try? Data(contentsOf: attachment.url) else {
                    return
                }
                imageView.image = UIImage(data: data)
            }
        }
    }
}

Podsumowanie

Mam nadzieję, że wszyscy wyglądacie teraz tak samo jak Anakin na gifie powyżej! Jeśli nie to niech moc będzie z Wami… Notyfikacje to jeden z tych tematów, które na papierze wydają się banalnie proste, a przy próbie implementacji okazują się równie zawiłe, co dzieje rodu Skywalkerów w uniwersum Gwiezdnych Wojen. Mam nadzieję, że praca włożona w ten tutorial, chociaż odrobinę ułatwi Wam pracę i sprawi, że Wasi managerowie będą dumni ze zrealizowanych przez Was w tym sprincie punktów. Ja tymczasem żegnam się z Wami i do zobaczenia w kolejnej edycji Frontendowego Czwartku.