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
51 changes: 46 additions & 5 deletions examples/build.md
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@ The Android demo **overrides the shared guide's "no repository wrapper" rule**.
- `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`.
- `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).
- `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).

`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`.

Expand Down Expand Up @@ -178,9 +178,20 @@ Patterns used by this demo beyond the shared guide's table:
- `MainActivity` sets `semantics(mergeDescendants = false) { testTagsAsResourceId = true }` on the root `Surface` so Appium `id=` selectors map onto Compose `testTag` values (`MainActivity.kt` lines ~41–43).
- `Dialogs.kt` re-applies the same via an `exposeTestTagsAsResourceId()` helper inside each dialog because Compose dialogs render in a separate window -- required for dialog-scoped test tags to be visible to UiAutomator.

### Log tags

The demo logs through `util/DemoLog.kt` rather than `android.util.Log`. It marks both the tag and the message with `[Demo]`, so the tag stays filterable with `logcat -s` and a line is still recognizable when only the message column is in view.

```kotlin
DemoLog.d(TAG, "Sending notification: Simple")
// D/[Demo]MainViewModel: [Demo] Sending notification: Simple
```

Pass the plain class name as the tag; `DemoLog` adds the prefix, so `[Demo]` is defined in exactly one place. `v`, `d`, `i`, `w`, `e`, and `e(tag, message, throwable)` are all there. Current tags are `[Demo]OneSignalExample`, `[Demo]MainViewModel`, `[Demo]OneSignalService`, `[Demo]OneSignalRepository`, `[Demo]TooltipHelper`, `[Demo]NSE`, and `[Demo]OneSignalHMS` on the Huawei flavor.

### SDK log forwarding

`MainApplication` registers `OneSignal.Debug.addLogListener` and forwards each entry to `android.util.Log` under the `OneSignalSDK` tag, so SDK output shows up alongside app output in Android Studio's Logcat (filter `package:mine` to see both). There is no in-app log viewer — match the shared guide and other wrapper SDK demos by relying on Logcat.
`MainApplication` registers `OneSignal.Debug.addLogListener` and forwards each entry to `android.util.Log` under the `OneSignalSDK` tag, so SDK output shows up alongside app output in Android Studio's Logcat (filter `package:mine` to see both). Those five calls are the one place in the demo that still uses `android.util.Log` directly, on purpose. The lines are the SDK's, mirrored, and routing them through `DemoLog` would bury the demo's own output whenever you grep `[Demo]`. There is no in-app log viewer — match the shared guide and other wrapper SDK demos by relying on Logcat.

---

Expand All @@ -193,6 +204,34 @@ The Android demo exercises a few SDK features that are not described in the shar
- **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.

### Notification service extension

`DemoNotificationServiceExtension` implements `INotificationServiceExtension` and is registered from `app/src/main/AndroidManifest.xml`:

```xml
<meta-data
android:name="com.onesignal.NotificationServiceExtension"
android:value="com.onesignal.example.notification.DemoNotificationServiceExtension" />
```

The SDK resolves that string with `Class.forName` (`NotificationLifecycleService.setupNotificationServiceExtension`), so a wrong class name here fails silently at runtime. Compiling the class inside `OneSignalSDK/`'s `:app` project is what turns a breaking change to `INotificationServiceExtension` or `INotificationReceivedEvent` into a CI failure, and the release build is the only place the `-keep class ** implements com.onesignal.notifications.INotificationServiceExtension` rule in `onesignal/notifications/consumer-rules.pro` gets exercised end to end.

The UI is a single Enable Extension toggle (`nse_enabled_toggle`). Off means `onNotificationReceived` returns before touching anything, so the notifications the demo sends stay usable as a manual QA baseline and the section stays close to the other wrapper demos.

