Skip to content

Commit

Permalink
[connect] Fix file uploading (#9879)
Browse files Browse the repository at this point in the history
* rm duplicate color values; fix dynamic

* fix webview file uploading

* minor cleanup

* cleanup chooseFileLaunchers

* fix filePathCallback error edge case
  • Loading branch information
lng-stripe authored Jan 9, 2025
1 parent 924207c commit 0a13596
Show file tree
Hide file tree
Showing 7 changed files with 159 additions and 77 deletions.
56 changes: 2 additions & 54 deletions connect-example/src/main/res/values-night/colors.xml
Original file line number Diff line number Diff line change
@@ -1,59 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="default_border_color">#FBD992</color>
<color name="default_background_color">#FCEEB5</color>
<color name="default_text_color">#B13600</color>

<!-- Ogre Theme Colors -->
<color name="ogre_primary">#5AE92B</color>
<color name="ogre_background">#837411</color>
<color name="ogre_button_primary_background">#DAB9B9</color>
<color name="ogre_button_primary_border">#F00000</color>
<color name="ogre_button_primary_text">#000000</color>
<color name="ogre_button_secondary_background">#025F08</color>
<color name="ogre_text">#554125</color>
<color name="ogre_badge_neutral_text">#28D72A</color>
<color name="ogre_badge_neutral_background">#638863</color>
<color name="ogre_button_secondary_text">#000000</color>

<!-- Hot Dog Theme Colors -->
<color name="hot_dog_primary">#FF2200</color>
<color name="hot_dog_background">#FFFF00</color>
<color name="hot_dog_button_secondary_background">#C6C6C6</color>
<color name="hot_dog_button_secondary_border">#1F1F1F</color>
<color name="hot_dog_button_primary_border">#1F1F1F</color>
<color name="hot_dog_button_primary_text">#1F1F1F</color>
<color name="hot_dog_button_primary_background">#C6C6C6</color>
<color name="hot_dog_offset_background">#FF2200</color>
<color name="hot_dog_text">#000000</color>
<color name="hot_dog_secondary_text">#000000</color>
<color name="hot_dog_badge_danger_text">#991400</color>
<color name="hot_dog_badge_warning_background">#F9A443</color>

<!-- Ocean Breeze Theme Colors -->
<color name="ocean_breeze_background">#EAF6FB</color>
<color name="ocean_breeze_primary">#15609E</color>
<color name="ocean_breeze_badge_success_text">#2A6093</color>
<color name="ocean_breeze_badge_neutral_text">#5A621D</color>
<color name="ocean_breeze_button_secondary_text">#2C93E8</color>
<color name="ocean_breeze_button_secondary_border">#2C93E8</color>

<!-- Link Theme Colors -->
<color name="link_primary">#1C3944</color>
<color name="link_button_primary_background">#33DCB3</color>
<color name="link_button_primary_border">#33DCB3</color>
<color name="link_button_primary_text">#1C3944</color>
<color name="link_secondary_text">#485B61</color>
<color name="link_text">#1C3944</color>
<color name="link_action_primary_text">#33DCB3</color>
<color name="link_badge_success_background">#B4FEE1</color>
<color name="link_badge_success_border">#C0D7CD</color>
<color name="link_badge_success_text">#1C3944</color>
<color name="link_badge_neutral_background">#DEFECC</color>
<color name="link_badge_neutral_text">#1C3944</color>

<!-- Dynamic Colors Theme -->
<color name="dynamic_colors_primary">#EBF0F4</color>
<color name="dynamic_colors_primary">#0969DA</color>
<color name="dynamic_colors_text">#FFFFFF</color>
<color name="dynamic_colors_background">#272626</color>
<color name="dynamic_colors_button_primary_background">#077EDF</color>
Expand All @@ -64,7 +12,7 @@
<color name="dynamic_colors_button_secondary_text">#FFFFFF</color>
<color name="dynamic_colors_border">#3D3D3D</color>
<color name="dynamic_colors_secondary_text">#F4F3F3</color>
<color name="dynamic_colors_action_primary_text">#EBF0F4</color>
<color name="dynamic_colors_action_primary_text">#077EDF</color>
<color name="dynamic_colors_action_secondary_text">#F7F7F7</color>
<color name="dynamic_colors_form_accent">#EBF0F4</color>
<color name="dynamic_colors_form_highlight_border">#363636</color>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ import android.Manifest
import android.app.Activity
import android.app.Application.ActivityLifecycleCallbacks
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Bundle
import android.os.Parcelable
import androidx.activity.ComponentActivity
Expand All @@ -19,6 +21,7 @@ import com.stripe.android.connect.analytics.DefaultConnectAnalyticsService
import com.stripe.android.connect.appearance.Appearance
import com.stripe.android.connect.appearance.fonts.CustomFontSource
import com.stripe.android.connect.util.findActivity
import com.stripe.android.connect.webview.ChooseFileActivityResultContract
import com.stripe.android.connect.webview.serialization.ConnectInstanceJs
import com.stripe.android.connect.webview.serialization.toJs
import com.stripe.android.core.Logger
Expand Down Expand Up @@ -60,7 +63,7 @@ class EmbeddedComponentManager(
val activity = checkNotNull(context.findActivity()) {
"You must create an AccountOnboardingView from an Activity"
}
checkNotNull(launcherMap[activity]) {
checkNotNull(requestPermissionLaunchers[activity]) {
"You must call EmbeddedComponentManager.onActivityCreate in your Activity.onCreate function"
}

Expand All @@ -83,7 +86,7 @@ class EmbeddedComponentManager(
val activity = checkNotNull(context.findActivity()) {
"You must create a PayoutsView from an Activity"
}
checkNotNull(launcherMap[activity]) {
checkNotNull(requestPermissionLaunchers[activity]) {
"You must call EmbeddedComponentManager.onActivityCreate in your Activity.onCreate function"
}

Expand Down Expand Up @@ -151,6 +154,26 @@ class EmbeddedComponentManager(
return true
}

val launcher = getLauncher(context, requestPermissionLaunchers, "Error launching camera permission request")
?: return null
launcher.launch(Manifest.permission.CAMERA)

return permissionsFlow.first()
}

internal suspend fun chooseFile(context: Context, requestIntent: Intent): Array<Uri>? {
val launcher = getLauncher(context, chooseFileLaunchers, "Error choosing file")
?: return null
launcher.launch(requestIntent)

return chooseFileResultFlow.first()
}

private fun <I> getLauncher(
context: Context,
launchers: Map<Activity, ActivityResultLauncher<I>>,
errorMessage: String,
): ActivityResultLauncher<I>? {
val activity = context.findActivity()
if (activity == null) {
logger.warning("($loggerTag) You must create the EmbeddedComponent view from an Activity")
Expand All @@ -159,17 +182,14 @@ class EmbeddedComponentManager(
error("You must create an AccountOnboardingView from an Activity")
}
}
val launcher = launcherMap[activity]
val launcher = launchers[activity]
if (launcher == null) {
logger.warning(
"($loggerTag) Error launching camera permission request. " +
"($loggerTag) $errorMessage " +
"Did you call EmbeddedComponentManager.onActivityCreate in your Activity.onCreate function?"
)
return null
}
launcher.launch(Manifest.permission.CAMERA)

return permissionsFlow.first()
return launcher
}

internal fun getComponentAnalyticsService(component: StripeEmbeddedComponent): ComponentAnalyticsService {
Expand Down Expand Up @@ -203,7 +223,11 @@ class EmbeddedComponentManager(

@VisibleForTesting
internal val permissionsFlow: MutableSharedFlow<Boolean> = MutableSharedFlow(extraBufferCapacity = 1)
private val launcherMap = mutableMapOf<Activity, ActivityResultLauncher<String>>()
private val requestPermissionLaunchers = mutableMapOf<Activity, ActivityResultLauncher<String>>()

@VisibleForTesting
internal val chooseFileResultFlow: MutableSharedFlow<Array<Uri>?> = MutableSharedFlow(extraBufferCapacity = 1)
private val chooseFileLaunchers = mutableMapOf<Activity, ActivityResultLauncher<Intent>>()

/**
* Hooks the [EmbeddedComponentManager] into this activity's lifecycle.
Expand All @@ -225,30 +249,47 @@ class EmbeddedComponentManager(
override fun onActivityDestroyed(destroyedActivity: Activity) {
// ensure we remove the activity and its launcher from our map, and unregister
// this activity from future callbacks
launcherMap.remove(destroyedActivity)
requestPermissionLaunchers.remove(destroyedActivity)
chooseFileLaunchers.remove(destroyedActivity)
if (destroyedActivity == activity) {
application.unregisterActivityLifecycleCallbacks(this)
}
}

override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { /* no-op */ }
override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
/* no-op */
}

override fun onActivityStarted(activity: Activity) { /* no-op */ }
override fun onActivityStarted(activity: Activity) {
/* no-op */
}

override fun onActivityResumed(activity: Activity) { /* no-op */ }
override fun onActivityResumed(activity: Activity) {
/* no-op */
}

override fun onActivityPaused(activity: Activity) { /* no-op */ }
override fun onActivityPaused(activity: Activity) {
/* no-op */
}

override fun onActivityStopped(activity: Activity) { /* no-op */ }
override fun onActivityStopped(activity: Activity) {
/* no-op */
}

override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) { /* no-op */ }
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
/* no-op */
}
})

