From 88366cd4168ee2d4403d5ed4250fd66298e1588b Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Tue, 14 Jul 2026 16:20:49 -0300 Subject: [PATCH] Add donation checkout entry-point tests. --- app/build.gradle.kts | 1 + .../donate/BecomeASustainerUpsellTest.kt | 191 +++++++++++++++++ .../v2/InlineDonationPromptTest.kt | 200 ++++++++++++++++++ .../megaphone/DonationMegaphoneGatingTest.kt | 84 ++++++++ .../megaphone/DonationMegaphoneTest.kt | 113 ++++++++++ .../megaphone/DonationMegaphoneTestSupport.kt | 54 +++++ .../donate/CheckoutFlowActivity.kt | 4 +- .../donate/DonateToSignalFragment.kt | 4 +- 8 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/BecomeASustainerUpsellTest.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/InlineDonationPromptTest.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/megaphone/DonationMegaphoneGatingTest.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/megaphone/DonationMegaphoneTest.kt create mode 100644 app/src/androidTest/java/org/thoughtcrime/securesms/megaphone/DonationMegaphoneTestSupport.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index afad58f838..cb143880f4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -842,6 +842,7 @@ dependencies { androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) + androidTestImplementation(libs.androidx.compose.ui.test.manifest) androidTestImplementation(testLibs.androidx.test.ext.junit) androidTestImplementation(testLibs.espresso.core) androidTestImplementation(testLibs.espresso.contrib) { diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/BecomeASustainerUpsellTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/BecomeASustainerUpsellTest.kt new file mode 100644 index 0000000000..e23dcdd6a0 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/BecomeASustainerUpsellTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.settings.app.subscription.donate + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import androidx.fragment.app.Fragment +import androidx.navigation.fragment.NavHostFragment +import androidx.recyclerview.widget.RecyclerView +import androidx.test.core.app.ActivityScenario +import androidx.test.espresso.Espresso.onView +import androidx.test.espresso.action.ViewActions.click +import androidx.test.espresso.action.ViewActions.scrollTo +import androidx.test.espresso.contrib.RecyclerViewActions +import androidx.test.espresso.matcher.ViewMatchers.hasDescendant +import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.core.util.getParcelableExtraCompat +import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.badges.self.none.BecomeASustainerFragment +import org.thoughtcrime.securesms.badges.self.overview.BadgesOverviewFragment +import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.components.settings.app.routes.AppSettingsRoute +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.profiles.manage.EditProfileActivity +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.testing.InAppPaymentsRule +import org.thoughtcrime.securesms.testing.SignalActivityRule +import java.util.concurrent.TimeUnit + +/** + * Entry-path coverage for the "become a subscriber" upsell. + * + * The upsell surface is [BecomeASustainerFragment], reached from the profile screen's badges row. The + * handoff test asserts the sheet's button launches the recurring-donation checkout route; the gating + * tests assert the "user might be a sustainer" rule in [org.thoughtcrime.securesms.profiles.manage.EditProfileFragment]: + * a non-donor sees the upsell, an existing donor is routed to badge management instead. + */ +@RunWith(AndroidJUnit4::class) +class BecomeASustainerUpsellTest { + + @get:Rule + val harness = SignalActivityRule() + + @get:Rule + val iapRule = InAppPaymentsRule() + + private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Before + fun setUp() { + setSelfBadges(emptyList()) + } + + @Test + fun nonDonor_badgeTap_showsUpsellSheet() { + ActivityScenario.launch(EditProfileActivity::class.java).use { scenario -> + onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click()) + + val sheet = awaitNavHostFragment(scenario) { it.filterIsInstance().firstOrNull() } + assertThat(sheet).isNotNull() + } + } + + @Test + fun becomeASustainer_launchesRecurringCheckoutRoute() { + LaunchedActivityRecorder(AppSettingsActivity::class.java).use { recorder -> + ActivityScenario.launch(EditProfileActivity::class.java).use { scenario -> + onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click()) + awaitNavHostFragment(scenario) { it.filterIsInstance().firstOrNull() } + + val becomeASustainer = withText(R.string.BecomeASustainerMegaphone__become_a_sustainer) + onView(withId(R.id.recycler)).perform(RecyclerViewActions.scrollTo(hasDescendant(becomeASustainer))) + onView(becomeASustainer).perform(click()) + + val intent = recorder.awaitLaunch() + val route = intent.extras!!.keySet().firstNotNullOfOrNull { intent.getParcelableExtraCompat(it, AppSettingsRoute::class.java) } + assertThat(route).isEqualTo(AppSettingsRoute.DonationsRoute.Donations(directToCheckoutType = InAppPaymentType.RECURRING_DONATION)) + } + } + } + + @Test + fun donor_badgeTap_opensBadgeManagement() { + setSelfBadges(listOf(donorBadge())) + + ActivityScenario.launch(EditProfileActivity::class.java).use { scenario -> + onView(withId(R.id.manage_profile_badges_container)).perform(scrollTo(), click()) + + val badgeManagement = awaitNavHostFragment(scenario) { it.filterIsInstance().firstOrNull() } + assertThat(badgeManagement).isNotNull() + + val upsell = navHostFragments(scenario).filterIsInstance().firstOrNull() + assertThat(upsell).isNull() + } + } + + private fun awaitNavHostFragment(scenario: ActivityScenario, selector: (List) -> Fragment?): Fragment { + return await(description = "nav host fragment") { selector(navHostFragments(scenario)) } + } + + private fun navHostFragments(scenario: ActivityScenario): List { + var fragments: List = emptyList() + scenario.onActivity { activity -> + val navHost = activity.supportFragmentManager.findFragmentById(R.id.nav_host_fragment) as? NavHostFragment + fragments = navHost?.childFragmentManager?.fragments ?: emptyList() + } + return fragments + } + + private fun setSelfBadges(badges: List) { + SignalDatabase.recipients.setBadges(Recipient.self().id, badges) + Recipient.self().fresh() + } + + private fun donorBadge(): Badge { + return Badge( + id = "test-donor-badge", + category = Badge.Category.Donor, + name = "Signal Sustainer", + description = "", + imageUrl = Uri.EMPTY, + imageDensity = "xxhdpi", + expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30), + visible = true, + duration = TimeUnit.DAYS.toMillis(30) + ) + } + + private fun await(timeoutMs: Long = 10_000, pollMs: Long = 50, description: String, supplier: () -> T?): T { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + supplier()?.let { return it } + Thread.sleep(pollMs) + } + error("Timed out after ${timeoutMs}ms waiting for $description") + } + + /** + * Captures the first launched activity of [activityClass] and finishes it immediately so its own + * downstream launches (e.g. the auto-launched checkout) don't cascade during the test. + */ + private inner class LaunchedActivityRecorder(private val activityClass: Class) : AutoCloseable { + @Volatile + private var launched: Intent? = null + + private val app = context.applicationContext as Application + private val callbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (activityClass.isInstance(activity) && launched == null) { + launched = activity.intent + activity.finish() + } + } + + override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityResumed(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + } + + init { + app.registerActivityLifecycleCallbacks(callbacks) + } + + fun awaitLaunch(): Intent = await(description = "${activityClass.simpleName} launch") { launched } + + override fun close() = app.unregisterActivityLifecycleCallbacks(callbacks) + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/InlineDonationPromptTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/InlineDonationPromptTest.kt new file mode 100644 index 0000000000..c79d9dfdb9 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/conversation/v2/InlineDonationPromptTest.kt @@ -0,0 +1,200 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.view.View +import androidx.fragment.app.FragmentActivity +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isInstanceOf +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.core.util.getSerializableCompat +import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.MainActivity +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.DonateToSignalFragment +import org.thoughtcrime.securesms.conversation.ConversationIntents +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.keyvalue.SignalStore +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.testing.InAppPaymentsRule +import org.thoughtcrime.securesms.testing.SignalActivityRule +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit + +/** + * Entry-path coverage for the inline chat donation prompt: the Release-Channel "donation request" + * conversation item that renders a "Donate" action button and opens the one-time checkout. + * + * The conversation is hosted by [MainActivity], so this drives it the same way + * [org.thoughtcrime.securesms.main.MainNavigationLaunchTest] does — a manual lifecycle-callback launch + * plus direct view interaction, since ActivityScenario/Espresso misbehave for the conversation + * custom-action intent. + * + * Unlike the megaphone and subscriber-upsell paths, this prompt is intentionally NOT gated by sustainer + * status; [sustainer_donatePromptStillShown] documents that. + */ +@RunWith(AndroidJUnit4::class) +class InlineDonationPromptTest { + + @get:Rule + val harness = SignalActivityRule() + + @get:Rule + val iapRule = InAppPaymentsRule() + + private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Before + fun setUp() { + SignalStore.inAppPayments.setLastEndOfPeriod(0L) + SignalDatabase.recipients.setBadges(Recipient.self().id, emptyList()) + Recipient.self().fresh() + } + + @Test + fun nonSustainer_donatePromptOpensOneTimeCheckout() { + seedDonationRequest() + + assertDonatePromptOpensCheckout() + } + + @Test + fun sustainer_donatePromptStillShown() { + SignalStore.inAppPayments.setLastEndOfPeriod(TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()) + TimeUnit.DAYS.toSeconds(30)) + SignalDatabase.recipients.setBadges(Recipient.self().id, listOf(donorBadge())) + Recipient.self().fresh() + + seedDonationRequest() + + assertDonatePromptOpensCheckout() + } + + private fun assertDonatePromptOpensCheckout() { + val activity = launchConversation() + + val donateButton = awaitView(activity, R.id.conversation_update_action) + runOnMainSync { donateButton.performClick() } + + val dialog = await(description = "one-time checkout dialog") { + activity.supportFragmentManager.findFragmentByTag(ONE_TIME_NAV_TAG) + } + + assertThat(dialog).isInstanceOf(DonateToSignalFragment.Dialog::class) + val type = dialog.requireArguments().getSerializableCompat(DonateToSignalFragment.Dialog.ARG, InAppPaymentType::class.java) + assertThat(type).isEqualTo(InAppPaymentType.ONE_TIME_DONATION) + } + + private fun seedDonationRequest() { + val recipientId: RecipientId = SignalDatabase.recipients.insertReleaseChannelRecipient() + SignalStore.releaseChannel.setReleaseChannelRecipientId(recipientId) + + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId)) + SignalDatabase.messages.insertBoostRequestMessage(recipientId, threadId) + } + + private fun launchConversation(): MainActivity { + val recipientId = SignalStore.releaseChannel.releaseChannelRecipientId!! + val threadId = SignalDatabase.threads.getOrCreateThreadIdFor(Recipient.resolved(recipientId)) + + val conversationIntent = ConversationIntents.createBuilder(context, recipientId, threadId).blockingGet().build() + val launchIntent = Intent(context, MainActivity::class.java).apply { + action = ConversationIntents.ACTION + putExtras(conversationIntent) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + val app = context.applicationContext as Application + val resumed = CountDownLatch(1) + val holder = arrayOfNulls(1) + val callbacks = object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) = Unit + + // Capture on resume rather than create: a CLEAR_TOP|SINGLE_TOP conversation intent may be + // delivered to an existing MainActivity via onNewIntent, which never fires onActivityCreated. + override fun onActivityResumed(activity: Activity) { + if (activity is MainActivity) { + holder[0] = activity + resumed.countDown() + } + } + + override fun onActivityStarted(activity: Activity) = Unit + override fun onActivityPaused(activity: Activity) = Unit + override fun onActivityStopped(activity: Activity) = Unit + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit + override fun onActivityDestroyed(activity: Activity) = Unit + } + app.registerActivityLifecycleCallbacks(callbacks) + + app.startActivity(launchIntent) + if (!resumed.await(15, TimeUnit.SECONDS)) { + app.unregisterActivityLifecycleCallbacks(callbacks) + error("MainActivity did not reach RESUMED within 15s") + } + app.unregisterActivityLifecycleCallbacks(callbacks) + + return holder[0] ?: error("MainActivity was not captured") + } + + private fun awaitView(activity: FragmentActivity, viewId: Int): View { + return await(description = "view $viewId") { + activity.findViewById(viewId)?.takeIf { it.isShown } + } + } + + private fun await( + timeoutMs: Long = 15_000, + pollMs: Long = 50, + description: String, + supplier: () -> T? + ): T { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + val result = runOnMainSync(supplier) + if (result != null) return result + Thread.sleep(pollMs) + } + error("Timed out after ${timeoutMs}ms waiting for $description") + } + + private fun runOnMainSync(block: () -> T): T { + var result: Result = Result.failure(IllegalStateException("runOnMainSync produced no result")) + InstrumentationRegistry.getInstrumentation().runOnMainSync { result = runCatching(block) } + return result.getOrThrow() + } + + private fun donorBadge(): Badge { + return Badge( + id = "test-donor-badge", + category = Badge.Category.Donor, + name = "Signal Sustainer", + description = "", + imageUrl = Uri.EMPTY, + imageDensity = "xxhdpi", + expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30), + visible = true, + duration = TimeUnit.DAYS.toMillis(30) + ) + } + + companion object { + private const val ONE_TIME_NAV_TAG = "one_time_nav" + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/megaphone/DonationMegaphoneGatingTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/megaphone/DonationMegaphoneGatingTest.kt new file mode 100644 index 0000000000..3ec9a848fa --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/megaphone/DonationMegaphoneGatingTest.kt @@ -0,0 +1,84 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.megaphone + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isFalse +import assertk.assertions.isNull +import assertk.assertions.isTrue +import io.mockk.every +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.core.util.deleteAll +import org.thoughtcrime.securesms.components.settings.app.subscription.InAppDonations +import org.thoughtcrime.securesms.database.InAppPaymentTable +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.testing.InAppPaymentsRule +import org.thoughtcrime.securesms.testing.SignalActivityRule +import org.thoughtcrime.securesms.util.VersionTracker + +/** + * The "user might be a sustainer" rule for the donations remote megaphone: the `standard_donate` + * conditional (via [RemoteMegaphoneRepository.getRemoteMegaphoneToShow]) suppresses the megaphone for + * existing donors and shows it otherwise. + */ +@RunWith(AndroidJUnit4::class) +class DonationMegaphoneGatingTest { + + @get:Rule + val harness = SignalActivityRule() + + @get:Rule + val iapRule = InAppPaymentsRule() + + @Before + fun setUp() { + SignalDatabase.inAppPayments.writableDatabase.deleteAll(InAppPaymentTable.TABLE_NAME) + SignalDatabase.remoteMegaphones.debugRemoveAll() + setSelfBadges(emptyList()) + + // Freshly-installed test APKs report 0 days installed and no configured payment methods, both of + // which independently fail shouldShowDonateMegaphone. Fix them so the donor badge is the only + // variable across the gating tests. + mockkStatic(VersionTracker::class) + mockkObject(InAppDonations) + every { VersionTracker.getDaysSinceFirstInstalled(any()) } returns 30L + every { InAppDonations.hasAtLeastOnePaymentMethodAvailable() } returns true + } + + @After + fun tearDown() { + unmockkStatic(VersionTracker::class) + unmockkObject(InAppDonations) + } + + @Test + fun nonDonor_showsStandardDonateMegaphone() { + val record = donateMegaphoneRecord(conditionalId = "standard_donate") + SignalDatabase.remoteMegaphones.insert(record) + + assertThat(RemoteMegaphoneRepository.getRemoteMegaphoneToShow()?.uuid).isEqualTo(record.uuid) + assertThat(RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate = true)).isTrue() + } + + @Test + fun sustainer_donorBadge_suppressesStandardDonateMegaphone() { + SignalDatabase.remoteMegaphones.insert(donateMegaphoneRecord(conditionalId = "standard_donate")) + setSelfBadges(listOf(donorBadge())) + + assertThat(RemoteMegaphoneRepository.getRemoteMegaphoneToShow()).isNull() + assertThat(RemoteMegaphoneRepository.hasRemoteMegaphoneToShow(canShowLocalDonate = true)).isFalse() + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/megaphone/DonationMegaphoneTest.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/megaphone/DonationMegaphoneTest.kt new file mode 100644 index 0000000000..8ddd532ef5 --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/megaphone/DonationMegaphoneTest.kt @@ -0,0 +1,113 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.megaphone + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.compose.ui.test.junit4.v2.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.fragment.app.DialogFragment +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import assertk.assertThat +import assertk.assertions.isEqualTo +import assertk.assertions.isNotNull +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.signal.core.ui.compose.theme.SignalTheme +import org.signal.core.util.getSerializableCompat +import org.signal.donations.InAppPaymentType +import org.thoughtcrime.securesms.components.settings.app.subscription.donate.CheckoutFlowActivity +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord +import org.thoughtcrime.securesms.testing.InAppPaymentsRule +import org.thoughtcrime.securesms.testing.SignalActivityRule + +/** + * Entry-path coverage for the donations remote megaphone: the megaphone shown on the conversation list + * that carries a "Donate" action into the one-time checkout flow. + * + * The megaphone renders through [MegaphoneComponent], so this renders that composable directly (rather + * than launching the full [org.thoughtcrime.securesms.MainActivity]) and asserts that tapping Donate + * runs the production [RemoteMegaphoneRepository] action, which navigates to [CheckoutFlowActivity] + * with [InAppPaymentType.ONE_TIME_DONATION]. The sustainer gating is covered by [DonationMegaphoneGatingTest]. + */ +@RunWith(AndroidJUnit4::class) +class DonationMegaphoneTest { + + @get:Rule + val harness = SignalActivityRule() + + @get:Rule + val iapRule = InAppPaymentsRule() + + @get:Rule + val composeRule = createComposeRule() + + private val context: Context get() = InstrumentationRegistry.getInstrumentation().targetContext + + @Before + fun setUp() { + SignalDatabase.remoteMegaphones.debugRemoveAll() + } + + @Test + fun donateMegaphone_donateClick_opensOneTimeCheckout() { + val record = donateMegaphoneRecord(conditionalId = null) + SignalDatabase.remoteMegaphones.insert(record) + + val controller = RecordingMegaphoneActionController() + + composeRule.setContent { + SignalTheme { + MegaphoneComponent(buildDonateMegaphone(record), controller) + } + } + + composeRule.onNodeWithText(record.primaryActionText!!).performClick() + + val intent = controller.navigationIntent + assertThat(intent).isNotNull() + assertThat(intent!!.component?.className).isEqualTo(CheckoutFlowActivity::class.java.name) + + val type = intent.extras!!.getSerializableCompat(CheckoutFlowActivity.ARG_IN_APP_PAYMENT_TYPE, InAppPaymentType::class.java) + assertThat(type).isEqualTo(InAppPaymentType.ONE_TIME_DONATION) + } + + /** Mirrors [Megaphones.buildRemoteMegaphone]: wires the primary button to the real repository action. */ + private fun buildDonateMegaphone(record: RemoteMegaphoneRecord): Megaphone { + return Megaphone.Builder(Megaphones.Event.REMOTE_MEGAPHONE, Megaphone.Style.BASIC) + .setTitle(record.title) + .setBody(record.body) + .setActionButton(record.primaryActionText!!) { _, controller -> + RemoteMegaphoneRepository.getAction(record.primaryActionId!!).run(context, controller, record) + } + .build() + } + + private class RecordingMegaphoneActionController : MegaphoneActionController { + var navigationIntent: Intent? = null + private set + + override fun onMegaphoneNavigationRequested(intent: Intent) { + navigationIntent = intent + } + + override fun onMegaphoneNavigationRequested(intent: Intent, requestCode: Int) { + navigationIntent = intent + } + + override fun onMegaphoneToastRequested(string: String) = Unit + override fun getMegaphoneActivity(): Activity = throw UnsupportedOperationException("not used") + override fun onMegaphoneSnooze(event: Megaphones.Event) = Unit + override fun onMegaphoneCompleted(event: Megaphones.Event) = Unit + override fun onMegaphoneDialogFragmentRequested(dialogFragment: DialogFragment) = Unit + } +} diff --git a/app/src/androidTest/java/org/thoughtcrime/securesms/megaphone/DonationMegaphoneTestSupport.kt b/app/src/androidTest/java/org/thoughtcrime/securesms/megaphone/DonationMegaphoneTestSupport.kt new file mode 100644 index 0000000000..c4159e1c7b --- /dev/null +++ b/app/src/androidTest/java/org/thoughtcrime/securesms/megaphone/DonationMegaphoneTestSupport.kt @@ -0,0 +1,54 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.megaphone + +import android.net.Uri +import org.thoughtcrime.securesms.badges.models.Badge +import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.database.model.RemoteMegaphoneRecord +import org.thoughtcrime.securesms.recipients.Recipient +import java.util.UUID +import java.util.concurrent.TimeUnit + +fun donateMegaphoneRecord(conditionalId: String?): RemoteMegaphoneRecord { + val now = System.currentTimeMillis() + return RemoteMegaphoneRecord( + priority = 100, + uuid = UUID.randomUUID().toString(), + countries = null, + minimumVersion = 1, + doNotShowBefore = now - TimeUnit.DAYS.toMillis(2), + doNotShowAfter = now + TimeUnit.DAYS.toMillis(28), + showForNumberOfDays = 30, + conditionalId = conditionalId, + primaryActionId = RemoteMegaphoneRecord.ActionId.DONATE, + secondaryActionId = RemoteMegaphoneRecord.ActionId.SNOOZE, + imageUrl = null, + title = "Donate Test", + body = "Donate body test.", + primaryActionText = "Donate", + secondaryActionText = "Snooze" + ) +} + +fun donorBadge(): Badge { + return Badge( + id = "test-donor-badge", + category = Badge.Category.Donor, + name = "Signal Sustainer", + description = "", + imageUrl = Uri.EMPTY, + imageDensity = "xxhdpi", + expirationTimestamp = System.currentTimeMillis() + TimeUnit.DAYS.toMillis(30), + visible = true, + duration = TimeUnit.DAYS.toMillis(30) + ) +} + +fun setSelfBadges(badges: List) { + SignalDatabase.recipients.setBadges(Recipient.self().id, badges) + Recipient.self().fresh() +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivity.kt index 75712a087e..6fb53dede9 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/CheckoutFlowActivity.kt @@ -9,6 +9,7 @@ import android.content.Context import android.content.Intent import android.os.Parcelable import androidx.activity.result.contract.ActivityResultContract +import androidx.annotation.VisibleForTesting import androidx.fragment.app.Fragment import io.reactivex.rxjava3.subjects.PublishSubject import io.reactivex.rxjava3.subjects.Subject @@ -26,7 +27,8 @@ import org.thoughtcrime.securesms.components.settings.app.subscription.GooglePay class CheckoutFlowActivity : FragmentWrapperActivity(), GooglePayComponent { companion object { - private const val ARG_IN_APP_PAYMENT_TYPE = "in_app_payment_type" + @VisibleForTesting + const val ARG_IN_APP_PAYMENT_TYPE = "in_app_payment_type" const val RESULT_DATA = "result_data" fun createIntent(context: Context, inAppPaymentType: InAppPaymentType): Intent { diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt index 9bc8ea0b37..d29e50c15b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/components/settings/app/subscription/donate/DonateToSignalFragment.kt @@ -3,6 +3,7 @@ package org.thoughtcrime.securesms.components.settings.app.subscription.donate import android.text.SpannableStringBuilder import android.view.View import android.view.ViewGroup +import androidx.annotation.VisibleForTesting import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat import androidx.core.os.bundleOf @@ -78,7 +79,8 @@ class DonateToSignalFragment : companion object { - private const val ARG = "in_app_payment_type" + @VisibleForTesting + const val ARG = "in_app_payment_type" @JvmStatic fun create(inAppPaymentType: InAppPaymentType): DialogFragment {