Push

Set up your app

The Apple and Google side of push: the keys honkio sends with, the setting your app needs, and the code that gets a device token to your backend.

Before you start

For iOS you need a membership in the Apple Developer Program, to create the key honkio sends to your app with. For Android you need a Firebase project, which is free. Once your app has those credentials, you can build against a test key: test mode sends nothing to Apple or Google, charges nothing and takes magic tokens, so it needs no phone (Test mode).

Your app never talks to honkio. It gets a token from Apple or Google and sends it to your own backend, which registers it with honkio. Keep your honkio API key on your server: a key built into an app can be read out of it. The HonkIO Swift and Android packages used below never call honkio and hold no API keys.

iOS: Apple

  1. In your Apple Developer account, open Certificates, Identifiers & Profiles, then Identifiers. Open your app's App ID (an explicit bundle ID, not a wildcard) and turn on Push Notifications.
  2. Under Keys, add a key with Apple Push Notifications service (APNs) ticked and download the .p8 file. Apple lets you download it once, so keep it somewhere safe. Note the key ID shown with the key, and your team ID, shown under Membership details. If Apple asks which environment the key is for, choose both, so one key covers development and release builds.
  3. In Xcode, select your app target, open Signing & Capabilities and add Push Notifications. Add Background Modes with Remote notifications ticked only if you send data-only pushes, and a Notification Service Extension only if you send encrypted pushes.
  1. Ask for permission and register with Apple on every launch, as usual. Add the HonkioPush Swift package to your app target (iOS 17 or later) and use it to turn the token into the hex text honkio expects. Send that token, its APNs environment and the device's encryption public key (HonkioPush.encryptionPublicKey) to your backend; the app knows which environment it has (Sandbox or production).
swift
.package(url: "https://github.com/somecorp-dev/honkio-swift", from: "0.1.0")

In the app delegate:

swift
import HonkioPush

func application(_ application: UIApplication,
                 didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
    let token = HonkioDeviceToken.hexString(from: deviceToken)
    #if DEBUG
    let environment = "sandbox"     // a build you run from Xcode
    #else
    let environment = "production"  // TestFlight, App Store and ad hoc builds
    #endif
    // Created on first use, then the same key on every launch.
    guard let key = try? HonkioPush.encryptionPublicKey(accessGroup: "group.com.example.app") else { return }
    // sendTokenToYourBackend is your app's own function; your backend registers all three with HonkIO
    // (key.publicKey as encryption_public_key).
    Task { await sendTokenToYourBackend(token, apnsEnvironment: environment, encryptionPublicKey: key.publicKey) }
}
Without the package

Ask for permission, register with Apple on every launch and send the token to your backend. The sample is a SwiftUI app; in a UIKit app the app delegate is the same.

swift
import SwiftUI
import UIKit
import UserNotifications

@main
struct AcmeApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate

    var body: some Scene {
        WindowGroup { Text("Acme") }
    }
}

final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDelegate {
    func application(_ application: UIApplication,
                     didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
        UNUserNotificationCenter.current().delegate = self
        Task {
            // Asks once; on later launches it answers straight away with the person's choice.
            let granted = (try? await UNUserNotificationCenter.current()
                .requestAuthorization(options: [.alert, .sound, .badge])) ?? false
            // Register on every launch: Apple can change the token.
            if granted { application.registerForRemoteNotifications() }
        }
        return true
    }

    func application(_ application: UIApplication,
                     didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
        // HonkIO takes the token as hex text. Don't assume a fixed length.
        let token = deviceToken.map { String(format: "%02x", $0) }.joined()
        Task { await sendTokenToBackend(token) }
    }

    func application(_ application: UIApplication,
                     didFailToRegisterForRemoteNotificationsWithError error: Error) {
        // Most often the Push Notifications capability is missing from the target.
        print("Push registration failed: \(error)")
    }

    // Show a push that arrives while the app is open, instead of dropping it.
    nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter,
                                            willPresent notification: UNNotification) async -> UNNotificationPresentationOptions {
        [.banner, .sound, .badge]
    }
}

/// Your own endpoint, called as the signed-in person. Your backend then calls PUT /v1/push/devices.
func sendTokenToBackend(_ token: String) async {
    #if DEBUG
    let environment = "sandbox"     // a build you run from Xcode
    #else
    let environment = "production"  // TestFlight, App Store and ad hoc builds
    #endif
    var request = URLRequest(url: URL(string: "https://api.example.com/me/push-token")!)
    request.httpMethod = "PUT"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    // Add your own sign-in header here, so your backend knows whose device this is.
    request.httpBody = try? JSONEncoder().encode([
        "token": token, "platform": "ios", "apns_environment": environment,
    ])
    _ = try? await URLSession.shared.data(for: request)
}

Test on a real iPhone or iPad, or on the Simulator on an Apple silicon Mac. A build you run from Xcode gets a sandbox token, so your backend registers it with apns_environment sandbox (Sandbox or production).

Android: Firebase

  1. In the Firebase console, create a project (Google Analytics is not needed), then add an Android app with your app's package name. Download google-services.json into your app module and follow the console's steps to add the Google services Gradle plugin and the Firebase BoM.
  2. Add the messaging library to your app module: implementation("com.google.firebase:firebase-messaging"). With the BoM in place it needs no version.
  3. Declare the notification permission and the messaging service in AndroidManifest.xml.
xml
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <!-- Android 13 and later ask the person before an app may show notifications. -->
    <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

    <application>
        <!-- Your activities as usual, plus the service that hears about new tokens. -->
        <service
            android:name=".PushService"
            android:exported="false">
            <intent-filter>
                <action android:name="com.google.firebase.MESSAGING_EVENT" />
            </intent-filter>
        </service>
    </application>
