# Unity SDK


`openpush-unity` is a receive-only Unity package over the native OpenPush iOS and Android SDKs. The natives register the device, carry identity and tags, post delivery receipts, present in-app messages, and run Live Activities (iOS) and Live Updates (Android). Like the native SDKs it has no send surface — messages are created through the REST API or the console.

## When to use

Add this package to a Unity project targeting Android or iOS that should receive push from OpenPush. By default the package obtains the push token itself: Android fetches the FCM token through the native FCM module, and iOS captures the APNs token Unity receives. A project that already runs its own Firebase messaging on Android keeps it by choosing `HostOwned` delivery (Step 2).

## Requirements

| Requirement | Value |
|---|---|
| Package version | `1.1.5` |
| Package name | `ai.openpush.unity` |
| Native SDKs (vendored in the package) | iOS `1.1.5`, Android `1.1.5` |
| Minimum Unity | 2022.3 LTS |
| Android | Minimum API Level 24, Target API Level 34 or Automatic, EDM4U 1.2.189; IL2CPP + ARM64 recommended |
| iOS | Deployment target 16.0, built with Xcode 26 or newer (Swift 6.3.3 or newer) |
| Android, Automatic delivery | `google-services.json` in `Assets/`, or the Firebase project id, application id and API key in `OpenPushSettings` |
| Credentials | Your app id and the app's **SDK key** (`X-OP-SDK-Key`) |

The native SDKs ship inside the package: an in-package Maven repository for Android (`Runtime/Plugins/Android/maven~`, resolved by EDM4U) and static XCFrameworks for iOS (`Runtime/Plugins/iOS/Native~`, linked at export). No OpenPush artifact is downloaded from a public registry; EDM4U fetches only their third-party dependencies (`firebase-messaging` 24.0.2, Kotlin and AndroidX) from Maven Central and Google Maven.

The Editor and desktop players run an in-memory stub: calls are logged, nothing touches the network, and `OpenPush.SdkVersion` reads `stub`.

## Step 1 — install the package

Installation is through a **Git URL in Unity Package Manager**. Install Git 2.14 or newer first and make sure `git` is on the system `PATH`; Package Manager shells out to the system client.

The repository is private, so every developer and CI runner needs read access before Unity can resolve it. Package Manager cannot show a credential prompt, so configure one of these up front:

- **HTTPS** — load a valid credential into the system Git credential helper.
- **SSH** — add an authorized key to `ssh-agent` before launching Unity.

Do not embed an access token in the package URL or commit one to a package file. Confirm authentication outside Unity with `git ls-remote`; it must succeed without prompting.

Then **Window → Package Manager → + → Add package from git URL**:

```text
https://github.com/JuneSoftware/openpush-unity.git#1.1.5
git+ssh://git@github.com/JuneSoftware/openpush-unity.git#1.1.5
```

Or add it to `Packages/manifest.json`:

```json
{
  "dependencies": {
    "ai.openpush.unity": "https://github.com/JuneSoftware/openpush-unity.git#1.1.5"
  }
}
```

Pin production projects to a release tag or a full commit SHA, never a moving branch, and commit `packages-lock.json` so every machine resolves the same revision.

An optional **Feature Lab** sample is available from the package's Samples section. It is copied into `Assets/Samples` on import and is not compiled until then. It forwards no tokens: the native SDKs obtain them.

## Step 2 — Android: EDM4U and delivery mode

Open **Window → OpenPush → Setup** (also under **Project Settings → OpenPush**). Each row checks one requirement and offers a fix.

