diff --git a/demo-app/build.gradle b/demo-app/build.gradle
index c569e864..6fce698d 100644
--- a/demo-app/build.gradle
+++ b/demo-app/build.gradle
@@ -3,6 +3,13 @@ plugins {
id 'org.jetbrains.kotlin.android'
}
+// Process google-services.json (untracked, local-only) so Firebase/FCM is configured
+// and the push token can be fetched. Applied conditionally to keep the build green
+// in CI or for contributors who don't have the file.
+if (file('google-services.json').exists()) {
+ apply plugin: 'com.google.gms.google-services'
+}
+
android {
namespace 'com.personalization.demo'
compileSdkVersion 34
diff --git a/demo-app/src/androidTest/java/com/personalization/demo/CatalogReadIntegrationTest.kt b/demo-app/src/androidTest/java/com/personalization/demo/CatalogReadIntegrationTest.kt
new file mode 100644
index 00000000..2c2960dc
--- /dev/null
+++ b/demo-app/src/androidTest/java/com/personalization/demo/CatalogReadIntegrationTest.kt
@@ -0,0 +1,50 @@
+package com.personalization.demo
+
+import androidx.test.espresso.Espresso.onView
+import androidx.test.espresso.action.ViewActions.click
+import androidx.test.espresso.action.ViewActions.scrollTo
+import androidx.test.espresso.matcher.ViewMatchers.withId
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+/**
+ * On-device E2E for the catalog/profile read demo buttons. Launches [MainActivity]
+ * (real production Firebase + SDK init) and taps the buttons, exercising
+ * `profileManager.getProfile`, `productsManager.getProductCounters` and
+ * `categoryManager.getCategory` against the live REES46 API on the emulator.
+ *
+ * Mirrors [LoyaltyIntegrationTest]: asserts the real on-device code path runs without crashing.
+ */
+@RunWith(AndroidJUnit4::class)
+class CatalogReadIntegrationTest {
+
+ @get:Rule
+ val activityRule = ActivityScenarioRule(MainActivity::class.java)
+
+ @Test
+ fun getProfile_tapButton_noCrash() {
+ onView(withId(R.id.btnGetProfile)).perform(scrollTo(), click())
+ Thread.sleep(3000)
+ }
+
+ @Test
+ fun getProductCounters_tapButton_noCrash() {
+ onView(withId(R.id.btnGetProductCounters)).perform(scrollTo(), click())
+ Thread.sleep(3000)
+ }
+
+ @Test
+ fun getCategory_tapButton_noCrash() {
+ onView(withId(R.id.btnGetCategory)).perform(scrollTo(), click())
+ Thread.sleep(3000)
+ }
+
+ @Test
+ fun getCollection_tapButton_noCrash() {
+ onView(withId(R.id.btnGetCollection)).perform(scrollTo(), click())
+ Thread.sleep(3000)
+ }
+}
diff --git a/demo-app/src/main/AndroidManifest.xml b/demo-app/src/main/AndroidManifest.xml
index a8abb8a0..d383d9b4 100644
--- a/demo-app/src/main/AndroidManifest.xml
+++ b/demo-app/src/main/AndroidManifest.xml
@@ -3,6 +3,8 @@
xmlns:tools="http://schemas.android.com/tools">
+
+
+ Thread { showPushNotification(data) }.start()
+ }
+ }
+
+ private fun createPushChannel() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
+ // IMPORTANCE_HIGH so the push appears as a heads-up pop-up with sound.
+ val channel = NotificationChannel(
+ PUSH_CHANNEL_ID,
+ getString(R.string.push_channel_name),
+ NotificationManager.IMPORTANCE_HIGH,
+ )
+ ContextCompat.getSystemService(this, NotificationManager::class.java)
+ ?.createNotificationChannel(channel)
+ }
+ }
+
+ /**
+ * Builds and posts a standard BigPicture notification from the SDK push data — the native
+ * equivalent of the React Native demo's notifee BIGPICTURE notification.
+ */
+ private fun showPushNotification(data: NotificationData) {
+ val bigPicture = data.image?.split(",")?.firstOrNull()?.trim()?.let(::loadBitmap)
+ val largeIcon = data.icon?.trim()?.takeIf { it.isNotEmpty() }?.let(::loadBitmap)
+
+ val builder = NotificationCompat.Builder(this, PUSH_CHANNEL_ID)
+ .setSmallIcon(com.personalization.R.drawable.ic_notification_logo)
+ .setContentTitle(data.title)
+ .setContentText(data.body)
+ .setAutoCancel(true)
+ .setPriority(NotificationCompat.PRIORITY_HIGH)
+ .setDefaults(NotificationCompat.DEFAULT_ALL)
+ .setContentIntent(buildContentIntent(data))
+
+ if (largeIcon != null) builder.setLargeIcon(largeIcon)
+ if (bigPicture != null) {
+ builder.setStyle(
+ NotificationCompat.BigPictureStyle()
+ .bigPicture(bigPicture)
+ .bigLargeIcon(null as Bitmap?),
+ )
+ }
+
+ ContextCompat.getSystemService(this, NotificationManager::class.java)
+ ?.notify((data.title.orEmpty() + data.body.orEmpty()).hashCode(), builder.build())
+ }
+
+ /** Tapping opens MainActivity, carrying the push type/id so it can report the click to the SDK. */
+ private fun buildContentIntent(data: NotificationData): PendingIntent {
+ val intent = Intent(this, MainActivity::class.java).apply {
+ putExtra("NOTIFICATION_TYPE", data.type)
+ putExtra("NOTIFICATION_ID", data.id)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or
+ Intent.FLAG_ACTIVITY_CLEAR_TOP or
+ Intent.FLAG_ACTIVITY_SINGLE_TOP
+ }
+ return PendingIntent.getActivity(
+ this,
+ (data.id ?: (data.title.orEmpty() + data.body.orEmpty())).hashCode(),
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ private fun loadBitmap(url: String): Bitmap? = try {
+ URL(url).openStream().use { BitmapFactory.decodeStream(it) }
+ } catch (e: Exception) {
+ e.printStackTrace()
+ null
+ }
+
+ private fun installCrashHandler() {
+ val previous = Thread.getDefaultUncaughtExceptionHandler()
+ Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
+ try {
+ val stack = StringWriter().also { throwable.printStackTrace(PrintWriter(it)) }
+ val text = "Thread: ${thread.name}\n\n$stack"
+ // commit() is synchronous — it must persist before the process is killed.
+ getSharedPreferences(CRASH_PREFS, Context.MODE_PRIVATE)
+ .edit()
+ .putString(KEY_LAST_CRASH, text)
+ .commit()
+ } catch (_: Throwable) {
+ // Never let the crash handler itself throw.
+ }
+ // Let the default handler run so the OS still reports/kills as usual.
+ previous?.uncaughtException(thread, throwable)
+ }
+ }
+
+ companion object {
+ const val CRASH_PREFS = "demo_crash"
+ const val KEY_LAST_CRASH = "last_crash"
+ const val PUSH_CHANNEL_ID = "demo_push"
+ }
+}
diff --git a/demo-app/src/main/java/com/personalization/demo/MainActivity.kt b/demo-app/src/main/java/com/personalization/demo/MainActivity.kt
index 9131b62c..d6c2c7e2 100644
--- a/demo-app/src/main/java/com/personalization/demo/MainActivity.kt
+++ b/demo-app/src/main/java/com/personalization/demo/MainActivity.kt
@@ -1,10 +1,22 @@
package com.personalization.demo
+import android.Manifest
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.os.Build
import android.os.Bundle
import android.widget.Button
+import android.widget.TextView
import android.widget.Toast
+import androidx.appcompat.app.AlertDialog
import androidx.appcompat.app.AppCompatActivity
+import androidx.core.app.ActivityCompat
+import androidx.core.content.ContextCompat
import com.google.firebase.FirebaseApp
+import com.google.firebase.messaging.FirebaseMessaging
import com.personalization.Params
import com.personalization.Params.TrackEvent
import com.personalization.SDK
@@ -23,6 +35,7 @@ import com.personalization.sdk.data.models.dto.popUp.Position
class MainActivity : AppCompatActivity() {
private lateinit var sdk: SDK
+ private var pushToken: String? = null
private object DemoTrackEventConstants {
/** Same value as SDK client-side validation errors for custom field key collisions. */
@@ -53,6 +66,12 @@ class MainActivity : AppCompatActivity() {
const val LAST_NAME = "User"
}
+ private object DemoCatalogConstants {
+ const val ITEM_ID = "300275"
+ const val CATEGORY_SLUG = "smartfony-i-gadzhety"
+ const val COLLECTION_ID = "1"
+ }
+
private object DemoProductViewConstants {
const val PRODUCT_ID = "demo-product-view-001"
const val DEMO_PRICE = 2499.99
@@ -82,19 +101,10 @@ class MainActivity : AppCompatActivity() {
e.printStackTrace()
}
- // Initialize SDK
- try {
- sdk = SDK()
- sdk.initialize(
- context = this,
- shopId = BuildConfig.SHOP_ID,
- apiDomain = "api.rees46.ru",
- autoSendPushToken = false
- )
- } catch (e: Exception) {
- e.printStackTrace()
- // Continue even if SDK initialization fails for demo purposes
- }
+ // The SDK singleton (SDK.instance) is initialized in DemoApplication.onCreate so it is
+ // ready in every process — including the cold process FCM starts to deliver a push.
+ // Reuse that same initialized instance here (do NOT create a new SDK()).
+ sdk = SDK.instance
// Initialize fragment manager for popups
sdk.inAppNotificationManager.initFragmentManager(supportFragmentManager)
@@ -152,6 +162,218 @@ class MainActivity : AppCompatActivity() {
findViewById