Skip to content

Kotlin Multiplatform

Herald for Kotlin Multiplatform lets you write your events once, in shared Kotlin, and track them from shared view models. It wraps the two native SDKs, so each platform keeps its own setup and vendors:

  • on Android, the Android SDK: events from shared code are Android Herald events;
  • on iOS, the iOS SDK, through its HeraldInterop bridge.

Notes Changelog Source

herald-kmp Android SDK iOS SDK
1.1.0 1.4.0 1.3.0
1.0.0 1.2.0 1.1.0

Use it when your app shares Kotlin code between Android and iOS. A native app keeps using its own SDK.

Installation

In your shared module, add herald-kmp-core, and export it so Swift can use it:

kotlin {
    listOf(iosArm64(), iosSimulatorArm64()).forEach { target ->
        target.binaries.framework {
            baseName = "Shared"
            export("io.github.mkhytarmkhoian:herald-kmp-core:1.1.0")
        }
    }
    sourceSets {
        commonMain.dependencies {
            api("io.github.mkhytarmkhoian:herald-kmp-core:1.1.0")
        }
    }
}
Module What it is
herald-kmp-core Events, properties and the five capabilities, for shared code.
herald-kmp-amplitude Amplitude's revenue event.
herald-kmp-adjust Adjust's revenue and ad revenue events.
herald-kmp-appsflyer AppsFlyer's purchase, subscription and ad revenue events.
herald-kmp-meta Meta's purchase event.

Add a vendor's module only for its revenue events, and export it too. Every other event works with the core alone.

Shared code

Events and view models are the same Kotlin as on Android:

package io.github.mkhytarmkhoian.herald.sample

import io.github.mkhytarmkhoian.herald.kmp.AnalyticsValue
import io.github.mkhytarmkhoian.herald.kmp.Event
import io.github.mkhytarmkhoian.herald.kmp.EventTrackerService
import io.github.mkhytarmkhoian.herald.kmp.ScreenViewEvent
import io.github.mkhytarmkhoian.herald.kmp.parameters

// Written once, in shared code. On Android these are Android Herald events; on iOS they reach
// herald-ios through the bridge.

public object CheckoutViewed : ScreenViewEvent {
    override val name: String = "checkout"
}

public class CheckoutStarted(plan: String, seats: Int, price: Double) : Event {
    override val name: String = "checkout_started"
    override val parameters: Map<String, AnalyticsValue> = parameters {
        put("plan", plan)
        put("seats", seats)
        put("price", price)
    }
}

public class CheckoutViewModel(private val analytics: EventTrackerService) {
    public suspend fun opened(): Unit = analytics.track(CheckoutViewed)

    public suspend fun started(plan: String, seats: Int): Unit =
        analytics.track(CheckoutStarted(plan, seats, price = seats * 4.99))
}

Android

Nothing extra. Set Herald up as in any Android app, and give it to your shared code:

val herald = Herald {
    provider("amplitude", events = amplitudeTracker /* … */)
}
CheckoutViewModel(analytics = herald)

iOS

  1. In Xcode, add herald-ios with its HeraldCore and HeraldInterop modules, and your vendors' packages, as for any iOS app.
  2. Copy two files into your app, once. They can't ship in a package, because they import your app's own shared framework.
  3. Set herald-ios up as in any iOS app, then connect it to your shared code:
let herald = Herald(providers: [amplitudeProvider(amplitude)])
let bridge = HeraldBridge(herald, translate: revenueEvent, errorReporter: report)
let viewModel = CheckoutViewModel(analytics: IosAnalytics(bridge: HeraldKmpBridge(bridge)))
HeraldKmpBridge.swift: copy it as is
import HeraldInterop
import Shared

/// Connects herald-kmp's shared code to herald-ios. Copy this file into your iOS app as is: it
/// can't ship in a package, because it imports your app's own shared framework.
final class HeraldKmpBridge: NSObject, IosHeraldBridge {
    private let bridge: HeraldBridge

    init(_ bridge: HeraldBridge) {
        self.bridge = bridge
    }

    func track(
        name: String, parameters: [String: IosBridgeValue], isScreenView: Bool,
        needsTranslation: Bool, event: any KotlinEvent
    ) {
        bridge.track(
            name: name, parameters: parameters.mapValues(Self.value), isScreenView: isScreenView,
            needsTranslation: needsTranslation, kotlinEvent: event)
    }

    func set(name: String, value: IosBridgeValue, isUserProperty: Bool) {
        bridge.set(name: name, value: Self.value(value), isUserProperty: isUserProperty)
    }