launcherMap[activity] = activity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
permissionsFlow.tryEmit(isGranted)
}
requestPermissionLaunchers[activity] =
activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { isGranted ->
permissionsFlow.tryEmit(isGranted)
}

chooseFileLaunchers[activity] =
activity.registerForActivityResult(ChooseFileActivityResultContract()) { result ->
chooseFileResultFlow.tryEmit(result)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package com.stripe.android.connect.webview

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.webkit.WebChromeClient
import androidx.activity.result.contract.ActivityResultContract

/**
* Contract for launching a file chooser activity and handling its result.
* Used to select files for upload in Connect web flows.
*/
internal class ChooseFileActivityResultContract : ActivityResultContract<Intent, Array<Uri>?>() {
override fun createIntent(context: Context, input: Intent): Intent {
return input
}

override fun parseResult(resultCode: Int, intent: Intent?): Array<Uri>? {
return WebChromeClient.FileChooserParams.parseResult(resultCode, intent)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,12 @@ package com.stripe.android.connect.webview
import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.graphics.Bitmap
import android.net.Uri
import android.os.Build
import android.view.LayoutInflater
import android.webkit.JavascriptInterface
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebChromeClient
import android.webkit.WebResourceError
import android.webkit.WebResourceRequest
Expand Down Expand Up @@ -301,8 +303,7 @@ internal class StripeConnectWebViewContainerImpl<Listener, Props>(
}

/**
* A [WebChromeClient] that provides additional functionality for Stripe Connect Embedded Component WebViews,
* namely around permissions.
* A [WebChromeClient] that provides additional functionality for Stripe Connect Embedded Component WebViews.
*/
internal inner class StripeConnectWebChromeClient : WebChromeClient() {
override fun onPermissionRequest(request: PermissionRequest) {
Expand All @@ -322,6 +323,25 @@ internal class StripeConnectWebViewContainerImpl<Listener, Props>(
// request and delegate all the UI to the Android system, meaning
// there's no way for us to cancel any permissions UI
}

override fun onShowFileChooser(
webView: WebView,
filePathCallback: ValueCallback<Array<Uri>>,
fileChooserParams: FileChooserParams
): Boolean {
val lifecycleScope = webView.findViewTreeLifecycleOwner()?.lifecycleScope
?: return false
val controller = this@StripeConnectWebViewContainerImpl.controller
?: return false
lifecycleScope.launch {
controller.onChooseFile(
context = webView.context,
filePathCallback = filePathCallback,
requestIntent = fileChooserParams.createIntent()
)
}
return true
}
}

private inner class StripeJsInterface {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package com.stripe.android.connect.webview

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.webkit.PermissionRequest
import android.webkit.ValueCallback
import android.webkit.WebResourceRequest
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
Expand Down Expand Up @@ -30,6 +33,7 @@ import kotlinx.coroutines.withContext

@Suppress("TooManyFunctions")
@OptIn(PrivateBetaConnectSDK::class)
@Suppress("TooManyFunctions")
internal class StripeConnectWebViewContainerController<Listener : StripeEmbeddedComponentListener>(
private val view: StripeConnectWebViewContainerInternal,
private val analyticsService: ComponentAnalyticsService,
Expand Down Expand Up @@ -195,6 +199,20 @@ internal class StripeConnectWebViewContainerController<Listener : StripeEmbedded
}
}

suspend fun onChooseFile(
context: Context,
filePathCallback: ValueCallback<Array<Uri>>,
requestIntent: Intent
) {
var result: Array<Uri>? = null
try {
result = embeddedComponentManager.chooseFile(context, requestIntent)
} finally {
// Ensure `filePathCallback` always gets a value.
filePathCallback.onReceiveValue(result)
}
}

/**
* Callback to invoke upon receiving 'pageDidLoad' message.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@ package com.stripe.android.connect

import android.Manifest
import android.app.Application
import android.content.Intent
import android.net.Uri
import androidx.activity.ComponentActivity
import androidx.test.core.app.ApplicationProvider
import com.google.common.truth.Truth.assertThat
import junit.framework.TestCase.assertFalse
import kotlinx.coroutines.async
import kotlinx.coroutines.test.advanceUntilIdle
Expand Down Expand Up @@ -112,4 +115,18 @@ class EmbeddedComponentManagerTest {

assertNull(embeddedComponentManager.requestCameraPermission(testActivity))
}

@Test
fun `chooseFile returns correct response`() = runTest {
EmbeddedComponentManager.onActivityCreate(testActivity)
val resultAsync = async {
embeddedComponentManager.chooseFile(testActivity, Intent())
}
advanceUntilIdle()
val expected = arrayOf(Uri.parse("content://test"))
EmbeddedComponentManager.chooseFileResultFlow.emit(expected) // Simulate a file being chosen.
val actual = resultAsync.await()

assertThat(actual).isEqualTo(expected)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.stripe.android.connect.webview

import android.Manifest
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.graphics.Color
import android.net.Uri
Expand Down Expand Up @@ -241,6 +242,22 @@ class StripeConnectWebViewContainerControllerTest {
}
}

@Test
fun `onChooseFile should delegate to manager`() = runTest {
val intent = Intent()
val expected = arrayOf(Uri.parse("content://path/to/file"))
var actual: Array<Uri>? = null
wheneverBlocking { embeddedComponentManager.chooseFile(mockContext, intent) } doReturn expected

controller.onChooseFile(
context = mockContext,
filePathCallback = { actual = it },
requestIntent = intent
)

assertThat(actual).isEqualTo(expected)
}

@Test
fun `onPermissionRequest denies permission when no supported permissions are requested`() = runTest {
whenever(mockPermissionRequest.resources) doReturn arrayOf("unsupported_permission")
Expand Down

0 comments on commit 0a13596

Please sign in to comment.