diff --git a/Examples/OneSignalDemoV2/app/agconnect-services.json b/Examples/OneSignalDemoV2/app/agconnect-services.json
new file mode 100644
index 0000000000..965d4a0f6c
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/agconnect-services.json
@@ -0,0 +1,96 @@
+{
+ "agcgw":{
+ "backurl":"connect-dre.hispace.hicloud.com",
+ "url":"connect-dre.dbankcloud.cn",
+ "websocketbackurl":"connect-ws-dre.hispace.dbankcloud.com",
+ "websocketurl":"connect-ws-dre.hispace.dbankcloud.cn"
+ },
+ "agcgw_all":{
+ "CN":"connect-drcn.dbankcloud.cn",
+ "CN_back":"connect-drcn.hispace.hicloud.com",
+ "DE":"connect-dre.dbankcloud.cn",
+ "DE_back":"connect-dre.hispace.hicloud.com",
+ "RU":"connect-drru.hispace.dbankcloud.ru",
+ "RU_back":"connect-drru.hispace.dbankcloud.cn",
+ "SG":"connect-dra.dbankcloud.cn",
+ "SG_back":"connect-dra.hispace.hicloud.com"
+ },
+ "websocketgw_all":{
+ "CN":"connect-ws-drcn.hispace.dbankcloud.cn",
+ "CN_back":"connect-ws-drcn.hispace.dbankcloud.com",
+ "DE":"connect-ws-dre.hispace.dbankcloud.cn",
+ "DE_back":"connect-ws-dre.hispace.dbankcloud.com",
+ "RU":"connect-ws-drru.hispace.dbankcloud.ru",
+ "RU_back":"connect-ws-drru.hispace.dbankcloud.cn",
+ "SG":"connect-ws-dra.hispace.dbankcloud.cn",
+ "SG_back":"connect-ws-dra.hispace.dbankcloud.com"
+ },
+ "client":{
+ "cp_id":"5190001000034239317",
+ "product_id":"388421841221340564",
+ "client_id":"1103097158011211392",
+ "client_secret":"14843C60CAFDCFD5E50025C14864697AFF55886BCF00558E8C817F141E0B4704",
+ "project_id":"388421841221340564",
+ "app_id":"107780279",
+ "api_key":"DAEDAN06wwm3fsiHbQaQzugegFDUc6lpsR9VZGRNoWEbjHpDphR5rSbobUr5/ohT1WlRTyIykjr4GzzEJ/jSxlziFmXF/8e56HAYiw==",
+ "package_name":"com.onesignal.sdktest"
+ },
+ "oauth_client":{
+ "client_id":"107780279",
+ "client_type":1
+ },
+ "app_info":{
+ "app_id":"107780279",
+ "package_name":"com.onesignal.sdktest"
+ },
+ "service":{
+ "analytics":{
+ "collector_url":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn",
+ "collector_url_ru":"datacollector-drru.dt.dbankcloud.ru,datacollector-drru.dt.hicloud.com",
+ "collector_url_sg":"datacollector-dra.dt.hicloud.com,datacollector-dra.dt.dbankcloud.cn",
+ "collector_url_de":"datacollector-dre.dt.hicloud.com,datacollector-dre.dt.dbankcloud.cn",
+ "collector_url_cn":"datacollector-drcn.dt.hicloud.com,datacollector-drcn.dt.dbankcloud.cn",
+ "resource_id":"p1",
+ "channel_id":""
+ },
+ "edukit":{
+ "edu_url":"edukit.edu.cloud.huawei.com.cn",
+ "dh_url":"edukit.edu.cloud.huawei.com.cn"
+ },
+ "search":{
+ "url":"https://search-dre.cloud.huawei.com"
+ },
+ "cloudstorage":{
+ "storage_url_sg_back":"https://agc-storage-dra.cloud.huawei.asia",
+ "storage_url_ru_back":"https://agc-storage-drru.cloud.huawei.ru",
+ "storage_url_ru":"https://agc-storage-drru.cloud.huawei.ru",
+ "storage_url_de_back":"https://agc-storage-dre.cloud.huawei.eu",
+ "storage_url_de":"https://ops-dre.agcstorage.link",
+ "storage_url":"https://agc-storage-drcn.platform.dbankcloud.cn",
+ "storage_url_sg":"https://ops-dra.agcstorage.link",
+ "storage_url_cn_back":"https://agc-storage-drcn.cloud.huawei.com.cn",
+ "storage_url_cn":"https://agc-storage-drcn.platform.dbankcloud.cn"
+ },
+ "ml":{
+ "mlservice_url":"ml-api-dre.ai.dbankcloud.com,ml-api-dre.ai.dbankcloud.cn"
+ }
+ },
+ "region":"DE",
+ "configuration_version":"3.0",
+ "appInfos":[
+ {
+ "package_name":"com.onesignal.sdktest",
+ "client":{
+ "app_id":"107780279"
+ },
+ "app_info":{
+ "package_name":"com.onesignal.sdktest",
+ "app_id":"107780279"
+ },
+ "oauth_client":{
+ "client_type":1,
+ "client_id":"107780279"
+ }
+ }
+ ]
+}
\ No newline at end of file
diff --git a/Examples/OneSignalDemoV2/app/build.gradle.kts b/Examples/OneSignalDemoV2/app/build.gradle.kts
new file mode 100644
index 0000000000..3c16b29203
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/build.gradle.kts
@@ -0,0 +1,136 @@
+plugins {
+ id(Plugins.androidApplication)
+ id(Plugins.kotlinAndroid)
+}
+
+// Apply GMS or Huawei plugin based on build variant
+// Check at configuration time, not when task graph is ready
+val taskRequests = gradle.startParameter.taskRequests.toString().lowercase()
+if (taskRequests.contains("gms")) {
+ apply(plugin = Plugins.googleServices)
+} else if (taskRequests.contains("huawei")) {
+ apply(plugin = Plugins.huaweiAgconnect)
+}
+
+// OneSignal SDK version - can be overridden via gradle property SDK_VERSION
+val sdkVersion: String = rootProject.findProperty("SDK_VERSION") as? String ?: Versions.oneSignalSdk
+
+android {
+ namespace = AppConfig.applicationId
+ compileSdk = Versions.compileSdk
+
+ defaultConfig {
+ minSdk = Versions.minSdk
+ targetSdk = Versions.targetSdk
+ versionCode = Versions.versionCode
+ versionName = Versions.versionName
+ multiDexEnabled = true
+
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildFeatures {
+ compose = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = Versions.composeCompiler
+ }
+
+ flavorDimensions += "default"
+
+ productFlavors {
+ create("gms") {
+ dimension = "default"
+ applicationId = AppConfig.applicationId
+ }
+ create("huawei") {
+ dimension = "default"
+ minSdk = Versions.minSdk
+ applicationId = AppConfig.applicationId
+ }
+ }
+
+ buildTypes {
+ release {
+ isMinifyEnabled = false
+ proguardFiles(
+ getDefaultProguardFile("proguard-android-optimize.txt"),
+ "proguard-rules.pro"
+ )
+ }
+ debug {
+ isDebuggable = true
+ }
+ create("profileable") {
+ initWith(getByName("release"))
+ isDebuggable = false
+ isProfileable = true
+ isMinifyEnabled = false
+ signingConfig = signingConfigs.getByName("debug")
+ matchingFallbacks += listOf("release")
+ }
+ }
+
+ kotlinOptions {
+ jvmTarget = "1.8"
+ }
+
+ compileOptions {
+ sourceCompatibility = JavaVersion.VERSION_1_8
+ targetCompatibility = JavaVersion.VERSION_1_8
+ }
+
+ packaging {
+ resources {
+ excludes += "androidsupportmultidexversion.txt"
+ }
+ }
+}
+
+dependencies {
+ // Kotlin
+ implementation(Dependencies.kotlinStdlib)
+ implementation(Dependencies.coroutinesAndroid)
+
+ // AndroidX
+ implementation(Dependencies.multidex)
+ implementation(Dependencies.appcompat)
+ implementation(Dependencies.coreKtx)
+
+ // Compose BOM
+ implementation(platform(Dependencies.composeBom))
+ implementation(Dependencies.composeUi)
+ implementation(Dependencies.composeUiGraphics)
+ implementation(Dependencies.composeUiToolingPreview)
+ implementation(Dependencies.composeMaterial3)
+ implementation(Dependencies.composeMaterialIcons)
+ implementation(Dependencies.composeRuntime)
+ implementation(Dependencies.composeRuntimeLivedata)
+ debugImplementation(Dependencies.composeUiTooling)
+
+ // Activity & Lifecycle Compose
+ implementation(Dependencies.activityCompose)
+ implementation(Dependencies.lifecycleViewModelCompose)
+ implementation(Dependencies.lifecycleRuntimeCompose)
+
+ // Lifecycle
+ implementation(Dependencies.lifecycleViewModelKtx)
+ implementation(Dependencies.lifecycleRuntimeKtx)
+
+ // Google Play Services
+ implementation(Dependencies.playServicesLocation)
+
+ // OneSignal - Google Play Builds
+ "gmsImplementation"("com.onesignal:OneSignal:$sdkVersion")
+
+ // OneSignal - Huawei Builds
+ "huaweiImplementation"("com.onesignal:OneSignal:$sdkVersion") {
+ exclude(group = "com.google.android.gms", module = "play-services-gcm")
+ exclude(group = "com.google.android.gms", module = "play-services-analytics")
+ exclude(group = "com.google.android.gms", module = "play-services-location")
+ exclude(group = "com.google.firebase", module = "firebase-messaging")
+ }
+ "huaweiImplementation"(Dependencies.huaweiPush)
+ "huaweiImplementation"(Dependencies.huaweiLocation)
+}
diff --git a/Examples/OneSignalDemoV2/app/google-services.json b/Examples/OneSignalDemoV2/app/google-services.json
new file mode 100644
index 0000000000..42c6c4e5cc
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/google-services.json
@@ -0,0 +1,30 @@
+{
+ "project_info": {
+ "project_number": "249481192614",
+ "firebase_url": "https://onesignaltest-e7802.firebaseio.com",
+ "project_id": "onesignaltest-e7802",
+ "storage_bucket": "onesignaltest-e7802.appspot.com"
+ },
+ "client": [
+ {
+ "client_info": {
+ "mobilesdk_app_id": "1:249481192614:android:9d39e24e24034b14",
+ "android_client_info": {
+ "package_name": "com.onesignal.sdktest"
+ }
+ },
+ "oauth_client": [],
+ "api_key": [
+ {
+ "current_key": "AIzaSyC2H_Z5NRhKVJsoG6dLwzSrH3aLNm7p3sw"
+ }
+ ],
+ "services": {
+ "appinvite_service": {
+ "other_platform_oauth_client": []
+ }
+ }
+ }
+ ],
+ "configuration_version": "1"
+}
\ No newline at end of file
diff --git a/Examples/OneSignalDemoV2/app/proguard-rules.pro b/Examples/OneSignalDemoV2/app/proguard-rules.pro
new file mode 100644
index 0000000000..f1b424510d
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/proguard-rules.pro
@@ -0,0 +1,21 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+#
+# For more details, see
+# http://developer.android.com/guide/developing/tools/proguard.html
+
+# If your project uses WebView with JS, uncomment the following
+# and specify the fully qualified class name to the JavaScript interface
+# class:
+#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
+# public *;
+#}
+
+# Uncomment this to preserve the line number information for
+# debugging stack traces.
+#-keepattributes SourceFile,LineNumberTable
+
+# If you keep the line number information, uncomment this to
+# hide the original source file name.
+#-renamesourcefileattribute SourceFile
diff --git a/Examples/OneSignalDemoV2/app/src/huawei/AndroidManifest.xml b/Examples/OneSignalDemoV2/app/src/huawei/AndroidManifest.xml
new file mode 100644
index 0000000000..4cf0999869
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/huawei/AndroidManifest.xml
@@ -0,0 +1,17 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/OneSignalDemoV2/app/src/huawei/java/com/onesignal/sdktest/notification/HmsMessageServiceAppLevel.kt b/Examples/OneSignalDemoV2/app/src/huawei/java/com/onesignal/sdktest/notification/HmsMessageServiceAppLevel.kt
new file mode 100644
index 0000000000..eed818c155
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/huawei/java/com/onesignal/sdktest/notification/HmsMessageServiceAppLevel.kt
@@ -0,0 +1,68 @@
+package com.onesignal.sdktest.notification
+
+import android.os.Bundle
+import android.util.Log
+import com.huawei.hms.push.HmsMessageService
+import com.huawei.hms.push.RemoteMessage
+import com.onesignal.notifications.bridges.OneSignalHmsEventBridge
+
+/**
+ * HMS Message Service for handling Huawei Push notifications.
+ * This service forwards all push events to the OneSignal SDK.
+ *
+ * Note: This is the Huawei flavor-specific implementation.
+ */
+class HmsMessageServiceAppLevel : HmsMessageService() {
+
+ companion object {
+ private const val TAG = "OneSignalHMS"
+ }
+
+ /**
+ * When an app calls the getToken method to apply for a token from the server,
+ * if the server does not return the token during current method calling,
+ * the server can return the token through this method later.
+ * This method callback must be completed in 10 seconds.
+ * Otherwise, you need to start a new Job for callback processing.
+ */
+ override fun onNewToken(token: String, bundle: Bundle) {
+ Log.d(TAG, "HmsMessageServiceAppLevel onNewToken refresh token: $token bundle: $bundle")
+
+ // Forward event on to OneSignal SDK
+ OneSignalHmsEventBridge.onNewToken(this, token, bundle)
+ }
+
+ @Deprecated("Deprecated in Java")
+ override fun onNewToken(token: String) {
+ Log.d(TAG, "HmsMessageServiceAppLevel onNewToken refresh token: $token")
+
+ // Forward event on to OneSignal SDK
+ OneSignalHmsEventBridge.onNewToken(this, token)
+ }
+
+ /**
+ * This method is called in the following cases:
+ * 1. "Data messages" - App process is alive when received.
+ * 2. "Notification Message" - foreground_show = false and app is in focus
+ * This method callback must be completed in 10 seconds.
+ * Start a new Job if more time is needed.
+ */
+ override fun onMessageReceived(message: RemoteMessage) {
+ Log.d(TAG, "HMS onMessageReceived: $message")
+ Log.d(TAG, "HMS onMessageReceived.ttl: ${message.ttl}")
+ Log.d(TAG, "HMS onMessageReceived.data: ${message.data}")
+
+ message.notification?.let { notification ->
+ Log.d(TAG, "HMS onMessageReceived.title: ${notification.title}")
+ Log.d(TAG, "HMS onMessageReceived.body: ${notification.body}")
+ Log.d(TAG, "HMS onMessageReceived.icon: ${notification.icon}")
+ Log.d(TAG, "HMS onMessageReceived.color: ${notification.color}")
+ Log.d(TAG, "HMS onMessageReceived.channelId: ${notification.channelId}")
+ Log.d(TAG, "HMS onMessageReceived.imageURL: ${notification.imageUrl}")
+ Log.d(TAG, "HMS onMessageReceived.tag: ${notification.tag}")
+ }
+
+ // Forward event on to OneSignal SDK
+ OneSignalHmsEventBridge.onMessageReceived(this, message)
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/AndroidManifest.xml b/Examples/OneSignalDemoV2/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..9d72e7e0ed
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/AndroidManifest.xml
@@ -0,0 +1,72 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/application/MainApplication.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/application/MainApplication.kt
new file mode 100644
index 0000000000..2cfe743f3b
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/application/MainApplication.kt
@@ -0,0 +1,145 @@
+package com.onesignal.sdktest.application
+
+import android.os.StrictMode
+import androidx.multidex.MultiDexApplication
+import com.onesignal.OneSignal
+import com.onesignal.debug.LogLevel
+import com.onesignal.sdktest.util.LogManager
+import com.onesignal.sdktest.util.LogLevel as AppLogLevel
+import com.onesignal.inAppMessages.IInAppMessageClickEvent
+import com.onesignal.inAppMessages.IInAppMessageClickListener
+import com.onesignal.inAppMessages.IInAppMessageDidDismissEvent
+import com.onesignal.inAppMessages.IInAppMessageDidDisplayEvent
+import com.onesignal.inAppMessages.IInAppMessageLifecycleListener
+import com.onesignal.inAppMessages.IInAppMessageWillDismissEvent
+import com.onesignal.inAppMessages.IInAppMessageWillDisplayEvent
+import com.onesignal.notifications.IDisplayableNotification
+import com.onesignal.notifications.INotificationClickEvent
+import com.onesignal.notifications.INotificationClickListener
+import com.onesignal.notifications.INotificationLifecycleListener
+import com.onesignal.notifications.INotificationWillDisplayEvent
+import com.onesignal.sdktest.R
+import com.onesignal.sdktest.data.network.OneSignalService
+import com.onesignal.sdktest.util.SharedPreferenceUtil
+import com.onesignal.sdktest.util.TooltipHelper
+import com.onesignal.user.state.IUserStateObserver
+import com.onesignal.user.state.UserChangedState
+
+class MainApplication : MultiDexApplication() {
+
+ companion object {
+ private const val TAG = "OneSignalExample"
+ private const val SLEEP_TIME_TO_MIMIC_ASYNC_OPERATION = 2000L
+ }
+
+ init {
+ // Run strict mode to surface any potential issues easier
+ StrictMode.enableDefaults()
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+
+ OneSignal.Debug.logLevel = LogLevel.VERBOSE
+
+ // Add SDK log listener BEFORE init to capture all SDK logs in UI
+ OneSignal.Debug.addLogListener { event ->
+ val level = when (event.level) {
+ LogLevel.VERBOSE, LogLevel.DEBUG -> AppLogLevel.DEBUG
+ LogLevel.INFO -> AppLogLevel.INFO
+ LogLevel.WARN -> AppLogLevel.WARN
+ LogLevel.ERROR, LogLevel.FATAL -> AppLogLevel.ERROR
+ LogLevel.NONE -> return@addLogListener
+ }
+ LogManager.log("SDK", event.entry, level)
+ }
+
+ // Get or set the OneSignal App ID
+ var appId = SharedPreferenceUtil.getOneSignalAppId(this)
+ if (appId == null) {
+ appId = getString(R.string.onesignal_app_id)
+ SharedPreferenceUtil.cacheOneSignalAppId(this, appId)
+ }
+
+ // Initialize OneSignal Service with app ID and REST API key
+ OneSignalService.setAppId(appId)
+
+ // Initialize tooltip helper
+ TooltipHelper.init(this)
+
+ // Set consent required before init (must be set before initWithContext)
+ OneSignal.consentRequired = SharedPreferenceUtil.getCachedConsentRequired(this)
+ OneSignal.consentGiven = SharedPreferenceUtil.getUserPrivacyConsent(this)
+
+ // Initialize OneSignal on main thread (required)
+ OneSignal.initWithContext(this, appId)
+ LogManager.i(TAG, "OneSignal init completed")
+
+ // Set up all OneSignal listeners
+ setupOneSignalListeners()
+
+ // Note: Notification permission is automatically requested when MainActivity loads.
+ // This ensures the prompt appears after the user sees the app UI.
+ }
+
+ private fun setupOneSignalListeners() {
+ OneSignal.InAppMessages.addLifecycleListener(object : IInAppMessageLifecycleListener {
+ override fun onWillDisplay(event: IInAppMessageWillDisplayEvent) {
+ LogManager.d(TAG, "onWillDisplayInAppMessage")
+ }
+
+ override fun onDidDisplay(event: IInAppMessageDidDisplayEvent) {
+ LogManager.d(TAG, "onDidDisplayInAppMessage")
+ }
+
+ override fun onWillDismiss(event: IInAppMessageWillDismissEvent) {
+ LogManager.d(TAG, "onWillDismissInAppMessage")
+ }
+
+ override fun onDidDismiss(event: IInAppMessageDidDismissEvent) {
+ LogManager.d(TAG, "onDidDismissInAppMessage")
+ }
+ })
+
+ OneSignal.InAppMessages.addClickListener(object : IInAppMessageClickListener {
+ override fun onClick(event: IInAppMessageClickEvent) {
+ LogManager.d(TAG, "IInAppMessageClickListener.onClick")
+ }
+ })
+
+ OneSignal.Notifications.addClickListener(object : INotificationClickListener {
+ override fun onClick(event: INotificationClickEvent) {
+ LogManager.d(TAG, "INotificationClickListener.onClick fired with event: $event")
+ }
+ })
+
+ OneSignal.Notifications.addForegroundLifecycleListener(object : INotificationLifecycleListener {
+ override fun onWillDisplay(event: INotificationWillDisplayEvent) {
+ LogManager.d(TAG, "INotificationLifecycleListener.onWillDisplay fired with event: $event")
+
+ val notification: IDisplayableNotification = event.notification
+
+ // Prevent OneSignal from displaying the notification immediately on return.
+ // Spin up a new thread to mimic some asynchronous behavior.
+ event.preventDefault()
+ Thread {
+ try {
+ Thread.sleep(SLEEP_TIME_TO_MIMIC_ASYNC_OPERATION)
+ } catch (ignored: InterruptedException) {
+ }
+ notification.display()
+ }.start()
+ }
+ })
+
+ OneSignal.User.addObserver(object : IUserStateObserver {
+ override fun onUserStateChange(state: UserChangedState) {
+ LogManager.i(TAG, "User state changed: onesignalId=${state.current.onesignalId}, externalId=${state.current.externalId}")
+ }
+ })
+
+ // Restore cached states
+ OneSignal.InAppMessages.paused = SharedPreferenceUtil.getCachedInAppMessagingPausedStatus(this)
+ OneSignal.Location.isShared = SharedPreferenceUtil.getCachedLocationSharedStatus(this)
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/data/model/InAppMessageType.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/data/model/InAppMessageType.kt
new file mode 100644
index 0000000000..7491dc0524
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/data/model/InAppMessageType.kt
@@ -0,0 +1,43 @@
+package com.onesignal.sdktest.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.
+ */
+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/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/data/model/NotificationType.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/data/model/NotificationType.kt
new file mode 100644
index 0000000000..b6ee86bf27
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/data/model/NotificationType.kt
@@ -0,0 +1,24 @@
+package com.onesignal.sdktest.data.model
+
+/**
+ * Enum representing different types of push notifications that can be sent.
+ */
+enum class NotificationType(
+ val title: String,
+ val notificationTitle: String,
+ val notificationBody: String,
+ val bigPicture: String? = null,
+ val largeIcon: String? = null
+) {
+ SIMPLE(
+ title = "Simple",
+ notificationTitle = "Simple Notification",
+ notificationBody = "This is a simple push notification"
+ ),
+ WITH_IMAGE(
+ title = "With Image",
+ notificationTitle = "Image Notification",
+ notificationBody = "This notification includes an image",
+ bigPicture = "https://media.onesignal.com/automated_push_templates/ratings_template.png"
+ )
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt
new file mode 100644
index 0000000000..8982aefc85
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/data/network/OneSignalService.kt
@@ -0,0 +1,288 @@
+package com.onesignal.sdktest.data.network
+
+import com.onesignal.OneSignal
+import com.onesignal.sdktest.data.model.NotificationType
+import com.onesignal.sdktest.util.LogManager
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * OneSignal API service for testing purposes.
+ * Provides methods to send notifications and fetch user data via the REST API.
+ *
+ * Note: This approach is for testing purposes only. In production, notifications
+ * should be sent from your backend server.
+ */
+object OneSignalService {
+
+ private const val TAG = "OneSignalService"
+ private const val ONESIGNAL_API_URL = "https://onesignal.com/api/v1/notifications"
+ private const val ONESIGNAL_API_BASE_URL = "https://api.onesignal.com"
+
+ private var appId: String = ""
+
+ fun setAppId(appId: String) {
+ this.appId = appId
+ }
+
+ fun getAppId(): String = appId
+
+ /**
+ * Send a notification to this device.
+ */
+ suspend fun sendNotification(type: NotificationType): Boolean = withContext(Dispatchers.IO) {
+ val subscription = OneSignal.User.pushSubscription
+
+ if (!subscription.optedIn) {
+ LogManager.w(TAG, "Cannot send notification - user not opted in")
+ return@withContext false
+ }
+
+ val subscriptionId = subscription.id
+ if (subscriptionId.isNullOrEmpty()) {
+ LogManager.w(TAG, "Cannot send notification - no subscription ID")
+ return@withContext false
+ }
+
+ try {
+ val notificationJson = JSONObject().apply {
+ put("app_id", appId)
+ put("include_subscription_ids", org.json.JSONArray().put(subscriptionId))
+ put("headings", JSONObject().put("en", type.notificationTitle))
+ put("contents", JSONObject().put("en", type.notificationBody))
+ put("android_group", type.title)
+ put("android_led_color", "FF595CF2")
+ put("android_accent_color", "FF595CF2")
+ // Add large icon if available
+ type.largeIcon?.let {
+ put("large_icon", it)
+ LogManager.d(TAG, "Adding large_icon: $it")
+ }
+ // Add big picture if available
+ type.bigPicture?.let {
+ put("big_picture", it)
+ LogManager.d(TAG, "Adding big_picture: $it")
+ }
+ }
+
+ LogManager.d(TAG, "Sending notification: ${notificationJson.toString(2)}")
+ LogManager.d(TAG, "Request URL: $ONESIGNAL_API_URL")
+
+ val connection = (URL(ONESIGNAL_API_URL).openConnection() as HttpURLConnection).apply {
+ useCaches = false
+ connectTimeout = 30000
+ readTimeout = 30000
+ setRequestProperty("Accept", "application/vnd.onesignal.v1+json")
+ setRequestProperty("Content-Type", "application/json; charset=UTF-8")
+ requestMethod = "POST"
+ doOutput = true
+ doInput = true
+ }
+
+ val outputBytes = notificationJson.toString().toByteArray(Charsets.UTF_8)
+ connection.setFixedLengthStreamingMode(outputBytes.size)
+ connection.outputStream.write(outputBytes)
+
+ val responseCode = connection.responseCode
+
+ if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_ACCEPTED || responseCode == HttpURLConnection.HTTP_CREATED) {
+ val response = connection.inputStream.bufferedReader().use { it.readText() }
+ LogManager.d(TAG, "Notification sent successfully: $response")
+ return@withContext true
+ } else {
+ val errorResponse = connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "Unknown error"
+ LogManager.e(TAG, "Failed to send notification (HTTP $responseCode): $errorResponse")
+ LogManager.e(TAG, "Request body was: ${notificationJson.toString()}")
+ return@withContext false
+ }
+ } catch (e: Exception) {
+ LogManager.e(TAG, "Error sending notification", e)
+ return@withContext false
+ }
+ }
+
+ /**
+ * Send a custom notification with title and body.
+ */
+ suspend fun sendCustomNotification(title: String, body: String): Boolean = withContext(Dispatchers.IO) {
+ val subscription = OneSignal.User.pushSubscription
+
+ if (!subscription.optedIn) {
+ LogManager.w(TAG, "Cannot send notification - user not opted in")
+ return@withContext false
+ }
+
+ val subscriptionId = subscription.id
+ if (subscriptionId.isNullOrEmpty()) {
+ LogManager.w(TAG, "Cannot send notification - no subscription ID")
+ return@withContext false
+ }
+
+ try {
+ val notificationJson = JSONObject().apply {
+ put("app_id", appId)
+ put("include_subscription_ids", org.json.JSONArray().put(subscriptionId))
+ put("headings", JSONObject().put("en", title))
+ put("contents", JSONObject().put("en", body))
+ put("android_led_color", "FF595CF2")
+ put("android_accent_color", "FF595CF2")
+ }
+
+ val connection = (URL(ONESIGNAL_API_URL).openConnection() as HttpURLConnection).apply {
+ useCaches = false
+ connectTimeout = 30000
+ readTimeout = 30000
+ setRequestProperty("Accept", "application/vnd.onesignal.v1+json")
+ setRequestProperty("Content-Type", "application/json; charset=UTF-8")
+ requestMethod = "POST"
+ doOutput = true
+ doInput = true
+ }
+
+ val outputBytes = notificationJson.toString().toByteArray(Charsets.UTF_8)
+ connection.setFixedLengthStreamingMode(outputBytes.size)
+ connection.outputStream.write(outputBytes)
+
+ val responseCode = connection.responseCode
+
+ if (responseCode == HttpURLConnection.HTTP_OK || responseCode == HttpURLConnection.HTTP_ACCEPTED || responseCode == HttpURLConnection.HTTP_CREATED) {
+ val response = connection.inputStream.bufferedReader().use { it.readText() }
+ LogManager.d(TAG, "Custom notification sent successfully: $response")
+ return@withContext true
+ } else {
+ val errorResponse = connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "Unknown error"
+ LogManager.e(TAG, "Failed to send custom notification (HTTP $responseCode): $errorResponse")
+ return@withContext false
+ }
+ } catch (e: Exception) {
+ LogManager.e(TAG, "Error sending custom notification", e)
+ return@withContext false
+ }
+ }
+
+ /**
+ * Fetch user data from OneSignal API.
+ * Note: This endpoint does not require authentication.
+ *
+ * @param onesignalId The OneSignal user ID
+ * @return UserData object containing aliases, tags, emails, and SMS numbers, or null on error
+ */
+ suspend fun fetchUser(onesignalId: String): UserData? = withContext(Dispatchers.IO) {
+ if (onesignalId.isEmpty()) {
+ LogManager.w(TAG, "Cannot fetch user - onesignalId is empty")
+ return@withContext null
+ }
+
+ if (appId.isEmpty()) {
+ LogManager.w(TAG, "Cannot fetch user - appId not set")
+ return@withContext null
+ }
+
+ try {
+ val url = "$ONESIGNAL_API_BASE_URL/apps/$appId/users/by/onesignal_id/$onesignalId"
+ LogManager.d(TAG, "Fetching user data from: $url")
+
+ val connection = (URL(url).openConnection() as HttpURLConnection).apply {
+ useCaches = false
+ connectTimeout = 30000
+ readTimeout = 30000
+ setRequestProperty("Accept", "application/json")
+ requestMethod = "GET"
+ }
+
+ val responseCode = connection.responseCode
+
+ if (responseCode == HttpURLConnection.HTTP_OK) {
+ val response = connection.inputStream.bufferedReader().use { it.readText() }
+ LogManager.d(TAG, "User data fetched successfully, parsing response...")
+ try {
+ val userData = parseUserResponse(response)
+ LogManager.d(TAG, "Parsed user data: aliases=${userData.aliases.size}, tags=${userData.tags.size}, emails=${userData.emails.size}, sms=${userData.smsNumbers.size}")
+ return@withContext userData
+ } catch (e: Exception) {
+ LogManager.e(TAG, "Error parsing user response", e)
+ return@withContext null
+ }
+ } else {
+ val errorResponse = connection.errorStream?.bufferedReader()?.use { it.readText() } ?: "Unknown error"
+ LogManager.e(TAG, "Failed to fetch user (HTTP $responseCode): $errorResponse")
+ return@withContext null
+ }
+ } catch (e: Exception) {
+ LogManager.e(TAG, "Error fetching user", e)
+ return@withContext null
+ }
+ }
+
+ private fun parseUserResponse(json: String): UserData {
+ val jsonObject = JSONObject(json)
+
+ // Parse aliases from identity object (filter out external_id and onesignal_id)
+ val aliases = mutableMapOf()
+ if (jsonObject.has("identity")) {
+ val identity = jsonObject.getJSONObject("identity")
+ identity.keys().forEach { key ->
+ if (key != "external_id" && key != "onesignal_id") {
+ aliases[key] = identity.getString(key)
+ }
+ }
+ }
+
+ // Parse external_id separately
+ val externalId = if (jsonObject.has("identity")) {
+ val identity = jsonObject.getJSONObject("identity")
+ if (identity.has("external_id")) identity.getString("external_id") else null
+ } else null
+
+ // Parse tags from properties object
+ val tags = mutableMapOf()
+ if (jsonObject.has("properties")) {
+ val properties = jsonObject.getJSONObject("properties")
+ if (properties.has("tags")) {
+ val tagsObj = properties.getJSONObject("tags")
+ tagsObj.keys().forEach { key ->
+ tags[key] = tagsObj.getString(key)
+ }
+ }
+ }
+
+ // Parse subscriptions for emails and SMS
+ val emails = mutableListOf()
+ val smsNumbers = mutableListOf()
+ if (jsonObject.has("subscriptions")) {
+ val subscriptions = jsonObject.getJSONArray("subscriptions")
+ for (i in 0 until subscriptions.length()) {
+ val subscription = subscriptions.getJSONObject(i)
+ val type = subscription.optString("type", "")
+ val token = subscription.optString("token", "")
+
+ when (type) {
+ "Email" -> if (token.isNotEmpty()) emails.add(token)
+ "SMS" -> if (token.isNotEmpty()) smsNumbers.add(token)
+ }
+ }
+ }
+
+ return UserData(
+ aliases = aliases,
+ tags = tags,
+ emails = emails,
+ smsNumbers = smsNumbers,
+ externalId = externalId
+ )
+ }
+}
+
+/**
+ * Data class representing user data fetched from the OneSignal API.
+ */
+data class UserData(
+ val aliases: Map,
+ val tags: Map,
+ val emails: List,
+ val smsNumbers: List,
+ val externalId: String?
+)
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt
new file mode 100644
index 0000000000..70696e54fd
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/data/repository/OneSignalRepository.kt
@@ -0,0 +1,243 @@
+package com.onesignal.sdktest.data.repository
+
+import android.util.Log
+import com.onesignal.OneSignal
+import com.onesignal.sdktest.data.model.NotificationType
+import com.onesignal.sdktest.data.network.OneSignalService
+import com.onesignal.sdktest.data.network.UserData
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+
+/**
+ * Repository for all OneSignal SDK operations.
+ * All methods are suspend functions to be called from coroutines on background threads.
+ */
+class OneSignalRepository {
+
+ companion object {
+ private const val TAG = "OneSignalRepository"
+ }
+
+ // User operations
+ suspend fun loginUser(externalUserId: String) = withContext(Dispatchers.IO) {
+ Log.d(TAG, "Logging in user with externalUserId: $externalUserId")
+ OneSignal.login(externalUserId)
+ Log.d(TAG, "Logged in user with onesignalId: ${OneSignal.User.onesignalId}")
+ }
+
+ suspend fun logoutUser() = withContext(Dispatchers.IO) {
+ Log.d(TAG, "Logging out user")
+ OneSignal.logout()
+ }
+
+ // Alias operations
+ fun addAlias(label: String, id: String) {
+ Log.d(TAG, "Adding alias: $label -> $id")
+ OneSignal.User.addAlias(label, id)
+ }
+
+ fun addAliases(aliases: Map) {
+ Log.d(TAG, "Adding aliases: $aliases")
+ OneSignal.User.addAliases(aliases)
+ }
+
+ fun removeAlias(label: String) {
+ Log.d(TAG, "Removing alias: $label")
+ OneSignal.User.removeAlias(label)
+ }
+
+ fun removeAliases(labels: Collection) {
+ Log.d(TAG, "Removing aliases: $labels")
+ if (labels.isNotEmpty()) {
+ OneSignal.User.removeAliases(labels)
+ }
+ }
+
+ // Email operations
+ fun addEmail(email: String) {
+ Log.d(TAG, "Adding email: $email")
+ OneSignal.User.addEmail(email)
+ }
+
+ fun removeEmail(email: String) {
+ Log.d(TAG, "Removing email: $email")
+ OneSignal.User.removeEmail(email)
+ }
+
+ // SMS operations
+ fun addSms(smsNumber: String) {
+ Log.d(TAG, "Adding SMS: $smsNumber")
+ OneSignal.User.addSms(smsNumber)
+ }
+
+ fun removeSms(smsNumber: String) {
+ Log.d(TAG, "Removing SMS: $smsNumber")
+ OneSignal.User.removeSms(smsNumber)
+ }
+
+ // Tag operations
+ fun addTag(key: String, value: String) {
+ Log.d(TAG, "Adding tag: $key -> $value")
+ OneSignal.User.addTag(key, value)
+ }
+
+ fun addTags(tags: Map) {
+ Log.d(TAG, "Adding tags: $tags")
+ OneSignal.User.addTags(tags)
+ }
+
+ fun removeTag(key: String) {
+ Log.d(TAG, "Removing tag: $key")
+ OneSignal.User.removeTag(key)
+ }
+
+ fun removeTags(keys: Collection) {
+ Log.d(TAG, "Removing tags: $keys")
+ if (keys.isNotEmpty()) {
+ OneSignal.User.removeTags(keys)
+ }
+ }
+
+ fun getTags(): Map {
+ return OneSignal.User.getTags()
+ }
+
+ // Trigger operations
+ fun addTrigger(key: String, value: String) {
+ Log.d(TAG, "Adding trigger: $key -> $value")
+ OneSignal.InAppMessages.addTrigger(key, value)
+ }
+
+ fun addTriggers(triggers: Map) {
+ Log.d(TAG, "Adding triggers: $triggers")
+ OneSignal.InAppMessages.addTriggers(triggers)
+ }
+
+ fun removeTrigger(key: String) {
+ Log.d(TAG, "Removing trigger: $key")
+ OneSignal.InAppMessages.removeTrigger(key)
+ }
+
+ fun clearTriggers(keys: Collection) {
+ Log.d(TAG, "Clearing triggers: $keys")
+ if (keys.isNotEmpty()) {
+ OneSignal.InAppMessages.removeTriggers(keys)
+ }
+ }
+
+ // Outcome operations
+ fun sendOutcome(name: String) {
+ Log.d(TAG, "Sending outcome: $name")
+ OneSignal.Session.addOutcome(name)
+ }
+
+ fun sendUniqueOutcome(name: String) {
+ Log.d(TAG, "Sending unique outcome: $name")
+ OneSignal.Session.addUniqueOutcome(name)
+ }
+
+ fun sendOutcomeWithValue(name: String, value: Float) {
+ Log.d(TAG, "Sending outcome with value: $name -> $value")
+ OneSignal.Session.addOutcomeWithValue(name, value)
+ }
+
+ // Track Event
+ fun trackEvent(name: String, properties: Map?) {
+ Log.d(TAG, "Tracking event: $name with properties: $properties")
+ OneSignal.User.trackEvent(name, properties)
+ }
+
+ // Push subscription
+ fun getPushSubscriptionId(): String? {
+ return OneSignal.User.pushSubscription.id
+ }
+
+ fun isPushEnabled(): Boolean {
+ return OneSignal.User.pushSubscription.optedIn
+ }
+
+ fun setPushEnabled(enabled: Boolean) {
+ Log.d(TAG, "Setting push enabled: $enabled")
+ if (enabled) {
+ OneSignal.User.pushSubscription.optIn()
+ } else {
+ OneSignal.User.pushSubscription.optOut()
+ }
+ }
+
+ // In-App Messaging
+ fun isInAppMessagesPaused(): Boolean {
+ return OneSignal.InAppMessages.paused
+ }
+
+ fun setInAppMessagesPaused(paused: Boolean) {
+ Log.d(TAG, "Setting in-app messages paused: $paused")
+ OneSignal.InAppMessages.paused = paused
+ }
+
+ // Location
+ fun isLocationShared(): Boolean {
+ return OneSignal.Location.isShared
+ }
+
+ fun setLocationShared(shared: Boolean) {
+ Log.d(TAG, "Setting location shared: $shared")
+ OneSignal.Location.isShared = shared
+ }
+
+ suspend fun promptLocation() = withContext(Dispatchers.IO) {
+ Log.d(TAG, "Prompting for location permission")
+ OneSignal.Location.requestPermission()
+ }
+
+ // Notifications
+ suspend fun promptPushPermission() = withContext(Dispatchers.IO) {
+ Log.d(TAG, "Prompting for push permission")
+ OneSignal.Notifications.requestPermission(true)
+ }
+
+ fun hasNotificationPermission(): Boolean {
+ return OneSignal.Notifications.permission
+ }
+
+ // Send notifications
+ suspend fun sendNotification(type: NotificationType): Boolean {
+ Log.d(TAG, "Sending notification: ${type.title}")
+ return OneSignalService.sendNotification(type)
+ }
+
+ suspend fun sendCustomNotification(title: String, body: String): Boolean {
+ Log.d(TAG, "Sending custom notification: $title")
+ return OneSignalService.sendCustomNotification(title, body)
+ }
+
+ // Privacy consent
+ fun setConsentRequired(required: Boolean) {
+ Log.d(TAG, "Setting consent required: $required")
+ OneSignal.consentRequired = required
+ }
+
+ fun getConsentRequired(): Boolean {
+ return OneSignal.consentRequired
+ }
+
+ fun setPrivacyConsent(granted: Boolean) {
+ Log.d(TAG, "Setting privacy consent: $granted")
+ OneSignal.consentGiven = granted
+ }
+
+ fun getPrivacyConsent(): Boolean {
+ return OneSignal.consentGiven
+ }
+
+ // OneSignal ID
+ fun getOneSignalId(): String? {
+ return OneSignal.User.onesignalId
+ }
+
+ // Fetch user data from API
+ suspend fun fetchUser(onesignalId: String): UserData? = withContext(Dispatchers.IO) {
+ Log.d(TAG, "Fetching user data for: $onesignalId")
+ OneSignalService.fetchUser(onesignalId)
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/ActionButton.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/ActionButton.kt
new file mode 100644
index 0000000000..b99c2bb004
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/ActionButton.kt
@@ -0,0 +1,177 @@
+package com.onesignal.sdktest.ui.components
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.onesignal.sdktest.ui.theme.OneSignalRed
+
+private val ButtonShape = RoundedCornerShape(10.dp)
+
+/**
+ * Primary action button (full width, colored background).
+ */
+@Composable
+fun PrimaryButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ backgroundColor: Color = MaterialTheme.colorScheme.primary,
+ enabled: Boolean = true
+) {
+ Button(
+ onClick = onClick,
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .height(44.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = backgroundColor,
+ disabledContainerColor = backgroundColor.copy(alpha = 0.4f)
+ ),
+ enabled = enabled,
+ shape = ButtonShape,
+ elevation = ButtonDefaults.buttonElevation(
+ defaultElevation = 0.dp,
+ pressedElevation = 0.dp
+ )
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge.copy(
+ color = Color.White,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 13.sp,
+ letterSpacing = 0.8.sp
+ )
+ )
+ }
+}
+
+/**
+ * Outline button (outlined primary style).
+ */
+@Composable
+fun OutlineButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true
+) {
+ val color = MaterialTheme.colorScheme.primary
+ OutlinedButton(
+ onClick = onClick,
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .height(44.dp),
+ colors = ButtonDefaults.outlinedButtonColors(contentColor = color),
+ enabled = enabled,
+ shape = ButtonShape,
+ border = BorderStroke(1.dp, if (enabled) color.copy(alpha = 0.5f) else color.copy(alpha = 0.2f))
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge.copy(
+ color = color,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 13.sp,
+ letterSpacing = 0.8.sp
+ )
+ )
+ }
+}
+
+/**
+ * Destructive button (outlined red style).
+ */
+@Composable
+fun DestructiveButton(
+ text: String,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true
+) {
+ OutlinedButton(
+ onClick = onClick,
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .height(44.dp),
+ colors = ButtonDefaults.outlinedButtonColors(
+ contentColor = OneSignalRed
+ ),
+ enabled = enabled,
+ shape = ButtonShape,
+ border = BorderStroke(1.dp, if (enabled) OneSignalRed.copy(alpha = 0.5f) else OneSignalRed.copy(alpha = 0.2f))
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge.copy(
+ color = OneSignalRed,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 13.sp,
+ letterSpacing = 0.8.sp
+ )
+ )
+ }
+}
+
+/**
+ * Button with icon on the right side.
+ */
+@Composable
+fun IconButton(
+ text: String,
+ icon: ImageVector,
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ backgroundColor: Color = OneSignalRed
+) {
+ Button(
+ onClick = onClick,
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .height(44.dp),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = backgroundColor
+ ),
+ shape = ButtonShape,
+ elevation = ButtonDefaults.buttonElevation(
+ defaultElevation = 0.dp,
+ pressedElevation = 0.dp
+ )
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.labelLarge.copy(
+ color = Color.White,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 13.sp,
+ letterSpacing = 0.8.sp
+ ),
+ modifier = Modifier.padding(end = 8.dp)
+ )
+ Icon(
+ imageVector = icon,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt
new file mode 100644
index 0000000000..8045664984
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/Dialogs.kt
@@ -0,0 +1,660 @@
+package com.onesignal.sdktest.ui.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Add
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.window.Dialog
+import androidx.compose.ui.window.DialogProperties
+import androidx.compose.ui.graphics.Color
+import com.onesignal.sdktest.ui.theme.OneSignalRed
+import org.json.JSONObject
+
+private val TextFieldShape = RoundedCornerShape(10.dp)
+
+@Composable
+private fun dialogTextFieldColors() = OutlinedTextFieldDefaults.colors(
+ unfocusedBorderColor = Color(0xFFBDBDBD),
+ focusedBorderColor = OneSignalRed,
+ cursorColor = OneSignalRed,
+ focusedLabelColor = OneSignalRed
+)
+
+/**
+ * Dialog for entering a single value.
+ */
+@Composable
+fun SingleInputDialog(
+ title: String,
+ label: String,
+ onDismiss: () -> Unit,
+ onConfirm: (String) -> Unit,
+ keyboardType: KeyboardType = KeyboardType.Text
+) {
+ var value by remember { mutableStateOf("") }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ title = {
+ Text(title, style = MaterialTheme.typography.titleMedium)
+ },
+ text = {
+ OutlinedTextField(
+ value = value,
+ onValueChange = { value = it },
+ label = { Text(label) },
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions(keyboardType = keyboardType),
+ singleLine = true,
+ shape = TextFieldShape,
+ colors = dialogTextFieldColors()
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(value) },
+ enabled = value.isNotBlank()
+ ) {
+ Text("Add")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ },
+ shape = RoundedCornerShape(16.dp)
+ )
+}
+
+/**
+ * Dialog for entering a key-value pair.
+ */
+@Composable
+fun PairInputDialog(
+ title: String,
+ keyLabel: String = "Key",
+ valueLabel: String = "Value",
+ onDismiss: () -> Unit,
+ onConfirm: (String, String) -> Unit
+) {
+ var key by remember { mutableStateOf("") }
+ var value by remember { mutableStateOf("") }
+
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
+ shape = RoundedCornerShape(16.dp),
+ tonalElevation = 2.dp
+ ) {
+ Column(modifier = Modifier.padding(24.dp)) {
+ Text(title, style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(20.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp)
+ ) {
+ OutlinedTextField(
+ value = key,
+ onValueChange = { key = it },
+ label = { Text(keyLabel) },
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ shape = TextFieldShape,
+ colors = dialogTextFieldColors()
+ )
+ OutlinedTextField(
+ value = value,
+ onValueChange = { value = it },
+ label = { Text(valueLabel) },
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ shape = TextFieldShape,
+ colors = dialogTextFieldColors()
+ )
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ TextButton(
+ onClick = { onConfirm(key, value) },
+ enabled = key.isNotBlank() && value.isNotBlank()
+ ) {
+ Text("Add")
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Dialog for entering multiple key-value pairs.
+ */
+@Composable
+fun MultiPairInputDialog(
+ title: String,
+ keyLabel: String = "Key",
+ valueLabel: String = "Value",
+ onDismiss: () -> Unit,
+ onConfirm: (List>) -> Unit
+) {
+ var pairs by remember { mutableStateOf(listOf(Pair("", ""))) }
+
+ val allValid = pairs.all { it.first.isNotBlank() && it.second.isNotBlank() }
+
+ Dialog(
+ onDismissRequest = onDismiss,
+ properties = DialogProperties(usePlatformDefaultWidth = false)
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
+ shape = RoundedCornerShape(16.dp),
+ tonalElevation = 2.dp
+ ) {
+ Column(modifier = Modifier.padding(24.dp)) {
+ Text(title, style = MaterialTheme.typography.titleMedium)
+ Spacer(modifier = Modifier.height(20.dp))
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 400.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ pairs.forEachIndexed { index, (key, value) ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.spacedBy(10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ OutlinedTextField(
+ value = key,
+ onValueChange = { newKey ->
+ pairs = pairs.toMutableList().apply {
+ this[index] = Pair(newKey, this[index].second)
+ }
+ },
+ label = { Text(keyLabel) },
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ shape = TextFieldShape,
+ colors = dialogTextFieldColors()
+ )
+ OutlinedTextField(
+ value = value,
+ onValueChange = { newValue ->
+ pairs = pairs.toMutableList().apply {
+ this[index] = Pair(this[index].first, newValue)
+ }
+ },
+ label = { Text(valueLabel) },
+ modifier = Modifier.weight(1f),
+ singleLine = true,
+ shape = TextFieldShape,
+ colors = dialogTextFieldColors()
+ )
+ if (pairs.size > 1) {
+ IconButton(
+ onClick = {
+ pairs = pairs.toMutableList().apply { removeAt(index) }
+ }
+ ) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Remove",
+ tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f)
+ )
+ }
+ }
+ }
+ if (index < pairs.lastIndex) {
+ HorizontalDivider(modifier = Modifier.padding(vertical = 12.dp))
+ }
+ }
+
+ Spacer(modifier = Modifier.height(12.dp))
+
+ TextButton(
+ onClick = { pairs = pairs + Pair("", "") },
+ modifier = Modifier.align(Alignment.CenterHorizontally)
+ ) {
+ Icon(Icons.Default.Add, contentDescription = null)
+ Spacer(modifier = Modifier.width(4.dp))
+ Text("Add Row")
+ }
+ }
+ Spacer(modifier = Modifier.height(24.dp))
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.End
+ ) {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ TextButton(
+ onClick = { onConfirm(pairs) },
+ enabled = allValid
+ ) {
+ Text("Add All")
+ }
+ }
+ }
+ }
+ }
+}
+
+/**
+ * Dialog for selecting multiple items to remove.
+ */
+@Composable
+fun MultiSelectRemoveDialog(
+ title: String,
+ items: List>,
+ onDismiss: () -> Unit,
+ onConfirm: (Collection) -> Unit
+) {
+ var selectedKeys by remember { mutableStateOf(setOf()) }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ title = {
+ Text(title, style = MaterialTheme.typography.titleMedium)
+ },
+ text = {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .heightIn(max = 400.dp)
+ .verticalScroll(rememberScrollState())
+ ) {
+ items.forEach { (key, value) ->
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable {
+ selectedKeys = if (key in selectedKeys) {
+ selectedKeys - key
+ } else {
+ selectedKeys + key
+ }
+ }
+ .padding(vertical = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Checkbox(
+ checked = key in selectedKeys,
+ onCheckedChange = { checked ->
+ selectedKeys = if (checked) {
+ selectedKeys + key
+ } else {
+ selectedKeys - key
+ }
+ }
+ )
+ Spacer(modifier = Modifier.width(8.dp))
+ Text(
+ text = key,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(selectedKeys) },
+ enabled = selectedKeys.isNotEmpty()
+ ) {
+ Text("Remove (${selectedKeys.size})")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ },
+ shape = RoundedCornerShape(16.dp)
+ )
+}
+
+/**
+ * Dialog for login/switch user.
+ */
+@Composable
+fun LoginDialog(
+ onDismiss: () -> Unit,
+ onConfirm: (String) -> Unit
+) {
+ SingleInputDialog(
+ title = "Login User",
+ label = "External User Id",
+ onDismiss = onDismiss,
+ onConfirm = onConfirm
+ )
+}
+
+/**
+ * Dialog for outcome selection and input.
+ */
+@Composable
+fun OutcomeDialog(
+ onDismiss: () -> Unit,
+ onSendNormal: (String) -> Unit,
+ onSendUnique: (String) -> Unit,
+ onSendWithValue: (String, Float) -> Unit
+) {
+ var selectedType by remember { mutableStateOf(0) }
+ var outcomeName by remember { mutableStateOf("") }
+ var outcomeValue by remember { mutableStateOf("") }
+
+ val outcomeTypes = listOf("Normal Outcome", "Unique Outcome", "Outcome with Value")
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ title = {
+ Text("Send Outcome", style = MaterialTheme.typography.titleMedium)
+ },
+ text = {
+ Column {
+ outcomeTypes.forEachIndexed { index, type ->
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ RadioButton(
+ selected = selectedType == index,
+ onClick = { selectedType = index }
+ )
+ Text(
+ text = type,
+ style = MaterialTheme.typography.bodyMedium,
+ modifier = Modifier.padding(start = 4.dp)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = outcomeName,
+ onValueChange = { outcomeName = it },
+ label = { Text("Outcome Name") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ shape = TextFieldShape,
+ colors = dialogTextFieldColors()
+ )
+
+ if (selectedType == 2) {
+ Spacer(modifier = Modifier.height(10.dp))
+ OutlinedTextField(
+ value = outcomeValue,
+ onValueChange = { outcomeValue = it },
+ label = { Text("Value") },
+ modifier = Modifier.fillMaxWidth(),
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
+ singleLine = true,
+ shape = TextFieldShape,
+ colors = dialogTextFieldColors()
+ )
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ when (selectedType) {
+ 0 -> onSendNormal(outcomeName)
+ 1 -> onSendUnique(outcomeName)
+ 2 -> onSendWithValue(outcomeName, outcomeValue.toFloatOrNull() ?: 0f)
+ }
+ },
+ enabled = outcomeName.isNotBlank()
+ ) {
+ Text("Send")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ },
+ shape = RoundedCornerShape(16.dp)
+ )
+}
+
+private fun isValidJsonObject(value: String?): Boolean {
+ if (value.isNullOrBlank()) return true
+ return try {
+ JSONObject(value)
+ true
+ } catch (e: Exception) {
+ false
+ }
+}
+
+private fun parseJsonToMap(json: String): Map? {
+ if (json.isBlank()) return null
+ return try {
+ val jsonObject = JSONObject(json)
+ val map = mutableMapOf()
+ jsonObject.keys().forEach { key ->
+ map[key] = jsonObject.get(key)
+ }
+ map
+ } catch (e: Exception) {
+ null
+ }
+}
+
+/**
+ * Dialog for track event with JSON validation.
+ */
+@Composable
+fun TrackEventDialog(
+ onDismiss: () -> Unit,
+ onConfirm: (String, Map?) -> Unit
+) {
+ var eventName by remember { mutableStateOf("") }
+ var eventValue by remember { mutableStateOf("") }
+ var showError by remember { mutableStateOf(false) }
+
+ val isValueValid = isValidJsonObject(eventValue)
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ title = {
+ Text("Track Event", style = MaterialTheme.typography.titleMedium)
+ },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = eventName,
+ onValueChange = { eventName = it },
+ label = { Text("Event Name") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ isError = eventName.isBlank() && showError,
+ shape = TextFieldShape,
+ colors = dialogTextFieldColors()
+ )
+
+ Spacer(modifier = Modifier.height(10.dp))
+
+ OutlinedTextField(
+ value = eventValue,
+ onValueChange = {
+ eventValue = it
+ showError = false
+ },
+ label = { Text("Properties (JSON, optional)") },
+ placeholder = { Text("{\"key\": \"value\"}") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = false,
+ minLines = 2,
+ maxLines = 4,
+ isError = !isValueValid && eventValue.isNotBlank(),
+ shape = TextFieldShape,
+ colors = dialogTextFieldColors(),
+ supportingText = if (!isValueValid && eventValue.isNotBlank()) {
+ {
+ Text(
+ text = "Invalid JSON format",
+ color = MaterialTheme.colorScheme.error
+ )
+ }
+ } else null
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ showError = true
+ if (eventName.isNotBlank() && isValueValid) {
+ val properties = parseJsonToMap(eventValue)
+ onConfirm(eventName, properties)
+ }
+ },
+ enabled = eventName.isNotBlank() && isValueValid
+ ) {
+ Text("Track")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ },
+ shape = RoundedCornerShape(16.dp)
+ )
+}
+
+/**
+ * Dialog for custom notification.
+ */
+@Composable
+fun CustomNotificationDialog(
+ onDismiss: () -> Unit,
+ onConfirm: (String, String) -> Unit
+) {
+ var title by remember { mutableStateOf("") }
+ var body by remember { mutableStateOf("") }
+
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ title = {
+ Text("Custom Notification", style = MaterialTheme.typography.titleMedium)
+ },
+ text = {
+ Column {
+ OutlinedTextField(
+ value = title,
+ onValueChange = { title = it },
+ label = { Text("Title") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ shape = TextFieldShape,
+ colors = dialogTextFieldColors()
+ )
+ Spacer(modifier = Modifier.height(10.dp))
+ OutlinedTextField(
+ value = body,
+ onValueChange = { body = it },
+ label = { Text("Body") },
+ modifier = Modifier.fillMaxWidth(),
+ singleLine = true,
+ shape = TextFieldShape,
+ colors = dialogTextFieldColors()
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onConfirm(title, body) },
+ enabled = title.isNotBlank() && body.isNotBlank()
+ ) {
+ Text("Send")
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = onDismiss) {
+ Text("Cancel")
+ }
+ },
+ shape = RoundedCornerShape(16.dp)
+ )
+}
+
+/**
+ * Tooltip info dialog.
+ */
+@Composable
+fun TooltipDialog(
+ title: String,
+ description: String,
+ options: List>? = null,
+ onDismiss: () -> Unit
+) {
+ AlertDialog(
+ onDismissRequest = onDismiss,
+ modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
+ properties = DialogProperties(usePlatformDefaultWidth = false),
+ title = {
+ Text(title, style = MaterialTheme.typography.titleMedium)
+ },
+ text = {
+ Column {
+ Text(
+ description,
+ style = MaterialTheme.typography.bodyMedium
+ )
+ options?.let { opts ->
+ Spacer(modifier = Modifier.height(12.dp))
+ opts.forEach { (name, desc) ->
+ Text(
+ text = "• $name: $desc",
+ style = MaterialTheme.typography.bodySmall,
+ modifier = Modifier.padding(vertical = 3.dp)
+ )
+ }
+ }
+ }
+ },
+ confirmButton = {
+ TextButton(onClick = onDismiss) {
+ Text("OK")
+ }
+ },
+ shape = RoundedCornerShape(16.dp)
+ )
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/ListComponents.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/ListComponents.kt
new file mode 100644
index 0000000000..0a2d5ebccd
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/ListComponents.kt
@@ -0,0 +1,220 @@
+package com.onesignal.sdktest.ui.components
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ExpandLess
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material.icons.filled.Close
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+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.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import com.onesignal.sdktest.ui.theme.DividerColor
+
+/**
+ * A row displaying a key-value pair with delete button.
+ */
+@Composable
+fun PairItem(
+ key: String,
+ value: String,
+ onDelete: (() -> Unit)? = null,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 10.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = key,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ if (onDelete != null) {
+ IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Delete",
+ tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+ }
+}
+
+/**
+ * A row displaying a single value with delete button.
+ */
+@Composable
+fun SingleItem(
+ value: String,
+ onDelete: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 12.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = value,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(1f),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ IconButton(onClick = onDelete, modifier = Modifier.size(32.dp)) {
+ Icon(
+ imageVector = Icons.Default.Close,
+ contentDescription = "Delete",
+ tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+}
+
+/**
+ * Empty state text.
+ */
+@Composable
+fun EmptyState(
+ text: String,
+ modifier: Modifier = Modifier
+) {
+ Box(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(vertical = 20.dp),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = text,
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
+ )
+ }
+}
+
+/**
+ * Collapsible list section for emails/SMS (shows "X more available" when collapsed).
+ */
+@Composable
+fun CollapsibleSingleList(
+ items: List,
+ emptyText: String,
+ onDelete: (String) -> Unit,
+ maxCollapsedItems: Int = 5,
+ modifier: Modifier = Modifier
+) {
+ var expanded by remember { mutableStateOf(false) }
+ val shouldCollapse = items.size > maxCollapsedItems
+ val displayItems = if (shouldCollapse && !expanded) {
+ items.take(maxCollapsedItems)
+ } else {
+ items
+ }
+
+ Column(modifier = modifier.fillMaxWidth()) {
+ if (items.isEmpty()) {
+ EmptyState(text = emptyText)
+ } else {
+ displayItems.forEachIndexed { index, item ->
+ SingleItem(value = item, onDelete = { onDelete(item) })
+ if (index < displayItems.lastIndex) {
+ HorizontalDivider(
+ color = DividerColor,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
+ }
+
+ if (shouldCollapse) {
+ HorizontalDivider(
+ color = DividerColor,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { expanded = !expanded }
+ .padding(12.dp),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = if (expanded) "Show less" else "${items.size - maxCollapsedItems} more",
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Icon(
+ imageVector = if (expanded) Icons.Default.ExpandLess else Icons.Default.ExpandMore,
+ contentDescription = null,
+ tint = MaterialTheme.colorScheme.primary,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+ }
+ }
+}
+
+/**
+ * List of key-value pairs with optional action buttons.
+ */
+@Composable
+fun PairList(
+ items: List>,
+ emptyText: String,
+ onDelete: ((String) -> Unit)? = null,
+ modifier: Modifier = Modifier
+) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ if (items.isEmpty()) {
+ EmptyState(text = emptyText)
+ } else {
+ items.forEachIndexed { index, (key, value) ->
+ PairItem(key = key, value = value, onDelete = onDelete?.let { { it(key) } })
+ if (index < items.lastIndex) {
+ HorizontalDivider(
+ color = DividerColor,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/LoadingOverlay.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/LoadingOverlay.kt
new file mode 100644
index 0000000000..defe0ddbef
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/LoadingOverlay.kt
@@ -0,0 +1,56 @@
+package com.onesignal.sdktest.ui.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import com.onesignal.sdktest.ui.theme.OneSignalRed
+
+/**
+ * Full screen loading overlay with a centered spinner.
+ */
+@Composable
+fun LoadingOverlay(
+ isLoading: Boolean,
+ modifier: Modifier = Modifier
+) {
+ if (isLoading) {
+ Box(
+ modifier = modifier
+ .fillMaxSize()
+ .background(Color.Black.copy(alpha = 0.25f))
+ .clickable(
+ interactionSource = remember { MutableInteractionSource() },
+ indication = null,
+ onClick = { /* Consume clicks */ }
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Surface(
+ modifier = Modifier.size(56.dp),
+ shape = CircleShape,
+ color = Color.White,
+ shadowElevation = 4.dp
+ ) {
+ Box(contentAlignment = Alignment.Center) {
+ CircularProgressIndicator(
+ color = OneSignalRed,
+ strokeWidth = 3.dp,
+ modifier = Modifier.size(28.dp)
+ )
+ }
+ }
+ }
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/LogView.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/LogView.kt
new file mode 100644
index 0000000000..22b149b07f
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/LogView.kt
@@ -0,0 +1,232 @@
+package com.onesignal.sdktest.ui.components
+
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.expandVertically
+import androidx.compose.animation.shrinkVertically
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Column
+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.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Delete
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.KeyboardArrowUp
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.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.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.font.FontFamily
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.onesignal.sdktest.util.LogEntry
+import com.onesignal.sdktest.util.LogLevel
+import com.onesignal.sdktest.util.LogManager
+
+private val LogBackground = Color(0xFF1A1B1E)
+private val LogHeaderBackground = Color(0xFF1A1B1E)
+private val LogDebugColor = Color(0xFF82AAFF)
+private val LogInfoColor = Color(0xFFC3E88D)
+private val LogWarnColor = Color(0xFFFFCB6B)
+private val LogErrorColor = Color(0xFFFF5370)
+private val LogTimestampColor = Color(0xFF676E7B)
+
+/**
+ * Collapsible log view that displays app logs at the top of the screen.
+ */
+@Composable
+fun LogView(
+ modifier: Modifier = Modifier,
+ defaultExpanded: Boolean = true
+) {
+ var isExpanded by remember { mutableStateOf(defaultExpanded) }
+ val logs = LogManager.logs
+ val listState = rememberLazyListState()
+
+ // Auto-scroll to top when new logs arrive
+ LaunchedEffect(logs.size) {
+ if (logs.isNotEmpty()) {
+ listState.animateScrollToItem(0)
+ }
+ }
+
+ Column(
+ modifier = modifier
+ .fillMaxWidth()
+ .testTag("log_view_container")
+ ) {
+ // Header with toggle
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(LogHeaderBackground)
+ .clickable { isExpanded = !isExpanded }
+ .padding(horizontal = 14.dp, vertical = 10.dp)
+ .testTag("log_view_header"),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "LOGS",
+ color = Color.White.copy(alpha = 0.9f),
+ fontSize = 11.sp,
+ fontWeight = FontWeight.Bold,
+ fontFamily = FontFamily.Monospace,
+ letterSpacing = 1.sp,
+ modifier = Modifier.testTag("log_view_title")
+ )
+
+ Spacer(modifier = Modifier.width(6.dp))
+
+ Text(
+ text = "(${logs.size})",
+ color = LogTimestampColor,
+ fontSize = 11.sp,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier
+ .testTag("log_view_count")
+ .semantics { contentDescription = "Log count: ${logs.size}" }
+ )
+
+ Spacer(modifier = Modifier.weight(1f))
+
+ // Clear button
+ if (logs.isNotEmpty()) {
+ Icon(
+ imageVector = Icons.Default.Delete,
+ contentDescription = "Clear logs",
+ tint = LogTimestampColor,
+ modifier = Modifier
+ .clickable { LogManager.clear() }
+ .padding(4.dp)
+ .size(14.dp)
+ .testTag("log_view_clear_button")
+ )
+
+ Spacer(modifier = Modifier.width(10.dp))
+ }
+
+ Icon(
+ imageVector = if (isExpanded) Icons.Default.KeyboardArrowUp else Icons.Default.KeyboardArrowDown,
+ contentDescription = if (isExpanded) "Collapse" else "Expand",
+ tint = Color.White.copy(alpha = 0.6f),
+ modifier = Modifier.size(18.dp)
+ )
+ }
+
+ // Log content
+ AnimatedVisibility(
+ visible = isExpanded,
+ enter = expandVertically(),
+ exit = shrinkVertically()
+ ) {
+ if (logs.isEmpty()) {
+ Text(
+ text = "No logs yet",
+ color = LogTimestampColor,
+ fontSize = 11.sp,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(LogBackground)
+ .padding(14.dp)
+ .testTag("log_view_empty")
+ )
+ } else {
+ LazyColumn(
+ state = listState,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(100.dp)
+ .background(LogBackground)
+ .testTag("log_view_list")
+ ) {
+ items(logs.size) { index ->
+ LogEntryRow(entry = logs[index], index = index)
+ }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun LogEntryRow(entry: LogEntry, index: Int) {
+ val levelText = when (entry.level) {
+ LogLevel.DEBUG -> "D"
+ LogLevel.INFO -> "I"
+ LogLevel.WARN -> "W"
+ LogLevel.ERROR -> "E"
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 14.dp, vertical = 4.dp)
+ .testTag("log_entry_$index")
+ .semantics {
+ contentDescription = "Log $index: $levelText ${entry.message}"
+ },
+ verticalAlignment = Alignment.Top
+ ) {
+ // Timestamp
+ Text(
+ text = entry.timestamp,
+ color = LogTimestampColor,
+ fontSize = 10.sp,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.testTag("log_entry_${index}_timestamp")
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ // Level indicator
+ Text(
+ text = levelText,
+ color = when (entry.level) {
+ LogLevel.DEBUG -> LogDebugColor
+ LogLevel.INFO -> LogInfoColor
+ LogLevel.WARN -> LogWarnColor
+ LogLevel.ERROR -> LogErrorColor
+ },
+ fontSize = 10.sp,
+ fontWeight = FontWeight.Bold,
+ fontFamily = FontFamily.Monospace,
+ modifier = Modifier.testTag("log_entry_${index}_level")
+ )
+
+ Spacer(modifier = Modifier.width(8.dp))
+
+ // Message
+ Text(
+ text = entry.message,
+ color = Color.White.copy(alpha = 0.85f),
+ fontSize = 11.sp,
+ fontFamily = FontFamily.Monospace,
+ maxLines = 2,
+ overflow = TextOverflow.Ellipsis,
+ modifier = Modifier
+ .weight(1f)
+ .testTag("log_entry_${index}_message")
+ )
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/SectionCard.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/SectionCard.kt
new file mode 100644
index 0000000000..ab0a76f4b9
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/SectionCard.kt
@@ -0,0 +1,88 @@
+package com.onesignal.sdktest.ui.components
+
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+
+/**
+ * Reusable section card with title and optional info tooltip.
+ */
+@Composable
+fun SectionCard(
+ title: String,
+ modifier: Modifier = Modifier,
+ showCard: Boolean = true,
+ onInfoClick: (() -> Unit)? = null,
+ content: @Composable ColumnScope.() -> Unit
+) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ // Section header
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(top = 16.dp, bottom = 6.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = title.uppercase(),
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(1f)
+ )
+ if (onInfoClick != null) {
+ IconButton(
+ onClick = onInfoClick,
+ modifier = Modifier.size(28.dp)
+ ) {
+ Icon(
+ imageVector = Icons.Outlined.Info,
+ contentDescription = "Info",
+ tint = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.size(18.dp)
+ )
+ }
+ }
+ }
+
+ if (showCard) {
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ ),
+ shape = MaterialTheme.shapes.medium,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
+ ) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ content = content
+ )
+ }
+ } else {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ content = content
+ )
+ }
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/ToggleRow.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/ToggleRow.kt
new file mode 100644
index 0000000000..d0ba154cdf
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/components/ToggleRow.kt
@@ -0,0 +1,62 @@
+package com.onesignal.sdktest.ui.components
+
+import androidx.compose.foundation.layout.*
+import androidx.compose.material3.*
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.unit.dp
+import com.onesignal.sdktest.ui.theme.OneSignalRed
+
+/**
+ * Reusable toggle row with label and optional description.
+ */
+@Composable
+fun ToggleRow(
+ label: String,
+ checked: Boolean,
+ onCheckedChange: (Boolean) -> Unit,
+ modifier: Modifier = Modifier,
+ description: String? = null,
+ enabled: Boolean = true
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = label,
+ style = MaterialTheme.typography.bodyLarge,
+ color = if (enabled) {
+ MaterialTheme.colorScheme.onSurface
+ } else {
+ MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f)
+ }
+ )
+ if (description != null) {
+ Spacer(modifier = Modifier.height(2.dp))
+ Text(
+ text = description,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(
+ alpha = if (enabled) 1f else 0.4f
+ )
+ )
+ }
+ }
+ Spacer(modifier = Modifier.width(12.dp))
+ Switch(
+ checked = checked,
+ onCheckedChange = onCheckedChange,
+ enabled = enabled,
+ colors = SwitchDefaults.colors(
+ checkedThumbColor = MaterialTheme.colorScheme.surface,
+ checkedTrackColor = OneSignalRed,
+ checkedBorderColor = OneSignalRed
+ )
+ )
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/main/MainActivity.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/main/MainActivity.kt
new file mode 100644
index 0000000000..46aab10a58
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/main/MainActivity.kt
@@ -0,0 +1,44 @@
+package com.onesignal.sdktest.ui.main
+
+import android.os.Bundle
+import android.widget.Toast
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.activity.viewModels
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.material3.Surface
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.ui.Modifier
+import com.onesignal.sdktest.ui.theme.LightBackground
+import com.onesignal.sdktest.ui.theme.OneSignalTheme
+
+class MainActivity : ComponentActivity() {
+
+ private val viewModel: MainViewModel by viewModels()
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ OneSignalTheme {
+ Surface(
+ modifier = Modifier.fillMaxSize(),
+ color = LightBackground
+ ) {
+ MainScreen(viewModel = viewModel)
+ }
+
+ // Observe toast messages
+ val toastMessage by viewModel.toastMessage.observeAsState()
+ LaunchedEffect(toastMessage) {
+ toastMessage?.let {
+ Toast.makeText(this@MainActivity, it, Toast.LENGTH_SHORT).show()
+ viewModel.clearToast()
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt
new file mode 100644
index 0000000000..05c2c04bf5
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/main/MainScreen.kt
@@ -0,0 +1,461 @@
+package com.onesignal.sdktest.ui.main
+
+import android.content.Intent
+import android.net.Uri
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.livedata.observeAsState
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.onesignal.sdktest.R
+import com.onesignal.sdktest.data.model.NotificationType
+import com.onesignal.sdktest.ui.components.CustomNotificationDialog
+import com.onesignal.sdktest.ui.components.LoadingOverlay
+import com.onesignal.sdktest.ui.components.LoginDialog
+import com.onesignal.sdktest.ui.components.LogView
+import com.onesignal.sdktest.ui.components.MultiPairInputDialog
+import com.onesignal.sdktest.ui.components.MultiSelectRemoveDialog
+import com.onesignal.sdktest.ui.components.OutcomeDialog
+import com.onesignal.sdktest.ui.components.PairInputDialog
+import com.onesignal.sdktest.ui.components.PrimaryButton
+import com.onesignal.sdktest.ui.components.SingleInputDialog
+import com.onesignal.sdktest.ui.components.TooltipDialog
+import com.onesignal.sdktest.ui.components.TrackEventDialog
+import com.onesignal.sdktest.ui.secondary.SecondaryActivity
+import com.onesignal.sdktest.ui.theme.OneSignalRed
+
+import com.onesignal.sdktest.util.TooltipHelper
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun MainScreen(viewModel: MainViewModel) {
+ val context = LocalContext.current
+
+ // Observe all LiveData as State
+ val appId by viewModel.appId.observeAsState("")
+ val pushSubscriptionId by viewModel.pushSubscriptionId.observeAsState()
+ val pushEnabled by viewModel.pushEnabled.observeAsState(false)
+ val hasNotificationPermission by viewModel.hasNotificationPermission.observeAsState(false)
+ val consentRequired by viewModel.consentRequired.observeAsState(false)
+ val privacyConsentGiven by viewModel.privacyConsentGiven.observeAsState(false)
+ val externalUserId by viewModel.externalUserId.observeAsState()
+ val aliases by viewModel.aliases.observeAsState(emptyList())
+ val emails by viewModel.emails.observeAsState(emptyList())
+ val smsNumbers by viewModel.smsNumbers.observeAsState(emptyList())
+ val tags by viewModel.tags.observeAsState(emptyList())
+ val triggers by viewModel.triggers.observeAsState(emptyList())
+ val inAppMessagesPaused by viewModel.inAppMessagesPaused.observeAsState(true)
+ val locationShared by viewModel.locationShared.observeAsState(false)
+ val isLoading by viewModel.isLoading.observeAsState(false)
+
+ // Dialog states
+ var showLoginDialog 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) }
+
+ // Auto prompt for notification permission
+ LaunchedEffect(Unit) {
+ viewModel.promptPush()
+ }
+
+ Box(modifier = Modifier.fillMaxSize()) {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Image(
+ painter = painterResource(id = R.drawable.onesignal_rectangle),
+ contentDescription = "OneSignal Logo",
+ modifier = Modifier.height(24.dp),
+ colorFilter = ColorFilter.tint(Color.White)
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ Text(
+ "Sample App",
+ color = Color.White.copy(alpha = 0.7f),
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Normal
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = OneSignalRed
+ )
+ )
+ }
+ ) { paddingValues ->
+ Column(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues)
+ .background(MaterialTheme.colorScheme.background)
+ ) {
+ // Log view at top (fixed, not scrolling)
+ LogView(defaultExpanded = true)
+
+ // Scrollable content
+ Column(
+ modifier = Modifier
+ .weight(1f)
+ .verticalScroll(rememberScrollState())
+ ) {
+ Spacer(modifier = Modifier.height(4.dp))
+
+ // === APP SECTION ===
+ 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")))
+ }
+ )
+
+ // === USER SECTION ===
+ UserSection(
+ externalUserId = externalUserId,
+ onLoginClick = { showLoginDialog = true },
+ onLogoutClick = { viewModel.logoutUser() }
+ )
+
+ // === PUSH SECTION ===
+ PushSection(
+ pushSubscriptionId = pushSubscriptionId,
+ pushEnabled = pushEnabled,
+ hasPermission = hasNotificationPermission,
+ onEnabledChange = { viewModel.setPushEnabled(it) },
+ onPromptPush = { viewModel.promptPush() },
+ onInfoClick = { showTooltipDialog = "push" }
+ )
+
+ // === SEND PUSH NOTIFICATION SECTION ===
+ SendPushSection(
+ onSimpleClick = { viewModel.sendNotification(NotificationType.SIMPLE) },
+ onImageClick = { viewModel.sendNotification(NotificationType.WITH_IMAGE) },
+ onCustomClick = { showCustomNotificationDialog = true },
+ onInfoClick = { showTooltipDialog = "sendPushNotification" }
+ )
+
+ // === IN-APP MESSAGING SECTION ===
+ InAppMessagingSection(
+ isPaused = inAppMessagesPaused,
+ onPausedChange = { viewModel.setInAppMessagesPaused(it) },
+ onInfoClick = { showTooltipDialog = "inAppMessaging" }
+ )
+
+ // === SEND IN-APP MESSAGE SECTION ===
+ SendInAppMessageSection(
+ onSendMessage = { type ->
+ viewModel.sendInAppMessage(type.title, type.triggerKey, type.triggerValue)
+ },
+ onInfoClick = { showTooltipDialog = "sendInAppMessage" }
+ )
+
+ // === ALIASES SECTION ===
+ AliasesSection(
+ aliases = aliases,
+ onAddClick = { showAddAliasDialog = true },
+ onAddMultipleClick = { showAddMultipleAliasDialog = true },
+ onInfoClick = { showTooltipDialog = "aliases" }
+ )
+
+ // === EMAILS SECTION ===
+ EmailsSection(
+ emails = emails,
+ onAddClick = { showAddEmailDialog = true },
+ onRemove = { viewModel.removeEmail(it) },
+ onInfoClick = { showTooltipDialog = "emails" }
+ )
+
+ // === SMS SECTION ===
+ SmsSection(
+ smsNumbers = smsNumbers,
+ onAddClick = { showAddSmsDialog = true },
+ onRemove = { viewModel.removeSms(it) },
+ onInfoClick = { showTooltipDialog = "sms" }
+ )
+
+ // === TAGS SECTION ===
+ TagsSection(
+ tags = tags,
+ onAddClick = { showAddTagDialog = true },
+ onAddMultipleClick = { showAddMultipleTagDialog = true },
+ onRemove = { viewModel.removeTag(it) },
+ onRemoveSelected = { showRemoveTagsDialog = true },
+ onInfoClick = { showTooltipDialog = "tags" }
+ )
+
+ // === OUTCOME EVENTS SECTION ===
+ OutcomeSection(
+ onSendOutcome = { showOutcomeDialog = true },
+ onInfoClick = { showTooltipDialog = "outcomes" }
+ )
+
+ // === TRIGGERS SECTION ===
+ TriggersSection(
+ triggers = triggers,
+ onAddClick = { showAddTriggerDialog = true },
+ onAddMultipleClick = { showAddMultipleTriggerDialog = true },
+ onRemove = { viewModel.removeTrigger(it) },
+ onRemoveSelected = { showRemoveTriggersDialog = true },
+ onClearAll = { viewModel.clearTriggers() },
+ onInfoClick = { showTooltipDialog = "triggers" }
+ )
+
+ // === TRACK EVENT SECTION ===
+ TrackEventSection(
+ onTrackClick = { showTrackEventDialog = true },
+ onInfoClick = { showTooltipDialog = "trackEvent" }
+ )
+
+ // === LOCATION SECTION ===
+ LocationSection(
+ locationShared = locationShared,
+ onLocationSharedChange = { viewModel.setLocationShared(it) },
+ onPromptLocation = { viewModel.promptLocation() },
+ onInfoClick = { showTooltipDialog = "location" }
+ )
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // === NEXT ACTIVITY BUTTON ===
+ PrimaryButton(
+ text = "NEXT ACTIVITY",
+ onClick = {
+ context.startActivity(Intent(context, SecondaryActivity::class.java))
+ }
+ )
+
+ Spacer(modifier = Modifier.height(24.dp))
+ }
+ }
+ }
+
+ // Loading overlay
+ LoadingOverlay(isLoading = isLoading)
+ }
+
+ // === DIALOGS ===
+ if (showLoginDialog) {
+ LoginDialog(
+ onDismiss = { showLoginDialog = false },
+ onConfirm = { userId ->
+ viewModel.loginUser(userId)
+ showLoginDialog = false
+ }
+ )
+ }
+
+ if (showAddAliasDialog) {
+ PairInputDialog(
+ title = "Add Alias",
+ keyLabel = "Label",
+ valueLabel = "ID",
+ onDismiss = { showAddAliasDialog = false },
+ onConfirm = { key, value ->
+ viewModel.addAlias(key, value)
+ showAddAliasDialog = false
+ }
+ )
+ }
+
+ 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
+ }
+ )
+ }
+
+ if (showAddSmsDialog) {
+ SingleInputDialog(
+ title = "Add SMS",
+ label = "Phone Number",
+ onDismiss = { showAddSmsDialog = false },
+ onConfirm = { sms ->
+ viewModel.addSms(sms)
+ showAddSmsDialog = false
+ }
+ )
+ }
+
+ if (showAddTagDialog) {
+ PairInputDialog(
+ title = "Add Tag",
+ onDismiss = { showAddTagDialog = false },
+ onConfirm = { key, value ->
+ viewModel.addTag(key, value)
+ showAddTagDialog = false
+ }
+ )
+ }
+
+ 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
+ }
+ )
+ }
+
+ 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) {
+ TooltipDialog(
+ title = tooltip.title,
+ description = tooltip.description,
+ options = tooltip.options?.map { it.name to it.description },
+ onDismiss = { showTooltipDialog = null }
+ )
+ }
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt
new file mode 100644
index 0000000000..e65af736d2
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/main/MainViewModel.kt
@@ -0,0 +1,626 @@
+package com.onesignal.sdktest.ui.main
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import androidx.lifecycle.viewModelScope
+import com.onesignal.OneSignal
+import com.onesignal.notifications.IPermissionObserver
+import com.onesignal.sdktest.data.model.NotificationType
+import com.onesignal.sdktest.data.repository.OneSignalRepository
+import com.onesignal.sdktest.util.LogManager
+import com.onesignal.sdktest.util.SharedPreferenceUtil
+import com.onesignal.user.state.IUserStateObserver
+import com.onesignal.user.state.UserChangedState
+import com.onesignal.user.subscriptions.IPushSubscriptionObserver
+import com.onesignal.user.subscriptions.PushSubscriptionChangedState
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+
+class MainViewModel(application: Application) : AndroidViewModel(application), IPushSubscriptionObserver, IPermissionObserver, IUserStateObserver {
+
+ private val repository = OneSignalRepository()
+
+ // App ID
+ private val _appId = MutableLiveData()
+ val appId: LiveData = _appId
+
+ // Push Subscription
+ private val _pushSubscriptionId = MutableLiveData()
+ val pushSubscriptionId: LiveData = _pushSubscriptionId
+
+ private val _pushEnabled = MutableLiveData()
+ val pushEnabled: LiveData = _pushEnabled
+
+ // Notification Permission
+ private val _hasNotificationPermission = MutableLiveData()
+ val hasNotificationPermission: LiveData = _hasNotificationPermission
+
+ // Consent Required
+ private val _consentRequired = MutableLiveData()
+ val consentRequired: LiveData = _consentRequired
+
+ // Privacy Consent
+ private val _privacyConsentGiven = MutableLiveData()
+ val privacyConsentGiven: LiveData = _privacyConsentGiven
+
+ // Aliases
+ private val _aliases = MutableLiveData>>()
+ val aliases: LiveData>> = _aliases
+
+ // Emails
+ private val _emails = MutableLiveData>()
+ val emails: LiveData> = _emails
+
+ // SMS numbers
+ private val _smsNumbers = MutableLiveData>()
+ val smsNumbers: LiveData> = _smsNumbers
+
+ // Tags
+ private val _tags = MutableLiveData>>()
+ val tags: LiveData>> = _tags
+
+ // Triggers
+ private val _triggers = MutableLiveData>>()
+ val triggers: LiveData>> = _triggers
+
+ // In-App Messages Paused
+ private val _inAppMessagesPaused = MutableLiveData()
+ val inAppMessagesPaused: LiveData = _inAppMessagesPaused
+
+ // Location Shared
+ private val _locationShared = MutableLiveData()
+ val locationShared: LiveData = _locationShared
+
+ // Toast messages
+ private val _toastMessage = MutableLiveData()
+ val toastMessage: LiveData = _toastMessage
+
+ // Loading state
+ private val _isLoading = MutableLiveData()
+ val isLoading: LiveData = _isLoading
+
+ // External User ID (for login state display)
+ private val _externalUserId = MutableLiveData()
+ val externalUserId: LiveData = _externalUserId
+
+ // Local lists to track added items
+ private val aliasesList = mutableListOf>()
+ private val emailsList = mutableListOf()
+ private val smsNumbersList = mutableListOf()
+ private val tagsList = mutableListOf>()
+ private val triggersList = mutableListOf>()
+
+ init {
+ LogManager.info("App initialized")
+ loadInitialState()
+ OneSignal.User.pushSubscription.addObserver(this)
+ OneSignal.Notifications.addPermissionObserver(this)
+ OneSignal.User.addObserver(this)
+ android.util.Log.d("MainViewModel", "init: observers registered, current onesignalId=${OneSignal.User.onesignalId}")
+ LogManager.debug("OneSignal ID: ${OneSignal.User.onesignalId ?: "not set"}")
+ }
+
+ // IPermissionObserver
+ override fun onNotificationPermissionChange(permission: Boolean) {
+ _hasNotificationPermission.postValue(permission)
+ }
+
+ // IUserStateObserver - called when user changes (login/logout)
+ override fun onUserStateChange(state: UserChangedState) {
+ android.util.Log.d("MainViewModel", "onUserStateChange fired: ${state.current.onesignalId}")
+ viewModelScope.launch(Dispatchers.Main) {
+ loadExistingAliases()
+ loadExistingTags()
+ refreshPushSubscription()
+ fetchUserDataFromApi()
+ }
+ }
+
+ private fun loadInitialState() {
+ val context = getApplication()
+
+ _appId.value = SharedPreferenceUtil.getOneSignalAppId(context) ?: ""
+ _consentRequired.value = repository.getConsentRequired()
+ _privacyConsentGiven.value = repository.getPrivacyConsent()
+ _inAppMessagesPaused.value = repository.isInAppMessagesPaused()
+ _locationShared.value = repository.isLocationShared()
+
+ val externalId = OneSignal.User.externalId
+ _externalUserId.value = if (externalId.isEmpty()) null else externalId
+
+ refreshPushSubscription()
+ loadExistingAliases()
+ loadExistingTags()
+ refreshEmails()
+ refreshSmsNumbers()
+ refreshTriggers()
+
+ val onesignalId = OneSignal.User.onesignalId
+ if (!onesignalId.isNullOrEmpty()) {
+ fetchUserDataFromApi()
+ }
+ }
+
+ fun fetchUserDataFromApi() {
+ val onesignalId = OneSignal.User.onesignalId
+ if (onesignalId.isNullOrEmpty()) {
+ _isLoading.value = false
+ return
+ }
+
+ _isLoading.value = true
+ viewModelScope.launch(Dispatchers.IO) {
+ try {
+ val userData = repository.fetchUser(onesignalId)
+ withContext(Dispatchers.Main) {
+ if (userData != null) {
+ aliasesList.clear()
+ aliasesList.addAll(userData.aliases.map { Pair(it.key, it.value) })
+ refreshAliases()
+
+ tagsList.clear()
+ tagsList.addAll(userData.tags.map { Pair(it.key, it.value) })
+ refreshTags()
+
+ emailsList.clear()
+ emailsList.addAll(userData.emails)
+ refreshEmails()
+
+ smsNumbersList.clear()
+ smsNumbersList.addAll(userData.smsNumbers)
+ refreshSmsNumbers()
+
+ if (!userData.externalId.isNullOrEmpty()) {
+ _externalUserId.value = userData.externalId
+ SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), userData.externalId)
+ }
+
+ kotlinx.coroutines.delay(100)
+ }
+ _isLoading.value = false
+ }
+ } catch (e: Exception) {
+ android.util.Log.e("MainViewModel", "Error fetching user data", e)
+ withContext(Dispatchers.Main) {
+ logError("Failed to fetch user data: ${e.message}")
+ _isLoading.value = false
+ }
+ }
+ }
+ }
+
+ private fun loadExistingAliases() {
+ aliasesList.clear()
+ refreshAliases()
+ }
+
+ private fun loadExistingTags() {
+ val existingTags = repository.getTags()
+ tagsList.clear()
+ tagsList.addAll(existingTags.map { Pair(it.key, it.value) })
+ refreshTags()
+ }
+
+ fun refreshPushSubscription() {
+ _pushSubscriptionId.value = repository.getPushSubscriptionId()
+ _pushEnabled.value = repository.isPushEnabled()
+ _hasNotificationPermission.value = repository.hasNotificationPermission()
+ }
+
+ private fun refreshAliases() { _aliases.value = aliasesList.toList() }
+ private fun refreshEmails() { _emails.value = emailsList.toList() }
+ private fun refreshSmsNumbers() { _smsNumbers.value = smsNumbersList.toList() }
+ private fun refreshTags() { _tags.value = tagsList.toList() }
+ private fun refreshTriggers() { _triggers.value = triggersList.toList() }
+
+ // User operations
+ fun loginUser(externalUserId: String) {
+ _isLoading.value = true
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.loginUser(externalUserId)
+ withContext(Dispatchers.Main) {
+ SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), externalUserId)
+ _externalUserId.value = externalUserId
+ showToast("Logged in as: $externalUserId")
+ aliasesList.clear()
+ emailsList.clear()
+ smsNumbersList.clear()
+ triggersList.clear()
+ refreshAliases()
+ refreshEmails()
+ refreshSmsNumbers()
+ refreshTriggers()
+ loadExistingTags()
+ refreshPushSubscription()
+ // Loading stays on; onUserStateChange will call fetchUserDataFromApi() to dismiss it
+ }
+ }
+ }
+
+ fun logoutUser() {
+ _isLoading.value = true
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.logoutUser()
+ withContext(Dispatchers.Main) {
+ SharedPreferenceUtil.cacheUserExternalUserId(getApplication(), "")
+ _externalUserId.value = null
+ showToast("Logged out")
+ loadExistingAliases()
+ loadExistingTags()
+ refreshPushSubscription()
+ _isLoading.value = false
+ emailsList.clear()
+ smsNumbersList.clear()
+ triggersList.clear()
+ refreshEmails()
+ refreshSmsNumbers()
+ refreshTriggers()
+ }
+ }
+ }
+
+ // Consent required
+ fun setConsentRequired(required: Boolean) {
+ repository.setConsentRequired(required)
+ SharedPreferenceUtil.cacheConsentRequired(getApplication(), required)
+ _consentRequired.value = required
+ showToast(if (required) "Consent required enabled" else "Consent required disabled")
+ }
+
+ // Privacy consent
+ fun setPrivacyConsent(granted: Boolean) {
+ repository.setPrivacyConsent(granted)
+ SharedPreferenceUtil.cacheUserPrivacyConsent(getApplication(), granted)
+ _privacyConsentGiven.value = granted
+ showToast(if (granted) "Consent granted" else "Consent revoked")
+ }
+
+ // Alias operations (single and batch)
+ fun addAlias(label: String, id: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.addAlias(label, id)
+ withContext(Dispatchers.Main) {
+ aliasesList.removeAll { it.first == label }
+ aliasesList.add(Pair(label, id))
+ refreshAliases()
+ showToast("Alias added: $label")
+ }
+ }
+ }
+
+ fun addAliases(pairs: List>) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val map = pairs.associate { it.first to it.second }
+ repository.addAliases(map)
+ withContext(Dispatchers.Main) {
+ for ((label, id) in pairs) {
+ aliasesList.removeAll { it.first == label }
+ aliasesList.add(Pair(label, id))
+ }
+ refreshAliases()
+ showToast("${pairs.size} alias(es) added")
+ }
+ }
+ }
+
+ fun removeAlias(label: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.removeAlias(label)
+ withContext(Dispatchers.Main) {
+ aliasesList.removeAll { it.first == label }
+ refreshAliases()
+ showToast("Alias removed: $label")
+ }
+ }
+ }
+
+ fun removeSelectedAliases(labels: Collection) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.removeAliases(labels)
+ withContext(Dispatchers.Main) {
+ aliasesList.removeAll { it.first in labels }
+ refreshAliases()
+ showToast("${labels.size} alias(es) removed")
+ }
+ }
+ }
+
+ // Email operations
+ fun addEmail(email: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.addEmail(email)
+ withContext(Dispatchers.Main) {
+ if (!emailsList.contains(email)) {
+ emailsList.add(email)
+ refreshEmails()
+ }
+ showToast("Email added: $email")
+ }
+ }
+ }
+
+ fun removeEmail(email: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.removeEmail(email)
+ withContext(Dispatchers.Main) {
+ emailsList.remove(email)
+ refreshEmails()
+ showToast("Email removed: $email")
+ }
+ }
+ }
+
+ // SMS operations
+ fun addSms(smsNumber: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.addSms(smsNumber)
+ withContext(Dispatchers.Main) {
+ if (!smsNumbersList.contains(smsNumber)) {
+ smsNumbersList.add(smsNumber)
+ refreshSmsNumbers()
+ }
+ showToast("SMS added: $smsNumber")
+ }
+ }
+ }
+
+ fun removeSms(smsNumber: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.removeSms(smsNumber)
+ withContext(Dispatchers.Main) {
+ smsNumbersList.remove(smsNumber)
+ refreshSmsNumbers()
+ showToast("SMS removed: $smsNumber")
+ }
+ }
+ }
+
+ // Tag operations (single and batch)
+ fun addTag(key: String, value: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.addTag(key, value)
+ withContext(Dispatchers.Main) {
+ loadExistingTags()
+ showToast("Tag added: $key")
+ }
+ }
+ }
+
+ fun addTags(pairs: List>) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val map = pairs.associate { it.first to it.second }
+ repository.addTags(map)
+ withContext(Dispatchers.Main) {
+ loadExistingTags()
+ showToast("${pairs.size} tag(s) added")
+ }
+ }
+ }
+
+ fun removeTag(key: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.removeTag(key)
+ withContext(Dispatchers.Main) {
+ loadExistingTags()
+ showToast("Tag removed: $key")
+ }
+ }
+ }
+
+ fun removeSelectedTags(keys: Collection) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.removeTags(keys)
+ withContext(Dispatchers.Main) {
+ loadExistingTags()
+ showToast("${keys.size} tag(s) removed")
+ }
+ }
+ }
+
+ // Trigger operations (single and batch)
+ fun addTrigger(key: String, value: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.addTrigger(key, value)
+ withContext(Dispatchers.Main) {
+ triggersList.removeAll { it.first == key }
+ triggersList.add(Pair(key, value))
+ refreshTriggers()
+ showToast("Trigger added: $key")
+ }
+ }
+ }
+
+ fun addTriggers(pairs: List>) {
+ viewModelScope.launch(Dispatchers.IO) {
+ val map = pairs.associate { it.first to it.second }
+ repository.addTriggers(map)
+ withContext(Dispatchers.Main) {
+ for ((key, value) in pairs) {
+ triggersList.removeAll { it.first == key }
+ triggersList.add(Pair(key, value))
+ }
+ refreshTriggers()
+ showToast("${pairs.size} trigger(s) added")
+ }
+ }
+ }
+
+ fun removeTrigger(key: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.removeTrigger(key)
+ withContext(Dispatchers.Main) {
+ triggersList.removeAll { it.first == key }
+ refreshTriggers()
+ showToast("Trigger removed: $key")
+ }
+ }
+ }
+
+ fun removeSelectedTriggers(keys: Collection) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.clearTriggers(keys)
+ withContext(Dispatchers.Main) {
+ triggersList.removeAll { it.first in keys }
+ refreshTriggers()
+ showToast("${keys.size} trigger(s) removed")
+ }
+ }
+ }
+
+ fun clearTriggers() {
+ viewModelScope.launch(Dispatchers.IO) {
+ val keys = triggersList.map { it.first }
+ repository.clearTriggers(keys)
+ withContext(Dispatchers.Main) {
+ triggersList.clear()
+ refreshTriggers()
+ showToast("All triggers cleared")
+ }
+ }
+ }
+
+ // Outcome operations
+ fun sendOutcome(name: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.sendOutcome(name)
+ withContext(Dispatchers.Main) { showToast("Outcome sent: $name") }
+ }
+ }
+
+ fun sendUniqueOutcome(name: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.sendUniqueOutcome(name)
+ withContext(Dispatchers.Main) { showToast("Unique outcome sent: $name") }
+ }
+ }
+
+ fun sendOutcomeWithValue(name: String, value: Float) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.sendOutcomeWithValue(name, value)
+ withContext(Dispatchers.Main) { showToast("Outcome with value sent: $name = $value") }
+ }
+ }
+
+ // Track Event
+ fun trackEvent(name: String, properties: Map?) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.trackEvent(name, properties)
+ withContext(Dispatchers.Main) {
+ val message = if (!properties.isNullOrEmpty()) {
+ "Event tracked: $name with properties"
+ } else {
+ "Event tracked: $name"
+ }
+ showToast(message)
+ }
+ }
+ }
+
+ // Push subscription
+ fun setPushEnabled(enabled: Boolean) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.setPushEnabled(enabled)
+ withContext(Dispatchers.Main) {
+ _pushEnabled.value = enabled
+ showToast(if (enabled) "Push enabled" else "Push disabled")
+ }
+ }
+ }
+
+ fun promptPush() {
+ viewModelScope.launch(Dispatchers.Main) {
+ OneSignal.Notifications.requestPermission(true)
+ refreshPushSubscription()
+ }
+ }
+
+ // In-App Messages
+ fun setInAppMessagesPaused(paused: Boolean) {
+ repository.setInAppMessagesPaused(paused)
+ SharedPreferenceUtil.cacheInAppMessagingPausedStatus(getApplication(), paused)
+ _inAppMessagesPaused.value = paused
+ showToast(if (paused) "In-app messages paused" else "In-app messages resumed")
+ }
+
+ // Location
+ fun setLocationShared(shared: Boolean) {
+ repository.setLocationShared(shared)
+ SharedPreferenceUtil.cacheLocationSharedStatus(getApplication(), shared)
+ _locationShared.value = shared
+ showToast(if (shared) "Location sharing enabled" else "Location sharing disabled")
+ }
+
+ fun promptLocation() {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.promptLocation()
+ withContext(Dispatchers.Main) { showToast("Location permission requested") }
+ }
+ }
+
+ // Send notification
+ fun sendNotification(type: NotificationType) {
+ logDebug("Sending notification: ${type.title}")
+ viewModelScope.launch(Dispatchers.IO) {
+ val success = repository.sendNotification(type)
+ withContext(Dispatchers.Main) {
+ if (success) {
+ showToast("Notification sent: ${type.title}")
+ } else {
+ logError("Failed to send notification: ${type.title}")
+ showToast("Failed to send notification")
+ }
+ }
+ }
+ }
+
+ fun sendCustomNotification(title: String, body: String) {
+ logDebug("Sending custom notification: $title")
+ viewModelScope.launch(Dispatchers.IO) {
+ val success = repository.sendCustomNotification(title, body)
+ withContext(Dispatchers.Main) {
+ if (success) {
+ showToast("Notification sent: $title")
+ } else {
+ logError("Failed to send notification: $title")
+ showToast("Failed to send notification")
+ }
+ }
+ }
+ }
+
+ fun sendInAppMessage(title: String, triggerKey: String, triggerValue: String) {
+ viewModelScope.launch(Dispatchers.IO) {
+ repository.addTrigger(triggerKey, triggerValue)
+ withContext(Dispatchers.Main) {
+ triggersList.removeAll { it.first == triggerKey }
+ triggersList.add(Pair(triggerKey, triggerValue))
+ refreshTriggers()
+ showToast("Sent In-App Message: $title")
+ }
+ }
+ }
+
+ private fun showToast(message: String) {
+ _toastMessage.value = message
+ LogManager.info(message)
+ }
+
+ fun clearToast() { _toastMessage.value = null }
+
+ // Logging utilities
+ private fun logError(message: String) = LogManager.error(message)
+ private fun logDebug(message: String) = LogManager.debug(message)
+
+ override fun onPushSubscriptionChange(state: PushSubscriptionChangedState) {
+ _pushSubscriptionId.postValue(state.current.id)
+ _pushEnabled.postValue(state.current.optedIn)
+ }
+
+ override fun onCleared() {
+ super.onCleared()
+ OneSignal.User.pushSubscription.removeObserver(this)
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt
new file mode 100644
index 0000000000..f672d322c1
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/main/Sections.kt
@@ -0,0 +1,495 @@
+package com.onesignal.sdktest.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.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.layout.widthIn
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.onesignal.sdktest.data.model.InAppMessageType
+import com.onesignal.sdktest.ui.components.CollapsibleSingleList
+import com.onesignal.sdktest.ui.components.DestructiveButton
+import com.onesignal.sdktest.ui.components.OutlineButton
+import com.onesignal.sdktest.ui.components.PairList
+import com.onesignal.sdktest.ui.components.PrimaryButton
+import com.onesignal.sdktest.ui.components.SectionCard
+import com.onesignal.sdktest.ui.components.ToggleRow
+import com.onesignal.sdktest.ui.theme.DividerColor
+import com.onesignal.sdktest.ui.theme.OneSignalGreen
+import com.onesignal.sdktest.ui.theme.OneSignalGreenLight
+import com.onesignal.sdktest.ui.theme.OneSignalRed
+import com.onesignal.sdktest.ui.theme.WarningBackground
+
+// === APP SECTION ===
+@Composable
+fun AppSection(
+ appId: String,
+ consentRequired: Boolean,
+ onConsentRequiredChange: (Boolean) -> Unit,
+ privacyConsentGiven: Boolean,
+ onConsentChange: (Boolean) -> Unit,
+ onGetKeysClick: () -> Unit
+) {
+ SectionCard(title = "App") {
+ // App ID
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "App ID",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = appId,
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium)
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Guidance Banner
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors = CardDefaults.cardColors(containerColor = WarningBackground),
+ shape = MaterialTheme.shapes.medium,
+ border = BorderStroke(1.dp, Color(0xFFFFE082).copy(alpha = 0.5f)),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
+ ) {
+ Column(modifier = Modifier.padding(14.dp)) {
+ Text(
+ "Add your own App ID, then rebuild to fully test all functionality.",
+ style = MaterialTheme.typography.bodySmall
+ )
+ Text(
+ "Get your keys at onesignal.com",
+ style = MaterialTheme.typography.bodySmall.copy(fontWeight = FontWeight.SemiBold),
+ color = OneSignalRed,
+ modifier = Modifier
+ .padding(top = 4.dp)
+ .clickable { onGetKeysClick() }
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ // Privacy Consent Card
+ Card(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface),
+ shape = MaterialTheme.shapes.medium,
+ border = BorderStroke(1.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)),
+ elevation = CardDefaults.cardElevation(defaultElevation = 0.dp)
+ ) {
+ ToggleRow(
+ label = "Consent Required",
+ description = "Require consent before SDK processes data",
+ checked = consentRequired,
+ onCheckedChange = onConsentRequiredChange
+ )
+ if (consentRequired) {
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ ToggleRow(
+ label = "Privacy Consent",
+ description = "Consent given for data collection",
+ checked = privacyConsentGiven,
+ onCheckedChange = onConsentChange
+ )
+ }
+ }
+}
+
+// === USER SECTION ===
+@Composable
+fun UserSection(
+ externalUserId: String?,
+ onLoginClick: () -> Unit,
+ onLogoutClick: () -> Unit
+) {
+ val isLoggedIn = !externalUserId.isNullOrEmpty()
+
+ SectionCard(title = "User") {
+ // Status
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "Status",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = if (isLoggedIn) "Logged In" else "Anonymous",
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontWeight = FontWeight.Medium,
+ color = if (isLoggedIn) OneSignalGreen else MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ )
+ }
+ HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
+ // External ID
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "External ID",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = externalUserId ?: "—",
+ style = MaterialTheme.typography.bodyMedium.copy(fontWeight = FontWeight.Medium),
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis
+ )
+ }
+ }
+
+ Spacer(modifier = Modifier.height(8.dp))
+
+ PrimaryButton(
+ text = if (isLoggedIn) "SWITCH USER" else "LOGIN USER",
+ onClick = onLoginClick
+ )
+
+ if (isLoggedIn) {
+ OutlineButton(
+ text = "LOGOUT USER",
+ onClick = onLogoutClick
+ )
+ }
+}
+
+// === PUSH SECTION ===
+@Composable
+fun PushSection(
+ pushSubscriptionId: String?,
+ pushEnabled: Boolean,
+ hasPermission: Boolean,
+ onEnabledChange: (Boolean) -> Unit,
+ onPromptPush: () -> Unit,
+ onInfoClick: () -> Unit
+) {
+ SectionCard(title = "Push", onInfoClick = onInfoClick) {
+ // Push ID
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 14.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ "Push ID",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ Text(
+ text = pushSubscriptionId ?: "Not Available",
+ style = MaterialTheme.typography.bodyMedium.copy(
+ fontWeight = FontWeight.Medium,
+ color = if (pushSubscriptionId != null) {
+ MaterialTheme.colorScheme.onSurface
+ } else {
+ MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f)
+ }
+ )
+ )
+ }
+
+ HorizontalDivider(
+ color = DividerColor,
+ modifier = Modifier.padding(horizontal = 16.dp)
+ )
+
+ // Enabled Toggle
+ ToggleRow(
+ label = "Enabled",
+ checked = pushEnabled,
+ onCheckedChange = onEnabledChange,
+ enabled = hasPermission
+ )
+ }
+
+ if (!hasPermission) {
+ Spacer(modifier = Modifier.height(8.dp))
+ PrimaryButton(
+ text = "PROMPT PUSH",
+ onClick = onPromptPush
+ )
+ }
+}
+
+// === SEND PUSH NOTIFICATION SECTION ===
+@Composable
+fun SendPushSection(
+ onSimpleClick: () -> Unit,
+ onImageClick: () -> Unit,
+ onCustomClick: () -> Unit,
+ onInfoClick: () -> Unit
+) {
+ SectionCard(title = "Send Push Notification", showCard = false, onInfoClick = onInfoClick) {
+ PrimaryButton(text = "SIMPLE", onClick = onSimpleClick)
+ PrimaryButton(text = "WITH IMAGE", onClick = onImageClick)
+ PrimaryButton(text = "CUSTOM", onClick = onCustomClick)
+ }
+}
+
+// === IN-APP MESSAGING SECTION ===
+@Composable
+fun InAppMessagingSection(
+ isPaused: Boolean,
+ onPausedChange: (Boolean) -> Unit,
+ onInfoClick: () -> Unit
+) {
+ SectionCard(title = "In-App Messaging", onInfoClick = onInfoClick) {
+ ToggleRow(
+ label = "Pause In-App Messages",
+ description = "Toggle in-app message display",
+ checked = isPaused,
+ onCheckedChange = onPausedChange
+ )
+ }
+}
+
+// === SEND IN-APP MESSAGE SECTION ===
+@Composable
+fun SendInAppMessageSection(
+ onSendMessage: (InAppMessageType) -> Unit,
+ onInfoClick: () -> Unit
+) {
+ SectionCard(title = "Send In-App Message", showCard = false, onInfoClick = onInfoClick) {
+ InAppMessageType.values().forEach { type ->
+ Button(
+ onClick = { onSendMessage(type) },
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 4.dp)
+ .height(44.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = OneSignalRed),
+ shape = RoundedCornerShape(10.dp),
+ elevation = ButtonDefaults.buttonElevation(
+ defaultElevation = 0.dp,
+ pressedElevation = 0.dp
+ )
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Icon(
+ imageVector = type.icon,
+ contentDescription = null,
+ tint = Color.White,
+ modifier = Modifier.size(20.dp)
+ )
+ Spacer(modifier = Modifier.width(12.dp))
+ Text(
+ type.title,
+ style = MaterialTheme.typography.labelLarge.copy(
+ color = Color.White,
+ fontWeight = FontWeight.SemiBold,
+ fontSize = 13.sp,
+ letterSpacing = 0.8.sp
+ )
+ )
+ }
+ }
+ }
+ }
+}
+
+// === ALIASES SECTION ===
+@Composable
+fun AliasesSection(
+ aliases: List>,
+ onAddClick: () -> Unit,
+ onAddMultipleClick: () -> Unit,
+ onInfoClick: () -> Unit
+) {
+ SectionCard(title = "Aliases", onInfoClick = onInfoClick) {
+ PairList(
+ items = aliases,
+ emptyText = "No aliases added"
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ PrimaryButton(text = "ADD", onClick = onAddClick)
+ PrimaryButton(text = "ADD MULTIPLE", onClick = onAddMultipleClick)
+}
+
+// === EMAILS SECTION ===
+@Composable
+fun EmailsSection(
+ emails: List,
+ onAddClick: () -> Unit,
+ onRemove: (String) -> Unit,
+ onInfoClick: () -> Unit
+) {
+ SectionCard(title = "Emails", onInfoClick = onInfoClick) {
+ CollapsibleSingleList(
+ items = emails,
+ emptyText = "No emails added",
+ onDelete = onRemove
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ PrimaryButton(text = "ADD EMAIL", onClick = onAddClick)
+}
+
+// === SMS SECTION ===
+@Composable
+fun SmsSection(
+ smsNumbers: List,
+ onAddClick: () -> Unit,
+ onRemove: (String) -> Unit,
+ onInfoClick: () -> Unit
+) {
+ SectionCard(title = "SMS", onInfoClick = onInfoClick) {
+ CollapsibleSingleList(
+ items = smsNumbers,
+ emptyText = "No SMS added",
+ onDelete = onRemove
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ PrimaryButton(text = "ADD SMS", onClick = onAddClick)
+}
+
+// === TAGS SECTION ===
+@Composable
+fun TagsSection(
+ tags: List>,
+ onAddClick: () -> Unit,
+ onAddMultipleClick: () -> Unit,
+ onRemove: (String) -> Unit,
+ onRemoveSelected: () -> Unit,
+ onInfoClick: () -> Unit
+) {
+ SectionCard(title = "Tags", onInfoClick = onInfoClick) {
+ PairList(
+ items = tags,
+ emptyText = "No tags added",
+ onDelete = onRemove
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ PrimaryButton(text = "ADD", onClick = onAddClick)
+ PrimaryButton(text = "ADD MULTIPLE", onClick = onAddMultipleClick)
+
+ if (tags.isNotEmpty()) {
+ DestructiveButton(text = "REMOVE SELECTED", onClick = onRemoveSelected)
+ }
+}
+
+// === OUTCOME SECTION ===
+@Composable
+fun OutcomeSection(
+ onSendOutcome: () -> Unit,
+ onInfoClick: () -> Unit
+) {
+ SectionCard(title = "Outcome Events", showCard = false, onInfoClick = onInfoClick) {
+ PrimaryButton(text = "SEND OUTCOME", onClick = onSendOutcome)
+ }
+}
+
+// === TRIGGERS SECTION ===
+@Composable
+fun TriggersSection(
+ triggers: List>,
+ onAddClick: () -> Unit,
+ onAddMultipleClick: () -> Unit,
+ onRemove: (String) -> Unit,
+ onRemoveSelected: () -> Unit,
+ onClearAll: () -> Unit,
+ onInfoClick: () -> Unit
+) {
+ SectionCard(title = "Triggers", onInfoClick = onInfoClick) {
+ PairList(
+ items = triggers,
+ emptyText = "No triggers added",
+ onDelete = onRemove
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ PrimaryButton(text = "ADD", onClick = onAddClick)
+ PrimaryButton(text = "ADD MULTIPLE", onClick = onAddMultipleClick)
+
+ if (triggers.isNotEmpty()) {
+ DestructiveButton(text = "REMOVE SELECTED", onClick = onRemoveSelected)
+ DestructiveButton(text = "CLEAR ALL", onClick = onClearAll)
+ }
+}
+
+// === TRACK EVENT SECTION ===
+@Composable
+fun TrackEventSection(
+ onTrackClick: () -> Unit,
+ onInfoClick: () -> Unit
+) {
+ SectionCard(title = "Track Event", showCard = false, onInfoClick = onInfoClick) {
+ PrimaryButton(text = "TRACK EVENT", onClick = onTrackClick)
+ }
+}
+
+// === LOCATION SECTION ===
+@Composable
+fun LocationSection(
+ locationShared: Boolean,
+ onLocationSharedChange: (Boolean) -> Unit,
+ onPromptLocation: () -> Unit,
+ onInfoClick: () -> Unit
+) {
+ SectionCard(title = "Location", onInfoClick = onInfoClick) {
+ ToggleRow(
+ label = "Location Shared",
+ description = "Share device location with OneSignal",
+ checked = locationShared,
+ onCheckedChange = onLocationSharedChange
+ )
+ }
+ Spacer(modifier = Modifier.height(8.dp))
+ PrimaryButton(text = "PROMPT LOCATION", onClick = onPromptLocation)
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/secondary/SecondaryActivity.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/secondary/SecondaryActivity.kt
new file mode 100644
index 0000000000..130d503eff
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/secondary/SecondaryActivity.kt
@@ -0,0 +1,69 @@
+package com.onesignal.sdktest.ui.secondary
+
+import android.os.Bundle
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.setContent
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Text
+import androidx.compose.material3.TopAppBar
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import com.onesignal.sdktest.ui.theme.LightBackground
+import com.onesignal.sdktest.ui.theme.OneSignalRed
+import com.onesignal.sdktest.ui.theme.OneSignalTheme
+
+class SecondaryActivity : ComponentActivity() {
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ setContent {
+ OneSignalTheme {
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text("Secondary Activity", color = Color.White) },
+ navigationIcon = {
+ IconButton(onClick = { finish() }) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = "Back",
+ tint = Color.White
+ )
+ }
+ },
+ colors = TopAppBarDefaults.topAppBarColors(
+ containerColor = OneSignalRed
+ )
+ )
+ },
+ containerColor = LightBackground
+ ) { paddingValues ->
+ Box(
+ modifier = Modifier
+ .fillMaxSize()
+ .padding(paddingValues),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "Secondary Activity",
+ style = MaterialTheme.typography.headlineMedium
+ )
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/theme/Theme.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/theme/Theme.kt
new file mode 100644
index 0000000000..dca176608c
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/ui/theme/Theme.kt
@@ -0,0 +1,98 @@
+package com.onesignal.sdktest.ui.theme
+
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Shapes
+import androidx.compose.material3.Typography
+import androidx.compose.material3.lightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.TextStyle
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+// OneSignal brand colors
+val OneSignalRed = Color(0xFFE54B4D)
+val OneSignalRedDark = Color(0xFFCE3E40)
+val OneSignalGreen = Color(0xFF34A853)
+val OneSignalGreenLight = Color(0xFFE6F4EA)
+val DarkText = Color(0xFF1F1F1F)
+val SecondaryText = Color(0xFF5F6368)
+val LightBackground = Color(0xFFF8F9FA)
+val CardBackground = Color.White
+val DividerColor = Color(0xFFE8EAED)
+val WarningBackground = Color(0xFFFFF8E1)
+val SurfaceBorder = Color(0xFFDADCE0)
+
+private val LightColorScheme = lightColorScheme(
+ primary = OneSignalRed,
+ onPrimary = Color.White,
+ secondary = OneSignalGreen,
+ onSecondary = Color.White,
+ background = LightBackground,
+ surface = CardBackground,
+ onBackground = DarkText,
+ onSurface = DarkText,
+ surfaceVariant = Color(0xFFF1F3F4),
+ onSurfaceVariant = SecondaryText,
+ outline = SurfaceBorder,
+ error = OneSignalRed,
+ onError = Color.White
+)
+
+private val AppTypography = Typography(
+ headlineSmall = TextStyle(
+ fontSize = 20.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = (-0.2).sp,
+ color = DarkText
+ ),
+ titleMedium = TextStyle(
+ fontSize = 15.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.sp,
+ color = DarkText
+ ),
+ labelLarge = TextStyle(
+ fontSize = 13.sp,
+ fontWeight = FontWeight.SemiBold,
+ letterSpacing = 0.4.sp,
+ color = SecondaryText
+ ),
+ bodyLarge = TextStyle(
+ fontSize = 15.sp,
+ fontWeight = FontWeight.Normal,
+ letterSpacing = 0.sp,
+ color = DarkText
+ ),
+ bodyMedium = TextStyle(
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Normal,
+ letterSpacing = 0.sp,
+ color = DarkText
+ ),
+ bodySmall = TextStyle(
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Normal,
+ letterSpacing = 0.sp,
+ color = SecondaryText
+ )
+)
+
+private val AppShapes = Shapes(
+ small = RoundedCornerShape(8.dp),
+ medium = RoundedCornerShape(12.dp),
+ large = RoundedCornerShape(16.dp),
+ extraLarge = RoundedCornerShape(24.dp)
+)
+
+@Composable
+fun OneSignalTheme(content: @Composable () -> Unit) {
+ MaterialTheme(
+ colorScheme = LightColorScheme,
+ typography = AppTypography,
+ shapes = AppShapes,
+ content = content
+ )
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/util/LogManager.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/util/LogManager.kt
new file mode 100644
index 0000000000..ce6a6f9d6e
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/util/LogManager.kt
@@ -0,0 +1,111 @@
+package com.onesignal.sdktest.util
+
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import androidx.compose.runtime.mutableStateListOf
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+
+/**
+ * Pass-through log manager that both displays logs in the UI and forwards to Android's logcat.
+ * Use this instead of android.util.Log to get both logcat output and UI display.
+ */
+object LogManager {
+
+ private const val TAG = "OneSignalDemo"
+ private const val MAX_LOGS = 100
+
+ private val _logs = mutableStateListOf()
+ val logs: List get() = _logs
+
+ private val timeFormat = SimpleDateFormat("HH:mm:ss", Locale.getDefault())
+ private val mainHandler = Handler(Looper.getMainLooper())
+
+ /**
+ * Log with custom tag (used by SDK log listener)
+ */
+ fun log(tag: String, message: String, level: LogLevel) {
+ // Forward to Android logcat (can happen on any thread)
+ when (level) {
+ LogLevel.DEBUG -> Log.d(tag, message)
+ LogLevel.INFO -> Log.i(tag, message)
+ LogLevel.WARN -> Log.w(tag, message)
+ LogLevel.ERROR -> Log.e(tag, message)
+ }
+
+ // Create entry with current timestamp
+ val entry = LogEntry(
+ timestamp = timeFormat.format(Date()),
+ message = message,
+ level = level
+ )
+
+ // Add to UI log list on main thread (required for Compose state)
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ addLogEntry(entry)
+ } else {
+ mainHandler.post { addLogEntry(entry) }
+ }
+ }
+
+ private fun addLogEntry(entry: LogEntry) {
+ _logs.add(0, entry) // Add to beginning (newest first)
+
+ // Keep only the last MAX_LOGS entries
+ while (_logs.size > MAX_LOGS) {
+ _logs.removeAt(_logs.lastIndex)
+ }
+ }
+
+ // Convenience methods with default tag
+ fun d(message: String) = log(TAG, message, LogLevel.DEBUG)
+ fun i(message: String) = log(TAG, message, LogLevel.INFO)
+ fun w(message: String) = log(TAG, message, LogLevel.WARN)
+ fun e(message: String) = log(TAG, message, LogLevel.ERROR)
+
+ // Methods with custom tag (mimics android.util.Log API)
+ fun d(tag: String, message: String) = log(tag, message, LogLevel.DEBUG)
+ fun i(tag: String, message: String) = log(tag, message, LogLevel.INFO)
+ fun w(tag: String, message: String) = log(tag, message, LogLevel.WARN)
+ fun e(tag: String, message: String) = log(tag, message, LogLevel.ERROR)
+
+ // Methods with throwable (mimics android.util.Log API)
+ fun e(tag: String, message: String, throwable: Throwable) {
+ Log.e(tag, message, throwable)
+ log(tag, "$message: ${throwable.message}", LogLevel.ERROR)
+ }
+
+ fun w(tag: String, message: String, throwable: Throwable) {
+ Log.w(tag, message, throwable)
+ log(tag, "$message: ${throwable.message}", LogLevel.WARN)
+ }
+
+ // Legacy methods for compatibility
+ fun info(message: String) = i(message)
+ fun debug(message: String) = d(message)
+ fun warn(message: String) = w(message)
+ fun error(message: String) = e(message)
+
+ fun clear() {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ _logs.clear()
+ } else {
+ mainHandler.post { _logs.clear() }
+ }
+ }
+}
+
+data class LogEntry(
+ val timestamp: String,
+ val message: String,
+ val level: LogLevel
+)
+
+enum class LogLevel {
+ DEBUG,
+ INFO,
+ WARN,
+ ERROR
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt
new file mode 100644
index 0000000000..f3b93dfb00
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/util/SharedPreferenceUtil.kt
@@ -0,0 +1,72 @@
+package com.onesignal.sdktest.util
+
+import android.content.Context
+import android.content.SharedPreferences
+
+object SharedPreferenceUtil {
+
+ private const val APP_SHARED_PREFS = "com.onesignal.sdktest"
+ private const val OS_APP_ID_SHARED_PREF = "OS_APP_ID_SHARED_PREF"
+ private const val PRIVACY_CONSENT_SHARED_PREF = "PRIVACY_CONSENT_SHARED_PREF"
+ private const val USER_EXTERNAL_USER_ID_SHARED_PREF = "USER_EXTERNAL_USER_ID_SHARED_PREF"
+ private const val LOCATION_SHARED_PREF = "LOCATION_SHARED_PREF"
+ private const val IN_APP_MESSAGING_PAUSED_PREF = "IN_APP_MESSAGING_PAUSED_PREF"
+ private const val CONSENT_REQUIRED_PREF = "CONSENT_REQUIRED_PREF"
+
+ private fun getSharedPreference(context: Context): SharedPreferences {
+ return context.getSharedPreferences(APP_SHARED_PREFS, Context.MODE_PRIVATE)
+ }
+
+ fun exists(context: Context, key: String): Boolean {
+ return getSharedPreference(context).contains(key)
+ }
+
+ fun getOneSignalAppId(context: Context): String? {
+ val defaultAppId = "77e32082-ea27-42e3-a898-c72e141824ef"
+ return getSharedPreference(context).getString(OS_APP_ID_SHARED_PREF, defaultAppId)
+ }
+
+ fun getUserPrivacyConsent(context: Context): Boolean {
+ return getSharedPreference(context).getBoolean(PRIVACY_CONSENT_SHARED_PREF, false)
+ }
+
+ fun getCachedUserExternalUserId(context: Context): String {
+ return getSharedPreference(context).getString(USER_EXTERNAL_USER_ID_SHARED_PREF, "") ?: ""
+ }
+
+ fun getCachedLocationSharedStatus(context: Context): Boolean {
+ return getSharedPreference(context).getBoolean(LOCATION_SHARED_PREF, false)
+ }
+
+ fun getCachedInAppMessagingPausedStatus(context: Context): Boolean {
+ return getSharedPreference(context).getBoolean(IN_APP_MESSAGING_PAUSED_PREF, true)
+ }
+
+ fun cacheOneSignalAppId(context: Context, appId: String) {
+ getSharedPreference(context).edit().putString(OS_APP_ID_SHARED_PREF, appId).apply()
+ }
+
+ fun cacheUserPrivacyConsent(context: Context, privacyConsent: Boolean) {
+ getSharedPreference(context).edit().putBoolean(PRIVACY_CONSENT_SHARED_PREF, privacyConsent).apply()
+ }
+
+ fun cacheUserExternalUserId(context: Context, userId: String) {
+ getSharedPreference(context).edit().putString(USER_EXTERNAL_USER_ID_SHARED_PREF, userId).apply()
+ }
+
+ fun cacheLocationSharedStatus(context: Context, shared: Boolean) {
+ getSharedPreference(context).edit().putBoolean(LOCATION_SHARED_PREF, shared).apply()
+ }
+
+ fun cacheInAppMessagingPausedStatus(context: Context, paused: Boolean) {
+ getSharedPreference(context).edit().putBoolean(IN_APP_MESSAGING_PAUSED_PREF, paused).apply()
+ }
+
+ fun getCachedConsentRequired(context: Context): Boolean {
+ return getSharedPreference(context).getBoolean(CONSENT_REQUIRED_PREF, false)
+ }
+
+ fun cacheConsentRequired(context: Context, required: Boolean) {
+ getSharedPreference(context).edit().putBoolean(CONSENT_REQUIRED_PREF, required).apply()
+ }
+}
diff --git a/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/util/TooltipHelper.kt b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/util/TooltipHelper.kt
new file mode 100644
index 0000000000..0a8c65a3a4
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/java/com/onesignal/sdktest/util/TooltipHelper.kt
@@ -0,0 +1,103 @@
+package com.onesignal.sdktest.util
+
+import android.content.Context
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.withContext
+import org.json.JSONObject
+import java.net.HttpURLConnection
+import java.net.URL
+
+/**
+ * Helper object for loading tooltip content from the sdk-shared repository.
+ * Tooltip content is fetched at runtime from a remote URL, ensuring all SDK demo apps
+ * share the same tooltip definitions.
+ */
+object TooltipHelper {
+
+ private var tooltips: Map = emptyMap()
+ private var initialized = false
+
+ private const val TOOLTIP_URL =
+ "https://raw.githubusercontent.com/OneSignal/sdk-shared/main/demo/tooltip_content.json"
+
+ /**
+ * Initialize the tooltip helper by fetching content from remote URL on a background thread.
+ * Call this once during app startup (e.g., in Application.onCreate()).
+ * On failure (no network, etc.), tooltips remain empty — they are non-critical.
+ */
+ @Suppress("unused")
+ fun init(context: Context) {
+ if (initialized) return
+
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ val connection = URL(TOOLTIP_URL).openConnection() as HttpURLConnection
+ connection.connectTimeout = 10000
+ connection.readTimeout = 10000
+ connection.requestMethod = "GET"
+
+ if (connection.responseCode == HttpURLConnection.HTTP_OK) {
+ val json = connection.inputStream.bufferedReader().use { it.readText() }
+ val jsonObject = JSONObject(json)
+
+ val tooltipMap = mutableMapOf()
+
+ jsonObject.keys().forEach { key ->
+ val tooltipJson = jsonObject.getJSONObject(key)
+ val title = tooltipJson.getString("title")
+ val description = tooltipJson.getString("description")
+
+ val options = if (tooltipJson.has("options")) {
+ val optionsArray = tooltipJson.getJSONArray("options")
+ (0 until optionsArray.length()).map { i ->
+ val optionJson = optionsArray.getJSONObject(i)
+ TooltipOption(
+ name = optionJson.getString("name"),
+ description = optionJson.getString("description")
+ )
+ }
+ } else {
+ null
+ }
+
+ tooltipMap[key] = TooltipData(title, description, options)
+ }
+
+ withContext(Dispatchers.Main) {
+ tooltips = tooltipMap
+ initialized = true
+ }
+ }
+ } catch (e: Exception) {
+ // Tooltips are non-critical; log and continue
+ android.util.Log.w("TooltipHelper", "Failed to fetch tooltip content: ${e.message}")
+ }
+ }
+ }
+
+ /**
+ * Get tooltip data for a specific key.
+ */
+ fun getTooltip(key: String): TooltipData? {
+ return tooltips[key]
+ }
+}
+
+/**
+ * Data class representing tooltip content.
+ */
+data class TooltipData(
+ val title: String,
+ val description: String,
+ val options: List? = null
+)
+
+/**
+ * Data class representing a tooltip option (for sections with multiple buttons).
+ */
+data class TooltipOption(
+ val name: String,
+ val description: String
+)
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_alert_octagon_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_alert_octagon_white_48dp.png
new file mode 100755
index 0000000000..d4673106b2
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_alert_octagon_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_alert_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_alert_white_48dp.png
new file mode 100755
index 0000000000..91e6b537eb
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_alert_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_bell_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_bell_white_24dp.png
new file mode 100755
index 0000000000..1ca1641d95
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_bell_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_brightness_percent_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_brightness_percent_white_24dp.png
new file mode 100755
index 0000000000..2203ae978f
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_brightness_percent_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_cart_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_cart_white_24dp.png
new file mode 100755
index 0000000000..92642235dd
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_cart_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_checkbox_marked_circle_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_checkbox_marked_circle_white_48dp.png
new file mode 100755
index 0000000000..47c5c85a99
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_checkbox_marked_circle_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_chevron_down_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_chevron_down_white_48dp.png
new file mode 100755
index 0000000000..2e435ce0c1
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_chevron_down_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_chevron_up_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_chevron_up_white_48dp.png
new file mode 100755
index 0000000000..96e1891eae
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_chevron_up_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_email_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_email_white_48dp.png
new file mode 100644
index 0000000000..7fd1b75de0
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_email_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_gesture_tap_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_gesture_tap_white_24dp.png
new file mode 100755
index 0000000000..358bbf7600
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_gesture_tap_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_human_greeting_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_human_greeting_white_24dp.png
new file mode 100755
index 0000000000..a14b71f59f
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_human_greeting_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_image_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_image_white_24dp.png
new file mode 100755
index 0000000000..8cb259f375
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_image_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_information_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_information_white_48dp.png
new file mode 100755
index 0000000000..86eef788d2
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_information_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_map_marker_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_map_marker_white_24dp.png
new file mode 100755
index 0000000000..a59232fbbc
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_map_marker_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_message_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_message_white_48dp.png
new file mode 100755
index 0000000000..cfbc0588e2
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_message_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_newspaper_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_newspaper_white_24dp.png
new file mode 100755
index 0000000000..9d659ee7cb
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_newspaper_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_star_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_star_white_24dp.png
new file mode 100755
index 0000000000..0ab3e54948
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-hdpi/ic_star_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_alert_octagon_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_alert_octagon_white_48dp.png
new file mode 100755
index 0000000000..e7e8920488
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_alert_octagon_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_alert_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_alert_white_48dp.png
new file mode 100755
index 0000000000..5e24141503
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_alert_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_bell_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_bell_white_24dp.png
new file mode 100755
index 0000000000..502ffb403a
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_bell_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_brightness_percent_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_brightness_percent_white_24dp.png
new file mode 100755
index 0000000000..816b2a842e
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_brightness_percent_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_cart_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_cart_white_24dp.png
new file mode 100755
index 0000000000..ee59c0cb96
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_cart_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_checkbox_marked_circle_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_checkbox_marked_circle_white_48dp.png
new file mode 100755
index 0000000000..9fec6c2322
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_checkbox_marked_circle_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_chevron_down_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_chevron_down_white_48dp.png
new file mode 100755
index 0000000000..3dfb8c58a4
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_chevron_down_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_chevron_up_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_chevron_up_white_48dp.png
new file mode 100755
index 0000000000..fb433d9334
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_chevron_up_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_email_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_email_white_48dp.png
new file mode 100644
index 0000000000..1e96a566ed
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_email_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_gesture_tap_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_gesture_tap_white_24dp.png
new file mode 100755
index 0000000000..4b7015c1c2
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_gesture_tap_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_human_greeting_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_human_greeting_white_24dp.png
new file mode 100755
index 0000000000..d0ccfcf9f1
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_human_greeting_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_image_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_image_white_24dp.png
new file mode 100755
index 0000000000..da01d9ed48
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_image_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_information_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_information_white_48dp.png
new file mode 100755
index 0000000000..5d40a5a2af
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_information_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_map_marker_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_map_marker_white_24dp.png
new file mode 100755
index 0000000000..5c816ab849
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_map_marker_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_message_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_message_white_48dp.png
new file mode 100755
index 0000000000..3970be757b
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_message_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_newspaper_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_newspaper_white_24dp.png
new file mode 100755
index 0000000000..b12809cbe7
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_newspaper_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_star_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_star_white_24dp.png
new file mode 100755
index 0000000000..4f78bf8367
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-mdpi/ic_star_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-nodpi/onesignal_rectangle.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-nodpi/onesignal_rectangle.png
new file mode 100644
index 0000000000..8277253388
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-nodpi/onesignal_rectangle.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-nodpi/onesignal_square.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-nodpi/onesignal_square.png
new file mode 100644
index 0000000000..f8fa700c4e
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-nodpi/onesignal_square.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_alert_octagon_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_alert_octagon_white_48dp.png
new file mode 100755
index 0000000000..6d1293c5cf
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_alert_octagon_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_alert_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_alert_white_48dp.png
new file mode 100755
index 0000000000..bc0a54dd84
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_alert_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_bell_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_bell_white_24dp.png
new file mode 100755
index 0000000000..626cf12ba9
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_bell_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_brightness_percent_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_brightness_percent_white_24dp.png
new file mode 100755
index 0000000000..75188d33c9
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_brightness_percent_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_cart_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_cart_white_24dp.png
new file mode 100755
index 0000000000..2bc1e4e09a
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_cart_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_checkbox_marked_circle_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_checkbox_marked_circle_white_48dp.png
new file mode 100755
index 0000000000..d1047c605b
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_checkbox_marked_circle_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_chevron_down_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_chevron_down_white_48dp.png
new file mode 100755
index 0000000000..37b13c70be
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_chevron_down_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_chevron_up_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_chevron_up_white_48dp.png
new file mode 100755
index 0000000000..872bb2673c
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_chevron_up_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_email_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_email_white_48dp.png
new file mode 100644
index 0000000000..68f209e2da
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_email_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_gesture_tap_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_gesture_tap_white_24dp.png
new file mode 100755
index 0000000000..2bc4b93477
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_gesture_tap_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_human_greeting_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_human_greeting_white_24dp.png
new file mode 100755
index 0000000000..4fa79f8a8b
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_human_greeting_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_image_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_image_white_24dp.png
new file mode 100755
index 0000000000..85a213f10d
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_image_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_information_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_information_white_48dp.png
new file mode 100755
index 0000000000..895e3642ef
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_information_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_map_marker_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_map_marker_white_24dp.png
new file mode 100755
index 0000000000..cedac382be
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_map_marker_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_message_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_message_white_48dp.png
new file mode 100755
index 0000000000..609b11fc6a
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_message_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_newspaper_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_newspaper_white_24dp.png
new file mode 100755
index 0000000000..67536da6f4
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_newspaper_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_star_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_star_white_24dp.png
new file mode 100755
index 0000000000..c199f9280b
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xhdpi/ic_star_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_alert_octagon_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_alert_octagon_white_48dp.png
new file mode 100755
index 0000000000..22efea47d2
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_alert_octagon_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_alert_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_alert_white_48dp.png
new file mode 100755
index 0000000000..95a55ced1a
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_alert_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_bell_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_bell_white_24dp.png
new file mode 100755
index 0000000000..d3d04b4617
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_bell_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_brightness_percent_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_brightness_percent_white_24dp.png
new file mode 100755
index 0000000000..dd66e838bb
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_brightness_percent_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_cart_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_cart_white_24dp.png
new file mode 100755
index 0000000000..c76336447e
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_cart_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_checkbox_marked_circle_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_checkbox_marked_circle_white_48dp.png
new file mode 100755
index 0000000000..389601b23a
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_checkbox_marked_circle_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_chevron_down_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_chevron_down_white_48dp.png
new file mode 100755
index 0000000000..640389ab4a
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_chevron_down_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_chevron_up_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_chevron_up_white_48dp.png
new file mode 100755
index 0000000000..ac07c74eb9
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_chevron_up_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_email_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_email_white_48dp.png
new file mode 100644
index 0000000000..7ee73fcd73
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_email_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_gesture_tap_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_gesture_tap_white_24dp.png
new file mode 100755
index 0000000000..625409633f
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_gesture_tap_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_human_greeting_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_human_greeting_white_24dp.png
new file mode 100755
index 0000000000..00d82db911
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_human_greeting_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_image_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_image_white_24dp.png
new file mode 100755
index 0000000000..5aa91f3433
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_image_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_information_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_information_white_48dp.png
new file mode 100755
index 0000000000..815941700e
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_information_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_map_marker_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_map_marker_white_24dp.png
new file mode 100755
index 0000000000..be7d044e30
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_map_marker_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_message_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_message_white_48dp.png
new file mode 100755
index 0000000000..390779409f
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_message_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_newspaper_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_newspaper_white_24dp.png
new file mode 100755
index 0000000000..258794b941
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_newspaper_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_star_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_star_white_24dp.png
new file mode 100755
index 0000000000..cc6a0424d2
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxhdpi/ic_star_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_alert_octagon_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_alert_octagon_white_48dp.png
new file mode 100755
index 0000000000..0f26cc1b8c
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_alert_octagon_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_alert_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_alert_white_48dp.png
new file mode 100755
index 0000000000..d6d7974e32
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_alert_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_bell_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_bell_white_24dp.png
new file mode 100755
index 0000000000..ac92677857
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_bell_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_brightness_percent_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_brightness_percent_white_24dp.png
new file mode 100755
index 0000000000..c3d6a0549b
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_brightness_percent_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_cart_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_cart_white_24dp.png
new file mode 100755
index 0000000000..47b2f3b0db
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_cart_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_checkbox_marked_circle_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_checkbox_marked_circle_white_48dp.png
new file mode 100755
index 0000000000..63c0c1f408
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_checkbox_marked_circle_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_chevron_down_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_chevron_down_white_48dp.png
new file mode 100755
index 0000000000..f26a8ffa66
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_chevron_down_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_chevron_up_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_chevron_up_white_48dp.png
new file mode 100755
index 0000000000..d1cfa7906c
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_chevron_up_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_email_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_email_white_48dp.png
new file mode 100644
index 0000000000..3f8b286912
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_email_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_gesture_tap_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_gesture_tap_white_24dp.png
new file mode 100755
index 0000000000..bf9a448143
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_gesture_tap_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_human_greeting_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_human_greeting_white_24dp.png
new file mode 100755
index 0000000000..a9ac8423cc
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_human_greeting_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_image_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_image_white_24dp.png
new file mode 100755
index 0000000000..4443bcca5a
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_image_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_information_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_information_white_48dp.png
new file mode 100755
index 0000000000..19be0de615
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_information_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_map_marker_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_map_marker_white_24dp.png
new file mode 100755
index 0000000000..badf8df20b
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_map_marker_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_message_white_48dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_message_white_48dp.png
new file mode 100755
index 0000000000..cc37ca735f
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_message_white_48dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_newspaper_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_newspaper_white_24dp.png
new file mode 100755
index 0000000000..ecd70d50ea
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_newspaper_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_star_white_24dp.png b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_star_white_24dp.png
new file mode 100755
index 0000000000..8c55e27539
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/drawable-xxxhdpi/ic_star_white_24dp.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable/ic_launcher_background.xml b/Examples/OneSignalDemoV2/app/src/main/res/drawable/ic_launcher_background.xml
new file mode 100644
index 0000000000..0d025f9bf6
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/res/drawable/ic_launcher_background.xml
@@ -0,0 +1,170 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/drawable/ic_launcher_foreground.xml b/Examples/OneSignalDemoV2/app/src/main/res/drawable/ic_launcher_foreground.xml
new file mode 100644
index 0000000000..1f6bb29060
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/res/drawable/ic_launcher_foreground.xml
@@ -0,0 +1,34 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/mipmap-hdpi/ic_onesignal_launcher.png b/Examples/OneSignalDemoV2/app/src/main/res/mipmap-hdpi/ic_onesignal_launcher.png
new file mode 100644
index 0000000000..ea135ad29b
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/mipmap-hdpi/ic_onesignal_launcher.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/mipmap-mdpi/ic_onesignal_launcher.png b/Examples/OneSignalDemoV2/app/src/main/res/mipmap-mdpi/ic_onesignal_launcher.png
new file mode 100644
index 0000000000..00c056dc2c
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/mipmap-mdpi/ic_onesignal_launcher.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/mipmap-xhdpi/ic_onesignal_launcher.png b/Examples/OneSignalDemoV2/app/src/main/res/mipmap-xhdpi/ic_onesignal_launcher.png
new file mode 100644
index 0000000000..d1c8395205
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/mipmap-xhdpi/ic_onesignal_launcher.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/mipmap-xxhdpi/ic_onesignal_launcher.png b/Examples/OneSignalDemoV2/app/src/main/res/mipmap-xxhdpi/ic_onesignal_launcher.png
new file mode 100644
index 0000000000..9cbaa538c7
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/mipmap-xxhdpi/ic_onesignal_launcher.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/mipmap-xxxhdpi/ic_onesignal_launcher.png b/Examples/OneSignalDemoV2/app/src/main/res/mipmap-xxxhdpi/ic_onesignal_launcher.png
new file mode 100644
index 0000000000..68bd42f9a6
Binary files /dev/null and b/Examples/OneSignalDemoV2/app/src/main/res/mipmap-xxxhdpi/ic_onesignal_launcher.png differ
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/values/colors.xml b/Examples/OneSignalDemoV2/app/src/main/res/values/colors.xml
new file mode 100644
index 0000000000..3315475a22
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/res/values/colors.xml
@@ -0,0 +1,22 @@
+
+
+ #E54B4D
+ #C13E40
+ #E54B4D
+
+ #ECECEC
+ #3A3A3A
+
+ #3A3A3A
+ #7A7A7A
+
+ #ECECEC
+ #E9444E
+
+ #FFFFFF
+
+ #1E88E5
+ #47B84C
+ #FFB300
+ #E53935
+
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/values/strings.xml b/Examples/OneSignalDemoV2/app/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..3b257250bc
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/res/values/strings.xml
@@ -0,0 +1,132 @@
+
+
+ OneSignal
+ 77e32082-ea27-42e3-a898-c72e141824ef
+
+
+ App
+ App-Id:
+ REVOKE CONSENT
+ LOGIN USER
+ SWITCH USER
+ LOGOUT USER
+ Logged in as:
+
+
+ Add your own App ID, then rebuild to fully test all functionality.
+ Get your keys at onesignal.com
+
+
+ %d more available
+
+
+ Privacy Consent
+ Privacy Consent:
+ Consent given for data collection
+
+
+ Aliases
+ No Aliases Added
+ ADD ALIAS
+ ADD ALIASES
+ REMOVE ALIASES
+
+
+ Push
+ Push-Id:
+ Enabled
+ PROMPT PUSH
+
+
+ Emails
+ No Emails Added
+ ADD EMAIL
+ New Email
+
+
+ SMSs
+ No SMSs Added
+ ADD SMS
+ New SMS
+
+
+ Tags
+ No Tags Added
+ ADD TAG
+ ADD TAGS
+ REMOVE TAGS
+
+
+ Outcome Events
+ SEND OUTCOME
+ Outcome Type
+ Outcome Name
+ Outcome Value
+ Select an Outcome Type…
+ Normal Outcome
+ Unique Outcome
+ Outcome with Value
+ SEND
+
+
+ In-App Messaging
+ Pause In-App Messages:
+ Toggle in-app messages
+
+
+ Triggers
+ No Triggers Added
+ ADD TRIGGER
+ ADD TRIGGERS
+ REMOVE TRIGGERS
+ CLEAR TRIGGERS
+
+
+ Track Event
+ TRACK EVENT
+ Event Name
+ Properties (optional)
+ {\"ABC\":123}
+
+
+ Location
+ Location Shared:
+ Location will be shared from device
+ PROMPT LOCATION
+
+
+ Send Push Notification
+ SIMPLE NOTIFICATION
+ NOTIFICATION WITH IMAGE
+ CUSTOM NOTIFICATION
+ Notification Title
+ Notification Body
+
+
+ Send In-App Message
+
+
+ NEXT ACTIVITY
+ Secondary Activity
+
+
+ External User Id
+ Key
+ Value
+ CANCEL
+ ADD
+ LOGIN
+ Remove
+ Icon
+ Add Alias
+ Add Aliases
+ Add Tag
+ Add Tags
+ Add Trigger
+ Add Triggers
+ + ADD ROW
+ Remove Aliases
+ Remove Tags
+ Remove Triggers
+ REMOVE
+
diff --git a/Examples/OneSignalDemoV2/app/src/main/res/values/styles.xml b/Examples/OneSignalDemoV2/app/src/main/res/values/styles.xml
new file mode 100644
index 0000000000..bfda9fbacc
--- /dev/null
+++ b/Examples/OneSignalDemoV2/app/src/main/res/values/styles.xml
@@ -0,0 +1,8 @@
+
+
+
+
+
diff --git a/Examples/OneSignalDemoV2/build.gradle.kts b/Examples/OneSignalDemoV2/build.gradle.kts
new file mode 100644
index 0000000000..3454c480fb
--- /dev/null
+++ b/Examples/OneSignalDemoV2/build.gradle.kts
@@ -0,0 +1,30 @@
+// Top-level build file where you can add configuration options common to all sub-projects/modules.
+
+buildscript {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ // Huawei maven
+ maven { url = uri("https://developer.huawei.com/repo/") }
+ }
+ dependencies {
+ classpath("com.android.tools.build:gradle:${Versions.androidGradlePlugin}")
+ classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:${Versions.kotlin}")
+ classpath("com.google.gms:google-services:${Versions.googleServices}")
+ classpath("com.huawei.agconnect:agcp:${Versions.huaweiAgcp}")
+ }
+}
+
+allprojects {
+ repositories {
+ google()
+ mavenCentral()
+ // Huawei maven
+ maven { url = uri("https://developer.huawei.com/repo/") }
+ }
+}
+
+tasks.register("clean", Delete::class) {
+ delete(rootProject.layout.buildDirectory)
+}
diff --git a/Examples/OneSignalDemoV2/buildSrc/build.gradle.kts b/Examples/OneSignalDemoV2/buildSrc/build.gradle.kts
new file mode 100644
index 0000000000..61553d8308
--- /dev/null
+++ b/Examples/OneSignalDemoV2/buildSrc/build.gradle.kts
@@ -0,0 +1,8 @@
+plugins {
+ `kotlin-dsl`
+}
+
+repositories {
+ mavenCentral()
+ google()
+}
diff --git a/Examples/OneSignalDemoV2/buildSrc/src/main/kotlin/Dependencies.kt b/Examples/OneSignalDemoV2/buildSrc/src/main/kotlin/Dependencies.kt
new file mode 100644
index 0000000000..3373508401
--- /dev/null
+++ b/Examples/OneSignalDemoV2/buildSrc/src/main/kotlin/Dependencies.kt
@@ -0,0 +1,52 @@
+object Dependencies {
+ // Kotlin
+ const val kotlinStdlib = "org.jetbrains.kotlin:kotlin-stdlib-jdk8:${Versions.kotlin}"
+ const val coroutinesAndroid = "org.jetbrains.kotlinx:kotlinx-coroutines-android:${Versions.coroutines}"
+
+ // AndroidX
+ const val appcompat = "androidx.appcompat:appcompat:${Versions.appcompat}"
+ const val coreKtx = "androidx.core:core-ktx:${Versions.coreKtx}"
+ const val multidex = "androidx.multidex:multidex:${Versions.multidex}"
+
+ // Compose BOM
+ const val composeBom = "androidx.compose:compose-bom:${Versions.composeBom}"
+
+ // Compose (versions managed by BOM)
+ const val composeUi = "androidx.compose.ui:ui"
+ const val composeUiGraphics = "androidx.compose.ui:ui-graphics"
+ const val composeUiToolingPreview = "androidx.compose.ui:ui-tooling-preview"
+ const val composeMaterial3 = "androidx.compose.material3:material3"
+ const val composeMaterialIcons = "androidx.compose.material:material-icons-extended"
+ const val composeUiTooling = "androidx.compose.ui:ui-tooling"
+ const val composeRuntime = "androidx.compose.runtime:runtime"
+ const val composeRuntimeLivedata = "androidx.compose.runtime:runtime-livedata"
+
+ // Activity Compose
+ const val activityCompose = "androidx.activity:activity-compose:${Versions.activityCompose}"
+
+ // Lifecycle Compose
+ const val lifecycleViewModelCompose = "androidx.lifecycle:lifecycle-viewmodel-compose:${Versions.lifecycleCompose}"
+ const val lifecycleRuntimeCompose = "androidx.lifecycle:lifecycle-runtime-compose:${Versions.lifecycleCompose}"
+
+ // Lifecycle
+ const val lifecycleViewModelKtx = "androidx.lifecycle:lifecycle-viewmodel-ktx:${Versions.lifecycle}"
+ const val lifecycleRuntimeKtx = "androidx.lifecycle:lifecycle-runtime-ktx:${Versions.lifecycle}"
+
+ // Google Play Services
+ const val playServicesLocation = "com.google.android.gms:play-services-location:${Versions.playServicesLocation}"
+
+ // Huawei
+ const val huaweiPush = "com.huawei.hms:push:${Versions.huaweiPush}"
+ const val huaweiLocation = "com.huawei.hms:location:${Versions.huaweiLocation}"
+}
+
+object Plugins {
+ const val androidApplication = "com.android.application"
+ const val kotlinAndroid = "kotlin-android"
+ const val googleServices = "com.google.gms.google-services"
+ const val huaweiAgconnect = "com.huawei.agconnect"
+}
+
+object AppConfig {
+ const val applicationId = "com.onesignal.sdktest"
+}
diff --git a/Examples/OneSignalDemoV2/buildSrc/src/main/kotlin/Versions.kt b/Examples/OneSignalDemoV2/buildSrc/src/main/kotlin/Versions.kt
new file mode 100644
index 0000000000..3c78dab00c
--- /dev/null
+++ b/Examples/OneSignalDemoV2/buildSrc/src/main/kotlin/Versions.kt
@@ -0,0 +1,46 @@
+object Versions {
+ // OneSignal SDK
+ const val oneSignalSdk = "5.6.1"
+
+ // SDK
+ const val compileSdk = 34
+ const val minSdk = 21
+ const val targetSdk = 34
+
+ // App
+ const val versionCode = 1
+ const val versionName = "1.0"
+
+ // Kotlin
+ const val kotlin = "1.9.24"
+ const val coroutines = "1.7.3"
+
+ // Compose
+ const val composeBom = "2024.02.00"
+ const val composeCompiler = "1.5.14"
+ const val activityCompose = "1.8.2"
+ const val lifecycleCompose = "2.7.0"
+
+ // AndroidX
+ const val appcompat = "1.5.1"
+ const val coreKtx = "1.9.0"
+ const val multidex = "2.0.1"
+
+ // Lifecycle
+ const val lifecycle = "2.7.0"
+
+ // Material
+ const val material3 = "1.2.0"
+
+ // Google Play Services
+ const val playServicesLocation = "21.0.0"
+ const val googleServices = "4.3.10"
+
+ // Huawei
+ const val huaweiAgcp = "1.9.1.304"
+ const val huaweiPush = "6.3.0.304"
+ const val huaweiLocation = "4.0.0.300"
+
+ // Gradle Plugins
+ const val androidGradlePlugin = "8.8.2"
+}
diff --git a/Examples/OneSignalDemoV2/build_app_prompt.md b/Examples/OneSignalDemoV2/build_app_prompt.md
new file mode 100644
index 0000000000..00b9bce835
--- /dev/null
+++ b/Examples/OneSignalDemoV2/build_app_prompt.md
@@ -0,0 +1,945 @@
+# OneSignal Sample App V2 - Build Guide
+
+This document contains all the prompts and requirements needed to build the OneSignal Sample App V2 from scratch. Give these prompts to an AI assistant or follow them manually to recreate the app.
+
+---
+
+## Phase 1: Initial Setup
+
+### Prompt 1.1 - Project Foundation
+
+```
+Build a sample Android app with:
+- MVVM architecture with Jetpack Compose UI
+- Kotlin Coroutines for background threading (Dispatchers.IO, Dispatchers.Main)
+- Gradle Kotlin DSL with buildSrc for type-safe dependency management
+- Support for Google FCM and Huawei HMS product flavors (matching existing OneSignalDemo setup)
+- Package name: com.onesignal.sdktest (must match google-services.json and agconnect-services.json)
+- All dialogs should have EMPTY input fields (for Appium testing - test framework enters values)
+- Material3 theming with OneSignal brand colors
+```
+
+### Prompt 1.2 - OneSignal Code Organization
+
+```
+Centralize all OneSignal SDK calls in a single OneSignalRepository.kt class:
+
+User operations:
+- loginUser(externalUserId: String)
+- logoutUser()
+
+Alias operations:
+- addAlias(label: String, id: String)
+- addAliases(aliases: Map) // Batch add
+
+Email operations:
+- addEmail(email: String)
+- removeEmail(email: String)
+
+SMS operations:
+- addSms(smsNumber: String)
+- removeSms(smsNumber: String)
+
+Tag operations:
+- addTag(key: String, value: String)
+- addTags(tags: Map) // Batch add
+- removeTag(key: String)
+- removeTags(keys: Collection) // Batch remove
+- getTags(): Map
+
+Trigger operations:
+- addTrigger(key: String, value: String)
+- addTriggers(triggers: Map) // Batch add
+- removeTrigger(key: String)
+- clearTriggers(keys: Collection)
+
+Outcome operations:
+- sendOutcome(name: String)
+- sendUniqueOutcome(name: String)
+- sendOutcomeWithValue(name: String, value: Float)
+
+Track Event:
+- trackEvent(name: String, properties: Map?) // Properties as parsed JSON map
+
+Push subscription:
+- getPushSubscriptionId(): String?
+- isPushEnabled(): Boolean
+- setPushEnabled(enabled: Boolean)
+
+In-App Messages:
+- setInAppMessagesPaused(paused: Boolean)
+- isInAppMessagesPaused(): Boolean
+
+Location:
+- setLocationShared(shared: Boolean)
+- isLocationShared(): Boolean
+- promptLocation()
+
+Privacy consent:
+- setConsentRequired(required: Boolean)
+- getConsentRequired(): Boolean
+- setPrivacyConsent(granted: Boolean)
+- getPrivacyConsent(): Boolean
+
+Notification sending (via REST API, delegated to OneSignalService):
+- sendNotification(type: NotificationType): Boolean
+- sendCustomNotification(title: String, body: String): Boolean
+- fetchUser(onesignalId: String): UserData?
+```
+
+### Prompt 1.3 - OneSignalService (REST API Client)
+
+```
+Create OneSignalService.kt object for REST API calls:
+
+Properties:
+- appId: String (set from MainApplication)
+
+Methods:
+- setAppId(appId: String)
+- getAppId(): String
+- sendNotification(type: NotificationType): Boolean
+- sendCustomNotification(title: String, body: String): Boolean
+- fetchUser(onesignalId: String): UserData?
+
+sendNotification endpoint:
+- POST https://onesignal.com/api/v1/notifications
+- Accept header: "application/vnd.onesignal.v1+json"
+- Uses include_subscription_ids (not include_player_ids)
+- Includes big_picture for image notifications
+
+fetchUser endpoint:
+- GET https://api.onesignal.com/apps/{app_id}/users/by/onesignal_id/{onesignal_id}
+- NO Authorization header needed (public endpoint)
+- Returns UserData with aliases, tags, emails, smsNumbers, externalId
+```
+
+### Prompt 1.4 - SDK Observers
+
+```
+In MainApplication.kt, set up OneSignal listeners:
+- IInAppMessageLifecycleListener (onWillDisplay, onDidDisplay, onWillDismiss, onDidDismiss)
+- IInAppMessageClickListener
+- INotificationClickListener
+- INotificationLifecycleListener (with preventDefault() for async display testing)
+- IUserStateObserver (log when user state changes)
+- After registering listeners, restore cached SDK states from SharedPreferences:
+ - OneSignal.InAppMessages.paused = cached paused status
+ - OneSignal.Location.isShared = cached location shared status
+
+In MainViewModel.kt, implement observers:
+- IPushSubscriptionObserver - react to push subscription changes
+- IPermissionObserver - react to notification permission changes
+- IUserStateObserver - call fetchUserDataFromApi() when user changes (login/logout)
+```
+
+---
+
+## Phase 2: UI Sections
+
+### Section Order (top to bottom) - FINAL
+
+1. **App Section** (App ID, Guidance Banner, Consent Toggle, Logged-in-as display, Login/Logout)
+2. **Push Section** (Push ID, Enabled Toggle, Auto-prompts permission on load)
+3. **Send Push Notification Section** (Simple, With Image, Custom buttons)
+4. **In-App Messaging Section** (Pause toggle)
+5. **Send In-App Message Section** (Top Banner, Bottom Banner, Center Modal, Full Screen - with icons)
+6. **Aliases Section** (Add/Add Multiple, read-only list)
+7. **Emails Section** (Collapsible list >5 items)
+8. **SMS Section** (Collapsible list >5 items)
+9. **Tags Section** (Add/Add Multiple/Remove Selected)
+10. **Outcome Events Section** (Send Outcome dialog with type selection)
+11. **Triggers Section** (Add/Add Multiple/Remove Selected/Clear All - IN MEMORY ONLY)
+12. **Track Event Section** (Track Event with JSON validation)
+13. **Location Section** (Location Shared toggle, Prompt Location button)
+14. **Next Activity Button**
+
+### Prompt 2.1 - App Section
+
+```
+App Section layout:
+
+1. App ID display (readonly Text showing the OneSignal App ID)
+
+2. Sticky guidance banner below App ID:
+ - Text: "Add your own App ID, then rebuild to fully test all functionality."
+ - Link text: "Get your keys at onesignal.com" (clickable, opens browser)
+ - Light background color to stand out
+
+3. Consent card with up to two toggles:
+ a. "Consent Required" toggle (always visible):
+ - Label: "Consent Required"
+ - Description: "Require consent before SDK processes data"
+ - Sets OneSignal.consentRequired
+ b. "Privacy Consent" toggle (only visible when Consent Required is ON):
+ - Label: "Privacy Consent"
+ - Description: "Consent given for data collection"
+ - Sets OneSignal.consentGiven
+ - Separated from the above toggle by a horizontal divider
+ - NOT a blocking overlay - user can interact with app regardless of state
+
+4. "Logged in as" display (ABOVE the buttons, only visible when logged in):
+ - Prominent green Card background (#E8F5E9)
+ - "Logged in as:" label
+ - External User ID displayed large and centered (bold, green #2E7D32)
+ - Positioned ABOVE the Login/Switch User button
+
+5. LOGIN USER button:
+ - Shows "LOGIN USER" when no user is logged in
+ - Shows "SWITCH USER" when a user is logged in
+ - Opens dialog with empty "External User Id" field
+
+6. LOGOUT USER button
+```
+
+### Prompt 2.2 - Push Section
+
+```
+Push Section:
+- Section title: "Push" with info icon for tooltip
+- Push Subscription ID display (readonly)
+- Enabled toggle switch (controls optIn/optOut)
+- Notification permission is automatically requested when MainActivity loads
+- PROMPT PUSH button:
+ - Only visible when notification permission is NOT granted (fallback if user denied)
+ - Requests notification permission when clicked
+ - Hidden once permission is granted
+```
+
+### Prompt 2.3 - Send Push Notification Section
+
+```
+Send Push Notification Section (placed right after Push Section):
+- Section title: "Send Push Notification" with info icon for tooltip
+- Three buttons:
+ 1. SIMPLE - sends basic notification with title/body
+ 2. WITH IMAGE - sends notification with big picture
+ (use https://media.onesignal.com/automated_push_templates/ratings_template.png)
+ 3. CUSTOM - opens dialog for custom title and body
+
+Tooltip should explain each button type.
+```
+
+### Prompt 2.4 - In-App Messaging Section
+
+```
+In-App Messaging Section (placed right after Send Push):
+- Section title: "In-App Messaging" with info icon for tooltip
+- Pause In-App Messages toggle switch:
+ - Label: "Pause In-App Messages"
+ - Description: "Toggle in-app message display"
+```
+
+### Prompt 2.5 - Send In-App Message Section
+
+```
+Send In-App Message Section (placed right after In-App Messaging):
+- Section title: "Send In-App Message" with info icon for tooltip
+- Four FULL-WIDTH buttons (not a grid):
+ 1. TOP BANNER - VerticalAlignTop icon, trigger: "iam_type" = "top_banner"
+ 2. BOTTOM BANNER - VerticalAlignBottom icon, trigger: "iam_type" = "bottom_banner"
+ 3. CENTER MODAL - CropSquare icon, trigger: "iam_type" = "center_modal"
+ 4. FULL SCREEN - Fullscreen icon, trigger: "iam_type" = "full_screen"
+- Button styling:
+ - RED background color (#E9444E)
+ - WHITE text
+ - Type-specific icon on LEFT side only (no right side icon)
+ - Full width of the card
+ - Left-aligned text and icon content (not centered)
+ - UPPERCASE button text
+- On click: adds trigger "iam_type" with the type's value and shows toast "Sent In-App Message: {type}"
+
+Tooltip should explain each IAM type.
+```
+
+### Prompt 2.6 - Aliases Section
+
+```
+Aliases Section (placed after Send In-App Message):
+- Section title: "Aliases" with info icon for tooltip
+- Compose list showing key-value pairs (read-only, no delete icons)
+- Each item shows: Label | ID
+- Filter out "external_id" and "onesignal_id" from display (these are special)
+- "No Aliases Added" text when empty
+- ADD button -> PairInputDialog with empty Label and ID fields (single add)
+- ADD MULTIPLE button -> MultiPairInputDialog (dynamic rows, add/remove)
+- No remove/delete functionality (aliases are add-only from the UI)
+```
+
+### Prompt 2.7 - Emails Section
+
+```
+Emails Section:
+- Section title: "Emails" with info icon for tooltip
+- Compose list showing email addresses
+- Each item shows email with delete icon
+- "No Emails Added" text when empty
+- ADD EMAIL button -> dialog with empty email field
+- Collapse behavior when >5 items:
+ - Show first 5 items
+ - Show "X more" text (clickable)
+ - Expand to show all when clicked
+```
+
+### Prompt 2.8 - SMS Section
+
+```
+SMS Section:
+- Section title: "SMS" with info icon for tooltip
+- Compose list showing phone numbers
+- Each item shows phone number with delete icon
+- "No SMS Added" text when empty
+- ADD SMS button -> dialog with empty SMS field
+- Collapse behavior when >5 items (same as Emails)
+```
+
+### Prompt 2.9 - Tags Section
+
+```
+Tags Section:
+- Section title: "Tags" with info icon for tooltip
+- Compose list showing key-value pairs
+- Each item shows: Key | Value with delete icon
+- "No Tags Added" text when empty
+- ADD button -> PairInputDialog with empty Key and Value fields (single add)
+- ADD MULTIPLE button -> MultiPairInputDialog (dynamic rows)
+- REMOVE SELECTED button:
+ - Only visible when at least one tag exists
+ - Opens MultiSelectRemoveDialog with checkboxes
+```
+
+### Prompt 2.10 - Outcome Events Section
+
+```
+Outcome Events Section:
+- Section title: "Outcome Events" with info icon for tooltip
+- SEND OUTCOME button -> opens dialog with 3 radio options:
+ 1. Normal Outcome -> shows name input field
+ 2. Unique Outcome -> shows name input field
+ 3. Outcome with Value -> shows name and value (float) input fields
+```
+
+### Prompt 2.11 - Triggers Section (IN MEMORY ONLY)
+
+```
+Triggers Section:
+- Section title: "Triggers" with info icon for tooltip
+- Compose list showing key-value pairs
+- Each item shows: Key | Value with delete icon
+- "No Triggers Added" text when empty
+- ADD button -> PairInputDialog with empty Key and Value fields (single add)
+- ADD MULTIPLE button -> MultiPairInputDialog (dynamic rows)
+- Two action buttons (only visible when triggers exist):
+ - REMOVE SELECTED -> MultiSelectRemoveDialog with checkboxes
+ - CLEAR ALL -> Removes all triggers at once
+
+IMPORTANT: Triggers are stored IN MEMORY ONLY during the app session.
+- triggersList is a mutableListOf>() in MainViewModel
+- Triggers are NOT persisted to SharedPreferences
+- Triggers are cleared when the app is killed/restarted
+- This is intentional - triggers are transient test data for IAM testing
+```
+
+### Prompt 2.12 - Track Event Section
+
+```
+Track Event Section:
+- Section title: "Track Event" with info icon for tooltip
+- TRACK EVENT button -> opens TrackEventDialog with:
+ - "Event Name" label + empty input field (required, shows error if empty on submit)
+ - "Properties (optional, JSON)" label + input field with placeholder hint {"key": "value"}
+ - If non-empty and not valid JSON, shows "Invalid JSON format" error on the field
+ - If valid JSON, parsed via JSONObject and converted to Map for the SDK call
+ - If empty, passes null
+ - TRACK button disabled until name is filled AND JSON is valid (or empty)
+- Calls OneSignal.User.trackEvent(name, properties)
+```
+
+### Prompt 2.13 - Location Section
+
+```
+Location Section:
+- Section title: "Location" with info icon for tooltip
+- Location Shared toggle switch:
+ - Label: "Location Shared"
+ - Description: "Share device location with OneSignal"
+- PROMPT LOCATION button
+```
+
+### Prompt 2.14 - Secondary Activity
+
+```
+Secondary Activity (launched by "Next Activity" button at bottom of main screen):
+- Activity title: "Secondary Activity"
+- Page content: centered text "Secondary Activity" using headlineMedium style
+- Simple screen, no additional functionality needed
+```
+
+---
+
+## Phase 3: View User API Integration
+
+### Prompt 3.1 - Data Loading Flow
+
+```
+Loading indicator overlay:
+- Full-screen semi-transparent overlay with centered spinner
+- isLoading LiveData in MainViewModel
+- Show/hide based on isLoading state
+- IMPORTANT: Add 100ms delay after populating data before dismissing loading indicator
+ - This ensures UI has time to render
+ - Use kotlinx.coroutines.delay(100) after setting all LiveData values
+
+On cold start:
+- Check if OneSignal.User.onesignalId is not null
+- If exists: show loading -> call fetchUserDataFromApi() -> populate UI -> delay 100ms -> hide loading
+- If null: just show empty state (no loading indicator)
+
+On login (LOGIN USER / SWITCH USER):
+- Show loading indicator immediately
+- Call OneSignal.login(externalUserId)
+- Clear old user data (aliases, emails, sms, triggers)
+- Wait for onUserStateChange callback
+- onUserStateChange calls fetchUserDataFromApi()
+- fetchUserDataFromApi() populates UI, delays 100ms, then hides loading
+
+On logout:
+- Show loading indicator
+- Call OneSignal.logout()
+- Clear local lists (aliases, emails, sms, triggers)
+- Hide loading indicator
+
+On onUserStateChange callback:
+- Call fetchUserDataFromApi() to sync with server state
+- Update UI with new data (aliases, tags, emails, sms)
+
+Note: REST API key is NOT required for fetchUser endpoint.
+```
+
+### Prompt 3.2 - UserData Model
+
+```
+data class UserData(
+ val aliases: Map, // From identity object (filter out external_id, onesignal_id)
+ val tags: Map, // From properties.tags object
+ val emails: List, // From subscriptions where type="Email" -> token
+ val smsNumbers: List, // From subscriptions where type="SMS" -> token
+ val externalId: String? // From identity.external_id
+)
+```
+
+---
+
+## Phase 4: Info Tooltips
+
+### Prompt 4.1 - Tooltip Content (Remote)
+
+```
+Tooltip content is fetched at runtime from the sdk-shared repo. Do NOT bundle a local copy.
+
+URL:
+https://raw.githubusercontent.com/OneSignal/sdk-shared/main/demo/tooltip_content.json
+
+This file is maintained in the sdk-shared repo and shared across all platform demo apps.
+```
+
+### Prompt 4.2 - Tooltip Helper
+
+```
+Create TooltipHelper.kt:
+
+object TooltipHelper {
+ private var tooltips: Map = emptyMap()
+ private var initialized = false
+
+ private const val TOOLTIP_URL =
+ "https://raw.githubusercontent.com/OneSignal/sdk-shared/main/demo/tooltip_content.json"
+
+ fun init(context: Context) {
+ if (initialized) return
+
+ // IMPORTANT: Fetch on background thread to avoid blocking app startup
+ CoroutineScope(Dispatchers.IO).launch {
+ // Fetch tooltip_content.json from TOOLTIP_URL using HttpURLConnection
+ // Parse JSON into tooltips map
+ // On failure (no network, etc.), leave tooltips empty — tooltips are non-critical
+
+ withContext(Dispatchers.Main) {
+ // Update tooltips map on main thread
+ initialized = true
+ }
+ }
+ }
+
+ fun getTooltip(key: String): TooltipData?
+}
+
+data class TooltipData(
+ val title: String,
+ val description: String,
+ val options: List? = null
+)
+
+data class TooltipOption(
+ val name: String,
+ val description: String
+)
+```
+
+### Prompt 4.3 - Tooltip UI Integration (Compose)
+
+```
+For each section, pass an onInfoClick callback to SectionCard:
+- SectionCard has an optional info icon that calls onInfoClick when tapped
+- In MainScreen, wire onInfoClick to show a TooltipDialog composable
+- TooltipDialog displays title, description, and options (if present)
+
+Example in MainScreen.kt:
+AliasesSection(
+ ...,
+ onInfoClick = { showTooltipDialog = "aliases" }
+)
+
+showTooltipDialog?.let { key ->
+ val tooltip = TooltipHelper.getTooltip(key)
+ if (tooltip != null) {
+ TooltipDialog(
+ title = tooltip.title,
+ description = tooltip.description,
+ options = tooltip.options?.map { it.name to it.description },
+ onDismiss = { showTooltipDialog = null }
+ )
+ }
+}
+```
+
+---
+
+## Phase 5: Data Persistence & Initialization
+
+### What IS Persisted (SharedPreferences)
+
+```
+SharedPreferenceUtil.kt stores:
+- OneSignal App ID
+- Consent required status
+- Privacy consent status
+- External user ID (for login state restoration)
+- Location shared status
+- In-app messaging paused status
+```
+
+### Initialization Flow
+
+```
+On app startup, state is restored in two layers:
+
+1. MainApplication.kt restores SDK state from SharedPreferences cache BEFORE init:
+ - OneSignal.consentRequired = SharedPreferenceUtil.getCachedConsentRequired(context)
+ - OneSignal.consentGiven = SharedPreferenceUtil.getUserPrivacyConsent(context)
+ - OneSignal.initWithContext(this, appId)
+ Then AFTER init, restores remaining SDK state:
+ - OneSignal.InAppMessages.paused = SharedPreferenceUtil.getCachedInAppMessagingPausedStatus(context)
+ - OneSignal.Location.isShared = SharedPreferenceUtil.getCachedLocationSharedStatus(context)
+ This ensures consent settings are in place before the SDK initializes.
+
+2. MainViewModel.loadInitialState() reads UI state from the SDK (not SharedPreferences):
+ - _consentRequired from repository.getConsentRequired() (reads OneSignal.consentRequired)
+ - _privacyConsentGiven from repository.getPrivacyConsent() (reads OneSignal.consentGiven)
+ - _inAppMessagesPaused from repository.isInAppMessagesPaused() (reads OneSignal.InAppMessages.paused)
+ - _locationShared from repository.isLocationShared() (reads OneSignal.Location.isShared)
+ - _externalUserId from OneSignal.User.externalId (empty string means no user logged in)
+ - _appId from SharedPreferenceUtil (app-level config, no SDK getter)
+
+This two-layer approach ensures:
+- The SDK is configured with the user's last preferences before anything else runs
+- The ViewModel reads the SDK's actual state as the source of truth for the UI
+- The UI always reflects what the SDK reports, not stale cache values
+```
+
+### What is NOT Persisted (In-Memory Only)
+
+```
+MainViewModel holds in memory:
+- triggersList: MutableList>
+ - Triggers are session-only
+ - Cleared on app restart
+ - Used for testing IAM trigger conditions
+
+- aliasesList:
+ - Populated from REST API on each session start
+ - When user adds alias locally, added to list immediately (SDK syncs async)
+ - Fetched fresh via fetchUserDataFromApi() on login/app start
+
+- emailsList, smsNumbersList:
+ - Populated from REST API on each session
+ - Not cached locally
+ - Fetched fresh via fetchUserDataFromApi()
+
+- tagsList:
+ - Can be read from SDK via getTags()
+ - Also fetched from API for consistency
+```
+
+---
+
+## Phase 6: Testing Values (Appium Compatibility)
+
+```
+All dialog input fields should be EMPTY by default.
+The test automation framework (Appium) will enter these values:
+
+- Login Dialog: External User Id = "test"
+- Add Alias Dialog: Key = "Test", Value = "Value"
+- Add Multiple Aliases Dialog: Key = "Test", Value = "Value" (first row; supports multiple rows)
+- Add Email Dialog: Email = "test@onesignal.com"
+- Add SMS Dialog: SMS = "123-456-5678"
+- Add Tag Dialog: Key = "Test", Value = "Value"
+- Add Multiple Tags Dialog: Key = "Test", Value = "Value" (first row; supports multiple rows)
+- Add Trigger Dialog: Key = "trigger_key", Value = "trigger_value"
+- Add Multiple Triggers Dialog: Key = "trigger_key", Value = "trigger_value" (first row; supports multiple rows)
+- Outcome Dialog: Name = "test_outcome", Value = "1.5"
+- Track Event Dialog: Name = "test_event", Properties = "{\"key\": \"value\"}"
+- Custom Notification Dialog: Title = "Test Title", Body = "Test Body"
+```
+
+---
+
+## Phase 7: Important Implementation Details
+
+### Alias Management
+
+```
+Aliases are managed with a hybrid approach:
+
+1. On app start/login: Fetched from REST API via fetchUserDataFromApi()
+2. When user adds alias locally:
+ - Call OneSignal.User.addAlias(label, id) - syncs to server async
+ - Immediately add to local aliasesList (don't wait for API)
+ - This ensures instant UI feedback while SDK syncs in background
+3. On next app launch: Fresh data from API includes the synced alias
+```
+
+### Notification Permission
+
+```
+Notification permission is automatically requested when MainActivity loads:
+- Call viewModel.promptPush() at end of onCreate()
+- This ensures prompt appears after user sees the app UI
+- PROMPT PUSH button remains as fallback if user initially denied
+- Button hidden once permission is granted
+```
+
+---
+
+## Phase 8: Jetpack Compose Architecture
+
+### Prompt 8.1 - Compose Setup
+
+```
+Enable Jetpack Compose in the project:
+
+build.gradle.kts (app):
+- buildFeatures { compose = true }
+- composeOptions { kotlinCompilerExtensionVersion = "1.5.10" }
+
+Dependencies (via BOM):
+- composeBom = "2024.02.00"
+- composeUi, composeUiGraphics, composeUiToolingPreview
+- composeMaterial3
+- composeMaterialIconsExtended (for IAM type icons)
+- composeRuntime, composeRuntimeLivedata
+- activityCompose
+- lifecycleViewModelCompose, lifecycleRuntimeCompose
+```
+
+### Prompt 8.2 - Reusable Components
+
+```
+Create reusable Compose components in ui/components/:
+
+SectionCard.kt:
+- Card with title text and optional info icon
+- Column content slot
+- OnInfoClick callback for tooltips
+
+ToggleRow.kt:
+- Label, optional description, Switch
+- Horizontal layout with space between
+
+ActionButton.kt:
+- PrimaryButton (filled, primary color background)
+- DestructiveButton (outlined, red accent)
+- Full-width buttons for consistent styling
+
+ListComponents.kt:
+- PairItem (key-value with delete icon)
+- SingleItem (single value with delete icon)
+- EmptyState (centered "No items" text)
+- CollapsibleSingleList (shows 5, expandable)
+- PairList (simple list of pairs)
+
+LoadingOverlay.kt:
+- Semi-transparent full-screen overlay
+- Centered CircularProgressIndicator
+- Shown via isLoading state
+
+Dialogs.kt:
+- SingleInputDialog (one text field)
+- PairInputDialog (key-value fields, single pair)
+- MultiPairInputDialog (dynamic rows, add/remove, batch submit)
+- MultiSelectRemoveDialog (checkboxes for batch remove)
+- LoginDialog, OutcomeDialog, TrackEventDialog
+- CustomNotificationDialog, TooltipDialog
+```
+
+### Prompt 8.3 - Reusable Multi-Pair Dialog (Compose)
+
+```
+Tags, Aliases, and Triggers all share a reusable MultiPairInputDialog composable
+for adding multiple key-value pairs at once.
+
+Behavior:
+- Dialog opens with one empty key-value row
+- "Add Row" button below the rows adds another empty row
+- Each row has a remove button (hidden when only one row exists)
+- "Add All" button is disabled until ALL key and value fields in every row are filled
+- Validation runs on every text change and after row add/remove
+- On "Add All" press, all rows are collected and submitted as a batch
+- Batch operations use SDK bulk APIs (addAliases, addTags, addTriggers)
+
+Used by:
+- ADD MULTIPLE button (Aliases section) -> calls viewModel.addAliases(pairs)
+- ADD MULTIPLE button (Tags section) -> calls viewModel.addTags(pairs)
+- ADD MULTIPLE button (Triggers section) -> calls viewModel.addTriggers(pairs)
+```
+
+### Prompt 8.4 - Reusable Remove Multi Dialog (Compose)
+
+```
+Aliases, Tags, and Triggers share a reusable MultiSelectRemoveDialog composable
+for selectively removing items from the current list.
+
+Behavior:
+- Accepts the current list of items as List>
+- Renders one Checkbox per item on the left with just the key as the label (not "key: value")
+- User can check 0, 1, or more items
+- "Remove (N)" button shows count of selected items, disabled when none selected
+- On confirm, checked items' keys are collected as Collection and passed to the callback
+
+Used by:
+- REMOVE SELECTED button (Tags section) -> calls viewModel.removeSelectedTags(keys)
+- REMOVE SELECTED button (Triggers section) -> calls viewModel.removeSelectedTriggers(keys)
+```
+
+### Prompt 8.5 - Theme
+
+```
+Create OneSignal theme in ui/theme/Theme.kt:
+
+Colors:
+- OneSignalRed = #E54B4D (primary)
+- OneSignalGreen = #34A853 (success)
+- OneSignalGreenLight = #E6F4EA (success background)
+- LightBackground = #F8F9FA
+- CardBackground = White
+- DividerColor = #E8EAED
+- WarningBackground = #FFF8E1
+
+OneSignalTheme composable:
+- MaterialTheme with LightColorScheme
+- Custom Typography with SemiBold weights
+- Custom Shapes with rounded corners (8/12/16/24dp)
+- Primary = OneSignalRed
+- Surface variants for cards
+```
+
+### Prompt 8.6 - Log View (Appium-Ready)
+
+```
+Add collapsible log view at top of screen for debugging and Appium testing.
+
+Files:
+- util/LogManager.kt - Thread-safe pass-through logger
+- ui/components/LogView.kt - Compose UI with test tags
+
+LogManager Features:
+- Pass-through to Android logcat AND UI display
+- Thread-safe (posts to main thread for Compose state)
+- Captures SDK logs via OneSignal.Debug.addLogListener
+- API: LogManager.d/i/w/e(tag, message) mimics android.util.Log
+
+LogView Features:
+- Collapsible header (default expanded)
+- 5-line height (~100dp)
+- Color-coded by level (Debug=blue, Info=green, Warn=amber, Error=red)
+- Clear button
+- Auto-scroll to newest
+
+Appium Test Tags:
+| Tag | Description |
+|-----|-------------|
+| log_view_container | Main container |
+| log_view_header | Clickable expand/collapse |
+| log_view_count | Shows "(N)" log count |
+| log_view_clear_button | Clear all logs |
+| log_view_list | Scrollable LazyColumn |
+| log_view_empty | "No logs yet" state |
+| log_entry_N | Each log row (N=index) |
+| log_entry_N_timestamp | Timestamp text |
+| log_entry_N_level | D/I/W/E indicator |
+| log_entry_N_message | Log message content |
+
+SDK Log Integration (MainApplication):
+OneSignal.Debug.addLogListener { event ->
+ LogManager.log("SDK", event.entry, level)
+}
+
+Appium Example:
+# Verify a log message exists
+log_msg = driver.find_element(By.XPATH, "//*[@resource-id='log_entry_0_message']")
+assert "Notification sent" in log_msg.text
+
+# Scroll logs
+log_list = driver.find_element(By.XPATH, "//*[@resource-id='log_view_list']")
+driver.execute_script("mobile: scroll", {"element": log_list, "direction": "down"})
+```
+
+### Prompt 8.7 - Toast Messages
+
+```
+All user actions should display toast messages:
+
+- Login: "Logged in as: {userId}"
+- Logout: "Logged out"
+- Add alias: "Alias added: {label}"
+- Add multiple aliases: "{count} alias(es) added"
+- Similar patterns for tags, triggers, emails, SMS
+- Notifications: "Notification sent: {type}" or "Failed to send notification"
+- In-App Messages: "Sent In-App Message: {type}"
+- Outcomes: "Outcome sent: {name}"
+- Events: "Event tracked: {name}"
+- Location: "Location sharing enabled/disabled"
+- Push: "Push enabled/disabled"
+
+Implementation:
+- MainViewModel has toastMessage: LiveData
+- MainActivity observes and shows Android Toast
+- LaunchedEffect triggers on toastMessage change
+- All toast messages are also logged via LogManager.info()
+```
+
+---
+
+## Key Files Structure
+
+```
+Examples/OneSignalDemoV2/
+├── buildSrc/
+│ └── src/main/kotlin/
+│ ├── Versions.kt # Version constants (includes Compose versions)
+│ └── Dependencies.kt # Dependency strings (includes Compose deps)
+├── app/
+│ ├── src/main/
+│ │ ├── java/com/onesignal/sdktest/
+│ │ │ ├── application/
+│ │ │ │ └── MainApplication.kt # SDK init, log listener, observers
+│ │ │ ├── data/
+│ │ │ │ ├── model/
+│ │ │ │ │ ├── NotificationType.kt # With bigPicture URL
+│ │ │ │ │ └── InAppMessageType.kt # With Material icons
+│ │ │ │ ├── network/
+│ │ │ │ │ └── OneSignalService.kt # REST API client
+│ │ │ │ └── repository/
+│ │ │ │ └── OneSignalRepository.kt
+│ │ │ ├── ui/
+│ │ │ │ ├── components/ # Reusable Compose components
+│ │ │ │ │ ├── SectionCard.kt # Card with title and info icon
+│ │ │ │ │ ├── ToggleRow.kt # Label + Switch
+│ │ │ │ │ ├── ActionButton.kt # Primary/Destructive buttons
+│ │ │ │ │ ├── ListComponents.kt # PairList, SingleList, EmptyState
+│ │ │ │ │ ├── LoadingOverlay.kt # Full-screen loading spinner
+│ │ │ │ │ ├── LogView.kt # Collapsible log viewer (Appium-ready)
+│ │ │ │ │ └── Dialogs.kt # All dialog composables
+│ │ │ │ ├── main/
+│ │ │ │ │ ├── MainActivity.kt # ComponentActivity with setContent
+│ │ │ │ │ ├── MainScreen.kt # Main Compose screen (includes LogView)
+│ │ │ │ │ ├── Sections.kt # Individual section composables
+│ │ │ │ │ └── MainViewModel.kt # With batch operations
+│ │ │ │ ├── secondary/
+│ │ │ │ │ └── SecondaryActivity.kt # Simple Compose screen
+│ │ │ │ └── theme/
+│ │ │ │ └── Theme.kt # OneSignal Material3 theme
+│ │ │ └── util/
+│ │ │ ├── SharedPreferenceUtil.kt
+│ │ │ ├── LogManager.kt # Thread-safe pass-through logger
+│ │ │ └── TooltipHelper.kt # Fetches tooltips from remote URL
+│ │ └── res/
+│ │ └── values/
+│ │ ├── strings.xml
+│ │ ├── colors.xml
+│ │ └── styles.xml
+│ └── src/huawei/
+│ └── java/com/onesignal/sdktest/notification/
+│ └── HmsMessageServiceAppLevel.kt
+├── google-services.json
+├── agconnect-services.json
+└── build_app_prompt.md (this file)
+```
+
+Note:
+
+- All UI is Jetpack Compose (no XML layouts)
+- Tooltip content is fetched from remote URL (not bundled locally)
+- LogView at top of screen displays SDK and app logs for debugging/Appium testing
+
+---
+
+## Configuration
+
+### strings.xml Placeholders
+
+```xml
+
+YOUR_APP_ID_HERE
+```
+
+Note: REST API key is NOT required for the fetchUser endpoint.
+
+### Package Name
+
+The package name MUST be `com.onesignal.sdktest` to work with the existing:
+
+- `google-services.json` (Firebase configuration)
+- `agconnect-services.json` (Huawei configuration)
+
+If you change the package name, you must also update these files with your own Firebase/Huawei project configuration.
+
+---
+
+## Summary
+
+This app demonstrates all OneSignal Android SDK features:
+
+- User management (login/logout, aliases with batch add)
+- Push notifications (subscription, sending with images, auto-permission prompt)
+- Email and SMS subscriptions
+- Tags for segmentation (batch add/remove support)
+- Triggers for in-app message targeting (in-memory only, batch operations)
+- Outcomes for conversion tracking
+- Event tracking with JSON properties validation
+- In-app messages (display testing with type-specific icons)
+- Location sharing
+- Privacy consent management
+
+The app is designed to be:
+
+1. **Testable** - Empty dialogs for Appium automation
+2. **Comprehensive** - All SDK features demonstrated
+3. **Clean** - MVVM architecture with Jetpack Compose UI
+4. **Cross-platform ready** - Tooltip content in JSON for sharing across wrappers
+5. **Session-based triggers** - Triggers stored in memory only, cleared on restart
+6. **Responsive UI** - Loading indicator with delay to ensure UI populates before dismissing
+7. **Performant** - Tooltip JSON loaded on background thread
+8. **Modern UI** - Material3 theming with reusable Compose components
+9. **Batch Operations** - Add multiple items at once, select and remove multiple items
diff --git a/Examples/OneSignalDemoV2/gradle.properties b/Examples/OneSignalDemoV2/gradle.properties
new file mode 100644
index 0000000000..a03b354896
--- /dev/null
+++ b/Examples/OneSignalDemoV2/gradle.properties
@@ -0,0 +1,21 @@
+# Project-wide Gradle settings.
+# IDE (e.g. Android Studio) users:
+# Gradle settings configured through the IDE *will override*
+# any settings specified in this file.
+# For more details on how to configure your build environment visit
+# http://www.gradle.org/docs/current/userguide/build_environment.html
+# Specifies the JVM arguments used for the daemon process.
+# The setting is particularly useful for tweaking memory settings.
+org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
+# When configured, Gradle will run in incubating parallel mode.
+# This option should only be used with decoupled projects. More details, visit
+# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
+# org.gradle.parallel=true
+# AndroidX package structure to make it clearer which packages are bundled with the
+# Android operating system, and which are packaged with your app's APK
+# https://developer.android.com/topic/libraries/support-library/androidx-rn
+android.useAndroidX=true
+# Enables namespacing of each library's R class so that its R class includes only the
+# resources declared in the library itself and none from the library's dependencies,
+# thereby reducing the size of the R class for that library
+android.nonTransitiveRClass=true
diff --git a/Examples/OneSignalDemoV2/gradle/wrapper/gradle-wrapper.jar b/Examples/OneSignalDemoV2/gradle/wrapper/gradle-wrapper.jar
new file mode 100644
index 0000000000..7454180f2a
Binary files /dev/null and b/Examples/OneSignalDemoV2/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/Examples/OneSignalDemoV2/gradle/wrapper/gradle-wrapper.properties b/Examples/OneSignalDemoV2/gradle/wrapper/gradle-wrapper.properties
new file mode 100644
index 0000000000..18330fcba8
--- /dev/null
+++ b/Examples/OneSignalDemoV2/gradle/wrapper/gradle-wrapper.properties
@@ -0,0 +1,5 @@
+distributionBase=GRADLE_USER_HOME
+distributionPath=wrapper/dists
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip
+zipStoreBase=GRADLE_USER_HOME
+zipStorePath=wrapper/dists
diff --git a/Examples/OneSignalDemoV2/gradlew b/Examples/OneSignalDemoV2/gradlew
new file mode 100755
index 0000000000..c53aefaa5f
--- /dev/null
+++ b/Examples/OneSignalDemoV2/gradlew
@@ -0,0 +1,234 @@
+#!/bin/sh
+
+#
+# Copyright © 2015-2021 the original authors.
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+##############################################################################
+#
+# Gradle start up script for POSIX generated by Gradle.
+#
+# Important for running:
+#
+# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
+# noncompliant, but you have some other compliant shell such as ksh or
+# bash, then to run this script, type that shell name before the whole
+# command line, like:
+#
+# ksh Gradle
+#
+# Busybox and similar reduced shells will NOT work, because this script
+# requires all of these POSIX shell features:
+# * functions;
+# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
+# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
+# * compound commands having a testable exit status, especially «case»;
+# * various built-in commands including «command», «set», and «ulimit».
+#
+# Important for patching:
+#
+# (2) This script targets any POSIX shell, so it avoids extensions provided
+# by Bash, Ksh, etc; in particular arrays are avoided.
+#
+# The "traditional" practice of packing multiple parameters into a
+# space-separated string is a well documented source of bugs and security
+# problems, so this is (mostly) avoided, by progressively accumulating
+# options in "$@", and eventually passing that to Java.
+#
+# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
+# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
+# see the in-line comments for details.
+#
+# There are tweaks for specific operating systems such as AIX, CygWin,
+# Darwin, MinGW, and NonStop.
+#
+# (3) This script is generated from the Groovy template
+# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# within the Gradle project.
+#
+# You can find Gradle at https://github.com/gradle/gradle/.
+#
+##############################################################################
+
+# Attempt to set APP_HOME
+
+# Resolve links: $0 may be a link
+app_path=$0
+
+# Need this for daisy-chained symlinks.
+while
+ APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
+ [ -h "$app_path" ]
+do
+ ls=$( ls -ld "$app_path" )
+ link=${ls#*' -> '}
+ case $link in #(
+ /*) app_path=$link ;; #(
+ *) app_path=$APP_HOME$link ;;
+ esac
+done
+
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
+
+APP_NAME="Gradle"
+APP_BASE_NAME=${0##*/}
+
+# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
+
+# Use the maximum available, or set MAX_FD != -1 to use that value.
+MAX_FD=maximum
+
+warn () {
+ echo "$*"
+} >&2
+
+die () {
+ echo
+ echo "$*"
+ echo
+ exit 1
+} >&2
+
+# OS specific support (must be 'true' or 'false').
+cygwin=false
+msys=false
+darwin=false
+nonstop=false
+case "$( uname )" in #(
+ CYGWIN* ) cygwin=true ;; #(
+ Darwin* ) darwin=true ;; #(
+ MSYS* | MINGW* ) msys=true ;; #(
+ NONSTOP* ) nonstop=true ;;
+esac
+
+CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
+
+
+# Determine the Java command to use to start the JVM.
+if [ -n "$JAVA_HOME" ] ; then
+ if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
+ # IBM's JDK on AIX uses strange locations for the executables
+ JAVACMD=$JAVA_HOME/jre/sh/java
+ else
+ JAVACMD=$JAVA_HOME/bin/java
+ fi
+ if [ ! -x "$JAVACMD" ] ; then
+ die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+ fi
+else
+ JAVACMD=java
+ which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+
+Please set the JAVA_HOME variable in your environment to match the
+location of your Java installation."
+fi
+
+# Increase the maximum file descriptors if we can.
+if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
+ case $MAX_FD in #(
+ max*)
+ MAX_FD=$( ulimit -H -n ) ||
+ warn "Could not query maximum file descriptor limit"
+ esac
+ case $MAX_FD in #(
+ '' | soft) :;; #(
+ *)
+ ulimit -n "$MAX_FD" ||
+ warn "Could not set maximum file descriptor limit to $MAX_FD"
+ esac
+fi
+
+# Collect all arguments for the java command, stacking in reverse order:
+# * args from the command line
+# * the main class name
+# * -classpath
+# * -D...appname settings
+# * --module-path (only if needed)
+# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
+
+# For Cygwin or MSYS, switch paths to Windows format before running java
+if "$cygwin" || "$msys" ; then
+ APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
+ CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
+
+ JAVACMD=$( cygpath --unix "$JAVACMD" )
+
+ # Now convert the arguments - kludge to limit ourselves to /bin/sh
+ for arg do
+ if
+ case $arg in #(
+ -*) false ;; # don't mess with options #(
+ /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
+ [ -e "$t" ] ;; #(
+ *) false ;;
+ esac
+ then
+ arg=$( cygpath --path --ignore --mixed "$arg" )
+ fi
+ # Roll the args list around exactly as many times as the number of
+ # args, so each arg winds up back in the position where it started, but
+ # possibly modified.
+ #
+ # NB: a `for` loop captures its iteration list before it begins, so
+ # changing the positional parameters here affects neither the number of
+ # iterations, nor the values presented in `arg`.
+ shift # remove old arg
+ set -- "$@" "$arg" # push replacement arg
+ done
+fi
+
+# Collect all arguments for the java command;
+# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
+# shell script including quotes and variable substitutions, so put them in
+# double quotes to make sure that they get re-expanded; and
+# * put everything else in single quotes, so that it's not re-expanded.
+
+set -- \
+ "-Dorg.gradle.appname=$APP_BASE_NAME" \
+ -classpath "$CLASSPATH" \
+ org.gradle.wrapper.GradleWrapperMain \
+ "$@"
+
+# Use "xargs" to parse quoted args.
+#
+# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
+#
+# In Bash we could simply go:
+#
+# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
+# set -- "${ARGS[@]}" "$@"
+#
+# but POSIX shell has neither arrays nor command substitution, so instead we
+# post-process each arg (as a line of input to sed) to backslash-escape any
+# character that might be a shell metacharacter, then use eval to reverse
+# that process (while maintaining the separation between arguments), and wrap
+# the whole thing up as a single "set" statement.
+#
+# This will of course break if any of these variables contains a newline or
+# an unmatched quote.
+#
+
+eval "set -- $(
+ printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
+ xargs -n1 |
+ sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
+ tr '\n' ' '
+ )" '"$@"'
+
+exec "$JAVACMD" "$@"
diff --git a/Examples/OneSignalDemoV2/gradlew.bat b/Examples/OneSignalDemoV2/gradlew.bat
new file mode 100644
index 0000000000..107acd32c4
--- /dev/null
+++ b/Examples/OneSignalDemoV2/gradlew.bat
@@ -0,0 +1,89 @@
+@rem
+@rem Copyright 2015 the original author or authors.
+@rem
+@rem Licensed under the Apache License, Version 2.0 (the "License");
+@rem you may not use this file except in compliance with the License.
+@rem You may obtain a copy of the License at
+@rem
+@rem https://www.apache.org/licenses/LICENSE-2.0
+@rem
+@rem Unless required by applicable law or agreed to in writing, software
+@rem distributed under the License is distributed on an "AS IS" BASIS,
+@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+@rem See the License for the specific language governing permissions and
+@rem limitations under the License.
+@rem
+
+@if "%DEBUG%" == "" @echo off
+@rem ##########################################################################
+@rem
+@rem Gradle startup script for Windows
+@rem
+@rem ##########################################################################
+
+@rem Set local scope for the variables with windows NT shell
+if "%OS%"=="Windows_NT" setlocal
+
+set DIRNAME=%~dp0
+if "%DIRNAME%" == "" set DIRNAME=.
+set APP_BASE_NAME=%~n0
+set APP_HOME=%DIRNAME%
+
+@rem Resolve any "." and ".." in APP_HOME to make it shorter.
+for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
+
+@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
+set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
+
+@rem Find java.exe
+if defined JAVA_HOME goto findJavaFromJavaHome
+
+set JAVA_EXE=java.exe
+%JAVA_EXE% -version >NUL 2>&1
+if "%ERRORLEVEL%" == "0" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:findJavaFromJavaHome
+set JAVA_HOME=%JAVA_HOME:"=%
+set JAVA_EXE=%JAVA_HOME%/bin/java.exe
+
+if exist "%JAVA_EXE%" goto execute
+
+echo.
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
+echo.
+echo Please set the JAVA_HOME variable in your environment to match the
+echo location of your Java installation.
+
+goto fail
+
+:execute
+@rem Setup the command line
+
+set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
+
+
+@rem Execute Gradle
+"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
+
+:end
+@rem End local scope for the variables with windows NT shell
+if "%ERRORLEVEL%"=="0" goto mainEnd
+
+:fail
+rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
+rem the _cmd.exe /c_ return code!
+if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
+exit /b 1
+
+:mainEnd
+if "%OS%"=="Windows_NT" endlocal
+
+:omega
diff --git a/Examples/OneSignalDemoV2/settings.gradle.kts b/Examples/OneSignalDemoV2/settings.gradle.kts
new file mode 100644
index 0000000000..11dfb5ad57
--- /dev/null
+++ b/Examples/OneSignalDemoV2/settings.gradle.kts
@@ -0,0 +1,2 @@
+rootProject.name = "OneSignalDemoV2"
+include(":app")