Skip to content

Feature : saved payment method image loader - #1721

Open
balamaninayard wants to merge 7 commits into
braintree:view-edit-fi-beta-featurefrom
balamaninayard:feature/saved-payment-method-image-loader
Open

balamaninayard wants to merge 7 commits into
braintree:view-edit-fi-beta-featurefrom
balamaninayard:feature/saved-payment-method-image-loader

Conversation

@balamaninayard

Copy link
Copy Markdown

Summary of changes

  • Added ImageLoader and LoaderImage to the PayPalSavedPaymentMethod module: a process-wide, singleton image loader that fetches, decodes, and caches funding-instrument/saved-payment-method icons off the main thread. It dedupes concurrent requests for the same URL, bounds parallel bitmap decoding, downsamples images to 128x128px, and safely handles RecyclerView view recycling via weak references. LoaderImage is a Compose wrapper around it.
  • Added sendRequestForBytes / executeRequestForBytes to SharedUtils' HttpClient / OkHttpSynchronousHttpClient so binary payloads (images) can be fetched without a String round-trip, with a hard maxBodyBytes size cap enforced via streaming — even when the server response omits Content-Length (e.g. chunked transfer).
  • Dependent PR for URL Validation: Feature : Saved payment method data models #1718 — added a TODO in ImageLoader.kt to switch to asHttpsUrlOrNull() once this dependency is merged, since it's still in review.

Key Characteristics

What this implementation specifically handles:

  • Request de-duplication — concurrent load() calls for the same URL share a single in-flight download instead of firing duplicate network requests.
  • Bounded decode concurrency — a Semaphore caps how many bitmaps are decoded in parallel, avoiding CPU/memory spikes on screens with many icons.
  • Bounded response size — downloads are capped at MAX_IMAGE_BYTES, enforced by streaming the body rather than trusting the declared Content-Length, so a misbehaving or malicious server can't exhaust memory.
  • Two-pass, downsampled decoding — bitmaps are decoded to at most 128x128px using inSampleSize, avoiding full-resolution decode of oversized images.
  • Memory-safe view binding — the loader holds ImageView keys weakly (WeakHashMap) and captures views via WeakReference inside coroutines, so it never keeps a bound View/Activity alive.
  • RecyclerView-safe lifecycle handling — detaching a bound view cancels only its wait (not the shared download); re-attaching resumes the wait; rebinding a view to a new URL discards any stale in-flight result for the old URL.
  • Explicit cancellation — cancel(imageView) tears down both the pending job and the attach-listener so a fully cancelled view carries no leftover state.
  • Dispatcher injection — all coroutine work uses injected dispatchers, keeping the loader unit-testable without real threads.

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?

  • Copilot
  • Claude
  • Other (Type Name Here)

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

  • less than 30%
  • 30 - 60%
  • 60 - 100%

Checklist

  • Added a changelog entry
  • Tested and confirmed payment flows affected by this change are functioning as expected

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:

  1. Comment /inner source on this PR — this will automatically add the inner source and tech lead review required labels. Open the PR in a draft state.
  2. PR should be reviewed by and approved by your team's technical lead, we do not allow LGTM reviews, there should be comments and feedback provided on all PR reviews
  3. Once the above steps are completed, comment /ready on this PR — this will automatically remove the tech lead review required label. Move the PR to ready to review.
  4. PR comments must be addressed within 24 hours, if you are unable to address within this timeframe, move the PR back to a draft state so our team knows not to review

Inner Source Checklist

  • Added all labels to the PR
  • Provide steps to test the flows changed, if applicable in the summary
  • Demo video of the functionality, if applicable
  • All upstream dependencies are merged in and this PR can be released at any time; PRs should not be opened until this is true
  • Unit tests and builds have been run locally and pass/compile as expected

@balamaninayard
balamaninayard requested a review from a team as a code owner October 13, 2026 11:01
@balamaninayard
balamaninayard marked this pull request as draft October 13, 2026 11:02
Bala Mani Nayar D and others added 2 commits October 14, 2026 10:45
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>
@balamaninayard
balamaninayard force-pushed the feature/saved-payment-method-image-loader branch from 143fb53 to 4381b29 Compare October 14, 2026 05:15
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 Bhuvana-S100 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

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.

Bala Mani Nayar D and others added 2 commits October 14, 2026 18:25
- 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>
@balamaninayard

