Skip to content

Commit 9afbe51

Browse files
committed
chore: [SDK-5283] give the demo the same JWT behavior as the iOS demo
The demo's Identity Verification controls now follow one contract shared with the iOS demo. - onUserJwtInvalidated only logged. MainApplication now registers the listener right after init, since the SDK doesn't replay the event and MainViewModel dies with MainActivity. The listener shows a banner with the asked id, a PROVIDE JWT button, and a snackbar once per ask. It leaves the stored token alone, since an SDK that asks when requests park without a token fires the same event. - The demo kept one token, so an invalidation for another user or an A to B to A switch lost the right one. It now keeps a token per external id. A login with a token or updateUserJwt stores it, and logout clears them all. - A failed /users fetch was invisible. A REST fetch row now reads OK or the reason, for example "external_id fetch failed: HTTP 401". A user switch, logout, or skipped fetch resets it, and a switch or logout drops any fetch still in flight for the previous user. - The toggle read "Identity Verification" but only picks the alias for the demo's own REST fetch. It now reads "Fetch by external_id (JWT)" and refetches when flipped. The launch fetch no longer needs a onesignal_id. - Update User JWT opens a dialog with the fields stacked full width, prefilled from the pending ask, confirming with "Update". UPDATE USER JWT and PROVIDE JWT are always enabled.
1 parent 1ad6f36 commit 9afbe51

10 files changed

Lines changed: 357 additions & 84 deletions

File tree

‎examples/build.md‎

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -119,17 +119,19 @@ The demo is 100% Jetpack Compose (no XML layouts). Dependencies are pulled via t
119119
The Android demo **overrides the shared guide's "no repository wrapper" rule**. Instead it uses:
120120

121121
- `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).
122-
- `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`.
122+
- `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`.
123123
- `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.
124124
- `OneSignalService.kt` (`object`) — REST API client described in the shared guide's Prompt 1.4.
125-
- `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).
125+
- `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).
126126

