diff --git a/.github/workflows/nightly.yaml b/.github/workflows/nightly.yaml
index 64b71c65fe..9c11c7dd25 100644
--- a/.github/workflows/nightly.yaml
+++ b/.github/workflows/nightly.yaml
@@ -37,6 +37,17 @@ jobs:
matrix:
lib: [SalesforceReact]
uses: ./.github/workflows/reusable-workflow.yaml
+ with:
+ lib: ${{ matrix.lib }}
+ secrets: inherit
+ android-nightly-UI-Tests:
+ if: success() || failure()
+ needs: [android-nightly-React]
+ strategy:
+ fail-fast: false
+ matrix:
+ lib: [App]
+ uses: ./.github/workflows/reusable-workflow.yaml
with:
lib: ${{ matrix.lib }}
secrets: inherit
\ No newline at end of file
diff --git a/.github/workflows/reusable-workflow.yaml b/.github/workflows/reusable-lib-workflow.yaml
similarity index 100%
rename from .github/workflows/reusable-workflow.yaml
rename to .github/workflows/reusable-lib-workflow.yaml
diff --git a/.github/workflows/reusable-ui-workflow.yaml b/.github/workflows/reusable-ui-workflow.yaml
new file mode 100644
index 0000000000..f250f36d70
--- /dev/null
+++ b/.github/workflows/reusable-ui-workflow.yaml
@@ -0,0 +1,140 @@
+on:
+ workflow_call:
+ inputs:
+ is_pr:
+ type: boolean
+ default: false
+
+jobs:
+ test-android:
+ runs-on: ubuntu-latest
+ env:
+ BUNDLE_GEMFILE: ${{ github.workspace }}/.github/DangerFiles/Gemfile
+ steps:
+ - uses: actions/checkout@v4
+ if: ${{ inputs.is_pr }}
+ with:
+ # We need a sufficient depth or Danger will occasionally run into issues checking which files were modified.
+ fetch-depth: 100
+ ref: ${{ github.event.pull_request.head.sha }}
+ - uses: actions/checkout@v4
+ if: ${{ ! inputs.is_pr }}
+ with:
+ ref: ${{ github.head_ref }}
+ - name: Install Dependencies
+ env:
+ TEST_CREDENTIALS: ${{ secrets.TEST_CREDENTIALS }}
+ run: |
+ ./install.sh
+ echo $TEST_CREDENTIALS > ./shared/test/test_credentials.json
+ - uses: actions/setup-java@v4
+ with:
+ distribution: 'zulu'
+ java-version: '21'
+ - name: Setup Android SDK
+ uses: android-actions/setup-android@v3
+ - uses: gradle/actions/setup-gradle@v4
+ with:
+ # This is the actual Gradle version (not AGP), which can be found in the gradle-wrapper.properties file.
+ gradle-version: "8.14.3"
+ add-job-summary: on-failure
+ add-job-summary-as-pr-comment: on-failure
+ - name: Build for Testing
+ if: success() || failure()
+ run: |
+ ./gradlew native:NativeSampleApps:AuthFlowTester:assembleDebug
+ - name: Build Tests for PR
+ if: ${{ inputs.is_pr }}
+ run: |
+ ./gradlew native:NativeSampleApps:AuthFlowTester:assembleAndroidTest --tests "com.salesforce.samples.authflowtester.loginTest"
+ - name: Build Tests for Nightly
+ if: ${{ !inputs.is_pr }}
+ run: |
+ ./gradlew native:NativeSampleApps:AuthFlowTester:assembleAndroidTest
+ - uses: 'google-github-actions/auth@v2'
+ if: success() || failure()
+ with:
+ credentials_json: '${{ secrets.GCLOUD_SERVICE_KEY }}'
+ - uses: 'google-github-actions/setup-gcloud@v2'
+ if: success() || failure()
+ - name: Run Tests
+ continue-on-error: true
+ if: success() || failure()
+ env:
+ # Most used according to https://gs.statcounter.com/android-version-market-share/mobile-tablet/worldwide
+ PR_API_VERSION: "35"
+ FULL_API_RANGE: "28 29 30 31 32 33 34 35 36"
+ IS_PR: ${{ inputs.is_pr }}
+ run: |
+ LEVELS_TO_TEST=$FULL_API_RANGE
+ RETRIES=0
+
+ if $IS_PR ; then
+ LEVELS_TO_TEST=$PR_API_VERSION
+ RETRIES=1
+ fi
+
+ mkdir firebase_results
+ for LEVEL in $LEVELS_TO_TEST
+ do
+ GCLOUD_RESULTS_DIR=authflowtester-api-${LEVEL}-build-${{github.run_number}}
+
+ eval gcloud beta firebase test android run \
+ --project mobile-apps-firebase-test \
+ --type instrumentation \
+ --app "native/NativeSampleApps/AuthFlowTester/build/outputs/apk/debug/AuthFlowTester-debug.apk" \
+ --test="native/NativeSampleApps/AuthFlowTester/build/outputs/apk/androidTest/debug/AuthFlowTester-debug-androidTest.apk" \
+ --device model=MediumPhone.arm,version=${LEVEL},locale=en,orientation=portrait \
+ --environment-variables \
+ --directories-to-pull=/sdcard \
+ --results-dir=${GCLOUD_RESULTS_DIR} \
+ --results-history-name=AuthFlowTester \
+ --timeout=30m --no-performance-metrics \
+ --num-flaky-test-attempts=${RETRIES} || true
+ done
+ - name: Copy Test Results
+ continue-on-error: true
+ if: success() || failure()
+ env:
+ # Most used according to https://gs.statcounter.com/android-version-market-share/mobile-tablet/worldwide
+ PR_API_VERSION: "35"
+ FULL_API_RANGE: "28 29 30 31 32 33 34 35 36"
+ IS_PR: ${{ inputs.is_pr }}
+ run: |
+ LEVELS_TO_TEST=$FULL_API_RANGE
+
+ if $IS_PR ; then
+ LEVELS_TO_TEST=$PR_API_VERSION
+ fi
+
+ for LEVEL in $LEVELS_TO_TEST
+ do
+ GCLOUD_RESULTS_DIR=authflowtester-api-${LEVEL}-build-${{github.run_number}}
+ BUCKET_PATH="gs://test-lab-w87i9sz6q175u-kwp8ium6js0zw/${GCLOUD_RESULTS_DIR}"
+
+ gsutil ls ${BUCKET_PATH} > /dev/null 2>&1
+ if [ $? == 0 ] ; then
+ # Copy XML file for test reporting
+ if gsutil ls "${BUCKET_PATH}/*test_results_merged.xml" > /dev/null 2>&1; then
+ # Sharded runs produce test_results_merged.xml at top level
+ gsutil cp "${BUCKET_PATH}/*test_results_merged.xml" firebase_results/api_${LEVEL}_test_result.xml
+ else
+ gsutil cp "${BUCKET_PATH}/*/test_result_1.xml" firebase_results/api_${LEVEL}_test_result.xml
+ fi
+ fi
+ done
+ - name: Test Report
+ uses: mikepenz/action-junit-report@v5
+ if: success() || failure()
+ with:
+ check_name: ${{ inputs.lib }} Test Results
+ job_name: ${{ inputs.lib }} Test Results
+ require_tests: true
+ check_retries: true
+ flaky_summary: true
+ fail_on_failure: true
+ group_reports: false
+ include_passed: true
+ include_empty_in_summary: false
+ simplified_summary: true
+ report_paths: 'firebase_results/**.xml'
diff --git a/.gitmodules b/.gitmodules
index 06b4553be1..f55effc0ed 100644
--- a/.gitmodules
+++ b/.gitmodules
@@ -1,3 +1,6 @@
[submodule "external/shared"]
path = external/shared
url = https://github.com/forcedotcom/SalesforceMobileSDK-Shared.git
+[submodule "external/SalesforceMobileSDK-UITests"]
+ path = external/SalesforceMobileSDK-UITests
+ url = https://github.com/forcedotcom/SalesforceMobileSDK-UITests
diff --git a/external/SalesforceMobileSDK-UITests b/external/SalesforceMobileSDK-UITests
new file mode 160000
index 0000000000..e805caa0cb
--- /dev/null
+++ b/external/SalesforceMobileSDK-UITests
@@ -0,0 +1 @@
+Subproject commit e805caa0cbe29e522447bd136069e2817292e892
diff --git a/libs/SalesforceSDK/res/values/sf__strings.xml b/libs/SalesforceSDK/res/values/sf__strings.xml
index bbd6ea5b27..f1600e0b7e 100644
--- a/libs/SalesforceSDK/res/values/sf__strings.xml
+++ b/libs/SalesforceSDK/res/values/sf__strings.xml
@@ -109,6 +109,7 @@
Mobile SDK Developer Support
+ Developer Support
Show Salesforce Mobile SDK developer support
Tap to display Salesforce Mobile SDK developer support in the active app.
Salesforce Mobile Developer Support
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/ScopeParser.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/ScopeParser.kt
index 7968d6b171..f44f42fbf4 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/ScopeParser.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/auth/ScopeParser.kt
@@ -48,6 +48,11 @@ class ScopeParser {
return ScopeParser(scopeString)
}
+ /**
+ * String extension to convert to [ScopeParser].
+ */
+ fun String?.toScopeParser(): ScopeParser = ScopeParser(scopeString = this)
+
/**
* Computes the scope parameter from an array of scopes.
*
@@ -74,6 +79,18 @@ class ScopeParser {
scopesSet.add(REFRESH_TOKEN)
return scopesSet.joinToString(" ")
}
+
+ /**
+ * Computes the scope parameter from an array of scopes.
+ *
+ * Behavior:
+ * - If {@code scopes} is null or empty, returns an empty string. This indicates that all
+ * scopes assigned to the connected app / external client app will be requested by default
+ * (no explicit scope parameter is sent).
+ * - If {@code scopes} is non-empty, ensures {@code refresh_token} is present in the set and
+ * returns a space-delimited string of unique, sorted scopes.
+ */
+ fun Array?.toScopeParameter(): String = computeScopeParameter(this)
}
private val _scopes: MutableSet
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginServerListItem.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginServerListItem.kt
index ad70dc8453..2f2d06ff49 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginServerListItem.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginServerListItem.kt
@@ -93,6 +93,7 @@ import com.salesforce.androidsdk.R.string.sf__server_url_delete
import com.salesforce.androidsdk.config.LoginServerManager.LoginServer
import com.salesforce.androidsdk.ui.theme.sfDarkColors
import com.salesforce.androidsdk.ui.theme.sfLightColors
+import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -244,6 +245,7 @@ fun LoginServerListItem(
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview("Default Server", showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
@Composable
@@ -258,6 +260,7 @@ private fun DefaultServerPreview() {
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview("Very Long Default Server", showBackground = true)
@Composable
private fun LongDefaultServerPreview() {
@@ -275,6 +278,7 @@ private fun LongDefaultServerPreview() {
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview("Custom Server", showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
@Composable
@@ -289,6 +293,7 @@ private fun CustomServerPreview() {
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview("Deleting", showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
@Composable
@@ -304,6 +309,7 @@ private fun DeletingLoginServer() {
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview("Very Long Custom Server", showBackground = true)
@Composable
private fun LongServerPreview() {
@@ -321,6 +327,7 @@ private fun LongServerPreview() {
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview("Very Long Custom Server Deleting", showBackground = true)
@Composable
private fun LongServerDeletingPreview() {
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt
index b390d251fc..554b72d4b7 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/LoginView.kt
@@ -114,6 +114,7 @@ import androidx.lifecycle.viewmodel.compose.viewModel
import com.salesforce.androidsdk.R.string.sf__back_button_content_description
import com.salesforce.androidsdk.R.string.sf__clear_cache
import com.salesforce.androidsdk.R.string.sf__clear_cookies
+import com.salesforce.androidsdk.R.string.sf__dev_support_title_menu_item
import com.salesforce.androidsdk.R.string.sf__launch_idp
import com.salesforce.androidsdk.R.string.sf__loading_indicator
import com.salesforce.androidsdk.R.string.sf__more_options
@@ -125,6 +126,7 @@ import com.salesforce.androidsdk.ui.LoginViewModel
import com.salesforce.androidsdk.ui.theme.SFColors
import com.salesforce.androidsdk.ui.theme.sfDarkColors
import com.salesforce.androidsdk.ui.theme.sfLightColors
+import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport
internal const val PADDING_SIZE = 12
internal const val HEADER_TEXT_SIZE = 20
@@ -148,6 +150,13 @@ fun LoginView() {
} else {
viewModel.titleText ?: viewModel.defaultTitleText
}
+ val showDevSupport = with(SalesforceSDKManager.getInstance()) {
+ return@with if (isDebugBuild && isDevSupportEnabled()) {
+ { showDevSupportDialog(activity) }
+ } else {
+ null
+ }
+ }
val topAppBar = viewModel.topAppBar ?: {
DefaultTopAppBar(
@@ -159,6 +168,7 @@ fun LoginView() {
clearWebViewCache = { viewModel.clearWebViewCache(activity.webView) },
reloadWebView = { viewModel.reloadWebView() },
shouldShowBackButton = viewModel.shouldShowBackButton,
+ showDevSupport = showDevSupport,
finish = { activity.handleBackBehavior() },
)
}
@@ -265,6 +275,7 @@ internal fun DefaultTopAppBar(
clearWebViewCache: () -> Unit,
reloadWebView: () -> Unit,
shouldShowBackButton: Boolean,
+ showDevSupport: (() -> Unit)?,
finish: () -> Unit,
) {
var showMenu by remember { mutableStateOf(false) }
@@ -321,6 +332,12 @@ internal fun DefaultTopAppBar(
reloadWebView()
showMenu = false
}
+ showDevSupport?.let {
+ MenuItem(stringResource(sf__dev_support_title_menu_item)) {
+ it.invoke()
+ showMenu = false
+ }
+ }
}
}
},
@@ -491,8 +508,8 @@ private fun Modifier.applyImePaddingConditionally() : Modifier =
this
}
-// Note: the light and dark previews should look the same.
-@Preview
+@ExcludeFromJacocoGeneratedReport
+@Preview // Note: the light and dark previews should look the same.
@Preview("Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, backgroundColor = 0xFF181818)
@Composable
private fun AppBarPreview() {
@@ -508,11 +525,13 @@ private fun AppBarPreview() {
clearWebViewCache = { },
reloadWebView = { },
shouldShowBackButton = false,
+ showDevSupport = null,
finish = { },
)
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview
@Composable
private fun AppBarLoadingPreview() {
@@ -528,11 +547,13 @@ private fun AppBarLoadingPreview() {
clearWebViewCache = { },
reloadWebView = { },
shouldShowBackButton = false,
+ showDevSupport = null,
finish = { },
)
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview
@Composable
private fun AppBarBackButtonPreview() {
@@ -548,11 +569,13 @@ private fun AppBarBackButtonPreview() {
clearWebViewCache = { },
reloadWebView = { },
shouldShowBackButton = true,
+ showDevSupport = null,
finish = { },
)
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview
@Composable
private fun AppBarDarkPreview() {
@@ -568,11 +591,13 @@ private fun AppBarDarkPreview() {
clearWebViewCache = { },
reloadWebView = { },
shouldShowBackButton = true,
+ showDevSupport = null,
finish = { },
)
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview
@Composable
private fun BlueAppBarPreview() {
@@ -588,11 +613,13 @@ private fun BlueAppBarPreview() {
clearWebViewCache = { },
reloadWebView = { },
shouldShowBackButton = true,
+ showDevSupport = null,
finish = { },
)
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview
@Composable
private fun BlueAppBarLoadingPreview() {
@@ -608,11 +635,13 @@ private fun BlueAppBarLoadingPreview() {
clearWebViewCache = { },
reloadWebView = { },
shouldShowBackButton = true,
+ showDevSupport = null,
finish = { },
)
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview
@Composable
private fun CustomTextAppBarPreview() {
@@ -627,11 +656,13 @@ private fun CustomTextAppBarPreview() {
clearWebViewCache = { },
reloadWebView = { },
shouldShowBackButton = false,
+ showDevSupport = null,
finish = { },
)
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview
@Composable
private fun CustomTextAppBarLoadingPreview() {
@@ -646,11 +677,13 @@ private fun CustomTextAppBarLoadingPreview() {
clearWebViewCache = { },
reloadWebView = { },
shouldShowBackButton = false,
+ showDevSupport = null,
finish = { },
)
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview
@Composable
private fun LongCustomTextAppBarPreview() {
@@ -665,11 +698,13 @@ private fun LongCustomTextAppBarPreview() {
clearWebViewCache = { },
reloadWebView = { },
shouldShowBackButton = true,
+ showDevSupport = null,
finish = { },
)
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview("Light Mode", showBackground = true, widthDp = 100, heightDp = 100)
@Preview(
"Dark Mode",
@@ -686,8 +721,8 @@ private fun LoadingIndicatorPreview() {
}
}
-// Note: the light and dark previews should look the same.
-@Preview
+@ExcludeFromJacocoGeneratedReport
+@Preview // Note: the light and dark previews should look the same.
@Preview("Dark Mode", uiMode = Configuration.UI_MODE_NIGHT_YES, backgroundColor = 0xFF181818)
@Composable
private fun BottomBarPreview() {
@@ -705,6 +740,7 @@ private fun BottomBarPreview() {
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview
@Composable
private fun BottomBarRedPreview() {
@@ -722,6 +758,7 @@ private fun BottomBarRedPreview() {
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview("Light", showBackground = true, heightDp = 100, widthDp = 100)
@Preview(
"Dark", showBackground = true, heightDp = 100, widthDp = 100,
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 21bacbfc1d..1a05126168 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/PickerBottomSheet.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/PickerBottomSheet.kt
@@ -129,6 +129,7 @@ import com.salesforce.androidsdk.ui.LoginViewModel
import com.salesforce.androidsdk.ui.theme.hintTextColor
import com.salesforce.androidsdk.ui.theme.sfDarkColors
import com.salesforce.androidsdk.ui.theme.sfLightColors
+import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -580,6 +581,7 @@ private tailrec fun Context.getActivity(): FragmentActivity? = when (this) {
else -> null
}
+@ExcludeFromJacocoGeneratedReport
@Preview("Default", showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
@Composable
@@ -589,6 +591,7 @@ private fun AddConnectionPreview() {
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview("Values", showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
@Composable
@@ -603,6 +606,7 @@ private fun AddConnectionValuesPreview() {
}
+@ExcludeFromJacocoGeneratedReport
@OptIn(ExperimentalMaterial3Api::class)
@Preview
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
diff --git a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/UserAccountListItem.kt b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/UserAccountListItem.kt
index 8838b2b918..040388ec50 100644
--- a/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/UserAccountListItem.kt
+++ b/libs/SalesforceSDK/src/com/salesforce/androidsdk/ui/components/UserAccountListItem.kt
@@ -63,6 +63,7 @@ import com.salesforce.androidsdk.R.string.sf__account_selector_click_label
import com.salesforce.androidsdk.R.string.sf__profile_photo_content_description
import com.salesforce.androidsdk.ui.theme.sfDarkColors
import com.salesforce.androidsdk.ui.theme.sfLightColors
+import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport
@Composable
fun UserAccountListItem(
@@ -126,6 +127,7 @@ fun UserAccountListItem(
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview("", showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
@Composable
@@ -141,6 +143,7 @@ private fun UserAccountPreview() {
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview("Selected", showBackground = true)
@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
@Composable
@@ -156,6 +159,7 @@ private fun UserAccountSelectedPreview() {
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview(name = "User account without provided profile picture.", showBackground = true)
@Composable
private fun UserAccountPreviewNoPic() {
@@ -170,6 +174,7 @@ private fun UserAccountPreviewNoPic() {
}
}
+@ExcludeFromJacocoGeneratedReport
@Preview("User Account with very long username and server url.", showBackground = true)
@Composable
private fun UserAccountPreviewLong() {
diff --git a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/ScopeParserTest.kt b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/ScopeParserTest.kt
index c4430a6dbb..6ca3b20222 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/ScopeParserTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/auth/ScopeParserTest.kt
@@ -28,6 +28,8 @@ package com.salesforce.androidsdk.auth
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.SmallTest
+import com.salesforce.androidsdk.auth.ScopeParser.Companion.toScopeParameter
+import com.salesforce.androidsdk.auth.ScopeParser.Companion.toScopeParser
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
@@ -231,4 +233,49 @@ class ScopeParserTest {
ScopeParser.computeScopeParameter(arrayOf("refresh_token")))
}
+ @Test
+ fun testScopeParserStringExtension() {
+ val parser = "api web refresh_token".toScopeParser()
+
+ // Test existing scopes
+ Assert.assertTrue("Should have api scope", parser.hasScope("api"))
+ Assert.assertTrue("Should have web scope", parser.hasScope("web"))
+ Assert.assertTrue("Should have refresh_token scope", parser.hasScope("refresh_token"))
+
+ // Test non-existing scope
+ Assert.assertFalse("Should not have unknown scope", parser.hasScope("unknown"))
+
+ // Test null/empty scope
+ Assert.assertFalse("Should return false for null scope", parser.hasScope(null))
+ Assert.assertFalse("Should return false for empty scope", parser.hasScope(""))
+ Assert.assertFalse("Should return false for whitespace scope", parser.hasScope(" "))
+
+ // Test trimming
+ Assert.assertTrue("Should handle leading/trailing whitespace", parser.hasScope(" api "))
+ }
+
+ @Test
+ fun testArrayToScopeParameterExtension() {
+ // Test with null
+ Assert.assertEquals("Should return empty string for null", "", (null as Array?).toScopeParameter())
+
+ // Test with empty array
+ Assert.assertEquals("Should return empty string for empty array", "", arrayOf().toScopeParameter())
+
+ // Test with single scope
+ Assert.assertEquals("Should add refresh_token to single scope", "api refresh_token",
+ arrayOf("api").toScopeParameter())
+
+ // Test when refresh_token is not included
+ Assert.assertEquals("Should add refresh_token and sort", "api refresh_token visualforce web",
+ arrayOf("web", "api", "visualforce").toScopeParameter())
+
+ // Test when refresh_token already included
+ Assert.assertEquals("Should not duplicate refresh_token", "api refresh_token web",
+ arrayOf("api", "refresh_token", "web").toScopeParameter())
+
+ // Test with only refresh_token
+ Assert.assertEquals("Should return only refresh_token", "refresh_token",
+ arrayOf("refresh_token").toScopeParameter())
+ }
}
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 6de85239df..e6f292473d 100644
--- a/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt
+++ b/libs/test/SalesforceSDKTest/src/com/salesforce/androidsdk/ui/LoginViewActivityTest.kt
@@ -264,6 +264,65 @@ class LoginViewActivityTest {
Assert.assertTrue("Reload should be called.", reloadCalled)
}
+ @Test
+ fun topAppBar_DevSupportButton_OpensSupportMenu() {
+ var devSupportCalled = false
+ androidComposeTestRule.setContent {
+ DefaultTopAppBarTestWrapper(
+ showDevSupport = { devSupportCalled = true },
+ )
+ }
+
+ val backButton = androidComposeTestRule.onNodeWithContentDescription(
+ androidComposeTestRule.activity.getString(R.string.sf__back_button_content_description)
+ )
+ val titleText = androidComposeTestRule.onNodeWithText(DEFAULT_URL)
+ val menu = androidComposeTestRule.onNodeWithContentDescription(
+ androidComposeTestRule.activity.getString(R.string.sf__more_options)
+ )
+ val devSupportButton = androidComposeTestRule.onNodeWithText(
+ androidComposeTestRule.activity.getString(R.string.sf__dev_support_title_menu_item)
+ )
+
+ backButton.assertDoesNotExist()
+ titleText.assertIsDisplayed()
+ menu.assertIsDisplayed()
+
+ menu.performClick()
+ devSupportButton.assertIsDisplayed()
+ Assert.assertFalse("Dev support should not be called yet.", devSupportCalled)
+
+ devSupportButton.performClick()
+ Assert.assertTrue("Dev support should be called.", devSupportCalled)
+ }
+
+ @Test
+ fun topAppBar_NullDevSupport_DoesNotShowDevSupportButton() {
+ androidComposeTestRule.setContent {
+ DefaultTopAppBarTestWrapper(
+ showDevSupport = null,
+ )
+ }
+
+ val backButton = androidComposeTestRule.onNodeWithContentDescription(
+ androidComposeTestRule.activity.getString(R.string.sf__back_button_content_description)
+ )
+ val titleText = androidComposeTestRule.onNodeWithText(DEFAULT_URL)
+ val menu = androidComposeTestRule.onNodeWithContentDescription(
+ androidComposeTestRule.activity.getString(R.string.sf__more_options)
+ )
+ val devSupportButton = androidComposeTestRule.onNodeWithText(
+ androidComposeTestRule.activity.getString(R.string.sf__dev_support_title_menu_item)
+ )
+
+ backButton.assertDoesNotExist()
+ titleText.assertIsDisplayed()
+ menu.assertIsDisplayed()
+
+ menu.performClick()
+ devSupportButton.assertDoesNotExist()
+ }
+
@Test
fun bottomAppBar_WithNoButton_DisplaysCorrectly() {
androidComposeTestRule.setContent {
@@ -419,11 +478,12 @@ class LoginViewActivityTest {
clearWebViewCache: () -> Unit = { },
reloadWebView: () -> Unit = { },
shouldShowBackButton: Boolean = false,
+ showDevSupport: (() -> Unit)? = { },
finish: () -> Unit = { },
) {
DefaultTopAppBar(
backgroundColor, titleText, titleTextColor, showServerPicker, clearCookies,
- clearWebViewCache, reloadWebView, shouldShowBackButton, finish
+ clearWebViewCache, reloadWebView, shouldShowBackButton, showDevSupport, finish
)
}
diff --git a/native/NativeSampleApps/AuthFlowTester/build.gradle.kts b/native/NativeSampleApps/AuthFlowTester/build.gradle.kts
new file mode 100644
index 0000000000..e90e9d4e3d
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/build.gradle.kts
@@ -0,0 +1,100 @@
+plugins {
+ android
+ `kotlin-android`
+}
+
+dependencies {
+ val composeVersion = "1.8.2" // Update requires Kotlin 2.
+
+ implementation(project(":libs:SalesforceSDK"))
+ implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.0")
+ implementation("androidx.compose.runtime:runtime-android:1.10.0")
+ implementation("androidx.core:core-ktx:1.16.0") // Update requires API 36 compileSdk
+ implementation("androidx.tracing:tracing:1.3.0")
+ implementation("com.google.android.material:material:1.13.0")
+ androidTestImplementation("androidx.test:runner:1.5.1") {
+ exclude("com.android.support", "support-annotations")
+ }
+
+ implementation("androidx.appcompat:appcompat:1.7.1")
+ implementation("androidx.appcompat:appcompat-resources:1.7.1")
+
+ androidTestImplementation("androidx.test:rules:1.5.0") {
+ exclude("com.android.support", "support-annotations")
+ }
+ androidTestImplementation("androidx.test.espresso:espresso-core:3.5.0") {
+ exclude("com.android.support", "support-annotations")
+ }
+ androidTestImplementation("androidx.test.ext:junit:1.3.0")
+ androidTestImplementation("androidx.test.uiautomator:uiautomator:2.3.0")
+
+ implementation("androidx.compose.material3:material3-android:1.3.2")
+ implementation(platform("androidx.compose:compose-bom:2025.07.00")) // Update requires Kotlin 2.
+ implementation("androidx.compose.foundation:foundation-android:$composeVersion")
+ implementation("androidx.compose.runtime:runtime-livedata:$composeVersion")
+ implementation("androidx.compose.ui:ui-tooling-preview-android:$composeVersion")
+ implementation("androidx.compose.material:material:$composeVersion")
+ implementation("androidx.activity:activity-compose:$composeVersion")
+
+ debugImplementation("androidx.compose.ui:ui-tooling:$composeVersion")
+ debugImplementation("androidx.compose.ui:ui-test-manifest:$composeVersion")
+}
+
+android {
+ namespace = "com.salesforce.samples.authflowtester"
+
+ compileSdk = 36
+
+ defaultConfig {
+ targetSdk = 36
+ minSdk = 28
+ testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
+ }
+
+ buildFeatures {
+ compose = true
+ renderScript = true
+ aidl = true
+ buildConfig = true
+ }
+
+ composeOptions {
+ kotlinCompilerExtensionVersion = "1.5.14"
+ }
+
+ buildTypes {
+ debug {
+ enableAndroidTestCoverage = true
+ }
+ }
+
+ packaging {
+ resources {
+ excludes += setOf(
+ "META-INF/LICENSE",
+ "META-INF/LICENSE.txt",
+ "META-INF/DEPENDENCIES",
+ "META-INF/NOTICE"
+ )
+ }
+ }
+
+ sourceSets {
+ getByName("androidTest") {
+ java.srcDirs(
+ "src/androidTest/java",
+ "${rootDir}/external/SalesforceMobileSDK-UITests/Android/app/src/androidTest/java"
+ )
+ }
+ }
+
+}
+
+repositories {
+ google()
+ mavenCentral()
+}
+
+kotlin {
+ jvmToolchain(17)
+}
diff --git a/native/NativeSampleApps/AuthFlowTester/proguard.cfg b/native/NativeSampleApps/AuthFlowTester/proguard.cfg
new file mode 100644
index 0000000000..b1cdf17b5b
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/proguard.cfg
@@ -0,0 +1,40 @@
+-optimizationpasses 5
+-dontusemixedcaseclassnames
+-dontskipnonpubliclibraryclasses
+-dontpreverify
+-verbose
+-optimizations !code/simplification/arithmetic,!field/*,!class/merging/*
+
+-keep public class * extends android.app.Activity
+-keep public class * extends android.app.Application
+-keep public class * extends android.app.Service
+-keep public class * extends android.content.BroadcastReceiver
+-keep public class * extends android.content.ContentProvider
+-keep public class * extends android.app.backup.BackupAgentHelper
+-keep public class * extends android.preference.Preference
+-keep public class com.android.vending.licensing.ILicensingService
+
+-keepclasseswithmembernames class * {
+ native ;
+}
+
+-keepclasseswithmembers class * {
+ public (android.content.Context, android.util.AttributeSet);
+}
+
+-keepclasseswithmembers class * {
+ public (android.content.Context, android.util.AttributeSet, int);
+}
+
+-keepclassmembers class * extends android.app.Activity {
+ public void *(android.view.View);
+}
+
+-keepclassmembers enum * {
+ public static **[] values();
+ public static ** valueOf(java.lang.String);
+}
+
+-keep class * implements android.os.Parcelable {
+ public static final android.os.Parcelable$Creator *;
+}
diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginTest.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginTest.kt
new file mode 100644
index 0000000000..14f7447157
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/LoginTest.kt
@@ -0,0 +1,77 @@
+/*
+ * 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.samples.authflowtester
+
+import android.content.Intent
+import android.text.TextWatcher
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.SdkSuppress
+import androidx.test.platform.app.InstrumentationRegistry
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.UiSelector
+import androidx.test.uiautomator.UiWatcher
+import androidx.test.uiautomator.Until
+import org.junit.Assert
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import pageobjects.loginpageobjects.LoginPageObject
+import testutility.UserUtility
+
+@RunWith(AndroidJUnit4::class)
+class LoginTest {
+
+ private lateinit var device: UiDevice
+ private val packageName = "com.salesforce.samples.authflowtester"
+
+ @Before
+ fun setup() {
+ // Initialize UiDevice instance
+ device = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation())
+
+ // Launch the app
+ val context = InstrumentationRegistry.getInstrumentation().context
+ val intent = context.packageManager.getLaunchIntentForPackage(packageName)
+ intent?.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK) // Clear out any previous instances
+ context.startActivity(intent)
+
+ device.addNotificationWatcher()
+ }
+
+ @Test
+ fun testLogin() {
+ val loginPage = LoginPageObject()
+ loginPage.setUsername(UserUtility.username)
+ loginPage.setPassword(UserUtility.password)
+ loginPage.tapLogin()
+
+ // Verify we are logged in
+ val successText = device.findObject(UiSelector().text("AuthFlowTester"))
+ Assert.assertTrue(successText.waitForExists(10000))
+ }
+}
diff --git a/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/NotificationDialogUiWatcher.kt b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/NotificationDialogUiWatcher.kt
new file mode 100644
index 0000000000..b72728afdb
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/androidTest/java/com/salesforce/samples/authflowtester/NotificationDialogUiWatcher.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2026-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.samples.authflowtester
+
+import androidx.test.uiautomator.By
+import androidx.test.uiautomator.UiDevice
+import androidx.test.uiautomator.UiWatcher
+
+class NotificationDialogUiWatcher(val device: UiDevice) : UiWatcher {
+
+ override fun checkForCondition(): Boolean {
+ // TODO: Add this to UI Test Framework
+ // Handle Notification Permission if it appears
+ val allowButton = device.findObject(By.text("Allow"))
+ if (allowButton != null && allowButton.isEnabled) {
+ allowButton.click()
+ return true
+ }
+
+ return false
+ }
+}
+
+fun UiDevice.addNotificationWatcher() {
+ registerWatcher("NotificationPermission", NotificationDialogUiWatcher(this))
+}
\ No newline at end of file
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml b/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..98dd7345e1
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/AndroidManifest.xml
@@ -0,0 +1,37 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt
new file mode 100644
index 0000000000..fb83a17e90
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterActivity.kt
@@ -0,0 +1,809 @@
+/*
+ * 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.samples.authflowtester
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
+import android.content.IntentFilter
+import android.content.res.Configuration
+import android.os.Build
+import android.os.Bundle
+import android.widget.ScrollView
+import android.widget.TextView
+import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+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.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.BottomAppBar
+import androidx.compose.material3.BottomAppBarDefaults
+import androidx.compose.material3.BottomAppBarScrollBehavior
+import androidx.compose.material3.Button
+import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Card
+import androidx.compose.material3.CenterAlignedTopAppBar
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ColorScheme
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.MaterialTheme.colorScheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SheetValue
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.TopAppBarColors
+import androidx.compose.material3.TopAppBarDefaults
+import androidx.compose.material3.TopAppBarScrollBehavior
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.material3.rememberStandardBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.neverEqualPolicy
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clipToBounds
+import androidx.compose.ui.graphics.toArgb
+import androidx.compose.ui.input.nestedscroll.nestedScroll
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.res.painterResource
+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 androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import com.salesforce.androidsdk.accounts.UserAccountManager
+import com.salesforce.androidsdk.app.SalesforceSDKManager
+import com.salesforce.androidsdk.auth.JwtAccessToken
+import com.salesforce.androidsdk.rest.ApiVersionStrings
+import com.salesforce.androidsdk.rest.ClientManager
+import com.salesforce.androidsdk.rest.RestClient
+import com.salesforce.androidsdk.ui.SalesforceActivity
+import com.salesforce.androidsdk.ui.theme.sfDarkColors
+import com.salesforce.androidsdk.ui.theme.sfLightColors
+import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport
+import kotlinx.coroutines.launch
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
+
+const val PADDING = 10
+const val INNER_CARD_PADDING = 16
+const val CORNER_SHAPE = 12
+const val SPINNER_STROKE_WIDTH = 2
+const val BOTTOM_BAR_SPACING = 20
+const val HALF_ALPHA = 0.5f
+const val RESPONSE_CARD_HEIGHT = 250
+const val ICON_SIZE = 24
+const val JWT = "jwt"
+const val CONSUMER_JSON_KEY = "remoteConsumerKey"
+const val REDIRECT_JSON_KEY = "oauthRedirectURI"
+const val SCOPE_JSON_KEY = "scopes"
+const val CONSUMER_KEY_LABEL = "Consumer Key"
+const val REDIRECT_LABEL = "Callback URL"
+const val SCOPES_LABEL = "Scopes (space-separated)"
+
+class AuthFlowTesterActivity : SalesforceActivity() {
+ private var client: RestClient? = null
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
+
+ setContent {
+ MaterialTheme(colorScheme = getColorScheme()) {
+ TesterUI()
+ }
+ }
+ }
+
+ override fun onResume(client: RestClient?) {
+ // Keeping reference to rest client
+ this.client = client
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun TesterUI() {
+ val topScrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior()
+ val bottomScrollBehavior = BottomAppBarDefaults.exitAlwaysScrollBehavior()
+ var showLogoutDialog by remember { mutableStateOf(false) }
+ var showMigrateBottomSheet by remember { mutableStateOf(false) }
+ val isPreview = LocalInspectionMode.current
+ val context = LocalContext.current
+ val currentUser = remember {
+ mutableStateOf(
+ value = if (isPreview) null else UserAccountManager.getInstance().currentUser,
+ // UserAccount's equals() function only compares userId and orgId.
+ policy = neverEqualPolicy(),
+ )
+ }
+ val jwtTokenInUse by remember { derivedStateOf { currentUser.value?.tokenFormat == JWT } }
+
+ // Set current user when it changes to update UI.
+ DisposableEffect(Unit) {
+ val receiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context, intent: Intent) {
+ currentUser.value = UserAccountManager.getInstance().currentUser
+ }
+ }
+ val filter = IntentFilter(UserAccountManager.USER_SWITCH_INTENT_ACTION)
+ filter.addAction(ClientManager.ACCESS_TOKEN_REFRESH_INTENT)
+ filter.addAction(ClientManager.INSTANCE_URL_UPDATE_INTENT)
+ ContextCompat.registerReceiver(context, receiver, filter, ContextCompat.RECEIVER_NOT_EXPORTED)
+ onDispose {
+ context.unregisterReceiver(receiver)
+ }
+ }
+
+ Scaffold(
+ topBar = { TesterUITopBar(topScrollBehavior) },
+ bottomBar = {
+ if (isPreview) {
+ TesterUIBottomBar(bottomScrollBehavior, {}, {}, {})
+ } else {
+ with(SalesforceSDKManager.getInstance()) {
+ return@with TesterUIBottomBar(
+ bottomScrollBehavior,
+ tokenMigrationAction = { showMigrateBottomSheet = true },
+ switchUserAction = {
+ appContext.startActivity(Intent(
+ appContext,
+ accountSwitcherActivityClass
+ ).apply {
+ flags = FLAG_ACTIVITY_NEW_TASK
+ })
+ },
+ logoutAction = { showLogoutDialog = true },
+ )
+ }
+ }
+ },
+ ) { innerPadding ->
+ Column(
+ modifier = Modifier
+ .padding(start = PADDING.dp, end = PADDING.dp)
+ .nestedScroll(topScrollBehavior.nestedScrollConnection)
+ .nestedScroll(bottomScrollBehavior.nestedScrollConnection)
+ .verticalScroll(rememberScrollState())
+ .fillMaxSize(),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ verticalArrangement = Arrangement.Top,
+ ) {
+ Spacer(Modifier.height(innerPadding.calculateTopPadding()))
+ RevokeButtonCard()
+ RequestButtonCard()
+
+ UserCredentialsView(currentUser.value)
+
+ if (jwtTokenInUse) {
+ currentUser.value?.authToken?.let { token ->
+ JwtTokenView(jwtToken = JwtAccessToken(token))
+ }
+ }
+
+ OAuthConfigurationView()
+
+ Spacer(Modifier.height(innerPadding.calculateBottomPadding()))
+ }
+ }
+
+ if (showLogoutDialog) {
+ @Suppress("AssignedValueIsNeverRead")
+ AlertDialog(
+ onDismissRequest = { showLogoutDialog = false },
+ title = { Text(stringResource(R.string.logout)) },
+ text = { Text(stringResource(R.string.logout_body)) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ SalesforceSDKManager.getInstance().logout(null)
+ }
+ ) {
+ Text(
+ text = stringResource(R.string.logout),
+ color = colorScheme.error,
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showLogoutDialog = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ )
+ }
+
+ if (showMigrateBottomSheet) {
+ @Suppress("AssignedValueIsNeverRead")
+ MigrateAppBottomSheet(onDismiss = { showMigrateBottomSheet = false })
+ }
+ }
+
+ @Composable
+ fun RevokeButtonCard(
+ initialProgressState: Boolean = false, // Only used for UI Previews
+ ) {
+ val coroutineScope = rememberCoroutineScope()
+ var revokeInProgress by remember { mutableStateOf(initialProgressState) }
+ var showAlertDialog by remember { mutableStateOf(false) }
+ var response: RequestResult? by remember { mutableStateOf(null) }
+
+ Card(
+ modifier = Modifier.padding((INNER_CARD_PADDING/2).dp),
+ shape = RoundedCornerShape(CORNER_SHAPE.dp),
+ ) {
+ Button(
+ onClick = {
+ @Suppress("AssignedValueIsNeverRead")
+ coroutineScope.launch {
+ revokeInProgress = true
+ response = revokeAccessTokenAction(client)
+ revokeInProgress = false
+ showAlertDialog = true
+ }
+ },
+ enabled = !revokeInProgress,
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(PADDING.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = colorScheme.error),
+ shape = RoundedCornerShape(CORNER_SHAPE.dp),
+ ) {
+ if (revokeInProgress) {
+ Row {
+ CircularProgressIndicator(
+ modifier = Modifier.size(INNER_CARD_PADDING.dp),
+ strokeWidth = SPINNER_STROKE_WIDTH.dp,
+ )
+ Spacer(Modifier.width(INNER_CARD_PADDING.dp))
+ Text(
+ text = stringResource(R.string.revoking),
+ color = colorScheme.onSurface,
+ )
+ }
+ } else {
+ Text(
+ text = stringResource(R.string.revoke_access_token),
+ color = colorScheme.onError,
+ )
+ }
+ }
+ }
+
+ if (showAlertDialog) {
+ val title = when {
+ response == null -> stringResource(R.string.no_response)
+ response?.success == true -> stringResource(R.string.revoke_successful)
+ else -> stringResource(R.string.revoke_failed)
+ }
+
+ @Suppress("AssignedValueIsNeverRead")
+ AlertDialog(
+ onDismissRequest = { showAlertDialog = false },
+ title = { Text(title) },
+ text = { Text(
+ text = response?.displayValue ?: "",
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ ) },
+ confirmButton = {
+ TextButton(onClick = { showAlertDialog = false }) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ shape = RoundedCornerShape(CORNER_SHAPE.dp),
+ )
+ }
+ }
+
+ @Composable
+ fun RequestButtonCard(
+ initialProgressState: Boolean = false, // Only used for UI Previews
+ initialResponse: RequestResult? = null, // Only used for UI Previews
+ ) {
+ val coroutineScope = rememberCoroutineScope()
+ var requestInProgress by remember { mutableStateOf(initialProgressState) }
+ var response: RequestResult? by remember { mutableStateOf(initialResponse) }
+ var showAlertDialog by remember { mutableStateOf(false) }
+
+ Card(modifier = Modifier.padding((INNER_CARD_PADDING/2).dp)) {
+ Button(
+ onClick = {
+ @Suppress("AssignedValueIsNeverRead")
+ coroutineScope.launch {
+ response = null
+ requestInProgress = true
+ response = makeRestRequest(client, ApiVersionStrings.VERSION_NUMBER)
+ requestInProgress = false
+ showAlertDialog = true
+ }
+ },
+ enabled = !requestInProgress,
+ modifier = Modifier.fillMaxWidth().padding(PADDING.dp),
+ colors = ButtonDefaults.buttonColors(containerColor = colorScheme.tertiary),
+ shape = RoundedCornerShape(CORNER_SHAPE.dp),
+ ) {
+ if (requestInProgress) {
+ Row {
+ CircularProgressIndicator(
+ modifier = Modifier.size(INNER_CARD_PADDING.dp),
+ strokeWidth = SPINNER_STROKE_WIDTH.dp,
+ )
+ Spacer(Modifier.width(INNER_CARD_PADDING.dp))
+ Text(
+ text = stringResource(R.string.making_request),
+ color = colorScheme.onSurface,
+ )
+ }
+ } else {
+ Text(
+ text = stringResource(R.string.make_rest_api_request),
+ color = colorScheme.onTertiary,
+ )
+ }
+ }
+
+ if (response != null) {
+ Column(modifier = Modifier.padding(INNER_CARD_PADDING.dp)) {
+ val noResponseString = stringResource(R.string.no_response)
+ InfoSection(title = stringResource(R.string.response_details), defaultExpanded = false) {
+ val textColor = colorScheme.onSurface.toArgb()
+ // This is necessary to prevent scrolling from affecting Top/BottomAppBar behavior.
+ AndroidView(
+ modifier = Modifier
+ .padding(INNER_CARD_PADDING.dp)
+ .height(RESPONSE_CARD_HEIGHT.dp)
+ .clipToBounds(),
+ factory = { context ->
+ ScrollView(context).apply {
+ addView(TextView(context).apply {
+ setTextColor(textColor)
+ })
+ }
+ },
+ update = { view ->
+ (view.getChildAt(0) as TextView).apply {
+ text = response?.response ?: noResponseString
+ }
+ }
+ )
+ }
+ }
+ }
+ }
+
+ if (showAlertDialog) {
+ val title = when {
+ response == null -> stringResource(R.string.no_response)
+ response?.success == true -> stringResource(R.string.request_successful)
+ else -> stringResource(R.string.request_failed)
+ }
+
+ @Suppress("AssignedValueIsNeverRead")
+ AlertDialog(
+ onDismissRequest = { showAlertDialog = false },
+ title = { Text(title) },
+ text = { Text(
+ text = response?.displayValue ?: "",
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ ) },
+ confirmButton = {
+ TextButton(onClick = { showAlertDialog = false }) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ shape = RoundedCornerShape(CORNER_SHAPE.dp),
+ )
+ }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun MigrateAppBottomSheet(onDismiss: () -> Unit) {
+ var consumerKey by remember { mutableStateOf("") }
+ var callbackUrl by remember { mutableStateOf("") }
+ var scopes by remember { mutableStateOf("") }
+ val validInput = consumerKey.isNotBlank() && callbackUrl.isNotBlank()
+ var showJsonImportDialog by remember { mutableStateOf(false) }
+
+ ModalBottomSheet(
+ onDismissRequest = onDismiss,
+ sheetState = rememberStandardBottomSheetState(
+ initialValue = SheetValue.Expanded,
+ skipHiddenState = false,
+ ),
+ dragHandle = null,
+ shape = RoundedCornerShape(CORNER_SHAPE.dp),
+ containerColor = colorScheme.surfaceContainer,
+ ) {
+ Column(modifier = Modifier.padding(INNER_CARD_PADDING.dp)) {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(PADDING.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(
+ onClick = onDismiss,
+ modifier = Modifier.size(ICON_SIZE.dp),
+ ) {
+ Icon(
+ painterResource(R.drawable.close),
+ contentDescription = stringResource(R.string.close_content_description),
+ )
+ }
+
+ Text(
+ text = stringResource(R.string.migrate_app_title),
+ fontSize = 20.sp,
+ )
+
+ IconButton(
+ onClick = { showJsonImportDialog = true },
+ modifier = Modifier.size(ICON_SIZE.dp),
+ ) {
+ Icon(
+ painterResource(R.drawable.json),
+ contentDescription = stringResource(R.string.json_content_description),
+ )
+ }
+ }
+
+ OutlinedTextField(
+ value = consumerKey,
+ onValueChange = {consumerKey = it},
+ label = { Text(CONSUMER_KEY_LABEL) },
+ singleLine = true,
+ shape = RoundedCornerShape(CORNER_SHAPE.dp),
+ modifier = Modifier.fillMaxWidth().padding(vertical = (INNER_CARD_PADDING/2).dp),
+ )
+
+ OutlinedTextField(
+ value = callbackUrl,
+ onValueChange = {callbackUrl = it},
+ label = { Text(REDIRECT_LABEL) },
+ singleLine = true,
+ shape = RoundedCornerShape(CORNER_SHAPE.dp),
+ modifier = Modifier.fillMaxWidth().padding(vertical = (INNER_CARD_PADDING/2).dp),
+ )
+
+ OutlinedTextField(
+ value = scopes,
+ onValueChange = {scopes = it},
+ label = { Text(SCOPES_LABEL) },
+ singleLine = true,
+ shape = RoundedCornerShape(CORNER_SHAPE.dp),
+ modifier = Modifier.fillMaxWidth().padding(vertical = (INNER_CARD_PADDING/2).dp),
+ )
+
+ Button(
+ modifier = Modifier.fillMaxWidth().padding(vertical = (INNER_CARD_PADDING/2).dp),
+ shape = RoundedCornerShape(CORNER_SHAPE.dp),
+ enabled = validInput,
+ onClick = { },
+ ) {
+ Text(
+ text = stringResource(R.string.migrate_button),
+ fontWeight = if (validInput) FontWeight.Normal else FontWeight.Medium,
+ color = if (validInput) colorScheme.onPrimary else colorScheme.onErrorContainer,
+ )
+ }
+ }
+ }
+
+ if (showJsonImportDialog) {
+ var jsonInput by remember { mutableStateOf("") }
+ val alertBody = LocalContext.current.getString(
+ /* resId = */ R.string.json_import,
+ /* ...formatArgs = */ CONSUMER_JSON_KEY, REDIRECT_JSON_KEY, SCOPE_JSON_KEY,
+ )
+
+ @Suppress("AssignedValueIsNeverRead")
+ AlertDialog(
+ onDismissRequest = { showJsonImportDialog = false },
+ title = { Text(stringResource(R.string.import_config)) },
+ text = {
+ Column {
+ Text(text = alertBody)
+ Spacer(modifier = Modifier.height(INNER_CARD_PADDING.dp))
+ OutlinedTextField(
+ value = jsonInput,
+ onValueChange = { jsonInput = it },
+ singleLine = true,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ try {
+ val jsonObject = Json.parseToJsonElement(jsonInput).jsonObject
+ jsonObject[CONSUMER_JSON_KEY]?.jsonPrimitive?.content?.let {
+ consumerKey = it
+ }
+ jsonObject[REDIRECT_JSON_KEY]?.jsonPrimitive?.content?.let {
+ callbackUrl = it
+ }
+ jsonObject[SCOPE_JSON_KEY]?.jsonPrimitive?.content?.let {
+ scopes = it
+ }
+ } catch (_: Exception) { }
+ showJsonImportDialog = false
+ },
+ ) {
+ Text(stringResource(R.string.import_button))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showJsonImportDialog = false }) {
+ Text(stringResource(R.string.cancel))
+ }
+ },
+ shape = RoundedCornerShape(CORNER_SHAPE.dp),
+ )
+ }
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun TesterUITopBar(scrollBehavior: TopAppBarScrollBehavior) {
+ CenterAlignedTopAppBar(
+ title = { Text(stringResource(R.string.app_name)) },
+ colors = TopAppBarColors(
+ containerColor = colorScheme.background,
+ scrolledContainerColor = colorScheme.background.copy(alpha = HALF_ALPHA),
+ navigationIconContentColor = colorScheme.onBackground,
+ titleContentColor = colorScheme.onBackground,
+ actionIconContentColor = colorScheme.onBackground,
+ ),
+ scrollBehavior = scrollBehavior,
+ )
+ }
+
+ @OptIn(ExperimentalMaterial3Api::class)
+ @Composable
+ fun TesterUIBottomBar(
+ scrollBehavior: BottomAppBarScrollBehavior,
+ tokenMigrationAction: () -> Unit,
+ switchUserAction: () -> Unit,
+ logoutAction: () -> Unit,
+ ) {
+ BottomAppBar(
+ actions = {
+ Row(
+ modifier = Modifier.fillMaxWidth().padding(BOTTOM_BAR_SPACING.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ ) {
+ IconButton(onClick = tokenMigrationAction) {
+ Icon(
+ painterResource(R.drawable.key),
+ contentDescription = stringResource(R.string.migrate_access_token),
+ )
+ }
+ IconButton(onClick = switchUserAction) {
+ Icon(
+ painterResource(R.drawable.person_add),
+ contentDescription = stringResource(R.string.switch_user),
+ )
+ }
+ IconButton(onClick = logoutAction) {
+ Icon(
+ painterResource(R.drawable.logout),
+ contentDescription = stringResource(R.string.logout),
+ )
+ }
+ }
+ },
+ containerColor = colorScheme.background.copy(alpha = HALF_ALPHA),
+ scrollBehavior = scrollBehavior,
+ )
+ }
+
+ @Composable
+ fun getColorScheme(): ColorScheme {
+ return with(SalesforceSDKManager.getInstance()) {
+ when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
+ if (isDarkTheme) {
+ dynamicDarkColorScheme(this@AuthFlowTesterActivity)
+ } else {
+ dynamicLightColorScheme(this@AuthFlowTesterActivity)
+ }
+ }
+ else -> {
+ if (isDarkTheme) sfDarkColors() else sfLightColors()
+ }
+ }
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.S)
+ @ExcludeFromJacocoGeneratedReport
+ @Preview
+ @Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
+ @Composable
+ fun RevokeButtonCardActivePreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+ MaterialTheme(scheme) {
+ RevokeButtonCard(initialProgressState = true)
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.S)
+ @ExcludeFromJacocoGeneratedReport
+ @Preview
+ @Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
+ @Composable
+ fun RequestButtonCardActivePreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+ MaterialTheme(scheme) {
+ RequestButtonCard(
+ initialProgressState = true,
+ initialResponse = RequestResult(
+ success = true,
+ displayValue = "",
+ response = "Preview Request Response!",
+ ),
+ )
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.S)
+ @ExcludeFromJacocoGeneratedReport
+ @Preview
+ @Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
+ @Composable
+ fun TesterUIPreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+ MaterialTheme(scheme) {
+ TesterUI()
+ }
+ }
+
+ @ExcludeFromJacocoGeneratedReport
+ @Preview
+ @Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
+ @Composable
+ fun RevokeButtonCardFallbackThemePreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ sfDarkColors()
+ } else {
+ sfLightColors()
+ }
+ MaterialTheme(scheme) {
+ RevokeButtonCard(initialProgressState = true)
+ }
+ }
+
+ @ExcludeFromJacocoGeneratedReport
+ @Preview
+ @Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
+ @Composable
+ fun RequestButtonCardFallbackThemePreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ sfDarkColors()
+ } else {
+ sfLightColors()
+ }
+ MaterialTheme(scheme) {
+ RequestButtonCard(
+ initialProgressState = true,
+ initialResponse = RequestResult(
+ success = true,
+ displayValue = "",
+ response = "Preview Request Response!",
+ ),
+ )
+ }
+ }
+
+ @ExcludeFromJacocoGeneratedReport
+ @Preview
+ @Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
+ @Composable
+ fun TesterUIFallbackThemePreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ sfDarkColors()
+ } else {
+ sfLightColors()
+ }
+ MaterialTheme(scheme) {
+ TesterUI()
+ }
+ }
+
+ @RequiresApi(Build.VERSION_CODES.S)
+ @ExcludeFromJacocoGeneratedReport
+ @Preview
+ @Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
+ @Composable
+ fun MigrateAppPreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+ MaterialTheme(scheme) {
+ MigrateAppBottomSheet { }
+ }
+ }
+
+ @ExcludeFromJacocoGeneratedReport
+ @Preview
+ @Preview(name = "Dark", uiMode = Configuration.UI_MODE_NIGHT_YES)
+ @Composable
+ fun MigrateAppFallbackThemePreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ sfDarkColors()
+ } else {
+ sfLightColors()
+ }
+ MaterialTheme(scheme) {
+ MigrateAppBottomSheet { }
+ }
+ }
+}
\ No newline at end of file
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt
new file mode 100644
index 0000000000..53c7f9d15d
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/AuthFlowTesterApplication.kt
@@ -0,0 +1,46 @@
+/*
+ * 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.samples.authflowtester
+
+import android.app.Application
+import com.salesforce.androidsdk.app.SalesforceSDKManager
+
+class AuthFlowTesterApplication : Application() {
+
+ companion object {
+ private const val FEATURE_APP_USES_KOTLIN = "KT"
+ }
+
+ override fun onCreate() {
+ super.onCreate()
+ SalesforceSDKManager.initNative(
+ applicationContext,
+ AuthFlowTesterActivity::class.java,
+ )
+ SalesforceSDKManager.getInstance().registerUsedAppFeature(FEATURE_APP_USES_KOTLIN)
+ }
+}
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/BaseComponents.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/BaseComponents.kt
new file mode 100644
index 0000000000..8ffad9f8c6
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/BaseComponents.kt
@@ -0,0 +1,496 @@
+/*
+ * Copyright (c) 2026-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.samples.authflowtester
+
+import android.content.ClipData
+import android.content.ClipboardManager
+import android.content.Context
+import android.content.res.Configuration
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.core.Animatable
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.KeyboardArrowDown
+import androidx.compose.material.icons.filled.Share
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+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.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.UiComposable
+import androidx.compose.ui.draw.rotate
+import androidx.compose.ui.hapticfeedback.HapticFeedbackType
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalHapticFeedback
+import androidx.compose.ui.res.painterResource
+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.ui.theme.sfDarkColors
+import com.salesforce.androidsdk.ui.theme.sfLightColors
+import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport
+
+const val SECTION_TITLE_SIZE = 16
+const val ROW_FONT_SIZE = 14
+const val EXPAND_SECTION_ICON_SIZE = 20
+const val EXPANDED_ROTATION = 180f
+const val UNEXPANDED_ROTATION = 0f
+const val LABEL_WEIGHT = 0.4f
+const val VALUE_WEIGHT = 1 - LABEL_WEIGHT
+const val FIVE_CHARS = 5
+const val VISIBILITY_ICON_SIZE = 24
+
+
+
+@Composable
+fun ExpandableCard(
+ title: String,
+ exportedJSON: String,
+ defaultExpanded: Boolean = false, // Only used for Previews
+ content: @Composable (() -> Unit),
+) {
+ var isExpanded by remember { mutableStateOf(defaultExpanded) }
+ var showExportAlert by remember { mutableStateOf(false) }
+
+ Card(
+ modifier = Modifier.fillMaxWidth().padding((INNER_CARD_PADDING/2).dp),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.secondaryContainer
+ ),
+ shape = RoundedCornerShape(CORNER_SHAPE.dp)
+ ) {
+ Column(
+ modifier = Modifier.padding(INNER_CARD_PADDING.dp)
+ ) {
+ Row(
+ modifier = Modifier.fillMaxWidth(),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Row(
+ modifier = Modifier
+ .weight(1f)
+ .clickable { isExpanded = !isExpanded },
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = title,
+ style = MaterialTheme.typography.headlineSmall,
+ fontWeight = FontWeight.SemiBold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ IconButton(onClick = { showExportAlert = true }) {
+ Icon(
+ imageVector = Icons.Default.Share,
+ contentDescription = stringResource(R.string.export_credentials),
+ tint = MaterialTheme.colorScheme.primary
+ )
+ }
+
+ IconButton(onClick = { isExpanded = !isExpanded }) {
+ Icon(
+ imageVector = Icons.Default.KeyboardArrowDown,
+ contentDescription = if (isExpanded) {
+ stringResource(R.string.collapse)
+ } else {
+ stringResource(R.string.expand)
+ },
+ modifier = Modifier.rotate(
+ degrees = if (isExpanded) EXPANDED_ROTATION else UNEXPANDED_ROTATION
+ ),
+ tint = MaterialTheme.colorScheme.secondary
+ )
+ }
+ }
+
+ AnimatedVisibility(visible = isExpanded) {
+ Column(
+ modifier = Modifier.padding(top = (INNER_CARD_PADDING/2).dp),
+ verticalArrangement = Arrangement.spacedBy((INNER_CARD_PADDING/2).dp)
+ ) {
+ // Inner content here
+ content.invoke()
+ }
+ }
+ }
+ }
+
+ if (showExportAlert) {
+ val context = LocalContext.current
+ @Suppress("AssignedValueIsNeverRead")
+ AlertDialog(
+ onDismissRequest = { showExportAlert = false },
+ title = { Text(title) },
+ text = { Text(
+ text = exportedJSON,
+ modifier = Modifier.verticalScroll(rememberScrollState()),
+ ) },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ copyToClipboard(context, title = title, text = exportedJSON)
+ showExportAlert = false
+ }
+ ) {
+ Text(stringResource(R.string.copy_to_clipboard))
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { showExportAlert = false }) {
+ Text(stringResource(R.string.ok))
+ }
+ },
+ )
+ }
+}
+
+@Composable
+fun InfoSection(
+ title: String,
+ defaultExpanded: Boolean = true,
+ content: @Composable @UiComposable () -> Unit,
+) {
+ var isExpanded by remember { mutableStateOf(defaultExpanded) }
+ val chevronRotation = remember { Animatable(UNEXPANDED_ROTATION) }
+
+ LaunchedEffect(isExpanded) {
+ chevronRotation.animateTo(
+ targetValue = if (isExpanded) EXPANDED_ROTATION else UNEXPANDED_ROTATION
+ )
+ }
+
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.surface
+ ),
+ shape = RoundedCornerShape(CORNER_SHAPE.dp)
+ ) {
+ Column {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable { isExpanded = !isExpanded }
+ .padding(INNER_CARD_PADDING.dp),
+ horizontalArrangement = Arrangement.SpaceBetween,
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = title,
+ fontSize = SECTION_TITLE_SIZE.sp,
+ fontWeight = FontWeight.Bold,
+ color = MaterialTheme.colorScheme.primary
+ )
+ Icon(
+ Icons.Default.KeyboardArrowDown,
+ modifier = Modifier
+ .size(EXPAND_SECTION_ICON_SIZE.dp)
+ .rotate(chevronRotation.value),
+ contentDescription = if (isExpanded) {
+ stringResource(R.string.collapse)
+ } else {
+ stringResource(R.string.expand)
+ },
+ tint = MaterialTheme.colorScheme.secondary
+ )
+ }
+
+ AnimatedVisibility(visible = isExpanded) {
+ Column(modifier = Modifier.padding(bottom = (INNER_CARD_PADDING/2).dp)) {
+ content()
+ }
+ }
+ }
+ }
+}
+
+@Composable
+fun InfoRowView(
+ label: String,
+ value: String?,
+ isSensitive: Boolean = false,
+) {
+ var isValueVisible by remember { mutableStateOf(!isSensitive) }
+ val emptyText = stringResource(R.string.empty_placeholder)
+ val displayValue = if (isSensitive && !isValueVisible && !value.isNullOrEmpty()) {
+ "${value.take(FIVE_CHARS)}...${value.takeLast(FIVE_CHARS)}"
+ } else {
+ value
+ }
+ val haptics = LocalHapticFeedback.current
+ val context = LocalContext.current
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = INNER_CARD_PADDING.dp, vertical = (INNER_CARD_PADDING/4).dp)
+ .clickable(enabled = isSensitive && !value.isNullOrEmpty()) {
+ isValueVisible = !isValueVisible
+ }
+ .combinedClickable(
+ onClick = {
+ if(isSensitive && !value.isNullOrEmpty()) {
+ isValueVisible = !isValueVisible
+ }
+ },
+ onLongClick = {
+ value?.let {
+ haptics.performHapticFeedback(HapticFeedbackType.LongPress)
+ copyToClipboard(context, label, value)
+ }
+ }
+ ),
+ horizontalArrangement = Arrangement.SpaceBetween
+ ) {
+ Text(
+ text = "${label}:",
+ fontSize = ROW_FONT_SIZE.sp,
+ fontWeight = FontWeight.Medium,
+ color = MaterialTheme.colorScheme.onSurface,
+ modifier = Modifier.weight(LABEL_WEIGHT),
+ )
+
+ // Ensure some space between label and values.
+ Spacer(modifier = Modifier.width((INNER_CARD_PADDING/4).dp))
+
+ Text(
+ text = displayValue?.ifEmpty { emptyText } ?: emptyText,
+ fontSize = ROW_FONT_SIZE.sp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ modifier = Modifier.weight(VALUE_WEIGHT).padding(end = (INNER_CARD_PADDING/2).dp),
+ )
+
+ if (isSensitive && !value.isNullOrEmpty()) {
+ if (isValueVisible) {
+ Icon(
+ painter = painterResource(id = R.drawable.visibility_off),
+ contentDescription = stringResource(R.string.hide_sensitive),
+ modifier = Modifier.size(VISIBILITY_ICON_SIZE.dp),
+ )
+ } else {
+ Icon(
+ painter = painterResource(id = R.drawable.visibility),
+ contentDescription = stringResource(R.string.show_sensitive),
+ modifier = Modifier.size(VISIBILITY_ICON_SIZE.dp),
+ )
+ }
+ } else {
+ // Add spacer so sensitive and non-sensitive fields remain aligned.
+ Spacer(modifier = Modifier.width(VISIBILITY_ICON_SIZE.dp))
+ }
+ }
+}
+
+private fun copyToClipboard(context: Context, title: String, text: String) {
+ val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
+ val label = context.getString(R.string.clipboard_label_format, title)
+ val clip = ClipData.newPlainText(label, text)
+ clipboard.setPrimaryClip(clip)
+}
+
+@RequiresApi(Build.VERSION_CODES.S)
+@ExcludeFromJacocoGeneratedReport
+@Preview(showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun InfoRowViewPreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+ MaterialTheme(scheme) {
+ InfoRowView("Label", "Value")
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.S)
+@ExcludeFromJacocoGeneratedReport
+@Preview(showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun InfoRowViewSensitivePreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+ MaterialTheme(scheme) {
+ InfoRowView("Sensitive Label", "3aZ*GQ!o2^@8QPR", isSensitive = true)
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.S)
+@ExcludeFromJacocoGeneratedReport
+@Preview(showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun InfoRowSectionPreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+ MaterialTheme(scheme) {
+ InfoSection("Section Title") {
+ InfoRowView("Sensitive Label", "3aZ*GQ!o2^@8QPR", isSensitive = true)
+ }
+ }
+}
+
+@ExcludeFromJacocoGeneratedReport
+@Preview(showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun InfoRowSectionFallbackThemePreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ sfDarkColors()
+ } else {
+ sfLightColors()
+ }
+ MaterialTheme(scheme) {
+ InfoSection("Section Title") {
+ InfoRowView("Sensitive Label", "3aZ*GQ!o2^@8QPR", isSensitive = true)
+ }
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.S)
+@ExcludeFromJacocoGeneratedReport
+@Preview(showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun UserCredentialsViewPreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+ MaterialTheme(scheme) {
+ ExpandableCard(
+ title = "Card Title",
+ exportedJSON = "",
+ ) { }
+ }
+}
+
+@ExcludeFromJacocoGeneratedReport
+@Preview(showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun UserCredentialsViewFallbackThemePreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ sfDarkColors()
+ } else {
+ sfLightColors()
+ }
+ MaterialTheme(scheme) {
+ ExpandableCard(
+ title = "Card Title",
+ exportedJSON = "",
+ ) { }
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.S)
+@ExcludeFromJacocoGeneratedReport
+@Preview(showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun UserCredentialsViewExpandedPreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+ MaterialTheme(scheme) {
+ ExpandableCard(
+ title = "Card Title",
+ exportedJSON = "",
+ defaultExpanded = true,
+ ) {
+ InfoSection("Section Title") {
+ InfoRowView("Sensitive Label", "3aZ*GQ!o2^@8QPR", isSensitive = true)
+ }
+ }
+ }
+}
+
+@ExcludeFromJacocoGeneratedReport
+@Preview(showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun UserCredentialsViewFallbackThemeExpandedPreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ sfDarkColors()
+ } else {
+ sfLightColors()
+ }
+ MaterialTheme(scheme) {
+ ExpandableCard(
+ title = "Card Title",
+ exportedJSON = "",
+ defaultExpanded = true,
+ ) {
+ InfoSection("Section Title") {
+ InfoRowView("Sensitive Label", "3aZ*GQ!o2^@8QPR", isSensitive = true)
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/JwtTokenView.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/JwtTokenView.kt
new file mode 100644
index 0000000000..2952e64393
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/JwtTokenView.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright (c) 2026-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.samples.authflowtester
+
+import android.content.res.Configuration
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import com.salesforce.androidsdk.auth.JwtAccessToken
+import com.salesforce.androidsdk.ui.theme.sfDarkColors
+import com.salesforce.androidsdk.ui.theme.sfLightColors
+import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonObject
+
+private const val CARD_TITLE = "JWT Details"
+
+// Section titles
+private const val HEADER = "Header"
+private const val PAYLOAD = "Payload"
+
+// Header fields
+private const val ALGORITHM = "Algorithm (alg)"
+private const val TYPE = "Type (typ)"
+private const val KEY_ID = "Key ID (kid)"
+private const val TOKEN_TYPE = "Token Type (tty)"
+private const val TENANT_KEY = "Tenant Key (tnk)"
+private const val VERSION = "Version (ver)"
+
+// Payload fields
+private const val AUDIENCE = "Audience (aud)"
+private const val EXPIRATION_DATE = "Expiration Date (exp)"
+private const val ISSUER = "Issuer (iss)"
+private const val SUBJECT = "Subject (sub)"
+private const val SCOPES = "Scopes (scp)"
+private const val CLIENT_ID = "Client ID (client_id)"
+
+@Composable
+fun JwtTokenView(jwtToken: JwtAccessToken?) {
+ ExpandableCard(
+ title = CARD_TITLE,
+ exportedJSON = generateJwtJSON(jwtToken)
+ ) {
+ InfoSection(title = HEADER) {
+ InfoRowView(label = ALGORITHM, value = jwtToken?.header?.algorithn)
+ InfoRowView(label = TYPE, value = jwtToken?.header?.type)
+ InfoRowView(label = KEY_ID, value = jwtToken?.header?.keyId)
+ InfoRowView(label = TOKEN_TYPE, value = jwtToken?.header?.tokenType)
+ InfoRowView(label = TENANT_KEY, value = jwtToken?.header?.tenantKey)
+ InfoRowView(label = VERSION, value = jwtToken?.header?.version)
+ }
+
+ InfoSection(title = PAYLOAD) {
+ InfoRowView(label = AUDIENCE, value = jwtToken?.payload?.audience?.joinToString(", "))
+ InfoRowView(label = EXPIRATION_DATE, value = jwtToken?.expirationDate().toString())
+ InfoRowView(label = ISSUER, value = jwtToken?.payload?.issuer)
+ InfoRowView(label = SUBJECT, value = jwtToken?.payload?.subject)
+ InfoRowView(label = SCOPES, value = jwtToken?.payload?.scopes)
+ InfoRowView(label = CLIENT_ID, value = jwtToken?.payload?.clientId, isSensitive = true)
+ }
+ }
+}
+
+private fun generateJwtJSON(jwtToken: JwtAccessToken?): String {
+ if (jwtToken == null) return "{}"
+
+ return try {
+ val result = buildJsonObject {
+ putJsonObject(HEADER) {
+ with(jwtToken.header) {
+ put(ALGORITHM, algorithn)
+ put(TYPE, type)
+ put(KEY_ID, keyId)
+ put(TOKEN_TYPE, tokenType)
+ put(TENANT_KEY, tenantKey)
+ put(VERSION, version)
+ }
+ }
+
+ putJsonObject(PAYLOAD) {
+ with(jwtToken.payload) {
+ put(AUDIENCE, audience?.joinToString(", "))
+ put(EXPIRATION_DATE, jwtToken.expirationDate().toString())
+ put(ISSUER, issuer)
+ put(SUBJECT, subject)
+ put(SCOPES, scopes)
+ put(CLIENT_ID, clientId)
+ }
+ }
+ }
+ Json { prettyPrint = true }.encodeToString(result)
+ } catch (_: Exception) {
+ "{}"
+ }
+}
+
+
+@RequiresApi(Build.VERSION_CODES.S)
+@ExcludeFromJacocoGeneratedReport
+@Preview(showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun JwtTokenViewPreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+
+ // Use Interactive mode to see preview data
+ MaterialTheme(scheme) {
+ JwtTokenView(jwtToken = null)
+ }
+}
+
+@ExcludeFromJacocoGeneratedReport
+@Preview(showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun JwtTokenViewFallbackThemePreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ sfDarkColors()
+ } else {
+ sfLightColors()
+ }
+
+ MaterialTheme(scheme) {
+ JwtTokenView(jwtToken = null)
+ }
+}
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/OAuthConfigurationView.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/OAuthConfigurationView.kt
new file mode 100644
index 0000000000..3625f24455
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/OAuthConfigurationView.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright (c) 2026-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.samples.authflowtester
+
+import android.content.res.Configuration
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalInspectionMode
+import androidx.compose.ui.tooling.preview.Preview
+import com.salesforce.androidsdk.auth.ScopeParser.Companion.toScopeParameter
+import com.salesforce.androidsdk.config.BootConfig
+import com.salesforce.androidsdk.ui.theme.sfDarkColors
+import com.salesforce.androidsdk.ui.theme.sfLightColors
+import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+
+private const val CARD_TITLE = "OAuth Configuration"
+private const val CONSUMER_KEY = "Configured Consumer Key"
+private const val CALLBACK_URL = "Configured Callback URL"
+private const val SCOPES = "Configured Scopes"
+
+
+@Composable
+fun OAuthConfigurationView() {
+ val bootConfig = if (LocalInspectionMode.current) {
+ null
+ } else {
+ BootConfig.getBootConfig(LocalContext.current)
+ }
+ val consumerKey = bootConfig?.remoteAccessConsumerKey
+ val redirect = bootConfig?.oauthRedirectURI
+ val scopes = bootConfig?.oauthScopes.toScopeParameter()
+
+ ExpandableCard(
+ title = CARD_TITLE,
+ exportedJSON = generateConfigJSON(consumerKey, redirect, scopes),
+ ) {
+ InfoSection(title = "") {
+ InfoRowView(label = CONSUMER_KEY, value = consumerKey)
+ InfoRowView(label = CALLBACK_URL, value = redirect)
+ InfoRowView(label = SCOPES, value = scopes)
+ }
+ }
+}
+
+private fun generateConfigJSON(
+ consumerKey: String?,
+ callbackUrl: String?,
+ scopes: String?,
+): String {
+ return try {
+ val result = buildJsonObject {
+ put(CONSUMER_KEY, consumerKey)
+ put(CALLBACK_URL, callbackUrl)
+ put(SCOPES, scopes)
+ }
+ Json { prettyPrint = true }.encodeToString(result)
+ } catch (_: Exception) {
+ "{}"
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.S)
+@ExcludeFromJacocoGeneratedReport
+@Preview
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun OAuthConfigurationViewPreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+ MaterialTheme(scheme) {
+ OAuthConfigurationView()
+ }
+}
+
+@ExcludeFromJacocoGeneratedReport
+@Preview
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun OAuthConfigurationViewFallbackThemePreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ sfDarkColors()
+ } else {
+ sfLightColors()
+ }
+ MaterialTheme(scheme) {
+ OAuthConfigurationView()
+ }
+}
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/RestUtils.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/RestUtils.kt
new file mode 100644
index 0000000000..2e72e8616f
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/RestUtils.kt
@@ -0,0 +1,103 @@
+package com.salesforce.samples.authflowtester
+
+import com.salesforce.androidsdk.app.SalesforceSDKManager
+import com.salesforce.androidsdk.rest.RestClient
+import com.salesforce.androidsdk.rest.RestRequest
+import com.salesforce.androidsdk.rest.RestResponse
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.RequestBody.Companion.toRequestBody
+import java.net.URLEncoder
+import java.nio.charset.StandardCharsets
+import kotlin.coroutines.resume
+import kotlin.coroutines.suspendCoroutine
+
+const val FAILED_OPERATION = "The operation could not be completed."
+const val UNKNOWN_ERROR = "An unexpected error has occurred."
+const val REVOKE_SUCCESS = "The access token has been successfully revoked. " +
+ "You may need to make a REST API request to trigger a token refresh."
+const val REQUEST_SUCCESS = "The REST API request completed successfully. Expand " +
+ "'Response Details' section to see the full response."
+const val AUTH_REQUIRED = "Please authenticate to use this function."
+
+data class RequestResult(val success: Boolean, val displayValue: String, val response: String? = null)
+
+suspend fun revokeAccessTokenAction(client: RestClient?): RequestResult {
+ // This should never happen.
+ if (client == null) return RequestResult(success = false, AUTH_REQUIRED)
+
+ val token = SalesforceSDKManager.getInstance().userAccountManager.currentUser.authToken
+ val encodedToken = URLEncoder.encode(token, StandardCharsets.UTF_8.toString())
+ val body = "token=$encodedToken".toRequestBody(
+ contentType = "application/x-www-form-urlencoded".toMediaType(),
+ )
+ val request = RestRequest(
+ RestRequest.RestMethod.POST,
+ RestRequest.RestEndpoint.INSTANCE,
+ "/services/oauth2/revoke",
+ body,
+ /* additionalHttpHeaders = */ emptyMap(),
+ )
+
+ val result = client.sendAsync(request)
+ if (result.isSuccess) {
+ val response = result.getOrNull()
+ val displayValue = when {
+ response == null -> FAILED_OPERATION
+ response.isSuccess -> REVOKE_SUCCESS
+ !response.isSuccess -> "$FAILED_OPERATION Error code: ${response.statusCode}"
+ else -> FAILED_OPERATION
+ }
+
+ return RequestResult(response?.isSuccess ?: false, displayValue)
+ } else {
+ val displayValue = result.exceptionOrNull()?.message ?: UNKNOWN_ERROR
+ return RequestResult(false, displayValue)
+ }
+}
+
+suspend fun makeRestRequest(client: RestClient?, apiVersion: String): RequestResult {
+ // This should never happen.
+ if (client == null) return RequestResult(success = false, AUTH_REQUIRED)
+
+ val result = client.sendAsync(RestRequest.getCheapRequest(apiVersion))
+ if (result.isSuccess) {
+ val response = result.getOrNull()
+ val displayValue = when {
+ response == null -> FAILED_OPERATION
+ response.isSuccess -> REQUEST_SUCCESS
+ !response.isSuccess -> "$FAILED_OPERATION Error code: ${response.statusCode}"
+ else -> FAILED_OPERATION
+ }
+ val formattedResponse = try {
+ val jsonElement = response?.asString()?.let { Json.parseToJsonElement(it) }
+ Json { prettyPrint = true }.encodeToString(jsonElement)
+ } catch (_: Exception) {
+ response?.asString() ?: ""
+ }
+
+ return RequestResult(response?.isSuccess ?: false, displayValue, formattedResponse)
+ } else {
+ return RequestResult(false, result.exceptionOrNull()?.message ?: UNKNOWN_ERROR)
+ }
+}
+
+suspend fun RestClient.sendAsync(request: RestRequest): Result {
+ return suspendCoroutine { continuation ->
+ sendAsync(request, object : RestClient.AsyncRequestCallback {
+ override fun onSuccess(request: RestRequest?, response: RestResponse?) {
+ val result: Result = if (response == null) {
+ Result.failure(Exception(UNKNOWN_ERROR))
+ } else {
+ Result.success(response)
+ }
+ continuation.resume(result)
+ }
+
+ override fun onError(exception: Exception?) {
+ continuation.resume(Result.failure(exception ?: Exception(UNKNOWN_ERROR)))
+ }
+ })
+ }
+}
\ No newline at end of file
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/UserCredentialsView.kt b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/UserCredentialsView.kt
new file mode 100644
index 0000000000..49f516f43c
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/java/com/salesforce/samples/authflowtester/UserCredentialsView.kt
@@ -0,0 +1,287 @@
+/*
+ * Copyright (c) 2026-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.samples.authflowtester
+
+import android.content.res.Configuration
+import android.os.Build
+import androidx.annotation.RequiresApi
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.dynamicDarkColorScheme
+import androidx.compose.material3.dynamicLightColorScheme
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.tooling.preview.Preview
+import com.salesforce.androidsdk.accounts.UserAccount
+import com.salesforce.androidsdk.auth.ScopeParser.Companion.toScopeParser
+import com.salesforce.androidsdk.ui.theme.sfDarkColors
+import com.salesforce.androidsdk.ui.theme.sfLightColors
+import com.salesforce.androidsdk.util.test.ExcludeFromJacocoGeneratedReport
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import kotlinx.serialization.json.buildJsonObject
+import kotlinx.serialization.json.put
+import kotlinx.serialization.json.putJsonObject
+
+private const val CARD_TITLE = "User Credentials"
+
+// Section titles
+private const val USER_IDENTITY = "User Identity"
+private const val OAUTH_CLIENT_CONFIGURATION = "OAuth Client Configuration"
+private const val TOKENS = "Tokens"
+private const val URLS = "URLs"
+private const val COMMUNITY = "Community"
+private const val DOMAINS_AND_SIDS = "Domains and SIDs"
+private const val COOKIES_AND_SECURITY = "Cookies and Security"
+private const val BEACON = "Beacon"
+private const val OTHER = "Other"
+
+// User Identity fields
+private const val USERNAME = "Username"
+private const val USER_ID_LABEL = "User ID"
+private const val ORGANIZATION_ID = "Organization ID"
+
+// OAuth Client Configuration fields
+private const val CLIENT_ID = "Client ID"
+private const val DOMAIN = "Domain"
+
+// Tokens fields
+private const val ACCESS_TOKEN = "Access Token"
+private const val REFRESH_TOKEN = "Refresh Token"
+private const val TOKEN_FORMAT = "Token Format"
+private const val SCOPES = "Scopes"
+
+// URLs fields
+private const val INSTANCE_URL = "Instance URL"
+private const val API_INSTANCE_URL = "API Instance URL"
+private const val IDENTITY_URL = "Identity URL"
+
+// Community fields
+private const val COMMUNITY_ID = "Community ID"
+private const val COMMUNITY_URL = "Community URL"
+
+// Domains and SIDs fields
+private const val LIGHTNING_DOMAIN = "Lightning Domain"
+private const val LIGHTNING_SID = "Lightning SID"
+private const val VF_DOMAIN = "VF Domain"
+private const val VF_SID = "VF SID"
+private const val CONTENT_DOMAIN = "Content Domain"
+private const val CONTENT_SID = "Content SID"
+private const val PARENT_SID = "Parent SID"
+private const val SID_COOKIE_NAME = "SID Cookie Name"
+
+// Cookies and Security fields
+private const val CSRF_TOKEN = "CSRF Token"
+private const val COOKIE_CLIENT_SRC = "Cookie Client Src"
+private const val COOKIE_SID_CLIENT = "Cookie SID Client"
+
+// Beacon fields
+private const val BEACON_CHILD_CONSUMER_KEY = "Beacon Child Consumer Key"
+private const val BEACON_CHILD_CONSUMER_SECRET = "Beacon Child Consumer Secret"
+
+// Other fields
+private const val ADDITIONAL_OAUTH_FIELDS = "Additional OAuth Fields"
+
+@Composable
+fun UserCredentialsView(currentUser: UserAccount?) {
+ ExpandableCard(
+ title = CARD_TITLE,
+ exportedJSON = generateCredentialsJSON(currentUser),
+ ) {
+ InfoSection(title = USER_IDENTITY) {
+ InfoRowView(label = USERNAME, value = currentUser?.username)
+ InfoRowView(label = USER_ID_LABEL, value = currentUser?.userId)
+ InfoRowView(label = ORGANIZATION_ID, value = currentUser?.orgId)
+ }
+
+ InfoSection(title = OAUTH_CLIENT_CONFIGURATION) {
+ InfoRowView(label = CLIENT_ID, value = currentUser?.clientId, isSensitive = true)
+ InfoRowView(label = DOMAIN, value = currentUser?.loginServer)
+ }
+
+ InfoSection(title = TOKENS) {
+ InfoRowView(label = ACCESS_TOKEN, value = currentUser?.authToken, isSensitive = true)
+ InfoRowView(label = REFRESH_TOKEN, value = currentUser?.refreshToken, isSensitive = true)
+ InfoRowView(label = TOKEN_FORMAT, value = currentUser?.tokenFormat)
+ InfoRowView(label = SCOPES, value = formatScopes(currentUser))
+ }
+
+ InfoSection(title = URLS) {
+ InfoRowView(label = INSTANCE_URL, value = currentUser?.instanceServer)
+ InfoRowView(label = API_INSTANCE_URL, value = currentUser?.apiInstanceServer)
+ InfoRowView(label = IDENTITY_URL, value = currentUser?.idUrl)
+ }
+
+ InfoSection(title = COMMUNITY) {
+ InfoRowView(label = COMMUNITY_ID, value = currentUser?.communityId)
+ InfoRowView(label = COMMUNITY_URL, value = currentUser?.communityUrl)
+ }
+
+ InfoSection(title = DOMAINS_AND_SIDS) {
+ InfoRowView(label = LIGHTNING_DOMAIN, value = currentUser?.lightningDomain)
+ InfoRowView(label = LIGHTNING_SID, value = currentUser?.lightningSid, isSensitive = true)
+ InfoRowView(label = VF_DOMAIN, value = currentUser?.vfDomain)
+ InfoRowView(label = VF_SID, value = currentUser?.vfSid, isSensitive = true)
+ InfoRowView(label = CONTENT_DOMAIN, value = currentUser?.contentDomain)
+ InfoRowView(label = CONTENT_SID, value = currentUser?.contentSid, isSensitive = true)
+ InfoRowView(label = PARENT_SID, value = currentUser?.parentSid, isSensitive = true)
+ InfoRowView(label = SID_COOKIE_NAME, value = currentUser?.sidCookieName)
+ }
+
+ InfoSection(title = COOKIES_AND_SECURITY) {
+ InfoRowView(label = CSRF_TOKEN, value = currentUser?.csrfToken, isSensitive = true)
+ InfoRowView(label = COOKIE_CLIENT_SRC, value = currentUser?.cookieClientSrc)
+ InfoRowView(label = COOKIE_SID_CLIENT, value = currentUser?.cookieSidClient, isSensitive = true)
+ }
+
+ InfoSection(title = BEACON) {
+ InfoRowView(label = BEACON_CHILD_CONSUMER_KEY, value = currentUser?.beaconChildConsumerKey)
+ InfoRowView(label = BEACON_CHILD_CONSUMER_SECRET, value = currentUser?.beaconChildConsumerSecret, isSensitive = true)
+ }
+
+ InfoSection(title = OTHER) {
+ InfoRowView(label = ADDITIONAL_OAUTH_FIELDS, value = formatAdditionalOAuthFields(currentUser))
+ }
+ }
+}
+
+private fun formatScopes(user: UserAccount?): String? {
+ return user?.scope?.toScopeParser()?.scopesAsString
+}
+
+private fun formatAdditionalOAuthFields(user: UserAccount?): String? {
+ val fields = user?.additionalOauthValues ?: return null
+ return try {
+ val json = buildJsonObject {
+ fields.forEach { (key, value) ->
+ put(key, value)
+ }
+ }
+ Json { prettyPrint = true }.encodeToString(json)
+ } catch (_: Exception) {
+ null
+ }
+}
+
+private fun generateCredentialsJSON(user: UserAccount?): String {
+ if (user == null) return "{}"
+
+ try {
+ val result = buildJsonObject {
+ putJsonObject(USER_IDENTITY) {
+ put(USERNAME, user.username)
+ put(USER_ID_LABEL, user.userId)
+ put(ORGANIZATION_ID, user.orgId)
+ }
+
+ putJsonObject(OAUTH_CLIENT_CONFIGURATION) {
+ put(CLIENT_ID, user.clientId)
+ put(DOMAIN, user.loginServer)
+ }
+
+ putJsonObject(TOKENS) {
+ put(ACCESS_TOKEN, user.authToken)
+ put(REFRESH_TOKEN, user.refreshToken)
+ put(TOKEN_FORMAT, user.tokenFormat)
+ put(SCOPES, formatScopes(user))
+ }
+
+ putJsonObject(URLS) {
+ put(INSTANCE_URL, user.instanceServer)
+ put(API_INSTANCE_URL, user.apiInstanceServer)
+ put(IDENTITY_URL, user.idUrl)
+ }
+
+ putJsonObject(COMMUNITY) {
+ put(COMMUNITY_ID, user.communityId)
+ put(COMMUNITY_URL, user.communityUrl)
+ }
+
+ putJsonObject(DOMAINS_AND_SIDS) {
+ put(LIGHTNING_DOMAIN, user.lightningDomain)
+ put(LIGHTNING_SID, user.lightningSid)
+ put(VF_DOMAIN, user.vfDomain)
+ put(VF_SID, user.vfSid)
+ put(CONTENT_DOMAIN, user.contentDomain)
+ put(CONTENT_SID, user.contentSid)
+ put(PARENT_SID, user.parentSid)
+ put(SID_COOKIE_NAME, user.sidCookieName)
+ }
+
+ putJsonObject(COOKIES_AND_SECURITY) {
+ put(CSRF_TOKEN, user.csrfToken)
+ put(COOKIE_CLIENT_SRC, user.cookieClientSrc)
+ put(COOKIE_SID_CLIENT, user.cookieSidClient)
+ }
+
+ putJsonObject(BEACON) {
+ put(BEACON_CHILD_CONSUMER_KEY, user.beaconChildConsumerKey)
+ put(BEACON_CHILD_CONSUMER_SECRET, user.beaconChildConsumerSecret)
+ }
+
+ putJsonObject(OTHER) {
+ put(ADDITIONAL_OAUTH_FIELDS, formatAdditionalOAuthFields(user))
+ }
+ }
+
+ return Json { prettyPrint = true }.encodeToString(result)
+ } catch (_: Exception) {
+ return "{}"
+ }
+}
+
+@RequiresApi(Build.VERSION_CODES.S)
+@ExcludeFromJacocoGeneratedReport
+@Preview(showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun UserCredentialsViewPreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ dynamicDarkColorScheme(LocalContext.current)
+ } else {
+ dynamicLightColorScheme(LocalContext.current)
+ }
+ MaterialTheme(scheme) {
+ UserCredentialsView(currentUser = null)
+ }
+}
+
+@ExcludeFromJacocoGeneratedReport
+@Preview(showBackground = true)
+@Preview(uiMode = Configuration.UI_MODE_NIGHT_YES, showBackground = true, backgroundColor = 0xFF181818)
+@Composable
+private fun UserCredentialsViewFallbackThemePreview() {
+ val scheme = if (isSystemInDarkTheme()) {
+ sfDarkColors()
+ } else {
+ sfLightColors()
+ }
+ MaterialTheme(scheme) {
+ UserCredentialsView(currentUser = null)
+ }
+}
\ No newline at end of file
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/close.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/close.xml
new file mode 100644
index 0000000000..7a0ff35dfb
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/close.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/json.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/json.xml
new file mode 100644
index 0000000000..e09dc3e070
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/json.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/key.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/key.xml
new file mode 100644
index 0000000000..e0df275b3c
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/key.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/logout.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/logout.xml
new file mode 100644
index 0000000000..77efdba791
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/logout.xml
@@ -0,0 +1,11 @@
+
+
+
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/person_add.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/person_add.xml
new file mode 100644
index 0000000000..466644d2d3
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/person_add.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/visibility.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/visibility.xml
new file mode 100644
index 0000000000..23d640a8b3
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/visibility.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/visibility_off.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/visibility_off.xml
new file mode 100644
index 0000000000..6fa698a6c3
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/drawable/visibility_off.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/values/bootconfig.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/values/bootconfig.xml
new file mode 100644
index 0000000000..1ecafa8204
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/values/bootconfig.xml
@@ -0,0 +1,6 @@
+
+
+
+ 3MVG98dostKihXN53TYStBIiS8FC2a3tE3XhGId0hQ37iQjF0xe4fxMSb2mFaWZn9e3GiLs1q67TNlyRji.Xw
+ testsfdc:///mobilesdk/detect/oauth/done
+
diff --git a/native/NativeSampleApps/AuthFlowTester/src/main/res/values/strings.xml b/native/NativeSampleApps/AuthFlowTester/src/main/res/values/strings.xml
new file mode 100644
index 0000000000..0e97750c6d
--- /dev/null
+++ b/native/NativeSampleApps/AuthFlowTester/src/main/res/values/strings.xml
@@ -0,0 +1,42 @@
+
+
+ com.salesforce.samples.authflowtester
+ AuthFlowTester
+ com.salesforce.samples.authflowtester
+
+
+ Revoking…
+ Revoke Access Token
+ No Response
+ Revoke Successful
+ Revoke Failed
+ OK
+ Making Request…
+ Make REST API Request
+ Response Details
+ Request Successful
+ Request Failed
+ Migrate Access Token
+ Switch User
+ Logout
+ Are you sure you want to logout?
+ Cancel
+ Migrate to New App
+ close
+ import json
+ Migrate Refresh Token
+ Import Configuration
+ Import
+ Paste json with %1$s, %2$s and %3$s:
+
+
+
+ (empty)
+ Export Credentials
+ Collapse
+ Expand
+ Copy to Clipboard
+ Hide sensitive content.
+ Show sensitive content.
+ %1$s JSON
+
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 38a6b2317d..457d57aa50 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -9,6 +9,7 @@ include("native:NativeSampleApps:AppConfigurator")
include("native:NativeSampleApps:ConfiguredApp")
include("hybrid:HybridSampleApps:MobileSyncExplorerHybrid")
include("native:NativeSampleApps:RestExplorer")
+include("native:NativeSampleApps:AuthFlowTester")
pluginManagement {
repositories {
@@ -27,4 +28,4 @@ dependencyResolutionManagement {
google()
mavenCentral()
}
-}
+}
\ No newline at end of file