Skip to content

Conversation

@toluo-stripe
Copy link
Contributor

@toluo-stripe toluo-stripe commented Jan 7, 2026

Summary

This is a structural change to make the card funding work simpler (especially from a testing perspective).

This PR refactors CardAccountRangeService from a concrete callback-based implementation to an interface-based architecture with Kotlin Flow reactivity:

Architectural Changes:

  • Extract CardAccountRangeService interface with DefaultCardAccountRangeService implementation
  • Replace callback-based AccountRangeResultListener with reactive Flows
  • Migrate to constructor injection pattern across CardNumberEditText and CardNumberController

Test Infrastructure:

  • Create FakeCardAccountRangeService for easier test injection
  • Modernize tests with Turbine library, replacing CountDownLatch/CompletableDeferred with flow.test { awaitItem() } patterns
  • Update test assertions to use flow collection instead of callback verification

Integration Updates:

  • Move service initialization from lazy properties to constructor parameters in CardNumberEditText and CardNumberController
  • Update call sites to collect flows for type-safe result handling

Motivation

The existing CardAccountRangeService implementation uses a concrete class with callback-based state updates, making it difficult to test components that depend on it. This refactoring:

  1. Enables testability: Interface-based design allows easy substitution with fakes in tests, critical for upcoming card funding feature development
  2. Modernizes reactive patterns: Flows provide better composability and lifecycle awareness compared to callbacks
  3. Improves separation of concerns: Clear interface contract makes dependencies explicit through constructor injection
  4. Prepares for card funding work: Both DefaultCardNumberController and AccountRangeService will be modified for card funding features - this structural foundation makes those changes safer and easier to test

Testing

  • Added tests
  • Modified tests
  • Manually verified

Changelog

@github-actions
Copy link
Contributor

github-actions bot commented Jan 7, 2026

Diffuse output:

OLD: paymentsheet-example-release-master.apk (signature: V1, V2)
NEW: paymentsheet-example-release-pr.apk (signature: V1, V2)

          │              compressed              │           uncompressed           
          ├─────────────┬─────────────┬──────────┼───────────┬───────────┬──────────
 APK      │ old         │ new         │ diff     │ old       │ new       │ diff     
──────────┼─────────────┼─────────────┼──────────┼───────────┼───────────┼──────────
      dex │     4.9 MiB │     4.9 MiB │ -2.4 KiB │    11 MiB │    11 MiB │ -1.2 KiB 
     arsc │     3.6 MiB │     3.6 MiB │      0 B │   3.6 MiB │   3.6 MiB │      0 B 
 manifest │     5.7 KiB │     5.7 KiB │      0 B │  30.2 KiB │  30.2 KiB │      0 B 
      res │ 1,002.9 KiB │ 1,002.9 KiB │      0 B │   1.7 MiB │   1.7 MiB │      0 B 
   native │   949.9 KiB │   949.9 KiB │      0 B │   2.5 MiB │   2.5 MiB │      0 B 
    asset │    26.2 KiB │    24.8 KiB │ -1.4 KiB │  46.7 KiB │  45.3 KiB │ -1.4 KiB 
    other │   205.3 KiB │   205.3 KiB │     -2 B │ 405.6 KiB │ 405.6 KiB │      0 B 
──────────┼─────────────┼─────────────┼──────────┼───────────┼───────────┼──────────
    total │    10.6 MiB │    10.6 MiB │ -3.8 KiB │  19.1 MiB │  19.1 MiB │ -2.7 KiB 

         │         raw          │              unique               
         ├───────┬───────┬──────┼───────┬───────┬───────────────────
 DEX     │ old   │ new   │ diff │ old   │ new   │ diff              
