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
28 changes: 20 additions & 8 deletions examples/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,17 +159,19 @@ The demo is 100% Jetpack Compose (no XML layouts). Dependencies are pulled via t
The Android demo **overrides the shared guide's "no repository wrapper" rule**. Instead it uses:

- `MainApplication.kt` — initializes the SDK before any UI renders, restores cached consent + IAM-paused + location-shared state, and registers `IInAppMessageLifecycleListener`, `IInAppMessageClickListener`, `INotificationClickListener`, and `INotificationLifecycleListener` (with `event.preventDefault()` so async display can be exercised).
- `MainViewModel : AndroidViewModel` — central state with `LiveData<T>` for every UI value. Implements `IPushSubscriptionObserver`, `IPermissionObserver`, `IUserStateObserver`, and `IUserJwtInvalidatedListener`. Holds a monotonic `private var fetchRequestSequence = 0L` that maps to the shared guide's `requestSequence` for stale-result protection in `fetchUserDataFromApi`.
- `MainViewModel : AndroidViewModel` — central state with `LiveData<T>` for every UI value. Implements `IPushSubscriptionObserver`, `IPermissionObserver`, and `IUserStateObserver`. The JWT invalidated listener lives in `MainApplication`. Holds a monotonic `private var fetchRequestSequence = 0L` that maps to the shared guide's `requestSequence` for stale-result protection in `fetchUserDataFromApi`.
- `OneSignalRepository.kt` — only some methods are `suspend` + `withContext(Dispatchers.IO)`; many are synchronous wrappers and the ViewModel wraps calls in `viewModelScope.launch(Dispatchers.IO)` itself.
- `OneSignalService.kt` (`object`) — REST API client described in the shared guide's Prompt 1.4.
- `SharedPreferenceUtil.kt` — backs the shared guide's PreferencesService (consent required, privacy consent, external user id, location shared, IAM paused, cached JWT token, cached identity-verification toggle, notification service extension switches).
- `SharedPreferenceUtil.kt` — backs the shared guide's PreferencesService (consent required, privacy consent, external user id, location shared, IAM paused, per-user JWT tokens, cached identity-verification toggle, notification service extension switches).

`fetchUserDataFromApi` loading sequence: the sequence is incremented first, then early returns may set `_isLoading = false` before `_isLoading = true` is set (see `MainViewModel.kt` lines ~167–192). Stale-fetch guards themselves are correct -- results are dropped when `requestId != fetchRequestSequence`, and the same guard wraps the catch branch and the final `_isLoading.value = false`.
`fetchUserDataFromApi` loading sequence: the sequence is incremented first, then early returns may set `_isLoading = false` before `_isLoading = true` is set (see `fetchUserDataFromApi` in `MainViewModel.kt`). Stale-fetch guards themselves are correct -- results are dropped when `requestId != fetchRequestSequence`, and the same guard wraps the catch branch and the final `_isLoading.value = false`. A user switch or logout also bumps the sequence to drop a fetch still in flight for the previous user. Both paths set `_isLoading = false` themselves, since the dropped fetch won't.

#### `MainApplication` observer

Both `MainApplication` and `MainViewModel` register `IUserStateObserver`. The Application's observer only logs (no UI side effects) and exists so user-state changes are captured even before the first `MainActivity` is created.

`MainApplication` also registers the `IUserJwtInvalidatedListener` and hands each event to `JwtAskStore`. See the Identity Verification controls under Android Additions.

---

