Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Connect SDK] Add camera support to the Connect SDK #9607

Merged
merged 42 commits into from
Dec 12, 2024

Conversation

simond-stripe
Copy link
Collaborator

@simond-stripe simond-stripe commented Nov 13, 2024

Summary

Hooks the webview's camera permission into the Android app's camera permission so that the user is shown a permissions modal when the connect SDK requests a camera permission.

MXMOBILE-2913

Motivation

Certain embedded components (particularly any that use Identity) make use of the camera, and the SDK needs to properly handle camera permissions to make sure these features work.

Testing

  • Added tests
  • Modified tests
  • Manually verified

The easiest way I found to test was:

  1. Go to payouts
  2. Select "Payout" button
  3. Select "Add account" under the dropdown of Stripe accounts to select
  4. Select an existing account and click the "...", then "edit"
  5. Click "you can verify your identity instead"
  6. This opens the Identity screen, at which point you can go through the identity flow with the camera being used to scan an ID (in test mode select "Proceed" under "Preview user experience" to make sure you get the camera pop-up)

Screenshots

Granting permission

demo-1733780574.mp4

Rejecting permission

demo-1733780639.mp4

@simond-stripe simond-stripe force-pushed the simond/camera-support-connect branch from 69616a3 to 16db6f3 Compare November 13, 2024 00:41
Copy link
Contributor

github-actions bot commented Nov 13, 2024

Diffuse output:

OLD: identity-example-release-base.apk (signature: V1, V2)
NEW: identity-example-release-pr.apk (signature: V1, V2)

          │          compressed          │         uncompressed         
          ├───────────┬───────────┬──────┼───────────┬───────────┬──────
 APK      │ old       │ new       │ diff │ old       │ new       │ diff 
──────────┼───────────┼───────────┼──────┼───────────┼───────────┼──────
      dex │     2 MiB │     2 MiB │  0 B │   4.1 MiB │   4.1 MiB │  0 B 
     arsc │     1 MiB │     1 MiB │  0 B │     1 MiB │     1 MiB │  0 B 
 manifest │   2.3 KiB │   2.3 KiB │  0 B │     8 KiB │     8 KiB │  0 B 
      res │ 301.8 KiB │ 301.8 KiB │  0 B │ 455.5 KiB │ 455.5 KiB │  0 B 
   native │   6.2 MiB │   6.2 MiB │  0 B │  15.8 MiB │  15.8 MiB │  0 B 
    asset │   7.1 KiB │   7.1 KiB │  0 B │   6.9 KiB │   6.9 KiB │  0 B 
    other │  90.2 KiB │  90.2 KiB │ -7 B │ 170.3 KiB │ 170.3 KiB │  0 B 
──────────┼───────────┼───────────┼──────┼───────────┼───────────┼──────
    total │   9.6 MiB │   9.6 MiB │ -7 B │  21.5 MiB │  21.5 MiB │  0 B 

 DEX     │ old   │ new   │ diff      
─────────┼───────┼───────┼───────────
   files │     1 │     1 │ 0         
 strings │ 19966 │ 19966 │ 0 (+0 -0) 
   types │  6188 │  6188 │ 0 (+0 -0) 
 classes │  4979 │  4979 │ 0 (+0 -0) 
 methods │ 29759 │ 29759 │ 0 (+0 -0) 
  fields │ 17526 │ 17526 │ 0 (+0 -0) 

 ARSC    │ old  │ new  │ diff 
─────────┼──────┼──────┼──────
 configs │  164 │  164 │  0   
 entries │ 3622 │ 3622 │  0
APK
   compressed    │   uncompressed   │                                           
──────────┬──────┼───────────┬──────┤                                           
 size     │ diff │ size      │ diff │ path                                      
──────────┼──────┼───────────┼──────┼───────────────────────────────────────────
 28.4 KiB │ -6 B │  62.9 KiB │  0 B │ ∆ META-INF/CERT.SF                        
    269 B │ -3 B │     120 B │  0 B │ ∆ META-INF/version-control-info.textproto 
 25.3 KiB │ +3 B │  62.8 KiB │  0 B │ ∆ META-INF/MANIFEST.MF                    
  1.2 KiB │ -1 B │   1.2 KiB │  0 B │ ∆ META-INF/CERT.RSA                       