The behavior switches have no UI. They live in `NotificationExtensionOptions`, persist through `SharedPreferenceUtil`, and are flipped in code: change the `false` defaults in `SharedPreferenceUtil.getNotificationExtensionOptions` (or call `cacheNotificationExtensionOptions`) and rebuild. The extension reads them from SharedPreferences rather than `MainViewModel`, because it runs whether or not the app is open.

| Option | What it does |
| --- | --- |
| `logDetails` | Logs id, sent time, and the channel the SDK resolved, under the `[Demo]NSE` tag. |
| `applyExtender` | Prefixes the title with `[NSE]` through a `NotificationCompat.Extender`. |
| `forceHighImportanceChannel` | Moves the notification onto an app-owned `IMPORTANCE_HIGH` channel. |
| `delayDisplay` | `preventDefault()`, then `display()` five seconds later. |
| `discard` | `preventDefault(true)`. Takes precedence over the other switches. |

The channel readout comes from `NotificationCompat.getChannelId(builder.build())` inside the extender, the only place an extension can see the SDK's choice. A restored notification lands on `restored_OS_notifications` no matter what the payload asked for, which the payload alone never shows. `event.restoring` is not on `INotificationReceivedEvent` yet; see the TODO in the class and SDK-5011.

The class sets an extender only when a switch needs one rather than installing a no-op whenever the extension is on, which is about not doing work nothing asked for. An extender cannot change what displays. `NotificationGenerationProcessor.shouldDisplayNotification` does read `hasExtender()`, but `processHandlerResponse` has already dropped a push with an empty body on `canDisplay` by the time it runs.

---

## Platform Config
Expand Down Expand Up @@ -236,7 +275,7 @@ If the package changes you must regenerate this file from the Huawei AppGallery

`src/huawei/` overlays the main source set:

- `src/huawei/AndroidManifest.xml` — declares `HmsMessageServiceAppLevel` with `android:name="com.onesignal.example.notification.HmsMessageServiceAppLevel"`.
- `src/huawei/AndroidManifest.xml` — declares `HmsMessageServiceAppLevel` with `android:name="com.onesignal.example.notification.HmsMessageServiceAppLevel"`. The notification service extension meta-data comes from the main manifest through manifest merge, so both flavors get it.
- `src/huawei/java/com/onesignal/example/notification/HmsMessageServiceAppLevel.kt` — minimal `HmsMessageService` subclass that forwards messages to OneSignal.

---
Expand All @@ -263,9 +302,11 @@ examples/
│ ├── java/com/onesignal/example/
│ │ ├── application/MainApplication.kt
│ │ ├── data/
│ │ │ ├── model/{NotificationType,InAppMessageType}.kt
│ │ │ ├── model/{NotificationType,InAppMessageType,
│ │ │ │ NotificationExtensionOptions}.kt
│ │ │ ├── network/OneSignalService.kt
│ │ │ └── repository/OneSignalRepository.kt
│ │ ├── notification/DemoNotificationServiceExtension.kt
│ │ ├── ui/
│ │ │ ├── components/ # SectionCard (with DemoSection),
│ │ │ │ # ToggleRow, ActionButton, ListComponents,
Expand All @@ -274,7 +315,7 @@ examples/
│ │ │ ├── main/ # MainActivity, MainScreen, Sections, MainViewModel
│ │ │ ├── secondary/SecondaryActivity.kt
│ │ │ └── theme/ # Theme.kt, DemoLayout.kt
│ │ └── util/ # SharedPreferenceUtil, TooltipHelper
│ │ └── util/ # SharedPreferenceUtil, TooltipHelper, DemoLog
│ └── res/
│ ├── values/{strings,colors,styles}.xml
│ ├── raw/ # vine_boom.wav
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
package com.onesignal.example.notification

import android.os.Bundle
import android.util.Log
import com.huawei.hms.push.HmsMessageService
import com.huawei.hms.push.RemoteMessage
import com.onesignal.notifications.bridges.OneSignalHmsEventBridge
import com.onesignal.example.util.DemoLog