## Android-Specific UI Details
Expand All @@ -186,7 +188,7 @@ Inline only — no full-screen overlay. The four list sections (Aliases, Emails,

- `SnackbarController` (`ui/components/SnackbarController.kt`) wraps Compose's `SnackbarHostState` and exposes a `show(message)` method with replace-on-show behavior.
- `MainScreen` creates `val snackbarController = rememberSnackbarController(snackbarHostState)` and exposes it down the tree via `CompositionLocalProvider(LocalSnackbarController provides snackbarController) { ... }` wrapping the sections column. The host is mounted on `Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) })`. The `Snackbar` applies `Modifier.testTag("snackbar_toast")` so the shared Appium suite finds it.
- Section composables read the controller with `val snackbar = LocalSnackbarController.current` and call `snackbar.show(...)` from action callbacks. Only Outcomes, Custom Events, and Location check trigger the snackbar.
- Section composables read the controller with `val snackbar = LocalSnackbarController.current` and call `snackbar.show(...)` from action callbacks. Only Outcomes, Custom Events, and Location check trigger the snackbar from a callback. The User section also shows one from a `LaunchedEffect` when the ViewModel's `jwtAskExternalId` turns non-null. The ViewModel only exposes the id, and the UI writes the message.
- Most other actions write to `android.util.Log.i(TAG, ...)` in `MainViewModel` (add/remove aliases/tags/emails/SMS/triggers, toggles, send push/IAM, etc.). Login and logout are intentionally silent — no snackbar and no `Log.i`. The User section reflects auth state via `externalUserId` LiveData ("Logged In" / "Anonymous"), matching the other wrapper demos.
- Replace-on-show: `show(...)` cancels any in-flight `showJob` and `dismissJob`, dismisses `hostState.currentSnackbarData`, then launches a new pair where the dismiss coroutine runs `delay(DemoLayout.toastDurationMs)` and the show coroutine calls `hostState.showSnackbar(message, duration = SnackbarDuration.Indefinite)`.
- Duration is the shared constant `DemoLayout.toastDurationMs = 3_000L` (milliseconds).
Expand Down Expand Up @@ -240,10 +242,20 @@ Pass the plain class name as the tag; `DemoLog` adds the prefix, so `[Demo]` is

The Android demo exercises a few SDK features that are not described in the shared guide today. They are wired into the User section so the shared Appium contract stays stable:

- **Identity Verification toggle** (`UserSection`, `testTag = "identity_verification_toggle"`) — persisted via `SharedPreferenceUtil.cacheIdentityVerification(...)`. When ON, `fetchUserDataFromApi` uses the cached external_id + JWT; when OFF it falls back to the onesignal_id endpoint.
- **JWT field in `LoginDialog`** — optional `"JWT Token (optional)"` input under the external-user-id field. `testTag = "login_user_jwt_input"`. `MainViewModel.loginUser(externalUserId, jwtToken)` threads the token into `OneSignal.login(externalUserId, jwtToken)` and caches it via `SharedPreferenceUtil.cacheJwtToken(...)`.
- **UPDATE USER JWT button** (`UserSection`, `testTag = "update_user_jwt_button"`) — opens a `PairInputDialog` (External User Id + JWT Token) and calls `viewModel.updateUserJwt(...)` → `OneSignal.updateUserJwt(...)`.
- **`IUserJwtInvalidatedListener`** — registered by `MainViewModel`; surfaces a log entry via `Log.w(TAG, ...)` when the SDK reports an invalidated JWT. Per Prompt 7.6 the snackbar is no longer fired from this listener.
These match the iOS demo's User section in labels, test ids, and behavior. Change both demos together.

- **Fetch by external_id (JWT) toggle** (`testTag = "identity_verification_toggle"`). `SharedPreferenceUtil.cacheIdentityVerification(...)` persists it. When ON, `fetchUserDataFromApi` fetches by the current external_id with that user's stored JWT as bearer, or unsigned when there is none. That includes the fetch at launch, even before the user has a onesignal_id. When OFF it fetches by onesignal_id, unsigned, which an app with Identity Verification on rejects with a 401. Flipping it refetches. It only drives the demo's own REST GET and does not turn Identity Verification on for the app.
- **REST fetch row** (`testTag = "user_fetch_status_value"`). Reads `—`, `OK`, or the failure reason, for example `external_id fetch failed: HTTP 401`, so a rejected fetch under Identity Verification doesn't look like an empty user. A failed fetch doesn't change the lists. A user switch, a logout, or a fetch skipped for lack of an id resets the row to `—`. A switch or logout also drops any fetch still in flight for the previous user.
- **Stored tokens.** `SharedPreferenceUtil` keeps every JWT the tester hands the SDK, keyed by external id, as JSON under `JWT_TOKENS_PREF`. A login with a token or an `updateUserJwt` stores it for that id, whoever it is for. A login with the field blank changes nothing, and neither does an invalidation event. Logout clears them all. Plaintext SharedPreferences is fine for a demo and not a pattern for real apps.
- **JWT field in `LoginDialog`** (`testTag = "login_user_jwt_input"`). Optional. `MainViewModel.loginUser(externalUserId, jwtToken)` passes it to `OneSignal.login(externalUserId, jwtToken)`. A same-user login refetches, because the SDK fires no user-state event for it.
- **UPDATE USER JWT button** (`testTag = "update_user_jwt_button"`). Always enabled. Opens `UpdateJwtDialog`, which stacks the External User Id and JWT Token fields full width, prefills the id from the pending ask and then the current user, and confirms with `Update`. Calls `OneSignal.updateUserJwt(...)` and refetches when the id is the current user's.
- **JWT ask banner** (`testTag = "jwt_ask_banner"`, with `jwt_ask_external_id_value` and `jwt_ask_provide_button`). `MainApplication` registers the `IUserJwtInvalidatedListener` right after init, because the SDK doesn't replay the event and `MainViewModel` dies with `MainActivity`. The listener writes the asked id to `JwtAskStore`. It leaves the stored token alone, because an SDK that asks for a token when requests park without one fires the same event, so the event doesn't prove the token is bad. A bad token shows up as a 401 in the REST fetch row. `MainViewModel.jwtAskExternalId` exposes it, and the User section shows the banner plus a snackbar once per ask. A token for that id, from `updateUserJwt` or a login, clears the ask, and so does logout. The demo never answers the ask with a stored token on its own. PROVIDE JWT is always enabled.

Some Identity Verification behavior still differs between the demos because the SDKs differ:

- A login without a token parks that user's requests. The iOS SDK asks for a token, so its banner shows. The Android SDK fires nothing until SDK-5476.
- After a relaunch with a token still owed, the iOS SDK asks again on the first parked request. The Android SDK doesn't, until SDK-5476.
- If a 401 for an old token lands after PROVIDE JWT, the Android SDK deletes the new token and asks again until SDK-5474. The iOS SDK keeps the new token.

### Notification service extension

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import com.onesignal.notifications.INotificationWillDisplayEvent
import com.onesignal.example.BuildConfig
import com.onesignal.example.data.network.OneSignalService
import com.onesignal.example.util.DemoLog
import com.onesignal.example.util.JwtAskStore
import com.onesignal.example.util.SharedPreferenceUtil
import com.onesignal.example.util.TooltipHelper

Expand Down Expand Up @@ -128,6 +129,13 @@ class MainApplication : MultiDexApplication() {
}
})