1. **Install EDM4U.** On the **External Dependency Manager (Android)** row press **Add EDM4U**. It adds the OpenUPM scoped registry and `com.google.external-dependency-manager` `1.2.189` to `Packages/manifest.json`. EDM4U creates and patches the Gradle templates itself; there is nothing to tick in Player Settings.
2. **Resolve.** The **Android dependencies resolved** row runs a forced resolve, which pulls the vendored OpenPush natives from the package's own Maven repository plus their dependencies. When the Editor loads with Android as the build target, OpenPush also forces a resolve after a package upgrade or a delivery-mode change: an upgraded package lives in a new `Library/PackageCache/ai.openpush.unity@<hash>` folder, and the patched Gradle settings must point at it. After upgrading the package, make sure this row passes before you build.
3. **Pick a delivery mode** — `OpenPushSettings.AndroidPushDelivery`, under **Project Settings → OpenPush → Android delivery**:
   - **Automatic** (default): the OpenPush FCM module fetches the token, renders notifications (with their action buttons) and reports taps. Your app adds no code. Put `google-services.json` in `Assets/` (it takes precedence), or fill the three Firebase fields `AndroidFirebaseProjectId`, `AndroidFirebaseApplicationId` and `AndroidFirebaseApiKey`. Without either, the device never gets an FCM token and the SDK only logs it; the **Firebase configuration** row checks for this.
   - **HostOwned**: your app owns FCM, for example with Firebase Unity Messaging. OpenPush's messaging service is removed from the merged manifest and the FCM module is not resolved. Your messaging code forwards to OpenPush — see [Android HostOwned forwarding](#android-hostowned-forwarding).
4. **API levels.** Minimum 24, Target 34 or Automatic. The **Android API levels** row fixes them.

The adapter library declares `android.permission.POST_NOTIFICATIONS` and ships its own R8 keep rules, so release builds with **Minify** enabled need no entries in your project's ProGuard file, and the Android 13+ prompt needs nothing in your manifest.

## Step 3 — iOS

There is no token step. The package observes Unity's APNs token notifications and hands the token to the native SDK (a token that arrives before `Initialize` is held and applied after it). It captures notification taps and foreground arrivals through the notification delegate Unity installs.

At export the package:

- copies the vendored XCFrameworks into the Xcode project and links them into UnityFramework (static, not embedded), and sets the deployment target to 16.0;
- adds the Push Notifications capability and the remote-notification background mode. The `aps-environment` entitlement follows Unity's **Development Build** checkbox: `development` when ticked, `production` when not;
- injects a notification service extension when a settings asset exists and its `InjectServiceExtension` flag is on (see below);
- injects a Live Activity widget when `OpenPushSettings.InjectLiveActivityWidget` is on.

Build the exported project with Xcode 26 or newer: the vendored frameworks' Swift interfaces need Swift 6.3.3 or newer.

### Notification service extension

The extension is injected when an `OpenPushSettings` asset loads from `Resources` and its `InjectServiceExtension` flag is on; the field defaults to on. Without a settings asset nothing is injected. When injected, the export gains an `OpenPushNotificationService` target whose extension is the native `OpenPushNotificationServiceHandler`, linked against the vendored `OpenPushCore` and `OpenPushNSE` frameworks. iOS runs it for every OpenPush alert, including while the app is not running; it attaches rich media (`image_url`) and posts the `received` receipt, so a push that is never tapped is still counted.

- iOS runs one service extension per app. If your project already owns one, turn `InjectServiceExtension` off. An export that already contains a `com.apple.usernotifications.service` target is left alone either way.
- The extension needs an App Group. OpenPush uses `OpenPushSettings.AppGroupIdentifier`, or `group.<your bundle id>.openpush` when it is empty. Add that group to the App ID in your developer account before the first build, or manual signing fails with a profile that does not carry it.
- No secret crosses the App Group boundary: the receipt route authenticates on the public SDK key, and the device credential stays in the app's Keychain.

### Live Activity widget

The injected `OpenPushLiveActivityWidget` imports `DefaultLiveActivityAttributes` from the vendored `OpenPushLiveActivities` framework. Turn `InjectLiveActivityWidget` off when your project already owns a widget.

## Step 4 — initialize

Create an `OpenPushSettings` asset — **Assets → Create → OpenPush → Settings** — and save it inside a `Resources` folder. Put your public SDK key in it; `Initialize` then needs only the app id.

```csharp
using OpenPushSDK;
using OpenPushSDK.Core;

var credentialStore = new DeviceCredentialStore("your.app.device");

OpenPush.OnLog = UnityEngine.Debug.Log;
OpenPush.Initialize(AppID, credential: () => credentialStore.Load());
OpenPush.Notifications.RequestPermission();
```