─────────┼───────┼───────┼──────┼───────┼───────┼───────────────────
   files │     2 │     2 │    0 │       │       │                   
 strings │ 58698 │ 58618 │  -80 │ 52145 │ 52152 │  +7 (+26 -19)     
   types │ 20714 │ 20656 │  -58 │ 17443 │ 17449 │  +6 (+22 -16)     
 classes │ 14675 │ 14681 │   +6 │ 14675 │ 14681 │  +6 (+10 -4)      
 methods │ 78294 │ 78243 │  -51 │ 73261 │ 73276 │ +15 (+1380 -1365) 
  fields │ 51282 │ 51261 │  -21 │ 48934 │ 48943 │  +9 (+881 -872)   

 ARSC    │ old  │ new  │ diff 
─────────┼──────┼──────┼──────
 configs │  325 │  325 │  0   
 entries │ 6941 │ 6941 │  0
APK
      compressed      │     uncompressed     │                                
───────────┬──────────┼───────────┬──────────┤                                
 size      │ diff     │ size      │ diff     │ path                           
───────────┼──────────┼───────────┼──────────┼────────────────────────────────
 810.6 KiB │ -5.9 KiB │   1.8 MiB │ -9.8 KiB │ ∆ classes2.dex                 
   4.1 MiB │ +3.5 KiB │   9.2 MiB │ +8.6 KiB │ ∆ classes.dex                  
   8.4 KiB │ -1.4 KiB │   8.3 KiB │ -1.4 KiB │ ∆ assets/dexopt/baseline.prof  
  54.4 KiB │     -6 B │ 127.2 KiB │      0 B │ ∆ META-INF/MANIFEST.MF         
  57.6 KiB │     +4 B │ 127.3 KiB │      0 B │ ∆ META-INF/CERT.SF             
   1.2 KiB │     -1 B │     1 KiB │     -1 B │ ∆ assets/dexopt/baseline.profm 
───────────┼──────────┼───────────┼──────────┼────────────────────────────────
     5 MiB │ -3.8 KiB │  11.2 MiB │ -2.7 KiB │ (total)