──────────┼──────┼───────────┼──────┼───────────────────────────────────────────
 55.2 KiB │ -7 B │ 127.1 KiB │  0 B │ (total)

@simond-stripe simond-stripe force-pushed the simond/camera-support-connect branch 2 times, most recently from 9e85b34 to 3603b54 Compare November 21, 2024 19:01
# Conflicts:
#	connect-example/src/main/java/com/stripe/android/connect/example/ui/features/payouts/PayoutsExampleActivity.kt
#	connect/src/main/java/com/stripe/android/connect/PayoutsView.kt

# Conflicts:
#	connect/src/main/java/com/stripe/android/connect/PayoutsView.kt
#	connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewClient.kt

# Conflicts:
#	connect-example/src/main/java/com/stripe/android/connect/example/ui/features/payouts/PayoutsExampleActivity.kt
#	connect/src/main/java/com/stripe/android/connect/EmbeddedComponentManager.kt
#	connect/src/main/java/com/stripe/android/connect/PayoutsView.kt
# Conflicts:
#	connect/src/main/java/com/stripe/android/connect/webview/StripeConnectWebViewContainer.kt
@simond-stripe simond-stripe force-pushed the simond/camera-support-connect branch from 3603b54 to 45f9c94 Compare December 4, 2024 18:41
simond-stripe and others added 14 commits December 4, 2024 14:20
* Show promo badge in bank form

* Address code review feedback

Fix layout issue with super-long bank name and validate with screenshot test.
…#9727)

* Add Embedded Appearance params to AppearanceBottomSheetDialogFragment
* update text style for bacs secondary button type

* screenshots for screenshot tests

* Apply suggestions from code review

Capitalize comment and add period

Co-authored-by: Bella Koch <160939932+amk-stripe@users.noreply.github.com>
@simond-stripe simond-stripe marked this pull request as ready for review December 9, 2024 21:48
@simond-stripe simond-stripe requested a review from a team as a code owner December 9, 2024 21:48
Copy link
Contributor

@lng-stripe lng-stripe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Very cool to see this working!

Comment on lines 135 to 138
/**
*
*/
suspend fun onPermissionRequest(context: Context, request: PermissionRequest) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fix docstring

Comment on lines 145 to 147
logger.debug(
"($loggerTag) Denying permission - ${request.resources.joinToString()} are not supported"
)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this be warning log level?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. We'll want an analytic event too now that I think about it so that we know if an embedded component is expecting any unsupported permissions. I've added a TODO for when we add analytics (hopefully tomorrow or Friday)

Comment on lines 39 to 41
internal suspend fun requestCameraPermission(context: Context): Boolean? {
val activity = context.findActivity() ?: error("You must create an AccountOnboardingView from an Activity")
launcherMap[activity]?.launch(android.Manifest.permission.CAMERA) ?: return null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm concerned throwing here would be too user-unfriendly since it's pretty late in the UX flow to be crashing. I think instead we should return null and log warnings if either the activity or launcher are null.

Separately, the log message should be generalized and not reference a specific view, "AccountOnboardingView" in this case.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it's a worse bug to crash than it is to let the user proceed but with broken permissions. What do you think about crashing if in debug (since this really is a programmer error), but then following the behavior you outlined otherwise?

Comment on lines 166 to 174
activity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
MainScope().launch {
permissionsFlow.emit(isGranted)
}
}.also {
launcherMap[activity] = it
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could simplify the assignment and maybe use tryEmit instead of launching. We might need to configure the MutableSharedFlow with extraBufferCapacity = 1 though.

Suggested change
activity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
MainScope().launch {
permissionsFlow.emit(isGranted)
}
}.also {
launcherMap[activity] = it
}
launcherMap[activity] =
activity.registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
permissionsFlow.tryEmit(isGranted)
}

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like this suggestion. Is there any risk of race conditions where multiple tryEmits are called before the consumers from permissionFlow.first get to them? I think the risk is low and acceptable, curious on your thoughts @lng-stripe

return
}

