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" + } +}