Copy link
Copy Markdown
Author

Added LoaderImageUnitTest — placeholder → image, cached first frame, failure, URL change. Also added the SharedUtils CHANGELOG entry. Steps-to-test still open.

@Bhuvana-S100 Bhuvana-S100 left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LGTM

@anibalb2500

Copy link
Copy Markdown
Contributor

LGTM

@vkspune

vkspune commented Oct 15, 2026

Copy link
Copy Markdown

Approved

@saralvasquez

Copy link
Copy Markdown
Contributor

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

@saralvasquez

Copy link
Copy Markdown
Contributor

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?

@balamaninayard

Copy link
Copy Markdown
Author

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

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 imageUrl (hosted by PayPal), so the view has to download and render a remote image in both the Compose and XML versions.

Braintree Android has no image loader today, and Android has no built-in equivalent of iOS's AsyncImage (which the iOS view uses for the same art). The alternatives were:

  • Glide/Coil: adds a third-party dependency, plus its transitive dependencies, to every merchant app that pulls in this module, and can clash with the versions merchants already use.
  • A small internal loader: no new dependency. It reuses the SDK's existing pinned HttpClient (same TLS setup as our other network calls) and is scoped to small icons.

So this PR adds that loader (internal, not public API):

  • caches icons in memory and shares concurrent downloads of the same URL
  • caps the download size and downsamples to icon size, so a bad server response can't use up memory
  • is safe with view recycling
  • falls back to the generic card or bank icon when there's no URL or the image can't be loaded, so the row always renders

The SharedUtils change is needed because HttpClient only returned String bodies; images need raw bytes with a size limit. It's @RestrictTo(LIBRARY_GROUP), so merchants don't see it.

"Why" section to add at the top of the PR summary:

Why

The 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 imageUrl per payment method, so the view must download and render a remote image in both the Compose and XML versions. Braintree Android has no image loader, and Android has no built-in equivalent of iOS's AsyncImage. To avoid adding Glide/Coil (and their transitive dependencies) to merchant apps, this adds a small internal loader built on the SDK's existing pinned HttpClient, with a generic card/bank icon fallback when the image is missing or fails to load.

@balamaninayard

balamaninayard commented Oct 15, 2026 •

Copy link
Copy Markdown
Author

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?

Fair question. We did consider Coil and Glide; the reasons for not using them:

  1. No new third-party dependency for merchants. No Braintree Android module ships an image library today. Adding Coil or Glide would bring it, plus its own transitive dependencies (e.g. Glide's gifdecoder/disklrucache, Coil's core/okio artifacts), into every merchant app that uses this module. That's extra APK size for one small icon.

  2. Version conflicts. Many merchant apps already use Coil or Glide, often a different major version (Coil 2 → 3 even changed the package from coil to coil3). Gradle resolves to a single version, which can break either the merchant's code or ours. As an SDK we try not to impose that.

  3. Same networking and security as the rest of the SDK. The images are fetched through our existing HttpClient, so they use the SDK's OkHttp client and certificate pinning (TLSSocketFactory). A third-party loader would use its own network stack unless we configured and kept it in sync.

  4. The scope is small. We need one or two small icons per screen. No disk cache, transformations, GIF/SVG or placeholders from the network, just a memory cache, a download size cap, downsampling to icon size, view-recycling safety and a fallback icon. That's what this implements, in one internal file plus a thin Compose wrapper, and none of it is public API, so we can replace it later without a breaking change.

iOS doesn't face this trade-off because SwiftUI ships AsyncImage; Android has no built-in equivalent.

]

@balamaninayard
balamaninayard marked this pull request as ready for review October 15, 2026 17:22
@saralvasquez

saralvasquez commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

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?

@balamaninayard

Copy link
Copy Markdown
Author

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 imageUrl for each card's issuer/card design. There are too many designs to bundle, and PayPal updates them server-side without SDK releases. Web and iOS render the same URL.

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.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 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.

Comment on lines +79 to +81
// 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" }

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

@saralvasquez

Copy link
Copy Markdown
Contributor

It's not unique per customer, but it is specific to the buyer's card/banl. The API returns a PayPal-hosted imageUrl for each card's issuer/card design. There are too many designs to bundle, and PayPal updates them server-side without SDK releases

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
@balamaninayard

Copy link
Copy Markdown
Author

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?

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.

This branch has not been deployed

No deployments
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.

6 participants