The credential closure returns the currently linked installation credential, or `null` before linking. To keep the key in code instead, call `Initialize(appId, sdkKey)`; an explicit key always wins over the asset. With no key in either place, `Initialize(appId)` refuses through `OnLog` and `OnRegistrationFailed` rather than half-configuring. `OpenPush.DefaultServerUrl` is `https://app.openpush.ai` and is used when no server URL is given.

To start the SDK without code, add **Add Component → OpenPush → Bootstrap** to an object in your first scene and set **App Id** (or leave it blank to use `OpenPushSettings.AppId`).

**Call `Initialize` first.** Every call with a side effect (`Register`, `Login`, `TrackEvent`, `User.*` writes, `PushSubscription.OptIn`/`OptOut`, `Notifications.RequestPermission`, in-app and live-feature calls) throws `InvalidOperationException` until `Initialize` has run. `LogLevel`, `OnLog`, event subscriptions and read-only getters work at any time, so you can subscribe in `Awake` and initialize later. Each event keeps up to ten values raised before its first subscriber and replays them to it, except `Notifications.ForegroundWillDisplay`.

`OpenPush.OnLog` is the one diagnostic sink, native log lines included; `OpenPush.LogLevel` (`Verbose` by default) decides how much arrives.

**Verify:** `OpenPush.IsInitialized` is `true`; `OpenPush.SdkVersion` reads the native SDK version (`1.1.5`); `OpenPush.WrapperVersion` reads `1.1.5`; the device appears in the console with wrapper `unity/1.1.5`.

## Permission

```csharp
OpenPush.Notifications.RequestPermission(fallbackToSettings: true, done: granted =>
    Debug.Log(granted ? "notifications allowed" : "notifications denied"));

bool granted = await OpenPush.Notifications.RequestPermissionAsync(true);

bool permitted = OpenPush.Notifications.Permission;   // cached, never blocking
int types = OpenPush.Notifications.NotificationTypes;

OpenPush.Notifications.PermissionChanged += allowed => Debug.Log($"permission changed: {allowed}");
```

On Android 13+ the request goes through Unity's runtime-permission API and the native SDK is told the result; with `fallbackToSettings`, a request after a permanent denial opens the app's notification settings. Below Android 13 there is no runtime prompt and the call reports the current permission. `RequestProvisionalPermission` asks for iOS provisional authorization and reports `false`, changing nothing, elsewhere. The native SDK refreshes the cached permission and re-registers when it changes.

## Identity

```csharp
OpenPush.Login(externalId, authHash, result =>
{
    if (result.Success) { /* linked */ }
    else if (result.IdentityRejectedNote != null) { /* registered anonymously */ }
    else { /* result.Error */ }
});

OpenPush.Logout();
```

Compute the identity-verification auth hash **on your server**, never in the client. A refused external id still registers the device anonymously and says why: `Success == false` with a non-null `IdentityRejectedNote`. `OpenPush.User.Changed` fires when the external id or the subscription id changes.

`OpenPush.OnIdentityVerificationRequired(externalId)` is raised when the app requires identity verification and the SDK holds an external id without an auth hash (for example one migrated from 1.1.4). Answer it with `Login(externalId, authHash)`.

## Tags, aliases and events

```csharp
OpenPush.User.AddTag("tier", "gold");
OpenPush.User.AddTags(new Dictionary<string, string> { ["tier"] = "gold", ["level"] = "12" });
OpenPush.User.RemoveTags("trial", "legacy");
var tags = OpenPush.User.GetTags();

OpenPush.User.AddAlias("crm_id", "c-42");
OpenPush.User.RemoveAliases("crm_id", "loyalty_id");

OpenPush.TrackEvent("level_complete", new Dictionary<string, object> { ["level"] = 12 });
```

Tags are device-level state kept by the native SDK; tag values are strings. The server caps aliases at 10 per user and refuses the reserved label `external_id`; refusals are logged. With identity verification on, tag and alias writes need a valid auth hash. The native SDKs persist tags, aliases, custom events and receipts and retry them on their own. See [events.md](events.md) for event limits.

## Notifications and receipts

With Android Automatic delivery and on iOS, the native SDK renders notifications, posts the `received`, `confirmed` and `clicked` receipts, and raises the click and foreground events. The host forwards nothing.

