From a31748905b5be8f98daa3ce84ed60a3cb1ee720a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 21 Sep 2026 15:58:27 -0400 Subject: [PATCH] fix(shareable): fund cash links copied from the Sharesheet Tapping the Sharesheet's own Copy button produced no confirmation modal and left the gift card unfunded, so the link the user pasted could not be collected. Picking a copy-to-clipboard app target works, which is why this looked unreproducible: the two are different controls in the same sheet. They also report differently. An app selection reaches ShareBroadcastReceiver with EXTRA_CHOSEN_COMPONENT; the Copy button reaches it with EXTRA_CHOOSER_RESULT and no component on API 35+, and goes unreported below that. The receiver read only the component, which left ShareResult.CopiedToClipboard unreachable. Parse the chooser result for CHOOSER_RESULT_COPY. The clipboard check meant to cover older releases could not run either: checkForShare() sat behind the foregroundRefreshed gate, which resets on ON_STOP, and the Sharesheet is translucent, so it pauses the activity without ever stopping it. Move the check ahead of that gate and have it wait for a clipboard the window has focus to read, since Android denies an unfocused read by reporting no clip at all and that is indistinguishable from an empty one. --- .../session/internal/RealSessionController.kt | 10 +- .../SessionControllerShareCheckTest.kt | 159 +++++++++++++++++ .../app/shareable/ShareSheetController.kt | 7 + .../internal/InternalShareSheetController.kt | 90 ++++++++-- .../internal/ShareBroadcastReceiver.kt | 29 ++- .../InternalShareSheetControllerTest.kt | 166 ++++++++++++++++++ .../internal/ShareBroadcastReceiverTest.kt | 106 +++++++++++ 7 files changed, 555 insertions(+), 12 deletions(-) create mode 100644 apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerShareCheckTest.kt create mode 100644 apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetControllerTest.kt create mode 100644 apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareBroadcastReceiverTest.kt diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index c92f62bc1d..e2fffff0ee 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -311,6 +311,15 @@ class RealSessionController @Inject constructor( // below survive that on their own -- `poll()` is idempotent (see NetworkUpdater) and the // feed catch-up joins an in-flight one; the other seven just fetch again. if (!userManager.authState.canAccessAuthenticatedApis) return + + // Ahead of the pass, because this one has to run on every resume rather than once per stay + // in the foreground. A cash link's Sharesheet is translucent: it pauses the activity + // beneath it without ever stopping it, so `onAppInBackground` does not run and the pass is + // still spent when the Sharesheet dismisses. Behind it, the resume that carries the user's + // answer is exactly the one that gets skipped, and an unnoticed share leaves the gift card + // unfunded. Checking costs nothing when no share is pending. + shareSheetController.checkForShare() + if (!foregroundRefreshed.compareAndSet(false, true)) return startPolling() @@ -321,7 +330,6 @@ class RealSessionController @Inject constructor( checkPendingItemsInFeed() bringActivityFeedCurrent() refreshBlocklist() - shareSheetController.checkForShare() if (userManager.authState.isAtLeastRegistered && userManager.state.value.flags?.requiresIapForRegistration == true) { billingClient.connect() } diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerShareCheckTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerShareCheckTest.kt new file mode 100644 index 0000000000..fc326aa186 --- /dev/null +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerShareCheckTest.kt @@ -0,0 +1,159 @@ +package com.flipcash.app.session.internal + +import com.flipcash.app.appsettings.AppSettingsCoordinator +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.services.controllers.AccountController +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.session.internal.delegates.DepositDelegate +import com.flipcash.app.shareable.ShareSheetController +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.user.AuthState +import com.flipcash.services.user.UserManager +import com.getcode.utils.network.ConnectionType +import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.utils.network.NetworkState +import com.getcode.utils.network.SignalStrength +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +/** + * A cash link's Sharesheet is translucent, so the activity underneath it is paused but never + * stopped. `onAppInBackground` -- the only thing that reopens the once-per-foreground gate -- runs + * on ON_STOP, so the resume that follows the Sharesheet finds the gate shut. Anything behind it is + * skipped, and the share check is the one call there that has to run on *every* resume: it is what + * notices a copy, and a copy that goes unnoticed leaves the gift card unfunded. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class SessionControllerShareCheckTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private val dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher()) + + // The observed state stays Unknown so that the collector's own `authState is Ready -> + // onAppInForeground()` never fires and each test counts only the resumes it makes itself; the + // direct getter, which is what onAppInForeground reads, answers Ready. + private val userManager = mockk(relaxed = true) { + every { state } returns MutableStateFlow(UserManager.State(authState = AuthState.Unknown)) + every { authState } returns AuthState.Ready + } + + private val featureFlagController = mockk(relaxed = true) { + every { observe(any()) } returns MutableStateFlow(false) + } + + private val appSettingsCoordinator = mockk(relaxed = true) { + every { observeValue(any()) } returns flowOf(false) + } + + private val tokenCoordinator = mockk(relaxed = true) { + every { tokenBalances } returns flowOf(emptyList()) + every { tokens } returns flowOf(emptyList()) + } + + private val networkObserver = mockk(relaxed = true) { + every { state } returns MutableStateFlow( + NetworkState( + connected = false, + signalStrength = SignalStrength.Unknown, + type = ConnectionType.Unknown, + ) + ) + } + + private val accountController = mockk(relaxed = true) { + coEvery { getUserFlags() } returns Result.failure(IllegalStateException("not under test")) + } + + private val shareSheetController = mockk(relaxed = true) + private val depositDelegate = mockk(relaxed = true) + private val billController = mockk(relaxed = true) + + @Before + fun setUp() { + every { billController.state } returns MutableStateFlow(mockk(relaxed = true)) + } + + @Test + fun `a resume the Sharesheet did not stop the activity for still checks for a share`() = runTest { + val controller = createController() + + controller.onAppInForeground() + controller.onAppInForeground() + advanceUntilIdle() + + verify(exactly = 2) { shareSheetController.checkForShare() } + } + + @Test + fun `the authenticated refresh still runs once per stay in the foreground`() = runTest { + val controller = createController() + + controller.onAppInForeground() + controller.onAppInForeground() + advanceUntilIdle() + + verify(exactly = 1) { depositDelegate.sweepIfNeeded() } + } + + @Test + fun `an unauthenticated resume checks for nothing`() = runTest { + every { userManager.authState } returns AuthState.Unknown + val controller = createController() + + controller.onAppInForeground() + advanceUntilIdle() + + verify(exactly = 0) { shareSheetController.checkForShare() } + } + + private fun createController(): RealSessionController = RealSessionController( + billDelegate = mockk(relaxed = true) { + every { events } returns flowOf() + every { billState } returns mockk(relaxed = true) { + every { value } returns mockk(relaxed = true) + } + }, + scanDelegate = mockk(relaxed = true) { every { events } returns flowOf() }, + cashLinkDelegate = mockk(relaxed = true) { every { events } returns flowOf() }, + depositDelegate = depositDelegate, + giftCardDelegate = mockk(relaxed = true) { every { events } returns flowOf() }, + tippingDelegate = mockk(relaxed = true), + stateHolder = SessionStateHolder(), + billController = billController, + userManager = userManager, + accountController = accountController, + settingsController = mockk(relaxed = true), + feedCoordinator = mockk(relaxed = true), + networkObserver = networkObserver, + tokenUpdater = mockk(relaxed = true), + activityFeedUpdater = mockk(relaxed = true), + profileUpdater = mockk(relaxed = true), + shareSheetController = shareSheetController, + toastController = mockk(relaxed = true), + billingClient = mockk(relaxed = true), + tokenCoordinator = tokenCoordinator, + contactCoordinator = mockk(relaxed = true), + chatCoordinator = mockk(relaxed = true), + blocklistCoordinator = mockk(relaxed = true), + blobStorageCoordinator = mockk(relaxed = true), + mediaUrlResolver = mockk(relaxed = true), + featureFlagController = featureFlagController, + appSettingsCoordinator = appSettingsCoordinator, + dispatchers = dispatchers, + ) +} diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt index d8f1719e39..8720f53abc 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/ShareSheetController.kt @@ -116,6 +116,13 @@ interface ShareSheetController { companion object { const val ACTION_SHARE_CASH_LINK = "com.flipcash.app.ACTION_SHARE_CASH_LINK" const val ACTION_CASH_LINK_SHARED = "com.flipcash.app.ACTION_CASH_LINK_SHARED" + + /** + * Marks an [ACTION_CASH_LINK_SHARED] broadcast as the Sharesheet's own Copy button rather + * than an app selection. Those two arrive by different routes and only one of them carries + * a component, so the relay has to say which happened. + */ + const val EXTRA_COPIED_TO_CLIPBOARD = "com.flipcash.app.EXTRA_COPIED_TO_CLIPBOARD" } } diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt index c434ff02d7..fff5bf441f 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetController.kt @@ -9,6 +9,8 @@ import android.content.Context import android.content.Intent import android.content.IntentFilter import android.os.Build +import android.os.Handler +import android.os.Looper import androidx.core.net.toUri import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.flipcash.app.core.money.formatted @@ -20,6 +22,7 @@ import com.flipcash.app.shareable.ShareResult import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.ShareSheetController.Companion.ACTION_CASH_LINK_SHARED import com.flipcash.app.shareable.ShareSheetController.Companion.ACTION_SHARE_CASH_LINK +import com.flipcash.app.shareable.ShareSheetController.Companion.EXTRA_COPIED_TO_CLIPBOARD import com.flipcash.app.shareable.Shareable import com.flipcash.app.shareable.ShareablePendingData.CashLink import com.flipcash.shared.shareable.R @@ -35,6 +38,7 @@ import java.security.SecureRandom import java.util.Timer import java.util.TimerTask import kotlin.concurrent.schedule +import kotlin.time.Duration import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds @@ -72,8 +76,15 @@ internal class InternalShareSheetController( private var sharedWithApp: String? = null + /** Set when the Sharesheet itself reports a copy -- see [ShareBroadcastReceiver]. */ + private var copiedToClipboard = false + private var pendingShareable: Shareable? = null + private val mainHandler = Handler(Looper.getMainLooper()) + + private var clipboardPoll: Runnable? = null + override var onShared: ((ShareResult) -> Unit)? = null override fun checkForShare() { @@ -85,20 +96,20 @@ internal class InternalShareSheetController( is Shareable.CashLink -> { // if it was shared with an app, return successfully if (sharedWithApp != null) { - onShared?.invoke(ShareResult.SharedToApp(sharedWithApp!!)) + report(ShareResult.SharedToApp(sharedWithApp!!)) return } - if (clipboardManager.hasPrimaryClip()) { - val clippedText = clipboardManager.primaryClip?.getItemAt(0)?.text - val pendingEntropy = shareable.pendingData?.entropy.orEmpty() - if (clippedText?.contains(pendingEntropy) == true) { - onShared?.invoke(ShareResult.CopiedToClipboard) - return - } + // The Sharesheet's own Copy button, on the versions that report it at all. + if (copiedToClipboard) { + report(ShareResult.CopiedToClipboard) + return } - onShared?.invoke(ShareResult.NotShared) + // One wait at a time: this now runs on every resume, and a second chain of + // retries would outlive the `report` that cancels the first and answer twice. + cancelClipboardPoll() + awaitClipboard(shareable.pendingData?.entropy.orEmpty(), waited = Duration.ZERO) } Shareable.DownloadLink -> Unit @@ -111,12 +122,62 @@ internal class InternalShareSheetController( } } + /** + * Decides a cash link's fate from the clipboard, once the clipboard can actually be read. + * + * This runs from ON_RESUME, and the window does not hold focus yet at that point. Android denies + * clipboard reads to an app that is not focused and reports no clip at all, which is + * indistinguishable from a genuinely empty one -- so reading once and concluding "not shared" + * loses every copy the Sharesheet did not report, and an unfunded link is one the recipient + * cannot collect. Wait for a readable clipboard instead: a readable one decides immediately, + * and only a clipboard that stays empty for the whole grace period is taken at its word. + */ + private fun awaitClipboard(entropy: String, waited: Duration) { + if (clipboardManager.hasPrimaryClip()) { + val clippedText = clipboardManager.primaryClip?.getItemAt(0)?.text + // A blank entropy is contained in every string, and funding a link the user never + // shared is worse than missing one. + val copied = entropy.isNotBlank() && clippedText?.contains(entropy) == true + report(if (copied) ShareResult.CopiedToClipboard else ShareResult.NotShared) + return + } + + if (waited >= CLIPBOARD_GRACE_PERIOD) { + report(ShareResult.NotShared) + return + } + + val retry = Runnable { awaitClipboard(entropy, waited + CLIPBOARD_POLL_INTERVAL) } + clipboardPoll = retry + mainHandler.postDelayed(retry, CLIPBOARD_POLL_INTERVAL.inWholeMilliseconds) + } + + /** + * Reports [result] and drops any clipboard poll still in flight, so a share the Sharesheet + * reports while we are waiting on the clipboard is not then reported a second time. + */ + private fun report(result: ShareResult) { + cancelClipboardPoll() + onShared?.invoke(result) + } + + private fun cancelClipboardPoll() { + clipboardPoll?.let { mainHandler.removeCallbacks(it) } + clipboardPoll = null + } + private val shareResultReceiver = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val packageName = intent.getStringExtra(Intent.EXTRA_CHOSEN_COMPONENT) if (packageName != null) { sharedWithApp = packageName - onShared?.invoke(ShareResult.SharedToApp(packageName)) + report(ShareResult.SharedToApp(packageName)) + return + } + + if (intent.getBooleanExtra(EXTRA_COPIED_TO_CLIPBOARD, false)) { + copiedToClipboard = true + report(ShareResult.CopiedToClipboard) } } } @@ -415,12 +476,21 @@ internal class InternalShareSheetController( } override fun reset(setChecked: Boolean) { + cancelClipboardPoll() pendingShareable = null sharedWithApp = null + copiedToClipboard = false if (isChecking != setChecked) { isChecking = setChecked } onShared = null LocalBroadcastManager.getInstance(context).unregisterReceiver(shareResultReceiver) } + + private companion object { + val CLIPBOARD_POLL_INTERVAL = 50.milliseconds + + /** How long an unreadable clipboard is given to become readable before we believe it. */ + val CLIPBOARD_GRACE_PERIOD = 1.seconds + } } \ No newline at end of file diff --git a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/ShareBroadcastReceiver.kt b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/ShareBroadcastReceiver.kt index 3200f649c7..4f4c64ca8d 100644 --- a/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/ShareBroadcastReceiver.kt +++ b/apps/flipcash/shared/shareable/src/main/kotlin/com/flipcash/app/shareable/internal/ShareBroadcastReceiver.kt @@ -4,8 +4,11 @@ import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent +import android.os.Build +import android.service.chooser.ChooserResult import androidx.localbroadcastmanager.content.LocalBroadcastManager import com.flipcash.app.shareable.ShareSheetController.Companion.ACTION_CASH_LINK_SHARED +import com.flipcash.app.shareable.ShareSheetController.Companion.EXTRA_COPIED_TO_CLIPBOARD import com.getcode.utils.TraceType import com.getcode.utils.trace @@ -26,6 +29,30 @@ class ShareBroadcastReceiver : BroadcastReceiver() { putExtra(Intent.EXTRA_CHOSEN_COMPONENT, clickedComponent.packageName) } LocalBroadcastManager.getInstance(context).sendBroadcast(internalIntent) + return } + + // No component means the user took one of the Sharesheet's own actions instead of picking an + // app -- the Copy button in the preview header is one. Since API 35 those arrive here as + // EXTRA_CHOOSER_RESULT with nothing else attached; before that the Sharesheet reports them + // not at all, and InternalShareSheetController's clipboard check is the only way to notice. + if (isSystemCopy(intent)) { + trace( + tag = "ShareSheet", + message = "User copied cashlink to clipboard", + type = TraceType.User + ) + + val internalIntent = Intent(ACTION_CASH_LINK_SHARED).apply { + putExtra(EXTRA_COPIED_TO_CLIPBOARD, true) + } + LocalBroadcastManager.getInstance(context).sendBroadcast(internalIntent) + } + } + + private fun isSystemCopy(intent: Intent): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.VANILLA_ICE_CREAM) return false + val result = intent.getParcelableExtra(Intent.EXTRA_CHOOSER_RESULT, ChooserResult::class.java) + return result?.type == ChooserResult.CHOOSER_RESULT_COPY } -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetControllerTest.kt b/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetControllerTest.kt new file mode 100644 index 0000000000..fb87ba3351 --- /dev/null +++ b/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/InternalShareSheetControllerTest.kt @@ -0,0 +1,166 @@ +package com.flipcash.app.shareable.internal + +import android.content.ClipData +import android.content.ClipboardManager +import android.content.Context +import android.content.Intent +import android.os.Looper +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.shareable.ShareResult +import com.flipcash.app.shareable.ShareSheetController.Companion.ACTION_CASH_LINK_SHARED +import com.flipcash.app.shareable.ShareSheetController.Companion.EXTRA_COPIED_TO_CLIPBOARD +import com.flipcash.app.shareable.Shareable +import com.getcode.crypt.MnemonicPhrase +import com.getcode.opencode.model.accounts.GiftCardAccount +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The controller has to decide whether a cash link actually left the app, because only then does + * [com.flipcash.app.shareable.ShareResult.ActionTaken] fund the gift card. Two routes report that: + * the Sharesheet's broadcast, and -- when the Sharesheet says nothing, as it does for its Copy + * button before API 35 -- the clipboard. + */ +@RunWith(RobolectricTestRunner::class) +class InternalShareSheetControllerTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private val clipboard: ClipboardManager = + context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + + private lateinit var controller: InternalShareSheetController + private val results = mutableListOf() + + @Before + fun setUp() { + controller = InternalShareSheetController(context, clipboard, FakeResourceHelper()) + controller.onShared = { results += it } + } + + @Test + fun `copy reported by the Sharesheet surfaces as a clipboard share`() { + presentCashLink() + + broadcast(Intent(ACTION_CASH_LINK_SHARED).putExtra(EXTRA_COPIED_TO_CLIPBOARD, true)) + + assertEquals(listOf(ShareResult.CopiedToClipboard), results) + } + + @Test + fun `app chosen in the Sharesheet surfaces as a share to that app`() { + presentCashLink() + + broadcast( + Intent(ACTION_CASH_LINK_SHARED) + .putExtra(Intent.EXTRA_CHOSEN_COMPONENT, "com.google.android.apps.messaging") + ) + + assertEquals( + listOf(ShareResult.SharedToApp("com.google.android.apps.messaging")), + results + ) + } + + @Test + fun `checkForShare waits for a clipboard it cannot read yet`() { + presentCashLink() + + // An unreadable clipboard is indistinguishable from an empty one: until the window takes + // focus, Android denies the read and reports no clip at all. + controller.checkForShare() + shadowOf(Looper.getMainLooper()).idle() + assertTrue(results.isEmpty(), "reported a result before the clipboard could be read") + + clipboard.setPrimaryClip(ClipData.newPlainText("", "$1.00 https://send.flipcash.com/c/#/e=$ENTROPY")) + shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofMillis(200)) + + assertEquals(listOf(ShareResult.CopiedToClipboard), results) + } + + @Test + fun `checkForShare reports not shared once a readable clipboard turns out not to hold the link`() { + presentCashLink() + clipboard.setPrimaryClip(ClipData.newPlainText("", "something else entirely")) + + controller.checkForShare() + shadowOf(Looper.getMainLooper()).idle() + + assertEquals(listOf(ShareResult.NotShared), results) + } + + @Test + fun `checkForShare gives up on a clipboard that never becomes readable`() { + presentCashLink() + + controller.checkForShare() + shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofSeconds(5)) + + assertEquals(listOf(ShareResult.NotShared), results) + } + + @Test + fun `a second check does not leave two polls running`() { + // checkForShare runs on every resume now, so a second one can arrive while the first is + // still waiting on the clipboard. It has to replace that wait rather than race it. + presentCashLink() + + controller.checkForShare() + controller.checkForShare() + + clipboard.setPrimaryClip(ClipData.newPlainText("", "look: $ENTROPY")) + shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofSeconds(5)) + + assertEquals(listOf(ShareResult.CopiedToClipboard), results) + } + + @Test + fun `a pending poll does not report again after the Sharesheet reports a copy`() { + presentCashLink() + + controller.checkForShare() + broadcast(Intent(ACTION_CASH_LINK_SHARED).putExtra(EXTRA_COPIED_TO_CLIPBOARD, true)) + shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofSeconds(5)) + + assertEquals(listOf(ShareResult.CopiedToClipboard), results) + } + + @Test + fun `checkForShare does nothing when no share is pending`() { + controller.checkForShare() + shadowOf(Looper.getMainLooper()).idleFor(java.time.Duration.ofSeconds(5)) + + assertTrue(results.isEmpty()) + } + + private fun presentCashLink() { + val mnemonic = mockk(relaxed = true) + every { mnemonic.getBase58EncodedEntropy() } returns ENTROPY + val giftCard = mockk(relaxed = true) + every { giftCard.mnemonic } returns mnemonic + + runBlocking { + controller.present(Shareable.CashLink(giftCard, LocalFiat.Zero)) + } + shadowOf(Looper.getMainLooper()).idle() + } + + private fun broadcast(intent: Intent) { + LocalBroadcastManager.getInstance(context).sendBroadcast(intent) + shadowOf(Looper.getMainLooper()).idle() + } + + private companion object { + const val ENTROPY = "2muLdneEFgWMtUejU7WVPj" + } +} diff --git a/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareBroadcastReceiverTest.kt b/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareBroadcastReceiverTest.kt new file mode 100644 index 0000000000..fd82784569 --- /dev/null +++ b/apps/flipcash/shared/shareable/src/test/kotlin/com/flipcash/app/shareable/internal/ShareBroadcastReceiverTest.kt @@ -0,0 +1,106 @@ +package com.flipcash.app.shareable.internal + +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.os.Looper +import android.service.chooser.ChooserResult +import androidx.localbroadcastmanager.content.LocalBroadcastManager +import androidx.test.core.app.ApplicationProvider +import com.flipcash.app.shareable.ShareSheetController.Companion.ACTION_CASH_LINK_SHARED +import com.flipcash.app.shareable.ShareSheetController.Companion.EXTRA_COPIED_TO_CLIPBOARD +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * The Sharesheet reports what the user did through the chosen-component IntentSender, but it speaks + * two dialects: picking an app sends EXTRA_CHOSEN_COMPONENT, while the sheet's *own* buttons — the + * Copy chip in the preview header — send only EXTRA_CHOOSER_RESULT, with no component attached. + * Reading only the first dialect drops the copy on the floor, and a cash link that is never reported + * as shared is never funded. + */ +@RunWith(RobolectricTestRunner::class) +class ShareBroadcastReceiverTest { + + private val context: Context = ApplicationProvider.getApplicationContext() + private val relayed = mutableListOf() + + private val spy = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + relayed += intent + } + } + + @Before + fun setUp() { + LocalBroadcastManager.getInstance(context) + .registerReceiver(spy, IntentFilter(ACTION_CASH_LINK_SHARED)) + } + + @After + fun tearDown() { + LocalBroadcastManager.getInstance(context).unregisterReceiver(spy) + } + + @Test + fun `system copy button is reported as a clipboard share`() { + deliver(Intent().putExtra(EXTRA_CHOOSER_RESULT, chooserResult(ChooserResult.CHOOSER_RESULT_COPY))) + + assertEquals(1, relayed.size) + assertTrue(relayed.single().getBooleanExtra(EXTRA_COPIED_TO_CLIPBOARD, false)) + } + + @Test + fun `chosen app is still reported by package name`() { + val component = ComponentName("com.google.android.apps.messaging", ".ShareActivity") + deliver(Intent().putExtra(Intent.EXTRA_CHOSEN_COMPONENT, component)) + + assertEquals(1, relayed.size) + assertEquals( + "com.google.android.apps.messaging", + relayed.single().getStringExtra(Intent.EXTRA_CHOSEN_COMPONENT) + ) + } + + @Test + fun `edit button is not reported as a share`() { + deliver(Intent().putExtra(EXTRA_CHOOSER_RESULT, chooserResult(ChooserResult.CHOOSER_RESULT_EDIT))) + + assertTrue(relayed.isEmpty()) + } + + @Test + fun `a result carrying neither component nor chooser result is ignored`() { + deliver(Intent()) + + assertTrue(relayed.isEmpty()) + } + + private fun deliver(intent: Intent) { + ShareBroadcastReceiver().onReceive(context, intent) + shadowOf(Looper.getMainLooper()).idle() + } + + /** [ChooserResult]'s constructor is `@hide`, so a test has to reach for it directly. */ + private fun chooserResult(type: Int, component: ComponentName? = null): ChooserResult = + ChooserResult::class.java + .getDeclaredConstructor( + Int::class.javaPrimitiveType, + ComponentName::class.java, + Boolean::class.javaPrimitiveType, + ) + .apply { isAccessible = true } + .newInstance(type, component, false) + + private companion object { + const val EXTRA_CHOOSER_RESULT = "android.intent.extra.CHOOSER_RESULT" + } +}