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