Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UserManager>(relaxed = true) {
every { state } returns MutableStateFlow(UserManager.State(authState = AuthState.Unknown))
every { authState } returns AuthState.Ready
}

private val featureFlagController = mockk<FeatureFlagController>(relaxed = true) {
every { observe(any()) } returns MutableStateFlow(false)
}

private val appSettingsCoordinator = mockk<AppSettingsCoordinator>(relaxed = true) {
every { observeValue(any()) } returns flowOf(false)
}

private val tokenCoordinator = mockk<TokenCoordinator>(relaxed = true) {
every { tokenBalances } returns flowOf(emptyList())
every { tokens } returns flowOf(emptyList())
}

private val networkObserver = mockk<NetworkConnectivityListener>(relaxed = true) {
every { state } returns MutableStateFlow(
NetworkState(
connected = false,
signalStrength = SignalStrength.Unknown,
type = ConnectionType.Unknown,
)
)
}

private val accountController = mockk<AccountController>(relaxed = true) {
coEvery { getUserFlags() } returns Result.failure(IllegalStateException("not under test"))
}

private val shareSheetController = mockk<ShareSheetController>(relaxed = true)
private val depositDelegate = mockk<DepositDelegate>(relaxed = true)
private val billController = mockk<BillController>(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,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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() {
Expand All @@ -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
Expand All @@ -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)
}
}
}
Expand Down Expand Up @@ -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
}
}
Loading
Loading