// Here rather than in MainViewModel, which dies with MainActivity. The SDK doesn't
// replay this event, so a listener added later misses a 401 at launch.
OneSignal.addUserJwtInvalidatedListener { event ->
DemoLog.w("JWT invalidated for externalId: ${event.externalId}")
JwtAskStore.onInvalidated(event.externalId)
}

// Restore cached states
OneSignal.InAppMessages.paused = SharedPreferenceUtil.getCachedInAppMessagingPausedStatus(this)
OneSignal.Location.isShared = SharedPreferenceUtil.getCachedLocationSharedStatus(this)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -205,22 +205,22 @@ object OneSignalService {
}

/**
* Fetch user data from OneSignal API.
* Note: This endpoint does not require authentication.
* Fetch user data from OneSignal API. Sends [jwt] as the bearer token when it is not null.
*
* @param aliasLabel The alias type to look up by (e.g. "onesignal_id" or "external_id")
* @param aliasValue The alias value
* @return UserData object containing aliases, tags, emails, and SMS numbers, or null on error
* @return [UserFetchResult.Success] with aliases, tags, emails, and SMS numbers, or
* [UserFetchResult.Failure] with the reason
*/
suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserData? = withContext(Dispatchers.IO) {
suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserFetchResult = withContext(Dispatchers.IO) {
if (aliasValue.isEmpty()) {
DemoLog.w("Cannot fetch user - aliasValue is empty")
return@withContext null
return@withContext UserFetchResult.Failure("bad request")
}

if (appId.isEmpty()) {
DemoLog.w("Cannot fetch user - appId not set")
return@withContext null
return@withContext UserFetchResult.Failure("app ID not set")
}

try {
Expand Down Expand Up @@ -251,21 +251,21 @@ object OneSignalService {
try {
val userData = parseUserResponse(response)
DemoLog.d("Parsed user data: aliases=${userData.aliases.size}, tags=${userData.tags.size}, emails=${userData.emails.size}, sms=${userData.smsNumbers.size}")
return@withContext userData
return@withContext UserFetchResult.Success(userData)
} catch (e: Exception) {
DemoLog.e("Error parsing user response", e)
return@withContext null
return@withContext UserFetchResult.Failure("unreadable body")
}
} else {
val errorResponse = connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "Unknown error"
DemoLog.e("Failed to fetch user (HTTP $responseCode): $errorResponse")
return@withContext null
return@withContext UserFetchResult.Failure("HTTP $responseCode")
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
DemoLog.e("Error fetching user", e)
return@withContext null
return@withContext UserFetchResult.Failure(e.message ?: e.javaClass.simpleName)
}
}

Expand Down Expand Up @@ -328,6 +328,15 @@ object OneSignalService {
}
}

/**
* Outcome of a demo /users fetch. The User section shows the failure reason so a 401 under
* Identity Verification doesn't look like an empty user.
*/
sealed class UserFetchResult {
data class Success(val userData: UserData) : UserFetchResult()
data class Failure(val reason: String) : UserFetchResult()
}

/**
* Data class representing user data fetched from the OneSignal API.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import com.onesignal.OneSignal
import com.onesignal.example.data.model.CompositeLoginExample
import com.onesignal.example.data.model.NotificationType
import com.onesignal.example.data.network.OneSignalService
import com.onesignal.example.data.network.UserData
import com.onesignal.example.data.network.UserFetchResult
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import com.onesignal.example.util.DemoLog
Expand Down Expand Up @@ -265,7 +265,7 @@ class OneSignalRepository {
}

// Fetch user data from API
suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserData? = withContext(Dispatchers.IO) {
suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserFetchResult = withContext(Dispatchers.IO) {
DemoLog.d("Fetching user data by $aliasLabel: $aliasValue")
OneSignalService.fetchUser(aliasLabel, aliasValue, jwt)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,72 @@ private fun String.toOptionalBoolean(): Boolean? = trim().lowercase().toBooleanS
/** Text that is neither blank nor a boolean, so it would silently read as "no status". */
private fun String.isStatusTypo(): Boolean = isNotBlank() && toOptionalBoolean() == null

/**
* Update User JWT dialog. Fields are stacked full width like [LoginDialog] because a JWT runs
* to several hundred characters. [initialExternalId] prefills the id and stays editable.
*/
@Composable
fun UpdateJwtDialog(
initialExternalId: String,
onDismiss: () -> Unit,
onConfirm: (String, String) -> Unit
) {
var externalId by remember { mutableStateOf(initialExternalId) }
var jwtToken by remember { mutableStateOf("") }

AlertDialog(
onDismissRequest = onDismiss,
modifier = Modifier.fillMaxWidth().padding(horizontal = DemoLayout.pagePadding).exposeTestTagsAsResourceId(),
properties = DialogProperties(usePlatformDefaultWidth = false),
title = {
DialogTitle("Update User JWT")
},
text = {
Column {
OutlinedTextField(
value = externalId,
onValueChange = { externalId = it },
label = { Text("External User Id") },
modifier = Modifier
.fillMaxWidth()
.testTag("update_jwt_external_id_input"),
singleLine = true,
shape = TextFieldShape,
colors = dialogTextFieldColors()
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = jwtToken,
onValueChange = { jwtToken = it },
label = { Text("JWT Token") },
modifier = Modifier
.fillMaxWidth()
.testTag("update_jwt_token_input"),
singleLine = true,
shape = TextFieldShape,
colors = dialogTextFieldColors()
)
}
},
confirmButton = {
TextButton(
onClick = { onConfirm(externalId, jwtToken) },
enabled = externalId.isNotBlank() && jwtToken.isNotBlank(),
modifier = Modifier.testTag("singlepair_confirm_button"),
colors = dialogTextButtonColors(),
) {
DialogActionLabel("Update")
}
},
dismissButton = {
TextButton(onClick = onDismiss, colors = dialogTextButtonColors()) {
DialogActionLabel("Cancel")
}
},
shape = DialogShape
)
}

/**
* Dialog for outcome selection and input.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ fun MainScreen(viewModel: MainViewModel) {
val externalUserId by viewModel.externalUserId.observeAsState()
val language by viewModel.language.observeAsState("")
val useIdentityVerification by viewModel.useIdentityVerification.observeAsState(false)
val userFetchStatus by viewModel.userFetchStatus.observeAsState()
val jwtAskExternalId by viewModel.jwtAskExternalId.observeAsState()
val aliases by viewModel.aliases.observeAsState(emptyList())
val emails by viewModel.emails.observeAsState(emptyList())
val smsNumbers by viewModel.smsNumbers.observeAsState(emptyList())
Expand Down Expand Up @@ -148,6 +150,8 @@ fun MainScreen(viewModel: MainViewModel) {
externalUserId = externalUserId,
language = language,
useIdentityVerification = useIdentityVerification,
userFetchStatus = userFetchStatus,
jwtAskExternalId = jwtAskExternalId,
onUseIdentityVerificationChange = { viewModel.setUseIdentityVerification(it) },
onLanguageChange = { viewModel.setLanguage(it) },
onLogin = { userId, jwt -> viewModel.loginUser(userId, jwt) },
Expand Down
Loading
Loading