diff --git a/references/api-reference.md b/references/api-reference.md index 2616eac..49e70a8 100644 --- a/references/api-reference.md +++ b/references/api-reference.md @@ -87,6 +87,9 @@ Skills must present these as dashboard steps (deep-link the user), never as curl ## SDK data surface (verified signatures — the codegen targets) - `OneSignal.login(externalId[, jwtToken])` / `logout()` — sets/clears external_id. **Call login BEFORE tags/email/sms** or data strands on the anonymous user. +- `OneSignal.login(externalId, profile[, jwtToken])`: **Android native ≥ 5.11.0 only** (not on iOS or any wrapper SDK yet). Kotlin `suspend fun` returning `OneSignalResult`; from Java pass `Continue.with` (needs API 24+, callback is off the main thread). Sends email, SMS, tags, and aliases inside the login request, so the ordering rule above does not apply to anything in the profile. Build with `OneSignalUserProfile.builder()`: `setEmail`, `setSms` (**E.164 with leading `+`**), `setEmailEnabled`, `setSmsEnabled`, `setTags`, `setAliases`, `build()`. Reserved alias labels (`external_id`, `onesignal_id`) are skipped with a warning, not an error. Prefer this over `login` + `addEmail`/`addSms` whenever the version allows and the contact details are known at sign-in. +- `setEmailEnabled(bool)` / `setSmsEnabled(bool)`: subscription status for the address in the same profile. `true` subscribes (resubscribes an opted-out address), `false` unsubscribes, **unset sends no status** (new address is subscribed, existing address keeps its status). Ignored without the matching `setEmail`/`setSms`, and cannot be returned to unset. This is the **only** SDK way to set an email/SMS status: `addEmail`/`addSms` always send subscribed. +- Composite login failure codes: `INVALID_ARGUMENT` (nothing sent, user unchanged), `NOT_INITIALIZED`, `BACKEND_ERROR` (`httpStatus` + `backendCode`), `UNKNOWN`. On `BACKEND_ERROR`/`UNKNOWN` the device is **already on the new external_id**. - `User.addAlias(label, id)` / `addAliases({...})` — ≤10 aliases/user, label+id ≤128 chars. - `User.addTag(key, value)` / `addTags({...})` — **string values only** on every platform. - `User.addEmail(email)` / `addSms(e164)` — creates channel subscriptions (consent-gated in YOUR code). diff --git a/skills/setup/android.md b/skills/setup/android.md index 86c04ab..5d537f4 100644 --- a/skills/setup/android.md +++ b/skills/setup/android.md @@ -77,13 +77,18 @@ If an `android:name` Application class already exists, add the init call to its ## Centralized wrapper (Kotlin, no-DI default) -Signatures verified against api-reference "SDK data surface". Tag values are strings only. `login()` before tags/email/sms (ordering rule). +Signatures verified against api-reference "SDK data surface". Tag values are strings only. -Start from the bundled templates rather than retyping — they carry the verified signatures and the `onesignal:managed` marker: -- Wrapper: [assets/android/OneSignalManager.kt.tmpl](assets/android/OneSignalManager.kt.tmpl) — substitute `__PACKAGE__`. -- Application subclass: [assets/android/Application.kt.tmpl](assets/android/Application.kt.tmpl) — substitute `__PACKAGE__`, `__APP_CLASS__`, and `__APP_ID__` (the real App ID from Step 2). +**Pick the wrapper by the version Step 4 resolved.** Composite `login(externalId, profile)` does not exist before **5.11.0**, and a wrapper that calls it against an older pin does not compile. Read the version from the resolver's own output; do not assume, and do not change the track to get the newer API (that is the user's call, not yours). -The wrapper's shape: +| Resolved version | Wrapper template | Identity surface | +| --- | --- | --- | +| **≥ 5.11.0** | [assets/android/OneSignalManagerComposite.kt.tmpl](assets/android/OneSignalManagerComposite.kt.tmpl) | `login(externalId, profile)`, preferred | +| **< 5.11.0** | [assets/android/OneSignalManager.kt.tmpl](assets/android/OneSignalManager.kt.tmpl) | `login(externalId)` + `addEmail`/`addSms` | + +The default `--track stable` has historically trailed 5.11.0 (it was 5.9.2 as of May 2026), so expect the second row unless the user asked for `--track current` or stable has since moved. Both templates carry the verified signatures and the `onesignal:managed` marker; substitute `__PACKAGE__` in either. The Application subclass is the same for both: [assets/android/Application.kt.tmpl](assets/android/Application.kt.tmpl). Substitute `__PACKAGE__`, `__APP_CLASS__`, and `__APP_ID__` (the real App ID from Step 2). + +The pre-5.11.0 wrapper's shape, where `login()` must come before tags/email/sms or that data strands on the anonymous user: ```kotlin object OneSignalManager { // onesignal:managed v1 private var initialized = false @@ -100,6 +105,19 @@ object OneSignalManager { // onesignal:managed v1 fun setTag(key: String, value: String) = OneSignal.User.addTag(key, value) } ``` + +### On 5.11.0+, prefer composite login + +`login(externalId, profile)` sends the external ID with the user's email, SMS, tags, and aliases as one Create User request and returns `OneSignalResult`, so a failure is visible instead of silent. It also removes the ordering rule for everything in the profile, because the profile ships inside the login request. The granular methods still need `login()` first. + +Facts that decide whether the generated code is correct: +- It is a `suspend fun`. From Kotlin call it in a coroutine; from Java pass `Continue.with` (API 24+, and the callback is not on the main thread). The suspend-ness is why the composite wrapper exposes a `suspend fun` rather than the plain `fun` the older wrapper uses. +- `setSms()` needs **E.164 with the leading `+`**. A malformed number returns `INVALID_ARGUMENT` and sends nothing. A number missing only the `+` passes the SDK check and is then rejected by the backend with HTTP 400. +- `setEmailEnabled`/`setSmsEnabled` set that address's subscription status: `true` subscribes, `false` unsubscribes, **unset sends no status**. Only set one when the app actually knows the user's intent. Guessing `true` on an address the user never consented to is a re-subscribe. This is the only SDK path to an email/SMS status, since `addEmail`/`addSms` always send subscribed. +- Do not feed it an address the user typed unverified: an address already owned by another user moves to whoever logs in. Note this to the user if you wire the profile to a text field. + +**The wrapper adds no coroutines dependency.** A `suspend fun` is backed by `kotlin.coroutines.Continuation` in **kotlin-stdlib**, not by `kotlinx-coroutines`, so declaring one and awaiting another suspend function needs nothing extra. Verified by compiling the composite template with zero coroutines jars on the classpath. Do not add `kotlinx-coroutines-android` on this file's account, because an unrequested dependency bump is a safety-contract §7 change. The verification helper is different: it calls `launch`, so it does need the dependency, and that is what `android_coroutines_on_classpath` is checking. The call site needs a scope (`lifecycleScope`, `viewModelScope`), which `activity-ktx` and `lifecycle-ktx` already supply on any modern template. + A Hilt `@Singleton` variant and a Java variant are in the upstream android/integrate.md; use them only to match an existing pattern. No direct OneSignal calls outside this wrapper (except the verification helper). ## Debug-only verification helper (`OneSignalSetupVerification.kt`) diff --git a/skills/setup/assets/android/OneSignalManagerComposite.kt.tmpl b/skills/setup/assets/android/OneSignalManagerComposite.kt.tmpl new file mode 100644 index 0000000..1b242e8 --- /dev/null +++ b/skills/setup/assets/android/OneSignalManagerComposite.kt.tmpl @@ -0,0 +1,83 @@ +// onesignal:managed v1 +// Centralized OneSignal wrapper: the ONLY place OneSignal SDK calls live +// (except the debug-only verification helper). Place in your app's package and +// set the package line to match. +// +// Placeholder: __PACKAGE__ (this file's package / module namespace). +// +// Use this variant ONLY when the pinned SDK is 5.11.0 or newer. Composite +// login(externalId, profile) does not exist before 5.11.0; against an older pin +// this file does not compile. Use OneSignalManager.kt.tmpl instead. +// +// `login` is a suspend fun, which needs only kotlin-stdlib, so this file adds no +// kotlinx-coroutines requirement. Verified by compiling it with zero coroutines +// jars on the classpath. The CALL SITE needs a scope (lifecycleScope / +// viewModelScope), which activity-ktx and lifecycle-ktx already provide. +package __PACKAGE__ + +import android.content.Context +import android.util.Log +import com.onesignal.LoginData +import com.onesignal.OneSignal +import com.onesignal.OneSignalResult +import com.onesignal.OneSignalUserProfile +import com.onesignal.debug.LogLevel + +object OneSignalManager { + private const val TAG = "OneSignalManager" + + private var initialized = false + + fun initialize(context: Context, appId: String) { + if (initialized) return + OneSignal.Debug.logLevel = LogLevel.VERBOSE // remove for production + OneSignal.initWithContext(context.applicationContext, appId) + initialized = true + } + + /** + * Identifies the user and sets email, SMS, and tags in one request. + * + * Pass only what the app actually knows. A null [email] or [smsE164] is + * omitted, and a null [emailSubscribed] / [smsSubscribed] sends no status, + * which leaves an address that already exists as it is. Only pass `true` + * where the user consented to that channel: it resubscribes an address that + * previously opted out. + * + * [smsE164] must be E.164 with the leading '+', e.g. "+15551234567". + */ + suspend fun login( + externalId: String, + email: String? = null, + smsE164: String? = null, + tags: Map = emptyMap(), + emailSubscribed: Boolean? = null, + smsSubscribed: Boolean? = null, + ): OneSignalResult { + val profile = OneSignalUserProfile.builder() + .setEmail(email) + .setSms(smsE164) + .setTags(tags) + .apply { + emailSubscribed?.let { setEmailEnabled(it) } + smsSubscribed?.let { setSmsEnabled(it) } + } + .build() + + val result = OneSignal.login(externalId, profile) + if (!result.isSuccess) { + // The device is on the new external ID regardless, so report and move on. + Log.w(TAG, "login failed: ${result.error?.first?.code} ${result.error?.first?.message}") + } + return result + } + + fun logout() = OneSignal.logout() + + /** For an address the user supplies after sign-in. Always sends it subscribed. */ + fun setEmail(email: String) = OneSignal.User.addEmail(email) + + fun setSmsNumber(number: String) = OneSignal.User.addSms(number) + + fun setTag(key: String, value: String) = OneSignal.User.addTag(key, value) +}