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