Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions references/api-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<LoginData>`; 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

“Android native ≥ 5.11.0 only (not on iOS or any wrapper SDK yet)” is right today, but it’s a fact with a short shelf life sitting in the file the skill treats as the verified source of truth. Worth a note on where this gets updated when iOS or a wrapper ships it — otherwise the agent will keep telling people it’s Android-only after it isn’t.

- `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).
Expand Down
28 changes: 23 additions & 5 deletions skills/setup/android.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<LoginData>`, 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`)
Expand Down
83 changes: 83 additions & 0 deletions skills/setup/assets/android/OneSignalManagerComposite.kt.tmpl
Original file line number Diff line number Diff line change
@@ -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<String, String> = emptyMap(),
emailSubscribed: Boolean? = null,
smsSubscribed: Boolean? = null,
): OneSignalResult<LoginData> {
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}")

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this drops everything after the first error. If the backend returns more than one — say a malformed E.164 and a rejected alias label — the generated app logs one and silently discards the rest, which is the worst case for someone debugging their own integration. A joinToString over result.error would cost nothing. Not blocking.

}
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)
}
Loading