```csharp
OpenPush.Notifications.Clicked += click => { /* click.Notification, click.ActionId, click.Url */ };
OpenPush.Notifications.ForegroundWillDisplay += n => { /* n.PreventDefault() */ };
```

Both events carry `Notification`, an `OpenPushNotification` (title, body, image, launch URL, TTL, priority, action buttons), while `Data` stays the raw map. A click also reports `ActionId` (the tapped action button) and `Url` (the message's deep link, used as received). A tap that launched the app is replayed to the first `Clicked` subscriber.

`ForegroundWillDisplay` is answered once all handlers return: the notification is displayed unless a handler called `PreventDefault()`. With no handler it is displayed at once. Android waits up to one second for the handlers and then displays.

`Notifications.ClearAll()` and `Notifications.Remove(messageId)` remove delivered notifications. `Notifications.SetBadgeCount(count)` sets the iOS app icon badge and is a no-op elsewhere.

### Android HostOwned forwarding

In `HostOwned` mode your own `FirebaseMessagingService` forwards to OpenPush:

```csharp
OpenPush.Register(token);                                          // every new FCM token
bool suppressed = OpenPush.Notifications.ProcessForeground(data);  // posts "received"
OpenPush.Notifications.ProcessDisplayed(data);                     // after your notify() call
OpenPush.Notifications.ProcessClick(data);                         // taps your service renders
```

`ProcessForeground` runs the `ForegroundWillDisplay` handlers on the calling thread (call it from the main thread), posts `received`, and returns `true` when a handler called `PreventDefault()`. `ProcessDisplayed` posts `confirmed`. `ProcessClick` posts `clicked` and raises `Clicked` once. Read the action buttons to render from `OpenPushNotification.From(data).Actions`. `OpenPush.MessageId(data)` reads `op_message_id` from an `IReadOnlyDictionary<string, string>`; stringify a numeric message id before building that dictionary.

## Subscription state

```csharp
var subscription = OpenPush.User.PushSubscription;   // live Id, Token, OptedIn
var state = subscription.Snapshot();

OpenPush.PushSubscription.OptOut();   // status -2: the server stops sending to this device
OpenPush.PushSubscription.OptIn();

OpenPush.PushSubscription.Changed += s => Debug.Log($"subscription changed: {s.OptedIn}");
OpenPush.PushSubscription.ChangedWithPrevious += (previous, current) =>
    Debug.Log($"{previous?.OptedIn} -> {current.OptedIn}");
```

`OptedIn` is true when the OS permission is granted, a provider token is present, and `OptOut()` was not called. `OptOut()` stops sends without deleting the subscription or the token; it survives a restart and is reversible.

`PushSubscriptionState.Status` is the native wire status: `1` subscribed, `-2` opted out, `0` no permission, `-18` never prompted, `-19` prompt unanswered. On iOS the permitted value is the authorization bitmask (any positive value is subscribed). Android reports only `-18`/`-19` and `null` otherwise; the Editor reports `null`.

## In-app messages

On Android and iOS the native SDK fetches in-app messages, evaluates triggers, persists dismissals, presents messages in a native web view over the game, and posts impression, click and page receipts. Prompt, tag and outcome actions inside a message are carried out by the native in-app module.

```csharp
OpenPush.InAppMessages.AddTrigger("level", 12);
OpenPush.InAppMessages.Paused = false;
OpenPush.InAppMessages.Lifecycle += (messageId, phase) => Debug.Log($"{messageId} {phase}");
OpenPush.InAppMessages.Clicked += (messageId, action) =>
    Debug.Log($"{messageId} {action.Kind} {action.ActionId}");
```

On both platforms `Clicked` reports `Kind` `url`, `custom` or `close` (a plain `click` when the action is none of these). `ActionId` is set only for `custom` clicks. Prompt, tag and outcome actions do not reach C# as their own kinds. See [in-app-messages.md](in-app-messages.md).

## Live Activities (iOS) and Live Updates (Android)

```csharp
// iOS — call setup on every launch, after Initialize
OpenPush.LiveActivities.SetupDefault(options);
OpenPush.LiveActivities.StartDefault(activityId, attributes, content);
OpenPush.LiveActivities.Enter(activityId, token);
OpenPush.LiveActivities.Exit(activityId);

// Android
var result = OpenPush.LiveUpdates.HandleAndroidPayload(data, collapseId);
if (result.IsHandled()) return;
```

With Automatic delivery the native FCM service renders Live Updates itself, including while the app is killed. In `HostOwned` mode your messaging service calls `ai.openpush.android.liveupdates.OpenPushLiveUpdates.handle(context, data, collapseKey)` from the native live-updates module to render while the app is killed. A force-stopped Android app cannot receive FCM until the user launches it again. Remote start, update and end happen through the REST API or the console — see [live-activities.md](live-activities.md).

## Credential storage

`DeviceCredentialStore` uses Android Keystore (AES-GCM) on Android and the Keychain on iOS. In the Unity Editor it is an in-memory, session-only store: a credential saved in play mode does not survive a domain reload or an Editor restart. Whenever secure storage is unavailable, saving fails closed: `Save` returns `false`, `Load` returns `null`, and credentials are never written as plaintext.

## Upgrade from 1.1.4

### Project changes

- Raise the project to Unity 2022.3 LTS.
- Android: set Minimum API Level 24 and Target API Level 34 or Automatic. The 1.1.4 library declared API 22, so **devices on API 22 and 23 are no longer supported**.
- Android: install EDM4U 1.2.189 and resolve (Step 2). After every package upgrade the resolved Gradle path contains the new `Library/PackageCache` hash, so let the forced resolve run, or press **Resolve**, before building.
- Android: choose a delivery mode. With `Automatic`, remove your own token forwarding and provide the Firebase configuration. Projects that keep their own Firebase messaging choose `HostOwned` and keep `Register(token)` plus the `Notifications.Process*` calls.
- Android: remove any OpenPush keep rules you added to your ProGuard file; the package ships its own.
- iOS: build with Xcode 26 or newer (Swift 6.3.3 or newer). The deployment target stays 16.0.
- iOS: **export into a clean folder** (or choose **Replace**) the first time. Appending into a 1.1.4 export keeps the old Live Activity widget target, which references a removed source file and does not link the new framework.

### Code changes

Removed (breaking):

- `OpenPush.Attach(...)` — use `OpenPush.Initialize`.
- The C# engine and its seams: `OpenPushClient`, `IOpenPushTransport`, `OpenPushRequest`, `OpenPushResponse`, `IOpenPushTokenCache`, `IOpenPushScheduler`, `OpenPushImmediateScheduler`, `IOpenPushPermissionBridge` and its helper interfaces (`IOpenPushNotificationTypesBridge`, `IOpenPushProvisionalPermissionBridge`, `IOpenPushPermissionResolutionSource`, `IOpenPushPermissionPromptStateBridge`), `IOpenPushIamPresenter`, `IOpenPushHtmlIamPresenter`, `OpenPushIamPresentationContext`, the engine class `OpenPushInAppMessages` (`OpenPush.InAppMessages` is unchanged), `InAppActionParser`, `InAppTriggerEngine`, `OpenPushInAppState`, `OpenPushPayload`, `OpenPushSessionStore`, `OpenPushSessionState`, `OpenPushEnvironment` and `UnityWebRequestTransport`.

Changed behaviour:

- Calls with side effects before `Initialize` throw `InvalidOperationException`; tags set before `Initialize` no longer ride the first registration.
- `OpenPush.SdkVersion` returns the native SDK version; the package version is `OpenPush.WrapperVersion`.
- In-app click events report only `url`, `custom` and `close` kinds; `ActionId` is set only for `custom` clicks.
- `OpenPush.Flush()` and `OpenPush.InAppMessages.ResetDisplayState()` are obsolete no-ops that only log.

Added:

- `OpenPush.OnIdentityVerificationRequired`, `OpenPush.WrapperVersion`, `PushSubscriptionState.Status`, `OpenPush.PushSubscription.ChangedWithPrevious`, `OpenPush.Notifications.SetBadgeCount` (iOS; a no-op elsewhere), `OpenPushLiveUpdateResult.Stale`.
- `OpenPushSettings` fields `AndroidPushDelivery` (`Automatic` | `HostOwned`), `AndroidFirebaseProjectId`, `AndroidFirebaseApplicationId`, `AndroidFirebaseApiKey` and `ReloginWithoutAuthHash`.

### State migration

The first launch of a 1.1.5 build moves the 1.1.x state kept in PlayerPrefs into the native SDK, once:

- The installation credential is reused — keep passing the same `DeviceCredentialStore` closure — so the server keeps the same subscription id and no duplicate device appears.
- Tags, aliases and an explicit opt-out are carried over. In Android `HostOwned` mode the stored provider token is registered again; Automatic delivery and iOS fetch a fresh one.
- The external id is logged back in without an auth hash when `OpenPushSettings.ReloginWithoutAuthHash` is on (the default). Apps that use identity verification turn it off: OpenPush then raises `OpenPush.OnIdentityVerificationRequired(externalId)`, leaves the device anonymous, and your handler calls `Login(externalId, authHash)` with a hash from your server.
- In-app dismissals are not migrated, so a dismissed message may show once more.

A failed migration is logged and retried on the next launch.

Migrating hosts that use the compatibility facade: see the compatibility migration guide in the package root.

## Limits

- No send surface, on any platform.
- No notification inbox and no consent gate.
- Git-URL installation only, from a private repository.
- Editor play mode runs a stub: no real push delivery and no persistent credential storage.

## Troubleshooting

| Symptom | Fix |
|---|---|
| Package Manager reports Git cannot be found | Install Git or add it to `PATH`, then restart Unity |
| `terminal prompts disabled` during resolve | Configure a credential helper or SSH agent, verify with `git ls-remote`, reopen Unity |
| `Initialize` refuses through `OnLog` | Create the settings asset inside a `Resources` folder with the SDK key, or pass `sdkKey` |
| `InvalidOperationException` from an OpenPush call | Call `Initialize` before any call with a side effect |
| Gradle cannot find `ai.openpush:openpush-android:1.1.5`, or points at an old package folder, after an upgrade | Press **Resolve** on the **Android dependencies resolved** setup row |
| `checkDebugAarMetadata` asks for `compileSdkVersion 34` | Set Target API Level to 34 or Automatic |
| `Sealed classes are not supported as program classes` during the Gradle build | The vendored natives are stale; update the package |
| No FCM token in Automatic mode | Add `google-services.json` or the three Firebase fields; the **Firebase configuration** row and the native log say which is missing |
| Device registered but nothing arrives on Android | Upload the FCM service account under Android — see [platform-setup-fcm.md](platform-setup-fcm.md) |
| Xcode cannot read the OpenPush Swift interfaces | Build with Xcode 26 or newer (Swift 6.3.3 or newer) |
| Old Live Activity widget fails to build after upgrading | Export into a clean folder, or choose **Replace** |
| Manual signing fails on the App Group | Add the App Group (`group.<bundle id>.openpush` or your `AppGroupIdentifier`) to the App ID |
| Identity reported anonymous | Identity verification is on and the auth hash is missing or wrong: compute the HMAC on your server and pass it to `Login` |

## FAQ

**Does this package fetch the push token for me?**
Yes, by default. Android fetches the FCM token through the native FCM module (Automatic delivery), and iOS forwards the APNs token Unity receives. Only Android `HostOwned` mode calls `Register(token)`.

**Can I use it with Firebase Unity Messaging?**
Yes. Set `AndroidPushDelivery` to `HostOwned`: OpenPush removes its own messaging service so yours is the only one, and you forward tokens and payloads to it.

**Why does `SdkVersion` differ from the package version?**
`SdkVersion` is the native SDK version; `WrapperVersion` is the Unity package version.

## Related

- [quickstart.md](quickstart.md)
- [sdk-android.md](sdk-android.md)
- [sdk-ios.md](sdk-ios.md)
- [platform-setup-fcm.md](platform-setup-fcm.md)
- [platform-setup-apns.md](platform-setup-apns.md)
- [users-and-subscriptions.md](users-and-subscriptions.md)
- [live-activities.md](live-activities.md)
- [events.md](events.md)
- [in-app-messages.md](in-app-messages.md)
- [../api-handbook/03-subscriptions-users.md](../api-handbook/03-subscriptions-users.md)
- [../api-handbook/10-in-app-messages.md](../api-handbook/10-in-app-messages.md)
