diff --git a/examples/build.md b/examples/build.md index aa7dfb3c1..150035443 100644 --- a/examples/build.md +++ b/examples/build.md @@ -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` 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` 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 @@ -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). @@ -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 diff --git a/examples/demo/app/src/main/java/com/onesignal/example/application/MainApplication.kt b/examples/demo/app/src/main/java/com/onesignal/example/application/MainApplication.kt index 7c4a67339..00927c4e2 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/application/MainApplication.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/application/MainApplication.kt @@ -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 @@ -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) diff --git a/examples/demo/app/src/main/java/com/onesignal/example/data/network/OneSignalService.kt b/examples/demo/app/src/main/java/com/onesignal/example/data/network/OneSignalService.kt index 45a31d6e9..1b20889db 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/data/network/OneSignalService.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/data/network/OneSignalService.kt @@ -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 { @@ -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) } } @@ -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. */ diff --git a/examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt b/examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt index 23bf1fad5..34a2d1e95 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/data/repository/OneSignalRepository.kt @@ -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 @@ -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) } diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt index 0df189728..0b97bb1de 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/components/Dialogs.kt @@ -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. */ diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt index 76a47f214..60da4f7d5 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt @@ -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()) @@ -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) }, diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainViewModel.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainViewModel.kt index 9c966103b..59f61790f 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainViewModel.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainViewModel.kt @@ -5,14 +5,14 @@ import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import com.onesignal.IUserJwtInvalidatedListener import com.onesignal.OneSignal -import com.onesignal.UserJwtInvalidatedEvent import com.onesignal.notifications.IPermissionObserver import com.onesignal.example.data.model.NotificationExtensionOptions import com.onesignal.example.data.model.NotificationType +import com.onesignal.example.data.network.UserFetchResult import com.onesignal.example.data.repository.OneSignalRepository import com.onesignal.example.util.DemoLog +import com.onesignal.example.util.JwtAskStore import com.onesignal.example.util.SharedPreferenceUtil import com.onesignal.user.state.IUserStateObserver import com.onesignal.user.state.UserChangedState @@ -23,7 +23,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -class MainViewModel(application: Application) : AndroidViewModel(application), IPushSubscriptionObserver, IPermissionObserver, IUserStateObserver, IUserJwtInvalidatedListener { +class MainViewModel(application: Application) : AndroidViewModel(application), IPushSubscriptionObserver, IPermissionObserver, IUserStateObserver { private companion object { } @@ -92,10 +92,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I private val _notificationExtensionOptions = MutableLiveData() val notificationExtensionOptions: LiveData = _notificationExtensionOptions - // Identity Verification toggle (demo app only, controls alias used for API calls) + // Picks the alias for the demo's own REST fetch. When on, it fetches by external_id with + // that user's stored JWT as bearer. It does not turn Identity Verification on for the app. private val _useIdentityVerification = MutableLiveData() val useIdentityVerification: LiveData = _useIdentityVerification + // External id the SDK is waiting on a JWT for. Drives the banner in UserSection. + // MainApplication's listener fills it. + val jwtAskExternalId: LiveData = JwtAskStore.externalId + + // Outcome of the last demo /users fetch, "OK" or the failure reason. + private val _userFetchStatus = MutableLiveData() + val userFetchStatus: LiveData = _userFetchStatus + // Loading state private val _isLoading = MutableLiveData() val isLoading: LiveData = _isLoading @@ -116,7 +125,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I // Monotonic counter for fetchUserDataFromApi. Mirrors the Capacitor demo's // `requestSequenceRef` so stale fetches after rapid login/logout cycles - // don't overwrite fresh state. Touched only on the main thread. + // don't overwrite fresh state. A user switch or logout bumps it too, through + // resetUserFetch. Touched only on the main thread. private var fetchRequestSequence = 0L init { @@ -125,7 +135,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I OneSignal.User.pushSubscription.addObserver(this) OneSignal.Notifications.addPermissionObserver(this) OneSignal.User.addObserver(this) - OneSignal.addUserJwtInvalidatedListener(this) DemoLog.d("init: observers registered, current onesignalId=${OneSignal.User.onesignalId}") DemoLog.d("OneSignal ID: ${OneSignal.User.onesignalId ?: "not set"}") } @@ -169,11 +178,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I refreshEmails() refreshSmsNumbers() refreshTriggers() - - val onesignalId = OneSignal.User.onesignalId - if (!onesignalId.isNullOrEmpty()) { - fetchUserDataFromApi() - } + + // No onesignal_id guard here. A user whose create is waiting on a JWT has none yet, + // and with the toggle on the fetch goes by external_id. fetchUserDataFromApi checks + // for the alias it needs. + fetchUserDataFromApi() } fun fetchUserDataFromApi() { @@ -185,6 +194,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I if (useIV) { val externalId = _externalUserId.value if (externalId.isNullOrEmpty()) { + _userFetchStatus.value = null _isLoading.value = false return } @@ -193,6 +203,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } else { val onesignalId = OneSignal.User.onesignalId if (onesignalId.isNullOrEmpty()) { + _userFetchStatus.value = null _isLoading.value = false return } @@ -200,41 +211,48 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I aliasValue = onesignalId } - val jwt = if (useIV) SharedPreferenceUtil.getCachedJwtToken(getApplication()) else null + val jwt = if (useIV) SharedPreferenceUtil.getCachedJwtToken(getApplication(), aliasValue) else null _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { try { - val userData = repository.fetchUser(aliasLabel, aliasValue, jwt) + val result = repository.fetchUser(aliasLabel, aliasValue, jwt) withContext(Dispatchers.Main) { if (requestId != fetchRequestSequence) { // A newer fetch superseded this one; drop the stale result. return@withContext } - if (userData != null) { - aliasesList.clear() - aliasesList.addAll(userData.aliases.map { Pair(it.key, it.value) }) - refreshAliases() - - tagsList.clear() - tagsList.addAll(userData.tags.map { Pair(it.key, it.value) }) - refreshTags() - - emailsList.clear() - emailsList.addAll(userData.emails) - refreshEmails() - - smsNumbersList.clear() - smsNumbersList.addAll(userData.smsNumbers) - refreshSmsNumbers() - - if (!userData.externalId.isNullOrEmpty()) { - _externalUserId.value = userData.externalId - SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), userData.externalId) + when (result) { + is UserFetchResult.Failure -> { + _userFetchStatus.value = "$aliasLabel fetch failed: ${result.reason}" + } + is UserFetchResult.Success -> { + val userData = result.userData + aliasesList.clear() + aliasesList.addAll(userData.aliases.map { Pair(it.key, it.value) }) + refreshAliases() + + tagsList.clear() + tagsList.addAll(userData.tags.map { Pair(it.key, it.value) }) + refreshTags() + + emailsList.clear() + emailsList.addAll(userData.emails) + refreshEmails() + + smsNumbersList.clear() + smsNumbersList.addAll(userData.smsNumbers) + refreshSmsNumbers() + + if (!userData.externalId.isNullOrEmpty()) { + _externalUserId.value = userData.externalId + SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), userData.externalId) + } + _userFetchStatus.value = "OK" + + kotlinx.coroutines.delay(100) + if (requestId != fetchRequestSequence) return@withContext } - - kotlinx.coroutines.delay(100) - if (requestId != fetchRequestSequence) return@withContext } _isLoading.value = false } @@ -279,14 +297,30 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I // User operations fun loginUser(externalUserId: String, jwtToken: String? = null) { + val externalId = externalUserId.trim() + if (externalId.isEmpty()) return + val token = jwtToken?.trim()?.ifEmpty { null } _isLoading.value = true viewModelScope.launch(Dispatchers.IO) { - repository.loginUser(externalUserId, jwtToken) + val sameUser = externalId == OneSignal.User.externalId + repository.loginUser(externalId, token) withContext(Dispatchers.Main) { - SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), externalUserId) - SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) - _externalUserId.value = externalUserId + SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), externalId) + // A blank token field leaves any token already stored for this user. + if (token != null) { + SharedPreferenceUtil.cacheJwtToken(getApplication(), externalId, token) + JwtAskStore.answered(externalId) + } + _externalUserId.value = externalId resetLanguagePicker() + refreshPushSubscription() + if (sameUser) { + // No user-state event fires for the same user, so refetch here instead of + // clearing lists that nothing would repopulate. + fetchUserDataFromApi() + return@withContext + } + resetUserFetch() aliasesList.clear() emailsList.clear() smsNumbersList.clear() @@ -296,7 +330,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I refreshSmsNumbers() refreshTriggers() loadExistingTags() - refreshPushSubscription() _isLoading.value = false } } @@ -317,7 +350,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I withContext(Dispatchers.Main) { if (result.isSuccess) { SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), externalUserId) - SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) + // A blank token field leaves any token already stored for this user. + if (jwtToken != null) { + SharedPreferenceUtil.cacheJwtToken(getApplication(), externalUserId, jwtToken) + JwtAskStore.answered(externalUserId) + } _externalUserId.value = externalUserId resetLanguagePicker() aliasesList.clear() @@ -339,11 +376,22 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } fun updateUserJwt(externalUserId: String, jwtToken: String) { + val externalId = externalUserId.trim() + val token = jwtToken.trim() + if (externalId.isEmpty() || token.isEmpty()) return + // Stored whoever it's for. The SDK can hold parked requests for a user who isn't the + // current one, including one who has logged out. + SharedPreferenceUtil.cacheJwtToken(getApplication(), externalId, token) + JwtAskStore.answered(externalId) viewModelScope.launch(Dispatchers.IO) { - repository.updateUserJwt(externalUserId, jwtToken) + repository.updateUserJwt(externalId, token) withContext(Dispatchers.Main) { - SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) - DemoLog.i("Updated JWT for: $externalUserId") + DemoLog.i("Updated JWT for: $externalId") + if (externalId == _externalUserId.value) { + // updateUserJwt may not fire a user-state event, so refetch instead of + // leaving the last failure on screen. + fetchUserDataFromApi() + } } } } @@ -354,7 +402,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I repository.logoutUser() withContext(Dispatchers.Main) { SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), "") + SharedPreferenceUtil.clearCachedJwtTokens(getApplication()) _externalUserId.value = null + JwtAskStore.clear() + resetUserFetch() resetLanguagePicker() loadExistingAliases() loadExistingTags() @@ -370,10 +421,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } } + // A fetch still in flight belongs to the previous user, so drop its result. Clear the row + // too, because a new user's create can wait on a JWT, and then no user-state event comes + // to refetch. + private fun resetUserFetch() { + fetchRequestSequence++ + _userFetchStatus.value = null + } + fun setUseIdentityVerification(enabled: Boolean) { SharedPreferenceUtil.cacheIdentityVerification(getApplication(), enabled) _useIdentityVerification.value = enabled - DemoLog.i(if (enabled) "Identity verification enabled" else "Identity verification disabled") + DemoLog.i(if (enabled) "Fetch by external_id enabled" else "Fetch by external_id disabled") + fetchUserDataFromApi() } fun setLanguage(language: String) { @@ -773,15 +833,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I // The SDK reports missing ids as empty strings; the Log View format uses "null". private fun String?.orNullText(): String = if (isNullOrEmpty()) "null" else this - override fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent) { - DemoLog.w("JWT invalidated for externalId: ${event.externalId}") - } - override fun onCleared() { super.onCleared() OneSignal.User.pushSubscription.removeObserver(this) OneSignal.Notifications.removePermissionObserver(this) OneSignal.User.removeObserver(this) - OneSignal.removeUserJwtInvalidatedListener(this) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/Sections.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/Sections.kt index 850475fd5..436d8aa3d 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/main/Sections.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/Sections.kt @@ -14,13 +14,18 @@ import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import com.onesignal.example.data.model.InAppMessageType import com.onesignal.example.data.model.NotificationExtensionOptions @@ -44,6 +49,7 @@ import com.onesignal.example.ui.components.SectionCard import com.onesignal.example.ui.components.SingleInputDialog import com.onesignal.example.ui.components.ToggleRow import com.onesignal.example.ui.components.TrackEventDialog +import com.onesignal.example.ui.components.UpdateJwtDialog import com.onesignal.example.ui.theme.DemoLayout import com.onesignal.example.ui.theme.OsCardBorder import com.onesignal.example.ui.theme.OsDivider @@ -182,6 +188,8 @@ fun UserSection( externalUserId: String?, language: String, useIdentityVerification: Boolean, + userFetchStatus: String?, + jwtAskExternalId: String?, onUseIdentityVerificationChange: (Boolean) -> Unit, onLanguageChange: (String) -> Unit, onLogin: (String, String?) -> Unit, @@ -194,16 +202,26 @@ fun UserSection( var loginOpen by remember { mutableStateOf(false) } var compositeLoginOpen by remember { mutableStateOf(false) } var updateJwtOpen by remember { mutableStateOf(false) } + val snackbar = LocalSnackbarController.current + + // Show the snackbar once per ask. Saving the id stops a rotation from showing it again. + var announcedAskId by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(jwtAskExternalId) { + if (jwtAskExternalId != null && jwtAskExternalId != announcedAskId) { + snackbar.show("SDK asked for a JWT for $jwtAskExternalId") + } + announcedAskId = jwtAskExternalId + } DemoSection { SectionCard(title = "User", sectionKey = "user") { ToggleRow( - label = "Identity Verification", - description = "Use external_id for API calls", + label = "Fetch by external_id (JWT)", + description = "Demo REST fetch only. Signs with the stored JWT.", checked = useIdentityVerification, onCheckedChange = onUseIdentityVerificationChange, testTag = "identity_verification_toggle", - contentDescription = "Identity verification", + contentDescription = "Fetch by external_id", ) HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) CardKvRow( @@ -220,6 +238,13 @@ fun UserSection( valueTestTag = "user_external_id_value", ) HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) + CardKvRow( + label = "REST fetch", + value = userFetchStatus ?: "—", + valueTestTag = "user_fetch_status_value", + monospaceValue = false, + ) + HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) DropdownRow( label = "Language", selectedValue = language, @@ -229,6 +254,36 @@ fun UserSection( ) } + // The demo shows the SDK's ask here instead of feeding the stored token back in. + if (jwtAskExternalId != null) { + Spacer(modifier = Modifier.height(DemoLayout.gap)) + DemoCard( + modifier = Modifier.testTag("jwt_ask_banner"), + containerColor = OsWarningBackground, + ) { + Text( + "The SDK is waiting for a JWT. Requests for this user are parked until one is supplied.", + style = MaterialTheme.typography.bodySmall, + color = OsGrey700, + ) + Text( + jwtAskExternalId, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = OsGrey700, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(top = 4.dp) + .testTag("jwt_ask_external_id_value"), + ) + OutlineButton( + text = "PROVIDE JWT", + onClick = { updateJwtOpen = true }, + testTag = "jwt_ask_provide_button", + ) + } + } + Spacer(modifier = Modifier.height(DemoLayout.gap)) PrimaryButton( @@ -282,17 +337,13 @@ fun UserSection( } if (updateJwtOpen) { - PairInputDialog( - title = "Update User JWT", - keyLabel = "External User Id", - valueLabel = "JWT Token", + UpdateJwtDialog( + initialExternalId = jwtAskExternalId ?: externalUserId.orEmpty(), onDismiss = { updateJwtOpen = false }, onConfirm = { externalId, token -> onUpdateJwt(externalId, token) updateJwtOpen = false }, - keyTestTag = "update_jwt_external_id_input", - valueTestTag = "update_jwt_token_input", ) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/example/util/JwtAskStore.kt b/examples/demo/app/src/main/java/com/onesignal/example/util/JwtAskStore.kt new file mode 100644 index 000000000..1ad055c75 --- /dev/null +++ b/examples/demo/app/src/main/java/com/onesignal/example/util/JwtAskStore.kt @@ -0,0 +1,43 @@ +package com.onesignal.example.util + +import android.os.Handler +import android.os.Looper +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData + +/** + * The SDK's pending ask for a JWT. App-scoped so the listener MainApplication registers at + * launch reaches the UI whether or not MainActivity exists. The SDK doesn't replay the event. + * State changes happen on the main thread. + */ +object JwtAskStore { + private val mainHandler = Handler(Looper.getMainLooper()) + + private val _externalId = MutableLiveData() + + /** External id the SDK is waiting on a JWT for, or null. */ + val externalId: LiveData = _externalId + + /** + * Called from IUserJwtInvalidatedListener, which the SDK runs on a background thread. + * Replaces any earlier ask. The stored token stays. An SDK that asks because requests parked + * without a token fires this event too, so it doesn't prove the stored token is bad. A token + * that is bad shows up as a 401 in the REST fetch row. + */ + fun onInvalidated(externalId: String) { + mainHandler.post { + _externalId.value = externalId + } + } + + /** A token for [externalId] answers the ask if it is for that user. */ + fun answered(externalId: String) { + if (_externalId.value == externalId) { + _externalId.value = null + } + } + + fun clear() { + _externalId.value = null + } +} diff --git a/examples/demo/app/src/main/java/com/onesignal/example/util/SharedPreferenceUtil.kt b/examples/demo/app/src/main/java/com/onesignal/example/util/SharedPreferenceUtil.kt index d24ca00e1..249afc7a0 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/util/SharedPreferenceUtil.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/util/SharedPreferenceUtil.kt @@ -3,6 +3,7 @@ package com.onesignal.example.util import android.content.Context import android.content.SharedPreferences import com.onesignal.example.data.model.NotificationExtensionOptions +import org.json.JSONObject object SharedPreferenceUtil { @@ -13,7 +14,14 @@ object SharedPreferenceUtil { private const val IN_APP_MESSAGING_PAUSED_PREF = "IN_APP_MESSAGING_PAUSED_PREF" private const val CONSENT_REQUIRED_PREF = "CONSENT_REQUIRED_PREF" private const val IDENTITY_VERIFICATION_PREF = "IDENTITY_VERIFICATION_PREF" - private const val JWT_TOKEN_PREF = "JWT_TOKEN_PREF" + + // Every JWT the tester handed the SDK, as a JSON object keyed by external id. Bearers for + // the demo's own /users fetch only. The demo never passes them to login or updateUserJwt + // on its own. Plaintext SharedPreferences is fine for a demo and not a pattern for real apps. + private const val JWT_TOKENS_PREF = "JWT_TOKENS_PREF" + + // The single token the demo kept before JWT_TOKENS_PREF. Deleted on first read. + private const val LEGACY_JWT_TOKEN_PREF = "JWT_TOKEN_PREF" private const val LANGUAGE_PREF = "LANGUAGE_PREF" // Notification service extension switches. DemoNotificationServiceExtension reads these @@ -83,12 +91,33 @@ object SharedPreferenceUtil { getSharedPreference(context).edit().putBoolean(IDENTITY_VERIFICATION_PREF, enabled).apply() } - fun getCachedJwtToken(context: Context): String? { - return getSharedPreference(context).getString(JWT_TOKEN_PREF, null) + fun getCachedJwtToken(context: Context, externalId: String): String? { + return readJwtTokens(context).optString(externalId).ifEmpty { null } + } + + fun cacheJwtToken(context: Context, externalId: String, token: String) { + writeJwtTokens(context, readJwtTokens(context).put(externalId, token)) } - fun cacheJwtToken(context: Context, token: String?) { - getSharedPreference(context).edit().putString(JWT_TOKEN_PREF, token).apply() + fun clearCachedJwtTokens(context: Context) { + getSharedPreference(context).edit().remove(JWT_TOKENS_PREF).apply() + } + + private fun readJwtTokens(context: Context): JSONObject { + val prefs = getSharedPreference(context) + if (prefs.contains(LEGACY_JWT_TOKEN_PREF)) { + prefs.edit().remove(LEGACY_JWT_TOKEN_PREF).apply() + } + val json = prefs.getString(JWT_TOKENS_PREF, null) ?: return JSONObject() + return try { + JSONObject(json) + } catch (e: Exception) { + JSONObject() + } + } + + private fun writeJwtTokens(context: Context, tokens: JSONObject) { + getSharedPreference(context).edit().putString(JWT_TOKENS_PREF, tokens.toString()).apply() } fun getCachedLanguage(context: Context): String {