    func identify(userId: String) { bridge.identify(userId: userId) }

    func reset() { bridge.reset() }

    func start() { bridge.start() }

    func flush() { bridge.flush() }

    func setEnabled(enabled: Bool) { bridge.setEnabled(enabled) }

    private static func value(_ value: IosBridgeValue) -> BridgeValue {
        switch value.type {
        case .string: return .string(value.string)
        case .int_: return .int(Int(value.int_))
        case .double_: return .double(value.double_)
        default: return .bool(value.bool_)
        }
    }
}
RevenueTranslation.swift: keep the blocks for your vendors
import AppsFlyerLib
import HeraldAdjust
import HeraldAmplitude
import HeraldAppsFlyer
import HeraldCore
import HeraldMeta
import Shared

/// Turns herald-kmp's revenue events into herald-ios's, so each vendor recognises them. Copy this
/// file into your iOS app and keep the blocks for the vendors you use.
@Sendable
func revenueEvent(from kotlinEvent: AnyObject, parameters: [String: AnalyticsValue])
    -> (any Event)?
{
    if let event = kotlinEvent as? KotlinAmplitudeRevenueEvent {
        return AmplitudeRevenueEvent(
            name: event.name, price: event.price, quantity: Int(event.quantity),
            productId: event.productId, revenueType: event.revenueType, currency: event.currency,
            revenue: event.revenue?.doubleValue, receipt: event.receipt,
            receiptSig: event.receiptSig, insertId: event.insertId, parameters: parameters)
    }
    if let event = kotlinEvent as? KotlinAdjustRevenueEvent {
        return AdjustRevenueEvent(
            name: event.name, revenue: event.revenue, currency: event.currency,
            deduplicationId: event.deduplicationId, parameters: parameters)
    }
    if let event = kotlinEvent as? KotlinAdjustAdRevenueEvent {
        return AdjustAdRevenueEvent(
            name: event.name, source: event.source, revenue: event.revenue,
            currency: event.currency, adImpressionsCount: event.adImpressionsCount?.intValue,
            adRevenueNetwork: event.adRevenueNetwork, adRevenueUnit: event.adRevenueUnit,
            adRevenuePlacement: event.adRevenuePlacement, parameters: parameters)
    }
    if let event = kotlinEvent as? KotlinAppsFlyerPurchaseEvent {
        return AppsFlyerPurchaseEvent(
            name: event.name, revenue: event.revenue, currency: event.currency,
            contentId: event.contentId, contentType: event.contentType,
            quantity: event.quantity?.intValue, orderId: event.orderId, parameters: parameters)
    }
    if let event = kotlinEvent as? KotlinAppsFlyerSubscribeEvent {
        return AppsFlyerSubscribeEvent(
            name: event.name, revenue: event.revenue, currency: event.currency,
            parameters: parameters)
    }
    if let event = kotlinEvent as? KotlinAppsFlyerAdRevenueEvent,
        let network = mediationNetworks[event.mediationNetwork.name]
    {
        return AppsFlyerAdRevenueEvent(
            name: event.name, monetizationNetwork: event.monetizationNetwork,
            mediationNetwork: network, revenue: event.revenue, currency: event.currency,
            parameters: parameters)
    }
    if let event = kotlinEvent as? KotlinMetaPurchaseEvent {
        return MetaPurchaseEvent(
            name: event.name, amount: event.amount, currency: event.currency,
            parameters: parameters)
    }
    return nil
}

/// AppsFlyer's mediation networks, by their name in Kotlin. The two SDKs list the same ones.
private let mediationNetworks: [String: MediationNetworkType] = [
    "IRONSOURCE": .ironSource, "APPLOVIN_MAX": .applovinMax, "GOOGLE_ADMOB": .googleAdMob,
    "FYBER": .fyber, "APPODEAL": .appodeal, "ADMOST": .admost, "TOPON": .topon,
    "TRADPLUS": .tradplus, "YANDEX": .yandex, "CHARTBOOST": .chartBoost, "UNITY": .unity,
    "TOPON_PTE": .toponPte, "CUSTOM_MEDIATION": .custom,
    "DIRECT_MONETIZATION_NETWORK": .directMonetization,
]

If RevenueTranslation.swift misses a revenue event, the bridge reports it to errorReporter instead of letting the revenue disappear without a word.

Where shared Kotlin differs from Android, and how labels and errors work across the two apps, is in the Kotlin Multiplatform notes.