</manifest>
  1. On Android 13 and later, ask for the notification permission first. Add the honkio-push library to your app module. Send the current token to your backend on every launch with HonkioTokens.current, and again from onNewToken, with the device's encryption public key from HonkioPush.encryptionPublicKey.
kotlin
dependencies {
    implementation(platform("com.google.firebase:firebase-bom:34.5.0"))
    implementation("com.google.firebase:firebase-messaging")
    implementation("ca.honkio:honkio-push:0.1.0")
}
kotlin
// sendTokenToBackend is your app's own function. onToken runs on the main thread, so move off it.
HonkioTokens.current(
    onToken = { token -> thread { sendTokenToBackend(token, HonkioPush.encryptionPublicKey(applicationContext)) } },
    onError = { e -> Log.w("push", "no FCM token", e) },
)

class PushService : FirebaseMessagingService() {
    // onNewToken already runs off the main thread.
    override fun onNewToken(token: String) = sendTokenToBackend(token, HonkioPush.encryptionPublicKey(applicationContext))
}
Without the package

Ask for the permission on Android 13 and later, read the token on every launch and whenever it changes, and send it to your backend.

kotlin
import android.Manifest
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.messaging.FirebaseMessagingService
import com.google.firebase.messaging.RemoteMessage
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
import kotlin.concurrent.thread

class MainActivity : ComponentActivity() {
    private val askPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU &&
            ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
        ) {
            askPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
        }
        // The current token, on every launch.
        FirebaseMessaging.getInstance().token.addOnSuccessListener { token -> sendTokenToBackend(token) }
    }
}

class PushService : FirebaseMessagingService() {
    // Google issued a new token: the old one stops working.
    override fun onNewToken(token: String) = sendTokenToBackend(token)

    // While the app is in the background, Android shows a push with a title and body
    // itself. This runs for those while the app is open, and for data-only and
    // encrypted pushes always. Nothing is shown unless you post the notification
    // here: the encryption page's Kotlin sample has a channel and a builder.
    override fun onMessageReceived(message: RemoteMessage) {}
}

/** Your own endpoint, called as the signed-in person. Your backend then calls PUT /v1/push/devices. */
fun sendTokenToBackend(token: String) {
    thread {
        var conn: HttpURLConnection? = null
        try {
            conn = URL("https://api.example.com/me/push-token").openConnection() as HttpURLConnection
            conn.requestMethod = "PUT"
            conn.doOutput = true
            conn.setRequestProperty("Content-Type", "application/json")
            // Add your own sign-in header here, so your backend knows whose device this is.
            val body = JSONObject().put("token", token).put("platform", "android").toString()
            conn.outputStream.use { it.write(body.toByteArray()) }
            conn.responseCode // sends the request and waits for the answer
        } catch (e: IOException) {
            // Offline or your backend is down: the next launch sends the token again.
        } finally {
            conn?.disconnect()
        }
    }
}
  1. For honkio, open Project settings, then Service accounts, and generate a new private key. The JSON file it downloads is the credential you give honkio. Anyone with it can send pushes to your users and use your Firebase project, so keep it out of your app and your code repository.

On an emulator, use a system image with Google Play. While your app is in the background, Android shows a push with a title and body by itself. While the app is open, and for data-only and encrypted pushes, onMessageReceived gets the push instead, and nothing is shown unless your app posts the notification itself; the encryption page shows how to decrypt the push first.

Pass the token to honkio

Your backend receives the token from the signed-in person and registers it with PUT /v1/push/devices, with your own ID for that person as user_id. Registering is an upsert, so doing it on every launch is fine, and registering a token under another user_id moves it, as on a shared phone. Pass the device's public key as encryption_public_key too, so honkio encrypts its pushes (Encryption).

// Call this from your backend when the app reports its token.
const { data: device, error } = await honkio.push.devices.register({
  appId: 'APP_ID',
  token: 'DEVICE_TOKEN',
  platform: 'ios', // or 'android'
  userId: 'user_1042', // your own identifier for this person
  apnsEnvironment: 'production', // iOS only: 'sandbox' for development builds
  encryptionPublicKey: 'DEVICE_PUBLIC_KEY', // the key the HonkIO push package made on the device
})
if (error) throw new Error(error.message)
console.log(device.id, device.active, device.encryption_key_id)

Call DELETE /v1/push/devices/:id when the person signs out. The device rules are in Devices.

Sandbox or production (iOS)

Apple has two push environments, and a token only works in the one its build was signed for. What decides it is how the build was signed, not whether it is a Debug or Release build.

Buildapns_environment
Run from Xcode on a devicesandbox
TestFlightproduction
App Storeproduction
Ad hoc or enterpriseproduction

The #if DEBUG in the sample matches Xcode's defaults; a Release build you run from Xcode still gets a sandbox token. Apple refuses a token registered for the wrong environment as a bad token, and honkio retires the device with the reason INVALID_TOKEN. Registering it again with the right environment brings it back.

Android has no environments: the same token works for every build.

Add the credentials to honkio

In the dashboard, switch to Live, open Push, then Apps, and choose New app: an app is shared by test and live mode, so only Live can create one. Paste the text of the .p8 key with its key ID, your team ID and the bundle ID, the contents of the Firebase JSON file, or both. honkio checks them with Apple and Google before it saves the app. To do it from code, with a live key, see Credentials.

Then send a push with a live key to the user_id you registered (Sending and fallback). The first 2,500 pushes Apple or Google accept each month are free.