Repository navigation
Feature : saved payment method image loader - #1721
balamaninayard wants to merge 7 commits into
Conversation
Image downloads (and other binary payloads) need a way to fetch raw bytes without corrupting them through a String round-trip, and with a hard cap on body size so a malicious or misbehaving server can't exhaust memory. sendRequestForBytes/executeRequestForBytes stream the body via okio and enforce maxBodyBytes even when the response omits a Content-Length (e.g. chunked transfer). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Saved-payment-method icons need to be fetched, decoded, and cached without blocking the main thread. ImageLoader de-dupes concurrent requests for the same URL, bounds parallel bitmap decoding, caches decoded bitmaps in an LruCache, and binds results into an ImageView with placeholder/cancel/re-attach-resume support for RecyclerView reuse. LoaderImage wraps it for Compose call sites. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
143fb53 to
4381b29
Compare
Clear the ImageView when no placeholder is given and nothing is cached, instead of leaving a recycled row showing a stale image while the new URL loads. Document the rationale behind the cache size, decode concurrency, decode dimension, and body size constants. Addresses review comments from @anibalb2500 on PR braintree#1721. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Bhuvana-S100
left a comment
There was a problem hiding this comment.
There are no LoaderImage tests. Please add a Compose test for placeholder → image, cached first frame, failure and URL change, and add the changelog and steps-to-test items.
- Catch Exception (rethrowing CancellationException) in the view-binding load path so an unexpected failure (e.g. a RuntimeException from a malformed decode) keeps the placeholder instead of crashing the host app under SupervisorJob. - Document that the loader is raster-only (no SVG support), matching standard Android image loading in general. - Document that load() and cancel() must be called on the main thread. - Add a CHANGELOG entry for SharedUtils#sendRequestForBytes. Addresses review comments from @Bhuvana-S100 on PR braintree#1721. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds androidx.compose.ui:ui-test-junit4/ui-test-manifest as test-only dependencies (Robolectric-based, reusing the existing MockK/HttpClient test setup) and a LoaderImageUnitTest covering placeholder-to-image, cached first frame (no placeholder flash), load failure, and a URL change triggering a fresh load. LoaderImage gets two test-only testTag modifiers (loaded vs placeholder) since both Image() calls intentionally use contentDescription = null for this decorative icon, leaving no other way to distinguish them. Addresses review comment from @Bhuvana-S100 on PR braintree#1721. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Added |
|
LGTM |
|
Approved |
|
Can you please provide a summary with some context on what this code is trying to accomplish and why it's necessary? The summary above lists what is changing but not why it's necessary |
|
Additionally this seems to be re-inventing a lot of functionality that I'm sure exists in established libraries. Can you offer some insight as to why this is the approach you took? |
Thanks, added a "Why" section to the summary. Short version: The saved payment method view shows the buyer's funding instrument with its card art, e.g. the Visa or issuer image next to "••1234". That image isn't bundled in the SDK. The funding instrument details GraphQL response returns it per payment method as an Braintree Android has no image loader today, and Android has no built-in equivalent of iOS's
So this PR adds that loader (
The SharedUtils change is needed because "Why" section to add at the top of the PR summary: WhyThe PayPalSavedPaymentMethod view displays the buyer's card art (e.g. the Visa/issuer image next to "••1234"). The funding instrument details API returns that art as a remote |
Fair question. We did consider Coil and Glide; the reasons for not using them:
iOS doesn't face this trade-off because SwiftUI ships ] |
|
Is there something special about the hosted images? Like is each image unique to the customer? I think I'm just wondering why the chosen method here is pulling an image and adding an image processor, instead of the SDK hosting the images? Is there a large number of options that the image could be, or some other reason that's an impractical idea? |
It's not unique per customer, but it is specific to the buyer's card/banl. The API returns a PayPal-hosted The SDK does bundle generic card/bank icons, shown while loading, when there's no URL, or if the load fails. There's no real image processing: we just decode the bytes and downsample to icon size to keep memory low. |
There was a problem hiding this comment.
🟡 Changes recommended
HTTPS enforcement, request-sharing correctness, and byte-limit overflow remain unresolved.
3 open findings
What changed in this PR
Adds internal icon-loading support for saved PayPal payment methods, backed by binary-response handling in SharedUtils.
Changes:
- Adds cached, shared image loading with ImageView and Compose bindings.
- Adds streaming response-size limits for binary downloads.
- Adds loader and HTTP tests, test dependencies, and a changelog entry.
| File | Description |
|---|---|
| SharedUtils/src/test/java/com/braintreepayments/api/sharedutils/OkHttpSynchronousHttpClientTest.kt | Tests binary responses and size limits. |
| SharedUtils/src/test/java/com/braintreepayments/api/sharedutils/HttpClientUnitTest.kt | Tests binary-request delegation and errors. |
| SharedUtils/src/main/java/com/braintreepayments/api/sharedutils/OkHttpSynchronousHttpClient.kt | Implements bounded binary downloads. |
| SharedUtils/src/main/java/com/braintreepayments/api/sharedutils/HttpClient.kt | Adds suspend binary-request API. |
| PayPalSavedPaymentMethod/src/test/java/com/braintreepayments/api/paypalsavedpaymentmethod/imageloader/TestImages.kt | Provides PNG test fixtures. |
| PayPalSavedPaymentMethod/src/test/java/com/braintreepayments/api/paypalsavedpaymentmethod/imageloader/LoaderImageUnitTest.kt | Tests Compose image rendering. |
| PayPalSavedPaymentMethod/src/test/java/com/braintreepayments/api/paypalsavedpaymentmethod/imageloader/ImageLoaderViewBindingUnitTest.kt | Tests view binding and lifecycle handling. |
| PayPalSavedPaymentMethod/src/test/java/com/braintreepayments/api/paypalsavedpaymentmethod/imageloader/ImageLoaderUnitTest.kt | Tests caching, request sharing, and decoding. |
| PayPalSavedPaymentMethod/src/main/java/com/braintreepayments/api/paypalsavedpaymentmethod/imageloader/LoaderImage.kt | Adds Compose image wrapper. |
| PayPalSavedPaymentMethod/src/main/java/com/braintreepayments/api/paypalsavedpaymentmethod/imageloader/ImageLoader.kt | Implements singleton loader and view bindings. |
| PayPalSavedPaymentMethod/build.gradle | Adds Compose test dependencies. |
| CHANGELOG.md | Documents binary-response support. |
🧠 Review effort: Balanced
💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
| // TODO: validate with asHttpsUrlOrNull() from UrlValidation.kt (PR #1718) once it is on this branch, so only | ||
| // https URLs with a host reach the network. | ||
| require(url.isNotBlank()) { "url must not be blank" } |
There was a problem hiding this comment.
URL validation will use asHttpsUrlOrNull() from #1718 once it's merged, I'll add it in a follow-up. Redirects from https to non-https now fail.
Just for the sake of really understanding the design choices made, let's put side the part server side updates for now and can I just ask what exactly these images would look like that make them specific to the buyers card/bank? Also when you say there are too many designs, what does it actually look ilke? Are we talking a couple dozen or hundreds? |
- ImageLoader re-checks the memory cache before starting a download, so a URL cached just after the caller's cache miss isn't downloaded again - executeRequestForBytes fails if an https request is redirected to a non-https URL - the maxBodyBytes read limit saturates at Long.MAX_VALUE instead of overflowing
They're small card/bank thumbnails returned per payment method. I'm not sure about the exact count. It covers the different banks, card providers (Visa, Mastercard, Amex, Discover, issuer/co-branded cards) and PayPal Credit, so it's more than a few fixed images. |
…llback icon while an image loads LoaderImage had one "no image" state, so the fallback icon showed for the whole download. It now has three: loading draws a slot that is blank by default, and the placeholder shows only when the load fails or there is no URL. The ImageView load() gets the same split: loadingPlaceholder while loading and errorPlaceholder on failure or a missing URL, each blank when null.

Summary of changes
Key Characteristics
What this implementation specifically handles:
Demo
Note : A demo video was captured by adding the feature to the Demo app, using @Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") in SavedPaymentMethodImageLoaderFragment to reach ImageLoader/LoaderImage from outside their module, since both are internal and not part of the public SDK surface
Image_loader.mp4
AI Usage
Which AI Agent Was Used?
How was AI used?
Code generation for the new ImageLoader/LoaderImage implementation and the HttpClient binary-body support, plus iterative code review (correctness, security, lifecycle/leak analysis) with fixes applied directly from review findings — bounded streaming for undeclared Content-Length, attach-listener cleanup on cancel(), and documentation of the loader's singleton coroutine scope lifetime.
Estimated AI Code Contribution
Checklist
Authors
▎ List GitHub usernames for everyone who contributed to this pull request.
Inner Source Process
Internal to PayPal contributors should fill out this section. All others can delete.
PR should follow these steps before codeowners review will begin:
Inner Source Checklist