diff --git a/.github/actions/setup-demo/action.yml b/.github/actions/setup-demo/action.yml new file mode 100644 index 0000000000..b076e5814d --- /dev/null +++ b/.github/actions/setup-demo/action.yml @@ -0,0 +1,50 @@ +name: 'Setup Demo' +description: 'Installs the JDK and Android toolchains, caches Gradle, and writes the demo local.properties file' +inputs: + onesignal-app-id: + description: 'OneSignal App ID written into examples/demo/local.properties' + required: true +runs: + using: 'composite' + steps: + - name: Set up Java + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + + - name: Set up Android SDK + uses: android-actions/setup-android@v3 + with: + cmdline-tools-version: '10406996' + log-accepted-android-sdk-licenses: false + + - name: Cache Gradle + uses: actions/cache@v4 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + # `*.gradle*` matches `build.gradle{,.kts}` etc. but NOT `gradle.properties` + # (no literal `.gradle` substring), so list `gradle.properties` explicitly. + key: ${{ runner.os }}-gradle-demo-${{ hashFiles('examples/demo/**/*.gradle*', 'examples/demo/**/gradle.properties', 'examples/demo/**/gradle-wrapper.properties') }} + restore-keys: | + ${{ runner.os }}-gradle-demo- + ${{ runner.os }}-gradle- + + # Mirrors the Capacitor demo's `.env`: writes the override file before any + # build runs so the OneSignal App ID + channel id end up baked into + # BuildConfig. `examples/demo/app/build.gradle.kts` resolves each key in + # this order: `-PKEY=value` from the CLI > local.properties > built-in + # default, so writing to local.properties is the closest equivalent to + # the Capacitor `.env` flow. + - name: Write demo local.properties + shell: bash + working-directory: examples/demo + env: + ONESIGNAL_APP_ID: ${{ inputs.onesignal-app-id }} + run: | + { + echo "ONESIGNAL_APP_ID=${ONESIGNAL_APP_ID}" + echo "ONESIGNAL_ANDROID_CHANNEL_ID=7ec2ece9-c538-4656-9516-1316f48a005c" + } > local.properties diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 0000000000..c05cada927 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,93 @@ +name: E2E Tests + +on: + push: + branches: + - rel/** + workflow_dispatch: + inputs: + sdk-version: + description: 'OneSignal Android SDK version to wait for and build against (defaults to OneSignalSDK/gradle.properties).' + required: false + type: string + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + build-android: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Fail fast on unset var. Otherwise `demoOverride()` collapses "" to + # null and the build silently uses the hardcoded default app ID. + - name: Validate APPIUM_ONESIGNAL_APP_ID + env: + ONESIGNAL_APP_ID: ${{ vars.APPIUM_ONESIGNAL_APP_ID }} + run: | + if [ -z "$ONESIGNAL_APP_ID" ]; then + echo "::error::APPIUM_ONESIGNAL_APP_ID is not set" + exit 1 + fi + + - name: Set up demo + uses: ./.github/actions/setup-demo + with: + onesignal-app-id: ${{ vars.APPIUM_ONESIGNAL_APP_ID }} + + - name: Resolve OneSignal Android SDK version + id: android-sdk-version + env: + INPUT_VERSION: ${{ github.event.inputs.sdk-version }} + run: | + if [ -n "$INPUT_VERSION" ]; then + VERSION="$INPUT_VERSION" + else + VERSION=$(grep -E '^SDK_VERSION=' OneSignalSDK/gradle.properties | cut -d '=' -f2) + fi + if [ -z "$VERSION" ]; then + echo "::error::Could not resolve OneSignal Android SDK version" + exit 1 + fi + echo "Resolved OneSignal Android SDK version: $VERSION" + echo "version=${VERSION}" >> "$GITHUB_OUTPUT" + + - name: Wait for OneSignal Android SDK on Maven Central + uses: OneSignal/sdk-shared/.github/actions/wait-for-maven-artifact@main + with: + version: ${{ steps.android-sdk-version.outputs.version }} + + # gms+debug: BrowserStack devices have GMS, debug enables UiAutomator2 + # attach. -PSDK_VERSION pins the Maven dep to the version we waited + # for. SDK_VERSION flows through env: to avoid script injection + # (workflow_dispatch input is user-controlled). + - name: Build debug APK + working-directory: examples/demo + env: + SDK_VERSION: ${{ steps.android-sdk-version.outputs.version }} + run: ./gradlew :app:assembleGmsDebug "-PSDK_VERSION=$SDK_VERSION" --console=plain --warning-mode=summary + + - name: Upload APK + uses: actions/upload-artifact@v4 + with: + name: demo-apk + path: examples/demo/app/build/outputs/apk/gms/debug/app-gms-debug.apk + retention-days: 1 + compression-level: 0 + + e2e-android: + needs: build-android + uses: OneSignal/sdk-shared/.github/workflows/appium-e2e.yml@main + secrets: inherit + with: + platform: android + app-artifact: demo-apk + app-filename: app-gms-debug.apk + sdk-type: android + build-name: android-${{ github.ref_name }}-${{ github.run_number }} diff --git a/examples/build.md b/examples/build.md index e54e29bf4f..2257ecafed 100644 --- a/examples/build.md +++ b/examples/build.md @@ -79,6 +79,21 @@ android { } ``` +#### Build types + +- `release` -- `isMinifyEnabled = true`, `isShrinkResources = true`, consumes the SDK's published ProGuard rules. +- `profileable` -- declared via `create("profileable") { initWith(release); ... }` so it inherits R8/shrinker settings while staying profileable on-device (used for Macrobenchmark / Android Studio profiling). +- `flavorDimensions += "default"` with `gms` and `huawei` flavors (see table above). +- BuildConfig fields: `ONESIGNAL_APP_ID` and `ONESIGNAL_ANDROID_CHANNEL_ID`, both populated through the `demoOverride(...)` helper (`-P` -> `local.properties` -> default). + +#### Root Gradle toolchain + +The root `examples/demo/build.gradle.kts` pins: + +- Android Gradle Plugin `8.8.2` +- Kotlin `1.9.25` +- Huawei AGCP classpath (`com.huawei.agconnect:agcp`) so the `huawei` flavor can apply `com.huawei.agconnect` at configuration time. + ### Build & run scripts ```bash @@ -94,7 +109,7 @@ android { ### Compose stack -The demo is 100% Jetpack Compose (no XML layouts). Dependencies are pulled via the Compose BOM (`2024.02.00`) with `material3`, `material-icons-extended` (used for the Send-IAM icons that the shared guide references), `runtime-livedata` (so `LiveData` integrates with Compose's `observeAsState`), and `activity-compose` + `lifecycle-*-compose` for the Compose entry point. +The demo is 100% Jetpack Compose (no XML layouts). Dependencies are pulled via the Compose BOM (`2024.02.00`) with `material3`, `material-icons-extended`, `runtime-livedata` (so `LiveData` integrates with Compose's `observeAsState`), and `activity-compose` + `lifecycle-*-compose` for the Compose entry point. --- @@ -104,11 +119,15 @@ The Android demo **overrides the shared guide's "no repository wrapper" rule**. - `MainApplication.kt` — initializes the SDK before any UI renders, restores cached consent + IAM-paused + location-shared state, and registers `IInAppMessageLifecycleListener`, `IInAppMessageClickListener`, `INotificationClickListener`, and `INotificationLifecycleListener` (with `event.preventDefault()` so async display can be exercised). - `MainViewModel : AndroidViewModel` — central state with `LiveData` for every UI value. Implements `IPushSubscriptionObserver`, `IPermissionObserver`, `IUserStateObserver`, and `IUserJwtInvalidatedListener`. Holds a monotonic `private var fetchRequestSequence = 0L` that maps to the shared guide's `requestSequence` for stale-result protection in `fetchUserDataFromApi`. -- `OneSignalRepository.kt` — thin coroutine wrapper that bounces every SDK call onto `Dispatchers.IO`. Exists so the ViewModel can stay on the main dispatcher and individual SDK call sites don't have to repeat `withContext`. +- `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). -`fetchUserDataFromApi` follows the shared guide exactly: bump `++fetchRequestSequence`, capture as `requestId`, flip `_isLoading.value = true`, then after every suspend point bail with `return@withContext` when `requestId != fetchRequestSequence`. Same guard wraps the catch branch and the final `_isLoading.value = false`. +`fetchUserDataFromApi` loading sequence: the sequence is incremented first, then early returns may set `_isLoading = false` before `_isLoading = true` is set (see `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`. + +#### `MainApplication` observer + +Both `MainApplication` and `MainViewModel` register `IUserStateObserver`. The Application's observer only logs (no UI side effects) and exists so user-state changes are captured even before the first `MainActivity` is created. --- @@ -124,24 +143,41 @@ Inline only — no full-screen overlay. The four list sections (Aliases, Emails, ### Snackbar / Toast -Compose's `SnackbarHostState`, mounted in `MainScreen`'s `Scaffold(snackbarHost = { SnackbarHost(...) })`. The `Snackbar` applies `Modifier.testTag("snackbar_toast")` so the shared Appium suite finds it. - -`MainViewModel` exposes `toastMessages: Flow` backed by a buffered `Channel` (not `LiveData`) so identical messages emitted in quick succession are not collapsed by structural equality, and so listeners that fire on a background dispatcher (e.g. `IUserJwtInvalidatedListener`) can post without violating LiveData's `@MainThread` contract. `MainScreen` collects the flow inside `LaunchedEffect(Unit) { viewModel.toastMessages.collect { snackbarHostState.showSnackbar(it) } }`. The host is the only feedback surface — there is no `android.widget.Toast` bridge in `MainActivity`. Snackbar usage matches the shared guide's allowed set (login/logout, outcomes, custom event, location check, JWT invalidation); every other action only writes to `android.util.Log.i(TAG, ...)`, matching the shared guide's "use the platform's built-in logging primitive directly" rule. - -### Send In-App Message icons - -Use `androidx.compose.material.icons.filled.*` from `material-icons-extended`: `VerticalAlignTop`, `VerticalAlignBottom`, `CropSquare`, `Fullscreen`. Icons sit on the LEFT of each red full-width button per the shared guide. +- `SnackbarController` (`ui/components/SnackbarController.kt`) wraps Compose's `SnackbarHostState` and exposes a `show(message)` method with replace-on-show behavior. +- `MainScreen` creates `val snackbarController = rememberSnackbarController(snackbarHostState)` and exposes it down the tree via `CompositionLocalProvider(LocalSnackbarController provides snackbarController) { ... }` wrapping the sections column. The host is mounted on `Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) })`. The `Snackbar` applies `Modifier.testTag("snackbar_toast")` so the shared Appium suite finds it. +- Section composables read the controller with `val snackbar = LocalSnackbarController.current` and call `snackbar.show(...)` from action callbacks. Only Outcomes, Custom Events, and Location check trigger the snackbar. +- Most other actions write to `android.util.Log.i(TAG, ...)` in `MainViewModel` (add/remove aliases/tags/emails/SMS/triggers, toggles, send push/IAM, etc.). Login and logout are intentionally silent — no snackbar and no `Log.i`. The User section reflects auth state via `externalUserId` LiveData ("Logged In" / "Anonymous"), matching the other wrapper demos. +- Replace-on-show: `show(...)` cancels any in-flight `showJob` and `dismissJob`, dismisses `hostState.currentSnackbarData`, then launches a new pair where the dismiss coroutine runs `delay(DemoLayout.toastDurationMs)` and the show coroutine calls `hostState.showSnackbar(message, duration = SnackbarDuration.Indefinite)`. +- Duration is the shared constant `DemoLayout.toastDurationMs = 3_000L` (milliseconds). +- `MainViewModel` must not hold any toast state, expose a `Channel`/`Flow` of toast messages, or own a `showToast` helper. There is no `android.widget.Toast` bridge in `MainActivity` either -- the snackbar host is the only feedback surface. ### Modals -`Dialogs.kt` defines AlertDialog-based composables: `SingleInputDialog`, `PairInputDialog`, `MultiPairInputDialog`, `MultiSelectRemoveDialog`, `LoginDialog`, `OutcomeDialog`, `TrackEventDialog`, `CustomNotificationDialog`, `TooltipDialog`. +- `MainScreen` owns layout + the tooltip dialog only (`var showTooltipDialog by remember { mutableStateOf(null) }` + `TooltipDialog`). Each section's `onInfoClick` callback sets the tooltip key. +- Sections own action dialog state via `var *Open by remember { mutableStateOf(false) }` and render shared dialog composables (from `ui/components/Dialogs.kt`) inside the section. Dialog confirm handlers call SDK methods through callbacks passed from `MainScreen`, then close the dialog; for snackbar-emitting actions they also call `LocalSnackbarController.current.show(...)`. +- `MainViewModel` must not hold any action dialog visibility flags or input drafts. +- Shared composables in `ui/components/Dialogs.kt`: `SingleInputDialog`, `MultiSelectRemoveDialog`, `LoginDialog`, `OutcomeDialog`, `TrackEventDialog`, `CustomNotificationDialog`, `TooltipDialog` use `AlertDialog`. `PairInputDialog` and `MultiPairInputDialog` use `Dialog` + `Surface` (for the wider two-column layout). +- Dialog state naming: `OutcomeSection` and `CustomEventsSection` use `var open by remember { mutableStateOf(false) }` (singular `open`) because each owns a single dialog. Other sections name their flags per dialog (`loginOpen`, `addOpen`, etc.). ### Accessibility (Appium) -Apply test ids via `Modifier.testTag("...")`. A small `Modifier.applyTestTag(tag: String?)` extension noops when the tag is null, so reusable components (`SectionCard`, `ToggleRow`, `ActionButton`, the list widgets, every dialog) take a nullable tag parameter. +Apply test ids via `Modifier.testTag("...")`. `Modifier.applyTestTag(tag: String?)` exists as private duplicate helpers in `Dialogs.kt` and `ActionButton.kt` (each ~5 lines) that noop when the tag is null -- not a single shared module-level utility today. `ToggleRow`, `SectionCard`, and `ListComponents` apply `testTag` inline. All identifiers match the shared guide's table exactly (`login_user_button`, `consent_required_toggle`, `snackbar_toast`, etc.). The dynamic patterns from the shared guide (`{sectionKey}_section`, `{sectionKey}_info_icon`, `{sectionKey}_pair_key_{key}`, `{sectionKey}_loading`, `{sectionKey}_empty`, `{sectionKey}_remove_{key}`) are driven by `SectionCard(sectionKey = "...")` and the list composables in `ListComponents.kt`. +#### Test-tag patterns + +Patterns used by this demo beyond the shared guide's table: + +- `{sectionKey}_pair_key_{key}` and `{sectionKey}_pair_value_{key}` -- two-column rows expose both halves so Appium can assert key and value independently. +- `{sectionKey}_value_{value}` -- list rows whose key is implicit (single-column lists). +- `main_scroll_view` -- the root scrollable `Column` (`verticalScroll(rememberScrollState())`) in `MainScreen`, used by Appium swipe gestures. + +#### Appium / UiAutomator: `testTagsAsResourceId` + +- `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. + ### 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. @@ -155,20 +191,30 @@ The Android demo exercises a few SDK features that are not described in the shar - **Identity Verification toggle** (`UserSection`, `testTag = "identity_verification_toggle"`) — persisted via `SharedPreferenceUtil.cacheIdentityVerification(...)`. When ON, `fetchUserDataFromApi` uses the cached external_id + JWT; when OFF it falls back to the onesignal_id endpoint. - **JWT field in `LoginDialog`** — optional `"JWT Token (optional)"` input under the external-user-id field. `testTag = "login_user_jwt_input"`. `MainViewModel.loginUser(externalUserId, jwtToken)` threads the token into `OneSignal.login(externalUserId, jwtToken)` and caches it via `SharedPreferenceUtil.cacheJwtToken(...)`. - **UPDATE USER JWT button** (`UserSection`, `testTag = "update_user_jwt_button"`) — opens a `PairInputDialog` (External User Id + JWT Token) and calls `viewModel.updateUserJwt(...)` → `OneSignal.updateUserJwt(...)`. -- **`IUserJwtInvalidatedListener`** — registered by `MainViewModel`; surfaces a snackbar + log entry when the SDK reports an invalidated JWT. +- **`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. --- ## Platform Config -### Permissions (`src/main/AndroidManifest.xml`) +### Permissions -```xml - - - - -``` +Declared in `app/src/main/AndroidManifest.xml`: + +- `android.permission.ACCESS_COARSE_LOCATION` +- `android.permission.ACCESS_FINE_LOCATION` +- `android.permission.ACCESS_BACKGROUND_LOCATION` +- `com.android.vending.BILLING` +- `android.permission.WAKE_LOCK` +- ADM / Amazon push wiring (`com.amazon.device.messaging.permission.RECEIVE`, plus the app-scoped `com.onesignal.example.permission.RECEIVE_ADM_MESSAGE` and matching intent category). + +Contributed via manifest merge from the OneSignal SDK: + +- `android.permission.INTERNET` +- `android.permission.POST_NOTIFICATIONS` +- FCM-related permissions (e.g. `com.google.android.c2dm.permission.RECEIVE`) +- Badge permissions (Samsung / Sony / HTC / etc.) +- `android.permission.RECEIVE_BOOT_COMPLETED` and other SDK-side wiring The ADM permission name and intent category use the application's package, so they reference `com.onesignal.example` (not the SDK's package). @@ -199,35 +245,47 @@ If the package changes you must regenerate these from the Firebase / Huawei AppG ## File Structure ``` -examples/demo/ -├── build.gradle.kts # root project, plugin classpath -├── settings.gradle.kts -├── gradle.properties -├── build.md # this file -└── app/ - ├── build.gradle.kts # namespace = com.onesignal.example, gms/huawei flavors - ├── google-services.json # package_name = com.onesignal.example - ├── agconnect-services.json # package_name = com.onesignal.example - └── src/ - ├── main/ - │ ├── AndroidManifest.xml - │ ├── java/com/onesignal/example/ - │ │ ├── application/MainApplication.kt - │ │ ├── data/ - │ │ │ ├── model/{NotificationType,InAppMessageType}.kt - │ │ │ ├── network/OneSignalService.kt - │ │ │ └── repository/OneSignalRepository.kt - │ │ ├── ui/ - │ │ │ ├── components/ # SectionCard, ToggleRow, ActionButton, - │ │ │ │ # ListComponents, Dialogs - │ │ │ ├── main/ # MainActivity, MainScreen, Sections, MainViewModel - │ │ │ ├── secondary/SecondaryActivity.kt - │ │ │ └── theme/Theme.kt - │ │ └── util/ # SharedPreferenceUtil, TooltipHelper - │ └── res/values/{strings,colors,styles}.xml - └── huawei/ - ├── AndroidManifest.xml - └── java/com/onesignal/example/notification/HmsMessageServiceAppLevel.kt +examples/ +├── build.md # this file +└── demo/ + ├── build.gradle.kts # root project, plugin classpath + ├── settings.gradle.kts + ├── gradle.properties + ├── gradlew + ├── gradlew.bat + ├── local.properties.example # template for local.properties + └── app/ + ├── build.gradle.kts # namespace = com.onesignal.example, gms/huawei flavors + ├── proguard-rules.pro + ├── google-services.json # package_name = com.onesignal.example + ├── agconnect-services.json # package_name = com.onesignal.example + └── src/ + ├── main/ + │ ├── AndroidManifest.xml + │ ├── java/com/onesignal/example/ + │ │ ├── application/MainApplication.kt + │ │ ├── data/ + │ │ │ ├── model/{NotificationType,InAppMessageType}.kt + │ │ │ ├── network/OneSignalService.kt + │ │ │ └── repository/OneSignalRepository.kt + │ │ ├── ui/ + │ │ │ ├── components/ # SectionCard (with DemoSection), + │ │ │ │ # ToggleRow, ActionButton, ListComponents, + │ │ │ │ # CardKvRow, DemoAppBar, Dialogs, + │ │ │ │ # SnackbarController + │ │ │ ├── main/ # MainActivity, MainScreen, Sections, MainViewModel + │ │ │ ├── secondary/SecondaryActivity.kt + │ │ │ └── theme/ # Theme.kt, DemoLayout.kt + │ │ └── util/ # SharedPreferenceUtil, TooltipHelper + │ └── res/ + │ ├── values/{strings,colors,styles}.xml + │ ├── raw/ # vine_boom.wav + │ ├── mipmap-*/ # launcher icons (hdpi..xxxhdpi) + │ ├── drawable-*/ # density-bucketed drawables (hdpi..xxxhdpi) + │ └── drawable-nodpi/onesignal_rectangle.png + └── huawei/ + ├── AndroidManifest.xml + └── java/com/onesignal/example/notification/HmsMessageServiceAppLevel.kt ``` --- diff --git a/examples/demo/app/src/main/java/com/onesignal/example/data/model/InAppMessageType.kt b/examples/demo/app/src/main/java/com/onesignal/example/data/model/InAppMessageType.kt index 88d1857c3b..a28042a008 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/data/model/InAppMessageType.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/data/model/InAppMessageType.kt @@ -1,12 +1,5 @@ package com.onesignal.example.data.model -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.CropSquare -import androidx.compose.material.icons.filled.Fullscreen -import androidx.compose.material.icons.filled.VerticalAlignBottom -import androidx.compose.material.icons.filled.VerticalAlignTop -import androidx.compose.ui.graphics.vector.ImageVector - /** * Enum representing different types of in-app messages that can be triggered. */ @@ -14,30 +7,25 @@ enum class InAppMessageType( val title: String, val triggerKey: String, val triggerValue: String, - val icon: ImageVector ) { TOP_BANNER( title = "TOP BANNER", triggerKey = "iam_type", triggerValue = "top_banner", - icon = Icons.Filled.VerticalAlignTop ), BOTTOM_BANNER( title = "BOTTOM BANNER", triggerKey = "iam_type", triggerValue = "bottom_banner", - icon = Icons.Filled.VerticalAlignBottom ), CENTER_MODAL( title = "CENTER MODAL", triggerKey = "iam_type", triggerValue = "center_modal", - icon = Icons.Filled.CropSquare ), FULL_SCREEN( title = "FULL SCREEN", triggerKey = "iam_type", triggerValue = "full_screen", - icon = Icons.Filled.Fullscreen ) } diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/components/SnackbarController.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/components/SnackbarController.kt new file mode 100644 index 0000000000..bad5148a6a --- /dev/null +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/components/SnackbarController.kt @@ -0,0 +1,53 @@ +package com.onesignal.example.ui.components + +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHostState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.compositionLocalOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import com.onesignal.example.ui.theme.DemoLayout +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * UI-layer snackbar controller per sdk-shared/demo/build.md Prompt 7.6. + * Feedback messages are owned by the UI layer, never by the ViewModel. + * Replace-on-show: dismisses any visible snackbar and resets the + * [DemoLayout.toastDurationMs] timer on every call. + */ +class SnackbarController( + val hostState: SnackbarHostState, + private val scope: CoroutineScope, +) { + private var showJob: Job? = null + private var dismissJob: Job? = null + + fun show(message: String) { + showJob?.cancel() + dismissJob?.cancel() + hostState.currentSnackbarData?.dismiss() + dismissJob = scope.launch { + delay(DemoLayout.toastDurationMs) + hostState.currentSnackbarData?.dismiss() + } + showJob = scope.launch { + hostState.showSnackbar( + message = message, + duration = SnackbarDuration.Indefinite, + ) + } + } +} + +@Composable +fun rememberSnackbarController(hostState: SnackbarHostState): SnackbarController { + val scope = rememberCoroutineScope() + return remember(hostState, scope) { SnackbarController(hostState, scope) } +} + +val LocalSnackbarController = compositionLocalOf { + error("No SnackbarController provided. Wrap your UI in CompositionLocalProvider(LocalSnackbarController provides ...).") +} 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 2233b21efa..78c279f6bf 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 @@ -22,6 +22,7 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import com.onesignal.example.ui.components.DemoAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -38,16 +39,10 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.onesignal.example.R import com.onesignal.example.data.model.NotificationType -import com.onesignal.example.ui.components.CustomNotificationDialog -import com.onesignal.example.ui.components.LoginDialog -import com.onesignal.example.ui.components.MultiPairInputDialog -import com.onesignal.example.ui.components.MultiSelectRemoveDialog -import com.onesignal.example.ui.components.OutcomeDialog -import com.onesignal.example.ui.components.PairInputDialog +import com.onesignal.example.ui.components.LocalSnackbarController import com.onesignal.example.ui.components.PrimaryButton -import com.onesignal.example.ui.components.SingleInputDialog import com.onesignal.example.ui.components.TooltipDialog -import com.onesignal.example.ui.components.TrackEventDialog +import com.onesignal.example.ui.components.rememberSnackbarController import com.onesignal.example.ui.secondary.SecondaryActivity import com.onesignal.example.ui.theme.DemoLayout import com.onesignal.example.util.TooltipHelper @@ -74,22 +69,6 @@ fun MainScreen(viewModel: MainViewModel) { val locationShared by viewModel.locationShared.observeAsState(false) val isLoading by viewModel.isLoading.observeAsState(false) - // Dialog states - var showLoginDialog by remember { mutableStateOf(false) } - var showUpdateJwtDialog by remember { mutableStateOf(false) } - var showAddAliasDialog by remember { mutableStateOf(false) } - var showAddMultipleAliasDialog by remember { mutableStateOf(false) } - var showAddEmailDialog by remember { mutableStateOf(false) } - var showAddSmsDialog by remember { mutableStateOf(false) } - var showAddTagDialog by remember { mutableStateOf(false) } - var showAddMultipleTagDialog by remember { mutableStateOf(false) } - var showRemoveTagsDialog by remember { mutableStateOf(false) } - var showAddTriggerDialog by remember { mutableStateOf(false) } - var showAddMultipleTriggerDialog by remember { mutableStateOf(false) } - var showRemoveTriggersDialog by remember { mutableStateOf(false) } - var showOutcomeDialog by remember { mutableStateOf(false) } - var showTrackEventDialog by remember { mutableStateOf(false) } - var showCustomNotificationDialog by remember { mutableStateOf(false) } var showTooltipDialog by remember { mutableStateOf(null) } LaunchedEffect(Unit) { @@ -97,16 +76,7 @@ fun MainScreen(viewModel: MainViewModel) { } val snackbarHostState = remember { SnackbarHostState() } - - // Collect the toast Channel as one-shot events so identical messages emitted - // within the snackbar display window aren't dropped by structural equality - // and so the snackbar can be targeted with `Modifier.testTag("snackbar_toast")` - // in E2E (matches Capacitor). - LaunchedEffect(Unit) { - viewModel.toastMessages.collect { message -> - snackbarHostState.showSnackbar(message) - } - } + val snackbarController = rememberSnackbarController(snackbarHostState) Scaffold( topBar = { @@ -143,328 +113,142 @@ fun MainScreen(viewModel: MainViewModel) { } } ) { paddingValues -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) - .background(MaterialTheme.colorScheme.background) - .verticalScroll(rememberScrollState()) - .testTag("main_scroll_view") - ) { - AppSection( - appId = appId, - consentRequired = consentRequired, - onConsentRequiredChange = { viewModel.setConsentRequired(it) }, - privacyConsentGiven = privacyConsentGiven, - onConsentChange = { viewModel.setPrivacyConsent(it) }, - onGetKeysClick = { - context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://onesignal.com"))) - } - ) - - UserSection( - externalUserId = externalUserId, - useIdentityVerification = useIdentityVerification, - onUseIdentityVerificationChange = { viewModel.setUseIdentityVerification(it) }, - onLoginClick = { showLoginDialog = true }, - onLogoutClick = { viewModel.logoutUser() }, - onUpdateJwtClick = { showUpdateJwtDialog = true }, - isLoading = isLoading - ) - - PushSection( - pushSubscriptionId = pushSubscriptionId, - pushEnabled = pushEnabled, - hasPermission = hasNotificationPermission, - onEnabledChange = { viewModel.setPushEnabled(it) }, - onPromptPush = { viewModel.promptPush() }, - onInfoClick = { showTooltipDialog = "push" } - ) - - SendPushSection( - onSimpleClick = { viewModel.sendNotification(NotificationType.SIMPLE) }, - onImageClick = { viewModel.sendNotification(NotificationType.WITH_IMAGE) }, - onSoundClick = { viewModel.sendNotification(NotificationType.WITH_SOUND) }, - onCustomClick = { showCustomNotificationDialog = true }, - onClearAllClick = { viewModel.clearAllNotifications() }, - onInfoClick = { showTooltipDialog = "sendPushNotification" } - ) - - InAppMessagingSection( - isPaused = inAppMessagesPaused, - onPausedChange = { viewModel.setInAppMessagesPaused(it) }, - onInfoClick = { showTooltipDialog = "inAppMessaging" } - ) - - SendInAppMessageSection( - onSendMessage = { type -> - viewModel.sendInAppMessage(type.title, type.triggerKey, type.triggerValue) - }, - onInfoClick = { showTooltipDialog = "sendInAppMessage" } - ) - - AliasesSection( - aliases = aliases, - onAddClick = { showAddAliasDialog = true }, - onAddMultipleClick = { showAddMultipleAliasDialog = true }, - onInfoClick = { showTooltipDialog = "aliases" }, - loading = isLoading - ) - - EmailsSection( - emails = emails, - onAddClick = { showAddEmailDialog = true }, - onRemove = { viewModel.removeEmail(it) }, - onInfoClick = { showTooltipDialog = "emails" }, - loading = isLoading - ) - - SmsSection( - smsNumbers = smsNumbers, - onAddClick = { showAddSmsDialog = true }, - onRemove = { viewModel.removeSms(it) }, - onInfoClick = { showTooltipDialog = "sms" }, - loading = isLoading - ) - - TagsSection( - tags = tags, - onAddClick = { showAddTagDialog = true }, - onAddMultipleClick = { showAddMultipleTagDialog = true }, - onRemove = { viewModel.removeTag(it) }, - onRemoveSelected = { showRemoveTagsDialog = true }, - onInfoClick = { showTooltipDialog = "tags" }, - loading = isLoading - ) - - OutcomeSection( - onSendOutcome = { showOutcomeDialog = true }, - onInfoClick = { showTooltipDialog = "outcomes" } - ) - - TriggersSection( - triggers = triggers, - onAddClick = { showAddTriggerDialog = true }, - onAddMultipleClick = { showAddMultipleTriggerDialog = true }, - onRemove = { viewModel.removeTrigger(it) }, - onRemoveSelected = { showRemoveTriggersDialog = true }, - onClearAll = { viewModel.clearTriggers() }, - onInfoClick = { showTooltipDialog = "triggers" } - ) - - CustomEventsSection( - onTrackClick = { showTrackEventDialog = true }, - onInfoClick = { showTooltipDialog = "customEvents" } - ) - - LocationSection( - locationShared = locationShared, - onLocationSharedChange = { viewModel.setLocationShared(it) }, - onCheckLocationShared = { viewModel.checkLocationShared() }, - onPromptLocation = { viewModel.promptLocation() }, - onInfoClick = { showTooltipDialog = "location" } - ) - - PrimaryButton( - text = "NEXT SCREEN", - onClick = { - context.startActivity(Intent(context, SecondaryActivity::class.java)) - }, - testTag = "next_screen_button" - ) - - Spacer(modifier = Modifier.height(24.dp)) - } - } - - // === DIALOGS === - if (showLoginDialog) { - LoginDialog( - onDismiss = { showLoginDialog = false }, - onConfirm = { userId, jwt -> - viewModel.loginUser(userId, jwt) - showLoginDialog = false - } - ) - } - - if (showUpdateJwtDialog) { - PairInputDialog( - title = "Update User JWT", - keyLabel = "External User Id", - valueLabel = "JWT Token", - onDismiss = { showUpdateJwtDialog = false }, - onConfirm = { externalId, token -> - viewModel.updateUserJwt(externalId, token) - showUpdateJwtDialog = false - }, - keyTestTag = "update_jwt_external_id_input", - valueTestTag = "update_jwt_token_input" - ) - } - - if (showAddAliasDialog) { - PairInputDialog( - title = "Add Alias", - keyLabel = "Label", - valueLabel = "ID", - onDismiss = { showAddAliasDialog = false }, - onConfirm = { key, value -> - viewModel.addAlias(key, value) - showAddAliasDialog = false - }, - keyTestTag = "alias_label_input", - valueTestTag = "alias_id_input" - ) - } - - if (showAddMultipleAliasDialog) { - MultiPairInputDialog( - title = "Add Multiple Aliases", - keyLabel = "Label", - valueLabel = "ID", - onDismiss = { showAddMultipleAliasDialog = false }, - onConfirm = { pairs -> - viewModel.addAliases(pairs) - showAddMultipleAliasDialog = false - } - ) - } - - if (showAddEmailDialog) { - SingleInputDialog( - title = "Add Email", - label = "Email", - onDismiss = { showAddEmailDialog = false }, - onConfirm = { email -> - viewModel.addEmail(email) - showAddEmailDialog = false - }, - inputTestTag = "email_input" - ) - } - - if (showAddSmsDialog) { - SingleInputDialog( - title = "Add SMS", - label = "Phone Number", - onDismiss = { showAddSmsDialog = false }, - onConfirm = { sms -> - viewModel.addSms(sms) - showAddSmsDialog = false - }, - inputTestTag = "sms_input" - ) - } - - if (showAddTagDialog) { - PairInputDialog( - title = "Add Tag", - onDismiss = { showAddTagDialog = false }, - onConfirm = { key, value -> - viewModel.addTag(key, value) - showAddTagDialog = false - }, - keyTestTag = "tag_key_input", - valueTestTag = "tag_value_input" - ) - } - - if (showAddMultipleTagDialog) { - MultiPairInputDialog( - title = "Add Multiple Tags", - onDismiss = { showAddMultipleTagDialog = false }, - onConfirm = { pairs -> - viewModel.addTags(pairs) - showAddMultipleTagDialog = false - } - ) - } - - if (showRemoveTagsDialog && tags.isNotEmpty()) { - MultiSelectRemoveDialog( - title = "Remove Tags", - items = tags, - onDismiss = { showRemoveTagsDialog = false }, - onConfirm = { keys -> - viewModel.removeSelectedTags(keys) - showRemoveTagsDialog = false - } - ) - } - - if (showAddTriggerDialog) { - PairInputDialog( - title = "Add Trigger", - onDismiss = { showAddTriggerDialog = false }, - onConfirm = { key, value -> - viewModel.addTrigger(key, value) - showAddTriggerDialog = false - }, - keyTestTag = "trigger_key_input", - valueTestTag = "trigger_value_input" - ) - } - - if (showAddMultipleTriggerDialog) { - MultiPairInputDialog( - title = "Add Multiple Triggers", - onDismiss = { showAddMultipleTriggerDialog = false }, - onConfirm = { pairs -> - viewModel.addTriggers(pairs) - showAddMultipleTriggerDialog = false - } - ) - } - - if (showRemoveTriggersDialog && triggers.isNotEmpty()) { - MultiSelectRemoveDialog( - title = "Remove Triggers", - items = triggers, - onDismiss = { showRemoveTriggersDialog = false }, - onConfirm = { keys -> - viewModel.removeSelectedTriggers(keys) - showRemoveTriggersDialog = false - } - ) - } - - if (showOutcomeDialog) { - OutcomeDialog( - onDismiss = { showOutcomeDialog = false }, - onSendNormal = { name -> - viewModel.sendOutcome(name) - showOutcomeDialog = false - }, - onSendUnique = { name -> - viewModel.sendUniqueOutcome(name) - showOutcomeDialog = false - }, - onSendWithValue = { name, value -> - viewModel.sendOutcomeWithValue(name, value) - showOutcomeDialog = false - } - ) - } - - if (showTrackEventDialog) { - TrackEventDialog( - onDismiss = { showTrackEventDialog = false }, - onConfirm = { name, properties -> - viewModel.trackEvent(name, properties) - showTrackEventDialog = false - } - ) - } - - if (showCustomNotificationDialog) { - CustomNotificationDialog( - onDismiss = { showCustomNotificationDialog = false }, - onConfirm = { title, body -> - viewModel.sendCustomNotification(title, body) - showCustomNotificationDialog = false + CompositionLocalProvider(LocalSnackbarController provides snackbarController) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + .background(MaterialTheme.colorScheme.background) + .verticalScroll(rememberScrollState()) + .testTag("main_scroll_view") + ) { + AppSection( + appId = appId, + consentRequired = consentRequired, + onConsentRequiredChange = { viewModel.setConsentRequired(it) }, + privacyConsentGiven = privacyConsentGiven, + onConsentChange = { viewModel.setPrivacyConsent(it) }, + onGetKeysClick = { + context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse("https://onesignal.com"))) + } + ) + + UserSection( + externalUserId = externalUserId, + useIdentityVerification = useIdentityVerification, + onUseIdentityVerificationChange = { viewModel.setUseIdentityVerification(it) }, + onLogin = { userId, jwt -> viewModel.loginUser(userId, jwt) }, + onLogout = { viewModel.logoutUser() }, + onUpdateJwt = { externalId, token -> viewModel.updateUserJwt(externalId, token) }, + isLoading = isLoading + ) + + PushSection( + pushSubscriptionId = pushSubscriptionId, + pushEnabled = pushEnabled, + hasPermission = hasNotificationPermission, + onEnabledChange = { viewModel.setPushEnabled(it) }, + onPromptPush = { viewModel.promptPush() }, + onInfoClick = { showTooltipDialog = "push" } + ) + + SendPushSection( + onSimpleClick = { viewModel.sendNotification(NotificationType.SIMPLE) }, + onImageClick = { viewModel.sendNotification(NotificationType.WITH_IMAGE) }, + onSoundClick = { viewModel.sendNotification(NotificationType.WITH_SOUND) }, + onCustomNotification = { title, body -> viewModel.sendCustomNotification(title, body) }, + onClearAllClick = { viewModel.clearAllNotifications() }, + onInfoClick = { showTooltipDialog = "sendPushNotification" } + ) + + InAppMessagingSection( + isPaused = inAppMessagesPaused, + onPausedChange = { viewModel.setInAppMessagesPaused(it) }, + onInfoClick = { showTooltipDialog = "inAppMessaging" } + ) + + SendInAppMessageSection( + onSendMessage = { type -> + viewModel.sendInAppMessage(type.title, type.triggerKey, type.triggerValue) + }, + onInfoClick = { showTooltipDialog = "sendInAppMessage" } + ) + + AliasesSection( + aliases = aliases, + onAdd = { key, value -> viewModel.addAlias(key, value) }, + onAddMultiple = { pairs -> viewModel.addAliases(pairs) }, + onInfoClick = { showTooltipDialog = "aliases" }, + loading = isLoading + ) + + EmailsSection( + emails = emails, + onAdd = { viewModel.addEmail(it) }, + onRemove = { viewModel.removeEmail(it) }, + onInfoClick = { showTooltipDialog = "emails" }, + loading = isLoading + ) + + SmsSection( + smsNumbers = smsNumbers, + onAdd = { viewModel.addSms(it) }, + onRemove = { viewModel.removeSms(it) }, + onInfoClick = { showTooltipDialog = "sms" }, + loading = isLoading + ) + + TagsSection( + tags = tags, + onAdd = { key, value -> viewModel.addTag(key, value) }, + onAddMultiple = { pairs -> viewModel.addTags(pairs) }, + onRemove = { viewModel.removeTag(it) }, + onRemoveSelected = { viewModel.removeSelectedTags(it) }, + onInfoClick = { showTooltipDialog = "tags" }, + loading = isLoading + ) + + OutcomeSection( + onSendNormal = { viewModel.sendOutcome(it) }, + onSendUnique = { viewModel.sendUniqueOutcome(it) }, + onSendWithValue = { name, value -> viewModel.sendOutcomeWithValue(name, value) }, + onInfoClick = { showTooltipDialog = "outcomes" } + ) + + TriggersSection( + triggers = triggers, + onAdd = { key, value -> viewModel.addTrigger(key, value) }, + onAddMultiple = { pairs -> viewModel.addTriggers(pairs) }, + onRemove = { viewModel.removeTrigger(it) }, + onRemoveSelected = { viewModel.removeSelectedTriggers(it) }, + onClearAll = { viewModel.clearTriggers() }, + onInfoClick = { showTooltipDialog = "triggers" } + ) + + CustomEventsSection( + onTrackEvent = { name, properties -> viewModel.trackEvent(name, properties) }, + onInfoClick = { showTooltipDialog = "customEvents" } + ) + + LocationSection( + locationShared = locationShared, + onLocationSharedChange = { viewModel.setLocationShared(it) }, + onCheckLocationShared = { viewModel.checkLocationShared() }, + onPromptLocation = { viewModel.promptLocation() }, + onInfoClick = { showTooltipDialog = "location" } + ) + + PrimaryButton( + text = "NEXT SCREEN", + onClick = { + context.startActivity(Intent(context, SecondaryActivity::class.java)) + }, + testTag = "next_screen_button" + ) + + Spacer(modifier = Modifier.height(24.dp)) } - ) + } } showTooltipDialog?.let { key -> 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 1365276188..d2284ea8d6 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 @@ -19,9 +19,6 @@ import com.onesignal.user.subscriptions.IPushSubscriptionObserver import com.onesignal.user.subscriptions.PushSubscriptionChangedState import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -92,13 +89,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I private val _useIdentityVerification = MutableLiveData() val useIdentityVerification: LiveData = _useIdentityVerification - // Toast messages. Channel (not LiveData) so identical messages emitted in - // quick succession aren't collapsed by structural equality, and so callers - // on any thread (e.g. IUserJwtInvalidatedListener fires on a background - // dispatcher) can post safely without a main-thread assertion. - private val _toastMessages = Channel(capacity = Channel.BUFFERED) - val toastMessages: Flow = _toastMessages.receiveAsFlow() - // Loading state private val _isLoading = MutableLiveData() val isLoading: LiveData = _isLoading @@ -282,7 +272,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), externalUserId) SharedPreferenceUtil.cacheJwtToken(getApplication(), jwtToken) _externalUserId.value = externalUserId - showToast("Logged in as: $externalUserId") aliasesList.clear() emailsList.clear() smsNumbersList.clear() @@ -315,7 +304,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I withContext(Dispatchers.Main) { SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), "") _externalUserId.value = null - showToast("Logged out") loadExistingAliases() loadExistingTags() refreshPushSubscription() @@ -560,21 +548,21 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I fun sendOutcome(name: String) { viewModelScope.launch(Dispatchers.IO) { repository.sendOutcome(name) - withContext(Dispatchers.Main) { showToast("Outcome sent: $name") } + withContext(Dispatchers.Main) { Log.i(TAG, "Outcome sent: $name") } } } fun sendUniqueOutcome(name: String) { viewModelScope.launch(Dispatchers.IO) { repository.sendUniqueOutcome(name) - withContext(Dispatchers.Main) { showToast("Unique outcome sent: $name") } + withContext(Dispatchers.Main) { Log.i(TAG, "Unique outcome sent: $name") } } } fun sendOutcomeWithValue(name: String, value: Float) { viewModelScope.launch(Dispatchers.IO) { repository.sendOutcomeWithValue(name, value) - withContext(Dispatchers.Main) { showToast("Outcome sent: $name = $value") } + withContext(Dispatchers.Main) { Log.i(TAG, "Outcome sent: $name = $value") } } } @@ -582,7 +570,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) { showToast("Event tracked: $name") } + withContext(Dispatchers.Main) { Log.i(TAG, "Event tracked: $name") } } } @@ -631,8 +619,10 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I Log.i(TAG, if (shared) "Location sharing enabled" else "Location sharing disabled") } - fun checkLocationShared() { - showToast("Location shared: ${repository.isLocationShared()}") + fun checkLocationShared(): Boolean { + val shared = repository.isLocationShared() + Log.i(TAG, "Location shared: $shared") + return shared } fun promptLocation() { @@ -688,11 +678,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I } } - private fun showToast(message: String) { - _toastMessages.trySend(message) - Log.i(TAG, message) - } - private fun logError(message: String) = Log.e(TAG, message) private fun logDebug(message: String) = Log.d(TAG, message) @@ -703,7 +688,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I override fun onUserJwtInvalidated(event: UserJwtInvalidatedEvent) { Log.w(TAG, "JWT invalidated for externalId: ${event.externalId}") - showToast("JWT invalidated for: ${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 0c87a7c895..3673320125 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,43 +2,45 @@ 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.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults 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 -import androidx.compose.ui.Alignment +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import com.onesignal.example.data.model.InAppMessageType import com.onesignal.example.ui.components.CardKvRow import com.onesignal.example.ui.components.CollapsibleSingleList +import com.onesignal.example.ui.components.CustomNotificationDialog import com.onesignal.example.ui.components.DemoSection import com.onesignal.example.ui.components.DestructiveButton +import com.onesignal.example.ui.components.LocalSnackbarController +import com.onesignal.example.ui.components.LoginDialog +import com.onesignal.example.ui.components.MultiPairInputDialog +import com.onesignal.example.ui.components.MultiSelectRemoveDialog +import com.onesignal.example.ui.components.OutcomeDialog import com.onesignal.example.ui.components.OutlineButton +import com.onesignal.example.ui.components.PairInputDialog import com.onesignal.example.ui.components.PairList import com.onesignal.example.ui.components.PrimaryButton import com.onesignal.example.ui.components.SectionCard +import com.onesignal.example.ui.components.SingleInputDialog import com.onesignal.example.ui.components.ToggleRow +import com.onesignal.example.ui.components.TrackEventDialog import com.onesignal.example.ui.theme.DemoLayout import com.onesignal.example.ui.theme.OsCardBorder import com.onesignal.example.ui.theme.OsDivider @@ -130,12 +132,14 @@ fun UserSection( externalUserId: String?, useIdentityVerification: Boolean, onUseIdentityVerificationChange: (Boolean) -> Unit, - onLoginClick: () -> Unit, - onLogoutClick: () -> Unit, - onUpdateJwtClick: () -> Unit, + onLogin: (String, String?) -> Unit, + onLogout: () -> Unit, + onUpdateJwt: (String, String) -> Unit, isLoading: Boolean = false, ) { val isLoggedIn = !externalUserId.isNullOrEmpty() + var loginOpen by remember { mutableStateOf(false) } + var updateJwtOpen by remember { mutableStateOf(false) } DemoSection { SectionCard(title = "User", sectionKey = "user") { @@ -167,7 +171,7 @@ fun UserSection( PrimaryButton( text = if (isLoggedIn) "SWITCH USER" else "LOGIN USER", - onClick = onLoginClick, + onClick = { loginOpen = true }, enabled = !isLoading, testTag = "login_user_button", ) @@ -175,7 +179,7 @@ fun UserSection( if (isLoggedIn) { OutlineButton( text = "LOGOUT USER", - onClick = onLogoutClick, + onClick = onLogout, enabled = !isLoading, testTag = "logout_user_button", ) @@ -183,10 +187,35 @@ fun UserSection( OutlineButton( text = "UPDATE USER JWT", - onClick = onUpdateJwtClick, + onClick = { updateJwtOpen = true }, testTag = "update_user_jwt_button", ) } + + if (loginOpen) { + LoginDialog( + onDismiss = { loginOpen = false }, + onConfirm = { userId, jwt -> + onLogin(userId, jwt) + loginOpen = false + }, + ) + } + + if (updateJwtOpen) { + PairInputDialog( + title = "Update User JWT", + keyLabel = "External User Id", + valueLabel = "JWT Token", + onDismiss = { updateJwtOpen = false }, + onConfirm = { externalId, token -> + onUpdateJwt(externalId, token) + updateJwtOpen = false + }, + keyTestTag = "update_jwt_external_id_input", + valueTestTag = "update_jwt_token_input", + ) + } } @Composable @@ -229,10 +258,12 @@ fun SendPushSection( onSimpleClick: () -> Unit, onImageClick: () -> Unit, onSoundClick: () -> Unit, - onCustomClick: () -> Unit, + onCustomNotification: (String, String) -> Unit, onClearAllClick: () -> Unit, onInfoClick: () -> Unit, ) { + var customOpen by remember { mutableStateOf(false) } + DemoSection { SectionCard( title = "Send Push Notification", @@ -243,10 +274,20 @@ fun SendPushSection( PrimaryButton(text = "SIMPLE", onClick = onSimpleClick, testTag = "send_simple_button") PrimaryButton(text = "WITH IMAGE", onClick = onImageClick, testTag = "send_image_button") PrimaryButton(text = "WITH SOUND", onClick = onSoundClick, testTag = "send_sound_button") - PrimaryButton(text = "CUSTOM", onClick = onCustomClick, testTag = "send_custom_button") + PrimaryButton(text = "CUSTOM", onClick = { customOpen = true }, testTag = "send_custom_button") OutlineButton(text = "CLEAR ALL", onClick = onClearAllClick, testTag = "clear_all_button") } } + + if (customOpen) { + CustomNotificationDialog( + onDismiss = { customOpen = false }, + onConfirm = { title, body -> + onCustomNotification(title, body) + customOpen = false + }, + ) + } } @Composable @@ -283,9 +324,8 @@ fun SendInAppMessageSection( ) { InAppMessageType.entries.forEach { type -> val typeTag = type.name.lowercase() - IamActionButton( + PrimaryButton( text = type.title, - icon = type.icon, onClick = { onSendMessage(type) }, testTag = "send_iam_${typeTag}_button", ) @@ -294,47 +334,17 @@ fun SendInAppMessageSection( } } -@Composable -private fun IamActionButton( - text: String, - icon: ImageVector, - onClick: () -> Unit, - testTag: String, -) { - Button( - onClick = onClick, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = DemoLayout.pagePadding, vertical = 4.dp) - .height(DemoLayout.buttonHeight) - .testTag(testTag), - colors = ButtonDefaults.buttonColors(containerColor = OsPrimary), - shape = RoundedCornerShape(DemoLayout.buttonRadius), - elevation = ButtonDefaults.buttonElevation(defaultElevation = 0.dp, pressedElevation = 0.dp), - ) { - Row( - modifier = Modifier.fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - Icon(imageVector = icon, contentDescription = null, tint = Color.White, modifier = Modifier.size(18.dp)) - Spacer(modifier = Modifier.width(DemoLayout.gap)) - Text( - text = text, - style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.SemiBold), - color = Color.White, - ) - } - } -} - @Composable fun AliasesSection( aliases: List>, - onAddClick: () -> Unit, - onAddMultipleClick: () -> Unit, + onAdd: (String, String) -> Unit, + onAddMultiple: (List>) -> Unit, onInfoClick: () -> Unit, loading: Boolean = false, ) { + var addOpen by remember { mutableStateOf(false) } + var addMultipleOpen by remember { mutableStateOf(false) } + DemoSection { SectionCard(title = "Aliases", sectionKey = "aliases", onInfoClick = onInfoClick) { PairList( @@ -345,19 +355,53 @@ fun AliasesSection( ) } Spacer(modifier = Modifier.height(DemoLayout.gap)) - PrimaryButton(text = "ADD ALIAS", onClick = onAddClick, testTag = "add_alias_button") - PrimaryButton(text = "ADD MULTIPLE ALIASES", onClick = onAddMultipleClick, testTag = "add_multiple_aliases_button") + PrimaryButton(text = "ADD ALIAS", onClick = { addOpen = true }, testTag = "add_alias_button") + PrimaryButton( + text = "ADD MULTIPLE ALIASES", + onClick = { addMultipleOpen = true }, + testTag = "add_multiple_aliases_button", + ) + } + + if (addOpen) { + PairInputDialog( + title = "Add Alias", + keyLabel = "Label", + valueLabel = "ID", + onDismiss = { addOpen = false }, + onConfirm = { key, value -> + onAdd(key, value) + addOpen = false + }, + keyTestTag = "alias_label_input", + valueTestTag = "alias_id_input", + ) + } + + if (addMultipleOpen) { + MultiPairInputDialog( + title = "Add Multiple Aliases", + keyLabel = "Label", + valueLabel = "ID", + onDismiss = { addMultipleOpen = false }, + onConfirm = { pairs -> + onAddMultiple(pairs) + addMultipleOpen = false + }, + ) } } @Composable fun EmailsSection( emails: List, - onAddClick: () -> Unit, + onAdd: (String) -> Unit, onRemove: (String) -> Unit, onInfoClick: () -> Unit, loading: Boolean = false, ) { + var addOpen by remember { mutableStateOf(false) } + DemoSection { SectionCard(title = "Emails", sectionKey = "emails", onInfoClick = onInfoClick) { CollapsibleSingleList( @@ -369,18 +413,33 @@ fun EmailsSection( ) } Spacer(modifier = Modifier.height(DemoLayout.gap)) - PrimaryButton(text = "ADD EMAIL", onClick = onAddClick, testTag = "add_email_button") + PrimaryButton(text = "ADD EMAIL", onClick = { addOpen = true }, testTag = "add_email_button") + } + + if (addOpen) { + SingleInputDialog( + title = "Add Email", + label = "Email", + onDismiss = { addOpen = false }, + onConfirm = { email -> + onAdd(email) + addOpen = false + }, + inputTestTag = "email_input", + ) } } @Composable fun SmsSection( smsNumbers: List, - onAddClick: () -> Unit, + onAdd: (String) -> Unit, onRemove: (String) -> Unit, onInfoClick: () -> Unit, loading: Boolean = false, ) { + var addOpen by remember { mutableStateOf(false) } + DemoSection { SectionCard(title = "SMS", sectionKey = "sms", onInfoClick = onInfoClick) { CollapsibleSingleList( @@ -392,20 +451,37 @@ fun SmsSection( ) } Spacer(modifier = Modifier.height(DemoLayout.gap)) - PrimaryButton(text = "ADD SMS", onClick = onAddClick, testTag = "add_sms_button") + PrimaryButton(text = "ADD SMS", onClick = { addOpen = true }, testTag = "add_sms_button") + } + + if (addOpen) { + SingleInputDialog( + title = "Add SMS", + label = "Phone Number", + onDismiss = { addOpen = false }, + onConfirm = { sms -> + onAdd(sms) + addOpen = false + }, + inputTestTag = "sms_input", + ) } } @Composable fun TagsSection( tags: List>, - onAddClick: () -> Unit, - onAddMultipleClick: () -> Unit, + onAdd: (String, String) -> Unit, + onAddMultiple: (List>) -> Unit, onRemove: (String) -> Unit, - onRemoveSelected: () -> Unit, + onRemoveSelected: (List) -> Unit, onInfoClick: () -> Unit, loading: Boolean = false, ) { + var addOpen by remember { mutableStateOf(false) } + var addMultipleOpen by remember { mutableStateOf(false) } + var removeOpen by remember { mutableStateOf(false) } + DemoSection { SectionCard(title = "Tags", sectionKey = "tags", onInfoClick = onInfoClick) { PairList( @@ -417,24 +493,69 @@ fun TagsSection( ) } Spacer(modifier = Modifier.height(DemoLayout.gap)) - PrimaryButton(text = "ADD TAG", onClick = onAddClick, testTag = "add_tag_button") - PrimaryButton(text = "ADD MULTIPLE TAGS", onClick = onAddMultipleClick, testTag = "add_multiple_tags_button") + PrimaryButton(text = "ADD TAG", onClick = { addOpen = true }, testTag = "add_tag_button") + PrimaryButton( + text = "ADD MULTIPLE TAGS", + onClick = { addMultipleOpen = true }, + testTag = "add_multiple_tags_button", + ) if (tags.isNotEmpty()) { DestructiveButton( - text = "REMOVE SELECTED", - onClick = onRemoveSelected, + text = "REMOVE TAGS", + onClick = { removeOpen = true }, testTag = "remove_tags_button", ) } } + + if (addOpen) { + PairInputDialog( + title = "Add Tag", + onDismiss = { addOpen = false }, + onConfirm = { key, value -> + onAdd(key, value) + addOpen = false + }, + keyTestTag = "tag_key_input", + valueTestTag = "tag_value_input", + ) + } + + if (addMultipleOpen) { + MultiPairInputDialog( + title = "Add Multiple Tags", + onDismiss = { addMultipleOpen = false }, + onConfirm = { pairs -> + onAddMultiple(pairs) + addMultipleOpen = false + }, + ) + } + + if (removeOpen && tags.isNotEmpty()) { + MultiSelectRemoveDialog( + title = "Remove Tags", + items = tags, + onDismiss = { removeOpen = false }, + onConfirm = { keys -> + onRemoveSelected(keys.toList()) + removeOpen = false + }, + ) + } } @Composable fun OutcomeSection( - onSendOutcome: () -> Unit, + onSendNormal: (String) -> Unit, + onSendUnique: (String) -> Unit, + onSendWithValue: (String, Float) -> Unit, onInfoClick: () -> Unit, ) { + var open by remember { mutableStateOf(false) } + val snackbar = LocalSnackbarController.current + DemoSection { SectionCard( title = "Outcome Events", @@ -442,21 +563,46 @@ fun OutcomeSection( sectionKey = "outcomes", onInfoClick = onInfoClick, ) { - PrimaryButton(text = "SEND OUTCOME", onClick = onSendOutcome, testTag = "send_outcome_button") + PrimaryButton(text = "SEND OUTCOME", onClick = { open = true }, testTag = "send_outcome_button") } } + + if (open) { + OutcomeDialog( + onDismiss = { open = false }, + onSendNormal = { name -> + onSendNormal(name) + snackbar.show("Outcome sent: $name") + open = false + }, + onSendUnique = { name -> + onSendUnique(name) + snackbar.show("Unique outcome sent: $name") + open = false + }, + onSendWithValue = { name, value -> + onSendWithValue(name, value) + snackbar.show("Outcome sent: $name = $value") + open = false + }, + ) + } } @Composable fun TriggersSection( triggers: List>, - onAddClick: () -> Unit, - onAddMultipleClick: () -> Unit, + onAdd: (String, String) -> Unit, + onAddMultiple: (List>) -> Unit, onRemove: (String) -> Unit, - onRemoveSelected: () -> Unit, + onRemoveSelected: (List) -> Unit, onClearAll: () -> Unit, onInfoClick: () -> Unit, ) { + var addOpen by remember { mutableStateOf(false) } + var addMultipleOpen by remember { mutableStateOf(false) } + var removeOpen by remember { mutableStateOf(false) } + DemoSection { SectionCard(title = "Triggers", sectionKey = "triggers", onInfoClick = onInfoClick) { PairList( @@ -467,33 +613,72 @@ fun TriggersSection( ) } Spacer(modifier = Modifier.height(DemoLayout.gap)) - PrimaryButton(text = "ADD TRIGGER", onClick = onAddClick, testTag = "add_trigger_button") + PrimaryButton(text = "ADD TRIGGER", onClick = { addOpen = true }, testTag = "add_trigger_button") PrimaryButton( text = "ADD MULTIPLE TRIGGERS", - onClick = onAddMultipleClick, + onClick = { addMultipleOpen = true }, testTag = "add_multiple_triggers_button", ) if (triggers.isNotEmpty()) { DestructiveButton( - text = "REMOVE SELECTED", - onClick = onRemoveSelected, + text = "REMOVE TRIGGERS", + onClick = { removeOpen = true }, testTag = "remove_triggers_button", ) DestructiveButton( - text = "CLEAR ALL", + text = "CLEAR ALL TRIGGERS", onClick = onClearAll, testTag = "clear_triggers_button", ) } } + + if (addOpen) { + PairInputDialog( + title = "Add Trigger", + onDismiss = { addOpen = false }, + onConfirm = { key, value -> + onAdd(key, value) + addOpen = false + }, + keyTestTag = "trigger_key_input", + valueTestTag = "trigger_value_input", + ) + } + + if (addMultipleOpen) { + MultiPairInputDialog( + title = "Add Multiple Triggers", + onDismiss = { addMultipleOpen = false }, + onConfirm = { pairs -> + onAddMultiple(pairs) + addMultipleOpen = false + }, + ) + } + + if (removeOpen && triggers.isNotEmpty()) { + MultiSelectRemoveDialog( + title = "Remove Triggers", + items = triggers, + onDismiss = { removeOpen = false }, + onConfirm = { keys -> + onRemoveSelected(keys.toList()) + removeOpen = false + }, + ) + } } @Composable fun CustomEventsSection( - onTrackClick: () -> Unit, + onTrackEvent: (String, Map?) -> Unit, onInfoClick: () -> Unit, ) { + var open by remember { mutableStateOf(false) } + val snackbar = LocalSnackbarController.current + DemoSection { SectionCard( title = "Custom Events", @@ -501,19 +686,32 @@ fun CustomEventsSection( sectionKey = "custom_events", onInfoClick = onInfoClick, ) { - PrimaryButton(text = "TRACK EVENT", onClick = onTrackClick, testTag = "track_event_button") + PrimaryButton(text = "TRACK EVENT", onClick = { open = true }, testTag = "track_event_button") } } + + if (open) { + TrackEventDialog( + onDismiss = { open = false }, + onConfirm = { name, properties -> + onTrackEvent(name, properties) + snackbar.show("Event tracked: $name") + open = false + }, + ) + } } @Composable fun LocationSection( locationShared: Boolean, onLocationSharedChange: (Boolean) -> Unit, - onCheckLocationShared: () -> Unit, + onCheckLocationShared: () -> Boolean, onPromptLocation: () -> Unit, onInfoClick: () -> Unit, ) { + val snackbar = LocalSnackbarController.current + DemoSection { SectionCard(title = "Location", sectionKey = "location", onInfoClick = onInfoClick) { ToggleRow( @@ -527,6 +725,13 @@ fun LocationSection( } Spacer(modifier = Modifier.height(DemoLayout.gap)) PrimaryButton(text = "PROMPT LOCATION", onClick = onPromptLocation, testTag = "prompt_location_button") - PrimaryButton(text = "CHECK LOCATION", onClick = onCheckLocationShared, testTag = "check_location_button") + PrimaryButton( + text = "CHECK LOCATION", + onClick = { + val shared = onCheckLocationShared() + snackbar.show("Location shared: $shared") + }, + testTag = "check_location_button", + ) } } diff --git a/examples/demo/app/src/main/java/com/onesignal/example/ui/theme/DemoLayout.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/theme/DemoLayout.kt index 402fa5e746..77725bf7fa 100644 --- a/examples/demo/app/src/main/java/com/onesignal/example/ui/theme/DemoLayout.kt +++ b/examples/demo/app/src/main/java/com/onesignal/example/ui/theme/DemoLayout.kt @@ -4,6 +4,7 @@ import androidx.compose.ui.unit.dp /** Spacing and sizing from sdk-shared/demo/styles.md */ object DemoLayout { + const val toastDurationMs = 3_000L val gap = 8.dp val sectionGap = 24.dp val pagePadding = 16.dp diff --git a/examples/demo/app/src/main/res/values/strings.xml b/examples/demo/app/src/main/res/values/strings.xml index 6f7c7c18e6..8a499818a2 100644 --- a/examples/demo/app/src/main/res/values/strings.xml +++ b/examples/demo/app/src/main/res/values/strings.xml @@ -25,7 +25,7 @@ Aliases - No Aliases Added + No aliases added ADD ALIAS ADD ALIASES REMOVE ALIASES @@ -38,19 +38,19 @@ Emails - No Emails Added + No emails added ADD EMAIL New Email SMSs - No SMSs Added + No SMS added ADD SMS New SMS Tags - No Tags Added + No tags added ADD TAG ADD TAGS REMOVE TAGS @@ -74,7 +74,7 @@ Triggers - No Triggers Added + No triggers added ADD TRIGGER ADD TRIGGERS REMOVE TRIGGERS