127-
`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`.
127+
`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.
128128

129129
#### `MainApplication` observer
130130

131131
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.
132132

133+
`MainApplication` also registers the `IUserJwtInvalidatedListener` and hands each event to `JwtAskStore`. See the Identity Verification controls under Android Additions.
134+
133135
---
134136

135137
## Android-Specific UI Details
@@ -146,7 +148,7 @@ Inline only — no full-screen overlay. The four list sections (Aliases, Emails,
146148

147149
- `SnackbarController` (`ui/components/SnackbarController.kt`) wraps Compose's `SnackbarHostState` and exposes a `show(message)` method with replace-on-show behavior.
148150
- `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.
149-
- 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.
151+
- 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.
150152
- 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.
151153
- 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)`.
152154
- Duration is the shared constant `DemoLayout.toastDurationMs = 3_000L` (milliseconds).
@@ -200,10 +202,20 @@ Pass the plain class name as the tag; `DemoLog` adds the prefix, so `[Demo]` is
200202

201203
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:
202204

203-
- **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.
204-
- **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(...)`.
205-
- **UPDATE USER JWT button** (`UserSection`, `testTag = "update_user_jwt_button"`) — opens a `PairInputDialog` (External User Id + JWT Token) and calls `viewModel.updateUserJwt(...)` → `OneSignal.updateUserJwt(...)`.
206-
- **`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.
205+
These match the iOS demo's User section in labels, test ids, and behavior. Change both demos together.
206+
207+
- **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.
208+
- **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.
209+
- **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.
210+
- **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.
211+
- **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.
212+
- **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.
213+
214+
Some Identity Verification behavior still differs between the demos because the SDKs differ:
215+
216+
- 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.
217+
- 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.
218+
- 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.
207219

208220
### Notification service extension
209221

‎examples/demo/app/src/main/java/com/onesignal/example/application/MainApplication.kt‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import com.onesignal.notifications.INotificationWillDisplayEvent
2020
import com.onesignal.example.BuildConfig
2121
import com.onesignal.example.data.network.OneSignalService
2222
import com.onesignal.example.util.DemoLog
23+
import com.onesignal.example.util.JwtAskStore
2324
import com.onesignal.example.util.SharedPreferenceUtil
2425
import com.onesignal.example.util.TooltipHelper
2526
import com.onesignal.user.state.IUserStateObserver
@@ -136,6 +137,13 @@ class MainApplication : MultiDexApplication() {
136137
}
137138
})
138139

140+
// Here rather than in MainViewModel, which dies with MainActivity. The SDK doesn't
141+
// replay this event, so a listener added later misses a 401 at launch.
142+
OneSignal.addUserJwtInvalidatedListener { event ->
143+
DemoLog.w("JWT invalidated for externalId: ${event.externalId}")
144+
JwtAskStore.onInvalidated(event.externalId)
145+
}
146+
139147
// Restore cached states
140148
OneSignal.InAppMessages.paused = SharedPreferenceUtil.getCachedInAppMessagingPausedStatus(this)
141149
OneSignal.Location.isShared = SharedPreferenceUtil.getCachedLocationSharedStatus(this)

‎examples/demo/app/src/main/java/com/onesignal/example/data/network/OneSignalService.kt‎

Lines changed: 19 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -205,22 +205,22 @@ object OneSignalService {
205205
}
206206

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

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

226226
try {
@@ -251,21 +251,21 @@ object OneSignalService {
251251
try {
252252
val userData = parseUserResponse(response)
253253
DemoLog.d("Parsed user data: aliases=${userData.aliases.size}, tags=${userData.tags.size}, emails=${userData.emails.size}, sms=${userData.smsNumbers.size}")
254-
return@withContext userData
254+
return@withContext UserFetchResult.Success(userData)
255255
} catch (e: Exception) {
256256
DemoLog.e("Error parsing user response", e)
257-
return@withContext null
257+
return@withContext UserFetchResult.Failure("unreadable body")
258258
}
259259
} else {
260260
val errorResponse = connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "Unknown error"
261261
DemoLog.e("Failed to fetch user (HTTP $responseCode): $errorResponse")
262-
return@withContext null
262+
return@withContext UserFetchResult.Failure("HTTP $responseCode")
263263
}
264264
} catch (e: CancellationException) {
265265
throw e
266266
} catch (e: Exception) {
267267
DemoLog.e("Error fetching user", e)
268-
return@withContext null
268+
return@withContext UserFetchResult.Failure(e.message ?: e.javaClass.simpleName)
269269
}
270270
}
271271

@@ -328,6 +328,15 @@ object OneSignalService {
328328
}
329329
}
330330

331+
/**
332+
* Outcome of a demo /users fetch. The User section shows the failure reason so a 401 under
333+
* Identity Verification doesn't look like an empty user.
334+
*/
335+
sealed class UserFetchResult {
336+
data class Success(val userData: UserData) : UserFetchResult()
337+
data class Failure(val reason: String) : UserFetchResult()
338+
}
339+
331340
/**
332341
* Data class representing user data fetched from the OneSignal API.
333342
*/

‎examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ package com.onesignal.example.data.repository
33
import com.onesignal.OneSignal
44
import com.onesignal.example.data.model.NotificationType
55
import com.onesignal.example.data.network.OneSignalService
6-
import com.onesignal.example.data.network.UserData
6+
import com.onesignal.example.data.network.UserFetchResult
77
import kotlinx.coroutines.Dispatchers
88
import kotlinx.coroutines.withContext
99
import com.onesignal.example.util.DemoLog
@@ -246,7 +246,7 @@ class OneSignalRepository {
246246
}
247247

248248
// Fetch user data from API
249-
suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserData? = withContext(Dispatchers.IO) {
249+
suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserFetchResult = withContext(Dispatchers.IO) {
250250
DemoLog.d("Fetching user data by $aliasLabel: $aliasValue")
251251
OneSignalService.fetchUser(aliasLabel, aliasValue, jwt)
252252
}

‎examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt‎

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,72 @@ fun LoginDialog(
465465
)
466466
}
467467

468+
/**
469+
* Update User JWT dialog. Fields are stacked full width like [LoginDialog] because a JWT runs
470+
* to several hundred characters. [initialExternalId] prefills the id and stays editable.
471+
*/
472+
@Composable
473+
fun UpdateJwtDialog(
474+
initialExternalId: String,
475+
onDismiss: () -> Unit,
476+
onConfirm: (String, String) -> Unit
477+
) {
478+
var externalId by remember { mutableStateOf(initialExternalId) }
479+
var jwtToken by remember { mutableStateOf("") }
480+
481+
AlertDialog(
482+
onDismissRequest = onDismiss,
483+
modifier = Modifier.fillMaxWidth().padding(horizontal = DemoLayout.pagePadding).exposeTestTagsAsResourceId(),
484+
properties = DialogProperties(usePlatformDefaultWidth = false),
485+
title = {
486+
DialogTitle("Update User JWT")
487+
},
488+
text = {
489+
Column {
490+
OutlinedTextField(
491+
value = externalId,
492+
onValueChange = { externalId = it },
493+
label = { Text("External User Id") },
494+
modifier = Modifier
495+
.fillMaxWidth()
496+
.testTag("update_jwt_external_id_input"),
497+
singleLine = true,
498+
shape = TextFieldShape,
499+
colors = dialogTextFieldColors()
500+
)
501+
Spacer(modifier = Modifier.height(12.dp))
502+
OutlinedTextField(
503+
value = jwtToken,
504+
onValueChange = { jwtToken = it },
505+
label = { Text("JWT Token") },
506+
modifier = Modifier
507+
.fillMaxWidth()
508+
.testTag("update_jwt_token_input"),
509+
singleLine = true,
510+
shape = TextFieldShape,
511+
colors = dialogTextFieldColors()
512+
)
513+
}
514+
},
515+
confirmButton = {
516+
TextButton(
517+
onClick = { onConfirm(externalId, jwtToken) },
518+
enabled = externalId.isNotBlank() && jwtToken.isNotBlank(),
519+
modifier = Modifier.testTag("singlepair_confirm_button"),
520+
colors = dialogTextButtonColors(),
521+
) {
522+
DialogActionLabel("Update")
523+
}
524+
},
525+
dismissButton = {
526+
TextButton(onClick = onDismiss, colors = dialogTextButtonColors()) {
527+
DialogActionLabel("Cancel")
528+
}
529+
},
530+
shape = DialogShape
531+
)
532+
}
533+
468534
/**
469535
* Dialog for outcome selection and input.
470536
*/

‎examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ fun MainScreen(viewModel: MainViewModel) {
6464
val externalUserId by viewModel.externalUserId.observeAsState()
6565
val language by viewModel.language.observeAsState("")
6666
val useIdentityVerification by viewModel.useIdentityVerification.observeAsState(false)
67+
val userFetchStatus by viewModel.userFetchStatus.observeAsState()
68+
val jwtAskExternalId by viewModel.jwtAskExternalId.observeAsState()
6769
val aliases by viewModel.aliases.observeAsState(emptyList())
6870
val emails by viewModel.emails.observeAsState(emptyList())
6971
val smsNumbers by viewModel.smsNumbers.observeAsState(emptyList())
@@ -142,6 +144,8 @@ fun MainScreen(viewModel: MainViewModel) {
142144
externalUserId = externalUserId,
143145
language = language,
144146
useIdentityVerification = useIdentityVerification,
147+
userFetchStatus = userFetchStatus,
148+
jwtAskExternalId = jwtAskExternalId,
145149
onUseIdentityVerificationChange = { viewModel.setUseIdentityVerification(it) },
146150
onLanguageChange = { viewModel.setLanguage(it) },
147151
onLogin = { userId, jwt -> viewModel.loginUser(userId, jwt) },

0 commit comments

Comments
 (0)