diff --git a/README.md b/README.md index c2fd0bd..d187394 100644 --- a/README.md +++ b/README.md @@ -1 +1,82 @@ -# android-test-apps \ No newline at end of file +# android-test-apps + +## Setup + +### Prerequisites +- Android Studio +- JDK 17+ +- Firebase CLI (`brew install firebase-cli`) + +### local.properties + +This file is git-ignored and must be created manually at the project root. It holds all secrets and local paths: + +```properties +sdk.dir=/path/to/your/Android/sdk + +# Movable Ink +MOVABLE_INK_SDK_API_KEY=your_value + +# Salesforce Marketing Cloud +MC_ACCESS_TOKEN=your_value +MC_APP_ID=your_value +FCM_SENDER_ID=your_value +MARKETING_CLOUD_URL=your_value + +# Release signing +STORE_FILE=movableink-release.jks +STORE_PASSWORD=your_password +KEY_ALIAS=movableink +KEY_PASSWORD=your_password +``` + +--- + +## Firebase App Distribution Release + +### First-time setup + +**1. Generate a keystore** (only needed once): +```bash +keytool -genkeypair -v \ + -keystore app/movableink-release.jks \ + -alias movableink \ + -keyalg RSA \ + -keysize 2048 \ + -validity 10000 \ + -storepass YOUR_PASSWORD \ + -keypass YOUR_PASSWORD \ + -dname "CN=Movable Ink, OU=Engineering, O=Movable Ink, L=New York, ST=NY, C=US" +``` + +Add the credentials to `local.properties` as shown above. Never commit the `.jks` file or `local.properties`. + +**2. Get a Firebase CI token** (only needed once): +```bash +firebase login:ci +``` + +Sign in with your Google account in the browser. Copy the token printed in the terminal and export it: +```bash +export FIREBASE_TOKEN=your_token_here +``` + +You can add this to your shell profile (`~/.zshrc` or `~/.bashrc`) to avoid setting it each time. + +--- + +### Releasing a build + +**1. Update the release notes** at `app/release-notes.txt` with what's changed. + +**2. Update `versionCode`** in `gradle.properties` (increment by 1 each release): +```properties +versionCode=5 +``` + +**3. Build and upload** from the project root: +```bash +./gradlew assembleRelease appDistributionUploadRelease +``` + +The APK will be built, signed, and uploaded to Firebase App Distribution automatically. Testers with access via the distribution link will be notified. diff --git a/app/android.gradle b/app/android.gradle index 474fb02..f3a86cc 100644 --- a/app/android.gradle +++ b/app/android.gradle @@ -7,12 +7,28 @@ android { composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } + + signingConfigs { + release { + storeFile file(localProperties['STORE_FILE']) + storePassword localProperties['STORE_PASSWORD'] + keyAlias localProperties['KEY_ALIAS'] + keyPassword localProperties['KEY_PASSWORD'] + } + } + buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + signingConfig signingConfigs.release + firebaseAppDistribution { + artifactType = "APK" + releaseNotesFile = "app/release-notes.txt" + groups = "Mobile" + } } - debug{ + debug { minifyEnabled false } } @@ -24,11 +40,12 @@ android { versionName findProperty("versionName") testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" manifestPlaceholders ["MOVABLE_INK_SDK_API_KEY"] = localProperties['MOVABLE_INK_SDK_API_KEY'] + resValue "string", "accessToken", localProperties['MC_ACCESS_TOKEN'] + resValue "string", "mc_appId", localProperties['MC_APP_ID'] + resValue "string", "mc_mid", localProperties['MC_MID'] + resValue "string", "fcm_sender_id", localProperties['FCM_SENDER_ID'] + resValue "string", "marketing_cloud_url", localProperties['MARKETING_CLOUD_URL'] vectorDrawables.useSupportLibrary = true - - - - } buildFeatures { compose true @@ -42,7 +59,7 @@ android { } } compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 } -} \ No newline at end of file +} diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d363333..31e130a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,23 +1,24 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") kotlin("android") id("org.jetbrains.kotlin.plugin.compose") id("com.google.gms.google-services") + id("com.google.firebase.appdistribution") } apply(from = "android.gradle") tasks.withType().configureEach { - kotlinOptions.jvmTarget = "1.8" + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) } -@Suppress("DSL_SCOPE_VIOLATION") dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.auth) implementation(libs.firebase.analytics) - implementation(libs.firebase.core) + implementation(libs.firebase.analytics) implementation(libs.firebase.messaging) implementation(libs.kotlin.stdlib.jdk7) @@ -34,13 +35,13 @@ dependencies { implementation(libs.activity.compose) implementation(libs.navigation.compose) - +// implementation(libs.play.services) // Browser implementation(libs.androidx.browser) - // AppsFlyer - implementation(libs.appsflyer) - implementation(libs.installreferrer) + // SFMC + implementation(libs.salesforce.mc.sdk) + // implementation(libs.marketingcloudsdk.v810) // Movable Ink implementation(libs.movableink) diff --git a/app/release-notes.txt b/app/release-notes.txt new file mode 100644 index 0000000..1ad04cb --- /dev/null +++ b/app/release-notes.txt @@ -0,0 +1 @@ +SFMC Integration \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 1f38788..1709923 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -5,6 +5,10 @@ + + + + - - - - - + + - - - - - - - - - - - - - @@ -121,4 +111,4 @@ - \ No newline at end of file + diff --git a/app/src/main/java/com/movableink/app/App.kt b/app/src/main/java/com/movableink/app/App.kt index 83b748a..481bbfd 100644 --- a/app/src/main/java/com/movableink/app/App.kt +++ b/app/src/main/java/com/movableink/app/App.kt @@ -1,29 +1,165 @@ package com.movableink.app +import android.app.Activity import android.app.Application -import android.content.Context -import android.hardware.display.DisplayManager -import android.view.Display.DEFAULT_DISPLAY +import android.app.PendingIntent +import android.content.Intent +import android.net.Uri +import android.util.Log import com.google.firebase.FirebaseApp import com.movableink.inked.MIClient +import com.movableink.inked.inAppMessage.MovableInAppClient +import com.salesforce.marketingcloud.MCLogListener +import com.salesforce.marketingcloud.MarketingCloudConfig +import com.salesforce.marketingcloud.MarketingCloudSdk +import com.salesforce.marketingcloud.events.EventManager +import com.salesforce.marketingcloud.messages.iam.InAppMessage +import com.salesforce.marketingcloud.messages.iam.InAppMessageManager +import com.salesforce.marketingcloud.notifications.NotificationCustomizationOptions +import com.salesforce.marketingcloud.sfmcsdk.InitializationStatus +import com.salesforce.marketingcloud.sfmcsdk.SFMCSdk +import com.salesforce.marketingcloud.sfmcsdk.SFMCSdkModuleConfig +import com.salesforce.marketingcloud.sfmcsdk.components.logging.LogLevel +import com.salesforce.marketingcloud.sfmcsdk.components.logging.LogListener +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.Random private const val LOG_TAG: String = "Application" +private const val PREFS_NAME = "settings_prefs" +private const val KEY_MIU = "mi_u" class App : Application() { + private var currentActivity: Activity? = null + override fun onCreate() { super.onCreate() + registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks { + override fun onActivityResumed(activity: Activity) { currentActivity = activity } + override fun onActivityPaused(activity: Activity) { currentActivity = null } + override fun onActivityCreated(activity: Activity, savedInstanceState: android.os.Bundle?) = Unit + override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: android.os.Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + }) + ensureMIU() MIClient.start() MIClient.registerDeeplinkDomains( listOf("afra.io"), ) - MIClient.appInstallEventEnabled(true) FirebaseApp.initializeApp(this) + setUpSalesForce() + } + + private fun ensureMIU() { + val prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE) + if (prefs.getString(KEY_MIU, null).isNullOrEmpty()) { + val generated = java.util.UUID.randomUUID().toString() + prefs.edit().putString(KEY_MIU, generated).apply() + Log.d(LOG_TAG, "Generated new MIU: $generated") + } } - private fun Context.displayContext(): Context { - val manager = getSystemService(DISPLAY_SERVICE) as DisplayManager - val display = manager.getDisplay(DEFAULT_DISPLAY) - return createDisplayContext(display) + private fun miu(): String? = + getSharedPreferences(PREFS_NAME, MODE_PRIVATE).getString(KEY_MIU, null) + + @Suppress("ktlint:standard:property-naming") + private fun setUpSalesForce() { + val mc_access_token = getString(R.string.accessToken) + val mc_application_id = getString(R.string.mc_appId) + val marketing_cloud_url = getString(R.string.marketing_cloud_url) + val senderId = getString(R.string.fcm_sender_id) + val mid = getString(R.string.mc_mid) + + Log.d(LOG_TAG, "SFMC: starting configuration") + + SFMCSdk.setLogging(LogLevel.DEBUG, LogListener.AndroidLogger()) + MarketingCloudSdk.setLogLevel(MCLogListener.VERBOSE) + MarketingCloudSdk.setLogListener(MCLogListener.AndroidLogListener()) + + val config = SFMCSdkModuleConfig.build { + pushModuleConfig = + MarketingCloudConfig + .builder() + .apply { + setApplicationId(mc_application_id) + setAccessToken(mc_access_token) + setMarketingCloudServerUrl(marketing_cloud_url) + setSenderId(senderId) + setMid(mid) + setAnalyticsEnabled(true) + setNotificationCustomizationOptions( + NotificationCustomizationOptions.create(android.R.drawable.stat_notify_chat), + ) + setUrlHandler { context, url, _ -> + PendingIntent.getActivity( + context, + Random().nextInt(), + Intent(Intent.ACTION_VIEW, Uri.parse(url)), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, + ) + } + }.build(applicationContext) + } + + SFMCSdk.configure( + applicationContext as Application, + config, + { initStatus -> + when (initStatus.status) { + InitializationStatus.SUCCESS -> { + Log.d(LOG_TAG, "SFMC init: SUCCESS") + miu()?.let { miu -> + SFMCSdk.requestSdk { sdk -> + sdk.identity.setProfileId(miu) + Log.d(LOG_TAG, "SFMC profile ID set: $miu") + } + } + MIClient.setMIU(miu() ?: "") + } + InitializationStatus.FAILURE -> { + Log.e(LOG_TAG, "SFMC init: FAILED (status=${initStatus.status})") + } + } + }, + ) + + SFMCSdk.requestSdk { sdk -> + sdk.mp { + it.inAppMessageManager.setInAppMessageListener(object : InAppMessageManager.EventListener { + override fun shouldShowMessage(message: InAppMessage): Boolean { + val text = message.title?.text + if (text != null && text.startsWith("mi_link:")) { + val miLink = text.drop("mi_link:".length) + val activity = currentActivity ?: return@shouldShowMessage true + CoroutineScope(Dispatchers.Main).launch { + MIClient.showInAppBrowser( + activity, + miLink, + listener = object : MovableInAppClient.OnUrlLoadingListener { + override fun onButtonClicked(buttonID: String) { + // User interacted with a link that has a buttonID + } + }, + ) + } + return false + } + return true + } + + override fun didShowMessage(message: InAppMessage) { + Log.d(LOG_TAG, "IAM shown: ${message.id}") + } + + override fun didCloseMessage(message: InAppMessage) { + Log.d(LOG_TAG, "IAM closed: ${message.id}") + } + }) + } + } } } diff --git a/app/src/main/java/com/movableink/app/BrazeListener.kt b/app/src/main/java/com/movableink/app/BrazeListener.kt deleted file mode 100644 index 7e259c0..0000000 --- a/app/src/main/java/com/movableink/app/BrazeListener.kt +++ /dev/null @@ -1,38 +0,0 @@ -/* -package com.movableink.app - -import android.app.Activity -import com.braze.models.inappmessage.IInAppMessage -import com.braze.ui.inappmessage.InAppMessageOperation -import com.braze.ui.inappmessage.listeners.IInAppMessageManagerListener -import com.movableink.inked.MIClient -import com.movableink.inked.inAppMessage.MovableInAppClient - -private const val KEY_MI_LINK = "mi_link" -class BrazeListener(activity: Activity) : IInAppMessageManagerListener { - private val activity = activity - - override fun beforeInAppMessageDisplayed(inAppMessage: IInAppMessage): InAppMessageOperation { - if (inAppMessage.extras.containsKey(KEY_MI_LINK)) { - */ -/* let the MovableInk SDK handle this. - Log the impression to Braze, ask MIClient to show the message, and return .discard to - notify the Braze SDK that we don't want it to show anything.*/ -/* - - val movableLink = inAppMessage.extras[KEY_MI_LINK] as String - MIClient.showInAppBrowser( - activity, - movableLink, - listener = object : MovableInAppClient.OnUrlLoadingListener { - override fun onButtonClicked(value: String) { - // log button clicks to braze - // inAppMessage.logButtonClick(value) - } - }, - ) - } - return InAppMessageOperation.DISCARD - } -} -*/ diff --git a/app/src/main/java/com/movableink/app/DeepLinkActivity.kt b/app/src/main/java/com/movableink/app/DeepLinkActivity.kt index 4faf70d..3df9354 100644 --- a/app/src/main/java/com/movableink/app/DeepLinkActivity.kt +++ b/app/src/main/java/com/movableink/app/DeepLinkActivity.kt @@ -25,6 +25,7 @@ class DeepLinkActivity : ComponentActivity() { } } } + override fun onNewIntent(intent: Intent?) { super.onNewIntent(intent) handleIntent(intent) @@ -33,26 +34,22 @@ class DeepLinkActivity : ComponentActivity() { public override fun onResume() { super.onResume() } + public override fun onPause() { super.onPause() } private fun fetchClickableLink(uri: String) { - /* MIClient.resolveUrlAsync(uri) { resolvedLink -> - resolvedLink?.let { - deepLinkToProductPage(it) - } - }*/ - when (urlScheme(uri)) { Scheme.INTERNAL -> { deepLinkToProductPage(uri, this@DeepLinkActivity, Scheme.INTERNAL) } Scheme.GLOBAL -> { lifecycleScope.launch { - val resolvedLink = withContext(Dispatchers.IO) { - MIClient.resolveUrl(uri) - } + val resolvedLink = + withContext(Dispatchers.IO) { + MIClient.resolveUrl(uri) + } resolvedLink?.let { deepLinkToProductPage(it, this@DeepLinkActivity) } diff --git a/app/src/main/java/com/movableink/app/MainActivity.kt b/app/src/main/java/com/movableink/app/MainActivity.kt index 202d08e..f263bfb 100644 --- a/app/src/main/java/com/movableink/app/MainActivity.kt +++ b/app/src/main/java/com/movableink/app/MainActivity.kt @@ -7,6 +7,7 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.util.Log +import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts @@ -14,8 +15,10 @@ import androidx.core.content.ContextCompat import com.google.android.gms.tasks.OnCompleteListener import com.google.firebase.messaging.FirebaseMessaging import com.movableink.inked.MIClient +import com.salesforce.marketingcloud.events.EventManager +import com.salesforce.marketingcloud.sfmcsdk.SFMCSdk -private const val TAG = "MainActivity " +private const val TAG = "MainActivity" class MainActivity : ComponentActivity() { private val requestPermissionLauncher = @@ -23,8 +26,8 @@ class MainActivity : ComponentActivity() { ActivityResultContracts.RequestPermission(), ) { isGranted: Boolean -> if (isGranted) { - // FCM SDK (and your app) can post notifications. - } else { + // User just granted notification permission — notify SFMC to enable push + enableSFMCPush() } } @@ -32,10 +35,21 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) askNotificationPermission() getFCMToken() + fetchClickableLink() checkIntentExtras() setContent { ShoppingCartApp() } + + EventManager.customEvent("display_message", mapOf())?.track() + } + + private fun enableSFMCPush() { + SFMCSdk.requestSdk { sdk -> + sdk.mp { + it.pushMessageManager.enablePush() + } + } } private fun checkIntentExtras() { @@ -45,7 +59,6 @@ class MainActivity : ComponentActivity() { Log.d(TAG, "Intent Extra - Key: $key, Value: $value") } } - MIClient.handlePushNotificationOpened(bundle) } } @@ -62,47 +75,51 @@ class MainActivity : ComponentActivity() { Log.w(TAG, "Fetching FCM registration token failed", task.exception) return@OnCompleteListener } - - // Get new FCM registration token val token = task.result - - // Log and toast Log.d(TAG, "FCM Token: $token") + + val miu = getSharedPreferences("settings_prefs", MODE_PRIVATE) + .getString("mi_u", null) + SFMCSdk.requestSdk { sdk -> + if (!miu.isNullOrEmpty()) { + sdk.identity.setProfileId(miu) + } +// sdk.mp { +// it.pushMessageManager.setPushToken(token) +// } + } }) } private fun askNotificationPermission() { - // This is only necessary for API level >= 33 (TIRAMISU) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == PackageManager.PERMISSION_GRANTED ) { Log.d(TAG, "askNotificationPermission: granted") + enableSFMCPush() } else if (shouldShowRequestPermissionRationale(Manifest.permission.POST_NOTIFICATIONS)) { + // Could show rationale UI here if needed } else { - // Directly ask for the permission requestPermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS) } + } else { + // Below Android 13, no runtime permission needed — enable push directly + enableSFMCPush() } } - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - } - - private fun fetchClickableLink() { MIClient.checkPasteboardOnInstall { resolvedLink -> try { resolvedLink?.let { val uri = Uri.parse(it) if (uri != null) { - // Check if your app can handle this URI val intent = Intent(Intent.ACTION_VIEW, uri) if (intent.resolveActivity(packageManager) != null) { startActivity(intent) } else { - Log.d(TAG, "Cannot open the link") + Toast.makeText(this, "Cannot open the link", Toast.LENGTH_SHORT).show() } } } diff --git a/app/src/main/java/com/movableink/app/MyFirebaseMessagingService.kt b/app/src/main/java/com/movableink/app/MyFirebaseMessagingService.kt index 62d1ff0..4508283 100644 --- a/app/src/main/java/com/movableink/app/MyFirebaseMessagingService.kt +++ b/app/src/main/java/com/movableink/app/MyFirebaseMessagingService.kt @@ -10,6 +10,9 @@ import android.util.Log import androidx.core.app.NotificationCompat import com.google.firebase.messaging.FirebaseMessagingService import com.google.firebase.messaging.RemoteMessage +import com.salesforce.marketingcloud.MarketingCloudSdk +import com.salesforce.marketingcloud.messages.push.PushMessageManager +import com.salesforce.marketingcloud.sfmcsdk.SFMCSdk import kotlin.random.Random class MyFirebaseMessagingService : FirebaseMessagingService() { @@ -17,26 +20,35 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { override fun onNewToken(token: String) { super.onNewToken(token) Log.d(TAG, "Refreshed token: $token") - // If you want to send messages to this application instance or - // manage this apps subscriptions on the server side, send the - // FCM registration token to your app server. + val miu = getSharedPreferences("settings_prefs", MODE_PRIVATE) + .getString("mi_u", null) + SFMCSdk.requestSdk { sdk -> + if (!miu.isNullOrEmpty()) { + sdk.identity.setProfileId(miu) + } +// sdk.mp { +// it.pushMessageManager.setPushToken(token) +// } + } } override fun onMessageReceived(remoteMessage: RemoteMessage) { super.onMessageReceived(remoteMessage) - - Log.d(TAG, "From: ${remoteMessage.from}") - // Check if message contains a data payload. - if (remoteMessage.data.isNotEmpty()) { - Log.d(TAG, "Message data payload: ${remoteMessage.data}") - } + Log.d(TAG, "From: ${remoteMessage.from}") - // Check if message contains a notification payload. - remoteMessage.notification?.let { - Log.d(TAG, "Message Notification Body: ${it.body}") - // Handle foreground notification - showNotification(it.title, it.body, remoteMessage.data) + if (PushMessageManager.isMarketingCloudPush(remoteMessage)) { + SFMCSdk.requestSdk { sdk -> + sdk.mp { + it.pushMessageManager.handleMessage(remoteMessage) + } + } + } else { + // Not from Marketing Cloud Engagement. Must handle ourselves. + remoteMessage.notification?.let { + Log.d(TAG, "Message Notification Body: ${it.body}") + showNotification(it.title, it.body, remoteMessage.data) + } } } @@ -44,20 +56,19 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { val intent = Intent(this, MainActivity::class.java) intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP) - // Pass the data payload to the intent for ((key, value) in data) { intent.putExtra(key, value) } - + val pendingIntent = PendingIntent.getActivity( this, 0, intent, - PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE + PendingIntent.FLAG_ONE_SHOT or PendingIntent.FLAG_IMMUTABLE, ) val channelId = "Default" val defaultSoundUri = android.provider.Settings.System.DEFAULT_NOTIFICATION_URI val notificationBuilder = NotificationCompat.Builder(this, channelId) - .setSmallIcon(R.mipmap.ic_launcher) // utilizing default icon + .setSmallIcon(R.mipmap.ic_launcher) .setContentTitle(title) .setContentText(messageBody) .setAutoCancel(true) @@ -66,12 +77,11 @@ class MyFirebaseMessagingService : FirebaseMessagingService() { val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - // Since android Oreo notification channel is needed. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val channel = NotificationChannel( channelId, "Default Channel", - NotificationManager.IMPORTANCE_DEFAULT + NotificationManager.IMPORTANCE_DEFAULT, ) notificationManager.createNotificationChannel(channel) } diff --git a/app/src/main/java/com/movableink/app/salesforce/FullScreenWebViewActivity.kt b/app/src/main/java/com/movableink/app/salesforce/FullScreenWebViewActivity.kt new file mode 100644 index 0000000..275c82a --- /dev/null +++ b/app/src/main/java/com/movableink/app/salesforce/FullScreenWebViewActivity.kt @@ -0,0 +1,85 @@ +package com.movableink.app.salesforce + +import android.annotation.SuppressLint +import android.os.Bundle +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView + +class FullScreenWebViewActivity : ComponentActivity() { + companion object { + const val EXTRA_URL = "extra_url" + private const val USE_LEGACY_WEBVIEW = false + } + + private lateinit var webView: WebView + + @SuppressLint("SetJavaScriptEnabled") + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val url = intent.getStringExtra(EXTRA_URL) + if (url.isNullOrEmpty()) { + finish() + return + } + + if (USE_LEGACY_WEBVIEW) { + setupLegacyWebView(url) + } else { + setupComposeWebView(url) + } + } + + @SuppressLint("SetJavaScriptEnabled") + private fun setupLegacyWebView(url: String) { + webView = + WebView(this).apply { + settings.apply { + javaScriptEnabled = true + domStorageEnabled = true + setSupportZoom(true) + builtInZoomControls = true + displayZoomControls = false + } + webViewClient = WebViewClient() + loadUrl(url) + } + setContentView(webView) + } + + private fun setupComposeWebView(url: String) { + setContent { + FullScreenWebView(url = url) + } + } + + override fun onBackPressed() { + if (::webView.isInitialized && USE_LEGACY_WEBVIEW && webView.canGoBack()) { + webView.goBack() + } else { + super.onBackPressed() + } + } +} + +@Suppress("ktlint:standard:function-naming") +@SuppressLint("SetJavaScriptEnabled") +@Composable +private fun FullScreenWebView(url: String) { + AndroidView( + modifier = Modifier.fillMaxSize(), + factory = { context -> + WebView(context).apply { + settings.javaScriptEnabled = true + webViewClient = WebViewClient() + loadUrl(url) + } + }, + ) +} diff --git a/app/src/main/java/com/movableink/app/salesforce/WebViewUtility.kt b/app/src/main/java/com/movableink/app/salesforce/WebViewUtility.kt new file mode 100644 index 0000000..b0e3345 --- /dev/null +++ b/app/src/main/java/com/movableink/app/salesforce/WebViewUtility.kt @@ -0,0 +1,17 @@ +package com.movableink.app.salesforce + +import android.content.Context +import android.content.Intent + +object WebViewUtility { + fun openUrlInFullScreenWebView( + context: Context, + url: String, + ) { + val intent = + Intent(context, FullScreenWebViewActivity::class.java).apply { + putExtra(FullScreenWebViewActivity.EXTRA_URL, url) + } + context.startActivity(intent) + } +} diff --git a/app/src/main/java/com/movableink/app/ui/component/MovableTopBar.kt b/app/src/main/java/com/movableink/app/ui/component/MovableTopBar.kt index af248d2..b2c47a7 100644 --- a/app/src/main/java/com/movableink/app/ui/component/MovableTopBar.kt +++ b/app/src/main/java/com/movableink/app/ui/component/MovableTopBar.kt @@ -1,6 +1,9 @@ package com.movableink.app.ui.component import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.material.MaterialTheme import androidx.compose.material.Text @@ -13,25 +16,34 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +@Suppress("ktlint:standard:function-naming") @Composable -fun MovableTopBar(modifier: Modifier = Modifier, title: String) { +fun MovableTopBar( + modifier: Modifier = Modifier, + title: String, + actions: @Composable RowScope.() -> Unit = {}, +) { Column(modifier = modifier.statusBarsPadding()) { TopAppBar( backgroundColor = MaterialTheme.colors.primaryVariant, contentColor = Color.Black, elevation = 0.dp, ) { - Text( - text = title, - style = MaterialTheme.typography.h4, - color = Color.White, - textAlign = TextAlign.Center, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically), - ) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = title, + style = MaterialTheme.typography.h4, + color = Color.White, + textAlign = TextAlign.Start, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + actions() + } } } } diff --git a/app/src/main/java/com/movableink/app/ui/screens/home/HomeScreen.kt b/app/src/main/java/com/movableink/app/ui/screens/home/HomeScreen.kt index 1032493..bba0842 100644 --- a/app/src/main/java/com/movableink/app/ui/screens/home/HomeScreen.kt +++ b/app/src/main/java/com/movableink/app/ui/screens/home/HomeScreen.kt @@ -19,13 +19,18 @@ import androidx.compose.foundation.layout.windowInsetsTopHeight import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.Icon +import androidx.compose.material.IconButton import androidx.compose.material.Surface import androidx.compose.material.Text +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -33,20 +38,32 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.movableink.app.R import com.movableink.app.ui.component.MovableTopBar +import com.movableink.app.ui.screens.settings.SettingsBottomSheet @Composable fun HomeScreen( onGenderSelected: (String) -> Unit, homeViewModel: HomeViewModel, ) { - Surface( - modifier = - Modifier - .fillMaxSize(), - ) { - Box { - GenderList(onGenderSelected, homeViewModel) - MovableTopBar(title = stringResource(id = R.string.app_name)) + SettingsBottomSheet { showSettings -> + Surface( + modifier = Modifier.fillMaxSize(), + ) { + Box { + GenderList(onGenderSelected, homeViewModel) + MovableTopBar( + title = stringResource(id = R.string.app_name), + actions = { + IconButton(onClick = showSettings) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(R.string.settings), + tint = Color.White, + ) + } + }, + ) + } } } } diff --git a/app/src/main/java/com/movableink/app/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/movableink/app/ui/screens/settings/SettingsScreen.kt new file mode 100644 index 0000000..8cea971 --- /dev/null +++ b/app/src/main/java/com/movableink/app/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,243 @@ +@file:Suppress("ktlint:standard:function-naming") + +package com.movableink.app.ui.screens.settings + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.widget.Toast +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material.Divider +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.material.MaterialTheme +import androidx.compose.material.ModalBottomSheetLayout +import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material.OutlinedTextField +import androidx.compose.material.Text +import androidx.compose.material.rememberModalBottomSheetState +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.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.google.firebase.messaging.FirebaseMessaging +import com.movableink.app.R +import com.movableink.inked.MIClient +import com.salesforce.marketingcloud.MarketingCloudSdk +import com.salesforce.marketingcloud.sfmcsdk.SFMCSdk +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.tasks.await +import java.util.UUID + +private const val PREFS_NAME = "settings_prefs" +private const val KEY_MIU = "mi_u" +private const val DEBOUNCE_MS = 500L + +@OptIn(ExperimentalMaterialApi::class) +@Composable +fun SettingsBottomSheet( + content: @Composable (() -> Unit) -> Unit, +) { + val sheetState = rememberModalBottomSheetState( + initialValue = ModalBottomSheetValue.Hidden, + skipHalfExpanded = true, + ) + val scope = rememberCoroutineScope() + + val showSheet: () -> Unit = { scope.launch { sheetState.show() } } + val hideSheet: () -> Unit = { scope.launch { sheetState.hide() } } + + ModalBottomSheetLayout( + sheetState = sheetState, + sheetContent = { + SettingsScreen(onDismiss = hideSheet) + }, + ) { + content(showSheet) + } +} + +@Composable +fun SettingsScreen(onDismiss: () -> Unit) { + val context = LocalContext.current + val prefs = remember { context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) } + + var fcmToken by remember { mutableStateOf(null) } + var contactKey by remember { mutableStateOf(null) } + var deviceId by remember { mutableStateOf(null) } + var pushToken by remember { mutableStateOf(null) } + + // Load persisted MIU or generate + persist a UUID on first boot + var miu by remember { + val saved = prefs.getString(KEY_MIU, null) + val initial = if (saved.isNullOrEmpty()) { + val generated = UUID.randomUUID().toString() + prefs.edit().putString(KEY_MIU, generated).apply() + generated + } else { + saved + } + mutableStateOf(initial) + } + + LaunchedEffect(Unit) { + fcmToken = try { + FirebaseMessaging.getInstance().token.await() + } catch (e: Exception) { + null + } + + MarketingCloudSdk.requestSdk { sdk -> + contactKey = sdk.registrationManager.contactKey + deviceId = sdk.registrationManager.deviceId + pushToken = sdk.pushMessageManager.pushToken + } + } + + // Debounced: fires 500ms after miu stops changing + LaunchedEffect(miu) { + val currentMiu = miu + delay(DEBOUNCE_MS) + prefs.edit().putString(KEY_MIU, currentMiu).apply() + MIClient.setMIU(currentMiu) + SFMCSdk.requestSdk { sfmcSdk -> + sfmcSdk.identity.setProfileId(currentMiu) + } + } + + LazyColumn(modifier = Modifier.padding(bottom = 32.dp)) { + + // --- User section --- + item { + SettingsSectionHeader(title = stringResource(R.string.settings_user_header)) + } + item { + OutlinedTextField( + value = miu, + onValueChange = { miu = it }, + label = { Text(stringResource(R.string.settings_miu_placeholder)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp), + ) + Text( + text = stringResource(R.string.settings_miu_footer), + style = MaterialTheme.typography.caption, + color = Color.Gray, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp), + ) + } + + // --- FCM section --- + item { + SettingsSectionHeader(title = stringResource(R.string.settings_fcm_token_header)) + } + item { + SettingsRow( + label = stringResource(R.string.settings_push_token_label), + value = fcmToken ?: stringResource(R.string.settings_none), + copyable = fcmToken != null, + context = context, + ) + } + + // --- SFMC Attributes section --- + item { + SettingsSectionHeader(title = stringResource(R.string.settings_sfmc_header)) + } + item { + SettingsRow( + label = stringResource(R.string.settings_contact_key_label), + value = contactKey ?: "-", + copyable = contactKey != null, + context = context, + ) + } + item { + SettingsRow( + label = stringResource(R.string.settings_device_id_label), + value = deviceId ?: "-", + copyable = deviceId != null, + context = context, + ) + } + item { + SettingsRow( + label = stringResource(R.string.settings_push_token_label), + value = pushToken ?: "-", + copyable = pushToken != null, + context = context, + ) + } + + } +} + +@Composable +private fun SettingsSectionHeader(title: String) { + Text( + text = title.uppercase(), + style = MaterialTheme.typography.caption, + color = Color.Gray, + modifier = Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 6.dp), + ) +} + +@Composable +private fun SettingsRow( + label: String, + value: String, + copyable: Boolean = false, + context: Context? = null, +) { + val modifier = if (copyable && context != null) { + Modifier + .fillMaxWidth() + .clickable { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboard.setPrimaryClip(ClipData.newPlainText(label, value)) + Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() + } + .padding(horizontal = 16.dp, vertical = 12.dp) + } else { + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 12.dp) + } + + Column(modifier = modifier) { + Text( + text = label, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = Color.Gray, + ) + Text( + text = value, + fontSize = 14.sp, + color = MaterialTheme.colors.onSurface, + modifier = Modifier.padding(top = 2.dp), + ) + } + Divider(color = Color.LightGray, thickness = 0.5.dp) +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 488ee79..37009d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,4 +17,15 @@ Sunday Braze Fallback Channel settings - \ No newline at end of file + User + mi_u + The current MIU of the user. Changes are applied to the MI SDK and SFMC automatically. + FCM + SFMC Attributes + Contact Key + Device ID + System Token + Push Enabled + Push Token + None + diff --git a/build.gradle.kts b/build.gradle.kts index ef6bab2..77a7b47 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -13,6 +13,7 @@ buildscript { classpath(libs.kotlin.composePlugin) classpath(libs.spotless.gradlePlugin) classpath(libs.google.services) + classpath("com.google.firebase:firebase-appdistribution-gradle:5.0.0") } } allprojects { @@ -20,6 +21,7 @@ allprojects { google() maven(url = "https://appboy.github.io/appboy-android-sdk/sdk") maven(url = "https://s01.oss.sonatype.org/content/repositories/snapshots/") + maven(url = "https://salesforce-marketingcloud.github.io/MarketingCloudSDK-Android/repository") } } diff --git a/gradle.properties b/gradle.properties index 9455993..f5ddd3c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,8 +11,18 @@ compileSdkVersion=34 applicationId=com.movableink.app targetSdkVersion=34 minSdkVersion=24 -versionCode=4 -versionName=deferred-deeplinking +versionCode=8 +versionName=SFMCv9-1 -resolutionStrategyConfig=true \ No newline at end of file +resolutionStrategyConfig=true +android.defaults.buildfeatures.resvalues=true +android.sdk.defaultTargetSdkToCompileSdkIfUnset=false +android.enableAppCompileTimeRClass=false +android.usesSdkInManifest.disallowed=false +android.uniquePackageNames=false +android.dependency.useConstraints=true +android.r8.strictFullModeForKeepRules=false +android.r8.optimizedResourceShrinking=false +android.builtInKotlin=false +android.newDsl=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6f82948..8269aab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,9 +1,10 @@ [versions] # Build tools and plugins -kotlin = "2.1.0" +kotlin = "2.2.10" +play-services = "17.0.0" spotless = "6.12.1" google-services = "4.3.15" -agp = "8.13.2" +agp = "9.1.0" # AndroidX and Material appcompat = "1.6.1" @@ -22,17 +23,9 @@ lifecycle = "2.7.0" lifecycle-runtime-compose = "2.7.0" # Firebase -firebase-bom = "32.7.0" -firebase-core = "21.1.1" -firebase-messaging = "23.1.2" - -# Other -appsflyer = "6.12.1" -installreferrer = "2.2" -movableink = "1.6.2-SNAPSHOT" -datastore = "1.0.0" -serialization = "1.6.0" -okhttp = "5.3.2" +firebase-bom = "34.11.0" +firebase-analytics = "23.2.0" +firebase-messaging = "25.0.1" # Testing junit = "4.13.2" @@ -43,11 +36,22 @@ espresso = "3.5.1" mockito = "5.8.0" mockito-kotlin = "5.2.1" +# Other +appsflyer = "6.12.1" +installreferrer = "2.2" +movableink = "3.0.0" +datastore = "1.0.0" +serialization = "1.6.0" +okhttp = "5.3.2" +marketingcloudsdk = "9.0.3" + + [libraries] # Build plugins android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", version.ref = "agp" } kotlin-gradlePlugin = { group = "org.jetbrains.kotlin", name = "kotlin-gradle-plugin", version.ref = "kotlin" } kotlin-composePlugin = { group = "org.jetbrains.kotlin", name = "compose-compiler-gradle-plugin", version.ref = "kotlin" } +#play-services = { module = "com.google.android.gms:play-services", version.ref = "play-services" } spotless-gradlePlugin = { group = "com.diffplug.spotless", name = "spotless-plugin-gradle", version.ref = "spotless" } google-services = { group = "com.google.gms", name = "google-services", version.ref = "google-services" } @@ -85,8 +89,7 @@ kotlin-stdlib-jdk7 = { group = "org.jetbrains.kotlin", name = "kotlin-stdlib-jdk # Firebase firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase-bom" } firebase-auth = { group = "com.google.firebase", name = "firebase-auth" } -firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } -firebase-core = { group = "com.google.firebase", name = "firebase-core", version.ref = "firebase-core" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics", version.ref ="firebase-analytics" } firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging", version.ref = "firebase-messaging" } # MovableInk @@ -95,9 +98,9 @@ datastore-preferences = { group = "androidx.datastore", name = "datastore-prefer serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serialization-json", version.ref = "serialization" } okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } -# AppsFlyer -appsflyer = { group = "com.appsflyer", name = "af-android-sdk", version.ref = "appsflyer" } -installreferrer = { group = "com.android.installreferrer", name = "installreferrer", version.ref = "installreferrer" } +#sfmc +salesforce-mc-sdk = { group = "com.salesforce.marketingcloud", name = "marketingcloudsdk", version.ref = "marketingcloudsdk" } + # Testing junit = { group = "junit", name = "junit", version.ref = "junit" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 37f853b..37f78a6 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME