Keep what the payer typed when the form leaves the composition - #50
Keep what the payer typed when the form leaves the composition#50Alex Arguello (alex-arguello) wants to merge 10 commits into
Conversation
PayInFormContent kept the chosen instrument, the typed values, the rejected fields and the pending flag in remember, so all four went with the composition. A rotation, a fold, a switch to another tab and a return from a pushed screen each end it, and the payer entered the card again. PayInFormDraft holds the four, and PayabliPayInPaymentFlow owns one. That is the object a host already keeps for the life of the screen, so retention arrives with no change at the call site and no new public surface. Nothing typed reaches saved instance state, and rememberSaveable is gone from the module. A Bundle is serialized by the system and can be written to disk, and what recovers a payment interrupted by process death is the idempotency key. The draft compares what it was last filled from, so a composition starting again with the same configuration and values keeps what is in the boxes. The comparison belongs there rather than in a remember key: a key goes with the composition, and refilling on every composition writes state that the same composition reads, so the form never settles. The instrument still goes on any outcome. Everything goes when the host's scope is cancelled, which is the screen going for good. One form per flow. Two given the same one with different configurations refill each other on every frame, which PayabliPayInForm now states.
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Moves PayIn form state (typed values, selected method, rejected fields, submission pending) out of the Compose composition into a reusable draft object so user input survives composition teardown (e.g., rotation / navigation), while explicitly not persisting sensitive input to saved instance state.
Changes:
- Introduce
PayInFormDraftand thread it throughPayInFormContentandPayabliPayInPaymentFlowto retain form state outside Composeremember. - Update UI and instrumentation tests to use the shared draft and add new retention/draft-behavior tests.
- Update public KDoc to clarify retention guarantees and constraints (one form per flow, no saved-instance retention of typed values).
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| payin/src/main/java/com/payabli/sdk/payin/form/PayInFormDraft.kt | Adds draft object that owns method/typed/errors/pending and handles seeding/switching/clearing. |
| payin/src/main/java/com/payabli/sdk/payin/ui/PayInFormContent.kt | Replaces composition-owned state (remember/rememberSaveable) with the injected draft. |
| payin/src/main/java/com/payabli/sdk/payin/payment/PayabliPayInPaymentFlow.kt | Stores a long-lived draft in the flow and attempts to clear it on scope completion. |
| payin/src/main/java/com/payabli/sdk/payin/PayabliPayInForm.kt | Passes flow.draft down and updates KDoc on retention and lifecycle expectations. |
| payin/src/test/java/com/payabli/sdk/payin/form/PayInFormDraftTest.kt | Adds JVM unit tests for draft seeding/resetting/switching/clearing semantics. |
| payin/src/androidTest/java/com/payabli/sdk/payin/ui/PayInFormSubmissionInstrumentedTest.kt | Uses a composition-external draft to match host behavior. |
| payin/src/androidTest/java/com/payabli/sdk/payin/ui/PayInFormSeedInstrumentedTest.kt | Uses a composition-external draft to match host behavior. |
| payin/src/androidTest/java/com/payabli/sdk/payin/ui/PayInFormRetentionInstrumentedTest.kt | Adds coverage for retention across composition teardown vs. saved-state restore. |
| payin/src/androidTest/java/com/payabli/sdk/payin/ui/PayInFormOutcomeAcrossRecreationInstrumentedTest.kt | Updates test to supply draft; aligns commentary with new retention approach. |
| payin/src/androidTest/java/com/payabli/sdk/payin/ui/PayInBrandBadgeInstrumentedTest.kt | Updates test to supply draft. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
PayInFormDraft compared against the PayInFormValues it was last filled from, so it held the caller's object for the life of the screen. That object can carry a card number, and it outlived the outcome that empties the boxes drawn from it. The composition held the same object before this branch, and a composition ends sooner than a screen does. The comparison now runs against the seed's hash. The configuration is still held as itself, carrying no payer input, so its half of the comparison stays exact. Two seeds sharing a hash are read as one, and the form keeps the values it already has. Keying on the non-sensitive fields alone would drop a caller who swaps a stored card, since the card number is then the only field that changed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
payin/src/main/java/com/payabli/sdk/payin/form/PayInFormDraft.kt:81
seed()is called on every composition, andinitialValues?.hashCode()for a data class containing aMapis typically O(n) over entries. This adds avoidable overhead during frequent recompositions (e.g., while typing). Consider having callers supply a stable lightweight seed key (e.g., a version/nonce) or caching a precomputed seed hash onPayInFormValuescreation soseed()can compare in O(1).
fun seed(
configuration: PayInFormConfiguration,
initialValues: PayInFormValues?,
) {
val key = configuration to initialValues?.hashCode()
if (seededFrom == key) return
seededFrom = key
payin/src/test/java/com/payabli/sdk/payin/form/PayInFormDraftTest.kt:167
- This test is inherently flaky:
System.gc()is not deterministic and CI/JVM GC behavior can vary widely, so this can intermittently fail even when the implementation is correct. A more reliable approach is to assert the draft’s internal seed tracking does not retain the values object (e.g., by design/structure), or to use aReferenceQueueand memory-pressure allocation techniques to make collection more likely and the test less timing-dependent.
// System.gc is a request rather than a command, so this asks until it is answered.
repeat(GC_ATTEMPTS) {
if (watched.get() == null) return
System.gc()
Thread.sleep(GC_PAUSE_MILLIS)
}
fail("the draft still holds the values it was seeded from")
payin/src/test/java/com/payabli/sdk/payin/form/PayInFormDraftTest.kt:189
- To improve the reliability of the GC-based assertion, explicitly drop the strong reference before returning (e.g., create the
WeakReferencefirst, then set avar values: PayInFormValues? = ...to null). As written, the JVM may keepvaluesstrongly reachable longer than expected (e.g., due to stack/escape analysis), increasing flakiness.
private fun seedAndRelease(): WeakReference<PayInFormValues> {
val values =
PayInFormValues(
PayInMethodType.Card,
// Built rather than written as a literal, so the map holds a string the constant pool does not.
mapOf(PayInField.CardNumber to StringBuilder("4111111111111111").toString()),
)
draft.seed(configuration, values)
return WeakReference(values)
}
The test that asserts the draft does not hold the caller's seed waited on System.gc alone, which the runtime is free to ignore, and left the strong reference in a local the frame could still reach. The reference is now nulled before the loop, and each round allocates a megabyte that replaces the one before it, so there is real garbage behind the request rather than a bare hint. Restoring the seed object to the key still fails this test and nothing else, over 13. Three consecutive runs green with the key as it stands. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suppressed comment ·
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
payin/src/main/java/com/payabli/sdk/payin/payment/PayabliPayInPaymentFlow.kt:63
- The
Joblookup is nullable, so in the (mis)configuration where the providedCoroutineScopehas noJob, the draft will never be cleared and may retain sensitive typed data longer than intended. Consider failing fast (require aJob), or ensure the flow always constructs/owns a scope with aJoband uses that for lifecycle cleanup.
scope.coroutineContext[Job]?.invokeOnCompletion { draft.clear() }
payin/src/main/java/com/payabli/sdk/payin/ui/PayInFormContent.kt:82
draft.seed(...)can mutate Compose snapshot state during composition (it clears/fills the state map and updatesmutableStateOffields). This pattern is fragile and often triggers Compose runtime/lint warnings about state writes in composition, and can introduce hard-to-debug recomposition behavior when future refactors cause reads before the write. A safer approach is to move seeding into an effect keyed by(configuration, initialValues)(and render only once seeded), or otherwise structureseedso it doesn’t perform snapshot state writes directly from the composition phase.
// Before anything below reads the draft, and on every composition: a caller replacing the configuration or
// the values starts the form again from what they handed over, and re-entering a composition with the same
// pair keeps what the payer typed.
draft.seed(configuration, initialValues)
payin/src/test/java/com/payabli/sdk/payin/form/PayInFormDraftTest.kt:184
- This test relies on GC timing/behavior (
System.gc(), allocation pressure, sleeps) and can be flaky across JVMs/CI environments. To make it deterministic, consider asserting the implementation behavior directly (e.g., that the draft stores only a derived key and does not retain thePayInFormValuesreference), or reworking the test to avoid depending on GC guarantees.
repeat(GC_ATTEMPTS) {
if (watched.get() == null) return
System.gc()
// Real garbage each round, so a collection the runtime is free to skip has a reason to happen. The
// previous array becomes unreachable as this one replaces it.
ballast = ByteArray(BALLAST_BYTES)
Thread.sleep(GC_PAUSE_MILLIS)
}
fail("the draft still holds the values it was seeded from")
RecordingLogSink collected into an ArrayList, and a transport under test writes to it from every request in flight at once. Eight writers appending 2000 lines each left 4579 of 16000, and the same race throws out of ArrayList.add, which is what reddened AuthenticatedTransportTest's five-caller refresh test on this branch's CI with nothing in :core changed. A CopyOnWriteArrayList, as LoopbackServer.requests already uses for the same reason. The new test fails against the old field and passes against this one. Both failure modes land on whichever test happened to be running rather than on the sink, which is what made this expensive to place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The check that the draft keeps no reference to the caller's values waited on System.gc, allocation pressure and sleeps, so it asserted a runtime behaviour nothing guarantees and could redden a correct build. It now reads the draft's own fields and fails naming any that holds a PayInFormValues. That is the claim the ticket makes, it answers in the same millisecond every run, and it covers a second field keeping a seed rather than only the one that used to. Putting the object back in the key fails it with "the draft holds the caller's values in [seededFrom]" and fails nothing else, over 13. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suppressed comment ·
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
core/src/sharedTest/java/com/payabli/sdk/core/logging/RecordingLogSink.kt:33
CopyOnWriteArrayListis optimized for many reads / few writes; eachaddcopies the backing array, which can significantly slow tests that log heavily (and can add GC pressure). For a write-heavy sink, prefer a concurrent queue (e.g.,ConcurrentLinkedQueue) or a synchronized list (Collections.synchronizedList(...)) and expose a snapshot for assertions when needed.
/**
* Thread-safe, because a transport under test writes from every request in flight at once.
*
* An `ArrayList` here loses records rather than reporting anything: eight writers appending 2000 lines each
* left 4579 of 16000, and the same race throws out of `ArrayList.add` often enough to redden a run that has
* nothing wrong with it. `LoopbackServer.requests` is a `CopyOnWriteArrayList` for the same reason.
*/
val records: MutableList<Record> = CopyOnWriteArrayList()
payin/src/main/java/com/payabli/sdk/payin/ui/PayInFormContent.kt:82
draft.seed(...)can mutate Compose state during composition (entered.clear(),chosen = ..., etc.). Even with the idempotency guard, this pattern can trigger Compose warnings and is easy to regress into recomposition loops. A safer approach is to (a) makePayInFormDraftconstructible in a pre-seeded state (so the first composition can read it without mutation), or (b) allowmethodto be nullable/derivable and runseedfrom a side-effect (LaunchedEffect/SideEffect) while rendering a consistent initial UI until seeding completes.
// Before anything below reads the draft, and on every composition: a caller replacing the configuration or
// the values starts the form again from what they handed over, and re-entering a composition with the same
// pair keeps what the payer typed.
draft.seed(configuration, initialValues)
payin/src/main/java/com/payabli/sdk/payin/form/PayInFormDraft.kt:81
- Using
initialValues?.hashCode()as the seed identity intentionally trades correctness for not retainingPayInFormValues, but it makes seed-change detection vulnerable to hash collisions (causing a genuinely new seed to be treated as the old one). To reduce collision risk without retaining the original values, consider storing a more collision-resistant fingerprint (e.g., a 64-bit/128-bit content hash derived from method + field/value hashes) or storing a redacted copy ofPayInFormValueswhere sensitive fields are removed before caching for equality.
val key = configuration to initialValues?.hashCode()
if (seededFrom == key) return
seededFrom = key
core/src/test/java/com/payabli/sdk/core/logging/RecordingLogSinkConcurrencyTest.kt:36
- If the
awaitassertion fails (or any earlier assertion throws), the executor may not be shut down, potentially leaking threads to later tests. Wrap the executor lifecycle intry/finallyand callshutdownNow()(orshutdown()+ boundedawaitTermination) in thefinally. Also preferassertTrue(\"a writer never finished\", finished.await(...))for clearer intent.
val pool = Executors.newFixedThreadPool(WRITERS)
val start = CountDownLatch(1)
val finished = CountDownLatch(WRITERS)
repeat(WRITERS) {
pool.submit {
start.await()
repeat(WRITES_EACH) { sink.write(LogLevel.DEBUG, "tag", "message") }
finished.countDown()
}
}
start.countDown()
assertEquals("a writer never finished", true, finished.await(TIMEOUT_SECONDS, TimeUnit.SECONDS))
pool.shutdown()
assertEquals(WRITERS * WRITES_EACH, sink.records.size)
}
The pool was shut down after the assertions, so a failure left eight writers running for whatever test came next, on a suite that shares a JVM. The lifecycle moves into a try/finally with shutdownNow, and the latch check becomes assertTrue rather than an equality against a boolean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suppressed comment ·
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
payin/src/main/java/com/payabli/sdk/payin/form/PayInFormDraft.kt:81
initialValues?.hashCode()is derived from the caller-provided values and may incorporate sensitive strings (e.g., PAN) into a long-lived fingerprint. Even though it’s not reversible, retaining a derived value of sensitive input can be problematic for some compliance/security interpretations. Consider using a non-sensitive seed revision token (passed separately),System.identityHashCode(initialValues)(trades off “equal content” detection), or a fingerprint that deliberately excludes sensitive fields (PayInSensitiveFields) so no PAN-derived data is kept.
val key = configuration to initialValues?.hashCode()
if (seededFrom == key) return
seededFrom = key
payin/src/main/java/com/payabli/sdk/payin/ui/PayInFormContent.kt:82
draft.seed(...)mutates Compose snapshot state and is invoked directly during composition. Even with the internal early-return, mutating state from the composable body is a side-effect pattern that can trigger Compose runtime warnings and makes recomposition behavior harder to reason about. If possible, move seeding to a Compose side-effect (SideEffect/LaunchedEffectdriven by a stable key) or restructure so the draft is seeded before the composable reads it (e.g., by the flow/host), keeping composition free of state mutations.
// Before anything below reads the draft, and on every composition: a caller replacing the configuration or
// the values starts the form again from what they handed over, and re-entering a composition with the same
// pair keeps what the payer typed.
draft.seed(configuration, initialValues)
val method = draft.method
val typed = draft.typed
A host may cancel its scope while the form is still on screen, so a recomposition can land part-way through clear(). Nulling seededFrom first is what makes that safe: seed() runs before any read of the draft and refills from the caller's values, so no composition sees a draft with no instrument chosen. Any other order leaves seed() skipping and method raising. Nothing enforces the order, and reordering it looks harmless. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suppressed comment ·
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Suppressed comments (1)
core/src/sharedTest/java/com/payabli/sdk/core/logging/RecordingLogSink.kt:33
CopyOnWriteArrayListis optimized for many readers / few writers, butRecordingLogSinkis write-heavy (every log line is anadd, which copies the backing array). Even in tests this can become a noticeable slowdown as parallel request counts grow. Prefer a write-optimized thread-safe structure (e.g.,Collections.synchronizedList(mutableListOf())orConcurrentLinkedQueue) and adapt read/iteration accordingly.
val records: MutableList<Record> = CopyOnWriteArrayList()
| // The screen going for good, which is the one point this type is told about. Strings are immutable, so | ||
| // this drops the references at a defined moment rather than wiping them; what keeps a value out of the | ||
| // request path is `SensitiveDigits`, not this. | ||
| scope.coroutineContext[Job]?.invokeOnCompletion { draft.clear() } |
There was a problem hiding this comment.
Fixed, recommendation corrected · 8c7a877
The transient method read failure is real, and the earlier replies on this thread were wrong to treat the ordering inside clear as covering it. Reproduced by seeding and reading in a loop on one thread while clearing on another: IllegalStateException: a form draft was read before it was seeded, within three seconds on the first run.
The interleaving the ordering misses: a reader passes seed's early return while seededFrom still matches, and clear then nulls the instrument before that reader reaches method. Nulling seededFrom first only helps a reader that has not yet made its check.
The threading half of the finding is still not what is happening. SnapshotStateMap mutates under a lock with a compare-and-retry loop, so nothing is corrupted and no snapshot exception is raised. entered.clear() from another thread is safe. The failure is a nullable field read after being nulled, and it would happen identically with no Compose state involved.
So neither remedy is the fix. Dispatching to the main thread cannot use the host's scope, which has completed by the time invokeOnCompletion runs, and Snapshot.withMutableSnapshot throws SnapshotApplyConflictException on a concurrent write, which is a crash at teardown rather than a fix for one. Taking the draft off Compose state removes what makes the form recompose as the payer types.
Change clear leaves the chosen instrument alone. Which tab is on screen is not the payer's data and does not need clearing, and the next seed sets it before anything reads it, so no interleaving leaves a reader without one. seededFrom becomes @Volatile, being a plain field written by the completing thread and read by the composition.
Test PayInFormDraftTest.aClearedDraftStillAnswersWhichInstrumentIsOnScreen. Restoring the null fails it and nothing else, over 14. The loop that reproduced the race was used to prove the defect and then removed, because a probabilistic test is the shape this review has twice asked to be replaced.
clear() runs on whichever thread completed the host's scope, and a composition can be reading at the same time. A reader that had already passed seed()'s early return went on to read method, and clear() nulling the instrument under it raised "a form draft was read before it was seeded". Reproduced by seeding and reading in a loop on one thread while clearing on another: the first run raised inside three seconds. Which tab is on screen is not the payer's data and does not need clearing. The next seed() sets it before anything reads it, so no interleaving leaves a reader without one. The seed key becomes @volatile, since it is a plain field written by the completing thread and read by the composition. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suppressed comment ·
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 12 out of 12 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
payin/src/main/java/com/payabli/sdk/payin/form/PayInFormDraft.kt:88
- Using
initialValues?.hashCode()as the only discriminator for value changes can mis-detect changes due to hash collisions, causing the draft to incorrectly retain prior typed state when the caller actually provided new values. Mandatory fix: use a collision-resistant signature (e.g., a stable digest/long hash overmethod+ ordered field/value pairs) or store a non-sensitive structural key provided by the caller (e.g.,initialValuesKey) so “new values” cannot be skipped due to anIntcollision.
fun seed(
configuration: PayInFormConfiguration,
initialValues: PayInFormValues?,
) {
val key = configuration to initialValues?.hashCode()
if (seededFrom == key) return
seededFrom = key
chosen = initialValues?.method?.takeIf { it in configuration.methodsOffered } ?: configuration.startingMethod
entered.clear()
initialValues?.values?.forEach { (field, value) -> if (value.isNotEmpty()) entered[field] = value }
rejectedFields = emptyMap()
}
core/src/sharedTest/java/com/payabli/sdk/core/logging/RecordingLogSink.kt:33
CopyOnWriteArrayListis thread-safe but is optimized for read-mostly workloads; eachaddcopies the entire backing array, which is disproportionately expensive for a log sink where writes dominate (especially under concurrency). Suggested fix: switch to a structure designed for frequent concurrent appends (e.g.,ConcurrentLinkedQueue) or aCollections.synchronizedList(mutableListOf())if you need list semantics; then adaptrecordsexposure accordingly.
val records: MutableList<Record> = CopyOnWriteArrayList()
core/src/test/java/com/payabli/sdk/core/logging/RecordingLogSinkConcurrencyTest.kt:39
shutdownNow()does not guarantee thread termination before the test ends; if a worker ignores interrupts (or is between interrupt checks), the thread can leak into subsequent tests. Suggested fix: followshutdownNow()withawaitTermination(...)(or useshutdown()+awaitTermination(...)with a timeout) to reliably ensure the pool is fully stopped before returning.
} finally {
// In a finally, so a failed assertion leaves no writers behind for whatever test runs next.
pool.shutdownNow()
}
| // The screen going for good, which is the one point this type is told about. Strings are immutable, so | ||
| // this drops the references at a defined moment rather than wiping them; what keeps a value out of the | ||
| // request path is `SensitiveDigits`, not this. | ||
| scope.coroutineContext[Job]?.invokeOnCompletion { draft.clear() } |
There was a problem hiding this comment.
Declined
The race this named is fixed, in 8c7a877 and on the reply above. What remains here is the threading claim, answered on four rounds and unchanged.
Writing snapshot state off the main thread does not throw. SnapshotStateMap mutates through a compare-and-retry loop under an explicit lock, and that lock's own comment in SnapshotStateMap.kt, androidx.compose.runtime 1.10.4, describes concurrent writers as ordinary rather than as a violation:
As there is already contention on the global snapshot lock to write so the additional contention introduced by this lock is nominal.
The failure that was real had nothing to do with snapshots: a nullable field was read after being nulled, and it would have happened identically with plain vars. That is why the fix is to stop nulling it rather than to move where the nulling runs.
Both remedies still cost more than they buy. Dispatchers.Main.immediate needs a live scope, and invokeOnCompletion runs after the host's has completed, so the clear would never execute. Snapshot.withMutableSnapshot throws SnapshotApplyConflictException when a concurrent write lands inside its apply, which is a crash at teardown in exactly the case that motivates it.
Commit 8c7a877 for the race. Nothing further changes.
Test PayInFormDraftTest.aClearedDraftStillAnswersWhichInstrumentIsOnScreen, which fails against the null and nothing else.
A collision in the seed key makes the form skip a refill, so the caller supplies a new card and the service takes the one it replaced. String.hashCode gives 32 bits, and folding several of them into a wider accumulator does not change that, because the inputs are what bound it. The digest reads the characters instead, per field and summed so the order a caller built its map in does not matter. That puts a missed swap on a 64-bit coincidence. Measured over the draft rather than the arithmetic: 0 missed in 2,000,000 swaps between unrelated cards and 0 in 576,000 between cards one digit apart. The salt is per draft, so the value kept is not one that can be computed elsewhere and matched against a table of card numbers. It compares only with itself, which is all the key needs. Two tests cover what the key decides: a swapped card fills in the new one, and the order a caller assembled its values in is not a change. The live tier gains the same swap against a real environment, since what a wrong answer costs is a payment taken on the wrong card. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
shutdownNow interrupts the writers and returns without waiting, so a worker between interrupt checks could still be running as the test returned. awaitTermination is now asserted, on the same bound the latch uses. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Suppressed comment ·
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 13 out of 13 changed files in this pull request and generated 1 comment.
Suppressed comments (3)
payin/src/main/java/com/payabli/sdk/payin/form/PayInFormDraft.kt:150
- The digest is used as an equality key to decide whether
seed()should re-fill. Summing per-field digests (total + ...) materially increases collision likelihood compared to a normal fold, which can make different seeds look identical and cause the draft to ignore changed input (the exact failure this key is meant to prevent). Prefer a canonical order (e.g., sort byfield.ordinal) and then fold withmix(...)so the hash remains order-independent without relying on commutative addition.
private fun PayInFormValues.digest(): Long =
values.entries.fold(mix(salt, method.ordinal.toLong())) { total, (field, value) ->
total + value.fold(mix(salt, field.ordinal.toLong())) { running, char -> mix(running, char.code.toLong()) }
}
payin/src/main/java/com/payabli/sdk/payin/ui/PayInFormContent.kt:82
draft.seed(...)mutates Compose state (chosen,entered,rejectedFields) during composition. Even though it currently runs before this composable reads the draft, state writes during composition are fragile and can break with refactors (and may trigger snapshot/runtime warnings). Consider moving this call into a Compose side-effect (SideEffectorLaunchedEffectkeyed byconfiguration+ a stable representation ofinitialValues) while keeping the seed-key comparison insidePayInFormDraft.
// Before anything below reads the draft, and on every composition: a caller replacing the configuration or
// the values starts the form again from what they handed over, and re-entering a composition with the same
// pair keeps what the payer typed.
draft.seed(configuration, initialValues)
core/src/sharedTest/java/com/payabli/sdk/core/logging/RecordingLogSink.kt:33
CopyOnWriteArrayListmakes everyaddcopy the backing array; for log sinks that can receive many writes (especially in concurrent transport tests), this can become a noticeable overhead. A better fit is a write-optimized thread-safe structure such asCollections.synchronizedList(mutableListOf())orConcurrentLinkedQueue, converting to a list only when needed for assertions.
val records: MutableList<Record> = CopyOnWriteArrayList()
| // The screen going for good, which is the one point this type is told about. Strings are immutable, so | ||
| // this drops the references at a defined moment rather than wiping them; what keeps a value out of the | ||
| // request path is `SensitiveDigits`, not this. | ||
| scope.coroutineContext[Job]?.invokeOnCompletion { draft.clear() } |
|