/**
* HMS Message Service for handling Huawei Push notifications.
Expand All @@ -26,15 +26,15 @@ class HmsMessageServiceAppLevel : HmsMessageService() {
* Otherwise, you need to start a new Job for callback processing.
*/
override fun onNewToken(token: String, bundle: Bundle) {
Log.d(TAG, "HmsMessageServiceAppLevel onNewToken refresh token: $token bundle: $bundle")
DemoLog.d(TAG, "HmsMessageServiceAppLevel onNewToken refresh token: $token bundle: $bundle")

// Forward event on to OneSignal SDK
OneSignalHmsEventBridge.onNewToken(this, token, bundle)
}

@Deprecated("Deprecated in Java")
override fun onNewToken(token: String) {
Log.d(TAG, "HmsMessageServiceAppLevel onNewToken refresh token: $token")
DemoLog.d(TAG, "HmsMessageServiceAppLevel onNewToken refresh token: $token")

// Forward event on to OneSignal SDK
OneSignalHmsEventBridge.onNewToken(this, token)
Expand All @@ -48,18 +48,18 @@ class HmsMessageServiceAppLevel : HmsMessageService() {
* Start a new Job if more time is needed.
*/
override fun onMessageReceived(message: RemoteMessage) {
Log.d(TAG, "HMS onMessageReceived: $message")
Log.d(TAG, "HMS onMessageReceived.ttl: ${message.ttl}")
Log.d(TAG, "HMS onMessageReceived.data: ${message.data}")
DemoLog.d(TAG, "HMS onMessageReceived: $message")
DemoLog.d(TAG, "HMS onMessageReceived.ttl: ${message.ttl}")
DemoLog.d(TAG, "HMS onMessageReceived.data: ${message.data}")

message.notification?.let { notification ->
Log.d(TAG, "HMS onMessageReceived.title: ${notification.title}")
Log.d(TAG, "HMS onMessageReceived.body: ${notification.body}")
Log.d(TAG, "HMS onMessageReceived.icon: ${notification.icon}")
Log.d(TAG, "HMS onMessageReceived.color: ${notification.color}")
Log.d(TAG, "HMS onMessageReceived.channelId: ${notification.channelId}")
Log.d(TAG, "HMS onMessageReceived.imageURL: ${notification.imageUrl}")
Log.d(TAG, "HMS onMessageReceived.tag: ${notification.tag}")
DemoLog.d(TAG, "HMS onMessageReceived.title: ${notification.title}")
DemoLog.d(TAG, "HMS onMessageReceived.body: ${notification.body}")
DemoLog.d(TAG, "HMS onMessageReceived.icon: ${notification.icon}")
DemoLog.d(TAG, "HMS onMessageReceived.color: ${notification.color}")
DemoLog.d(TAG, "HMS onMessageReceived.channelId: ${notification.channelId}")
DemoLog.d(TAG, "HMS onMessageReceived.imageURL: ${notification.imageUrl}")
DemoLog.d(TAG, "HMS onMessageReceived.tag: ${notification.tag}")
}

// Forward event on to OneSignal SDK
Expand Down
7 changes: 7 additions & 0 deletions examples/demo/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@
android:name="com.amazon.device.messaging"
android:required="false"/>

<!-- Notification service extension. The SDK resolves this value by reflection, so a typo
fails silently at runtime. Every behavior in the class is off until switched on in
the demo's Notification Service Extension section. -->
<meta-data
android:name="com.onesignal.NotificationServiceExtension"
android:value="com.onesignal.example.notification.DemoNotificationServiceExtension" />

<!-- ADM Services -->
<service
android:name="com.onesignal.notifications.services.ADMMessageHandlerJob"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import com.onesignal.notifications.INotificationLifecycleListener
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.SharedPreferenceUtil
import com.onesignal.example.util.TooltipHelper
import com.onesignal.user.state.IUserStateObserver
Expand Down Expand Up @@ -68,7 +69,7 @@ class MainApplication : MultiDexApplication() {
// Initialize OneSignal on main thread (required)
// Crash handler + ANR detector are initialized early inside initWithContext
OneSignal.initWithContext(this, appId)
Log.i(TAG, "OneSignal init completed (crash handler, ANR detector, and logging active)")
DemoLog.i(TAG, "OneSignal init completed (crash handler, ANR detector, and logging active)")

// Set up all OneSignal listeners
setupOneSignalListeners()
Expand All @@ -80,37 +81,37 @@ class MainApplication : MultiDexApplication() {
private fun setupOneSignalListeners() {
OneSignal.InAppMessages.addLifecycleListener(object : IInAppMessageLifecycleListener {
override fun onWillDisplay(event: IInAppMessageWillDisplayEvent) {
Log.d(TAG, "onWillDisplayInAppMessage")
DemoLog.d(TAG, "onWillDisplayInAppMessage")
}

override fun onDidDisplay(event: IInAppMessageDidDisplayEvent) {
Log.d(TAG, "onDidDisplayInAppMessage")
DemoLog.d(TAG, "onDidDisplayInAppMessage")
}

override fun onWillDismiss(event: IInAppMessageWillDismissEvent) {
Log.d(TAG, "onWillDismissInAppMessage")
DemoLog.d(TAG, "onWillDismissInAppMessage")
}

override fun onDidDismiss(event: IInAppMessageDidDismissEvent) {
Log.d(TAG, "onDidDismissInAppMessage")
DemoLog.d(TAG, "onDidDismissInAppMessage")
}
})

OneSignal.InAppMessages.addClickListener(object : IInAppMessageClickListener {
override fun onClick(event: IInAppMessageClickEvent) {
Log.d(TAG, "IInAppMessageClickListener.onClick")
DemoLog.d(TAG, "IInAppMessageClickListener.onClick")
}
})

OneSignal.Notifications.addClickListener(object : INotificationClickListener {
override fun onClick(event: INotificationClickEvent) {
Log.d(TAG, "INotificationClickListener.onClick fired with event: $event")
DemoLog.d(TAG, "INotificationClickListener.onClick fired with event: $event")
}
})

OneSignal.Notifications.addForegroundLifecycleListener(object : INotificationLifecycleListener {
override fun onWillDisplay(event: INotificationWillDisplayEvent) {
Log.d(TAG, "INotificationLifecycleListener.onWillDisplay fired with event: $event")
DemoLog.d(TAG, "INotificationLifecycleListener.onWillDisplay fired with event: $event")

val notification: IDisplayableNotification = event.notification

Expand All @@ -129,7 +130,7 @@ class MainApplication : MultiDexApplication() {

OneSignal.User.addObserver(object : IUserStateObserver {
override fun onUserStateChange(state: UserChangedState) {
Log.i(TAG, "User state changed: onesignalId=${state.current.onesignalId}, externalId=${state.current.externalId}")
DemoLog.i(TAG, "User state changed: onesignalId=${state.current.onesignalId}, externalId=${state.current.externalId}")
}
})

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.onesignal.example.data.model

/**
* Runtime switches for [com.onesignal.example.notification.DemoNotificationServiceExtension].
*
* Every switch defaults to false. A fresh install behaves like a demo with no extension
* registered at all, so the notifications the demo sends stay usable as a manual QA baseline.
*
* Only [enabled] has a UI toggle. Flip the others by changing the defaults in
* SharedPreferenceUtil.getNotificationExtensionOptions (or by calling
* cacheNotificationExtensionOptions) and rebuilding.
*/
data class NotificationExtensionOptions(
val enabled: Boolean = false,
val logDetails: Boolean = false,
val applyExtender: Boolean = false,
val forceHighImportanceChannel: Boolean = false,
val delayDisplay: Boolean = false,
val discard: Boolean = false,
)
Loading