From 7edbcc9e5afdd5cf4e1236d2ed1950e38067f293 Mon Sep 17 00:00:00 2001 From: christine Date: Wed, 5 Feb 2025 15:56:38 +0300 Subject: [PATCH 1/8] sc-135665: SFMC IIAM setup code snippet --- app/build.gradle.kts | 9 +- app/src/main/AndroidManifest.xml | 4 + app/src/main/java/com/movableink/app/App.kt | 70 ++++++++++++--- .../java/com/movableink/app/BrazeListener.kt | 38 --------- .../com/movableink/app/DeepLinkActivity.kt | 15 ++-- .../java/com/movableink/app/MainActivity.kt | 41 ++++++--- .../salesforce/FullScreenWebViewActivity.kt | 85 +++++++++++++++++++ .../app/salesforce/WebViewUtility.kt | 17 ++++ .../app/ui/component/MovableTopBar.kt | 13 ++- build.gradle.kts | 1 + gradle/libs.versions.toml | 12 ++- 11 files changed, 222 insertions(+), 83 deletions(-) delete mode 100644 app/src/main/java/com/movableink/app/BrazeListener.kt create mode 100644 app/src/main/java/com/movableink/app/salesforce/FullScreenWebViewActivity.kt create mode 100644 app/src/main/java/com/movableink/app/salesforce/WebViewUtility.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 41feacc..89ea0cc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -33,13 +33,14 @@ 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/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7cb59e3..df580a9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,10 @@ + + + + + PendingIntent.getActivity( + context, + Random().nextInt(), + Intent(Intent.ACTION_VIEW, Uri.parse(url)), + PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + }.build(applicationContext) + }, + ) { initStatus -> + } + + SFMCSdk.requestSdk { sdk -> + sdk.mp { + it.pushMessageManager.enablePush() + } + } } } 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 e764c62..c96ecba 100644 --- a/app/src/main/java/com/movableink/app/MainActivity.kt +++ b/app/src/main/java/com/movableink/app/MainActivity.kt @@ -12,18 +12,22 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import com.movableink.app.salesforce.WebViewUtility import com.movableink.inked.MIClient +import com.salesforce.marketingcloud.MarketingCloudSdk private const val TAG = "MainActivity " class MainActivity : ComponentActivity() { - private val requestPermissionLauncher = registerForActivityResult( - ActivityResultContracts.RequestPermission(), - ) { isGranted: Boolean -> - if (isGranted) { - // FCM SDK (and your app) can post notifications. - } else { } - } + private val requestPermissionLauncher = + registerForActivityResult( + ActivityResultContracts.RequestPermission(), + ) { isGranted: Boolean -> + if (isGranted) { + // FCM SDK (and your app) can post notifications. + } else { + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -31,6 +35,23 @@ class MainActivity : ComponentActivity() { setContent { ShoppingCartApp() } + checkForSFMCMessage() + } + + private fun checkForSFMCMessage() { + MarketingCloudSdk.requestSdk { marketingCloudSdk -> + val inboxMessageManager = marketingCloudSdk.inboxMessageManager + val messages = inboxMessageManager.messages + + if (messages.isNotEmpty()) { + val message = messages[0] + val htmlLink = message.customKeys?.get("mi_link") + htmlLink?.let { + // render page + WebViewUtility.openUrlInFullScreenWebView(this, it) + } + } + } } private fun askNotificationPermission() { @@ -52,11 +73,9 @@ class MainActivity : ComponentActivity() { super.onWindowFocusChanged(hasFocus) fetchClickableLink() } + private fun fetchClickableLink() { - val context = this MIClient.checkPasteboardOnInstall { resolvedLink -> - Log.d("ksk", "haha $resolvedLink") - Toast.makeText(this, " Text From CP: $resolvedLink", Toast.LENGTH_LONG).show() try { resolvedLink?.let { val uri = Uri.parse(it) @@ -64,10 +83,8 @@ class MainActivity : ComponentActivity() { // Check if your app can handle this URI val intent = Intent(Intent.ACTION_VIEW, uri) if (intent.resolveActivity(packageManager) != null) { - Log.d(TAG, "fetchClickableLink: hahahah yeah {${intent.action}}") startActivity(intent) } else { - Log.d(TAG, "fetchClickableLink: hahahah Not") Toast.makeText(this, "Cannot open the link", Toast.LENGTH_SHORT).show() } } 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..6242688 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 @@ -13,8 +13,12 @@ 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, +) { Column(modifier = modifier.statusBarsPadding()) { TopAppBar( backgroundColor = MaterialTheme.colors.primaryVariant, @@ -28,9 +32,10 @@ fun MovableTopBar(modifier: Modifier = Modifier, title: String) { textAlign = TextAlign.Center, maxLines = 1, overflow = TextOverflow.Ellipsis, - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically), + modifier = + Modifier + .weight(1f) + .align(Alignment.CenterVertically), ) } } diff --git a/build.gradle.kts b/build.gradle.kts index 2793e1c..8eafa55 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,6 +19,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/libs.versions.toml b/gradle/libs.versions.toml index 82e429b..723cc4c 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] # Build tools and plugins kotlin = "1.8.22" +play-services = "17.0.0'" spotless = "6.12.1" google-services = "4.3.15" agp = "8.2.0" @@ -40,11 +41,16 @@ mockito-kotlin = "5.2.1" appsflyer = "6.12.1" installreferrer = "2.2" movableink = "1.6.0" +salesforce = "8.0.8" +marketingcloudsdk = "8.1.0" + [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" } +#play-services = { module = "com.google.android.gms:play-services", version.ref = "play-services" } +marketingcloudsdk-v810 = { module = "com.salesforce.marketingcloud:marketingcloudsdk", version.ref = "marketingcloudsdk" } 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" } @@ -89,9 +95,9 @@ firebase-messaging = { group = "com.google.firebase", name = "firebase-messaging # MovableInk movableink = { group = "com.movableink.sdk", name = "inked", version.ref = "movableink" } -# 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 = "salesforce" } + # Testing junit = { group = "junit", name = "junit", version.ref = "junit" } From f056e745e43e31e0c31feb1e8c545f48d2f65467 Mon Sep 17 00:00:00 2001 From: Chayel J Heinsen Date: Mon, 30 Mar 2026 14:20:27 -0400 Subject: [PATCH 2/8] updated sf sdk --- app/android.gradle | 8 ++++++-- app/build.gradle.kts | 2 +- app/src/main/java/com/movableink/app/App.kt | 2 +- .../main/java/com/movableink/app/MainActivity.kt | 2 ++ gradle/libs.versions.toml | 15 +++++---------- 5 files changed, 15 insertions(+), 14 deletions(-) diff --git a/app/android.gradle b/app/android.gradle index 474fb02..c8bf848 100644 --- a/app/android.gradle +++ b/app/android.gradle @@ -24,6 +24,10 @@ 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", "fcm_sender_id", localProperties['FCM_SENDER_ID'] + resValue "string", "marketing_cloud_url", localProperties['MARKETING_CLOUD_URL'] vectorDrawables.useSupportLibrary = true @@ -42,7 +46,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 0f1edea..c5e921e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,7 +10,7 @@ plugins { apply(from = "android.gradle") tasks.withType().configureEach { - kotlinOptions.jvmTarget = "1.8" + kotlinOptions.jvmTarget = "17" } @Suppress("DSL_SCOPE_VIOLATION") dependencies { diff --git a/app/src/main/java/com/movableink/app/App.kt b/app/src/main/java/com/movableink/app/App.kt index 907f6ce..b917ca7 100644 --- a/app/src/main/java/com/movableink/app/App.kt +++ b/app/src/main/java/com/movableink/app/App.kt @@ -4,7 +4,7 @@ import android.app.Application import android.content.Context import android.hardware.display.DisplayManager import android.view.Display.DEFAULT_DISPLAY -import com.appsflyer.AppsFlyerLib +// import com.appsflyer.AppsFlyerLib import android.app.PendingIntent import android.content.Intent import android.net.Uri diff --git a/app/src/main/java/com/movableink/app/MainActivity.kt b/app/src/main/java/com/movableink/app/MainActivity.kt index 43614be..3d4897d 100644 --- a/app/src/main/java/com/movableink/app/MainActivity.kt +++ b/app/src/main/java/com/movableink/app/MainActivity.kt @@ -16,6 +16,7 @@ import com.google.firebase.messaging.FirebaseMessaging import com.movableink.app.salesforce.WebViewUtility import com.movableink.inked.MIClient import com.salesforce.marketingcloud.MarketingCloudSdk +import android.widget.Toast private const val TAG = "MainActivity " @@ -107,6 +108,7 @@ class MainActivity : ComponentActivity() { override fun onWindowFocusChanged(hasFocus: Boolean) { super.onWindowFocusChanged(hasFocus) + fetchClickableLink() } private fun fetchClickableLink() { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f364d64..c2862f7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,14 +27,6 @@ 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" - # Testing junit = "4.13.2" androidx-test = "1.5.0" @@ -47,9 +39,12 @@ mockito-kotlin = "5.2.1" # Other appsflyer = "6.12.1" installreferrer = "2.2" -movableink = "1.6.0" +movableink = "3.0.0" +datastore = "1.0.0" +serialization = "1.6.0" +okhttp = "5.3.2" salesforce = "8.0.8" -marketingcloudsdk = "8.1.0" +marketingcloudsdk = "9.0.3" [libraries] From 1de8b3aba3fe7659fab043517e75649a7467b461 Mon Sep 17 00:00:00 2001 From: Chayel J Heinsen Date: Mon, 30 Mar 2026 15:22:02 -0400 Subject: [PATCH 3/8] Add settings page --- app/build.gradle.kts | 4 +- .../app/ui/component/MovableTopBar.kt | 31 ++- .../app/ui/screens/home/HomeScreen.kt | 33 ++- .../app/ui/screens/settings/SettingsScreen.kt | 231 ++++++++++++++++++ app/src/main/res/values/strings.xml | 11 +- gradle.properties | 12 +- gradle/libs.versions.toml | 4 +- gradle/wrapper/gradle-wrapper.properties | 2 +- 8 files changed, 301 insertions(+), 27 deletions(-) create mode 100644 app/src/main/java/com/movableink/app/ui/screens/settings/SettingsScreen.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index c5e921e..457c39e 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,4 +1,5 @@ import org.jetbrains.kotlin.gradle.tasks.KotlinCompile +import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { id("com.android.application") @@ -10,9 +11,8 @@ plugins { apply(from = "android.gradle") tasks.withType().configureEach { - kotlinOptions.jvmTarget = "17" + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) } -@Suppress("DSL_SCOPE_VIOLATION") dependencies { implementation(platform(libs.firebase.bom)) implementation(libs.firebase.auth) 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 6242688..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 @@ -18,6 +21,7 @@ import androidx.compose.ui.unit.dp fun MovableTopBar( modifier: Modifier = Modifier, title: String, + actions: @Composable RowScope.() -> Unit = {}, ) { Column(modifier = modifier.statusBarsPadding()) { TopAppBar( @@ -25,18 +29,21 @@ fun MovableTopBar( 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..0e17d67 --- /dev/null +++ b/app/src/main/java/com/movableink/app/ui/screens/settings/SettingsScreen.kt @@ -0,0 +1,231 @@ +@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) } + + // 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 + } + } + + // Debounced: fires 500ms after miu stops changing + LaunchedEffect(miu) { + delay(DEBOUNCE_MS) + prefs.edit().putString(KEY_MIU, miu).apply() + MIClient.setMIU(miu) + SFMCSdk.requestSdk { sfmcSdk -> + sfmcSdk.identity.setProfileId(miu) + } + } + + 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 Push Token section --- + item { + SettingsSectionHeader(title = stringResource(R.string.settings_fcm_token_header)) + } + item { + SettingsRow( + label = stringResource(R.string.settings_fcm_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, + ) + } + } +} + +@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..84e98e4 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,4 +17,13 @@ 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 Push Token + Token + SFMC Attributes + Contact Key + Device ID + None + diff --git a/gradle.properties b/gradle.properties index 9455993..4b7edaf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -15,4 +15,14 @@ versionCode=4 versionName=deferred-deeplinking -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 \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index c2862f7..2f2a31e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,10 +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" 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 From 9ea473d0d56697530b1d71872ab913563bfefe26 Mon Sep 17 00:00:00 2001 From: Chayel J Heinsen Date: Tue, 31 Mar 2026 11:59:00 -0400 Subject: [PATCH 4/8] updates from copilot review --- app/build.gradle.kts | 2 +- app/src/main/java/com/movableink/app/App.kt | 4 ---- app/src/main/java/com/movableink/app/MainActivity.kt | 6 +----- gradle/libs.versions.toml | 4 ++-- 4 files changed, 4 insertions(+), 12 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 457c39e..61b2ef2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -41,7 +41,7 @@ dependencies { // SFMC implementation(libs.salesforce.mc.sdk) - implementation(libs.marketingcloudsdk.v810) + // implementation(libs.marketingcloudsdk.v810) // Movable Ink implementation(libs.movableink) diff --git a/app/src/main/java/com/movableink/app/App.kt b/app/src/main/java/com/movableink/app/App.kt index b917ca7..1302929 100644 --- a/app/src/main/java/com/movableink/app/App.kt +++ b/app/src/main/java/com/movableink/app/App.kt @@ -1,10 +1,6 @@ package com.movableink.app import android.app.Application -import android.content.Context -import android.hardware.display.DisplayManager -import android.view.Display.DEFAULT_DISPLAY -// import com.appsflyer.AppsFlyerLib import android.app.PendingIntent import android.content.Intent import android.net.Uri diff --git a/app/src/main/java/com/movableink/app/MainActivity.kt b/app/src/main/java/com/movableink/app/MainActivity.kt index 3d4897d..89eb025 100644 --- a/app/src/main/java/com/movableink/app/MainActivity.kt +++ b/app/src/main/java/com/movableink/app/MainActivity.kt @@ -35,6 +35,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) askNotificationPermission() getFCMToken() + fetchClickableLink() checkIntentExtras() setContent { ShoppingCartApp() @@ -106,11 +107,6 @@ class MainActivity : ComponentActivity() { } } - override fun onWindowFocusChanged(hasFocus: Boolean) { - super.onWindowFocusChanged(hasFocus) - fetchClickableLink() - } - private fun fetchClickableLink() { MIClient.checkPasteboardOnInstall { resolvedLink -> try { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2f2a31e..d92771a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,7 +1,7 @@ [versions] # Build tools and plugins kotlin = "2.2.10" -play-services = "17.0.0'" +play-services = "17.0.0" spotless = "6.12.1" google-services = "4.3.15" agp = "9.1.0" @@ -102,7 +102,7 @@ serialization-json = { group = "org.jetbrains.kotlinx", name = "kotlinx-serializ okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" } #sfmc -salesforce-mc-sdk = { group = "com.salesforce.marketingcloud", name = "marketingcloudsdk", version.ref = "salesforce" } +salesforce-mc-sdk = { group = "com.salesforce.marketingcloud", name = "marketingcloudsdk", version.ref = "marketingcloudsdk" } # Testing From 93eb4bf77adda47b4b6ea18e35dcfccfba33fc67 Mon Sep 17 00:00:00 2001 From: Chayel J Heinsen Date: Tue, 31 Mar 2026 12:03:34 -0400 Subject: [PATCH 5/8] Fix PendingIntent missing FLAG_IMMUTABLE for Android 12+ compatibility --- app/src/main/java/com/movableink/app/App.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/movableink/app/App.kt b/app/src/main/java/com/movableink/app/App.kt index 1302929..f1c814e 100644 --- a/app/src/main/java/com/movableink/app/App.kt +++ b/app/src/main/java/com/movableink/app/App.kt @@ -60,7 +60,7 @@ class App : Application() { context, Random().nextInt(), Intent(Intent.ACTION_VIEW, Uri.parse(url)), - PendingIntent.FLAG_UPDATE_CURRENT, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) } }.build(applicationContext) From a7659464cb39491cffb893e364a543d09fffc6de Mon Sep 17 00:00:00 2001 From: Chayel J Heinsen Date: Wed, 1 Apr 2026 13:46:36 -0400 Subject: [PATCH 6/8] setup override for SFMC --- .../java/com/movableink/app/MainActivity.kt | 46 ++++++++++++++----- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/movableink/app/MainActivity.kt b/app/src/main/java/com/movableink/app/MainActivity.kt index 89eb025..a328f72 100644 --- a/app/src/main/java/com/movableink/app/MainActivity.kt +++ b/app/src/main/java/com/movableink/app/MainActivity.kt @@ -11,12 +11,18 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.result.contract.ActivityResultContracts import androidx.core.content.ContextCompat +import androidx.lifecycle.lifecycleScope import com.google.android.gms.tasks.OnCompleteListener import com.google.firebase.messaging.FirebaseMessaging import com.movableink.app.salesforce.WebViewUtility import com.movableink.inked.MIClient +import com.movableink.inked.inAppMessage.MovableInAppClient import com.salesforce.marketingcloud.MarketingCloudSdk +import com.salesforce.marketingcloud.sfmcsdk.SFMCSdk +import com.salesforce.marketingcloud.messages.iam.InAppMessageManager +import com.salesforce.marketingcloud.messages.iam.InAppMessage import android.widget.Toast +import kotlinx.coroutines.launch private const val TAG = "MainActivity " @@ -44,17 +50,35 @@ class MainActivity : ComponentActivity() { } private fun checkForSFMCMessage() { - MarketingCloudSdk.requestSdk { marketingCloudSdk -> - val inboxMessageManager = marketingCloudSdk.inboxMessageManager - val messages = inboxMessageManager.messages - - if (messages.isNotEmpty()) { - val message = messages[0] - val htmlLink = message.customKeys?.get("mi_link") - htmlLink?.let { - // render page - WebViewUtility.openUrlInFullScreenWebView(this, it) - } + 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) + + lifecycleScope.launch { + MIClient.showInAppBrowser( + this@MainActivity, + 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) = Unit + override fun didCloseMessage(message: InAppMessage) = Unit + }) } } } From 60122cdace5b0934c96f3c9cfda02036cdf2a07e Mon Sep 17 00:00:00 2001 From: Chayel J Heinsen Date: Wed, 1 Apr 2026 14:18:02 -0400 Subject: [PATCH 7/8] Setup release via firebase --- README.md | 83 ++++++++++++++++++++++++++++++++++++++++++- app/android.gradle | 23 ++++++++---- app/build.gradle.kts | 2 +- app/release-notes.txt | 1 + build.gradle.kts | 1 + gradle.properties | 6 ++-- 6 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 app/release-notes.txt 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 c8bf848..d82c861 100644 --- a/app/android.gradle +++ b/app/android.gradle @@ -7,12 +7,27 @@ 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" + } } - debug{ + debug { minifyEnabled false } } @@ -29,10 +44,6 @@ android { resValue "string", "fcm_sender_id", localProperties['FCM_SENDER_ID'] resValue "string", "marketing_cloud_url", localProperties['MARKETING_CLOUD_URL'] vectorDrawables.useSupportLibrary = true - - - - } buildFeatures { compose true @@ -49,4 +60,4 @@ android { 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 61b2ef2..7d67b1f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,7 @@ plugins { kotlin("android") id("org.jetbrains.kotlin.plugin.compose") id("com.google.gms.google-services") + id("com.google.firebase.appdistribution") } apply(from = "android.gradle") @@ -39,7 +40,6 @@ dependencies { implementation(libs.androidx.browser) // SFMC - implementation(libs.salesforce.mc.sdk) // implementation(libs.marketingcloudsdk.v810) 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/build.gradle.kts b/build.gradle.kts index c98b26a..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 { diff --git a/gradle.properties b/gradle.properties index 4b7edaf..b5f13a4 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,8 +11,8 @@ compileSdkVersion=34 applicationId=com.movableink.app targetSdkVersion=34 minSdkVersion=24 -versionCode=4 -versionName=deferred-deeplinking +versionCode=5 +versionName=SFMC resolutionStrategyConfig=true @@ -25,4 +25,4 @@ android.dependency.useConstraints=true android.r8.strictFullModeForKeepRules=false android.r8.optimizedResourceShrinking=false android.builtInKotlin=false -android.newDsl=false \ No newline at end of file +android.newDsl=false From e26a168bf373c4f6d3dff05eefbfdbd5ffb7448f Mon Sep 17 00:00:00 2001 From: Chayel J Heinsen Date: Fri, 3 Apr 2026 11:31:17 -0400 Subject: [PATCH 8/8] Fix SFMC bug --- app/android.gradle | 2 + app/build.gradle.kts | 2 +- app/src/main/AndroidManifest.xml | 24 +-- app/src/main/java/com/movableink/app/App.kt | 144 ++++++++++++++---- .../java/com/movableink/app/MainActivity.kt | 73 +++------ .../app/MyFirebaseMessagingService.kt | 50 +++--- .../app/ui/screens/settings/SettingsScreen.kt | 22 ++- app/src/main/res/values/strings.xml | 6 +- gradle.properties | 4 +- gradle/libs.versions.toml | 11 +- 10 files changed, 206 insertions(+), 132 deletions(-) diff --git a/app/android.gradle b/app/android.gradle index d82c861..f3a86cc 100644 --- a/app/android.gradle +++ b/app/android.gradle @@ -25,6 +25,7 @@ android { firebaseAppDistribution { artifactType = "APK" releaseNotesFile = "app/release-notes.txt" + groups = "Mobile" } } debug { @@ -41,6 +42,7 @@ android { 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 diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7d67b1f..31e130a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -18,7 +18,7 @@ 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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4355acd..1709923 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -34,23 +34,22 @@ android:theme="@style/Theme.ShoppingCart"> - - - - - + + - - - - - - - - - - - - - @@ -125,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 f1c814e..481bbfd 100644 --- a/app/src/main/java/com/movableink/app/App.kt +++ b/app/src/main/java/com/movableink/app/App.kt @@ -1,27 +1,51 @@ package com.movableink.app +import android.app.Activity import android.app.Application 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.BuildConfig 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"), @@ -30,47 +54,111 @@ class App : Application() { 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 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 fcm_sender_id = getString(R.string.fcm_sender_id) val marketing_cloud_url = getString(R.string.marketing_cloud_url) - if (BuildConfig.DEBUG) { - SFMCSdk.setLogging(LogLevel.DEBUG, LogListener.AndroidLogger()) - MarketingCloudSdk.setLogLevel(MCLogListener.VERBOSE) - MarketingCloudSdk.setLogListener(MCLogListener.AndroidLogListener()) + 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, - SFMCSdkModuleConfig.build { - pushModuleConfig = - MarketingCloudConfig - .builder() - .apply { - setApplicationId(mc_application_id) - setAccessToken(mc_access_token) - setSenderId(fcm_sender_id) - setMarketingCloudServerUrl(marketing_cloud_url) - 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, - ) + 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") } - }.build(applicationContext) + } + MIClient.setMIU(miu() ?: "") + } + InitializationStatus.FAILURE -> { + Log.e(LOG_TAG, "SFMC init: FAILED (status=${initStatus.status})") + } + } }, - ) { initStatus -> - } + ) SFMCSdk.requestSdk { sdk -> sdk.mp { - it.pushMessageManager.enablePush() + 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/MainActivity.kt b/app/src/main/java/com/movableink/app/MainActivity.kt index a328f72..f263bfb 100644 --- a/app/src/main/java/com/movableink/app/MainActivity.kt +++ b/app/src/main/java/com/movableink/app/MainActivity.kt @@ -7,24 +7,18 @@ 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 import androidx.core.content.ContextCompat -import androidx.lifecycle.lifecycleScope import com.google.android.gms.tasks.OnCompleteListener import com.google.firebase.messaging.FirebaseMessaging -import com.movableink.app.salesforce.WebViewUtility import com.movableink.inked.MIClient -import com.movableink.inked.inAppMessage.MovableInAppClient -import com.salesforce.marketingcloud.MarketingCloudSdk +import com.salesforce.marketingcloud.events.EventManager import com.salesforce.marketingcloud.sfmcsdk.SFMCSdk -import com.salesforce.marketingcloud.messages.iam.InAppMessageManager -import com.salesforce.marketingcloud.messages.iam.InAppMessage -import android.widget.Toast -import kotlinx.coroutines.launch -private const val TAG = "MainActivity " +private const val TAG = "MainActivity" class MainActivity : ComponentActivity() { private val requestPermissionLauncher = @@ -32,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() } } @@ -46,39 +40,14 @@ class MainActivity : ComponentActivity() { setContent { ShoppingCartApp() } - checkForSFMCMessage() + + EventManager.customEvent("display_message", mapOf())?.track() } - private fun checkForSFMCMessage() { + private fun enableSFMCPush() { 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) - - lifecycleScope.launch { - MIClient.showInAppBrowser( - this@MainActivity, - 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) = Unit - override fun didCloseMessage(message: InAppMessage) = Unit - }) + it.pushMessageManager.enablePush() } } } @@ -90,7 +59,6 @@ class MainActivity : ComponentActivity() { Log.d(TAG, "Intent Extra - Key: $key, Value: $value") } } - MIClient.handlePushNotificationOpened(bundle) } } @@ -107,27 +75,37 @@ 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() } } @@ -137,7 +115,6 @@ class MainActivity : ComponentActivity() { 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) 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/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/movableink/app/ui/screens/settings/SettingsScreen.kt index 0e17d67..8cea971 100644 --- 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 @@ -81,6 +81,7 @@ fun SettingsScreen(onDismiss: () -> Unit) { 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 { @@ -105,16 +106,18 @@ fun SettingsScreen(onDismiss: () -> Unit) { 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, miu).apply() - MIClient.setMIU(miu) + prefs.edit().putString(KEY_MIU, currentMiu).apply() + MIClient.setMIU(currentMiu) SFMCSdk.requestSdk { sfmcSdk -> - sfmcSdk.identity.setProfileId(miu) + sfmcSdk.identity.setProfileId(currentMiu) } } @@ -143,13 +146,13 @@ fun SettingsScreen(onDismiss: () -> Unit) { ) } - // --- FCM Push Token section --- + // --- FCM section --- item { SettingsSectionHeader(title = stringResource(R.string.settings_fcm_token_header)) } item { SettingsRow( - label = stringResource(R.string.settings_fcm_token_label), + label = stringResource(R.string.settings_push_token_label), value = fcmToken ?: stringResource(R.string.settings_none), copyable = fcmToken != null, context = context, @@ -176,6 +179,15 @@ fun SettingsScreen(onDismiss: () -> Unit) { context = context, ) } + item { + SettingsRow( + label = stringResource(R.string.settings_push_token_label), + value = pushToken ?: "-", + copyable = pushToken != null, + context = context, + ) + } + } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 84e98e4..37009d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -20,10 +20,12 @@ User mi_u The current MIU of the user. Changes are applied to the MI SDK and SFMC automatically. - FCM Push Token - Token + FCM SFMC Attributes Contact Key Device ID + System Token + Push Enabled + Push Token None diff --git a/gradle.properties b/gradle.properties index b5f13a4..f5ddd3c 100644 --- a/gradle.properties +++ b/gradle.properties @@ -11,8 +11,8 @@ compileSdkVersion=34 applicationId=com.movableink.app targetSdkVersion=34 minSdkVersion=24 -versionCode=5 -versionName=SFMC +versionCode=8 +versionName=SFMCv9-1 resolutionStrategyConfig=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index d92771a..8269aab 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,9 +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" +firebase-bom = "34.11.0" +firebase-analytics = "23.2.0" +firebase-messaging = "25.0.1" # Testing junit = "4.13.2" @@ -43,7 +43,6 @@ movableink = "3.0.0" datastore = "1.0.0" serialization = "1.6.0" okhttp = "5.3.2" -salesforce = "8.0.8" marketingcloudsdk = "9.0.3" @@ -53,7 +52,6 @@ android-gradlePlugin = { group = "com.android.tools.build", name = "gradle", ver 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" } -marketingcloudsdk-v810 = { module = "com.salesforce.marketingcloud:marketingcloudsdk", version.ref = "marketingcloudsdk" } 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" } @@ -91,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