DEX
STRINGS:

   old   │ new   │ diff         
  ───────┼───────┼──────────────
   52145 │ 52152 │ +7 (+26 -19) 
  
  + , unfilteredAccountRanges=
  + Lme/k;
  + Ln5/a0;
  + Lt9/l;
  + Lvh/r;
  + Lwd/h;
  + Lzb/v;
  + Lzb/w;
  + Lzb/x;
  + Success(accountRanges=
  + [Lkh/a0;
  + [Lkh/e;
  + [Lkh/p;
  + [Ln5/i;
  + [Ln5/o;
  + [Lzh/h0;
  + [Lzh/n0;
  + [Lzh/u0;
  + r8-map-id-57b7a0efeab5317b720b00440ade19354d77ee8cd31403fdeca7c553622e3f24
  + ~~R8{"backend":"dex","compilation-mode":"release","has-checksums":false,"min-api":21,"pg-map-id":"57b7a0efeab5317b720b00440ade19354d77ee8cd31403fdeca7c553622e3f24","r8-mode":"full","version":"8.13.17"}
  + Lhl/n1;
  + Lhl/o1;
  + [Lhl/c1;
  + [Lhl/d0;
  + [Lhl/l0;
  + [Lhl/m1;
  
  - Lmc/l;
  - Lt5/g0;
  - Lvb/e;
  - Lwe/j;
  - [Lkh/b0;
  - [Lkh/g;
  - [Lkh/q;
  - [Ln5/h;
  - [Ln5/n;
  - [Lzh/i0;
  - [Lzh/p0;
  - [Lzh/w0;
  - r8-map-id-59cdc766c776da1a075e84c30b5c3034467ac2e8578323bc13404cd8b09fbe39
  - ~~R8{"backend":"dex","compilation-mode":"release","has-checksums":false,"min-api":21,"pg-map-id":"59cdc766c776da1a075e84c30b5c3034467ac2e8578323bc13404cd8b09fbe39","r8-mode":"full","version":"8.13.17"}
  - [Lhl/j0;
  - [Lhl/k1;
  - [Lhl/y0;
  - [Lhl/y;
  - getAccountRangeService_annotations
  

TYPES:

   old   │ new   │ diff         
  ───────┼───────┼──────────────
   17443 │ 17449 │ +6 (+22 -16) 
  
  + Lme/k;
  + Ln5/a0;
  + Lt9/l;
  + Lvh/r;
  + Lwd/h;
  + Lzb/v;
  + Lzb/w;
  + Lzb/x;
  + [Lkh/a0;
  + [Lkh/e;
  + [Lkh/p;
  + [Ln5/i;
  + [Ln5/o;
  + [Lzh/h0;
  + [Lzh/n0;
  + [Lzh/u0;
  + Lhl/n1;
  + Lhl/o1;
  + [Lhl/c1;
  + [Lhl/d0;
  + [Lhl/l0;
  + [Lhl/m1;
  
  - Lmc/l;
  - Lt5/g0;
  - Lvb/e;
  - Lwe/j;
  - [Lkh/b0;
  - [Lkh/g;
  - [Lkh/q;
  - [Ln5/h;
  - [Ln5/n;
  - [Lzh/i0;
  - [Lzh/p0;
  - [Lzh/w0;
  - [Lhl/j0;
  - [Lhl/k1;
  - [Lhl/y0;
  - [Lhl/y;
  

METHODS:

   old   │ new   │ diff              
  ───────┼───────┼───────────────────
   73261 │ 73276 │ +15 (+1380 -1365) 
  
  + a.a G(k) → b
  + a4.b0 c(View) → boolean
  + a4.j D(WindowInsetsController, c0)
  + a4.j s(View, r1)
  + a4.j v(WindowInsetsController, c0)
  + a4.o e(KProperty, Object)
  + a6.a b(Object, int, int, h) → b0
  + a6.c0 b(Object, int, int, h) → b0
  + a6.c <init>(Resources, b0)
  + a6.d transform(Context, b0, int, int) → b0
  + a6.e b(Object, int, int, h) → b0
  + a6.f0 e(MediaMetadataRetriever, Object)
  + a6.g0 b(Object, int, int, h) → b0
  + a6.s transform(Context, b0, int, int) → b0
  + a6.w J(b0, h) → b0
  + aa.q f(v) → p
  + aa.q g(y) → Typeface
  + ac.y a(d, f, d, f, c) → d
  + ae.e0 <init>(b0, w, j, Continuation)
  + ae.l <init>(s, c)
  + ae.p0 <init>(c, CoroutineContext, CoroutineContext, t, a, c)
  + ae.p0 b() → b
  + ae.p0 c(i, boolean)
  + ae.p0 d(i)
  + ae.p0 e(List)
  + af.n0 d(String, String, boolean, q, Continuation) → Object
  + af.s0 f(v) → p
  + af.s0 g() → IntentFilter
  + af.s0 h(int) → int[]
  + ai.a2 a(h)
  + ai.e2 a(h)
  + ak.f J(b0, h) → b0
  + ak.f c(View) → boolean
  + ak.f d(long) → boolean
  + ak.f e() → k
  + ak.m a() → boolean
  + ak.m b(k, String, int) → n3
  + ak.m c(int)
  + ak.m e(long, q, q, q) → q
  + ak.m f(Context) → int
  + ak.m h(Context, boolean) → int
  + ak.m j(q, q, q) → long
  + ak.m k() → int
  + ak.m l(long, q, q, q) → q
  + ak.m o() → int
  + androidx.appcompat.widget.ActionBarContextView i(int, long) → i1
  + androidx.coordinatorlayout.widget.CoordinatorLayout getLastWindowInsets() → j2
  + b8.b f(Context) → int
  + b8.b h(Context, boolean) → int
  + b8.f m0(p, List) → k
  + bd.i0 f(v) → p
  + bd.i0 g(Object)
  + bd.i0 h(q1)
  + bf.d b(d, d, f, Context, CoroutineContext) → k
  + c6.b b(Object, int, int, h) → b0
  + c6.b c(Uri) → b0
  + ch.a onApplyWindowInsets(View, j2) → j2
  + cj.h <init>(e, i, w, e, Application, b1, h0)
  + com.android.volley.toolbox.f <init>(String, s, ImageView_ScaleType, Bitmap_Config, r)
  + com.android.volley.toolbox.f b(k) → t
  + com.android.volley.toolbox.f getPriority() → o
  + com.android.volley.toolbox.f parseNetworkResponse(k) → t
  + com.android.volley.toolbox.g parseNetworkResponse(k) → t
  + com.android.volley.toolbox.h <init>(String, String, s, r)
  + com.bumptech.glide.b <init>(Context, m, d, a, f, i, e, e, e, List)
  + com.bumptech.glide.c <init>(Context, f, f, i, e, e, List, m)
  + com.google.android.gms.common.internal.a c(g)
  + com.google.android.gms.common.internal.a d(String)
  + com.google.android.gms.common.internal.a e() → boolean
  + com.google.android.gms.common.internal.a g()
  + com.google.android.gms.c
...✂

@toluo-stripe toluo-stripe force-pushed the tolu/funding/structural_changes branch 2 times, most recently from 86efc22 to c55c8ae Compare January 7, 2026 23:58
@toluo-stripe toluo-stripe changed the title Structural change for CardAccountRangeService Move accountRangeService to DefaultCardNumberController constructor Jan 7, 2026
Comment on lines -114 to -138
@Test
fun `Entering VISA BIN does not call accountRangeRepository`() {
val fakeRepository = FakeCardAccountRangeRepository()
val cardNumberController = createController(repository = fakeRepository)

cardNumberController.onValueChange("42424242424242424242")
idleLooper()
assertThat(fakeRepository.numberOfCalls).isEqualTo(0)
}

@Test
fun `Entering valid 19 digit UnionPay BIN returns accountRange of 19`() {
val cardNumberController = createController()
cardNumberController.onValueChange("6216828050000000000")
idleLooper()
assertThat(cardNumberController.accountRangeService.accountRange!!.panLength).isEqualTo(19)
}

@Test
fun `Entering valid 16 digit UnionPay BIN returns accountRange of 16`() {
val cardNumberController = createController()
cardNumberController.onValueChange("6282000000000000")
idleLooper()
assertThat(cardNumberController.accountRangeService.accountRange!!.panLength).isEqualTo(16)
}
Copy link
Contributor Author

@toluo-stripe toluo-stripe Jan 8, 2026

Choose a reason for hiding this comment

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

These are testing implementation details of accountRangeService and they already exist in CardAccountRangeServiceTest.kt

@toluo-stripe toluo-stripe force-pushed the tolu/funding/structural_changes branch from c55c8ae to 2450ba3 Compare January 8, 2026 01:20
assertThat(cardNumberEditText.accountRangeService.accountRangeRepositoryJob)
.isNull()
assertThat(accountRangeService.cancelAccountRangeRepositoryJobCount).isEqualTo(1)
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

We should not have access to accountRangeRepositoryJob job here

@toluo-stripe toluo-stripe force-pushed the tolu/funding/structural_changes branch 4 times, most recently from 0a1071a to aaa1fbf Compare January 8, 2026 14:14
@toluo-stripe toluo-stripe changed the title Move accountRangeService to DefaultCardNumberController constructor Refactor CardAccountRangeService to Flow-based interface for improved testability Jan 8, 2026
@toluo-stripe toluo-stripe force-pushed the tolu/funding/structural_changes branch from aaa1fbf to f09313d Compare January 8, 2026 14:42
@toluo-stripe toluo-stripe marked this pull request as ready for review January 8, 2026 14:42
@toluo-stripe toluo-stripe requested review from a team as code owners January 8, 2026 14:42
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.

2 participants