if (checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Make EmbeddedComponentManager fully responsible for the native camera permission and change this to

Suggested change
if (checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
if (embeddedComponentManager.hasCameraPermission(context)) {

otherwise this code risks becoming out of sync with embeddedComponentManager.requestCameraPermission(context)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call. @lng-stripe Thoughts on having a single call to EmbeddedComponentManager and inlining this into requestCameraPermission()? ie:

internal suspend fun requestCameraPermission(context: Context): Boolean? {
        if (checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
            logger.debug("($loggerTag) Skipping permission request - CAMERA permission already granted")
            return true
        }

        // proceed with explicit check...
    }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's worth having a hasCameraPermission() function and your change above.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Like abstract the above if statement into a hasCameraPermission function, or keep what I have in that comment and also add a separate hasCameraPermission() call in the Controller? The latter feels duplicative to me

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think arguments could be made for either. What you have now is fine 👍

request.grant(permissionsRequested)
} else {
val isGranted = embeddedComponentManager.requestCameraPermission(context) ?: return
withContext(Dispatchers.Main) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If I'm reading the code correctly, either setting the dispatcher here isn't necessary, or we'd need to do so for the other request.deny() / grant() calls above (I think it's the former). Could you check?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah, we do need this because the request/deny call crashes if not done on the Main thread. I re-confirmed by wrapping the parent call in withContext(Dispatchers.IO) { ... } - the reason I didn't catch the one on line 153 is that I moved the parent call to use the view lifecycle scope which is the main thread. For safety it's important this function guarantees it's done on the main thread - updated!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed -- good point that it's safer to be explicit here

@@ -58,6 +66,10 @@ class StripeConnectWebViewContainerControllerTest {

@Before
fun setup() {
Dispatchers.setMain(Dispatchers.Unconfined)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't be necessary if we remove setting the dispatcher as suggested in my other comment. Otherwise, we'll need to Dispatchers.resetMain() in an @After block.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is still necessary - see comment above. I've added an @After as well now

Comment on lines 38 to 39
private val logger: Logger = Logger.getInstance(enableLogging = BuildConfig.DEBUG),
private val isDebugBuild: Boolean = BuildConfig.DEBUG,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We shouldn't add this to the public API, which EmbeddedComponentManager and this constructor will be part of (we should remove the @RestrictTo above). I do think we should add a debug: Boolean property to Configuration, but logger should not be exposed and instead should be instantiated internally.

Comment on lines +195 to +197
val application = activity.application

application.registerActivityLifecycleCallbacks(object : ActivityLifecycleCallbacks {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why register on the Application? I think it was better before to register on the Activity. Reading the docs, it turns out you actually don't need to unregister the callback:

As this callback is associated with only this Activity, it is not usually necessary to unregister it unless you specifically do not want to receive further lifecycle callbacks.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I also prefer the callbacks on the activity, but that API is only available on API 29+ (whereas the Application version is available on 14+): https://developer.android.com/reference/android/app/Activity#registerActivityLifecycleCallbacks(android.app.Application.ActivityLifecycleCallbacks)


@Before
fun setup() {
Dispatchers.setMain(Dispatchers.Unconfined)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to resetMain() in this test class, too

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oops, I don't even need this dispatcher and forgot to remove it 😅


/**
* Create a new [AccountOnboardingView] for inclusion in the view hierarchy.
*/
@PrivateBetaConnectSDK
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
Copy link
Contributor

@lng-stripe lng-stripe Dec 12, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these functions should not be restricted to the library -- they will be part of the public API. Also, isn't @PrivateBetaConnectSDK redundant when it's already on the class?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I didn't realize that this is only needed at the class level, so I cleaned it up throughout the library.

We're adding @RestrictTo until we're ready to go into private beta, at which point we'll remove it and external developers will be able to use these classes (with the right opt-in). See here: #9203 (comment)

@@ -42,17 +70,40 @@ class EmbeddedComponentManager(
/**
* Create a new [PayoutsView] for inclusion in the view hierarchy.
*/
@PrivateBetaConnectSDK
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ditto -- see other comment

lng-stripe
lng-stripe previously approved these changes Dec 12, 2024
Copy link
Contributor

@lng-stripe lng-stripe left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 👍

@simond-stripe simond-stripe merged commit 3d66b3c into master Dec 12, 2024
13 checks passed
@simond-stripe simond-stripe deleted the simond/camera-support-connect branch December 12, 2024 22:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants