From fcedf213ad9dc400eb23e273f54c2a01142cd169 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 25 Aug 2026 15:22:10 -0700 Subject: [PATCH 1/4] feat: [SDK-5083] add an inert-by-default notification service extension to the demo Nothing in the repo implemented INotificationServiceExtension, so reproducing an NSE bug meant writing one from scratch and no compiled sample guarded the interface against a breaking change. Building the demo inside OneSignalSDK's :app project now turns that into a CI failure, and the release build exercises the -keep rule in onesignal/notifications/consumer-rules.pro end to end. Six switches drive it, all off, folded behind a Show options row. It reads them from SharedPreferences rather than MainViewModel because it runs whether or not the app is open, and it sets an extender only when a switch needs one, since an extender makes the SDK display a data-only push carrying no alert. The channel readout uses NotificationCompat.getChannelId inside the extender, the only place an extension sees the SDK's choice. A restored notification lands on restored_OS_notifications whatever the payload asked for. Logging restoring next to it waits on SDK-5011. --- examples/build.md | 38 ++++- .../demo/app/src/main/AndroidManifest.xml | 7 + .../model/NotificationExtensionOptions.kt | 16 +++ .../DemoNotificationServiceExtension.kt | 132 ++++++++++++++++++ .../onesignal/example/ui/main/MainScreen.kt | 7 + .../example/ui/main/MainViewModel.kt | 15 +- .../com/onesignal/example/ui/main/Sections.kt | 114 +++++++++++++++ .../example/util/SharedPreferenceUtil.kt | 35 +++++ 8 files changed, 360 insertions(+), 4 deletions(-) create mode 100644 examples/demo/app/src/main/java/com/onesignal/example/data/model/NotificationExtensionOptions.kt create mode 100644 examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt diff --git a/examples/build.md b/examples/build.md index b8fe9df310..468ff9bd2d 100644 --- a/examples/build.md +++ b/examples/build.md @@ -121,7 +121,7 @@ The Android demo **overrides the shared guide's "no repository wrapper" rule**. - `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`. - `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`. @@ -193,6 +193,36 @@ 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 + +``` + +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. + +Every behavior is off until switched on in the **Notification Service Extension** section, so the notifications the demo sends stay usable as a manual QA baseline. Switches live in `NotificationExtensionOptions` and persist through `SharedPreferenceUtil`; the extension reads them from SharedPreferences rather than `MainViewModel`, because it runs whether or not the app is open. + +The five behavior switches sit behind a Show options / Hide options row so the section stays two rows tall while the extension is off. Enable Extension is always visible, and turning it on opens the options. The row reuses the collapse idiom from `CollapsibleSingleList` in `ListComponents.kt` (centered, `OsPrimary` label, `ExpandMore` / `ExpandLess` chevron). + +| Toggle | testTag | What it does | +| --- | --- | --- | +| Enable Extension | `nse_enabled_toggle` | Master switch. Off means `onNotificationReceived` returns before touching anything. | +| Show / Hide options | `nse_options_toggle` | Folds the five switches below. Not a setting, nothing is persisted. | +| Log Details | `nse_log_toggle` | Logs id, sent time, and the channel the SDK resolved, under the `DemoNSE` tag. | +| Apply Extender | `nse_extender_toggle` | Prefixes the title with `[NSE]` through a `NotificationCompat.Extender`. | +| Force High Importance Channel | `nse_high_importance_toggle` | Moves the notification onto an app-owned `IMPORTANCE_HIGH` channel. | +| Delay Display | `nse_delay_toggle` | `preventDefault()`, then `display()` five seconds later. | +| Discard | `nse_discard_toggle` | `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. + +An extender also makes the SDK display a data-only push that carries no `alert` (`NotificationGenerationProcessor.shouldDisplayNotification`), so the class sets one only when a switch needs it. + --- ## Platform Config @@ -236,7 +266,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. --- @@ -263,9 +293,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, diff --git a/examples/demo/app/src/main/AndroidManifest.xml b/examples/demo/app/src/main/AndroidManifest.xml index fc85fb6a46..c91d1c923a 100644 --- a/examples/demo/app/src/main/AndroidManifest.xml +++ b/examples/demo/app/src/main/AndroidManifest.xml @@ -30,6 +30,13 @@ android:name="com.amazon.device.messaging" android:required="false"/> + + + + if (options.logDetails) { + // Read the channel before anything below overwrites it. This is the only place an + // extension can see what the SDK picked. A restored notification lands on + // `restored_OS_notifications` no matter what the payload asked for, and the payload + // by itself never shows that. + Log.d( + TAG, + "building androidNotificationId=${notification.androidNotificationId}" + + " channel=${NotificationCompat.getChannelId(builder.build())}", + ) + } + + if (options.applyExtender) { + builder.setContentTitle("[NSE] ${notification.title.orEmpty()}") + } + + if (options.forceHighImportanceChannel) { + forceHighImportanceChannel(context, builder) + } + + builder + } + + /** + * Moves the notification onto an IMPORTANCE_HIGH channel the app owns. This is the pattern a + * customer used to make every push heads-up, and on an SDK without the SDK-5011 fix it also + * makes every restored notification alert again after a reboot. + */ + private fun forceHighImportanceChannel( + context: Context, + builder: NotificationCompat.Builder, + ) { + builder.priority = NotificationCompat.PRIORITY_HIGH + + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.createNotificationChannel( + NotificationChannel( + HIGH_IMPORTANCE_CHANNEL_ID, + "Demo high importance", + NotificationManager.IMPORTANCE_HIGH, + ), + ) + builder.setChannelId(HIGH_IMPORTANCE_CHANNEL_ID) + } + + private companion object { + const val TAG = "DemoNSE" + const val HIGH_IMPORTANCE_CHANNEL_ID = "demo_nse_high_importance" + + // Well under the SDK's 30 second wait for the extension, and long enough to watch the + // notification arrive late on a device. + const val DISPLAY_DELAY_MS = 5_000L + } +} 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 78c279f6bf..e1f0c6bbe9 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 @@ -38,6 +38,7 @@ import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.onesignal.example.R +import com.onesignal.example.data.model.NotificationExtensionOptions import com.onesignal.example.data.model.NotificationType import com.onesignal.example.ui.components.LocalSnackbarController import com.onesignal.example.ui.components.PrimaryButton @@ -65,6 +66,7 @@ fun MainScreen(viewModel: MainViewModel) { val smsNumbers by viewModel.smsNumbers.observeAsState(emptyList()) val tags by viewModel.tags.observeAsState(emptyList()) val triggers by viewModel.triggers.observeAsState(emptyList()) + val notificationExtensionOptions by viewModel.notificationExtensionOptions.observeAsState(NotificationExtensionOptions()) val inAppMessagesPaused by viewModel.inAppMessagesPaused.observeAsState(false) val locationShared by viewModel.locationShared.observeAsState(false) val isLoading by viewModel.isLoading.observeAsState(false) @@ -161,6 +163,11 @@ fun MainScreen(viewModel: MainViewModel) { onInfoClick = { showTooltipDialog = "sendPushNotification" } ) + NotificationExtensionSection( + options = notificationExtensionOptions, + onOptionsChange = { viewModel.setNotificationExtensionOptions(it) } + ) + InAppMessagingSection( isPaused = inAppMessagesPaused, onPausedChange = { viewModel.setInAppMessagesPaused(it) }, 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 d2284ea8d6..4676f1575f 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 @@ -10,6 +10,7 @@ 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.repository.OneSignalRepository import com.onesignal.example.util.SharedPreferenceUtil @@ -85,6 +86,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I private val _locationShared = MutableLiveData() val locationShared: LiveData = _locationShared + // Notification service extension switches (demo app only, read by DemoNotificationServiceExtension) + private val _notificationExtensionOptions = MutableLiveData() + val notificationExtensionOptions: LiveData = _notificationExtensionOptions + // Identity Verification toggle (demo app only, controls alias used for API calls) private val _useIdentityVerification = MutableLiveData() val useIdentityVerification: LiveData = _useIdentityVerification @@ -146,7 +151,8 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I _inAppMessagesPaused.value = repository.isInAppMessagesPaused() _locationShared.value = repository.isLocationShared() _useIdentityVerification.value = SharedPreferenceUtil.getCachedIdentityVerification(context) - + _notificationExtensionOptions.value = SharedPreferenceUtil.getNotificationExtensionOptions(context) + val externalId = OneSignal.User.externalId _externalUserId.value = if (externalId.isEmpty()) null else externalId @@ -666,6 +672,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I Log.i(TAG, "All notifications cleared") } + // Notification service extension + fun setNotificationExtensionOptions(options: NotificationExtensionOptions) { + SharedPreferenceUtil.cacheNotificationExtensionOptions(getApplication(), options) + _notificationExtensionOptions.value = options + Log.i(TAG, "Notification service extension options: $options") + } + fun sendInAppMessage(title: String, triggerKey: String, triggerValue: String) { viewModelScope.launch(Dispatchers.IO) { repository.addTrigger(triggerKey, triggerValue) 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 3673320125..027a79cb8c 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 @@ -2,15 +2,22 @@ package com.onesignal.example.ui.main import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ExpandLess +import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -18,11 +25,14 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.onesignal.example.data.model.InAppMessageType +import com.onesignal.example.data.model.NotificationExtensionOptions import com.onesignal.example.ui.components.CardKvRow import com.onesignal.example.ui.components.CollapsibleSingleList import com.onesignal.example.ui.components.CustomNotificationDialog @@ -290,6 +300,110 @@ fun SendPushSection( } } +@Composable +fun NotificationExtensionSection( + options: NotificationExtensionOptions, + onOptionsChange: (NotificationExtensionOptions) -> Unit, +) { + // Five switches is a lot of vertical space for a section that is off most of the time, so + // they stay folded until asked for. Enabling the extension opens them, since that is the + // moment you want them. + var expanded by remember { mutableStateOf(false) } + + DemoSection { + SectionCard(title = "Notification Service Extension", sectionKey = "nse") { + ToggleRow( + label = "Enable Extension", + description = "Off by default, so the demo's notifications stay untouched", + checked = options.enabled, + onCheckedChange = { + if (it) expanded = true + onOptionsChange(options.copy(enabled = it)) + }, + testTag = "nse_enabled_toggle", + contentDescription = "Enable notification service extension", + ) + + HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(vertical = DemoLayout.gap / 2) + .testTag("nse_options_toggle"), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (expanded) "Hide options" else "Show options", + style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), + color = OsPrimary, + ) + Icon( + imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, + contentDescription = null, + tint = OsPrimary, + modifier = Modifier.size(18.dp), + ) + } + + if (expanded) { + HorizontalDivider(color = OsDivider, modifier = Modifier.padding(bottom = DemoLayout.gap)) + ToggleRow( + label = "Log Details", + description = "Log id, sent time, and the channel the SDK resolved, under the DemoNSE tag", + checked = options.logDetails, + onCheckedChange = { onOptionsChange(options.copy(logDetails = it)) }, + enabled = options.enabled, + testTag = "nse_log_toggle", + contentDescription = "Log notification details", + ) + HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) + ToggleRow( + label = "Apply Extender", + description = "Prefix the title with [NSE] through a NotificationCompat.Extender", + checked = options.applyExtender, + onCheckedChange = { onOptionsChange(options.copy(applyExtender = it)) }, + enabled = options.enabled, + testTag = "nse_extender_toggle", + contentDescription = "Apply notification extender", + ) + HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) + ToggleRow( + label = "Force High Importance Channel", + description = "Move every notification onto an app-owned IMPORTANCE_HIGH channel", + checked = options.forceHighImportanceChannel, + onCheckedChange = { onOptionsChange(options.copy(forceHighImportanceChannel = it)) }, + enabled = options.enabled, + testTag = "nse_high_importance_toggle", + contentDescription = "Force high importance channel", + ) + HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) + ToggleRow( + label = "Delay Display", + description = "preventDefault(), then display() five seconds later", + checked = options.delayDisplay, + onCheckedChange = { onOptionsChange(options.copy(delayDisplay = it)) }, + enabled = options.enabled, + testTag = "nse_delay_toggle", + contentDescription = "Delay notification display", + ) + HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) + ToggleRow( + label = "Discard", + description = "preventDefault(true). Takes precedence over the switches above", + checked = options.discard, + onCheckedChange = { onOptionsChange(options.copy(discard = it)) }, + enabled = options.enabled, + testTag = "nse_discard_toggle", + contentDescription = "Discard notification", + ) + } + } + } +} + @Composable fun InAppMessagingSection( isPaused: Boolean, 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 86edcf6167..5c94620b62 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 @@ -2,6 +2,7 @@ package com.onesignal.example.util import android.content.Context import android.content.SharedPreferences +import com.onesignal.example.data.model.NotificationExtensionOptions object SharedPreferenceUtil { @@ -14,6 +15,15 @@ object SharedPreferenceUtil { private const val IDENTITY_VERIFICATION_PREF = "IDENTITY_VERIFICATION_PREF" private const val JWT_TOKEN_PREF = "JWT_TOKEN_PREF" + // Notification service extension switches. DemoNotificationServiceExtension reads these + // directly because it runs whether or not the app is open. + private const val NSE_ENABLED_PREF = "NSE_ENABLED_PREF" + private const val NSE_LOG_DETAILS_PREF = "NSE_LOG_DETAILS_PREF" + private const val NSE_APPLY_EXTENDER_PREF = "NSE_APPLY_EXTENDER_PREF" + private const val NSE_FORCE_HIGH_IMPORTANCE_PREF = "NSE_FORCE_HIGH_IMPORTANCE_PREF" + private const val NSE_DELAY_DISPLAY_PREF = "NSE_DELAY_DISPLAY_PREF" + private const val NSE_DISCARD_PREF = "NSE_DISCARD_PREF" + private fun getSharedPreference(context: Context): SharedPreferences { return context.getSharedPreferences(APP_SHARED_PREFS, Context.MODE_PRIVATE) } @@ -80,4 +90,29 @@ object SharedPreferenceUtil { fun cacheJwtToken(context: Context, token: String?) { getSharedPreference(context).edit().putString(JWT_TOKEN_PREF, token).apply() } + + // Every switch defaults to false so a fresh install behaves as if no extension were + // registered. See NotificationExtensionOptions. + fun getNotificationExtensionOptions(context: Context): NotificationExtensionOptions { + val prefs = getSharedPreference(context) + return NotificationExtensionOptions( + enabled = prefs.getBoolean(NSE_ENABLED_PREF, false), + logDetails = prefs.getBoolean(NSE_LOG_DETAILS_PREF, false), + applyExtender = prefs.getBoolean(NSE_APPLY_EXTENDER_PREF, false), + forceHighImportanceChannel = prefs.getBoolean(NSE_FORCE_HIGH_IMPORTANCE_PREF, false), + delayDisplay = prefs.getBoolean(NSE_DELAY_DISPLAY_PREF, false), + discard = prefs.getBoolean(NSE_DISCARD_PREF, false), + ) + } + + fun cacheNotificationExtensionOptions(context: Context, options: NotificationExtensionOptions) { + getSharedPreference(context).edit() + .putBoolean(NSE_ENABLED_PREF, options.enabled) + .putBoolean(NSE_LOG_DETAILS_PREF, options.logDetails) + .putBoolean(NSE_APPLY_EXTENDER_PREF, options.applyExtender) + .putBoolean(NSE_FORCE_HIGH_IMPORTANCE_PREF, options.forceHighImportanceChannel) + .putBoolean(NSE_DELAY_DISPLAY_PREF, options.delayDisplay) + .putBoolean(NSE_DISCARD_PREF, options.discard) + .apply() + } } From 18bd2bcd41951ea70549778f4157eea670f8de17 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 25 Aug 2026 15:22:27 -0700 Subject: [PATCH 2/4] refactor: [SDK-5083] mark demo log lines with [Demo] Demo output and forwarded SDK output sat side by side in logcat under tags that gave no hint which was which, which made reading a notification repro slower than it needed to be. DemoLog stamps both the tag and the message, so `logcat -s` still filters on the tag and a line stays recognizable when only the message column is in view. Callers pass the plain class name and DemoLog adds the prefix, keeping [Demo] in one place. All 127 demo call sites go through it. The five forwarding calls in MainApplication keep using android.util.Log and stay unmarked. Those lines are the SDK's, and marking them would bury the demo's own output whenever you grep [Demo]. --- examples/build.md | 17 +++- .../notification/HmsMessageServiceAppLevel.kt | 26 +++--- .../example/application/MainApplication.kt | 19 ++-- .../example/data/network/OneSignalService.kt | 42 ++++----- .../data/repository/OneSignalRepository.kt | 70 +++++++-------- .../DemoNotificationServiceExtension.kt | 10 +-- .../example/ui/main/MainViewModel.kt | 88 +++++++++---------- .../com/onesignal/example/ui/main/Sections.kt | 2 +- .../com/onesignal/example/util/DemoLog.kt | 34 +++++++ .../onesignal/example/util/TooltipHelper.kt | 2 +- 10 files changed, 178 insertions(+), 132 deletions(-) create mode 100644 examples/demo/app/src/main/java/com/onesignal/example/util/DemoLog.kt diff --git a/examples/build.md b/examples/build.md index 468ff9bd2d..51469bfa9e 100644 --- a/examples/build.md +++ b/examples/build.md @@ -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. --- @@ -213,7 +224,7 @@ The five behavior switches sit behind a Show options / Hide options row so the s | --- | --- | --- | | Enable Extension | `nse_enabled_toggle` | Master switch. Off means `onNotificationReceived` returns before touching anything. | | Show / Hide options | `nse_options_toggle` | Folds the five switches below. Not a setting, nothing is persisted. | -| Log Details | `nse_log_toggle` | Logs id, sent time, and the channel the SDK resolved, under the `DemoNSE` tag. | +| Log Details | `nse_log_toggle` | Logs id, sent time, and the channel the SDK resolved, under the `[Demo]NSE` tag. | | Apply Extender | `nse_extender_toggle` | Prefixes the title with `[NSE]` through a `NotificationCompat.Extender`. | | Force High Importance Channel | `nse_high_importance_toggle` | Moves the notification onto an app-owned `IMPORTANCE_HIGH` channel. | | Delay Display | `nse_delay_toggle` | `preventDefault()`, then `display()` five seconds later. | @@ -306,7 +317,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 diff --git a/examples/demo/app/src/huawei/java/com/onesignal/example/notification/HmsMessageServiceAppLevel.kt b/examples/demo/app/src/huawei/java/com/onesignal/example/notification/HmsMessageServiceAppLevel.kt index e51bfb57e9..30e1fc7c56 100644 --- a/examples/demo/app/src/huawei/java/com/onesignal/example/notification/HmsMessageServiceAppLevel.kt +++ b/examples/demo/app/src/huawei/java/com/onesignal/example/notification/HmsMessageServiceAppLevel.kt @@ -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. @@ -26,7 +26,7 @@ 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) @@ -34,7 +34,7 @@ class HmsMessageServiceAppLevel : HmsMessageService() { @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) @@ -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 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 c5bba7e741..ec725707d5 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 @@ -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 @@ -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() @@ -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 @@ -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}") } }) 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 d1adbaf019..bc8d1cedb6 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 @@ -1,8 +1,8 @@ package com.onesignal.example.data.network -import android.util.Log import com.onesignal.OneSignal import com.onesignal.example.BuildConfig +import com.onesignal.example.util.DemoLog import com.onesignal.example.data.model.NotificationType import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -41,13 +41,13 @@ object OneSignalService { val subscription = OneSignal.User.pushSubscription if (!subscription.optedIn) { - Log.w(TAG, "Cannot send notification - user not opted in") + DemoLog.w(TAG, "Cannot send notification - user not opted in") return@withContext false } val subscriptionId = subscription.id if (subscriptionId.isNullOrEmpty()) { - Log.w(TAG, "Cannot send notification - no subscription ID") + DemoLog.w(TAG, "Cannot send notification - no subscription ID") return@withContext false } @@ -62,16 +62,16 @@ object OneSignalService { put("android_accent_color", "FF595CF2") type.largeIcon?.let { put("large_icon", it) - Log.d(TAG, "Adding large_icon: $it") + DemoLog.d(TAG, "Adding large_icon: $it") } type.bigPicture?.let { put("big_picture", it) - Log.d(TAG, "Adding big_picture: $it") + DemoLog.d(TAG, "Adding big_picture: $it") } type.sound?.let { put("android_sound", it) put("android_channel_id", BuildConfig.ONESIGNAL_ANDROID_CHANNEL_ID) - Log.d(TAG, "Adding android_sound: $it (channel: ${BuildConfig.ONESIGNAL_ANDROID_CHANNEL_ID})") + DemoLog.d(TAG, "Adding android_sound: $it (channel: ${BuildConfig.ONESIGNAL_ANDROID_CHANNEL_ID})") } } @@ -79,7 +79,7 @@ object OneSignalService { } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.e(TAG, "Error sending notification", e) + DemoLog.e(TAG, "Error sending notification", e) return@withContext false } } @@ -91,13 +91,13 @@ object OneSignalService { val subscription = OneSignal.User.pushSubscription if (!subscription.optedIn) { - Log.w(TAG, "Cannot send notification - user not opted in") + DemoLog.w(TAG, "Cannot send notification - user not opted in") return@withContext false } val subscriptionId = subscription.id if (subscriptionId.isNullOrEmpty()) { - Log.w(TAG, "Cannot send notification - no subscription ID") + DemoLog.w(TAG, "Cannot send notification - no subscription ID") return@withContext false } @@ -115,7 +115,7 @@ object OneSignalService { } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.e(TAG, "Error sending custom notification", e) + DemoLog.e(TAG, "Error sending custom notification", e) return@withContext false } } @@ -157,7 +157,7 @@ object OneSignalService { } if (responseCode !in 200..299) { - Log.e(TAG, "Send $label failed: $response") + DemoLog.e(TAG, "Send $label failed: $response") return false } @@ -166,7 +166,7 @@ object OneSignalService { delay(backoffMs(attempt)) continue } - Log.e(TAG, "Send $label failed: $response") + DemoLog.e(TAG, "Send $label failed: $response") return false } @@ -176,7 +176,7 @@ object OneSignalService { // teardown while `delay` is suspending between retries). throw e } catch (e: Exception) { - Log.e(TAG, "Send $label error: ${e.message}") + DemoLog.e(TAG, "Send $label error: ${e.message}") return false } finally { connection.disconnect() @@ -213,12 +213,12 @@ object OneSignalService { */ suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserData? = withContext(Dispatchers.IO) { if (aliasValue.isEmpty()) { - Log.w(TAG, "Cannot fetch user - aliasValue is empty") + DemoLog.w(TAG, "Cannot fetch user - aliasValue is empty") return@withContext null } if (appId.isEmpty()) { - Log.w(TAG, "Cannot fetch user - appId not set") + DemoLog.w(TAG, "Cannot fetch user - appId not set") return@withContext null } @@ -229,7 +229,7 @@ object OneSignalService { // space as `+`; swap to %20 since `+` is treated as a literal in paths. val encodedAliasValue = URLEncoder.encode(aliasValue, "UTF-8").replace("+", "%20") val url = "$ONESIGNAL_API_BASE_URL/apps/$appId/users/by/$aliasLabel/$encodedAliasValue" - Log.d(TAG, "Fetching user data from: $url") + DemoLog.d(TAG, "Fetching user data from: $url") val connection = (URL(url).openConnection() as HttpURLConnection).apply { useCaches = false @@ -246,24 +246,24 @@ object OneSignalService { if (responseCode == HttpURLConnection.HTTP_OK) { val response = connection.inputStream.bufferedReader().use { it.readText() } - Log.d(TAG, "User data fetched successfully, parsing response...") + DemoLog.d(TAG, "User data fetched successfully, parsing response...") try { val userData = parseUserResponse(response) - Log.d(TAG, "Parsed user data: aliases=${userData.aliases.size}, tags=${userData.tags.size}, emails=${userData.emails.size}, sms=${userData.smsNumbers.size}") + DemoLog.d(TAG, "Parsed user data: aliases=${userData.aliases.size}, tags=${userData.tags.size}, emails=${userData.emails.size}, sms=${userData.smsNumbers.size}") return@withContext userData } catch (e: Exception) { - Log.e(TAG, "Error parsing user response", e) + DemoLog.e(TAG, "Error parsing user response", e) return@withContext null } } else { val errorResponse = connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "Unknown error" - Log.e(TAG, "Failed to fetch user (HTTP $responseCode): $errorResponse") + DemoLog.e(TAG, "Failed to fetch user (HTTP $responseCode): $errorResponse") return@withContext null } } catch (e: CancellationException) { throw e } catch (e: Exception) { - Log.e(TAG, "Error fetching user", e) + DemoLog.e(TAG, "Error fetching user", e) return@withContext null } } 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 818a484faf..9c0473e8f8 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 @@ -1,12 +1,12 @@ package com.onesignal.example.data.repository -import android.util.Log import com.onesignal.OneSignal import com.onesignal.example.data.model.NotificationType import com.onesignal.example.data.network.OneSignalService import com.onesignal.example.data.network.UserData import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import com.onesignal.example.util.DemoLog /** * Repository for all OneSignal SDK operations. @@ -20,39 +20,39 @@ class OneSignalRepository { // User operations suspend fun loginUser(externalUserId: String, jwtToken: String? = null) = withContext(Dispatchers.IO) { - Log.d(TAG, "Logging in user with externalUserId: $externalUserId, jwt: ${if (jwtToken != null) "provided" else "none"}") + DemoLog.d(TAG, "Logging in user with externalUserId: $externalUserId, jwt: ${if (jwtToken != null) "provided" else "none"}") OneSignal.login(externalUserId, jwtToken) - Log.d(TAG, "Logged in user with onesignalId: ${OneSignal.User.onesignalId}") + DemoLog.d(TAG, "Logged in user with onesignalId: ${OneSignal.User.onesignalId}") } suspend fun updateUserJwt(externalUserId: String, jwtToken: String) = withContext(Dispatchers.IO) { - Log.d(TAG, "Updating JWT for externalUserId: $externalUserId") + DemoLog.d(TAG, "Updating JWT for externalUserId: $externalUserId") OneSignal.updateUserJwt(externalUserId, jwtToken) } suspend fun logoutUser() = withContext(Dispatchers.IO) { - Log.d(TAG, "Logging out user") + DemoLog.d(TAG, "Logging out user") OneSignal.logout() } // Alias operations fun addAlias(label: String, id: String) { - Log.d(TAG, "Adding alias: $label -> $id") + DemoLog.d(TAG, "Adding alias: $label -> $id") OneSignal.User.addAlias(label, id) } fun addAliases(aliases: Map) { - Log.d(TAG, "Adding aliases: $aliases") + DemoLog.d(TAG, "Adding aliases: $aliases") OneSignal.User.addAliases(aliases) } fun removeAlias(label: String) { - Log.d(TAG, "Removing alias: $label") + DemoLog.d(TAG, "Removing alias: $label") OneSignal.User.removeAlias(label) } fun removeAliases(labels: Collection) { - Log.d(TAG, "Removing aliases: $labels") + DemoLog.d(TAG, "Removing aliases: $labels") if (labels.isNotEmpty()) { OneSignal.User.removeAliases(labels) } @@ -60,44 +60,44 @@ class OneSignalRepository { // Email operations fun addEmail(email: String) { - Log.d(TAG, "Adding email: $email") + DemoLog.d(TAG, "Adding email: $email") OneSignal.User.addEmail(email) } fun removeEmail(email: String) { - Log.d(TAG, "Removing email: $email") + DemoLog.d(TAG, "Removing email: $email") OneSignal.User.removeEmail(email) } // SMS operations fun addSms(smsNumber: String) { - Log.d(TAG, "Adding SMS: $smsNumber") + DemoLog.d(TAG, "Adding SMS: $smsNumber") OneSignal.User.addSms(smsNumber) } fun removeSms(smsNumber: String) { - Log.d(TAG, "Removing SMS: $smsNumber") + DemoLog.d(TAG, "Removing SMS: $smsNumber") OneSignal.User.removeSms(smsNumber) } // Tag operations fun addTag(key: String, value: String) { - Log.d(TAG, "Adding tag: $key -> $value") + DemoLog.d(TAG, "Adding tag: $key -> $value") OneSignal.User.addTag(key, value) } fun addTags(tags: Map) { - Log.d(TAG, "Adding tags: $tags") + DemoLog.d(TAG, "Adding tags: $tags") OneSignal.User.addTags(tags) } fun removeTag(key: String) { - Log.d(TAG, "Removing tag: $key") + DemoLog.d(TAG, "Removing tag: $key") OneSignal.User.removeTag(key) } fun removeTags(keys: Collection) { - Log.d(TAG, "Removing tags: $keys") + DemoLog.d(TAG, "Removing tags: $keys") if (keys.isNotEmpty()) { OneSignal.User.removeTags(keys) } @@ -109,22 +109,22 @@ class OneSignalRepository { // Trigger operations fun addTrigger(key: String, value: String) { - Log.d(TAG, "Adding trigger: $key -> $value") + DemoLog.d(TAG, "Adding trigger: $key -> $value") OneSignal.InAppMessages.addTrigger(key, value) } fun addTriggers(triggers: Map) { - Log.d(TAG, "Adding triggers: $triggers") + DemoLog.d(TAG, "Adding triggers: $triggers") OneSignal.InAppMessages.addTriggers(triggers) } fun removeTrigger(key: String) { - Log.d(TAG, "Removing trigger: $key") + DemoLog.d(TAG, "Removing trigger: $key") OneSignal.InAppMessages.removeTrigger(key) } fun clearTriggers(keys: Collection) { - Log.d(TAG, "Clearing triggers: $keys") + DemoLog.d(TAG, "Clearing triggers: $keys") if (keys.isNotEmpty()) { OneSignal.InAppMessages.removeTriggers(keys) } @@ -132,23 +132,23 @@ class OneSignalRepository { // Outcome operations fun sendOutcome(name: String) { - Log.d(TAG, "Sending outcome: $name") + DemoLog.d(TAG, "Sending outcome: $name") OneSignal.Session.addOutcome(name) } fun sendUniqueOutcome(name: String) { - Log.d(TAG, "Sending unique outcome: $name") + DemoLog.d(TAG, "Sending unique outcome: $name") OneSignal.Session.addUniqueOutcome(name) } fun sendOutcomeWithValue(name: String, value: Float) { - Log.d(TAG, "Sending outcome with value: $name -> $value") + DemoLog.d(TAG, "Sending outcome with value: $name -> $value") OneSignal.Session.addOutcomeWithValue(name, value) } // Track Event fun trackEvent(name: String, properties: Map?) { - Log.d(TAG, "Tracking event: $name with properties: $properties") + DemoLog.d(TAG, "Tracking event: $name with properties: $properties") OneSignal.User.trackEvent(name, properties) } @@ -162,7 +162,7 @@ class OneSignalRepository { } fun setPushEnabled(enabled: Boolean) { - Log.d(TAG, "Setting push enabled: $enabled") + DemoLog.d(TAG, "Setting push enabled: $enabled") if (enabled) { OneSignal.User.pushSubscription.optIn() } else { @@ -176,7 +176,7 @@ class OneSignalRepository { } fun setInAppMessagesPaused(paused: Boolean) { - Log.d(TAG, "Setting in-app messages paused: $paused") + DemoLog.d(TAG, "Setting in-app messages paused: $paused") OneSignal.InAppMessages.paused = paused } @@ -186,18 +186,18 @@ class OneSignalRepository { } fun setLocationShared(shared: Boolean) { - Log.d(TAG, "Setting location shared: $shared") + DemoLog.d(TAG, "Setting location shared: $shared") OneSignal.Location.isShared = shared } suspend fun promptLocation() = withContext(Dispatchers.IO) { - Log.d(TAG, "Prompting for location permission") + DemoLog.d(TAG, "Prompting for location permission") OneSignal.Location.requestPermission() } // Notifications suspend fun promptPushPermission() = withContext(Dispatchers.IO) { - Log.d(TAG, "Prompting for push permission") + DemoLog.d(TAG, "Prompting for push permission") OneSignal.Notifications.requestPermission(true) } @@ -207,18 +207,18 @@ class OneSignalRepository { // Send notifications suspend fun sendNotification(type: NotificationType): Boolean { - Log.d(TAG, "Sending notification: ${type.title}") + DemoLog.d(TAG, "Sending notification: ${type.title}") return OneSignalService.sendNotification(type) } suspend fun sendCustomNotification(title: String, body: String): Boolean { - Log.d(TAG, "Sending custom notification: $title") + DemoLog.d(TAG, "Sending custom notification: $title") return OneSignalService.sendCustomNotification(title, body) } // Privacy consent fun setConsentRequired(required: Boolean) { - Log.d(TAG, "Setting consent required: $required") + DemoLog.d(TAG, "Setting consent required: $required") OneSignal.consentRequired = required } @@ -227,7 +227,7 @@ class OneSignalRepository { } fun setPrivacyConsent(granted: Boolean) { - Log.d(TAG, "Setting privacy consent: $granted") + DemoLog.d(TAG, "Setting privacy consent: $granted") OneSignal.consentGiven = granted } @@ -242,7 +242,7 @@ class OneSignalRepository { // Fetch user data from API suspend fun fetchUser(aliasLabel: String, aliasValue: String, jwt: String? = null): UserData? = withContext(Dispatchers.IO) { - Log.d(TAG, "Fetching user data by $aliasLabel: $aliasValue") + DemoLog.d(TAG, "Fetching user data by $aliasLabel: $aliasValue") OneSignalService.fetchUser(aliasLabel, aliasValue, jwt) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt b/examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt index bcbdedeafc..da323fd729 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt @@ -4,9 +4,9 @@ import android.app.NotificationChannel import android.app.NotificationManager import android.content.Context import android.os.Build -import android.util.Log import androidx.core.app.NotificationCompat import com.onesignal.example.data.model.NotificationExtensionOptions +import com.onesignal.example.util.DemoLog import com.onesignal.example.util.SharedPreferenceUtil import com.onesignal.notifications.IDisplayableMutableNotification import com.onesignal.notifications.INotificationReceivedEvent @@ -36,7 +36,7 @@ class DemoNotificationServiceExtension : INotificationServiceExtension { if (options.logDetails) { // TODO: [SDK-5011] log `event.restoring` here once it ships. Reading it next to the // channel below is the whole diagnosis for a notification that re-alerts on reboot. - Log.d( + DemoLog.d( TAG, "received androidNotificationId=${notification.androidNotificationId}" + " notificationId=${notification.notificationId}" + @@ -47,7 +47,7 @@ class DemoNotificationServiceExtension : INotificationServiceExtension { if (options.discard) { if (options.logDetails) { - Log.d(TAG, "discarding androidNotificationId=${notification.androidNotificationId}") + DemoLog.d(TAG, "discarding androidNotificationId=${notification.androidNotificationId}") } event.preventDefault(true) return @@ -79,7 +79,7 @@ class DemoNotificationServiceExtension : INotificationServiceExtension { // extension can see what the SDK picked. A restored notification lands on // `restored_OS_notifications` no matter what the payload asked for, and the payload // by itself never shows that. - Log.d( + DemoLog.d( TAG, "building androidNotificationId=${notification.androidNotificationId}" + " channel=${NotificationCompat.getChannelId(builder.build())}", @@ -122,7 +122,7 @@ class DemoNotificationServiceExtension : INotificationServiceExtension { } private companion object { - const val TAG = "DemoNSE" + const val TAG = "NSE" const val HIGH_IMPORTANCE_CHANNEL_ID = "demo_nse_high_importance" // Well under the SDK's 30 second wait for the extension, and long enough to watch the 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 4676f1575f..2d6bd010fc 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 @@ -1,7 +1,6 @@ package com.onesignal.example.ui.main import android.app.Application -import android.util.Log import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData @@ -13,6 +12,7 @@ import com.onesignal.notifications.IPermissionObserver import com.onesignal.example.data.model.NotificationExtensionOptions import com.onesignal.example.data.model.NotificationType import com.onesignal.example.data.repository.OneSignalRepository +import com.onesignal.example.util.DemoLog import com.onesignal.example.util.SharedPreferenceUtil import com.onesignal.user.state.IUserStateObserver import com.onesignal.user.state.UserChangedState @@ -115,14 +115,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I private var fetchRequestSequence = 0L init { - Log.i(TAG, "App initialized") + DemoLog.i(TAG, "App initialized") loadInitialState() OneSignal.User.pushSubscription.addObserver(this) OneSignal.Notifications.addPermissionObserver(this) OneSignal.User.addObserver(this) OneSignal.addUserJwtInvalidatedListener(this) - Log.d(TAG, "init: observers registered, current onesignalId=${OneSignal.User.onesignalId}") - Log.d(TAG, "OneSignal ID: ${OneSignal.User.onesignalId ?: "not set"}") + DemoLog.d(TAG, "init: observers registered, current onesignalId=${OneSignal.User.onesignalId}") + DemoLog.d(TAG, "OneSignal ID: ${OneSignal.User.onesignalId ?: "not set"}") } // IPermissionObserver @@ -132,7 +132,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I // IUserStateObserver - called when user changes (login/logout) override fun onUserStateChange(state: UserChangedState) { - Log.d(TAG, "onUserStateChange fired: ${state.current.onesignalId}") + DemoLog.d(TAG, "onUserStateChange fired: ${state.current.onesignalId}") _oneSignalId.postValue(state.current.onesignalId) viewModelScope.launch(Dispatchers.Main) { loadExistingAliases() @@ -234,7 +234,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } catch (e: CancellationException) { throw e } catch (e: Exception) { - android.util.Log.e("MainViewModel", "Error fetching user data", e) + DemoLog.e(TAG, "Error fetching user data", e) withContext(Dispatchers.Main) { if (requestId != fetchRequestSequence) return@withContext logError("Failed to fetch user data: ${e.message}") @@ -298,7 +298,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I repository.updateUserJwt(externalUserId, jwtToken) withContext(Dispatchers.Main) { SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) - Log.i(TAG, "Updated JWT for: $externalUserId") + DemoLog.i(TAG, "Updated JWT for: $externalUserId") } } } @@ -327,7 +327,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I fun setUseIdentityVerification(enabled: Boolean) { SharedPreferenceUtil.cacheIdentityVerification(getApplication(), enabled) _useIdentityVerification.value = enabled - Log.i(TAG, if (enabled) "Identity verification enabled" else "Identity verification disabled") + DemoLog.i(TAG, if (enabled) "Identity verification enabled" else "Identity verification disabled") } // Consent required @@ -335,7 +335,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I repository.setConsentRequired(required) SharedPreferenceUtil.cacheConsentRequired(getApplication(), required) _consentRequired.value = required - Log.i(TAG, if (required) "Consent required enabled" else "Consent required disabled") + DemoLog.i(TAG, if (required) "Consent required enabled" else "Consent required disabled") } // Privacy consent @@ -343,7 +343,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I repository.setPrivacyConsent(granted) SharedPreferenceUtil.cacheUserPrivacyConsent(getApplication(), granted) _privacyConsentGiven.value = granted - Log.i(TAG, if (granted) "Consent granted" else "Consent revoked") + DemoLog.i(TAG, if (granted) "Consent granted" else "Consent revoked") } // Alias operations (single and batch) @@ -354,7 +354,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I aliasesList.removeAll { it.first == label } aliasesList.add(Pair(label, id)) refreshAliases() - Log.i(TAG, "Alias added: $label") + DemoLog.i(TAG, "Alias added: $label") } } } @@ -369,7 +369,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I aliasesList.add(Pair(label, id)) } refreshAliases() - Log.i(TAG, "${pairs.size} alias(es) added") + DemoLog.i(TAG, "${pairs.size} alias(es) added") } } } @@ -380,7 +380,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I withContext(Dispatchers.Main) { aliasesList.removeAll { it.first == label } refreshAliases() - Log.i(TAG, "Alias removed: $label") + DemoLog.i(TAG, "Alias removed: $label") } } } @@ -391,7 +391,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I withContext(Dispatchers.Main) { aliasesList.removeAll { it.first in labels } refreshAliases() - Log.i(TAG, "${labels.size} alias(es) removed") + DemoLog.i(TAG, "${labels.size} alias(es) removed") } } } @@ -405,7 +405,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I emailsList.add(email) refreshEmails() } - Log.i(TAG, "Email added: $email") + DemoLog.i(TAG, "Email added: $email") } } } @@ -416,7 +416,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I withContext(Dispatchers.Main) { emailsList.remove(email) refreshEmails() - Log.i(TAG, "Email removed: $email") + DemoLog.i(TAG, "Email removed: $email") } } } @@ -430,7 +430,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I smsNumbersList.add(smsNumber) refreshSmsNumbers() } - Log.i(TAG, "SMS added: $smsNumber") + DemoLog.i(TAG, "SMS added: $smsNumber") } } } @@ -441,7 +441,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I withContext(Dispatchers.Main) { smsNumbersList.remove(smsNumber) refreshSmsNumbers() - Log.i(TAG, "SMS removed: $smsNumber") + DemoLog.i(TAG, "SMS removed: $smsNumber") } } } @@ -452,7 +452,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I repository.addTag(key, value) withContext(Dispatchers.Main) { loadExistingTags() - Log.i(TAG, "Tag added: $key") + DemoLog.i(TAG, "Tag added: $key") } } } @@ -463,7 +463,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I repository.addTags(map) withContext(Dispatchers.Main) { loadExistingTags() - Log.i(TAG, "${pairs.size} tag(s) added") + DemoLog.i(TAG, "${pairs.size} tag(s) added") } } } @@ -473,7 +473,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I repository.removeTag(key) withContext(Dispatchers.Main) { loadExistingTags() - Log.i(TAG, "Tag removed: $key") + DemoLog.i(TAG, "Tag removed: $key") } } } @@ -483,7 +483,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I repository.removeTags(keys) withContext(Dispatchers.Main) { loadExistingTags() - Log.i(TAG, "${keys.size} tag(s) removed") + DemoLog.i(TAG, "${keys.size} tag(s) removed") } } } @@ -496,7 +496,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I triggersList.removeAll { it.first == key } triggersList.add(Pair(key, value)) refreshTriggers() - Log.i(TAG, "Trigger added: $key") + DemoLog.i(TAG, "Trigger added: $key") } } } @@ -511,7 +511,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I triggersList.add(Pair(key, value)) } refreshTriggers() - Log.i(TAG, "${pairs.size} trigger(s) added") + DemoLog.i(TAG, "${pairs.size} trigger(s) added") } } } @@ -522,7 +522,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I withContext(Dispatchers.Main) { triggersList.removeAll { it.first == key } refreshTriggers() - Log.i(TAG, "Trigger removed: $key") + DemoLog.i(TAG, "Trigger removed: $key") } } } @@ -533,7 +533,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I withContext(Dispatchers.Main) { triggersList.removeAll { it.first in keys } refreshTriggers() - Log.i(TAG, "${keys.size} trigger(s) removed") + DemoLog.i(TAG, "${keys.size} trigger(s) removed") } } } @@ -545,7 +545,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I withContext(Dispatchers.Main) { triggersList.clear() refreshTriggers() - Log.i(TAG, "All triggers cleared") + DemoLog.i(TAG, "All triggers cleared") } } } @@ -554,21 +554,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I fun sendOutcome(name: String) { viewModelScope.launch(Dispatchers.IO) { repository.sendOutcome(name) - withContext(Dispatchers.Main) { Log.i(TAG, "Outcome sent: $name") } + withContext(Dispatchers.Main) { DemoLog.i(TAG, "Outcome sent: $name") } } } fun sendUniqueOutcome(name: String) { viewModelScope.launch(Dispatchers.IO) { repository.sendUniqueOutcome(name) - withContext(Dispatchers.Main) { Log.i(TAG, "Unique outcome sent: $name") } + withContext(Dispatchers.Main) { DemoLog.i(TAG, "Unique outcome sent: $name") } } } fun sendOutcomeWithValue(name: String, value: Float) { viewModelScope.launch(Dispatchers.IO) { repository.sendOutcomeWithValue(name, value) - withContext(Dispatchers.Main) { Log.i(TAG, "Outcome sent: $name = $value") } + withContext(Dispatchers.Main) { DemoLog.i(TAG, "Outcome sent: $name = $value") } } } @@ -576,7 +576,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I fun trackEvent(name: String, properties: Map?) { viewModelScope.launch(Dispatchers.IO) { repository.trackEvent(name, properties) - withContext(Dispatchers.Main) { Log.i(TAG, "Event tracked: $name") } + withContext(Dispatchers.Main) { DemoLog.i(TAG, "Event tracked: $name") } } } @@ -586,7 +586,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I repository.setPushEnabled(enabled) withContext(Dispatchers.Main) { _pushEnabled.value = enabled - Log.i(TAG, if (enabled) "Push enabled" else "Push disabled") + DemoLog.i(TAG, if (enabled) "Push enabled" else "Push disabled") } } } @@ -614,7 +614,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I repository.setInAppMessagesPaused(paused) SharedPreferenceUtil.cacheInAppMessagingPausedStatus(getApplication(), paused) _inAppMessagesPaused.value = paused - Log.i(TAG, if (paused) "In-app messages paused" else "In-app messages resumed") + DemoLog.i(TAG, if (paused) "In-app messages paused" else "In-app messages resumed") } // Location @@ -622,19 +622,19 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I repository.setLocationShared(shared) SharedPreferenceUtil.cacheLocationSharedStatus(getApplication(), shared) _locationShared.value = shared - Log.i(TAG, if (shared) "Location sharing enabled" else "Location sharing disabled") + DemoLog.i(TAG, if (shared) "Location sharing enabled" else "Location sharing disabled") } fun checkLocationShared(): Boolean { val shared = repository.isLocationShared() - Log.i(TAG, "Location shared: $shared") + DemoLog.i(TAG, "Location shared: $shared") return shared } fun promptLocation() { viewModelScope.launch(Dispatchers.IO) { repository.promptLocation() - withContext(Dispatchers.Main) { Log.i(TAG, "Location permission requested") } + withContext(Dispatchers.Main) { DemoLog.i(TAG, "Location permission requested") } } } @@ -645,7 +645,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I val success = repository.sendNotification(type) withContext(Dispatchers.Main) { if (success) { - Log.i(TAG, "Notification sent: ${type.title}") + DemoLog.i(TAG, "Notification sent: ${type.title}") } else { logError("Failed to send notification: ${type.title}") } @@ -659,7 +659,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I val success = repository.sendCustomNotification(title, body) withContext(Dispatchers.Main) { if (success) { - Log.i(TAG, "Notification sent: $title") + DemoLog.i(TAG, "Notification sent: $title") } else { logError("Failed to send notification: $title") } @@ -669,14 +669,14 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I fun clearAllNotifications() { OneSignal.Notifications.clearAllNotifications() - Log.i(TAG, "All notifications cleared") + DemoLog.i(TAG, "All notifications cleared") } // Notification service extension fun setNotificationExtensionOptions(options: NotificationExtensionOptions) { SharedPreferenceUtil.cacheNotificationExtensionOptions(getApplication(), options) _notificationExtensionOptions.value = options - Log.i(TAG, "Notification service extension options: $options") + DemoLog.i(TAG, "Notification service extension options: $options") } fun sendInAppMessage(title: String, triggerKey: String, triggerValue: String) { @@ -686,13 +686,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I triggersList.removeAll { it.first == triggerKey } triggersList.add(Pair(triggerKey, triggerValue)) refreshTriggers() - Log.i(TAG, "Sent In-App Message: $title") + DemoLog.i(TAG, "Sent In-App Message: $title") } } } - private fun logError(message: String) = Log.e(TAG, message) - private fun logDebug(message: String) = Log.d(TAG, message) + private fun logError(message: String) = DemoLog.e(TAG, message) + private fun logDebug(message: String) = DemoLog.d(TAG, message) override fun onPushSubscriptionChange(state: PushSubscriptionChangedState) { _pushSubscriptionId.postValue(state.current.id) @@ -700,7 +700,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } override fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent) { - Log.w(TAG, "JWT invalidated for externalId: ${event.externalId}") + DemoLog.w(TAG, "JWT invalidated for externalId: ${event.externalId}") } override fun onCleared() { 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 027a79cb8c..fb2cb8b0a8 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 @@ -352,7 +352,7 @@ fun NotificationExtensionSection( HorizontalDivider(color = OsDivider, modifier = Modifier.padding(bottom = DemoLayout.gap)) ToggleRow( label = "Log Details", - description = "Log id, sent time, and the channel the SDK resolved, under the DemoNSE tag", + description = "Log id, sent time, and the channel the SDK resolved, under the [Demo]NSE tag", checked = options.logDetails, onCheckedChange = { onOptionsChange(options.copy(logDetails = it)) }, enabled = options.enabled, diff --git a/examples/demo/app/src/main/java/com/onesignal/example/util/DemoLog.kt b/examples/demo/app/src/main/java/com/onesignal/example/util/DemoLog.kt new file mode 100644 index 0000000000..ac6985b447 --- /dev/null +++ b/examples/demo/app/src/main/java/com/onesignal/example/util/DemoLog.kt @@ -0,0 +1,34 @@ +package com.onesignal.example.util + +import android.util.Log + +/** + * Logging for the demo app. Marks both halves of every line with `[Demo]`, so `logcat -s` can + * filter on the tag and a line is still recognizable when only the message column is in view. + * + * ``` + * DemoLog.d(TAG, "Sending notification: Simple") + * // D/[Demo]MainViewModel: [Demo] Sending notification: Simple + * ``` + * + * Pass the plain class name as the tag. This adds the prefix. + * + * SDK output that MainApplication's log listener forwards does not come through here. Those + * lines belong to the SDK, and marking them would bury the demo's own output when you grep. + */ +object DemoLog { + private const val PREFIX = "[Demo]" + + fun v(tag: String, message: String) = Log.v(PREFIX + tag, "$PREFIX $message") + + fun d(tag: String, message: String) = Log.d(PREFIX + tag, "$PREFIX $message") + + fun i(tag: String, message: String) = Log.i(PREFIX + tag, "$PREFIX $message") + + fun w(tag: String, message: String) = Log.w(PREFIX + tag, "$PREFIX $message") + + fun e(tag: String, message: String) = Log.e(PREFIX + tag, "$PREFIX $message") + + fun e(tag: String, message: String, throwable: Throwable) = + Log.e(PREFIX + tag, "$PREFIX $message", throwable) +} diff --git a/examples/demo/app/src/main/java/com/onesignal/example/util/TooltipHelper.kt b/examples/demo/app/src/main/java/com/onesignal/example/util/TooltipHelper.kt index 95031977e4..7097827328 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/util/TooltipHelper.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/util/TooltipHelper.kt @@ -72,7 +72,7 @@ object TooltipHelper { } } catch (e: Exception) { // Tooltips are non-critical; log and continue - android.util.Log.w("TooltipHelper", "Failed to fetch tooltip content: ${e.message}") + DemoLog.w("TooltipHelper", "Failed to fetch tooltip content: ${e.message}") } } } From e705a8ad4084a1948016124dcfaf07f015b19eb5 Mon Sep 17 00:00:00 2001 From: Nan Date: Tue, 25 Aug 2026 15:58:22 -0700 Subject: [PATCH 3/4] docs: [SDK-5083] correct why the demo extender is conditional The comment claimed an extender makes the SDK display a data-only push, so installing a no-op one would not be inert. That is not how 5.x behaves. processHandlerResponse gates on canDisplay, a non-empty notification body, before it reaches shouldDisplayNotification, so hasExtender() is never read for a bodyless push and an extender cannot rescue one. The code stays as it is. Setting an extender only when a switch needs one is still right, just for the duller reason that nothing asked for it otherwise. Left uncorrected, a customer reading the demo could design around SDK behavior that does not exist. --- examples/build.md | 2 +- .../notification/DemoNotificationServiceExtension.kt | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/examples/build.md b/examples/build.md index 51469bfa9e..1e3111a322 100644 --- a/examples/build.md +++ b/examples/build.md @@ -232,7 +232,7 @@ The five behavior switches sit behind a Show options / Hide options row so the s 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. -An extender also makes the SDK display a data-only push that carries no `alert` (`NotificationGenerationProcessor.shouldDisplayNotification`), so the class sets one only when a switch needs it. +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. --- diff --git a/examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt b/examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt index da323fd729..390ee42d9e 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt @@ -53,9 +53,11 @@ class DemoNotificationServiceExtension : INotificationServiceExtension { return } - // Set an extender only when a switch needs one. An extender makes the SDK display a - // data-only push that carries no `alert` (NotificationGenerationProcessor - // .shouldDisplayNotification), so an always-installed no-op extender is not free. + // Set an extender only when a switch needs one, rather than installing a no-op + // whenever the extension is on. This 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. if (options.logDetails || options.applyExtender || options.forceHighImportanceChannel) { notification.setExtender(buildExtender(event.context, notification, options)) } From 0717e9ad74e0074e1288437cec1dacb4170eecc3 Mon Sep 17 00:00:00 2001 From: Nan Date: Fri, 28 Aug 2026 11:57:29 -0700 Subject: [PATCH 4/4] refactor: [SDK-5083] show only the master toggle for the demo extension The shared demo is meant to look almost the same across every wrapper, so a section with six switches and a fold row was more surface than this earns. Enable Extension is the only control now, and the section reads like the In-App Messaging card next to it. The five behavior switches are unchanged and still wired end to end. They just have no UI: flip the defaults in SharedPreferenceUtil.getNotificationExtensionOptions and rebuild when reproducing something. --- examples/build.md | 24 ++--- .../model/NotificationExtensionOptions.kt | 4 + .../DemoNotificationServiceExtension.kt | 8 +- .../com/onesignal/example/ui/main/Sections.kt | 101 +----------------- 4 files changed, 25 insertions(+), 112 deletions(-) diff --git a/examples/build.md b/examples/build.md index 1e3111a322..e03f608c13 100644 --- a/examples/build.md +++ b/examples/build.md @@ -216,19 +216,17 @@ The Android demo exercises a few SDK features that are not described in the shar 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. -Every behavior is off until switched on in the **Notification Service Extension** section, so the notifications the demo sends stay usable as a manual QA baseline. Switches live in `NotificationExtensionOptions` and persist through `SharedPreferenceUtil`; the extension reads them from SharedPreferences rather than `MainViewModel`, because it runs whether or not the app is open. - -The five behavior switches sit behind a Show options / Hide options row so the section stays two rows tall while the extension is off. Enable Extension is always visible, and turning it on opens the options. The row reuses the collapse idiom from `CollapsibleSingleList` in `ListComponents.kt` (centered, `OsPrimary` label, `ExpandMore` / `ExpandLess` chevron). - -| Toggle | testTag | What it does | -| --- | --- | --- | -| Enable Extension | `nse_enabled_toggle` | Master switch. Off means `onNotificationReceived` returns before touching anything. | -| Show / Hide options | `nse_options_toggle` | Folds the five switches below. Not a setting, nothing is persisted. | -| Log Details | `nse_log_toggle` | Logs id, sent time, and the channel the SDK resolved, under the `[Demo]NSE` tag. | -| Apply Extender | `nse_extender_toggle` | Prefixes the title with `[NSE]` through a `NotificationCompat.Extender`. | -| Force High Importance Channel | `nse_high_importance_toggle` | Moves the notification onto an app-owned `IMPORTANCE_HIGH` channel. | -| Delay Display | `nse_delay_toggle` | `preventDefault()`, then `display()` five seconds later. | -| Discard | `nse_discard_toggle` | `preventDefault(true)`. Takes precedence over the other switches. | +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. diff --git a/examples/demo/app/src/main/java/com/onesignal/example/data/model/NotificationExtensionOptions.kt b/examples/demo/app/src/main/java/com/onesignal/example/data/model/NotificationExtensionOptions.kt index ca55e12aaa..49fb3b9679 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/data/model/NotificationExtensionOptions.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/data/model/NotificationExtensionOptions.kt @@ -5,6 +5,10 @@ package com.onesignal.example.data.model * * 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, diff --git a/examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt b/examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt index 390ee42d9e..e883db43b0 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/notification/DemoNotificationServiceExtension.kt @@ -19,9 +19,11 @@ import com.onesignal.notifications.INotificationServiceExtension * runtime instead of at build time. That reflection is also why the class needs the -keep rule * in `onesignal/notifications/consumer-rules.pro` to survive R8. * - * Nothing here runs until you turn it on in the demo's Notification Service Extension section. - * An always-on extension would change the baseline for every notification the demo sends, and - * anyone chasing a grouping or channel bug would end up debugging this file without knowing it. + * Nothing here runs until Enable Extension is turned on in the demo's Notification Service + * Extension section. An always-on extension would change the baseline for every notification the + * demo sends, and anyone chasing a grouping or channel bug would end up debugging this file + * without knowing it. That master toggle is the only UI; the behavior switches are flipped in + * code through [NotificationExtensionOptions] and SharedPreferenceUtil. * * The switches come from SharedPreferences, not MainViewModel. This is called whether or not the * app is open, so there may be no ViewModel yet. 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 fb2cb8b0a8..44287c61dd 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 @@ -2,22 +2,15 @@ package com.onesignal.example.ui.main import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ExpandLess -import androidx.compose.material.icons.filled.ExpandMore import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -25,10 +18,8 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.onesignal.example.data.model.InAppMessageType @@ -305,101 +296,19 @@ fun NotificationExtensionSection( options: NotificationExtensionOptions, onOptionsChange: (NotificationExtensionOptions) -> Unit, ) { - // Five switches is a lot of vertical space for a section that is off most of the time, so - // they stay folded until asked for. Enabling the extension opens them, since that is the - // moment you want them. - var expanded by remember { mutableStateOf(false) } - DemoSection { SectionCard(title = "Notification Service Extension", sectionKey = "nse") { + // Only the master switch gets UI, keeping the section close to the other wrapper + // demos. The behavior switches stay code-level; flip the defaults in + // SharedPreferenceUtil.getNotificationExtensionOptions and rebuild. ToggleRow( label = "Enable Extension", - description = "Off by default, so the demo's notifications stay untouched", + description = "Run the demo's extension on received pushes", checked = options.enabled, - onCheckedChange = { - if (it) expanded = true - onOptionsChange(options.copy(enabled = it)) - }, + onCheckedChange = { onOptionsChange(options.copy(enabled = it)) }, testTag = "nse_enabled_toggle", contentDescription = "Enable notification service extension", ) - - HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { expanded = !expanded } - .padding(vertical = DemoLayout.gap / 2) - .testTag("nse_options_toggle"), - horizontalArrangement = Arrangement.Center, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = if (expanded) "Hide options" else "Show options", - style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium), - color = OsPrimary, - ) - Icon( - imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore, - contentDescription = null, - tint = OsPrimary, - modifier = Modifier.size(18.dp), - ) - } - - if (expanded) { - HorizontalDivider(color = OsDivider, modifier = Modifier.padding(bottom = DemoLayout.gap)) - ToggleRow( - label = "Log Details", - description = "Log id, sent time, and the channel the SDK resolved, under the [Demo]NSE tag", - checked = options.logDetails, - onCheckedChange = { onOptionsChange(options.copy(logDetails = it)) }, - enabled = options.enabled, - testTag = "nse_log_toggle", - contentDescription = "Log notification details", - ) - HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) - ToggleRow( - label = "Apply Extender", - description = "Prefix the title with [NSE] through a NotificationCompat.Extender", - checked = options.applyExtender, - onCheckedChange = { onOptionsChange(options.copy(applyExtender = it)) }, - enabled = options.enabled, - testTag = "nse_extender_toggle", - contentDescription = "Apply notification extender", - ) - HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) - ToggleRow( - label = "Force High Importance Channel", - description = "Move every notification onto an app-owned IMPORTANCE_HIGH channel", - checked = options.forceHighImportanceChannel, - onCheckedChange = { onOptionsChange(options.copy(forceHighImportanceChannel = it)) }, - enabled = options.enabled, - testTag = "nse_high_importance_toggle", - contentDescription = "Force high importance channel", - ) - HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) - ToggleRow( - label = "Delay Display", - description = "preventDefault(), then display() five seconds later", - checked = options.delayDisplay, - onCheckedChange = { onOptionsChange(options.copy(delayDisplay = it)) }, - enabled = options.enabled, - testTag = "nse_delay_toggle", - contentDescription = "Delay notification display", - ) - HorizontalDivider(color = OsDivider, modifier = Modifier.padding(vertical = DemoLayout.gap)) - ToggleRow( - label = "Discard", - description = "preventDefault(true). Takes precedence over the switches above", - checked = options.discard, - onCheckedChange = { onOptionsChange(options.copy(discard = it)) }, - enabled = options.enabled, - testTag = "nse_discard_toggle", - contentDescription = "Discard notification", - ) - } } } }