From 8e6f1a57962a68b3df6c9126fd0f2a47e8d85768 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Thu, 6 Nov 2025 20:16:55 -0800 Subject: [PATCH 01/11] Add Login Options activity to dev menu. --- libs/SalesforceSDK/AndroidManifest.xml | 5 + libs/SalesforceSDK/res/values/sf__strings.xml | 1 + .../androidsdk/app/SalesforceSDKManager.kt | 129 ++++---- .../local/ShowDeveloperSupportNotifier.kt | 16 +- .../androidsdk/ui/DevInfoActivity.kt | 49 ++- .../androidsdk/ui/LoginOptionsActivity.kt | 300 ++++++++++++++++++ 6 files changed, 413 insertions(+), 87 deletions(-) create mode 100644 libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt diff --git a/libs/SalesforceSDK/AndroidManifest.xml b/libs/SalesforceSDK/AndroidManifest.xml index ed6fce4e50..976c09369a 100644 --- a/libs/SalesforceSDK/AndroidManifest.xml +++ b/libs/SalesforceSDK/AndroidManifest.xml @@ -74,6 +74,11 @@ android:theme="@style/SalesforceSDK" android:exported="false" /> + + + Show Salesforce Mobile SDK developer support Tap to display Salesforce Mobile SDK developer support in the active app. Salesforce Mobile Developer Support + Login Options diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index 0a514a1bc1..68df077364 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -132,6 +132,7 @@ import com.salesforce.androidsdk.security.ScreenLockManager import com.salesforce.androidsdk.ui.AccountSwitcherActivity import com.salesforce.androidsdk.ui.DevInfoActivity import com.salesforce.androidsdk.ui.LoginActivity +import com.salesforce.androidsdk.ui.LoginOptionsActivity import com.salesforce.androidsdk.ui.LoginViewModel import com.salesforce.androidsdk.ui.theme.sfDarkColors import com.salesforce.androidsdk.ui.theme.sfLightColors @@ -204,7 +205,7 @@ open class SalesforceSDKManager protected constructor( * Null or an authenticated Activity for private use when developer support * is enabled. */ - private var authenticatedActivityForDeveloperSupport: Activity? = null + private var activityForDeveloperSupport: Activity? = null /** * Null or the Android Activity lifecycle callbacks object registered when @@ -863,10 +864,10 @@ open class SalesforceSDKManager protected constructor( ) { // Assign the authenticated Activity - authenticatedActivityForDeveloperSupport = authenticatedActivity + activityForDeveloperSupport = authenticatedActivity // Display or hide the show developer support notification - when (userAccountManager.currentAccount == null || authenticatedActivityForDeveloperSupport == null) { + when (activityForDeveloperSupport == null) { true -> hideDeveloperSupportNotification(lifecycleActivity) else -> showDeveloperSupportNotification(lifecycleActivity) } @@ -1286,37 +1287,52 @@ open class SalesforceSDKManager protected constructor( * features for * @return map of title to dev actions handlers to display */ - protected open fun getDevActions( - frontActivity: Activity - ) = mapOf( - - "Show dev info" to object : DevActionHandler { - override fun onSelected() { - frontActivity.startActivity( - Intent( - frontActivity, - DevInfoActivity::class.java + protected open fun getDevActions(frontActivity: Activity): Map { + val actions = mutableMapOf( + "Show dev info" to object : DevActionHandler { + override fun onSelected() { + frontActivity.startActivity( + Intent( + frontActivity, + DevInfoActivity::class.java + ) ) - ) - } - }, + } + }, + "Login Options" to object : DevActionHandler { + override fun onSelected() { + frontActivity.startActivity( + Intent( + frontActivity, + LoginOptionsActivity::class.java + ) + ) + } + }, + ) - "Logout" to object : DevActionHandler { - override fun onSelected() { - logout(frontActivity = frontActivity, reason = LogoutReason.USER_LOGOUT) + // Do not show Logout or Switch User options in Dev menu on the Login screen. + if (frontActivity !is LoginActivity) { + actions["Logout"] = object : DevActionHandler { + override fun onSelected() { + logout(frontActivity = frontActivity, reason = LogoutReason.USER_LOGOUT) + } } - }, - - "Switch user" to object : DevActionHandler { - override fun onSelected() { - appContext.startActivity(Intent( - appContext, - accountSwitcherActivityClass - ).apply { - flags = FLAG_ACTIVITY_NEW_TASK - }) + + actions["Switch User"] = object : DevActionHandler { + override fun onSelected() { + appContext.startActivity(Intent( + appContext, + accountSwitcherActivityClass + ).apply { + flags = FLAG_ACTIVITY_NEW_TASK + }) + } } - }) + } + + return actions + } /** Information to display in the developer support dialog */ open val devSupportInfos: List @@ -1328,8 +1344,8 @@ open class SalesforceSDKManager protected constructor( "Browser Login Enabled", "$isBrowserLoginEnabled", "IDP Enabled", "$isIDPLoginFlowEnabled", "Identity Provider", "$isIdentityProvider", - "Current User", usersToString(userAccountManager.cachedCurrentUser), - "Scopes", (userAccountManager.cachedCurrentUser).scope, + "Current User", userAccountManager.cachedCurrentUser?.accountName ?: "", + "Scopes", userAccountManager.cachedCurrentUser?.scope ?: "", "Access Token Expiration", accessTokenExpiration(), "Authenticated Users", usersToString(userAccountManager.authenticatedUsers) ).apply { @@ -1357,11 +1373,11 @@ open class SalesforceSDKManager protected constructor( } private fun accessTokenExpiration(): String { - val currentUSer = userAccountManager.cachedCurrentUser + val currentUser = userAccountManager.cachedCurrentUser var expiration = "Unknown" - if (currentUSer.tokenFormat == "jwt") { - val jwtAccessToken = JwtAccessToken(currentUSer.authToken) + if (currentUser?.tokenFormat == "jwt") { + val jwtAccessToken = JwtAccessToken(currentUser.authToken) val expirationDate = jwtAccessToken.expirationDate() if (expirationDate != null) { val dateFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) @@ -1397,20 +1413,6 @@ open class SalesforceSDKManager protected constructor( return devInfos } - /** - * Returns a string representation of the provided users. - * @param userAccounts The user accounts - * @return A string representation of the provided users. - */ - private fun usersToString( - vararg userAccounts: UserAccount - ) = join( - ", ", - userAccounts.map { userAccount -> - userAccount.accountName - } - ) - /** * Returns a string representation of the provided users. * @param userAccounts The user accounts @@ -1419,7 +1421,9 @@ open class SalesforceSDKManager protected constructor( private fun usersToString( userAccounts: List? ) = userAccounts?.toTypedArray()?.let { - usersToString(*it) + join(", ", it.map { userAccount -> + userAccount.accountName + }) } ?: "" /** Sends the logout completed intent */ @@ -1582,7 +1586,7 @@ open class SalesforceSDKManager protected constructor( (biometricAuthenticationManager as? BiometricAuthenticationManager)?.onAppBackgrounded() // Hide the Salesforce Mobile SDK "Show Developer Support" notification - authenticatedActivityForDeveloperSupport?.let { + activityForDeveloperSupport?.let { hideDeveloperSupportNotification(it) } } @@ -1605,9 +1609,9 @@ open class SalesforceSDKManager protected constructor( } // Display the Salesforce Mobile SDK "Show Developer Support" notification - if (userAccountManager.currentAccount != null && authenticatedActivityForDeveloperSupport != null) { + if (activityForDeveloperSupport != null) { showDeveloperSupportNotification( - authenticatedActivityForDeveloperSupport + activityForDeveloperSupport ) } } @@ -1838,19 +1842,10 @@ open class SalesforceSDKManager protected constructor( } override fun onActivityResumed(activity: Activity) { - when (activity.javaClass) { - salesforceSDKManager.loginActivityClass -> - salesforceSDKManager.updateDeveloperSupportForActivityLifecycle( - authenticatedActivity = null, - lifecycleActivity = activity - ) - - else -> - salesforceSDKManager.updateDeveloperSupportForActivityLifecycle( - authenticatedActivity = activity, - lifecycleActivity = activity - ) - } + salesforceSDKManager.updateDeveloperSupportForActivityLifecycle( + authenticatedActivity = activity, + lifecycleActivity = activity, + ) } override fun onActivityPaused(activity: Activity) { @@ -1876,7 +1871,7 @@ open class SalesforceSDKManager protected constructor( val showDeveloperSupportBroadcastIntentReceiver: BroadcastReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { salesforceSDKManager.showDevSupportDialog( - salesforceSDKManager.authenticatedActivityForDeveloperSupport + salesforceSDKManager.activityForDeveloperSupport ) } } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/notifications/local/ShowDeveloperSupportNotifier.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/notifications/local/ShowDeveloperSupportNotifier.kt index 702e99ed4d..064a4e4e95 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/notifications/local/ShowDeveloperSupportNotifier.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/notifications/local/ShowDeveloperSupportNotifier.kt @@ -131,21 +131,7 @@ internal class ShowDeveloperSupportNotifier { activity.getSharedPreferences( SFDC_SHARED_PREFERENCES_NAME_DEVELOPER_SUPPORT, MODE_PRIVATE - ).run { - val postNotificationsPermissionRequested = getBoolean( - SFDC_SHARED_PREFERENCES_KEY_DEVELOPER_SUPPORT_POST_NOTIFICATIONS_PERMISSION_REQUESTED, - false - ) - if (postNotificationsPermissionRequested) return - - edit { - putBoolean( - SFDC_SHARED_PREFERENCES_KEY_DEVELOPER_SUPPORT_POST_NOTIFICATIONS_PERMISSION_REQUESTED, - true - ) - apply() - } - } + ) // Prompt for the post notifications permission. ActivityCompat.requestPermissions( diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt index 360d872861..81659f1134 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt @@ -26,10 +26,12 @@ */ package com.salesforce.androidsdk.ui +import android.content.ClipData import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.WindowInsets @@ -46,20 +48,24 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.ClipEntry +import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import com.salesforce.androidsdk.R import com.salesforce.androidsdk.app.SalesforceSDKManager +import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) class DevInfoActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - enableEdgeToEdge() val devInfoList = prepareListData(SalesforceSDKManager.getInstance().devSupportInfos) @@ -92,9 +98,9 @@ fun DevInfoScreen( devInfoList: List>, ) { LazyColumn( - contentPadding = paddingValues, modifier = Modifier .fillMaxSize() + .padding(paddingValues) ) { items(devInfoList) { (name, value) -> DevInfoItem(name, value) @@ -104,13 +110,46 @@ fun DevInfoScreen( @Composable fun DevInfoItem(name: String, value: String?) { + val coroutineScope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + Column( modifier = Modifier .fillMaxWidth() - .padding(8.dp) + .padding(start = 10.dp, end = 10.dp, top = 10.dp) + .clickable { + // Copy to clipboard + val clipData = ClipData.newPlainText(name, value) + coroutineScope.launch { + clipboard.setClipEntry(ClipEntry(clipData)) + } + } ) { Text(text = name, fontWeight = FontWeight.Bold) - Text(text = value ?: "", color = Color.Gray) - HorizontalDivider() + Text( + text = value ?: "", + color = Color.Gray, + modifier = Modifier.padding(top = 10.dp), + ) + HorizontalDivider(modifier = Modifier.padding(top = 10.dp)) } } + +@Preview(showBackground = true) +@Composable +private fun DevInfoItemPreview() { + DevInfoItem("Item Name", "Item Value") +} + +@Preview(showBackground = true) +@Composable +private fun DevInfoScreenPreview() { + DevInfoScreen( + PaddingValues(0.dp), + devInfoList = listOf( + "SDK Version" to SalesforceSDKManager.SDK_VERSION, + "User Agent" to "SalesforceMobileSDK/13.2.0.dev android mobile/15 (sdk_gphone64_arm64) " + + "RestExplorer/1.0(1) Native uid_adc6e133bd0ac338 ftr_AI.SP.UA SecurityPatch/2024-09-05", + ), + ) +} \ No newline at end of file diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt new file mode 100644 index 0000000000..b07876d39c --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt @@ -0,0 +1,300 @@ +package com.salesforce.androidsdk.ui + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +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.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.lifecycle.MediatorLiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import com.salesforce.androidsdk.R +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.config.BootConfig +import com.salesforce.androidsdk.ui.components.PADDING_SIZE +import com.salesforce.androidsdk.ui.theme.hintTextColor + +var dynamicConsumerKey = MutableLiveData("") +var dynamicRedirectUri = MutableLiveData("") +var dynamicScopes = MutableLiveData("") + +class LoginOptionsActivity: ComponentActivity() { + val useWebServer = MutableLiveData(SalesforceSDKManager.getInstance().useWebServerAuthentication) + val useHybridToken = MediatorLiveData(SalesforceSDKManager.getInstance().useHybridAuthentication) + + @OptIn(ExperimentalMaterial3Api::class) + @Override + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + useWebServer.observe( + /* owner = */ this, + Observer { + // onChanged lambda + value -> SalesforceSDKManager.getInstance().useWebServerAuthentication = value + }, + ) + useHybridToken.observe( + /* owner = */ this, + Observer { + // onChanged lambda + value -> SalesforceSDKManager.getInstance().useHybridAuthentication = value + }, + ) + + setContent { + MaterialTheme(colorScheme = SalesforceSDKManager.getInstance().colorScheme()) { + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + topBar = { + TopAppBar( + title = { + Text(stringResource(R.string.sf__dev_support_login_options_title)) + } + ) + } + ) { innerPadding -> + LoginOptionsScreen( + innerPadding, + useWebServer, + useHybridToken, + ) + } + } + } + } + + override fun onPause() { + super.onPause() + // TODO: Set Dynamic Boot Config + } +} + +@Composable +fun OptionToggle( + title: String, + useWebServer: MutableLiveData, +) { + val checked by useWebServer.observeAsState(initial = false) + + Row( + modifier = Modifier.fillMaxWidth().padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + title, + modifier = Modifier.height(50.dp).wrapContentHeight(align = Alignment.CenterVertically), + ) + Switch( + checked = checked, + onCheckedChange = { useWebServer.value = it }, + ) + } +} + +@Composable +fun BootConfigView() { + Column { + OutlinedTextField( + value = dynamicConsumerKey.value ?: "", + onValueChange = { dynamicConsumerKey.value = it }, + label = { Text("Consumer Key") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(PADDING_SIZE.dp), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = colorScheme.tertiary, + focusedLabelColor = colorScheme.tertiary, + focusedTextColor = colorScheme.onSecondary, + focusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = colorScheme.hintTextColor, + unfocusedLabelColor = colorScheme.hintTextColor, + unfocusedContainerColor = Color.Transparent, + unfocusedTextColor = colorScheme.onSecondary, + cursorColor = colorScheme.tertiary, + ), + ) + + OutlinedTextField( + value = dynamicRedirectUri.value ?: "", + onValueChange = { dynamicRedirectUri.value }, + label = { Text("Redirect URI") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(PADDING_SIZE.dp), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = colorScheme.tertiary, + focusedLabelColor = colorScheme.tertiary, + focusedTextColor = colorScheme.onSecondary, + focusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = colorScheme.hintTextColor, + unfocusedLabelColor = colorScheme.hintTextColor, + unfocusedContainerColor = Color.Transparent, + unfocusedTextColor = colorScheme.onSecondary, + cursorColor = colorScheme.tertiary, + ), + ) + + OutlinedTextField( + value = dynamicScopes.value ?: "", + onValueChange = { dynamicScopes.value = it }, + label = { Text("Scopes (comma separated)") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(PADDING_SIZE.dp), + colors = TextFieldDefaults.colors( + focusedIndicatorColor = colorScheme.tertiary, + focusedLabelColor = colorScheme.tertiary, + focusedTextColor = colorScheme.onSecondary, + focusedContainerColor = Color.Transparent, + unfocusedIndicatorColor = colorScheme.hintTextColor, + unfocusedLabelColor = colorScheme.hintTextColor, + unfocusedContainerColor = Color.Transparent, + unfocusedTextColor = colorScheme.onSecondary, + cursorColor = colorScheme.tertiary, + ), + ) + } +} + +@Composable +fun BootConfigItem(name: String, value: String?) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(start = 10.dp, end = 10.dp, top = 10.dp) + ) { + Text(text = name) + Text( + text = value ?: "", + color = Color.Gray, + modifier = Modifier.padding(top = 10.dp), + ) + } +} + +@Composable +fun LoginOptionsScreen( + innerPadding: PaddingValues, + useWebServer: MutableLiveData, + useHybridToken: MutableLiveData, + bootConfig: BootConfig = BootConfig.getBootConfig(LocalContext.current), +) { + var useDynamicConfig by remember { mutableStateOf(false) } + + Column(modifier = Modifier.padding(innerPadding)) { + OptionToggle("Use Web Server Flow", useWebServer) + OptionToggle("Use Hybrid Auth Token", useHybridToken) + + HorizontalDivider() + + Text( + text = "Boot Config File", + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(10.dp) + ) + + LazyColumn { + val scopes = bootConfig.oauthScopes ?: emptyArray() + val bootConfigList = listOf( + "Consumer Key" to bootConfig.remoteAccessConsumerKey, + "Redirect URI" to bootConfig.oauthRedirectURI, + "Scopes" to scopes.joinToString(separator = ", "), + ) + items(bootConfigList) { (name, value) -> + BootConfigItem(name, value) + } + } + + HorizontalDivider() + + Row( + modifier = Modifier.fillMaxWidth().padding(10.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + text = "Use Dynamic Boot Config", + modifier = Modifier.height(50.dp).wrapContentHeight(align = Alignment.CenterVertically), + ) + Switch( + checked = useDynamicConfig, + onCheckedChange = { useDynamicConfig = it }, + ) + } + + if (useDynamicConfig) { + BootConfigView() + } + } +} + +val previewBootConfig = object : BootConfig() { + override fun getRemoteAccessConsumerKey() = "3MVG98dostKihXN53TYStBIiS8FC2a3tE3XhGId0hQ37iQjF0xe4fxMSb2mFaWZn9e3GiLs1q67TNlyRji.Xw" + override fun getOauthRedirectURI() = "testsfdc:///mobilesdk/detect/oauth/done" +} + +@Preview(showBackground = true) +@Composable +fun OptionsTogglePreview() { + Column { + OptionToggle("Test Toggle", MutableLiveData(false)) + } +} + +@Preview(showBackground = true) +@Composable +fun BootConfigViewPreview() { + BootConfigView() +} + +@Preview(showBackground = true) +@Composable +fun LoginOptionsScreenPreview() { + LoginOptionsScreen( + innerPadding = PaddingValues(0.dp), + useWebServer = MutableLiveData(true), + useHybridToken = MutableLiveData(false), + bootConfig = previewBootConfig, + ) +} + + From 6ac6282c21e295bc7ac0770e3f737c0920d51400 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Fri, 7 Nov 2025 13:00:51 -0800 Subject: [PATCH 02/11] Fix scrolling issue and cleanup UI. --- .../androidsdk/app/SalesforceSDKManager.kt | 4 +- .../androidsdk/ui/DevInfoActivity.kt | 37 ++++++--- .../salesforce/androidsdk/ui/LoginActivity.kt | 5 ++ .../androidsdk/ui/LoginOptionsActivity.kt | 76 ++++++++++++------- 4 files changed, 81 insertions(+), 41 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index 68df077364..328ece6ada 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -1341,11 +1341,13 @@ open class SalesforceSDKManager protected constructor( "App Type", appType, "User Agent", userAgent, "Use Web Server Authentication", "$useWebServerAuthentication", + "Use Hybrid Authentication Token", "$useHybridAuthentication", + "Support Welcome Discovery", "$supportsWelcomeDiscovery", "Browser Login Enabled", "$isBrowserLoginEnabled", "IDP Enabled", "$isIDPLoginFlowEnabled", "Identity Provider", "$isIdentityProvider", "Current User", userAccountManager.cachedCurrentUser?.accountName ?: "", - "Scopes", userAccountManager.cachedCurrentUser?.scope ?: "", + "Current User Scopes", userAccountManager.cachedCurrentUser?.scope?.replace(" ", ", ") ?: "", "Access Token Expiration", accessTokenExpiration(), "Authenticated Users", usersToString(userAccountManager.authenticatedUsers) ).apply { diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt index 81659f1134..fed0d2c895 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt @@ -41,24 +41,27 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.salesforce.androidsdk.R import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.ui.components.PADDING_SIZE +import com.salesforce.androidsdk.ui.components.TEXT_SIZE import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -75,7 +78,7 @@ class DevInfoActivity : ComponentActivity() { Scaffold( contentWindowInsets = WindowInsets.safeDrawing, topBar = { - TopAppBar( + CenterAlignedTopAppBar( title = { Text(stringResource(id = R.string.sf__dev_support_title)) } ) } @@ -116,22 +119,32 @@ fun DevInfoItem(name: String, value: String?) { Column( modifier = Modifier .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp) + .padding(start = PADDING_SIZE.dp, end = PADDING_SIZE.dp, top = PADDING_SIZE.dp) .clickable { - // Copy to clipboard - val clipData = ClipData.newPlainText(name, value) - coroutineScope.launch { - clipboard.setClipEntry(ClipEntry(clipData)) + // Copy non-null and non-boolean values to clipboard. + value?.let { + if (it.toBooleanStrictOrNull() == null) { + val clipData = ClipData.newPlainText(name, it) + coroutineScope.launch { + clipboard.setClipEntry(ClipEntry(clipData)) + } + } } } ) { - Text(text = name, fontWeight = FontWeight.Bold) + Text( + text = name, + fontSize = TEXT_SIZE.sp, + fontWeight = FontWeight.Bold, + color = colorScheme.onSecondary, + ) Text( text = value ?: "", - color = Color.Gray, - modifier = Modifier.padding(top = 10.dp), + fontSize = TEXT_SIZE.sp, + color = colorScheme.onSecondaryContainer, + modifier = Modifier.padding(top = PADDING_SIZE.dp), ) - HorizontalDivider(modifier = Modifier.padding(top = 10.dp)) + HorizontalDivider(modifier = Modifier.padding(top = PADDING_SIZE.dp)) } } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index fe9d4d77a2..b5b3135a93 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -339,6 +339,11 @@ open class LoginActivity : FragmentActivity() { override fun onResume() { super.onResume() wasBackgrounded = false + + // If debug LoginOptions were changed reload the webview. + if (SalesforceSDKManager.getInstance().isDebugBuild && webView.url != viewModel.loginUrl.value) { + viewModel.reloadWebView() + } } override fun onKeyDown(keyCode: Int, event: KeyEvent) = diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt index b07876d39c..280f19189f 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt @@ -4,18 +4,21 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.viewModels import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.wrapContentHeight -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme @@ -25,7 +28,6 @@ import androidx.compose.material3.Scaffold import androidx.compose.material3.Switch import androidx.compose.material3.Text import androidx.compose.material3.TextFieldDefaults -import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState @@ -40,13 +42,14 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.lifecycle.MediatorLiveData +import androidx.compose.ui.unit.sp import androidx.lifecycle.MutableLiveData import androidx.lifecycle.Observer import com.salesforce.androidsdk.R import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.ui.components.PADDING_SIZE +import com.salesforce.androidsdk.ui.components.TEXT_SIZE import com.salesforce.androidsdk.ui.theme.hintTextColor var dynamicConsumerKey = MutableLiveData("") @@ -55,7 +58,8 @@ var dynamicScopes = MutableLiveData("") class LoginOptionsActivity: ComponentActivity() { val useWebServer = MutableLiveData(SalesforceSDKManager.getInstance().useWebServerAuthentication) - val useHybridToken = MediatorLiveData(SalesforceSDKManager.getInstance().useHybridAuthentication) + val useHybridToken = MutableLiveData(SalesforceSDKManager.getInstance().useHybridAuthentication) + val supportWelcomeDiscovery = MutableLiveData(SalesforceSDKManager.getInstance().supportsWelcomeDiscovery) @OptIn(ExperimentalMaterial3Api::class) @Override @@ -83,7 +87,7 @@ class LoginOptionsActivity: ComponentActivity() { Scaffold( contentWindowInsets = WindowInsets.safeDrawing, topBar = { - TopAppBar( + CenterAlignedTopAppBar( title = { Text(stringResource(R.string.sf__dev_support_login_options_title)) } @@ -94,6 +98,7 @@ class LoginOptionsActivity: ComponentActivity() { innerPadding, useWebServer, useHybridToken, + supportWelcomeDiscovery, ) } } @@ -103,18 +108,22 @@ class LoginOptionsActivity: ComponentActivity() { override fun onPause() { super.onPause() // TODO: Set Dynamic Boot Config + + val viewModel: LoginViewModel + by viewModels { SalesforceSDKManager.getInstance().loginViewModelFactory } + viewModel.reloadWebView() } } @Composable fun OptionToggle( title: String, - useWebServer: MutableLiveData, + optionData: MutableLiveData, ) { - val checked by useWebServer.observeAsState(initial = false) + val checked by optionData.observeAsState(initial = false) Row( - modifier = Modifier.fillMaxWidth().padding(10.dp), + modifier = Modifier.fillMaxWidth().padding(PADDING_SIZE.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { Text( @@ -123,7 +132,7 @@ fun OptionToggle( ) Switch( checked = checked, - onCheckedChange = { useWebServer.value = it }, + onCheckedChange = { optionData.value = it }, ) } } @@ -201,13 +210,18 @@ fun BootConfigItem(name: String, value: String?) { Column( modifier = Modifier .fillMaxWidth() - .padding(start = 10.dp, end = 10.dp, top = 10.dp) + .padding(start = PADDING_SIZE.dp, end = PADDING_SIZE.dp, top = PADDING_SIZE.dp) ) { - Text(text = name) + Text( + text = name, + fontSize = TEXT_SIZE.sp, + color = colorScheme.onSecondary, + ) Text( text = value ?: "", - color = Color.Gray, - modifier = Modifier.padding(top = 10.dp), + fontSize = TEXT_SIZE.sp, + color = colorScheme.onSecondaryContainer, + modifier = Modifier.padding(top = PADDING_SIZE.dp), ) } } @@ -217,43 +231,48 @@ fun LoginOptionsScreen( innerPadding: PaddingValues, useWebServer: MutableLiveData, useHybridToken: MutableLiveData, + supportWelcomeDiscovery: MutableLiveData, bootConfig: BootConfig = BootConfig.getBootConfig(LocalContext.current), ) { var useDynamicConfig by remember { mutableStateOf(false) } - Column(modifier = Modifier.padding(innerPadding)) { + Column( + modifier = Modifier + .padding(innerPadding) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { OptionToggle("Use Web Server Flow", useWebServer) OptionToggle("Use Hybrid Auth Token", useHybridToken) + OptionToggle("Support Welcome Discovery", supportWelcomeDiscovery) HorizontalDivider() Text( text = "Boot Config File", fontWeight = FontWeight.Bold, - modifier = Modifier.padding(10.dp) + modifier = Modifier.padding(PADDING_SIZE.dp) ) - LazyColumn { - val scopes = bootConfig.oauthScopes ?: emptyArray() - val bootConfigList = listOf( - "Consumer Key" to bootConfig.remoteAccessConsumerKey, - "Redirect URI" to bootConfig.oauthRedirectURI, - "Scopes" to scopes.joinToString(separator = ", "), - ) - items(bootConfigList) { (name, value) -> - BootConfigItem(name, value) - } + val scopes = bootConfig.oauthScopes ?: emptyArray() + val bootConfigList = listOf( + "Consumer Key" to bootConfig.remoteAccessConsumerKey, + "Redirect URI" to bootConfig.oauthRedirectURI, + "Scopes" to scopes.joinToString(separator = ", "), + ) + bootConfigList.forEach { (name, value) -> + BootConfigItem(name, value) } HorizontalDivider() Row( - modifier = Modifier.fillMaxWidth().padding(10.dp), + modifier = Modifier.fillMaxWidth().padding(PADDING_SIZE.dp), horizontalArrangement = Arrangement.SpaceBetween, ) { Text( text = "Use Dynamic Boot Config", - modifier = Modifier.height(50.dp).wrapContentHeight(align = Alignment.CenterVertically), + modifier = Modifier.height( 50.dp).wrapContentHeight(align = Alignment.CenterVertically), ) Switch( checked = useDynamicConfig, @@ -293,6 +312,7 @@ fun LoginOptionsScreenPreview() { innerPadding = PaddingValues(0.dp), useWebServer = MutableLiveData(true), useHybridToken = MutableLiveData(false), + supportWelcomeDiscovery = MutableLiveData(false), bootConfig = previewBootConfig, ) } From 9a65dac2f1132de8f1b90b3bb624929854051006 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Fri, 7 Nov 2025 15:00:14 -0800 Subject: [PATCH 03/11] Review feedback. --- .../androidsdk/ui/LoginOptionsActivity.kt | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt index 280f19189f..6f221dfd74 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt @@ -185,7 +185,7 @@ fun BootConfigView() { OutlinedTextField( value = dynamicScopes.value ?: "", onValueChange = { dynamicScopes.value = it }, - label = { Text("Scopes (comma separated)") }, + label = { Text("Scopes") }, singleLine = true, modifier = Modifier .fillMaxWidth() @@ -286,11 +286,6 @@ fun LoginOptionsScreen( } } -val previewBootConfig = object : BootConfig() { - override fun getRemoteAccessConsumerKey() = "3MVG98dostKihXN53TYStBIiS8FC2a3tE3XhGId0hQ37iQjF0xe4fxMSb2mFaWZn9e3GiLs1q67TNlyRji.Xw" - override fun getOauthRedirectURI() = "testsfdc:///mobilesdk/detect/oauth/done" -} - @Preview(showBackground = true) @Composable fun OptionsTogglePreview() { @@ -308,12 +303,18 @@ fun BootConfigViewPreview() { @Preview(showBackground = true) @Composable fun LoginOptionsScreenPreview() { + val consumerKey = stringResource(R.string.remoteAccessConsumerKey) + val redirect = stringResource(R.string.oauthRedirectURI) + LoginOptionsScreen( innerPadding = PaddingValues(0.dp), useWebServer = MutableLiveData(true), useHybridToken = MutableLiveData(false), supportWelcomeDiscovery = MutableLiveData(false), - bootConfig = previewBootConfig, + bootConfig = object : BootConfig() { + override fun getRemoteAccessConsumerKey() = consumerKey + override fun getOauthRedirectURI() = redirect + }, ) } From 789dc915ac31d36206310e1a71b71389a538cc62 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Fri, 7 Nov 2025 18:23:59 -0800 Subject: [PATCH 04/11] Update tests. --- .../androidsdk/rest/ClientManagerMockTest.kt | 1 + .../androidsdk/ui/LoginActivityTest.kt | 31 ++++++++++--------- 2 files changed, 17 insertions(+), 15 deletions(-) diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt index 8097e592c6..d2d44922e6 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/rest/ClientManagerMockTest.kt @@ -67,6 +67,7 @@ class ClientManagerMockTest { every { additionalOauthKeys } returns emptyList() every { useHybridAuthentication } returns true every { appContext } returns mockAppContext + every { isDevSupportEnabled() } returns true } every { SalesforceSDKManager.getInstance() } returns mockSDKManager mockkStatic(UserAccountManager::class) diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt index c8e6271c7d..acc07cd44a 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt @@ -75,21 +75,22 @@ class LoginActivityTest { } } - @Test - fun viewModelIsUsingFrontDoorBridge_DefaultValue_onCreateWithoutQrCodeLoginIntent() { - launch( - Intent( - getApplicationContext(), - LoginActivity::class.java - ) - ).use { activityScenario -> - - activityScenario.onActivity { activity -> - - assertFalse(activity.viewModel.isUsingFrontDoorBridge) - } - } - } +// TODO: Fix and re-enable this test. +// @Test +// fun viewModelIsUsingFrontDoorBridge_DefaultValue_onCreateWithoutQrCodeLoginIntent() { +// launch( +// Intent( +// getApplicationContext(), +// LoginActivity::class.java +// ) +// ).use { activityScenario -> +// +// activityScenario.onActivity { activity -> +// +// assertFalse(activity.viewModel.isUsingFrontDoorBridge) +// } +// } +// } @Test fun viewModelFrontDoorBridgeCodeVerifier_UpdatesOn_onCreateWithQrCodeLoginIntent() { From a9076329d2f7e3152cbf060c9732b3a413d0e3d8 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Wed, 12 Nov 2025 13:14:48 -0800 Subject: [PATCH 05/11] Fix all tests. --- .github/workflows/reusable-workflow.yaml | 2 +- .../androidsdk/app/PushServiceTest.kt | 15 +++++++-- .../androidsdk/auth/LoginViewModelTest.kt | 15 --------- .../androidsdk/ui/LoginActivityTest.kt | 31 +++++++++---------- .../androidsdk/ui/LoginViewActivityTest.kt | 11 +++++++ .../ui/PickerBottomSheetActivityTest.kt | 11 +++++++ .../androidsdk/ui/PickerBottomSheetTest.kt | 11 +++++++ 7 files changed, 61 insertions(+), 35 deletions(-) diff --git a/.github/workflows/reusable-workflow.yaml b/.github/workflows/reusable-workflow.yaml index 7383350499..d38f5c49e2 100644 --- a/.github/workflows/reusable-workflow.yaml +++ b/.github/workflows/reusable-workflow.yaml @@ -88,7 +88,7 @@ jobs: if $IS_PR ; then LEVELS_TO_TEST=$PR_API_VERSION - RETRIES=2 + RETRIES=1 fi mkdir firebase_results diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/PushServiceTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/PushServiceTest.kt index 08f5ec95fd..801cefef57 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/PushServiceTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/PushServiceTest.kt @@ -707,6 +707,7 @@ class PushServiceTest { // Setup. val notificationsTypesResponseBody = fromJson(NOTIFICATIONS_TYPES_JSON) + val originalNotificationTypes = notificationsTypesResponseBody.notificationTypes createTestAccountInAccountManager(userAccountManager) // Test when no notification types are in the data. @@ -714,9 +715,17 @@ class PushServiceTest { PushService().registerNotificationChannels(notificationsTypesResponseBody.copy(notificationTypes = null)) salesforceSdkManager.appContext.getSystemService(NotificationManager::class.java).run { - assertTrue( - notificationChannels.isEmpty() - ) + // Verify that the Salesforce notification channel group exists but has no channels. + val channelGroup = getNotificationChannelGroup(NOTIFICATION_CHANNEL_GROUP_SALESFORCE_ID) + assertNotNull(channelGroup) + assertTrue(channelGroup.channels.isEmpty()) + + // Verify that no channels with the original notification type IDs exist. + originalNotificationTypes?.forEach { + assertNull(notificationChannels.firstOrNull { notificationChannel -> + notificationChannel.id == it.type + }) + } } } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt index 9152684117..6305a8f7f9 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/LoginViewModelTest.kt @@ -26,12 +26,9 @@ */ package com.salesforce.androidsdk.auth -import android.webkit.WebView import androidx.arch.core.executor.testing.InstantTaskExecutorRule import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry -import io.mockk.mockk -import io.mockk.verify import com.salesforce.androidsdk.R.string.oauth_display_type import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.auth.OAuth2.getFrontdoorUrl @@ -200,18 +197,6 @@ class LoginViewModelTest { assertEquals(unchangedUrl, viewModel.getValidServerUrl(endingSlash)) } - @Test - fun clearWebViewCache_CallsWebViewClearCache_WithTrueParameter() { - // Arrange - val mockWebView = mockk(relaxed = true) - - // Act - viewModel.clearWebViewCache(mockWebView) - - // Assert - verify { mockWebView.clearCache(true) } - } - private fun generateExpectedAuthorizationUrl( server: String, codeChallenge: String, diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt index acc07cd44a..c8e6271c7d 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt @@ -75,22 +75,21 @@ class LoginActivityTest { } } -// TODO: Fix and re-enable this test. -// @Test -// fun viewModelIsUsingFrontDoorBridge_DefaultValue_onCreateWithoutQrCodeLoginIntent() { -// launch( -// Intent( -// getApplicationContext(), -// LoginActivity::class.java -// ) -// ).use { activityScenario -> -// -// activityScenario.onActivity { activity -> -// -// assertFalse(activity.viewModel.isUsingFrontDoorBridge) -// } -// } -// } + @Test + fun viewModelIsUsingFrontDoorBridge_DefaultValue_onCreateWithoutQrCodeLoginIntent() { + launch( + Intent( + getApplicationContext(), + LoginActivity::class.java + ) + ).use { activityScenario -> + + activityScenario.onActivity { activity -> + + assertFalse(activity.viewModel.isUsingFrontDoorBridge) + } + } + } @Test fun viewModelFrontDoorBridgeCodeVerifier_UpdatesOn_onCreateWithQrCodeLoginIntent() { diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt index 82dcc62812..6de85239df 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt @@ -26,6 +26,8 @@ */ package com.salesforce.androidsdk.ui +import android.Manifest +import android.os.Build import android.webkit.WebView import androidx.activity.ComponentActivity import androidx.compose.material3.BottomAppBar @@ -52,6 +54,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.lifecycle.LiveData import androidx.lifecycle.liveData +import androidx.test.rule.GrantPermissionRule import com.salesforce.androidsdk.R import com.salesforce.androidsdk.ui.components.DefaultBottomAppBar import com.salesforce.androidsdk.ui.components.DefaultLoadingIndicator @@ -69,6 +72,14 @@ class LoginViewActivityTest { @get:Rule val androidComposeTestRule = createAndroidComposeRule() + // TODO: Remove if when min SDK version is 33 + @get:Rule + val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) + } else { + GrantPermissionRule.grant() + } + @Test fun topAppBar_Default_DisplaysCorrectly() { androidComposeTestRule.setContent { diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/PickerBottomSheetActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/PickerBottomSheetActivityTest.kt index 3e9b46ca75..99513b09f3 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/PickerBottomSheetActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/PickerBottomSheetActivityTest.kt @@ -26,6 +26,8 @@ */ package com.salesforce.androidsdk.ui +import android.Manifest +import android.os.Build import androidx.activity.ComponentActivity import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.ui.test.assertIsDisplayed @@ -40,6 +42,7 @@ import androidx.compose.ui.test.onChildAt import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick +import androidx.test.rule.GrantPermissionRule import com.salesforce.androidsdk.R import com.salesforce.androidsdk.config.LoginServerManager.LoginServer import com.salesforce.androidsdk.ui.components.PickerStyle @@ -56,6 +59,14 @@ class PickerBottomSheetActivityTest { @get:Rule val androidComposeTestRule = createAndroidComposeRule() + // TODO: Remove if when min SDK version is 33 + @get:Rule + val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) + } else { + GrantPermissionRule.grant() + } + // region Login Server Picker Tests @OptIn(ExperimentalMaterial3Api::class) diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/PickerBottomSheetTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/PickerBottomSheetTest.kt index b49f792dc0..6d635eae31 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/PickerBottomSheetTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/PickerBottomSheetTest.kt @@ -26,6 +26,8 @@ */ package com.salesforce.androidsdk.ui +import android.Manifest +import android.os.Build import androidx.annotation.VisibleForTesting import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetState @@ -47,6 +49,7 @@ import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextClearance import androidx.compose.ui.test.performTextInput +import androidx.test.rule.GrantPermissionRule import com.salesforce.androidsdk.config.LoginServerManager.LoginServer import com.salesforce.androidsdk.ui.components.AddConnection import com.salesforce.androidsdk.ui.components.PickerBottomSheet @@ -89,6 +92,14 @@ class PickerBottomSheetTest { @get:Rule val composeTestRule = createComposeRule() + // TODO: Remove if when min SDK version is 33 + @get:Rule + val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) + } else { + GrantPermissionRule.grant() + } + /* This call will print the semantic tree: composeTestRule.onAllNodes(isRoot()).printToLog("", 10) */ private val customsRowCd = (hasText(customServer.name) and hasText(customServer.url)) From 3e11e60d8d0ff55a4bbec4cbaa6d6d3d22ec2f01 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Thu, 13 Nov 2025 15:07:04 -0800 Subject: [PATCH 06/11] Improve LoginOptionsActivity and the mechanism used to reload on option change. Add test for LoginOptionsActivity. --- libs/SalesforceSDK/res/values/sf__strings.xml | 7 + .../androidsdk/app/SalesforceSDKManager.kt | 3 + .../salesforce/androidsdk/ui/LoginActivity.kt | 10 +- .../androidsdk/ui/LoginOptionsActivity.kt | 84 +++-- .../ui/components/PickerBottomSheet.kt | 3 +- .../androidsdk/ui/LoginActivityTest.kt | 50 +++ .../androidsdk/ui/LoginOptionsActivityTest.kt | 356 ++++++++++++++++++ 7 files changed, 481 insertions(+), 32 deletions(-) create mode 100644 libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt diff --git a/libs/SalesforceSDK/res/values/sf__strings.xml b/libs/SalesforceSDK/res/values/sf__strings.xml index 8eafd4c39e..bbd6ea5b27 100644 --- a/libs/SalesforceSDK/res/values/sf__strings.xml +++ b/libs/SalesforceSDK/res/values/sf__strings.xml @@ -113,4 +113,11 @@ Tap to display Salesforce Mobile SDK developer support in the active app. Salesforce Mobile Developer Support Login Options + Toggle Web Server + Toggle Hybrid Token + Toggle Welcome Discovery + Toggle Dynamic Config + Consumer Key Field + Redirect URI Field + Scopes Field diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index 328ece6ada..b9cdcf78f5 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -387,6 +387,9 @@ open class SalesforceSDKManager protected constructor( @set:Synchronized var useHybridAuthentication = true + // Used to ensure the webview is reloaded when Dev Menu Login Options are changed. + internal var loginDevMenuReload = false + /** * The regular expression pattern used to detect "Use Custom Domain" input * from login web view. diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index b5b3135a93..560e2ffaec 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -341,8 +341,14 @@ open class LoginActivity : FragmentActivity() { wasBackgrounded = false // If debug LoginOptions were changed reload the webview. - if (SalesforceSDKManager.getInstance().isDebugBuild && webView.url != viewModel.loginUrl.value) { - viewModel.reloadWebView() + // + // Note: The dev menu cannot be access when a Custom Tab is displayed so + // we can safely ignore that scenario. + with(SalesforceSDKManager.getInstance()) { + if (isDebugBuild && loginDevMenuReload) { + viewModel.reloadWebView() + loginDevMenuReload = false + } } } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt index 6f221dfd74..ac75df2739 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt @@ -4,7 +4,6 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge -import androidx.activity.viewModels import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues @@ -39,6 +38,8 @@ 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.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -52,10 +53,6 @@ import com.salesforce.androidsdk.ui.components.PADDING_SIZE import com.salesforce.androidsdk.ui.components.TEXT_SIZE import com.salesforce.androidsdk.ui.theme.hintTextColor -var dynamicConsumerKey = MutableLiveData("") -var dynamicRedirectUri = MutableLiveData("") -var dynamicScopes = MutableLiveData("") - class LoginOptionsActivity: ComponentActivity() { val useWebServer = MutableLiveData(SalesforceSDKManager.getInstance().useWebServerAuthentication) val useHybridToken = MutableLiveData(SalesforceSDKManager.getInstance().useHybridAuthentication) @@ -81,6 +78,13 @@ class LoginOptionsActivity: ComponentActivity() { value -> SalesforceSDKManager.getInstance().useHybridAuthentication = value }, ) + supportWelcomeDiscovery.observe( + /* owner = */ this, + Observer { + // onChanged lambda + value -> SalesforceSDKManager.getInstance().supportsWelcomeDiscovery = value + }, + ) setContent { MaterialTheme(colorScheme = SalesforceSDKManager.getInstance().colorScheme()) { @@ -104,20 +108,12 @@ class LoginOptionsActivity: ComponentActivity() { } } } - - override fun onPause() { - super.onPause() - // TODO: Set Dynamic Boot Config - - val viewModel: LoginViewModel - by viewModels { SalesforceSDKManager.getInstance().loginViewModelFactory } - viewModel.reloadWebView() - } } @Composable fun OptionToggle( title: String, + contentDescription: String, optionData: MutableLiveData, ) { val checked by optionData.observeAsState(initial = false) @@ -132,22 +128,36 @@ fun OptionToggle( ) Switch( checked = checked, - onCheckedChange = { optionData.value = it }, + onCheckedChange = { + optionData.value = it + SalesforceSDKManager.getInstance().loginDevMenuReload = true + }, + modifier = Modifier.semantics { + this.contentDescription = contentDescription + } ) } } @Composable fun BootConfigView() { + var dynamicConsumerKey by remember { mutableStateOf("") } + var dynamicRedirectUri by remember { mutableStateOf("") } + var dynamicScopes by remember { mutableStateOf("") } + val consumerKeyFieldDesc = stringResource(R.string.sf__login_options_consumer_key_field_content_description) + val redirectFieldDesc = stringResource(R.string.sf__login_options_redirect_uri_field_content_description) + val scopesFieldDesc = stringResource(R.string.sf__login_options_scopes_field_content_description) + Column { OutlinedTextField( - value = dynamicConsumerKey.value ?: "", - onValueChange = { dynamicConsumerKey.value = it }, + value = dynamicConsumerKey, + onValueChange = { dynamicConsumerKey = it }, label = { Text("Consumer Key") }, singleLine = true, modifier = Modifier .fillMaxWidth() - .padding(PADDING_SIZE.dp), + .padding(PADDING_SIZE.dp) + .semantics { contentDescription = consumerKeyFieldDesc }, colors = TextFieldDefaults.colors( focusedIndicatorColor = colorScheme.tertiary, focusedLabelColor = colorScheme.tertiary, @@ -162,13 +172,14 @@ fun BootConfigView() { ) OutlinedTextField( - value = dynamicRedirectUri.value ?: "", - onValueChange = { dynamicRedirectUri.value }, + value = dynamicRedirectUri, + onValueChange = { dynamicRedirectUri = it }, label = { Text("Redirect URI") }, singleLine = true, modifier = Modifier .fillMaxWidth() - .padding(PADDING_SIZE.dp), + .padding(PADDING_SIZE.dp) + .semantics { contentDescription = redirectFieldDesc }, colors = TextFieldDefaults.colors( focusedIndicatorColor = colorScheme.tertiary, focusedLabelColor = colorScheme.tertiary, @@ -183,13 +194,14 @@ fun BootConfigView() { ) OutlinedTextField( - value = dynamicScopes.value ?: "", - onValueChange = { dynamicScopes.value = it }, + value = dynamicScopes, + onValueChange = { dynamicScopes = it }, label = { Text("Scopes") }, singleLine = true, modifier = Modifier .fillMaxWidth() - .padding(PADDING_SIZE.dp), + .padding(PADDING_SIZE.dp) + .semantics { contentDescription = scopesFieldDesc }, colors = TextFieldDefaults.colors( focusedIndicatorColor = colorScheme.tertiary, focusedLabelColor = colorScheme.tertiary, @@ -242,9 +254,21 @@ fun LoginOptionsScreen( .fillMaxSize() .verticalScroll(rememberScrollState()), ) { - OptionToggle("Use Web Server Flow", useWebServer) - OptionToggle("Use Hybrid Auth Token", useHybridToken) - OptionToggle("Support Welcome Discovery", supportWelcomeDiscovery) + OptionToggle( + "Use Web Server Flow", + stringResource(R.string.sf__login_options_webserver_toggle_content_description), + useWebServer, + ) + OptionToggle( + "Use Hybrid Auth Token", + stringResource(R.string.sf__login_options_hybrid_toggle_content_description), + useHybridToken, + ) + OptionToggle( + "Support Welcome Discovery", + stringResource(R.string.sf__login_options_welcome_toggle_content_description), + supportWelcomeDiscovery, + ) HorizontalDivider() @@ -274,9 +298,13 @@ fun LoginOptionsScreen( text = "Use Dynamic Boot Config", modifier = Modifier.height( 50.dp).wrapContentHeight(align = Alignment.CenterVertically), ) + val dynamicConfigToggleDesc = stringResource(R.string.sf__login_options_dynamic_config_toggle_content_description) Switch( checked = useDynamicConfig, onCheckedChange = { useDynamicConfig = it }, + modifier = Modifier.semantics { + contentDescription = dynamicConfigToggleDesc + } ) } @@ -290,7 +318,7 @@ fun LoginOptionsScreen( @Composable fun OptionsTogglePreview() { Column { - OptionToggle("Test Toggle", MutableLiveData(false)) + OptionToggle("Test Toggle", "", MutableLiveData(false)) } } diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/PickerBottomSheet.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/PickerBottomSheet.kt index aecc908782..21bacbfc1d 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/PickerBottomSheet.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/PickerBottomSheet.kt @@ -94,10 +94,9 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.graphics.painter.BitmapPainter -import androidx.compose.ui.res.painterResource +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt index c8e6271c7d..cc4ebcb196 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginActivityTest.kt @@ -234,4 +234,54 @@ class LoginActivityTest { SalesforceSDKManager.getInstance().supportsWelcomeDiscovery = supportWelcomeDiscovery } + + @Test + fun loginActivity_ReloadsWebview_OnResumeWithLoginOptionChanges() { + // Set loginDevMenuReload to false initially + SalesforceSDKManager.getInstance().loginDevMenuReload = false + + launch( + Intent( + getApplicationContext(), + LoginActivity::class.java + ) + ).use { activityScenario -> + // Get the initial login URL + var initialUrl: String? = null + activityScenario.onActivity { activity -> + initialUrl = activity.viewModel.loginUrl.value + } + + // Pause the activity (simulating going to dev menu) + activityScenario.moveToState(androidx.lifecycle.Lifecycle.State.STARTED) + + // Simulate changing login options in dev menu + activityScenario.onActivity { _ -> + SalesforceSDKManager.getInstance().loginDevMenuReload = true + } + + // Resume the activity + activityScenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED) + + // Verify the webview was reloaded (URL should be regenerated) + activityScenario.onActivity { activity -> + // The reload flag should be reset to false + assertFalse( + "loginDevMenuReload should be reset to false after reload", + SalesforceSDKManager.getInstance().loginDevMenuReload + ) + + // For Web Server Flow, the URL changes each time due to code challenge + // Verify that reloadWebView was called by checking the URL changed + val newUrl = activity.viewModel.loginUrl.value + if (SalesforceSDKManager.getInstance().useWebServerAuthentication) { + // Web Server Flow generates a new code challenge each time + assertTrue( + "Login URL should have changed after reload for Web Server Flow", + newUrl != initialUrl + ) + } + } + } + } } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt new file mode 100644 index 0000000000..41041c2ca8 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginOptionsActivityTest.kt @@ -0,0 +1,356 @@ +/* + * Copyright (c) 2025-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.androidsdk.ui + +import android.Manifest +import android.os.Build +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn +import androidx.compose.ui.test.assertTextEquals +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule +import com.salesforce.androidsdk.R +import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.config.BootConfig +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LoginOptionsActivityTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + // TODO: Remove if when min SDK version is 33 + @get:Rule + val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) + } else { + GrantPermissionRule.grant() + } + + private var originalUseWebServer: Boolean = false + private var originalUseHybridToken: Boolean = false + private var originalSupportsWelcomeDiscovery: Boolean = false + + @Before + fun setup() { + // Save original values + originalUseWebServer = SalesforceSDKManager.getInstance().useWebServerAuthentication + originalUseHybridToken = SalesforceSDKManager.getInstance().useHybridAuthentication + originalSupportsWelcomeDiscovery = SalesforceSDKManager.getInstance().supportsWelcomeDiscovery + SalesforceSDKManager.getInstance().loginDevMenuReload = false + } + + @After + fun teardown() { + // Restore original values + SalesforceSDKManager.getInstance().useWebServerAuthentication = originalUseWebServer + SalesforceSDKManager.getInstance().useHybridAuthentication = originalUseHybridToken + SalesforceSDKManager.getInstance().supportsWelcomeDiscovery = originalSupportsWelcomeDiscovery + } + + @Test + fun loginOptionsActivity_DisplaysBootConfigValues() { + val bootConfig = BootConfig.getBootConfig(composeTestRule.activity) + + // Check that boot config values are displayed + composeTestRule.onNodeWithText(bootConfig.remoteAccessConsumerKey).assertIsDisplayed() + composeTestRule.onNodeWithText(bootConfig.oauthRedirectURI).assertIsDisplayed() + + val scopes = bootConfig.oauthScopes?.joinToString(separator = ", ") ?: "" + if (scopes.isNotEmpty()) { + composeTestRule.onNodeWithText(scopes).assertIsDisplayed() + } + } + + @Test + fun loginOptionsActivity_WebServerFlowToggle_UpdatesSdkManager() { + // Set initial state via the activity's LiveData + composeTestRule.activity.runOnUiThread { + SalesforceSDKManager.getInstance().useWebServerAuthentication = false + composeTestRule.activity.useWebServer.value = false + } + composeTestRule.waitForIdle() + + // Verify initial state + assertFalse( + "Use Web Server Authentication should be disabled initially", + SalesforceSDKManager.getInstance().useWebServerAuthentication + ) + + val toggleDescriptor = composeTestRule.activity.getString(R.string.sf__login_options_webserver_toggle_content_description) + val webserverToggle = composeTestRule.onNodeWithContentDescription(toggleDescriptor) + webserverToggle.assertIsDisplayed() + webserverToggle.assertIsOff() + + // Verify reload flag is initially false + assertFalse( + "loginDevMenuReload should be false initially", + SalesforceSDKManager.getInstance().loginDevMenuReload + ) + + // Click + webserverToggle.performClick() + composeTestRule.waitForIdle() + + webserverToggle.assertIsOn() + + // Verify the SDK manager was updated + assertTrue( + "Use Web Server Authentication should be enabled", + SalesforceSDKManager.getInstance().useWebServerAuthentication + ) + + // Verify the reload flag was set + assertTrue( + "loginDevMenuReload should be true after toggle", + SalesforceSDKManager.getInstance().loginDevMenuReload + ) + } + + @Test + fun loginOptionsActivity_HybridAuthTokenToggle_UpdatesSdkManager() { + // Set initial state via the activity's LiveData + composeTestRule.activity.runOnUiThread { + SalesforceSDKManager.getInstance().useHybridAuthentication = false + composeTestRule.activity.useHybridToken.value = false + } + composeTestRule.waitForIdle() + + // Verify initial state + assertFalse( + "Use Hybrid Authentication should be disabled initially", + SalesforceSDKManager.getInstance().useHybridAuthentication + ) + + // Find and click the toggle + val toggleDescriptor = composeTestRule.activity.getString(R.string.sf__login_options_hybrid_toggle_content_description) + val hybridToggle = composeTestRule.onNodeWithContentDescription(toggleDescriptor) + hybridToggle.assertIsDisplayed() + hybridToggle.assertIsOff() + + // Verify reload flag is initially false + assertFalse( + "loginDevMenuReload should be false initially", + SalesforceSDKManager.getInstance().loginDevMenuReload + ) + + // Click + hybridToggle.performClick() + composeTestRule.waitForIdle() + + hybridToggle.assertIsOn() + + // Verify the SDK manager was updated + assertTrue( + "Use Hybrid Authentication should be enabled", + SalesforceSDKManager.getInstance().useHybridAuthentication + ) + + // Verify the reload flag was set + assertTrue( + "loginDevMenuReload should be true after toggle", + SalesforceSDKManager.getInstance().loginDevMenuReload + ) + } + + @Test + fun loginOptionsActivity_WelcomeDiscoveryToggle_UpdatesSdkManager() { + // Set initial state via the activity's LiveData + composeTestRule.activity.runOnUiThread { + SalesforceSDKManager.getInstance().supportsWelcomeDiscovery = false + composeTestRule.activity.supportWelcomeDiscovery.value = false + } + composeTestRule.waitForIdle() + + // Verify initial state + assertFalse( + "Support Welcome Discovery should be disabled initially", + SalesforceSDKManager.getInstance().supportsWelcomeDiscovery + ) + + // Find and click the toggle + val toggleDescriptor = composeTestRule.activity.getString(R.string.sf__login_options_welcome_toggle_content_description) + val welcomeToggle = composeTestRule.onNodeWithContentDescription(toggleDescriptor) + welcomeToggle.assertIsDisplayed() + welcomeToggle.assertIsOff() + + // Verify reload flag is initially false + assertFalse( + "loginDevMenuReload should be false initially", + SalesforceSDKManager.getInstance().loginDevMenuReload + ) + + // Click + welcomeToggle.performClick() + composeTestRule.waitForIdle() + + welcomeToggle.assertIsOn() + + // Verify the SDK manager was updated + assertTrue( + "Support Welcome Discovery should be enabled", + SalesforceSDKManager.getInstance().supportsWelcomeDiscovery + ) + + // Verify the reload flag was set + assertTrue( + "loginDevMenuReload should be true after toggle", + SalesforceSDKManager.getInstance().loginDevMenuReload + ) + } + + @Test + fun loginOptionsActivity_InitialToggleStates_ReflectSdkManagerValues() { + // Set known states via the activity's LiveData + composeTestRule.activity.runOnUiThread { + SalesforceSDKManager.getInstance().useWebServerAuthentication = true + SalesforceSDKManager.getInstance().useHybridAuthentication = false + composeTestRule.activity.useWebServer.value = true + composeTestRule.activity.useHybridToken.value = false + } + composeTestRule.waitForIdle() + + // Verify states match SDK manager + assertEquals( + true, + composeTestRule.activity.useWebServer.value + ) + assertEquals( + false, + composeTestRule.activity.useHybridToken.value + ) + } + + @Test + fun loginOptionsActivity_DynamicBootConfigToggle_ShowsInputFields() { + // Get content descriptions + val dynamicToggleDesc = composeTestRule.activity.getString(R.string.sf__login_options_dynamic_config_toggle_content_description) + val consumerKeyFieldDesc = composeTestRule.activity.getString(R.string.sf__login_options_consumer_key_field_content_description) + val redirectUriFieldDesc = composeTestRule.activity.getString(R.string.sf__login_options_redirect_uri_field_content_description) + val scopesFieldDesc = composeTestRule.activity.getString(R.string.sf__login_options_scopes_field_content_description) + + // Initially, dynamic config fields should not exist + val dynamicToggle = composeTestRule.onNodeWithContentDescription(dynamicToggleDesc) + dynamicToggle.assertIsDisplayed() + dynamicToggle.assertIsOff() + + composeTestRule.onNodeWithContentDescription(consumerKeyFieldDesc).assertDoesNotExist() + composeTestRule.onNodeWithContentDescription(redirectUriFieldDesc).assertDoesNotExist() + composeTestRule.onNodeWithContentDescription(scopesFieldDesc).assertDoesNotExist() + + // Click to enable dynamic config + dynamicToggle.performClick() + composeTestRule.waitForIdle() + + dynamicToggle.assertIsOn() + + // Now the input fields should be visible and accept text input + val consumerKeyField = composeTestRule.onNodeWithContentDescription(consumerKeyFieldDesc) + val redirectUriField = composeTestRule.onNodeWithContentDescription(redirectUriFieldDesc) + val scopesField = composeTestRule.onNodeWithContentDescription(scopesFieldDesc) + + consumerKeyField.assertIsDisplayed() + redirectUriField.assertIsDisplayed() + scopesField.assertIsDisplayed() + + // Test text input + consumerKeyField.performTextInput("test_consumer_key") + redirectUriField.performTextInput("test://redirect") + scopesField.performTextInput("api web") + + composeTestRule.waitForIdle() + + // Verify the text was entered + consumerKeyField.assertTextEquals("Consumer Key", "test_consumer_key") + redirectUriField.assertTextEquals("Redirect URI", "test://redirect") + scopesField.assertTextEquals("Scopes", "api web") + } + + @Test + fun loginOptionsActivity_MultipleToggles_WorkIndependently() { + // Set initial states + composeTestRule.activity.runOnUiThread { + SalesforceSDKManager.getInstance().useWebServerAuthentication = false + SalesforceSDKManager.getInstance().useHybridAuthentication = false + composeTestRule.activity.useWebServer.value = false + composeTestRule.activity.useHybridToken.value = false + } + composeTestRule.waitForIdle() + + // Verify reload flag is initially false + assertFalse( + "loginDevMenuReload should be false initially", + SalesforceSDKManager.getInstance().loginDevMenuReload + ) + + // Toggle web server flow + composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.sf__login_options_webserver_toggle_content_description) + ).performClick() + composeTestRule.waitForIdle() + + // Verify only web server flow changed + assertTrue(SalesforceSDKManager.getInstance().useWebServerAuthentication) + assertFalse(SalesforceSDKManager.getInstance().useHybridAuthentication) + + // Verify the reload flag was set + assertTrue( + "loginDevMenuReload should be true after first toggle", + SalesforceSDKManager.getInstance().loginDevMenuReload + ) + + // Toggle hybrid auth token + composeTestRule.onNodeWithContentDescription( + composeTestRule.activity.getString(R.string.sf__login_options_hybrid_toggle_content_description) + ).performClick() + composeTestRule.waitForIdle() + + // Verify both are now enabled + assertTrue(SalesforceSDKManager.getInstance().useWebServerAuthentication) + assertTrue(SalesforceSDKManager.getInstance().useHybridAuthentication) + + // Verify the reload flag is still true + assertTrue( + "loginDevMenuReload should still be true after second toggle", + SalesforceSDKManager.getInstance().loginDevMenuReload + ) + } +} From 4e954f594b2e8c1ecbb60e8a873c8e5b57e00487 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Thu, 13 Nov 2025 20:51:26 -0800 Subject: [PATCH 07/11] Add sections and additional info to DevInfoActivity and tests. --- .../androidsdk/app/SalesforceSDKManager.kt | 43 +++- .../developer/support/DevSupportInfo.kt | 101 ++++++++++ .../androidsdk/ui/DevInfoActivity.kt | 187 +++++++++++++++--- .../androidsdk/ui/DevInfoActivityTest.kt | 171 ++++++++++++++++ 4 files changed, 477 insertions(+), 25 deletions(-) create mode 100644 libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt create mode 100644 libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index b9cdcf78f5..558d0e7bc7 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -102,6 +102,7 @@ import com.salesforce.androidsdk.auth.idp.interfaces.IDPManager import com.salesforce.androidsdk.auth.idp.interfaces.SPManager import com.salesforce.androidsdk.config.AdminPermsManager import com.salesforce.androidsdk.config.AdminSettingsManager +import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.config.BootConfig.getBootConfig import com.salesforce.androidsdk.config.LoginServerManager import com.salesforce.androidsdk.config.LoginServerManager.PRODUCTION_LOGIN_URL @@ -109,6 +110,7 @@ import com.salesforce.androidsdk.config.LoginServerManager.SANDBOX_LOGIN_URL import com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL import com.salesforce.androidsdk.config.RuntimeConfig.ConfigKey.IDPAppPackageName import com.salesforce.androidsdk.config.RuntimeConfig.getRuntimeConfig +import com.salesforce.androidsdk.developer.support.DevSupportInfo import com.salesforce.androidsdk.developer.support.notifications.local.ShowDeveloperSupportNotifier.Companion.BROADCAST_INTENT_ACTION_SHOW_DEVELOPER_SUPPORT import com.salesforce.androidsdk.developer.support.notifications.local.ShowDeveloperSupportNotifier.Companion.hideDeveloperSupportNotification import com.salesforce.androidsdk.developer.support.notifications.local.ShowDeveloperSupportNotifier.Companion.showDeveloperSupportNotification @@ -1338,6 +1340,10 @@ open class SalesforceSDKManager protected constructor( } /** Information to display in the developer support dialog */ + @Deprecated( + "Will be removed in Mobile SDK 14.0, please use the new data class representation.", + ReplaceWith("devSupportInfo") + ) open val devSupportInfos: List get() = mutableListOf( "SDK Version", SDK_VERSION, @@ -1377,6 +1383,25 @@ open class SalesforceSDKManager protected constructor( } } + open val devSupportInfo + get() = DevSupportInfo( + SDK_VERSION, + appType, + userAgent, + userAccountManager.authenticatedUsers ?: emptyList(), + authConfig = listOf( + "Use Web Server Authentication" to "$useWebServerAuthentication", + "Use Hybrid Authentication Token" to "$useHybridAuthentication", + "Support Welcome Discovery" to "$supportsWelcomeDiscovery", + "Browser Login Enabled" to "$isBrowserLoginEnabled", + "IDP Enabled" to "$isIDPLoginFlowEnabled", + "Identity Provider" to "$isIdentityProvider", + ), + BootConfig.getBootConfig(appContext), + userAccountManager.currentUser, + getRuntimeConfig(appContext), + ) + private fun accessTokenExpiration(): String { val currentUser = userAccountManager.cachedCurrentUser var expiration = "Unknown" @@ -1418,6 +1443,20 @@ open class SalesforceSDKManager protected constructor( return devInfos } + /** + * Returns a string representation of the provided users. + * @param userAccounts The user accounts + * @return A string representation of the provided users. + */ + private fun usersToString( + vararg userAccounts: UserAccount + ) = join( + ", ", + userAccounts.map { userAccount -> + userAccount.accountName + } + ) + /** * Returns a string representation of the provided users. * @param userAccounts The user accounts @@ -1426,9 +1465,7 @@ open class SalesforceSDKManager protected constructor( private fun usersToString( userAccounts: List? ) = userAccounts?.toTypedArray()?.let { - join(", ", it.map { userAccount -> - userAccount.accountName - }) + usersToString(*it) } ?: "" /** Sends the logout completed intent */ diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt new file mode 100644 index 0000000000..fd6cf399da --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt @@ -0,0 +1,101 @@ +package com.salesforce.androidsdk.developer.support + +import com.salesforce.androidsdk.accounts.UserAccount +import com.salesforce.androidsdk.auth.JwtAccessToken +import com.salesforce.androidsdk.config.BootConfig +import com.salesforce.androidsdk.config.RuntimeConfig +import java.text.SimpleDateFormat +import java.util.Locale + +data class DevSupportInfo( + val sdkVersion: String, + val appType: String, + val userAgent: String, + val authenticatedUsers: List, + val authConfig: List>, + val bootConfig: BootConfig, + val currentUser: UserAccount?, + val runtimeConfig: RuntimeConfig, +) { + val bootConfigValues: List> by lazy { + with(bootConfig) { + val values = mutableListOf( + "Consumer Key" to remoteAccessConsumerKey, + "Redirect URI" to oauthRedirectURI, + "Scopes" to oauthScopes.joinToString(separator = " "), + ) + + if (appType == "Hybrid") { + values.addAll( + listOf( + "Local" to isLocal.toString(), + "Start Page" to startPage, + "Unauthenticated Start Page" to unauthenticatedStartPage, + "Error Page" to errorPage, + "Should Authenticate" to shouldAuthenticate().toString(), + "Attempt Offline Load" to attemptOfflineLoad().toString(), + ) + ) + } + + return@lazy values + } + } + + val authenticatedUsersString: String = authenticatedUsers.joinToString(separator = ",\n") { + "${it.displayName} (${it.username})" + } + + val currentUserInfo: List> by lazy { + if (currentUser != null) { + with(currentUser) { + return@lazy mutableListOf( + "Username" to username, + "Consumer Key" to clientId, + "Scopes" to scope, + "Instance URL" to instanceServer, + "Token Format" to tokenFormat, + "Access Token Expiration" to accessTokenExpiration, + "Beacon Child Consumer Key" to beaconChildConsumerKey, + ) + } + } else { + emptyList() + } + } + + val runtimeConfigValues: List> by lazy { + with(runtimeConfig) { + val values = mutableListOf( + "Managed App" to isManagedApp.toString() + ) + + if (isManagedApp) { + values.addAll(listOf( + "OAuth ID" to (getString(RuntimeConfig.ConfigKey.ManagedAppOAuthID) ?: "N/A"), + "Callback URL" to (getString(RuntimeConfig.ConfigKey.ManagedAppCallbackURL) ?: "N/A"), + "Require Cert Auth" to getBoolean(RuntimeConfig.ConfigKey.RequireCertAuth).toString(), + "Only Show Authorized Hosts" to getBoolean(RuntimeConfig.ConfigKey.OnlyShowAuthorizedHosts).toString(), + )) + } + + return@lazy values + } + } + + val accessTokenExpiration: String + get() { + var expiration = "Unknown" + + if (currentUser?.tokenFormat == "jwt") { + val jwtAccessToken = JwtAccessToken(currentUser.authToken) + val expirationDate = jwtAccessToken.expirationDate() + if (expirationDate != null) { + val dateFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + expiration = dateFormatter.format(expirationDate) + } + } + + return expiration + } +} \ No newline at end of file diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt index fed0d2c895..2fb3914c15 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt @@ -27,30 +27,50 @@ package com.salesforce.androidsdk.ui import android.content.ClipData +import android.content.res.Configuration import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Animatable import androidx.compose.foundation.clickable +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material3.CenterAlignedTopAppBar +import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme.colorScheme import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.res.stringResource @@ -60,8 +80,12 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.salesforce.androidsdk.R import com.salesforce.androidsdk.app.SalesforceSDKManager +import com.salesforce.androidsdk.developer.support.DevSupportInfo +import com.salesforce.androidsdk.ui.components.ICON_SIZE import com.salesforce.androidsdk.ui.components.PADDING_SIZE import com.salesforce.androidsdk.ui.components.TEXT_SIZE +import com.salesforce.androidsdk.ui.theme.sfDarkColors +import com.salesforce.androidsdk.ui.theme.sfLightColors import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -71,7 +95,7 @@ class DevInfoActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() - val devInfoList = prepareListData(SalesforceSDKManager.getInstance().devSupportInfos) + val devSupportInfo = SalesforceSDKManager.getInstance().devSupportInfo setContent { MaterialTheme(colorScheme = SalesforceSDKManager.getInstance().colorScheme()) { @@ -83,31 +107,68 @@ class DevInfoActivity : ComponentActivity() { ) } ) { innerPadding -> - DevInfoScreen(innerPadding, devInfoList) + DevInfoScreen(innerPadding, devSupportInfo) } } } } - - private fun prepareListData(rawData: List): List> { - return rawData.chunked(2).map { it[0] to it[1] } - } } -@OptIn(ExperimentalMaterial3Api::class) @Composable fun DevInfoScreen( paddingValues: PaddingValues, - devInfoList: List>, + devSupportInfo: DevSupportInfo, ) { LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(paddingValues) + modifier = Modifier.fillMaxSize().padding(paddingValues), ) { - items(devInfoList) { (name, value) -> + // Basic SDK Information (non-collapsible, no header) + val basicInfo = listOf( + "SDK Version" to devSupportInfo.sdkVersion, + "App Type" to devSupportInfo.appType, + "User Agent" to devSupportInfo.userAgent, + "Authenticated Users" to devSupportInfo.authenticatedUsersString, + ) + + items(basicInfo) { (name, value) -> DevInfoItem(name, value) } + + item { Spacer(modifier = Modifier.height(8.dp)) } + + // Auth Config Section + item { + CollapsibleSection( + title = "Authentication Configuration", + items = devSupportInfo.authConfig, + ) + } + + // Boot Config Section + item { + CollapsibleSection( + title = "Boot Configuration", + items = devSupportInfo.bootConfigValues, + ) + } + + // Current User Section + devSupportInfo.currentUser?.let { + item { + CollapsibleSection( + title = "Current User", + items = devSupportInfo.currentUserInfo, + ) + } + } + + // Runtime Config Section + item { + CollapsibleSection( + title = "Runtime Configuration", + items = devSupportInfo.runtimeConfigValues, + ) + } } } @@ -148,21 +209,103 @@ fun DevInfoItem(name: String, value: String?) { } } +@Composable +fun CollapsibleSection( + title: String, + items: List>, + defaultExpanded: Boolean = false, +) { + var expanded by remember { mutableStateOf(defaultExpanded) } + val chevronRotation = remember { Animatable(0f) } + + LaunchedEffect(expanded) { + chevronRotation.animateTo(targetValue = if (expanded) 180f else 0f) + } + + ElevatedCard( + modifier = Modifier.fillMaxWidth() + .padding(horizontal = PADDING_SIZE.dp, vertical = (PADDING_SIZE / 2).dp) + ) { + Column { + // Header with click handler + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .padding(PADDING_SIZE.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + fontSize = (TEXT_SIZE + 2).sp, + fontWeight = FontWeight.Bold, + color = colorScheme.primary + ) + Icon( + Icons.Default.KeyboardArrowDown, + modifier = Modifier.size(ICON_SIZE.dp) + .rotate(chevronRotation.value), + contentDescription = if (expanded) "Collapse" else "Expand", + ) + } + + AnimatedVisibility(visible = expanded) { + Column { + items.forEach { (name, value) -> + DevInfoItem(name, value) + } + } + } + } + } +} + @Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) @Composable private fun DevInfoItemPreview() { - DevInfoItem("Item Name", "Item Value") + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) sfDarkColors() else sfLightColors()) { + DevInfoItem("SDK Version", SalesforceSDKManager.SDK_VERSION) + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) +@Composable +private fun DevInfoItemLongPreview() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) sfDarkColors() else sfLightColors()) { + DevInfoItem("User Agent","SalesforceMobileSDK/13.2.0.dev android mobile/15 (sdk_gphone64_arm64) " + + "RestExplorer/1.0(1) Native uid_adc6e133bd0ac338 ftr_AI.SP.UA SecurityPatch/2024-09-05") + } } @Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) @Composable -private fun DevInfoScreenPreview() { - DevInfoScreen( - PaddingValues(0.dp), - devInfoList = listOf( - "SDK Version" to SalesforceSDKManager.SDK_VERSION, - "User Agent" to "SalesforceMobileSDK/13.2.0.dev android mobile/15 (sdk_gphone64_arm64) " + - "RestExplorer/1.0(1) Native uid_adc6e133bd0ac338 ftr_AI.SP.UA SecurityPatch/2024-09-05", - ), - ) +private fun CollapsibleSectionPreview() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) sfDarkColors() else sfLightColors()) { + CollapsibleSection( + "Collapsed", + listOf("User Agent" to "SalesforceMobileSDK/13.2.0.dev android mobile/15 (sdk_gphone64_arm64) " + + "RestExplorer/1.0(1) Native uid_adc6e133bd0ac338 ftr_AI.SP.UA SecurityPatch/2024-09-05"), + ) + } +} + +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) +@Composable +private fun CollapsibleSectionExpandedPreview() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) sfDarkColors() else sfLightColors()) { + CollapsibleSection( + "Expanded", + listOf( + "SDK Version" to SalesforceSDKManager.SDK_VERSION, + "User Agent" to "SalesforceMobileSDK/13.2.0.dev android mobile/15 (sdk_gphone64_arm64) " + + "RestExplorer/1.0(1) Native uid_adc6e133bd0ac338 ftr_AI.SP.UA SecurityPatch/2024-09-05" + ), + defaultExpanded = true, + ) + } } \ No newline at end of file diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt new file mode 100644 index 0000000000..a7a6d31f2d --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2025-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.androidsdk.ui + +import android.Manifest +import android.os.Build +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.rule.GrantPermissionRule +import com.salesforce.androidsdk.R +import com.salesforce.androidsdk.app.SalesforceSDKManager +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DevInfoActivityTest { + + @get:Rule + val composeTestRule = createAndroidComposeRule() + + // TODO: Remove if when min SDK version is 33 + @get:Rule + val permissionRule: GrantPermissionRule = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + GrantPermissionRule.grant(Manifest.permission.POST_NOTIFICATIONS) + } else { + GrantPermissionRule.grant() + } + + @Test + fun devInfoActivity_DisplaysTitle() { + val titleText = composeTestRule.activity.getString(R.string.sf__dev_support_title) + composeTestRule.onNodeWithText(titleText).assertIsDisplayed() + } + + @Test + fun devInfoActivity_DisplaysBasicSdkInfo() { + val devSupportInfo = SalesforceSDKManager.getInstance().devSupportInfo + + // Verify basic SDK information is displayed (non-collapsible) + composeTestRule.onNodeWithText("SDK Version").assertIsDisplayed() + composeTestRule.onNodeWithText(devSupportInfo.sdkVersion).assertIsDisplayed() + + composeTestRule.onNodeWithText("App Type").assertIsDisplayed() + composeTestRule.onNodeWithText(devSupportInfo.appType).assertIsDisplayed() + + composeTestRule.onNodeWithText("User Agent").assertIsDisplayed() + composeTestRule.onNodeWithText(devSupportInfo.userAgent).assertIsDisplayed() + } + + @Test + fun devInfoActivity_DisplaysCollapsibleSections() { + // Verify all collapsible section headers are displayed + composeTestRule.onNodeWithText("Authentication Configuration").assertIsDisplayed() + composeTestRule.onNodeWithText("Boot Configuration").assertIsDisplayed() + composeTestRule.onNodeWithText("Runtime Configuration").assertIsDisplayed() + } + + @Test + fun devInfoActivity_CollapsibleSections_StartCollapsed() { + val devSupportInfo = SalesforceSDKManager.getInstance().devSupportInfo + + // Verify sections start collapsed by checking that content is not initially visible + // Check that auth config items are not displayed initially + if (devSupportInfo.authConfig.isNotEmpty()) { + val firstAuthConfigKey = devSupportInfo.authConfig[0].first + composeTestRule.onNodeWithText(firstAuthConfigKey).assertIsNotDisplayed() + } + } + + @Test + fun devInfoActivity_CollapsibleSection_CanExpand() { + val devSupportInfo = SalesforceSDKManager.getInstance().devSupportInfo + + // Click on Authentication Configuration section to expand it + composeTestRule.onNodeWithText("Authentication Configuration").performClick() + + // Verify content is now visible + if (devSupportInfo.authConfig.isNotEmpty()) { + val firstAuthConfigKey = devSupportInfo.authConfig[0].first + composeTestRule.onNodeWithText(firstAuthConfigKey).assertIsDisplayed() + } + } + + @Test + fun devInfoActivity_CollapsibleSection_CanCollapse() { + val devSupportInfo = SalesforceSDKManager.getInstance().devSupportInfo + + // Expand the section + composeTestRule.onNodeWithText("Boot Configuration").performClick() + + // Verify content is visible + if (devSupportInfo.bootConfigValues.isNotEmpty()) { + val firstBootConfigKey = devSupportInfo.bootConfigValues[0].first + composeTestRule.onNodeWithText(firstBootConfigKey).assertIsDisplayed() + } + + // Collapse the section + composeTestRule.onNodeWithText("Boot Configuration").performClick() + + // Verify content is hidden again + if (devSupportInfo.bootConfigValues.isNotEmpty()) { + val firstBootConfigKey = devSupportInfo.bootConfigValues[0].first + composeTestRule.onNodeWithText(firstBootConfigKey).assertIsNotDisplayed() + } + } + + @Test + fun devInfoActivity_DisplaysBootConfigSection() { + val devSupportInfo = SalesforceSDKManager.getInstance().devSupportInfo + + // Expand Boot Configuration section + composeTestRule.onNodeWithText("Boot Configuration") + .performScrollTo() + .performClick() + + // Verify boot config items are displayed + assertTrue("Boot config should not be empty", devSupportInfo.bootConfigValues.isNotEmpty()) + + devSupportInfo.bootConfigValues.forEach { (key, _) -> + composeTestRule.onNodeWithText(key).assertIsDisplayed() + } + } + + @Test + fun devInfoActivity_DisplaysRuntimeConfigSection() { + val devSupportInfo = SalesforceSDKManager.getInstance().devSupportInfo + + // Expand Runtime Configuration section + composeTestRule.onNodeWithText("Runtime Configuration") + .performScrollTo() + .performClick() + + // Verify runtime config items are displayed + assertTrue("Runtime config should not be empty", devSupportInfo.runtimeConfigValues.isNotEmpty()) + + devSupportInfo.runtimeConfigValues.forEach { (key, _) -> + composeTestRule.onNodeWithText(key).assertIsDisplayed() + } + } +} From 37b8dbf000ff1b7f0b781ffa596d2edade97cef4 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Fri, 14 Nov 2025 10:12:32 -0800 Subject: [PATCH 08/11] Add DevSupportInfo tests. --- .../androidsdk/app/SalesforceSDKManager.kt | 3 +- .../androidsdk/ui/DevInfoActivity.kt | 5 + .../androidsdk/ui/LoginOptionsActivity.kt | 4 + .../app/SalesforceSDKManagerTests.kt | 43 ++ .../developer/support/DevSupportInfoTest.kt | 385 ++++++++++++++++++ 5 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index 558d0e7bc7..dcf99b8002 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -1292,7 +1292,8 @@ open class SalesforceSDKManager protected constructor( * features for * @return map of title to dev actions handlers to display */ - protected open fun getDevActions(frontActivity: Activity): Map { + @VisibleForTesting(otherwise = PROTECTED) + open fun getDevActions(frontActivity: Activity): Map { val actions = mutableMapOf( "Show dev info" to object : DevActionHandler { override fun onSelected() { diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt index 2fb3914c15..9c4d93e087 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt @@ -87,6 +87,7 @@ import com.salesforce.androidsdk.ui.components.TEXT_SIZE import com.salesforce.androidsdk.ui.theme.sfDarkColors import com.salesforce.androidsdk.ui.theme.sfLightColors import kotlinx.coroutines.launch +import javax.annotation.processing.Generated @OptIn(ExperimentalMaterial3Api::class) class DevInfoActivity : ComponentActivity() { @@ -261,6 +262,7 @@ fun CollapsibleSection( } } +@Generated // Prevents previews from being included in code coverage. @Preview(showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) @Composable @@ -270,6 +272,7 @@ private fun DevInfoItemPreview() { } } +@Generated @Preview(showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) @Composable @@ -280,6 +283,7 @@ private fun DevInfoItemLongPreview() { } } +@Generated @Preview(showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) @Composable @@ -293,6 +297,7 @@ private fun CollapsibleSectionPreview() { } } +@Generated @Preview(showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) @Composable diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt index ac75df2739..581291f275 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt @@ -52,6 +52,7 @@ import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.ui.components.PADDING_SIZE import com.salesforce.androidsdk.ui.components.TEXT_SIZE import com.salesforce.androidsdk.ui.theme.hintTextColor +import javax.annotation.processing.Generated class LoginOptionsActivity: ComponentActivity() { val useWebServer = MutableLiveData(SalesforceSDKManager.getInstance().useWebServerAuthentication) @@ -314,6 +315,7 @@ fun LoginOptionsScreen( } } +@Generated // Prevents previews from being included in code coverage. @Preview(showBackground = true) @Composable fun OptionsTogglePreview() { @@ -322,12 +324,14 @@ fun OptionsTogglePreview() { } } +@Generated @Preview(showBackground = true) @Composable fun BootConfigViewPreview() { BootConfigView() } +@Generated @Preview(showBackground = true) @Composable fun LoginOptionsScreenPreview() { diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt index 4ca221ccc7..0129114cf7 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/app/SalesforceSDKManagerTests.kt @@ -1,11 +1,13 @@ package com.salesforce.androidsdk.app +import android.app.Activity import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest import com.salesforce.androidsdk.auth.HttpAccess import com.salesforce.androidsdk.config.LoginServerManager.LoginServer import com.salesforce.androidsdk.config.LoginServerManager.PRODUCTION_LOGIN_URL import com.salesforce.androidsdk.config.LoginServerManager.WELCOME_LOGIN_URL +import com.salesforce.androidsdk.ui.LoginActivity import io.mockk.every import io.mockk.just import io.mockk.mockk @@ -17,7 +19,10 @@ import okhttp3.OkHttpClient import okhttp3.Response import okhttp3.ResponseBody import org.junit.After +import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -245,4 +250,42 @@ class SalesforceSDKManagerTests { assertFalse(SalesforceSDKManager.getInstance().isBrowserLoginEnabled) assertFalse(SalesforceSDKManager.getInstance().isShareBrowserSessionEnabled) } + + @Test + fun getDevActions_ReturnsAllActions_ForNonLoginActivity() { + // Arrange + val mockActivity = mockk(relaxed = true) + + // Act + val devActions = SalesforceSDKManager.getInstance().getDevActions(mockActivity) + + // Assert + assertEquals(4, devActions.size) + assertTrue(devActions.containsKey("Show dev info")) + assertTrue(devActions.containsKey("Login Options")) + assertTrue(devActions.containsKey("Logout")) + assertTrue(devActions.containsKey("Switch User")) + assertNotNull(devActions["Show dev info"]) + assertNotNull(devActions["Login Options"]) + assertNotNull(devActions["Logout"]) + assertNotNull(devActions["Switch User"]) + } + + @Test + fun getDevActions_ExcludesLogoutAndSwitchUser_ForLoginActivity() { + // Arrange + val mockLoginActivity = mockk(relaxed = true) + + // Act + val devActions = SalesforceSDKManager.getInstance().getDevActions(mockLoginActivity) + + // Assert + assertEquals(2, devActions.size) + assertTrue(devActions.containsKey("Show dev info")) + assertTrue(devActions.containsKey("Login Options")) + assertFalse(devActions.containsKey("Logout")) + assertFalse(devActions.containsKey("Switch User")) + assertNotNull(devActions["Show dev info"]) + assertNotNull(devActions["Login Options"]) + } } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt new file mode 100644 index 0000000000..e5fa088a91 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt @@ -0,0 +1,385 @@ +/* + * Copyright (c) 2025-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ +package com.salesforce.androidsdk.developer.support + +import android.os.Bundle +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.salesforce.androidsdk.accounts.UserAccount +import com.salesforce.androidsdk.config.BootConfig +import com.salesforce.androidsdk.config.RuntimeConfig +import io.mockk.every +import io.mockk.mockk +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.util.Calendar + +@RunWith(AndroidJUnit4::class) +class DevSupportInfoTest { + + @Test + fun devSupportInfo_BasicProperties_AreCorrect() { + val sdkVersion = "13.2.0" + val appType = "app_type_native" + val userAgent = "fake_user_agent" + val devSupportInfo = createBasicDevSupportInfo( + sdkVersion = sdkVersion, + appType = appType, + userAgent = userAgent, + ) + + assertEquals(sdkVersion, devSupportInfo.sdkVersion) + assertEquals(appType, devSupportInfo.appType) + assertEquals(userAgent, devSupportInfo.userAgent) + assertTrue(devSupportInfo.authenticatedUsers.isEmpty()) + assertTrue(devSupportInfo.authConfig.isNotEmpty()) + } + + @Test + fun bootConfigValues_NativeApp_ContainsBasicValues() { + val devSupportInfo = createBasicDevSupportInfo(appType = "Native") + + val bootConfigValues = devSupportInfo.bootConfigValues + + assertTrue(bootConfigValues.any { it.first == "Consumer Key" }) + assertTrue(bootConfigValues.any { it.first == "Redirect URI" }) + assertTrue(bootConfigValues.any { it.first == "Scopes" }) + + // Native apps should NOT have hybrid-specific fields + assertFalse(bootConfigValues.any { it.first == "Local" }) + assertFalse(bootConfigValues.any { it.first == "Start Page" }) + assertFalse(bootConfigValues.any { it.first == "Unauthenticated Start Page" }) + } + + @Test + fun bootConfigValues_HybridApp_ContainsHybridSpecificValues() { + val devSupportInfo = createBasicDevSupportInfo(appType = "Hybrid") + + val bootConfigValues = devSupportInfo.bootConfigValues + + // Should have basic values + assertTrue(bootConfigValues.any { it.first == "Consumer Key" }) + assertTrue(bootConfigValues.any { it.first == "Redirect URI" }) + assertTrue(bootConfigValues.any { it.first == "Scopes" }) + + // Should have hybrid-specific values + assertTrue(bootConfigValues.any { it.first == "Local" }) + assertTrue(bootConfigValues.any { it.first == "Start Page" }) + assertTrue(bootConfigValues.any { it.first == "Unauthenticated Start Page" }) + assertTrue(bootConfigValues.any { it.first == "Error Page" }) + assertTrue(bootConfigValues.any { it.first == "Should Authenticate" }) + assertTrue(bootConfigValues.any { it.first == "Attempt Offline Load" }) + } + + @Test + fun bootConfigValues_HybridApp_ValuesAreCorrect() { + val devSupportInfo = createBasicDevSupportInfo(appType = "Hybrid") + + val bootConfigValues = devSupportInfo.bootConfigValues + + assertEquals("test_consumer_key", bootConfigValues.find { it.first == "Consumer Key" }?.second) + assertEquals("test://redirect", bootConfigValues.find { it.first == "Redirect URI" }?.second) + assertEquals("api web", bootConfigValues.find { it.first == "Scopes" }?.second) + assertEquals("true", bootConfigValues.find { it.first == "Local" }?.second) + assertEquals("index.html", bootConfigValues.find { it.first == "Start Page" }?.second) + } + + @Test + fun runtimeConfigValues_UnmanagedApp_ContainsOnlyManagedFlag() { + val runtimeConfig = createMockRuntimeConfig(isManagedApp = false) + val devSupportInfo = createDevSupportInfoWithRuntimeConfig(runtimeConfig) + + val runtimeConfigValues = devSupportInfo.runtimeConfigValues + + assertEquals(1, runtimeConfigValues.size) + assertEquals("Managed App", runtimeConfigValues[0].first) + assertEquals("false", runtimeConfigValues[0].second) + } + + @Test + fun runtimeConfigValues_ManagedApp_ContainsManagedSpecificValues() { + val runtimeConfig = createMockRuntimeConfig( + isManagedApp = true, + oauthId = "managed_oauth_id", + callbackUrl = "managed://callback", + requireCertAuth = true, + onlyShowAuthorizedHosts = false + ) + val devSupportInfo = createDevSupportInfoWithRuntimeConfig(runtimeConfig) + + val runtimeConfigValues = devSupportInfo.runtimeConfigValues + + assertTrue(runtimeConfigValues.any { it.first == "Managed App" && it.second == "true" }) + assertTrue(runtimeConfigValues.any { it.first == "OAuth ID" && it.second == "managed_oauth_id" }) + assertTrue(runtimeConfigValues.any { it.first == "Callback URL" && it.second == "managed://callback" }) + assertTrue(runtimeConfigValues.any { it.first == "Require Cert Auth" && it.second == "true" }) + assertTrue(runtimeConfigValues.any { it.first == "Only Show Authorized Hosts" && it.second == "false" }) + } + + @Test + fun runtimeConfigValues_ManagedApp_NullValues_ShowNA() { + val runtimeConfig = createMockRuntimeConfig( + isManagedApp = true, + oauthId = null, + callbackUrl = null + ) + val devSupportInfo = createDevSupportInfoWithRuntimeConfig(runtimeConfig) + + val runtimeConfigValues = devSupportInfo.runtimeConfigValues + + assertTrue(runtimeConfigValues.any { it.first == "OAuth ID" && it.second == "N/A" }) + assertTrue(runtimeConfigValues.any { it.first == "Callback URL" && it.second == "N/A" }) + } + + @Test + fun authenticatedUsersString_EmptyList_ReturnsEmptyString() { + val devSupportInfo = createBasicDevSupportInfo() + + assertEquals("", devSupportInfo.authenticatedUsersString) + } + + @Test + fun authenticatedUsersString_MultipleUsers_FormatsCorrectly() { + val user1 = createMockUserAccount("user1@test.com", "User One") + val user2 = createMockUserAccount("user2@test.com", "User Two") + val devSupportInfo = createBasicDevSupportInfo(authenticatedUsers = listOf(user1, user2)) + + val expected = "User One (user1@test.com),\nUser Two (user2@test.com)" + assertEquals(expected, devSupportInfo.authenticatedUsersString) + } + + @Test + fun currentUserInfo_NoCurrentUser_ReturnsEmptyList() { + val devSupportInfo = createBasicDevSupportInfo(currentUser = null) + + assertTrue(devSupportInfo.currentUserInfo.isEmpty()) + } + + @Test + fun currentUserInfo_WithCurrentUser_ContainsAllFields() { + val user = createMockUserAccount( + username = "test@salesforce.com", + displayName = "Test User", + clientId = "test_client_id", + scope = "api web refresh_token", + instanceServer = "https://test.salesforce.com", + tokenFormat = "oauth2" + ) + val devSupportInfo = createBasicDevSupportInfo(currentUser = user) + + val currentUserInfo = devSupportInfo.currentUserInfo + + assertTrue(currentUserInfo.any { it.first == "Username" && it.second == "test@salesforce.com" }) + assertTrue(currentUserInfo.any { it.first == "Consumer Key" && it.second == "test_client_id" }) + assertTrue(currentUserInfo.any { it.first == "Scopes" && it.second == "api web refresh_token" }) + assertTrue(currentUserInfo.any { it.first == "Instance URL" && it.second == "https://test.salesforce.com" }) + assertTrue(currentUserInfo.any { it.first == "Token Format" && it.second == "oauth2" }) + } + + @Test + fun accessTokenExpiration_NoCurrentUser_ReturnsUnknown() { + val devSupportInfo = createBasicDevSupportInfo(currentUser = null) + + assertEquals("Unknown", devSupportInfo.accessTokenExpiration) + } + + @Test + fun accessTokenExpiration_NonJwtToken_ReturnsUnknown() { + val user = createMockUserAccount(tokenFormat = "oauth2") + val devSupportInfo = createBasicDevSupportInfo(currentUser = user) + + assertEquals("Unknown", devSupportInfo.accessTokenExpiration) + } + + @Test + fun accessTokenExpiration_JwtToken_ReturnsFormattedDate() { + // Create a JWT token that expires in the future + val futureTime = Calendar.getInstance().apply { + add(Calendar.HOUR, 2) + }.timeInMillis / 1000 + + val jwtToken = createMockJwtToken(expirationTime = futureTime) + val user = createMockUserAccount( + tokenFormat = "jwt", + authToken = jwtToken + ) + val devSupportInfo = createBasicDevSupportInfo(currentUser = user) + + val expiration = devSupportInfo.accessTokenExpiration + + // Should not be "Unknown" + assertNotEquals("Unknown", expiration) + + // Should be in the format "yyyy-MM-dd HH:mm:ss" + assertTrue(expiration.matches(Regex("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"))) + } + + // Helper methods + + private fun createBasicDevSupportInfo( + sdkVersion: String = "test_version", + appType: String = "Native", + userAgent: String = "TestUserAgent", + authenticatedUsers: List = emptyList(), + authConfig: List> = listOf("Test" to "Config"), + currentUser: UserAccount? = null + ): DevSupportInfo { + val bootConfig = createMockBootConfig() + val runtimeConfig = createMockRuntimeConfig(isManagedApp = false) + + return DevSupportInfo( + sdkVersion = sdkVersion, + appType = appType, + userAgent = userAgent, + authenticatedUsers = authenticatedUsers, + authConfig = authConfig, + bootConfig = bootConfig, + currentUser = currentUser, + runtimeConfig = runtimeConfig + ) + } + + private fun createDevSupportInfoWithRuntimeConfig( + runtimeConfig: RuntimeConfig + ): DevSupportInfo { + val bootConfig = createMockBootConfig() + + return DevSupportInfo( + sdkVersion = "test_version", + appType = "Native", + userAgent = "TestUserAgent", + authenticatedUsers = emptyList(), + authConfig = listOf("Test" to "Config"), + bootConfig = bootConfig, + currentUser = null, + runtimeConfig = runtimeConfig + ) + } + + private fun createMockBootConfig(): BootConfig { + return object : BootConfig() { + override fun getRemoteAccessConsumerKey() = "test_consumer_key" + override fun getOauthRedirectURI() = "test://redirect" + override fun getOauthScopes() = arrayOf("api", "web") + override fun isLocal() = true + override fun getStartPage() = "index.html" + override fun getUnauthenticatedStartPage() = "login.html" + override fun getErrorPage() = "error.html" + override fun shouldAuthenticate() = true + override fun attemptOfflineLoad() = false + } + } + + private fun createMockRuntimeConfig( + isManagedApp: Boolean, + oauthId: String? = null, + callbackUrl: String? = null, + requireCertAuth: Boolean = false, + onlyShowAuthorizedHosts: Boolean = false + ): RuntimeConfig { + return mockk(relaxed = true) { + every { isManagedApp() } returns isManagedApp + every { getString(RuntimeConfig.ConfigKey.ManagedAppOAuthID) } returns oauthId + every { getString(RuntimeConfig.ConfigKey.ManagedAppCallbackURL) } returns callbackUrl + every { getBoolean(RuntimeConfig.ConfigKey.RequireCertAuth) } returns requireCertAuth + every { getBoolean(RuntimeConfig.ConfigKey.OnlyShowAuthorizedHosts) } returns onlyShowAuthorizedHosts + } + } + + private fun createMockUserAccount( + username: String = "test@test.com", + displayName: String = "Test User", + clientId: String = "test_client_id", + scope: String = "api web", + instanceServer: String = "https://test.salesforce.com", + tokenFormat: String = "oauth2", + authToken: String = "test_token" + ): UserAccount { + return UserAccount( + Bundle().apply { + putString(UserAccount.USER_ID, "test_user_id") + putString(UserAccount.ORG_ID, "test_org_id") + putString(UserAccount.USERNAME, username) + putString(UserAccount.ACCOUNT_NAME, displayName) + putString(UserAccount.CLIENT_ID, clientId) + putString(UserAccount.LOGIN_SERVER, instanceServer) + putString(UserAccount.ID_URL, "$instanceServer/id/test_org_id/test_user_id") + putString(UserAccount.INSTANCE_SERVER, instanceServer) + putString(UserAccount.AUTH_TOKEN, authToken) + putString(UserAccount.REFRESH_TOKEN, "test_refresh_token") + putString(UserAccount.FIRST_NAME, "Test") + putString(UserAccount.LAST_NAME, "User") + putString(UserAccount.DISPLAY_NAME, displayName) + putString(UserAccount.EMAIL, username) + putString(UserAccount.PHOTO_URL, "$instanceServer/photo") + putString(UserAccount.THUMBNAIL_URL, "$instanceServer/thumbnail") + putString(UserAccount.LIGHTNING_DOMAIN, instanceServer) + putString(UserAccount.LIGHTNING_SID, "test_sid") + putString(UserAccount.VF_DOMAIN, instanceServer) + putString(UserAccount.VF_SID, "test_vf_sid") + putString(UserAccount.CONTENT_DOMAIN, instanceServer) + putString(UserAccount.CONTENT_SID, "test_content_sid") + putString(UserAccount.CSRF_TOKEN, "test_csrf_token") + putString(UserAccount.NATIVE_LOGIN, "false") + putString(UserAccount.LANGUAGE, "en_US") + putString(UserAccount.LOCALE, "en_US") + putString(UserAccount.SCOPE, scope) + putString(UserAccount.TOKEN_FORMAT, tokenFormat) + } + ) + } + + private fun createMockJwtToken(expirationTime: Long): String { + // Create a simple JWT token with the expiration time + // JWT format: header.payload.signature + val header = JSONObject().apply { + put("alg", "RS256") + put("typ", "JWT") + } + val payload = JSONObject().apply { + put("exp", expirationTime) + put("sub", "test_user") + put("iss", "https://test.salesforce.com") + } + + // Base64 encode (simplified - not proper base64url encoding for test purposes) + val headerEncoded = android.util.Base64.encodeToString( + header.toString().toByteArray(), + android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING + ) + val payloadEncoded = android.util.Base64.encodeToString( + payload.toString().toByteArray(), + android.util.Base64.URL_SAFE or android.util.Base64.NO_WRAP or android.util.Base64.NO_PADDING + ) + + return "$headerEncoded.$payloadEncoded.fake_signature" + } +} From 45dbec8a28d00a7e5fa497263a712929423cf22e Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Mon, 17 Nov 2025 16:24:30 -0800 Subject: [PATCH 09/11] Make new DevSupportInfo class backwards compatible. --- .github/workflows/reusable-workflow.yaml | 2 +- .../app/SalesforceReactSDKManager.java | 6 +- .../androidsdk/app/SalesforceSDKManager.kt | 97 +-- .../developer/support/DevSupportInfo.kt | 250 ++++++-- .../androidsdk/ui/DevInfoActivity.kt | 55 +- .../smartstore/app/SmartStoreSDKManager.java | 26 +- .../developer/support/DevSupportInfoTest.kt | 592 +++++++++++++++--- .../androidsdk/ui/DevInfoActivityTest.kt | 63 +- 8 files changed, 835 insertions(+), 256 deletions(-) diff --git a/.github/workflows/reusable-workflow.yaml b/.github/workflows/reusable-workflow.yaml index d38f5c49e2..99d34aaced 100644 --- a/.github/workflows/reusable-workflow.yaml +++ b/.github/workflows/reusable-workflow.yaml @@ -106,7 +106,7 @@ jobs: --directories-to-pull=/sdcard \ --results-dir=${GCLOUD_RESULTS_DIR} \ --results-history-name=${{ inputs.lib }} \ - --timeout=20m --no-auto-google-login --no-record-video --no-performance-metrics \ + --timeout=30m --no-auto-google-login --no-record-video --no-performance-metrics \ --num-flaky-test-attempts=${RETRIES} || true done - name: Copy Test Results diff --git a/libs/SalesforceReact/src/com/salesforce/androidsdk/reactnative/app/SalesforceReactSDKManager.java b/libs/SalesforceReact/src/com/salesforce/androidsdk/reactnative/app/SalesforceReactSDKManager.java index 0ab508036a..c147d608cb 100644 --- a/libs/SalesforceReact/src/com/salesforce/androidsdk/reactnative/app/SalesforceReactSDKManager.java +++ b/libs/SalesforceReact/src/com/salesforce/androidsdk/reactnative/app/SalesforceReactSDKManager.java @@ -26,10 +26,13 @@ */ package com.salesforce.androidsdk.reactnative.app; +import static androidx.annotation.VisibleForTesting.PROTECTED; + import android.app.Activity; import android.content.Context; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; import com.facebook.react.ReactPackage; import com.facebook.react.bridge.JavaScriptModule; @@ -165,7 +168,8 @@ public List createViewManagers( @NonNull @Override - protected Map getDevActions( + @VisibleForTesting(otherwise = PROTECTED) + public Map getDevActions( @NonNull final Activity frontActivity ) { Map devActions = super.getDevActions(frontActivity); diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index dcf99b8002..a288a426e7 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -102,7 +102,6 @@ import com.salesforce.androidsdk.auth.idp.interfaces.IDPManager import com.salesforce.androidsdk.auth.idp.interfaces.SPManager import com.salesforce.androidsdk.config.AdminPermsManager import com.salesforce.androidsdk.config.AdminSettingsManager -import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.config.BootConfig.getBootConfig import com.salesforce.androidsdk.config.LoginServerManager import com.salesforce.androidsdk.config.LoginServerManager.PRODUCTION_LOGIN_URL @@ -1317,8 +1316,8 @@ open class SalesforceSDKManager protected constructor( }, ) - // Do not show Logout or Switch User options in Dev menu on the Login screen. - if (frontActivity !is LoginActivity) { + // Do not show Logout or Switch User options in Dev menu on the Login screen or if there is no user(s). + if (frontActivity !is LoginActivity && userAccountManager.cachedCurrentUser != null) { actions["Logout"] = object : DevActionHandler { override fun onSelected() { logout(frontActivity = frontActivity, reason = LogoutReason.USER_LOGOUT) @@ -1356,52 +1355,60 @@ open class SalesforceSDKManager protected constructor( "Browser Login Enabled", "$isBrowserLoginEnabled", "IDP Enabled", "$isIDPLoginFlowEnabled", "Identity Provider", "$isIdentityProvider", - "Current User", userAccountManager.cachedCurrentUser?.accountName ?: "", - "Current User Scopes", userAccountManager.cachedCurrentUser?.scope?.replace(" ", ", ") ?: "", - "Access Token Expiration", accessTokenExpiration(), - "Authenticated Users", usersToString(userAccountManager.authenticatedUsers) + "Authenticated Users", userAccountManager.authenticatedUsers?.joinToString(separator = ",\n") { + "${it.displayName} (${it.username})" + } ?: "none", ).apply { - addAll( - getDevInfosFor( - getBootConfig(appContext).asJSON(), - "BootConfig" - ) - ) - val runtimeConfig = getRuntimeConfig(appContext) - addAll( - listOf( - "Managed?", - "${runtimeConfig.isManagedApp}" - ) - ) - if (runtimeConfig.isManagedApp) { - addAll( - getDevInfosFor( - runtimeConfig.asJSON(), - "Managed Pref" - ) - ) + val bootConfigValues = DevSupportInfo.parseBootConfigInfo(getBootConfig(appContext)) + addAll(bootConfigValues.flatMap { listOf(it.first, it.second) }) + + val currentUserValues = DevSupportInfo.parseUserInfoSection(userAccountManager.cachedCurrentUser) + currentUserValues?.let { (_, values) -> + addAll(values.flatMap { listOf(it.first, it.second) }) } + + val runtimeConfigValues = DevSupportInfo.parseRuntimeConfig(getRuntimeConfig(appContext)) + addAll(runtimeConfigValues.flatMap { listOf(it.first, it.second) }) } - open val devSupportInfo - get() = DevSupportInfo( - SDK_VERSION, - appType, - userAgent, - userAccountManager.authenticatedUsers ?: emptyList(), - authConfig = listOf( - "Use Web Server Authentication" to "$useWebServerAuthentication", - "Use Hybrid Authentication Token" to "$useHybridAuthentication", - "Support Welcome Discovery" to "$supportsWelcomeDiscovery", - "Browser Login Enabled" to "$isBrowserLoginEnabled", - "IDP Enabled" to "$isIDPLoginFlowEnabled", - "Identity Provider" to "$isIdentityProvider", - ), - BootConfig.getBootConfig(appContext), - userAccountManager.currentUser, - getRuntimeConfig(appContext), - ) +// val devSupportInfo: DevSupportInfo +// get() { +// val userList: String? = userAccountManager.authenticatedUsers?.joinToString(separator = ",\n") { +// "${it.displayName} (${it.username})" +// } +// val basicInfo = listOf( +// "SDK Version" to SDK_VERSION, +// "App Type" to appType, +// "User Agent" to userAgent, +// "Authenticated Users" to (userList ?: "None"), +// ) +// val authConfig = listOf( +// "Use Web Server Authentication" to "$useWebServerAuthentication", +// "Use Hybrid Authentication Token" to "$useHybridAuthentication", +// "Support Welcome Discovery" to "$supportsWelcomeDiscovery", +// "Browser Login Enabled" to "$isBrowserLoginEnabled", +// "IDP Enabled" to "$isIDPLoginFlowEnabled", +// "Identity Provider" to "$isIdentityProvider", +// ) +// +// return DevSupportInfo( +// basicInfo, +// authConfig, +// getBootConfig(appContext), +// userAccountManager.cachedCurrentUser, +// getRuntimeConfig(appContext), +// ) +// } +// +// TODO: Replace devSupportInfo with the above implementation when devSupportInfos is removed in 14.0. + open val devSupportInfo: DevSupportInfo + get() = DevSupportInfo.createFromLegacyDevInfos(devSupportInfos) + + private fun MutableList>.findValueAndRemove(key: String): String? = + find { it.first == key }?.let { pair -> + remove(pair) + pair.second + } private fun accessTokenExpiration(): String { val currentUser = userAccountManager.cachedCurrentUser diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt index fd6cf399da..f6d94dbdf9 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt @@ -1,101 +1,213 @@ +/* + * Copyright (c) 2025-present, salesforce.com, inc. + * All rights reserved. + * Redistribution and use of this software in source and binary forms, with or + * without modification, are permitted provided that the following conditions + * are met: + * - Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * - Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * - Neither the name of salesforce.com, inc. nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission of salesforce.com, inc. + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ package com.salesforce.androidsdk.developer.support import com.salesforce.androidsdk.accounts.UserAccount +import com.salesforce.androidsdk.app.SalesforceSDKManager import com.salesforce.androidsdk.auth.JwtAccessToken import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.config.RuntimeConfig import java.text.SimpleDateFormat import java.util.Locale +typealias DevInfoList = List> +typealias DevInfoSection = Pair + data class DevSupportInfo( - val sdkVersion: String, - val appType: String, - val userAgent: String, - val authenticatedUsers: List, - val authConfig: List>, - val bootConfig: BootConfig, - val currentUser: UserAccount?, - val runtimeConfig: RuntimeConfig, + val basicInfo: DevInfoList? = null, + val authConfigSection: DevInfoSection? = null, + val bootConfigSection: DevInfoSection? = null, + val currentUserSection: DevInfoSection? = null, + val runtimeConfigSection: DevInfoSection? = null, + val additionalSections: List? = null, ) { - val bootConfigValues: List> by lazy { - with(bootConfig) { - val values = mutableListOf( - "Consumer Key" to remoteAccessConsumerKey, - "Redirect URI" to oauthRedirectURI, - "Scopes" to oauthScopes.joinToString(separator = " "), + + constructor( + basicInfo: DevInfoList, + authConfig: DevInfoList, + bootConfig: BootConfig, + currentUser: UserAccount?, + runtimeConfig: RuntimeConfig, + ): this( + basicInfo, + "Authentication Configuration" to authConfig, + "Boot Configuration" to parseBootConfigInfo(bootConfig), + parseUserInfoSection(currentUser), + "Runtime Configuration" to parseRuntimeConfig(runtimeConfig), + ) + + companion object { + + // TODO: Remove this in 14.0 when the older devSupportInfos is removed. + internal fun createFromLegacyDevInfos(devSupportInfos: List): DevSupportInfo { + val legacyDevInfo: MutableList> = devSupportInfos.chunked(2) { it[0] to it[1] }.toMutableList() + val authConfigSection = legacyDevInfo.createSection( + sectionTitle = "Authentication Configuration", + "Use Web Server Authentication", + "Use Hybrid Authentication Token", + "Support Welcome Discovery", + "Browser Login Enabled", + "IDP Enabled", + "Identity Provider", + ) + val bootConfigSection = legacyDevInfo.createSection( + sectionTitle = "Boot Configuration", + /* ...keys = */ "Consumer Key", + "Redirect URI", + "Scopes", + "Local", + "Start Page", + "Unauthenticated Start Page", + "Error Page", + "Should Authenticate", + "Attempt Offline Load", + ) + val currentUserSection = legacyDevInfo.createSection( + sectionTitle = "Current User", + /* ...keys = */ "Username", + "Consumer Key", + "Scopes", + "Instance URL", + "Token Format", + "Access Token Expiration", + "Beacon Child Consumer Key", + ) + val runtimeConfigSection = legacyDevInfo.createSection( + sectionTitle = "Runtime Configuration", + /* ...keys = */ "Managed App", + "OAuth ID", + "Callback URL", + "Require Cert Auth", + "Only Show Authorized Hosts", + ) + val smartStoreSection = legacyDevInfo.createSection( + sectionTitle = "Smart Store", + /* ...keys = */ "SQLCipher version", + "SQLCipher Compile Options", + "SQLCipher Runtime Setting", + "User SmartStores", + "Global SmartStores", + "User Key-Value Stores", + "Global Key-Value Stores", ) - if (appType == "Hybrid") { - values.addAll( - listOf( - "Local" to isLocal.toString(), - "Start Page" to startPage, - "Unauthenticated Start Page" to unauthenticatedStartPage, - "Error Page" to errorPage, - "Should Authenticate" to shouldAuthenticate().toString(), - "Attempt Offline Load" to attemptOfflineLoad().toString(), - ) + return DevSupportInfo( + basicInfo = legacyDevInfo, + authConfigSection, + bootConfigSection, + currentUserSection, + runtimeConfigSection, + ).also { it.smartStoreSection = smartStoreSection } + } + + fun parseBootConfigInfo(bootConfig: BootConfig): DevInfoList { + with(bootConfig) { + val values = mutableListOf( + "Consumer Key" to remoteAccessConsumerKey, + "Redirect URI" to oauthRedirectURI, + "Scopes" to (oauthScopes?.joinToString(separator = " ") ?: ""), ) - } - return@lazy values + if (SalesforceSDKManager.getInstance().appType == "Hybrid") { + values.addAll( + listOf( + "Local" to isLocal.toString(), + "Start Page" to startPage, + "Unauthenticated Start Page" to unauthenticatedStartPage, + "Error Page" to errorPage, + "Should Authenticate" to shouldAuthenticate().toString(), + "Attempt Offline Load" to attemptOfflineLoad().toString(), + ) + ) + } + + return values + } } - } - - val authenticatedUsersString: String = authenticatedUsers.joinToString(separator = ",\n") { - "${it.displayName} (${it.username})" - } - val currentUserInfo: List> by lazy { - if (currentUser != null) { - with(currentUser) { - return@lazy mutableListOf( - "Username" to username, - "Consumer Key" to clientId, - "Scopes" to scope, - "Instance URL" to instanceServer, - "Token Format" to tokenFormat, - "Access Token Expiration" to accessTokenExpiration, - "Beacon Child Consumer Key" to beaconChildConsumerKey, - ) + fun parseUserInfoSection(currentUser: UserAccount?): DevInfoSection? { + if (currentUser == null) return null + + var accessTokenExpiration = "Unknown" + if (currentUser.tokenFormat == "jwt") { + val jwtAccessToken = JwtAccessToken(currentUser.authToken) + val expirationDate = jwtAccessToken.expirationDate() + if (expirationDate != null) { + val dateFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + accessTokenExpiration = dateFormatter.format(expirationDate) + } } - } else { - emptyList() + + return "Current User" to listOf( + "Username" to currentUser.username, + "Consumer Key" to currentUser.clientId, + "Scopes" to currentUser.scope, + "Instance URL" to currentUser.instanceServer, + "Token Format" to currentUser.tokenFormat, + "Access Token Expiration" to accessTokenExpiration, + "Beacon Child Consumer Key" to currentUser.beaconChildConsumerKey, + ) } - } - val runtimeConfigValues: List> by lazy { - with(runtimeConfig) { + fun parseRuntimeConfig(config: RuntimeConfig): DevInfoList { val values = mutableListOf( - "Managed App" to isManagedApp.toString() + "Managed App" to config.isManagedApp.toString() ) - - if (isManagedApp) { + + if (config.isManagedApp) { values.addAll(listOf( - "OAuth ID" to (getString(RuntimeConfig.ConfigKey.ManagedAppOAuthID) ?: "N/A"), - "Callback URL" to (getString(RuntimeConfig.ConfigKey.ManagedAppCallbackURL) ?: "N/A"), - "Require Cert Auth" to getBoolean(RuntimeConfig.ConfigKey.RequireCertAuth).toString(), - "Only Show Authorized Hosts" to getBoolean(RuntimeConfig.ConfigKey.OnlyShowAuthorizedHosts).toString(), + "OAuth ID" to (config.getString(RuntimeConfig.ConfigKey.ManagedAppOAuthID) ?: "N/A"), + "Callback URL" to (config.getString(RuntimeConfig.ConfigKey.ManagedAppCallbackURL) ?: "N/A"), + "Require Cert Auth" to config.getBoolean(RuntimeConfig.ConfigKey.RequireCertAuth).toString(), + "Only Show Authorized Hosts" to config.getBoolean(RuntimeConfig.ConfigKey.OnlyShowAuthorizedHosts).toString(), )) } - - return@lazy values + + return values } } - val accessTokenExpiration: String - get() { - var expiration = "Unknown" + var smartStoreSection: DevInfoSection? = null +} - if (currentUser?.tokenFormat == "jwt") { - val jwtAccessToken = JwtAccessToken(currentUser.authToken) - val expirationDate = jwtAccessToken.expirationDate() - if (expirationDate != null) { - val dateFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - expiration = dateFormatter.format(expirationDate) - } - } +/** + * Finds data pairs given a list of keys. Pairs are removed from the original list. + */ +private fun MutableList>.createSection(sectionTitle: String, vararg keys: String): DevInfoSection? { + val values = keys.mapNotNull { key -> + find { it.first == key }?.let { pair -> + remove(pair) + pair + } + } - return expiration + return if (values.isNotEmpty()) { + sectionTitle to values + } else { + null } } \ No newline at end of file diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt index 9c4d93e087..88a07d6820 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt @@ -123,52 +123,47 @@ fun DevInfoScreen( LazyColumn( modifier = Modifier.fillMaxSize().padding(paddingValues), ) { - // Basic SDK Information (non-collapsible, no header) - val basicInfo = listOf( - "SDK Version" to devSupportInfo.sdkVersion, - "App Type" to devSupportInfo.appType, - "User Agent" to devSupportInfo.userAgent, - "Authenticated Users" to devSupportInfo.authenticatedUsersString, - ) - - items(basicInfo) { (name, value) -> - DevInfoItem(name, value) + // Basic Info List + devSupportInfo.basicInfo?.let { + items(it) { (name, value) -> + DevInfoItem(name, value) + } } item { Spacer(modifier = Modifier.height(8.dp)) } // Auth Config Section - item { - CollapsibleSection( - title = "Authentication Configuration", - items = devSupportInfo.authConfig, - ) + devSupportInfo.authConfigSection?.let { (title, items) -> + item { + CollapsibleSection(title, items) + } } // Boot Config Section - item { - CollapsibleSection( - title = "Boot Configuration", - items = devSupportInfo.bootConfigValues, - ) + devSupportInfo.bootConfigSection?.let { (title, items) -> + item { + CollapsibleSection(title, items) + } } // Current User Section - devSupportInfo.currentUser?.let { + devSupportInfo.currentUserSection?.let { (title, items) -> item { - CollapsibleSection( - title = "Current User", - items = devSupportInfo.currentUserInfo, - ) + CollapsibleSection(title, items) } } // Runtime Config Section - item { - CollapsibleSection( - title = "Runtime Configuration", - items = devSupportInfo.runtimeConfigValues, - ) + devSupportInfo.runtimeConfigSection?.let { (title, items) -> + item { + CollapsibleSection(title, items) + } + } + + devSupportInfo.smartStoreSection?.let { (title, items) -> + item { + CollapsibleSection(title, items) + } } } } diff --git a/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java b/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java index 6ba30a03d0..87589ae53c 100644 --- a/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java +++ b/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java @@ -26,6 +26,7 @@ */ package com.salesforce.androidsdk.smartstore.app; +import static androidx.annotation.VisibleForTesting.PROTECTED; import static com.salesforce.androidsdk.smartstore.store.KeyValueEncryptedFileStore.KEY_VALUE_STORES; import android.app.Activity; @@ -33,6 +34,7 @@ import android.text.TextUtils; import androidx.annotation.NonNull; +import androidx.annotation.VisibleForTesting; import com.salesforce.androidsdk.accounts.UserAccount; import com.salesforce.androidsdk.app.SalesforceSDKManager; @@ -442,7 +444,8 @@ public void setupUserStoreFromDefaultConfig() { @NonNull @Override - protected Map getDevActions( + @VisibleForTesting(otherwise = PROTECTED) + public Map getDevActions( @NonNull final Activity frontActivity ) { Map devActions = super.getDevActions(frontActivity); @@ -481,6 +484,27 @@ public List getDevSupportInfos() { return devSupportInfos; } +// TODO: Use the below code in 14.0 when getDevSupportInfos is removed. +// +// @Override +// public @NotNull DevSupportInfo getDevSupportInfo() { +// DevSupportInfo devInfo = super.getDevSupportInfo(); +// Pair>> smartStoreSection = new Pair<>( +// "Smart Store", Arrays.asList( +// new Pair<>("SQLCipher version", getSmartStore().getSQLCipherVersion()), +// new Pair<>("SQLCipher Compile Options", TextUtils.join(", ", getSmartStore().getCompileOptions())), +// new Pair<>("SQLCipher Runtime Setting", TextUtils.join(", ", getSmartStore().getRuntimeSettings())), +// new Pair<>("User SmartStores", TextUtils.join(", ", getUserStoresPrefixList())), +// new Pair<>("Global SmartStores", TextUtils.join(", ", getGlobalStoresPrefixList())), +// new Pair<>("User Key-Value Stores", TextUtils.join(", ", getKeyValueStoresPrefixList())), +// new Pair<>("Global Key-Value Stores", TextUtils.join(", ", getGlobalKeyValueStoresPrefixList())) +// ) +// ); +// +// devInfo.setSmartStoreValues(smartStoreSection); +// return devInfo; +// } + /** * Get key value store with given name for current user * @param storeName store name diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt index e5fa088a91..4864b296a8 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt @@ -37,6 +37,7 @@ import org.json.JSONObject import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -50,24 +51,54 @@ class DevSupportInfoTest { val sdkVersion = "13.2.0" val appType = "app_type_native" val userAgent = "fake_user_agent" - val devSupportInfo = createBasicDevSupportInfo( - sdkVersion = sdkVersion, - appType = appType, - userAgent = userAgent, + + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to sdkVersion, + "App Type" to appType, + "User Agent" to userAgent, + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web" + ), + currentUserSection = null, + runtimeConfigSection = "Runtime Configuration" to listOf("Managed App" to "false") ) - assertEquals(sdkVersion, devSupportInfo.sdkVersion) - assertEquals(appType, devSupportInfo.appType) - assertEquals(userAgent, devSupportInfo.userAgent) - assertTrue(devSupportInfo.authenticatedUsers.isEmpty()) - assertTrue(devSupportInfo.authConfig.isNotEmpty()) + // Verify basic info contains the expected values + val basicInfo = devSupportInfo.basicInfo!! + assertTrue(basicInfo.any { it.first == "SDK Version" && it.second == sdkVersion }) + assertTrue(basicInfo.any { it.first == "App Type" && it.second == appType }) + assertTrue(basicInfo.any { it.first == "User Agent" && it.second == userAgent }) + + // Verify auth config section exists + assertTrue(devSupportInfo.authConfigSection != null) } @Test fun bootConfigValues_NativeApp_ContainsBasicValues() { - val devSupportInfo = createBasicDevSupportInfo(appType = "Native") + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web" + ), + currentUserSection = null, + runtimeConfigSection = "Runtime Configuration" to listOf("Managed App" to "false") + ) - val bootConfigValues = devSupportInfo.bootConfigValues + val bootConfigValues = devSupportInfo.bootConfigSection!!.second assertTrue(bootConfigValues.any { it.first == "Consumer Key" }) assertTrue(bootConfigValues.any { it.first == "Redirect URI" }) @@ -81,9 +112,30 @@ class DevSupportInfoTest { @Test fun bootConfigValues_HybridApp_ContainsHybridSpecificValues() { - val devSupportInfo = createBasicDevSupportInfo(appType = "Hybrid") + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Hybrid", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web", + "Local" to "true", + "Start Page" to "index.html", + "Unauthenticated Start Page" to "login.html", + "Error Page" to "error.html", + "Should Authenticate" to "true", + "Attempt Offline Load" to "false" + ), + currentUserSection = null, + runtimeConfigSection = "Runtime Configuration" to listOf("Managed App" to "false") + ) - val bootConfigValues = devSupportInfo.bootConfigValues + val bootConfigValues = devSupportInfo.bootConfigSection!!.second // Should have basic values assertTrue(bootConfigValues.any { it.first == "Consumer Key" }) @@ -101,9 +153,30 @@ class DevSupportInfoTest { @Test fun bootConfigValues_HybridApp_ValuesAreCorrect() { - val devSupportInfo = createBasicDevSupportInfo(appType = "Hybrid") + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Hybrid", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web", + "Local" to "true", + "Start Page" to "index.html", + "Unauthenticated Start Page" to "login.html", + "Error Page" to "error.html", + "Should Authenticate" to "true", + "Attempt Offline Load" to "false" + ), + currentUserSection = null, + runtimeConfigSection = "Runtime Configuration" to listOf("Managed App" to "false") + ) - val bootConfigValues = devSupportInfo.bootConfigValues + val bootConfigValues = devSupportInfo.bootConfigSection!!.second assertEquals("test_consumer_key", bootConfigValues.find { it.first == "Consumer Key" }?.second) assertEquals("test://redirect", bootConfigValues.find { it.first == "Redirect URI" }?.second) @@ -115,9 +188,25 @@ class DevSupportInfoTest { @Test fun runtimeConfigValues_UnmanagedApp_ContainsOnlyManagedFlag() { val runtimeConfig = createMockRuntimeConfig(isManagedApp = false) - val devSupportInfo = createDevSupportInfoWithRuntimeConfig(runtimeConfig) + + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web" + ), + currentUserSection = null, + runtimeConfigSection = "Runtime Configuration" to DevSupportInfo.parseRuntimeConfig(runtimeConfig) + ) - val runtimeConfigValues = devSupportInfo.runtimeConfigValues + val runtimeConfigValues = devSupportInfo.runtimeConfigSection!!.second assertEquals(1, runtimeConfigValues.size) assertEquals("Managed App", runtimeConfigValues[0].first) @@ -133,9 +222,25 @@ class DevSupportInfoTest { requireCertAuth = true, onlyShowAuthorizedHosts = false ) - val devSupportInfo = createDevSupportInfoWithRuntimeConfig(runtimeConfig) + + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web" + ), + currentUserSection = null, + runtimeConfigSection = "Runtime Configuration" to DevSupportInfo.parseRuntimeConfig(runtimeConfig) + ) - val runtimeConfigValues = devSupportInfo.runtimeConfigValues + val runtimeConfigValues = devSupportInfo.runtimeConfigSection!!.second assertTrue(runtimeConfigValues.any { it.first == "Managed App" && it.second == "true" }) assertTrue(runtimeConfigValues.any { it.first == "OAuth ID" && it.second == "managed_oauth_id" }) @@ -151,40 +256,103 @@ class DevSupportInfoTest { oauthId = null, callbackUrl = null ) - val devSupportInfo = createDevSupportInfoWithRuntimeConfig(runtimeConfig) + + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web" + ), + currentUserSection = null, + runtimeConfigSection = "Runtime Configuration" to DevSupportInfo.parseRuntimeConfig(runtimeConfig) + ) - val runtimeConfigValues = devSupportInfo.runtimeConfigValues + val runtimeConfigValues = devSupportInfo.runtimeConfigSection!!.second assertTrue(runtimeConfigValues.any { it.first == "OAuth ID" && it.second == "N/A" }) assertTrue(runtimeConfigValues.any { it.first == "Callback URL" && it.second == "N/A" }) } @Test - fun authenticatedUsersString_EmptyList_ReturnsEmptyString() { - val devSupportInfo = createBasicDevSupportInfo() + fun basicInfo_ContainsAuthenticatedUsers_EmptyList() { + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web" + ), + currentUserSection = null, + runtimeConfigSection = "Runtime Configuration" to listOf("Managed App" to "false") + ) - assertEquals("", devSupportInfo.authenticatedUsersString) + val basicInfo = devSupportInfo.basicInfo!! + val authenticatedUsersValue = basicInfo.find { it.first == "Authenticated Users" }?.second + assertEquals("", authenticatedUsersValue) } @Test - fun authenticatedUsersString_MultipleUsers_FormatsCorrectly() { - val user1 = createMockUserAccount("user1@test.com", "User One") - val user2 = createMockUserAccount("user2@test.com", "User Two") - val devSupportInfo = createBasicDevSupportInfo(authenticatedUsers = listOf(user1, user2)) + fun basicInfo_ContainsAuthenticatedUsers_MultipleUsers() { + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "User One (user1@test.com),\nUser Two (user2@test.com)" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web" + ), + currentUserSection = null, + runtimeConfigSection = "Runtime Configuration" to listOf("Managed App" to "false") + ) + val basicInfo = devSupportInfo.basicInfo!! + val authenticatedUsersValue = basicInfo.find { it.first == "Authenticated Users" }?.second val expected = "User One (user1@test.com),\nUser Two (user2@test.com)" - assertEquals(expected, devSupportInfo.authenticatedUsersString) + assertEquals(expected, authenticatedUsersValue) } @Test - fun currentUserInfo_NoCurrentUser_ReturnsEmptyList() { - val devSupportInfo = createBasicDevSupportInfo(currentUser = null) + fun currentUserSection_NoCurrentUser_ReturnsNull() { + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web" + ), + currentUserSection = null, + runtimeConfigSection = "Runtime Configuration" to listOf("Managed App" to "false") + ) - assertTrue(devSupportInfo.currentUserInfo.isEmpty()) + assertTrue(devSupportInfo.currentUserSection == null) } @Test - fun currentUserInfo_WithCurrentUser_ContainsAllFields() { + fun currentUserSection_WithCurrentUser_ContainsAllFields() { val user = createMockUserAccount( username = "test@salesforce.com", displayName = "Test User", @@ -193,9 +361,25 @@ class DevSupportInfoTest { instanceServer = "https://test.salesforce.com", tokenFormat = "oauth2" ) - val devSupportInfo = createBasicDevSupportInfo(currentUser = user) + + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web" + ), + currentUserSection = DevSupportInfo.parseUserInfoSection(user), + runtimeConfigSection = "Runtime Configuration" to listOf("Managed App" to "false") + ) - val currentUserInfo = devSupportInfo.currentUserInfo + val currentUserInfo = devSupportInfo.currentUserSection!!.second assertTrue(currentUserInfo.any { it.first == "Username" && it.second == "test@salesforce.com" }) assertTrue(currentUserInfo.any { it.first == "Consumer Key" && it.second == "test_client_id" }) @@ -205,22 +389,55 @@ class DevSupportInfoTest { } @Test - fun accessTokenExpiration_NoCurrentUser_ReturnsUnknown() { - val devSupportInfo = createBasicDevSupportInfo(currentUser = null) + fun currentUserSection_NoCurrentUser_NoAccessTokenExpiration() { + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web" + ), + currentUserSection = null, + runtimeConfigSection = "Runtime Configuration" to listOf("Managed App" to "false") + ) - assertEquals("Unknown", devSupportInfo.accessTokenExpiration) + assertTrue(devSupportInfo.currentUserSection == null) } @Test - fun accessTokenExpiration_NonJwtToken_ReturnsUnknown() { + fun currentUserSection_NonJwtToken_ReturnsUnknown() { val user = createMockUserAccount(tokenFormat = "oauth2") - val devSupportInfo = createBasicDevSupportInfo(currentUser = user) + + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web" + ), + currentUserSection = DevSupportInfo.parseUserInfoSection(user), + runtimeConfigSection = "Runtime Configuration" to listOf("Managed App" to "false") + ) - assertEquals("Unknown", devSupportInfo.accessTokenExpiration) + val currentUserInfo = devSupportInfo.currentUserSection!!.second + val expiration = currentUserInfo.find { it.first == "Access Token Expiration" }?.second + assertEquals("Unknown", expiration) } @Test - fun accessTokenExpiration_JwtToken_ReturnsFormattedDate() { + fun currentUserSection_JwtToken_ReturnsFormattedDate() { // Create a JWT token that expires in the future val futureTime = Calendar.getInstance().apply { add(Calendar.HOUR, 2) @@ -231,9 +448,26 @@ class DevSupportInfoTest { tokenFormat = "jwt", authToken = jwtToken ) - val devSupportInfo = createBasicDevSupportInfo(currentUser = user) + + val devSupportInfo = DevSupportInfo( + basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "" + ), + authConfigSection = "Authentication Configuration" to listOf("Test" to "Config"), + bootConfigSection = "Boot Configuration" to listOf( + "Consumer Key" to "test_consumer_key", + "Redirect URI" to "test://redirect", + "Scopes" to "api web" + ), + currentUserSection = DevSupportInfo.parseUserInfoSection(user), + runtimeConfigSection = "Runtime Configuration" to listOf("Managed App" to "false") + ) - val expiration = devSupportInfo.accessTokenExpiration + val currentUserInfo = devSupportInfo.currentUserSection!!.second + val expiration = currentUserInfo.find { it.first == "Access Token Expiration" }?.second!! // Should not be "Unknown" assertNotEquals("Unknown", expiration) @@ -242,62 +476,248 @@ class DevSupportInfoTest { assertTrue(expiration.matches(Regex("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}"))) } - // Helper methods - - private fun createBasicDevSupportInfo( - sdkVersion: String = "test_version", - appType: String = "Native", - userAgent: String = "TestUserAgent", - authenticatedUsers: List = emptyList(), - authConfig: List> = listOf("Test" to "Config"), - currentUser: UserAccount? = null - ): DevSupportInfo { - val bootConfig = createMockBootConfig() - val runtimeConfig = createMockRuntimeConfig(isManagedApp = false) + @Test + fun secondaryConstructor_ParsesBootConfigAndRuntimeConfig() { + val basicInfo = listOf( + "SDK Version" to "test_version", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "", + ) + val authConfig = listOf("Test" to "Config") + val bootConfig = mockk(relaxed = true) { + every { remoteAccessConsumerKey } returns "secondary_consumer_key" + every { oauthRedirectURI } returns "secondary://redirect" + every { oauthScopes } returns arrayOf("api", "web", "refresh_token") + } + val runtimeConfig = createMockRuntimeConfig(isManagedApp = true, oauthId = "secondary_oauth_id") + val user = createMockUserAccount(username = "secondary@test.com") - return DevSupportInfo( - sdkVersion = sdkVersion, - appType = appType, - userAgent = userAgent, - authenticatedUsers = authenticatedUsers, + val devSupportInfo = DevSupportInfo( + basicInfo = basicInfo, authConfig = authConfig, bootConfig = bootConfig, - currentUser = currentUser, + currentUser = user, runtimeConfig = runtimeConfig ) + + // Verify basic info is preserved + assertEquals(basicInfo, devSupportInfo.basicInfo) + + // Verify auth config section + assertEquals("Authentication Configuration", devSupportInfo.authConfigSection?.first) + assertEquals(authConfig, devSupportInfo.authConfigSection?.second) + + // Verify boot config was parsed + val bootConfigValues = devSupportInfo.bootConfigSection!!.second + assertTrue(bootConfigValues.any { it.first == "Consumer Key" && it.second == "secondary_consumer_key" }) + assertTrue(bootConfigValues.any { it.first == "Redirect URI" && it.second == "secondary://redirect" }) + assertTrue(bootConfigValues.any { it.first == "Scopes" && it.second == "api web refresh_token" }) + + // Verify current user was parsed + val currentUserInfo = devSupportInfo.currentUserSection!!.second + assertTrue(currentUserInfo.any { it.first == "Username" && it.second == "secondary@test.com" }) + + // Verify runtime config was parsed + val runtimeConfigValues = devSupportInfo.runtimeConfigSection!!.second + assertTrue(runtimeConfigValues.any { it.first == "Managed App" && it.second == "true" }) + assertTrue(runtimeConfigValues.any { it.first == "OAuth ID" && it.second == "secondary_oauth_id" }) + } + + @Test + fun createFromLegacyDevInfos_ExtractsSmartStoreSection() { + val legacyDevInfos = listOf( + "SDK Version", "13.2.0", + "App Type", "Native", + "User Agent", "TestUserAgent", + "SQLCipher version", "4.5.0", + "SQLCipher Compile Options", "OPTION1, OPTION2", + "SQLCipher Runtime Setting", "SETTING1, SETTING2", + "User SmartStores", "store1, store2", + "Global SmartStores", "global1", + "User Key-Value Stores", "kv1", + "Global Key-Value Stores", "kv2", + "Consumer Key", "test_key", + "Redirect URI", "test://redirect", + ) + + val devSupportInfo = DevSupportInfo.createFromLegacyDevInfos(legacyDevInfos) + + // Verify SmartStore section was extracted + assertNotNull(devSupportInfo.smartStoreSection) + assertEquals("Smart Store", devSupportInfo.smartStoreSection?.first) + + val smartStoreValues = devSupportInfo.smartStoreSection!!.second + assertEquals(7, smartStoreValues.size) + assertTrue(smartStoreValues.any { it.first == "SQLCipher version" && it.second == "4.5.0" }) + assertTrue(smartStoreValues.any { it.first == "SQLCipher Compile Options" && it.second == "OPTION1, OPTION2" }) + assertTrue(smartStoreValues.any { it.first == "SQLCipher Runtime Setting" && it.second == "SETTING1, SETTING2" }) + assertTrue(smartStoreValues.any { it.first == "User SmartStores" && it.second == "store1, store2" }) + assertTrue(smartStoreValues.any { it.first == "Global SmartStores" && it.second == "global1" }) + assertTrue(smartStoreValues.any { it.first == "User Key-Value Stores" && it.second == "kv1" }) + assertTrue(smartStoreValues.any { it.first == "Global Key-Value Stores" && it.second == "kv2" }) + + // Verify SmartStore values were removed from basicInfo + val basicInfo = devSupportInfo.basicInfo!! + assertFalse(basicInfo.any { it.first == "SQLCipher version" }) + assertFalse(basicInfo.any { it.first == "User SmartStores" }) + + // Verify other values remain in basicInfo + assertTrue(basicInfo.any { it.first == "SDK Version" && it.second == "13.2.0" }) + assertTrue(basicInfo.any { it.first == "App Type" && it.second == "Native" }) + assertTrue(basicInfo.any { it.first == "User Agent" && it.second == "TestUserAgent" }) + } + + @Test + fun createFromLegacyDevInfos_RemovesValuesFromBasicInfoWhenMovedToSections() { + val legacyDevInfos = listOf( + "SDK Version", "13.2.0", + "App Type", "Native", + "User Agent", "TestUserAgent", + "Use Web Server Authentication", "true", + "Browser Login Enabled", "false", + "Consumer Key", "test_key", + "Redirect URI", "test://redirect", + "Scopes", "api web", + "Username", "test@test.com", + "Instance URL", "https://test.salesforce.com", + "Managed App", "false", + ) + + val devSupportInfo = DevSupportInfo.createFromLegacyDevInfos(legacyDevInfos) + + val basicInfo = devSupportInfo.basicInfo!! + + // Verify auth config values were removed from basicInfo + assertFalse(basicInfo.any { it.first == "Use Web Server Authentication" }) + assertFalse(basicInfo.any { it.first == "Browser Login Enabled" }) + + // Verify boot config values were removed from basicInfo + assertFalse(basicInfo.any { it.first == "Consumer Key" }) + assertFalse(basicInfo.any { it.first == "Redirect URI" }) + assertFalse(basicInfo.any { it.first == "Scopes" }) + + // Verify current user values were removed from basicInfo + assertFalse(basicInfo.any { it.first == "Username" }) + assertFalse(basicInfo.any { it.first == "Instance URL" }) + + // Verify runtime config values were removed from basicInfo + assertFalse(basicInfo.any { it.first == "Managed App" }) + + // Verify only basic info values remain + assertTrue(basicInfo.any { it.first == "SDK Version" && it.second == "13.2.0" }) + assertTrue(basicInfo.any { it.first == "App Type" && it.second == "Native" }) + assertTrue(basicInfo.any { it.first == "User Agent" && it.second == "TestUserAgent" }) + assertEquals(3, basicInfo.size) + + // Verify values were moved to appropriate sections + assertNotNull(devSupportInfo.authConfigSection) + assertTrue(devSupportInfo.authConfigSection!!.second.any { it.first == "Use Web Server Authentication" }) + + assertNotNull(devSupportInfo.bootConfigSection) + assertTrue(devSupportInfo.bootConfigSection!!.second.any { it.first == "Consumer Key" }) + + assertNotNull(devSupportInfo.currentUserSection) + assertTrue(devSupportInfo.currentUserSection!!.second.any { it.first == "Username" }) + + assertNotNull(devSupportInfo.runtimeConfigSection) + assertTrue(devSupportInfo.runtimeConfigSection!!.second.any { it.first == "Managed App" }) } - private fun createDevSupportInfoWithRuntimeConfig( - runtimeConfig: RuntimeConfig - ): DevSupportInfo { - val bootConfig = createMockBootConfig() - - return DevSupportInfo( - sdkVersion = "test_version", - appType = "Native", - userAgent = "TestUserAgent", - authenticatedUsers = emptyList(), - authConfig = listOf("Test" to "Config"), + @Test + fun createFromLegacyDevInfos_ProducesSameResultAsSecondaryConstructor() { + // Create mock objects for secondary constructor + val bootConfig = mockk(relaxed = true) { + every { remoteAccessConsumerKey } returns "test_consumer_key" + every { oauthRedirectURI } returns "test://redirect" + every { oauthScopes } returns arrayOf("api", "web") + } + val runtimeConfig = createMockRuntimeConfig( + isManagedApp = true, + oauthId = "test_oauth_id", + callbackUrl = "test://callback", + requireCertAuth = true, + onlyShowAuthorizedHosts = false + ) + val user = createMockUserAccount( + username = "test@salesforce.com", + displayName = "Test User", + clientId = "test_client_id", + scope = "api web", + instanceServer = "https://test.salesforce.com", + tokenFormat = "oauth2" + ) + + // Create DevSupportInfo using secondary constructor + val basicInfo = listOf( + "SDK Version" to "13.2.0", + "App Type" to "Native", + "User Agent" to "TestUserAgent", + "Authenticated Users" to "Test User (test@salesforce.com)", + ) + val authConfig = listOf( + "Use Web Server Authentication" to "true", + "Browser Login Enabled" to "false", + ) + + val fromSecondaryConstructor = DevSupportInfo( + basicInfo = basicInfo, + authConfig = authConfig, bootConfig = bootConfig, - currentUser = null, + currentUser = user, runtimeConfig = runtimeConfig ) - } - private fun createMockBootConfig(): BootConfig { - return object : BootConfig() { - override fun getRemoteAccessConsumerKey() = "test_consumer_key" - override fun getOauthRedirectURI() = "test://redirect" - override fun getOauthScopes() = arrayOf("api", "web") - override fun isLocal() = true - override fun getStartPage() = "index.html" - override fun getUnauthenticatedStartPage() = "login.html" - override fun getErrorPage() = "error.html" - override fun shouldAuthenticate() = true - override fun attemptOfflineLoad() = false + // Create equivalent legacy dev infos list + val legacyDevInfos = mutableListOf() + + // Add basic info + basicInfo.forEach { (key, value) -> + legacyDevInfos.add(key) + legacyDevInfos.add(value) + } + + // Add auth config + authConfig.forEach { (key, value) -> + legacyDevInfos.add(key) + legacyDevInfos.add(value) } + + // Add boot config + legacyDevInfos.addAll(listOf( + "Consumer Key", "test_consumer_key", + "Redirect URI", "test://redirect", + "Scopes", "api web", + )) + + // Add current user + legacyDevInfos.addAll(listOf( + "Username", "test@salesforce.com", + "Consumer Key", "test_client_id", + "Scopes", "api web", + "Instance URL", "https://test.salesforce.com", + "Token Format", "oauth2", + "Access Token Expiration", "Unknown", + "Beacon Child Consumer Key", user.beaconChildConsumerKey, + )) + + // Add runtime config + legacyDevInfos.addAll(listOf( + "Managed App", "true", + "OAuth ID", "test_oauth_id", + "Callback URL", "test://callback", + "Require Cert Auth", "true", + "Only Show Authorized Hosts", "false", + )) + + val fromLegacy = DevSupportInfo.createFromLegacyDevInfos(legacyDevInfos) + + // Assert the two objects are identical + assertEquals(fromSecondaryConstructor, fromLegacy) } + // Helper methods + private fun createMockRuntimeConfig( isManagedApp: Boolean, oauthId: String? = null, diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt index a7a6d31f2d..d36f44d63c 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt @@ -68,14 +68,19 @@ class DevInfoActivityTest { val devSupportInfo = SalesforceSDKManager.getInstance().devSupportInfo // Verify basic SDK information is displayed (non-collapsible) + val basicInfo = devSupportInfo.basicInfo!! + composeTestRule.onNodeWithText("SDK Version").assertIsDisplayed() - composeTestRule.onNodeWithText(devSupportInfo.sdkVersion).assertIsDisplayed() + val sdkVersion = basicInfo.find { it.first == "SDK Version" }?.second!! + composeTestRule.onNodeWithText(sdkVersion).assertIsDisplayed() composeTestRule.onNodeWithText("App Type").assertIsDisplayed() - composeTestRule.onNodeWithText(devSupportInfo.appType).assertIsDisplayed() + val appType = basicInfo.find { it.first == "App Type" }?.second!! + composeTestRule.onNodeWithText(appType).assertIsDisplayed() composeTestRule.onNodeWithText("User Agent").assertIsDisplayed() - composeTestRule.onNodeWithText(devSupportInfo.userAgent).assertIsDisplayed() + val userAgent = basicInfo.find { it.first == "User Agent" }?.second!! + composeTestRule.onNodeWithText(userAgent).assertIsDisplayed() } @Test @@ -92,9 +97,11 @@ class DevInfoActivityTest { // Verify sections start collapsed by checking that content is not initially visible // Check that auth config items are not displayed initially - if (devSupportInfo.authConfig.isNotEmpty()) { - val firstAuthConfigKey = devSupportInfo.authConfig[0].first - composeTestRule.onNodeWithText(firstAuthConfigKey).assertIsNotDisplayed() + devSupportInfo.authConfigSection?.let { (_, items) -> + if (items.isNotEmpty()) { + val firstAuthConfigKey = items[0].first + composeTestRule.onNodeWithText(firstAuthConfigKey).assertIsNotDisplayed() + } } } @@ -106,9 +113,11 @@ class DevInfoActivityTest { composeTestRule.onNodeWithText("Authentication Configuration").performClick() // Verify content is now visible - if (devSupportInfo.authConfig.isNotEmpty()) { - val firstAuthConfigKey = devSupportInfo.authConfig[0].first - composeTestRule.onNodeWithText(firstAuthConfigKey).assertIsDisplayed() + devSupportInfo.authConfigSection?.let { (_, items) -> + if (items.isNotEmpty()) { + val firstAuthConfigKey = items[0].first + composeTestRule.onNodeWithText(firstAuthConfigKey).assertIsDisplayed() + } } } @@ -120,18 +129,22 @@ class DevInfoActivityTest { composeTestRule.onNodeWithText("Boot Configuration").performClick() // Verify content is visible - if (devSupportInfo.bootConfigValues.isNotEmpty()) { - val firstBootConfigKey = devSupportInfo.bootConfigValues[0].first - composeTestRule.onNodeWithText(firstBootConfigKey).assertIsDisplayed() + devSupportInfo.bootConfigSection?.let { (_, items) -> + if (items.isNotEmpty()) { + val firstBootConfigKey = items[0].first + composeTestRule.onNodeWithText(firstBootConfigKey).assertIsDisplayed() + } } // Collapse the section composeTestRule.onNodeWithText("Boot Configuration").performClick() // Verify content is hidden again - if (devSupportInfo.bootConfigValues.isNotEmpty()) { - val firstBootConfigKey = devSupportInfo.bootConfigValues[0].first - composeTestRule.onNodeWithText(firstBootConfigKey).assertIsNotDisplayed() + devSupportInfo.bootConfigSection?.let { (_, items) -> + if (items.isNotEmpty()) { + val firstBootConfigKey = items[0].first + composeTestRule.onNodeWithText(firstBootConfigKey).assertIsNotDisplayed() + } } } @@ -145,10 +158,12 @@ class DevInfoActivityTest { .performClick() // Verify boot config items are displayed - assertTrue("Boot config should not be empty", devSupportInfo.bootConfigValues.isNotEmpty()) - - devSupportInfo.bootConfigValues.forEach { (key, _) -> - composeTestRule.onNodeWithText(key).assertIsDisplayed() + devSupportInfo.bootConfigSection?.let { (_, items) -> + assertTrue("Boot config should not be empty", items.isNotEmpty()) + + items.forEach { (key, _) -> + composeTestRule.onNodeWithText(key).assertIsDisplayed() + } } } @@ -162,10 +177,12 @@ class DevInfoActivityTest { .performClick() // Verify runtime config items are displayed - assertTrue("Runtime config should not be empty", devSupportInfo.runtimeConfigValues.isNotEmpty()) - - devSupportInfo.runtimeConfigValues.forEach { (key, _) -> - composeTestRule.onNodeWithText(key).assertIsDisplayed() + devSupportInfo.runtimeConfigSection?.let { (_, items) -> + assertTrue("Runtime config should not be empty", items.isNotEmpty()) + + items.forEach { (key, _) -> + composeTestRule.onNodeWithText(key).assertIsDisplayed() + } } } } From 36509c297ae7f5272cea02fd712e8fce14464456 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Mon, 17 Nov 2025 17:35:32 -0800 Subject: [PATCH 10/11] Remove SmartStore string reference in SalesforceSDK library. --- .../developer/support/DevSupportInfo.kt | 16 +------ .../androidsdk/ui/DevInfoActivity.kt | 3 +- .../smartstore/app/SmartStoreSDKManager.java | 2 +- .../developer/support/DevSupportInfoTest.kt | 44 ------------------- 4 files changed, 5 insertions(+), 60 deletions(-) diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt index f6d94dbdf9..5580ca4bfe 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt @@ -43,7 +43,7 @@ data class DevSupportInfo( val bootConfigSection: DevInfoSection? = null, val currentUserSection: DevInfoSection? = null, val runtimeConfigSection: DevInfoSection? = null, - val additionalSections: List? = null, + val additionalSections: MutableList = mutableListOf(), ) { constructor( @@ -104,16 +104,6 @@ data class DevSupportInfo( "Require Cert Auth", "Only Show Authorized Hosts", ) - val smartStoreSection = legacyDevInfo.createSection( - sectionTitle = "Smart Store", - /* ...keys = */ "SQLCipher version", - "SQLCipher Compile Options", - "SQLCipher Runtime Setting", - "User SmartStores", - "Global SmartStores", - "User Key-Value Stores", - "Global Key-Value Stores", - ) return DevSupportInfo( basicInfo = legacyDevInfo, @@ -121,7 +111,7 @@ data class DevSupportInfo( bootConfigSection, currentUserSection, runtimeConfigSection, - ).also { it.smartStoreSection = smartStoreSection } + ) } fun parseBootConfigInfo(bootConfig: BootConfig): DevInfoList { @@ -190,8 +180,6 @@ data class DevSupportInfo( return values } } - - var smartStoreSection: DevInfoSection? = null } /** diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt index 88a07d6820..ab4c56ed4e 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt @@ -160,7 +160,8 @@ fun DevInfoScreen( } } - devSupportInfo.smartStoreSection?.let { (title, items) -> + // Additional Sections + devSupportInfo.additionalSections.forEach { (title, items) -> item { CollapsibleSection(title, items) } diff --git a/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java b/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java index 87589ae53c..eb5ab07d23 100644 --- a/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java +++ b/libs/SmartStore/src/com/salesforce/androidsdk/smartstore/app/SmartStoreSDKManager.java @@ -501,7 +501,7 @@ public List getDevSupportInfos() { // ) // ); // -// devInfo.setSmartStoreValues(smartStoreSection); +// devInfo.getAdditionalSections().add(smartStoreSection); // return devInfo; // } diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt index 4864b296a8..098a970506 100644 --- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt @@ -524,50 +524,6 @@ class DevSupportInfoTest { assertTrue(runtimeConfigValues.any { it.first == "OAuth ID" && it.second == "secondary_oauth_id" }) } - @Test - fun createFromLegacyDevInfos_ExtractsSmartStoreSection() { - val legacyDevInfos = listOf( - "SDK Version", "13.2.0", - "App Type", "Native", - "User Agent", "TestUserAgent", - "SQLCipher version", "4.5.0", - "SQLCipher Compile Options", "OPTION1, OPTION2", - "SQLCipher Runtime Setting", "SETTING1, SETTING2", - "User SmartStores", "store1, store2", - "Global SmartStores", "global1", - "User Key-Value Stores", "kv1", - "Global Key-Value Stores", "kv2", - "Consumer Key", "test_key", - "Redirect URI", "test://redirect", - ) - - val devSupportInfo = DevSupportInfo.createFromLegacyDevInfos(legacyDevInfos) - - // Verify SmartStore section was extracted - assertNotNull(devSupportInfo.smartStoreSection) - assertEquals("Smart Store", devSupportInfo.smartStoreSection?.first) - - val smartStoreValues = devSupportInfo.smartStoreSection!!.second - assertEquals(7, smartStoreValues.size) - assertTrue(smartStoreValues.any { it.first == "SQLCipher version" && it.second == "4.5.0" }) - assertTrue(smartStoreValues.any { it.first == "SQLCipher Compile Options" && it.second == "OPTION1, OPTION2" }) - assertTrue(smartStoreValues.any { it.first == "SQLCipher Runtime Setting" && it.second == "SETTING1, SETTING2" }) - assertTrue(smartStoreValues.any { it.first == "User SmartStores" && it.second == "store1, store2" }) - assertTrue(smartStoreValues.any { it.first == "Global SmartStores" && it.second == "global1" }) - assertTrue(smartStoreValues.any { it.first == "User Key-Value Stores" && it.second == "kv1" }) - assertTrue(smartStoreValues.any { it.first == "Global Key-Value Stores" && it.second == "kv2" }) - - // Verify SmartStore values were removed from basicInfo - val basicInfo = devSupportInfo.basicInfo!! - assertFalse(basicInfo.any { it.first == "SQLCipher version" }) - assertFalse(basicInfo.any { it.first == "User SmartStores" }) - - // Verify other values remain in basicInfo - assertTrue(basicInfo.any { it.first == "SDK Version" && it.second == "13.2.0" }) - assertTrue(basicInfo.any { it.first == "App Type" && it.second == "Native" }) - assertTrue(basicInfo.any { it.first == "User Agent" && it.second == "TestUserAgent" }) - } - @Test fun createFromLegacyDevInfos_RemovesValuesFromBasicInfoWhenMovedToSections() { val legacyDevInfos = listOf( From 6d4dd9ea0ca214c30946a392261772a572cd7706 Mon Sep 17 00:00:00 2001 From: Brandon Page Date: Tue, 18 Nov 2025 14:19:20 -0800 Subject: [PATCH 11/11] Remove unused code and introduce annotation to exclude UI previews from code coverage. --- .../androidsdk/app/SalesforceSDKManager.kt | 72 ------------------- .../androidsdk/ui/DevInfoActivity.kt | 10 +-- .../androidsdk/ui/LoginOptionsActivity.kt | 12 ++-- .../test/ExcludeFromJacocoGeneratedReport.kt | 5 ++ 4 files changed, 15 insertions(+), 84 deletions(-) create mode 100644 libs/SalesforceSDK/src/com/salesforce/androidsdk/util/test/ExcludeFromJacocoGeneratedReport.kt diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt index a288a426e7..b04f31058b 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -1404,78 +1404,6 @@ open class SalesforceSDKManager protected constructor( open val devSupportInfo: DevSupportInfo get() = DevSupportInfo.createFromLegacyDevInfos(devSupportInfos) - private fun MutableList>.findValueAndRemove(key: String): String? = - find { it.first == key }?.let { pair -> - remove(pair) - pair.second - } - - private fun accessTokenExpiration(): String { - val currentUser = userAccountManager.cachedCurrentUser - var expiration = "Unknown" - - if (currentUser?.tokenFormat == "jwt") { - val jwtAccessToken = JwtAccessToken(currentUser.authToken) - val expirationDate = jwtAccessToken.expirationDate() - if (expirationDate != null) { - val dateFormatter = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) - expiration = dateFormatter.format(expirationDate) - } - } - - return expiration - } - - - /** - * Information to display in the developer support dialog for a specified - * JSON configuration. - * @param jsonObject JSON for an object such as boot or runtime - * configuration - * @return The developer support dialog information - */ - private fun getDevInfosFor( - jsonObject: JSONObject?, - keyPrefix: String - ): List { - val devInfos: MutableList = ArrayList() - val jsonObjectResolved = jsonObject ?: return devInfos - val keys = jsonObjectResolved.keys() - while (keys.hasNext()) { - val key = keys.next() - devInfos.add("$keyPrefix - $key") - jsonObjectResolved.opt(key)?.toString()?.let { - devInfos.add(it) - } - } - return devInfos - } - - /** - * Returns a string representation of the provided users. - * @param userAccounts The user accounts - * @return A string representation of the provided users. - */ - private fun usersToString( - vararg userAccounts: UserAccount - ) = join( - ", ", - userAccounts.map { userAccount -> - userAccount.accountName - } - ) - - /** - * Returns a string representation of the provided users. - * @param userAccounts The user accounts - * @return A string representation of the provided users. - */ - private fun usersToString( - userAccounts: List? - ) = userAccounts?.toTypedArray()?.let { - usersToString(*it) - } ?: "" - /** Sends the logout completed intent */ private fun sendLogoutCompleteIntent(logoutReason: LogoutReason, userAccount: UserAccount?) = appContext.sendBroadcast(Intent( diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt index ab4c56ed4e..1b677f6572 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt @@ -86,8 +86,8 @@ import com.salesforce.androidsdk.ui.components.PADDING_SIZE import com.salesforce.androidsdk.ui.components.TEXT_SIZE import com.salesforce.androidsdk.ui.theme.sfDarkColors import com.salesforce.androidsdk.ui.theme.sfLightColors +import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport import kotlinx.coroutines.launch -import javax.annotation.processing.Generated @OptIn(ExperimentalMaterial3Api::class) class DevInfoActivity : ComponentActivity() { @@ -258,7 +258,7 @@ fun CollapsibleSection( } } -@Generated // Prevents previews from being included in code coverage. +@ExcludeFromJacocoGeneratedReport @Preview(showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) @Composable @@ -268,7 +268,7 @@ private fun DevInfoItemPreview() { } } -@Generated +@ExcludeFromJacocoGeneratedReport @Preview(showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) @Composable @@ -279,7 +279,7 @@ private fun DevInfoItemLongPreview() { } } -@Generated +@ExcludeFromJacocoGeneratedReport @Preview(showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) @Composable @@ -293,7 +293,7 @@ private fun CollapsibleSectionPreview() { } } -@Generated +@ExcludeFromJacocoGeneratedReport @Preview(showBackground = true) @Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) @Composable diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt index 581291f275..551d8058ec 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt @@ -52,7 +52,7 @@ import com.salesforce.androidsdk.config.BootConfig import com.salesforce.androidsdk.ui.components.PADDING_SIZE import com.salesforce.androidsdk.ui.components.TEXT_SIZE import com.salesforce.androidsdk.ui.theme.hintTextColor -import javax.annotation.processing.Generated +import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport class LoginOptionsActivity: ComponentActivity() { val useWebServer = MutableLiveData(SalesforceSDKManager.getInstance().useWebServerAuthentication) @@ -315,7 +315,7 @@ fun LoginOptionsScreen( } } -@Generated // Prevents previews from being included in code coverage. +@ExcludeFromJacocoGeneratedReport @Preview(showBackground = true) @Composable fun OptionsTogglePreview() { @@ -324,14 +324,14 @@ fun OptionsTogglePreview() { } } -@Generated +@ExcludeFromJacocoGeneratedReport @Preview(showBackground = true) @Composable fun BootConfigViewPreview() { BootConfigView() } -@Generated +@ExcludeFromJacocoGeneratedReport @Preview(showBackground = true) @Composable fun LoginOptionsScreenPreview() { @@ -348,6 +348,4 @@ fun LoginOptionsScreenPreview() { override fun getOauthRedirectURI() = redirect }, ) -} - - +} \ No newline at end of file diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/util/test/ExcludeFromJacocoGeneratedReport.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/util/test/ExcludeFromJacocoGeneratedReport.kt new file mode 100644 index 0000000000..34b1255ff9 --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/util/test/ExcludeFromJacocoGeneratedReport.kt @@ -0,0 +1,5 @@ +package com.salesforce.androidsdk.util.test + +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class ExcludeFromJacocoGeneratedReport \ No newline at end of file