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/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 + 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 0a514a1bc1..b04f31058b 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/app/SalesforceSDKManager.kt @@ -109,6 +109,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 @@ -132,6 +133,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 +206,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 @@ -386,6 +388,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. @@ -863,10 +868,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,141 +1291,118 @@ 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 + @VisibleForTesting(otherwise = 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 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) + } } - }, - - "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 */ + @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, "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", usersToString(userAccountManager.cachedCurrentUser), - "Scopes", (userAccountManager.cachedCurrentUser).scope, - "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) }) - 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) + val currentUserValues = DevSupportInfo.parseUserInfoSection(userAccountManager.cachedCurrentUser) + currentUserValues?.let { (_, values) -> + addAll(values.flatMap { listOf(it.first, it.second) }) } - } - 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) - } + val runtimeConfigValues = DevSupportInfo.parseRuntimeConfig(getRuntimeConfig(appContext)) + addAll(runtimeConfigValues.flatMap { listOf(it.first, it.second) }) } - 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) - } ?: "" +// 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) /** Sends the logout completed intent */ private fun sendLogoutCompleteIntent(logoutReason: LogoutReason, userAccount: UserAccount?) = @@ -1582,7 +1564,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 +1587,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 +1820,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 +1849,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/DevSupportInfo.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt new file mode 100644 index 0000000000..5580ca4bfe --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/developer/support/DevSupportInfo.kt @@ -0,0 +1,201 @@ +/* + * 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 basicInfo: DevInfoList? = null, + val authConfigSection: DevInfoSection? = null, + val bootConfigSection: DevInfoSection? = null, + val currentUserSection: DevInfoSection? = null, + val runtimeConfigSection: DevInfoSection? = null, + val additionalSections: MutableList = mutableListOf(), +) { + + 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", + ) + + return DevSupportInfo( + basicInfo = legacyDevInfo, + authConfigSection, + bootConfigSection, + currentUserSection, + runtimeConfigSection, + ) + } + + fun parseBootConfigInfo(bootConfig: BootConfig): DevInfoList { + with(bootConfig) { + val values = mutableListOf( + "Consumer Key" to remoteAccessConsumerKey, + "Redirect URI" to oauthRedirectURI, + "Scopes" to (oauthScopes?.joinToString(separator = " ") ?: ""), + ) + + 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 + } + } + + 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) + } + } + + 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, + ) + } + + fun parseRuntimeConfig(config: RuntimeConfig): DevInfoList { + val values = mutableListOf( + "Managed App" to config.isManagedApp.toString() + ) + + if (config.isManagedApp) { + values.addAll(listOf( + "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 values + } + } +} + +/** + * 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 if (values.isNotEmpty()) { + sectionTitle to values + } else { + null + } +} \ No newline at end of file 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..1b677f6572 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/DevInfoActivity.kt @@ -26,91 +26,287 @@ */ 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.material3.TopAppBar 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.graphics.Color +import androidx.compose.ui.draw.rotate +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.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 com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport +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) + val devSupportInfo = SalesforceSDKManager.getInstance().devSupportInfo setContent { MaterialTheme(colorScheme = SalesforceSDKManager.getInstance().colorScheme()) { Scaffold( contentWindowInsets = WindowInsets.safeDrawing, topBar = { - TopAppBar( + CenterAlignedTopAppBar( title = { Text(stringResource(id = R.string.sf__dev_support_title)) } ) } ) { 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( - contentPadding = paddingValues, - modifier = Modifier - .fillMaxSize() + modifier = Modifier.fillMaxSize().padding(paddingValues), ) { - items(devInfoList) { (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 + devSupportInfo.authConfigSection?.let { (title, items) -> + item { + CollapsibleSection(title, items) + } + } + + // Boot Config Section + devSupportInfo.bootConfigSection?.let { (title, items) -> + item { + CollapsibleSection(title, items) + } + } + + // Current User Section + devSupportInfo.currentUserSection?.let { (title, items) -> + item { + CollapsibleSection(title, items) + } + } + + // Runtime Config Section + devSupportInfo.runtimeConfigSection?.let { (title, items) -> + item { + CollapsibleSection(title, items) + } + } + + // Additional Sections + devSupportInfo.additionalSections.forEach { (title, items) -> + item { + CollapsibleSection(title, items) + } } } } @Composable fun DevInfoItem(name: String, value: String?) { + val coroutineScope = rememberCoroutineScope() + val clipboard = LocalClipboard.current + Column( modifier = Modifier .fillMaxWidth() - .padding(8.dp) + .padding(start = PADDING_SIZE.dp, end = PADDING_SIZE.dp, top = PADDING_SIZE.dp) + .clickable { + // 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, + fontSize = TEXT_SIZE.sp, + fontWeight = FontWeight.Bold, + color = colorScheme.onSecondary, + ) + Text( + text = value ?: "", + fontSize = TEXT_SIZE.sp, + color = colorScheme.onSecondaryContainer, + modifier = Modifier.padding(top = PADDING_SIZE.dp), + ) + HorizontalDivider(modifier = Modifier.padding(top = PADDING_SIZE.dp)) + } +} + +@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) ) { - Text(text = name, fontWeight = FontWeight.Bold) - Text(text = value ?: "", color = Color.Gray) - HorizontalDivider() + 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) + } + } + } + } + } +} + +@ExcludeFromJacocoGeneratedReport +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) +@Composable +private fun DevInfoItemPreview() { + MaterialTheme(colorScheme = if (isSystemInDarkTheme()) sfDarkColors() else sfLightColors()) { + DevInfoItem("SDK Version", SalesforceSDKManager.SDK_VERSION) + } +} + +@ExcludeFromJacocoGeneratedReport +@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") } } + +@ExcludeFromJacocoGeneratedReport +@Preview(showBackground = true) +@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818) +@Composable +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"), + ) + } +} + +@ExcludeFromJacocoGeneratedReport +@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/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt index fe9d4d77a2..560e2ffaec 100644 --- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginActivity.kt @@ -339,6 +339,17 @@ open class LoginActivity : FragmentActivity() { override fun onResume() { super.onResume() wasBackgrounded = false + + // If debug LoginOptions were changed reload the webview. + // + // 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 + } + } } 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 new file mode 100644 index 0000000000..551d8058ec --- /dev/null +++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/LoginOptionsActivity.kt @@ -0,0 +1,351 @@ +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.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.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 +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.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.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 +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 +import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport + +class LoginOptionsActivity: ComponentActivity() { + val useWebServer = MutableLiveData(SalesforceSDKManager.getInstance().useWebServerAuthentication) + val useHybridToken = MutableLiveData(SalesforceSDKManager.getInstance().useHybridAuthentication) + val supportWelcomeDiscovery = MutableLiveData(SalesforceSDKManager.getInstance().supportsWelcomeDiscovery) + + @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 + }, + ) + supportWelcomeDiscovery.observe( + /* owner = */ this, + Observer { + // onChanged lambda + value -> SalesforceSDKManager.getInstance().supportsWelcomeDiscovery = value + }, + ) + + setContent { + MaterialTheme(colorScheme = SalesforceSDKManager.getInstance().colorScheme()) { + Scaffold( + contentWindowInsets = WindowInsets.safeDrawing, + topBar = { + CenterAlignedTopAppBar( + title = { + Text(stringResource(R.string.sf__dev_support_login_options_title)) + } + ) + } + ) { innerPadding -> + LoginOptionsScreen( + innerPadding, + useWebServer, + useHybridToken, + supportWelcomeDiscovery, + ) + } + } + } + } +} + +@Composable +fun OptionToggle( + title: String, + contentDescription: String, + optionData: MutableLiveData, +) { + val checked by optionData.observeAsState(initial = false) + + Row( + modifier = Modifier.fillMaxWidth().padding(PADDING_SIZE.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + title, + modifier = Modifier.height(50.dp).wrapContentHeight(align = Alignment.CenterVertically), + ) + Switch( + checked = checked, + 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, + onValueChange = { dynamicConsumerKey = it }, + label = { Text("Consumer Key") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(PADDING_SIZE.dp) + .semantics { contentDescription = consumerKeyFieldDesc }, + 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, + onValueChange = { dynamicRedirectUri = it }, + label = { Text("Redirect URI") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(PADDING_SIZE.dp) + .semantics { contentDescription = redirectFieldDesc }, + 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, + onValueChange = { dynamicScopes = it }, + label = { Text("Scopes") }, + singleLine = true, + modifier = Modifier + .fillMaxWidth() + .padding(PADDING_SIZE.dp) + .semantics { contentDescription = scopesFieldDesc }, + 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 = PADDING_SIZE.dp, end = PADDING_SIZE.dp, top = PADDING_SIZE.dp) + ) { + Text( + text = name, + fontSize = TEXT_SIZE.sp, + color = colorScheme.onSecondary, + ) + Text( + text = value ?: "", + fontSize = TEXT_SIZE.sp, + color = colorScheme.onSecondaryContainer, + modifier = Modifier.padding(top = PADDING_SIZE.dp), + ) + } +} + +@Composable +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) + .fillMaxSize() + .verticalScroll(rememberScrollState()), + ) { + 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() + + Text( + text = "Boot Config File", + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(PADDING_SIZE.dp) + ) + + 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(PADDING_SIZE.dp), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + 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 + } + ) + } + + if (useDynamicConfig) { + BootConfigView() + } + } +} + +@ExcludeFromJacocoGeneratedReport +@Preview(showBackground = true) +@Composable +fun OptionsTogglePreview() { + Column { + OptionToggle("Test Toggle", "", MutableLiveData(false)) + } +} + +@ExcludeFromJacocoGeneratedReport +@Preview(showBackground = true) +@Composable +fun BootConfigViewPreview() { + BootConfigView() +} + +@ExcludeFromJacocoGeneratedReport +@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 = object : BootConfig() { + override fun getRemoteAccessConsumerKey() = consumerKey + override fun getOauthRedirectURI() = redirect + }, + ) +} \ No newline at end of file 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/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 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..eb5ab07d23 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.getAdditionalSections().add(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/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/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/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/developer/support/DevSupportInfoTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt new file mode 100644 index 0000000000..098a970506 --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/developer/support/DevSupportInfoTest.kt @@ -0,0 +1,761 @@ +/* + * 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.assertNotNull +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 = 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") + ) + + // 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 = 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.bootConfigSection!!.second + + 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 = 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.bootConfigSection!!.second + + // 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 = 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.bootConfigSection!!.second + + 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 = 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.runtimeConfigSection!!.second + + 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 = 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.runtimeConfigSection!!.second + + 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 = 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.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 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") + ) + + val basicInfo = devSupportInfo.basicInfo!! + val authenticatedUsersValue = basicInfo.find { it.first == "Authenticated Users" }?.second + assertEquals("", authenticatedUsersValue) + } + + @Test + 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, authenticatedUsersValue) + } + + @Test + 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.currentUserSection == null) + } + + @Test + fun currentUserSection_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 = 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.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" }) + 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 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") + ) + + assertTrue(devSupportInfo.currentUserSection == null) + } + + @Test + fun currentUserSection_NonJwtToken_ReturnsUnknown() { + val user = createMockUserAccount(tokenFormat = "oauth2") + + 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.currentUserSection!!.second + val expiration = currentUserInfo.find { it.first == "Access Token Expiration" }?.second + assertEquals("Unknown", expiration) + } + + @Test + fun currentUserSection_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 = 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.currentUserSection!!.second + val expiration = currentUserInfo.find { it.first == "Access Token Expiration" }?.second!! + + // 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}"))) + } + + @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") + + val devSupportInfo = DevSupportInfo( + basicInfo = basicInfo, + authConfig = authConfig, + bootConfig = bootConfig, + 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_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" }) + } + + @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 = user, + runtimeConfig = runtimeConfig + ) + + // 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, + 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" + } +} 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/DevInfoActivityTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt new file mode 100644 index 0000000000..d36f44d63c --- /dev/null +++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/DevInfoActivityTest.kt @@ -0,0 +1,188 @@ +/* + * 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) + val basicInfo = devSupportInfo.basicInfo!! + + composeTestRule.onNodeWithText("SDK Version").assertIsDisplayed() + val sdkVersion = basicInfo.find { it.first == "SDK Version" }?.second!! + composeTestRule.onNodeWithText(sdkVersion).assertIsDisplayed() + + composeTestRule.onNodeWithText("App Type").assertIsDisplayed() + val appType = basicInfo.find { it.first == "App Type" }?.second!! + composeTestRule.onNodeWithText(appType).assertIsDisplayed() + + composeTestRule.onNodeWithText("User Agent").assertIsDisplayed() + val userAgent = basicInfo.find { it.first == "User Agent" }?.second!! + composeTestRule.onNodeWithText(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 + devSupportInfo.authConfigSection?.let { (_, items) -> + if (items.isNotEmpty()) { + val firstAuthConfigKey = items[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 + devSupportInfo.authConfigSection?.let { (_, items) -> + if (items.isNotEmpty()) { + val firstAuthConfigKey = items[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 + 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 + devSupportInfo.bootConfigSection?.let { (_, items) -> + if (items.isNotEmpty()) { + val firstBootConfigKey = items[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 + devSupportInfo.bootConfigSection?.let { (_, items) -> + assertTrue("Boot config should not be empty", items.isNotEmpty()) + + items.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 + devSupportInfo.runtimeConfigSection?.let { (_, items) -> + assertTrue("Runtime config should not be empty", items.isNotEmpty()) + + items.forEach { (key, _) -> + composeTestRule.onNodeWithText(key).assertIsDisplayed() + } + } + } +} 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 + ) + } +} 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))