Ticket: PLA-2491
The payment form kept everything the payer had typed inside the composition that drew it. Anything that ends
that composition emptied the boxes, and a rotation, a fold, a switch to another tab and a return from a screen
pushed on top all end it. The values now live on the payment flow, which a host already holds for the life of
the screen, so a payer who rotates the phone mid-form does not enter the card again.
Structure
The state moves onto
PayabliPayInPaymentFlow.PayabliPayInFormalready takes that object, and itsdocumentation already tells a host to keep it in whatever survives a configuration change: a
ViewModel, aDecompose component, a presenter. So the thing with the right lifetime was in the signature and the values
were simply not on it. Nothing is added to the public API and no integrator changes a call site.
Nothing typed reaches saved instance state, and
rememberSaveableis now absent from the module. ABundleis serialized by the system and can be written to disk, so a form reopened after process death is anempty form; what recovers a payment interrupted there is the idempotency key the submission already mints.
The draft decides for itself when to refill. It records the configuration it was last filled from and a
digest of the seeded values, and compares against those, so a composition starting again with the same pair
keeps what is in the boxes while a caller handing over new values still replaces them. Reading that comparison
as unchanged is what a wrong answer costs: the form would submit the card the caller replaced, so the digest
reads the characters rather than
String.hashCode, which puts a miss on a 64-bit coincidence. It is a digestrather than the object because a caller's
PayInFormValuescan carry a card number and the draft outlives thecomposition that drew it, and salted per draft so the value kept cannot be computed elsewhere and matched
against a table of card numbers.
That comparison cannot live in a
rememberkey, which goes with the composition it belongs to. It also cannotrun unconditionally: refilling on every composition writes state that the same composition then reads, and the
form recomposes every frame without ever settling.
Notable changes
PayInFormDraftholds the chosen instrument, the typed values, the fields the service rejected and whethera submission in flight is this form's. All four were separate
remembercalls before, three of which werekeyed on the configuration and one of which was saveable.
PayInFormContenttakes the draft as a required parameter with no default. Where it is held is what decideswhether the payer keeps their input, so a composable that supplied its own would settle that by omission.
Everything is emptied when the host's scope is cancelled, which is the screen going for good.
what the last composition drew, which is what it already did when they were local state.
RecordingLogSinkin:core's shared test fixtures collects into aCopyOnWriteArrayList. It backed ontoan
ArrayListand is written by every request a transport has in flight, which lost 11,421 of 16,000 linesacross eight writers and threw out of
ArrayList.addoften enough to redden a run. That is what failed CI onthis branch, in a module it does not touch.
PayabliPayInForm. Two forms given the same flow draw the same boxes, and twogiven the same flow with different configurations refill each other on every frame.
Verification
Unit tests,
ktlintCheckandlintare clean. All 37:payininstrumented tests pass on four targets withnothing skipped: a Pixel 7a and an SM-S908U1 on API 36, an SM-A136U1 on API 33, and the API 34 emulator the
nightly runs. The sample app's 7 pass on that emulator, since it draws this form.
16 unit tests cover the draft and 5 instrumented tests cover retention. Four separate breaks were introduced
deliberately and reverted, and each was caught by exactly the tests naming the guarantee it broke: removing
the refill comparison, stopping the seed reading the caller's values, adding a saved-state copy of a typed
value, and holding the caller's seed object rather than its hash. The inventory is on the ticket.
Not included
Whether the form sets
FLAG_SECURE, and whether it opts its fields out of platform autofill. Neither is settoday, both are decisions rather than omissions, and both are recorded on the ticket with what they turn on.