From 1132973ed4b8ccfaf906214c4acfcb061738bdb2 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 8 Oct 2026 15:27:37 -0400 Subject: [PATCH] feat(links): don't ask again for a trusted website The "You're Leaving Flipcash" warning now has a "Don't ask again for " box above Open Website. It starts unchecked, and the host is saved only when Open Website is tapped with it checked; Cancel or dismissing saves nothing. A saved host opens straight away next time. The host is the normalized ASCII one the warning shows, so a look-alike domain is saved as its xn-- form. Matching is exact: x.com does not cover mail.x.com. First-party hosts keep their own handling, and a link with no host (mailto:) isn't offered the box. The list lives in its own Preferences file next to app-settings: one list for the device, never synced, and not registered with resetStateForUser, so it outlives Log Out and Switch Accounts. Settings > Privacy always shows a Trusted Websites row under Blocked. The screen lists hosts newest first with Remove, and an empty list gets the same title-and-hint treatment as Blocked. BottomBarMessage gains an optional checkbox, drawn between the subtitle and the actions; existing messages are unchanged. --- .../kotlin/com/flipcash/app/MainActivity.kt | 6 + .../ui/navigation/AppScreenContent.kt | 2 + .../kotlin/com/flipcash/app/core/AppRoute.kt | 2 + .../flipcash/app/core/links/ExternalLinks.kt | 105 +++++++++++---- .../app/core/links/TrustedWebsites.kt | 38 ++++++ .../core/src/main/res/values/strings.xml | 8 ++ .../app/core/links/ExternalLinksTest.kt | 2 +- .../app/core/links/TrustedLinksTest.kt | 117 ++++++++++++++++ .../internal/screens/MessengerScreen.kt | 6 +- .../app/myaccount/TrustedWebsitesScreen.kt | 41 ++++++ .../internal/settings/SettingsMenuItems.kt | 11 ++ .../internal/settings/SettingsViewModel.kt | 2 +- .../TrustedWebsitesScreenContent.kt | 127 ++++++++++++++++++ .../TrustedWebsitesViewModel.kt | 20 +++ .../internal/SettingsViewModelTest.kt | 9 +- .../shared/appsettings/build.gradle.kts | 3 + .../appsettings/inject/AppSettingModule.kt | 15 +++ .../internal/InternalTrustedWebsites.kt | 61 +++++++++ .../internal/InternalTrustedWebsitesTest.kt | 90 +++++++++++++ .../com/flipcash/app/auth/AuthManagerTest.kt | 14 ++ .../com/getcode/manager/BottomBarManager.kt | 15 ++- .../ui/components/bars/BottomBarContainer.kt | 40 +++++- 22 files changed, 703 insertions(+), 31 deletions(-) create mode 100644 apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/links/TrustedWebsites.kt create mode 100644 apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/links/TrustedLinksTest.kt create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/TrustedWebsitesScreen.kt create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/trustedwebsites/TrustedWebsitesScreenContent.kt create mode 100644 apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/trustedwebsites/TrustedWebsitesViewModel.kt create mode 100644 apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/InternalTrustedWebsites.kt create mode 100644 apps/flipcash/shared/appsettings/src/test/kotlin/com/flipcash/app/appsettings/internal/InternalTrustedWebsitesTest.kt diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt index fbf059c749..ceabcf1c76 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt @@ -22,6 +22,8 @@ import com.flipcash.app.billing.BillingClient import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.contacts.LocalContactCoordinator import com.flipcash.app.core.LocalUserManager +import com.flipcash.app.core.links.LocalTrustedWebsites +import com.flipcash.app.core.links.TrustedWebsites import com.flipcash.app.core.media.LocalMediaUrlResolver import com.flipcash.app.core.media.MediaUrlResolver import com.flipcash.app.core.tipping.LocalTipCoordinator @@ -147,6 +149,9 @@ class MainActivity : FragmentActivity() { @Inject lateinit var mediaUrlResolver: MediaUrlResolver + @Inject + lateinit var trustedWebsites: TrustedWebsites + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) handleUncaughtException() @@ -169,6 +174,7 @@ class MainActivity : FragmentActivity() { LocalVibrator provides vibrator, LocalRouter provides router, LocalUserManager provides userManager, + LocalTrustedWebsites provides trustedWebsites, LocalSessionController provides sessionController, LocalShareController provides shareController, LocalInviteController provides inviteController, diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 2e7c35aba5..6cd78e242d 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -56,6 +56,7 @@ import com.flipcash.app.myaccount.BlocklistScreen import com.flipcash.app.myaccount.UserProfileScreen import com.flipcash.app.myaccount.EditProfileScreen import com.flipcash.app.myaccount.SettingsScreen +import com.flipcash.app.myaccount.TrustedWebsitesScreen import com.flipcash.app.scanner.ScannerScreen import com.flipcash.app.shareapp.ShareAppScreen import com.flipcash.app.tokens.SwapFlowScreen @@ -186,6 +187,7 @@ fun appEntryProvider( annotatedEntry { EditProfileScreen() } annotatedEntry { key -> ProfileCardScreen(key) } annotatedEntry { BlocklistScreen() } + annotatedEntry { TrustedWebsitesScreen() } annotatedEntry { BackupKeyScreen() } annotatedEntry { DeviceLogsScreen() } annotatedEntry { AccountSelectionScreen() } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 7ca56b29f3..97d284c755 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -328,6 +328,8 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data object Blocklist: Menu @Serializable + data object TrustedWebsites : Menu + @Serializable data object DeviceLogs : Menu @Serializable data object AccountSelection : Menu diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/links/ExternalLinks.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/links/ExternalLinks.kt index c426d1c8af..7d08e99ba4 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/links/ExternalLinks.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/links/ExternalLinks.kt @@ -4,6 +4,7 @@ import android.content.Context import androidx.compose.ui.platform.UriHandler import com.flipcash.core.R import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarCheckbox import com.getcode.manager.BottomBarManager import java.net.IDN @@ -25,30 +26,42 @@ sealed interface LinkDestination { /** The host is exactly one of [FIRST_PARTY_HOSTS]; open without asking. */ data object FirstParty : LinkDestination + /** The host is exactly one the user chose to trust from the warning; open without asking. */ + data class Trusted(val host: String) : LinkDestination + /** * Anywhere else. [host] is what the warning shows: lowercased and in ASCII (punycode) form, so * a homograph domain can't pass for the real one. For a link with no host at all (`mailto:`), - * it is the scheme. + * it is the scheme, and [trustable] is false: there is no website to stop asking about. */ - data class External(val host: String) : LinkDestination + data class External(val host: String, val trustable: Boolean = true) : LinkDestination } /** - * Classifies [url] by its host, on an exact match against [FIRST_PARTY_HOSTS] — never a suffix - * match, so `evilflipcash.com` and `flipcash.com.evil.tld` both warn. + * Classifies [url] by its host, on an exact match against [FIRST_PARTY_HOSTS] and then + * [trustedHosts] — never a suffix match, so `evilflipcash.com` and `flipcash.com.evil.tld` both + * warn, and trusting `x.com` does not cover `mail.x.com`. [trustedHosts] holds hosts in the form + * [LinkDestination.External.host] gives them. * * The host is parsed here rather than with `Uri`/`URI`: those differ from browsers on inputs an * attacker picks (`\` as a path separator, non-ASCII hosts), and the answer has to agree with where * the browser actually goes. Anything this can't read as first-party warns. */ -fun classifyLink(url: String): LinkDestination { +fun classifyLink(url: String, trustedHosts: Set = emptySet()): LinkDestination { val raw = hostOf(url.trim()) if (raw == null) { val scheme = url.trim().substringBefore(':', missingDelimiterValue = "").lowercase() - return LinkDestination.External(scheme.ifEmpty { url.trim().take(MAX_FALLBACK_LENGTH) }) + return LinkDestination.External( + host = scheme.ifEmpty { url.trim().take(MAX_FALLBACK_LENGTH) }, + trustable = false, + ) } val host = asciiHost(raw) - return if (host in FIRST_PARTY_HOSTS) LinkDestination.FirstParty else LinkDestination.External(host) + return when (host) { + in FIRST_PARTY_HOSTS -> LinkDestination.FirstParty + in trustedHosts -> LinkDestination.Trusted(host) + else -> LinkDestination.External(host) + } } private const val MAX_FALLBACK_LENGTH = 64 @@ -94,29 +107,74 @@ private fun asciiHost(host: String): String { } /** - * Runs [open] straight away for a first-party link, and otherwise asks first: "You're Leaving - * Flipcash", naming the host, with Open Website as the primary button and Cancel as the secondary. + * Runs [open] straight away for a first-party or trusted link, and otherwise asks first: "You're + * Leaving Flipcash", naming the host, with Open Website as the primary button and Cancel as the + * secondary. Above the button sits "Don't ask again for ", unchecked; [trusted] keeps the host + * only when Open Website is tapped with it checked. * * Only for links someone else wrote, such as a chat message. A link the app opens on purpose * (terms, a token's socials) goes straight to the browser. */ -fun openWithExternalLinkCheck(context: Context, url: String, open: () -> Unit) { - when (val destination = classifyLink(url)) { - LinkDestination.FirstParty -> open() - is LinkDestination.External -> BottomBarManager.showInfo( - title = context.getString(R.string.prompt_title_externalLink), - message = context.getString(R.string.prompt_description_externalLink, destination.host), - actions = listOf( - BottomBarAction( - text = context.getString(R.string.action_openWebsite), - onClick = open, - ), - ), - showCancel = true, +fun openWithExternalLinkCheck( + context: Context, + url: String, + trusted: TrustedWebsites, + open: () -> Unit, +) { + val trustedHosts = trusted.websites.value.mapTo(mutableSetOf()) { it.host } + when (val destination = classifyLink(url, trustedHosts)) { + LinkDestination.FirstParty, + is LinkDestination.Trusted -> open() + is LinkDestination.External -> BottomBarManager.showMessage( + externalLinkWarning( + destination = destination, + title = context.getString(R.string.prompt_title_externalLink), + message = context.getString(R.string.prompt_description_externalLink, destination.host), + openWebsite = context.getString(R.string.action_openWebsite), + dontAskAgain = context.getString(R.string.action_dontAskAgainForHost, destination.host), + onTrust = trusted::trust, + open = open, + ) ) } } +/** + * The warning for [destination]. Separate from [openWithExternalLinkCheck] so the rule for when + * [onTrust] runs can be tested without resources: only from Open Website, and only with the box + * checked at that moment. + */ +internal fun externalLinkWarning( + destination: LinkDestination.External, + title: String, + message: String, + openWebsite: String, + dontAskAgain: String, + onTrust: (host: String) -> Unit, + open: () -> Unit, +): BottomBarManager.BottomBarMessage { + var dontAsk = false + return BottomBarManager.BottomBarMessage( + title = title, + subtitle = message, + type = BottomBarManager.BottomBarMessageType.INFO, + checkbox = BottomBarCheckbox( + label = dontAskAgain, + onCheckedChange = { dontAsk = it }, + ).takeIf { destination.trustable }, + actions = listOf( + BottomBarAction( + text = openWebsite, + onClick = { + if (dontAsk) onTrust(destination.host) + open() + }, + ), + ), + showCancel = true, + ) +} + /** * A `LocalUriHandler` that runs [openWithExternalLinkCheck] before [delegate] opens the link. * Provided around the chat transcript, so the `LinkAnnotation.Url` spans in message text pass @@ -125,8 +183,9 @@ fun openWithExternalLinkCheck(context: Context, url: String, open: () -> Unit) { class ExternalLinkUriHandler( private val context: Context, private val delegate: UriHandler, + private val trusted: TrustedWebsites, ) : UriHandler { override fun openUri(uri: String) { - openWithExternalLinkCheck(context, uri) { delegate.openUri(uri) } + openWithExternalLinkCheck(context, uri, trusted) { delegate.openUri(uri) } } } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/links/TrustedWebsites.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/links/TrustedWebsites.kt new file mode 100644 index 0000000000..2e8cb2a08e --- /dev/null +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/links/TrustedWebsites.kt @@ -0,0 +1,38 @@ +package com.flipcash.app.core.links + +import androidx.compose.runtime.staticCompositionLocalOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * A host the user chose not to be warned about again, from the "You're Leaving Flipcash" prompt. + * [host] is exactly what the prompt showed (see [LinkDestination.External.host]); [addedAtMillis] is + * epoch milliseconds. + */ +data class TrustedWebsite(val host: String, val addedAtMillis: Long) + +/** + * The hosts that skip the external-link warning. One list for the device, shared by every account + * on it and kept across Log Out and Switch Accounts; it lives only on this device. + * + * Matching is exact: a host here covers that host and nothing under it. + */ +interface TrustedWebsites { + /** Every trusted host, newest first. Current as of the last write, so a tap can read it. */ + val websites: StateFlow> + + fun trust(host: String) + + fun remove(host: String) +} + +/** No hosts, and nothing kept. For previews and tests that don't provide the real list. */ +object NoTrustedWebsites : TrustedWebsites { + override val websites: StateFlow> = MutableStateFlow(emptyList()) + + override fun trust(host: String) = Unit + + override fun remove(host: String) = Unit +} + +val LocalTrustedWebsites = staticCompositionLocalOf { NoTrustedWebsites } diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 59556f7c5c..f16b750f0a 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -401,6 +401,7 @@ This will open %1$s. Never share your Access Key with a website Open Website + Don\'t ask again for %1$s Unlink Account Verify Your Phone Number And Email To Continue @@ -1300,6 +1301,13 @@ The conversation with them will reappear in Tips No One Blocked Block people from sending you messages by tapping their profile and selecting block + + Trusted Websites + + Added %1$s + Links to these sites open without the “You\'re Leaving Flipcash” warning. Each entry is an exact match: x.com does not cover mail.x.com. + No Trusted Websites + Skip the “You\'re Leaving Flipcash” warning for a website by checking “Don\'t ask again” when you open its link Joined %1$s diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/links/ExternalLinksTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/links/ExternalLinksTest.kt index 1778dc63ef..25e287f6e2 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/links/ExternalLinksTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/links/ExternalLinksTest.kt @@ -72,6 +72,6 @@ class ExternalLinksTest { @Test fun `a link with no host warns with its scheme`() { - assertEquals(LinkDestination.External("mailto"), classifyLink("mailto:someone@flipcash.com")) + assertEquals(LinkDestination.External("mailto", trustable = false), classifyLink("mailto:someone@flipcash.com")) } } diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/links/TrustedLinksTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/links/TrustedLinksTest.kt new file mode 100644 index 0000000000..bd6423e634 --- /dev/null +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/links/TrustedLinksTest.kt @@ -0,0 +1,117 @@ +package com.flipcash.app.core.links + +import com.getcode.manager.SelectedBottomBarAction +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +class TrustedLinksTest { + + @Test + fun `a trusted host opens without a warning`() { + assertEquals(LinkDestination.Trusted("x.com"), classifyLink("https://x.com/flipcash", setOf("x.com"))) + assertEquals(LinkDestination.Trusted("x.com"), classifyLink("HTTPS://X.com:443/", setOf("x.com"))) + } + + @Test + fun `trusting a host does not cover its subdomains or lookalikes`() { + val trusted = setOf("x.com") + assertEquals(LinkDestination.External("mail.x.com"), classifyLink("https://mail.x.com", trusted)) + assertEquals(LinkDestination.External("x.com.evil.tld"), classifyLink("https://x.com.evil.tld", trusted)) + assertEquals(LinkDestination.External("evilx.com"), classifyLink("https://evilx.com", trusted)) + } + + @Test + fun `a trusted parent does not cover its own subdomain the other way round`() { + assertEquals(LinkDestination.External("x.com"), classifyLink("https://x.com", setOf("mail.x.com"))) + } + + @Test + fun `a homograph is matched on its punycode, not on how it looks`() { + // Cyrillic "а" in place of the Latin one. + val homograph = "https://flipcаsh.io/" + val ascii = (classifyLink(homograph) as LinkDestination.External).host + assertEquals(LinkDestination.External(ascii), classifyLink(homograph, setOf("flipcash.io"))) + assertEquals(LinkDestination.Trusted(ascii), classifyLink(homograph, setOf(ascii))) + } + + @Test + fun `first-party hosts keep their own handling`() { + assertEquals(LinkDestination.FirstParty, classifyLink("https://flipcash.com", setOf("flipcash.com"))) + } + + @Test + fun `a link with no host is never offered for trust`() { + val warning = warning(LinkDestination.External("mailto", trustable = false)) + assertNull(warning.message.checkbox) + } + + @Test + fun `open website with the box checked trusts the host and opens`() { + val warning = warning() + assertNotNull(warning.message.checkbox).onCheckedChange(true) + warning.message.actions.single().onClick() + + assertEquals(listOf("x.com"), warning.trusted) + assertEquals(1, warning.opened) + } + + @Test + fun `open website with the box unchecked opens without trusting`() { + val warning = warning() + warning.message.actions.single().onClick() + + assertEquals(emptyList(), warning.trusted) + assertEquals(1, warning.opened) + } + + @Test + fun `unticking before open website saves nothing`() { + val warning = warning() + val checkbox = assertNotNull(warning.message.checkbox) + checkbox.onCheckedChange(true) + checkbox.onCheckedChange(false) + warning.message.actions.single().onClick() + + assertEquals(emptyList(), warning.trusted) + } + + @Test + fun `cancel or dismiss with the box checked saves nothing`() { + val warning = warning() + assertNotNull(warning.message.checkbox).onCheckedChange(true) + warning.message.onClose(SelectedBottomBarAction(-1)) + + assertEquals(emptyList(), warning.trusted) + assertEquals(0, warning.opened) + } + + @Test + fun `each warning starts unchecked`() { + val first = warning() + assertNotNull(first.message.checkbox).onCheckedChange(true) + + val second = warning() + second.message.actions.single().onClick() + + assertEquals(emptyList(), second.trusted) + } + + private class Warning(destination: LinkDestination.External) { + val trusted = mutableListOf() + var opened = 0 + val message = externalLinkWarning( + destination = destination, + title = "You're Leaving Flipcash", + message = "This will open ${destination.host}.", + openWebsite = "Open Website", + dontAskAgain = "Don't ask again for ${destination.host}", + onTrust = { trusted += it }, + open = { opened++ }, + ) + } + + private fun warning(destination: LinkDestination.External = LinkDestination.External("x.com")) = + Warning(destination) +} diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index ad471ad32b..646111cb8a 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -41,6 +41,7 @@ import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.chat.ChatStep import com.flipcash.app.core.chat.ProfileAddress import com.flipcash.app.core.links.ExternalLinkUriHandler +import com.flipcash.app.core.links.LocalTrustedWebsites import com.flipcash.app.core.tokens.TokenInfoEntry import com.flipcash.app.messenger.internal.ChatSubject import com.flipcash.app.messenger.internal.ChatViewModel @@ -82,8 +83,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { val context = LocalContext.current // Links a sender typed are the ones that can lead anywhere, so only the transcript asks before // leaving. The cash card goes through [uriHandler] above: it is ours, and opens directly. - val transcriptUriHandler = remember(context, uriHandler) { - ExternalLinkUriHandler(context, uriHandler) + val trustedWebsites = LocalTrustedWebsites.current + val transcriptUriHandler = remember(context, uriHandler, trustedWebsites) { + ExternalLinkUriHandler(context, uriHandler, trustedWebsites) } val userManager = LocalUserManager.current diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/TrustedWebsitesScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/TrustedWebsitesScreen.kt new file mode 100644 index 0000000000..dca1e448c4 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/TrustedWebsitesScreen.kt @@ -0,0 +1,41 @@ +package com.flipcash.app.myaccount + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.myaccount.internal.trustedwebsites.TrustedWebsitesScreenContent +import com.flipcash.app.myaccount.internal.trustedwebsites.TrustedWebsitesViewModel +import com.flipcash.core.R +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.ui.components.AppBarDefaults +import com.getcode.ui.components.AppBarWithTitle + +@Composable +fun TrustedWebsitesScreen() { + val navigator = LocalCodeNavigator.current + val viewModel = hiltViewModel() + val websites by viewModel.websites.collectAsStateWithLifecycle() + + Column( + modifier = Modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AppBarWithTitle( + title = { + AppBarDefaults.Title(text = stringResource(R.string.title_trustedWebsites)) + }, + titleAlignment = Alignment.CenterHorizontally, + leftIcon = { AppBarDefaults.UpNavigation { navigator.pop() } }, + ) + TrustedWebsitesScreenContent( + websites = websites, + onRemove = viewModel::remove, + ) + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt index 957adf3271..9e88254eb9 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsMenuItems.kt @@ -5,6 +5,7 @@ import androidx.compose.material.icons.filled.ContactMail import androidx.compose.material.icons.filled.Science import androidx.compose.material.icons.outlined.Block import androidx.compose.material.icons.outlined.Description +import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.Palette import androidx.compose.runtime.Composable import androidx.compose.ui.graphics.painter.Painter @@ -47,6 +48,16 @@ internal data object Blocklist : FullMenuItem() { override val action: SettingsViewModel.Event = SettingsViewModel.Event.OpenScreen(AppRoute.Menu.Blocklist) } +/** Always shown; the screen has its own empty state. */ +internal data object TrustedWebsites : FullMenuItem() { + override val icon: Painter + @Composable get() = rememberVectorPainter(Icons.Outlined.Language) + override val name: String + @Composable get() = stringResource(CoreR.string.title_trustedWebsites) + override val action: SettingsViewModel.Event = + SettingsViewModel.Event.OpenScreen(AppRoute.Menu.TrustedWebsites) +} + internal data object BillCustomizer : FullMenuItem() { override val icon: Painter @Composable get() = rememberVectorPainter(Icons.Outlined.Palette) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt index b375d011a0..1be724447e 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/settings/SettingsViewModel.kt @@ -286,7 +286,7 @@ internal class SettingsViewModel @Inject constructor( R.string.title_settingsSectionSecurity, listOf(AccessKey, RequireBiometrics), ), - SettingsSection(R.string.title_settingsSectionPrivacy, listOf(Blocklist)), + SettingsSection(R.string.title_settingsSectionPrivacy, listOf(Blocklist, TrustedWebsites)), SettingsSection( R.string.title_advancedFeatures, listOf(/* BillCustomizer, */ DeviceLogs, BetaFlags, SwitchAccount), diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/trustedwebsites/TrustedWebsitesScreenContent.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/trustedwebsites/TrustedWebsitesScreenContent.kt new file mode 100644 index 0000000000..e32daa11ce --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/trustedwebsites/TrustedWebsitesScreenContent.kt @@ -0,0 +1,127 @@ +package com.flipcash.app.myaccount.internal.trustedwebsites + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.links.TrustedWebsite +import com.flipcash.core.R +import com.getcode.theme.CodeTheme +import com.getcode.util.formatLocalized +import kotlin.time.Instant + +@Composable +internal fun TrustedWebsitesScreenContent( + websites: List, + onRemove: (String) -> Unit, +) { + Box(modifier = Modifier.fillMaxSize()) { + if (websites.isEmpty()) { + EmptyTrustedWebsites(modifier = Modifier.align(Alignment.Center)) + } else { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = CodeTheme.dimens.inset), + ) { + items(websites, key = { it.host }) { website -> + TrustedWebsiteRow( + modifier = Modifier.animateItem(), + website = website, + onRemove = { onRemove(website.host) }, + ) + HorizontalDivider(color = CodeTheme.colors.divider, thickness = 0.5.dp) + } + item(key = "footer") { + Text( + modifier = Modifier + .animateItem() + .padding(vertical = CodeTheme.dimens.grid.x4), + text = stringResource(R.string.description_trustedWebsites), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + } + } + } +} + +@Composable +private fun TrustedWebsiteRow( + website: TrustedWebsite, + onRemove: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(vertical = CodeTheme.dimens.grid.x3), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x3), + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = website.host, + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = stringResource( + R.string.subtitle_trustedWebsiteAdded, + Instant.fromEpochMilliseconds(website.addedAtMillis).formatLocalized("MMM d"), + ), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + ) + } + Text( + modifier = Modifier + .clickable(onClick = onRemove) + .padding(CodeTheme.dimens.grid.x1), + text = stringResource(R.string.action_remove), + style = CodeTheme.typography.textMedium, + color = CodeTheme.colors.errorText, + ) + } +} + +/** Same treatment as the Blocked screen's empty state. */ +@Composable +private fun EmptyTrustedWebsites(modifier: Modifier = Modifier) { + Column( + modifier = modifier.padding(horizontal = CodeTheme.dimens.inset), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + ) { + Text( + text = stringResource(R.string.title_trustedWebsitesEmpty), + style = CodeTheme.typography.textLarge, + color = CodeTheme.colors.textMain, + ) + Text( + modifier = Modifier.fillMaxWidth(0.8f), + text = stringResource(R.string.description_trustedWebsitesEmpty), + style = CodeTheme.typography.caption, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } +} diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/trustedwebsites/TrustedWebsitesViewModel.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/trustedwebsites/TrustedWebsitesViewModel.kt new file mode 100644 index 0000000000..796339f692 --- /dev/null +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/trustedwebsites/TrustedWebsitesViewModel.kt @@ -0,0 +1,20 @@ +package com.flipcash.app.myaccount.internal.trustedwebsites + +import androidx.lifecycle.ViewModel +import com.flipcash.app.core.links.TrustedWebsite +import com.flipcash.app.core.links.TrustedWebsites +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.StateFlow +import javax.inject.Inject + +@HiltViewModel +internal class TrustedWebsitesViewModel @Inject constructor( + private val trustedWebsites: TrustedWebsites, +) : ViewModel() { + + /** Newest first. */ + val websites: StateFlow> = trustedWebsites.websites + + /** Takes effect at once, with no confirmation: the next link to [host] warns again. */ + fun remove(host: String) = trustedWebsites.remove(host) +} diff --git a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt index 4fa5c7829d..16265cba76 100644 --- a/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt +++ b/apps/flipcash/features/myaccount/src/test/kotlin/com/flipcash/app/myaccount/internal/SettingsViewModelTest.kt @@ -16,6 +16,7 @@ import com.flipcash.app.myaccount.internal.settings.LogOut import com.flipcash.app.myaccount.internal.settings.RequireBiometrics import com.flipcash.app.myaccount.internal.settings.SettingsViewModel import com.flipcash.app.myaccount.internal.settings.SwitchAccount +import com.flipcash.app.myaccount.internal.settings.TrustedWebsites import com.flipcash.app.updates.ReleaseStageProvider import com.flipcash.app.userflags.FieldOverride import com.flipcash.app.userflags.ResolvedFlag @@ -74,7 +75,7 @@ class SettingsViewModelTest { assertEquals( listOf( AccessKey, RequireBiometrics, - Blocklist, + Blocklist, TrustedWebsites, DeviceLogs, BetaFlags, LogOut, DeleteAccount, ), @@ -143,6 +144,12 @@ class SettingsViewModelTest { assertTrue(enrolled.biometricsRequired) } + @Test + fun `trusted websites row always sits directly under blocked`() { + val privacy = SettingsViewModel.State().sections.single { it.title == R.string.title_settingsSectionPrivacy } + assertEquals(listOf(Blocklist, TrustedWebsites), privacy.items) + } + @Test fun `navigation events leave state unchanged`() { val state = SettingsViewModel.State() diff --git a/apps/flipcash/shared/appsettings/build.gradle.kts b/apps/flipcash/shared/appsettings/build.gradle.kts index 16d80697cc..e81f82d003 100644 --- a/apps/flipcash/shared/appsettings/build.gradle.kts +++ b/apps/flipcash/shared/appsettings/build.gradle.kts @@ -13,4 +13,7 @@ dependencies { implementation(project(":ui:biometrics")) implementation(libs.androidx.datastore) + + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) } diff --git a/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/inject/AppSettingModule.kt b/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/inject/AppSettingModule.kt index b8afc258fb..d1ef0fc57e 100644 --- a/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/inject/AppSettingModule.kt +++ b/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/inject/AppSettingModule.kt @@ -3,13 +3,18 @@ package com.flipcash.app.appsettings.inject import android.content.Context import com.flipcash.app.appsettings.AppSettingsController import com.flipcash.app.appsettings.internal.InternalAppSettingsController +import com.flipcash.app.appsettings.internal.InternalTrustedWebsites +import com.flipcash.app.core.links.TrustedWebsites import com.flipcash.libs.coroutines.DispatcherProvider +import androidx.datastore.preferences.preferencesDataStoreFile import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob @Module @InstallIn(SingletonComponent::class) @@ -20,4 +25,14 @@ object AppSettingModule { @ApplicationContext context: Context, dispatchers: DispatcherProvider, ): AppSettingsController = InternalAppSettingsController(context, dispatchers) + + @Provides + @Singleton + fun providesTrustedWebsites( + @ApplicationContext context: Context, + dispatchers: DispatcherProvider, + ): TrustedWebsites = InternalTrustedWebsites( + scope = CoroutineScope(SupervisorJob() + dispatchers.IO), + produceFile = { context.preferencesDataStoreFile(InternalTrustedWebsites.FILE_NAME) }, + ) } \ No newline at end of file diff --git a/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/InternalTrustedWebsites.kt b/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/InternalTrustedWebsites.kt new file mode 100644 index 0000000000..f60f149cbb --- /dev/null +++ b/apps/flipcash/shared/appsettings/src/main/kotlin/com/flipcash/app/appsettings/internal/InternalTrustedWebsites.kt @@ -0,0 +1,61 @@ +package com.flipcash.app.appsettings.internal + +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.longPreferencesKey +import com.flipcash.app.core.links.TrustedWebsite +import com.flipcash.app.core.links.TrustedWebsites +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.io.File + +/** + * The trusted-website list, in its own Preferences file: one key per host, holding the time it was + * added. Its own file rather than `app-settings`, because those values are all booleans. + * + * Device-level on purpose. Nothing on the logout path clears it, so the list outlives Log Out and + * Switch Accounts; do not register it with `AuthManager.resetStateForUser`. + */ +internal class InternalTrustedWebsites( + private val scope: CoroutineScope, + produceFile: () -> File, + private val now: () -> Long = System::currentTimeMillis, +) : TrustedWebsites { + + private val store = PreferenceDataStoreFactory.create( + corruptionHandler = ReplaceFileCorruptionHandler(produceNewData = { emptyPreferences() }), + scope = scope, + produceFile = produceFile, + ) + + override val websites: StateFlow> = store.data + .map { it.toWebsites() } + .stateIn(scope, SharingStarted.Eagerly, emptyList()) + + override fun trust(host: String) { + scope.launch { + store.edit { prefs -> prefs[longPreferencesKey(host)] = now() } + } + } + + override fun remove(host: String) { + scope.launch { + store.edit { prefs -> prefs.remove(longPreferencesKey(host)) } + } + } + + private fun Preferences.toWebsites(): List = asMap() + .mapNotNull { (key, value) -> (value as? Long)?.let { TrustedWebsite(key.name, it) } } + .sortedByDescending { it.addedAtMillis } + + companion object { + const val FILE_NAME = "trusted-websites" + } +} diff --git a/apps/flipcash/shared/appsettings/src/test/kotlin/com/flipcash/app/appsettings/internal/InternalTrustedWebsitesTest.kt b/apps/flipcash/shared/appsettings/src/test/kotlin/com/flipcash/app/appsettings/internal/InternalTrustedWebsitesTest.kt new file mode 100644 index 0000000000..5a8db47c78 --- /dev/null +++ b/apps/flipcash/shared/appsettings/src/test/kotlin/com/flipcash/app/appsettings/internal/InternalTrustedWebsitesTest.kt @@ -0,0 +1,90 @@ +package com.flipcash.app.appsettings.internal + +import com.flipcash.app.core.links.TrustedWebsite +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.test.assertEquals + +class InternalTrustedWebsitesTest { + + @get:Rule + val folder = TemporaryFolder() + + private val jobs = mutableListOf() + private var clock = 1_000L + + private fun store(): InternalTrustedWebsites { + val job = Job().also { jobs += it } + return InternalTrustedWebsites( + scope = CoroutineScope(Dispatchers.IO + job), + produceFile = { folder.root.resolve("trusted-websites.preferences_pb") }, + now = { clock }, + ) + } + + private fun InternalTrustedWebsites.awaitHosts(vararg hosts: String): List = + runBlocking { + withTimeout(5_000) { websites.first { list -> list.map { it.host } == hosts.toList() } } + } + + @After + fun tearDown() = runBlocking { jobs.forEach { it.cancelAndJoin() } } + + @Test + fun `trusted hosts come back newest first with the time they were added`() { + val store = store() + clock = 1_000L + store.trust("youtube.com") + store.awaitHosts("youtube.com") + clock = 2_000L + store.trust("x.com") + + assertEquals( + listOf(TrustedWebsite("x.com", 2_000L), TrustedWebsite("youtube.com", 1_000L)), + store.awaitHosts("x.com", "youtube.com"), + ) + } + + @Test + fun `remove takes the host out of the list`() { + val store = store() + store.trust("x.com") + store.awaitHosts("x.com") + + store.remove("x.com") + + assertEquals(emptyList(), store.awaitHosts()) + } + + @Test + fun `hosts are kept exactly as given, so a subdomain is its own entry`() { + val store = store() + store.trust("x.com") + + assertEquals(listOf("x.com"), store.awaitHosts("x.com").map { it.host }) + } + + /** + * Log Out and Switch Accounts end in `AuthManager.resetStateForUser`, which does not know this + * store; what keeps the list across them is that it lives in its own device-level file. A fresh + * instance over that file — as after logging in again, or a restart — still has the host. + */ + @Test + fun `the list outlives the instance that wrote it`() = runBlocking { + val first = store() + first.trust("x.com") + first.awaitHosts("x.com") + jobs.single().cancelAndJoin() + + assertEquals(listOf("x.com"), store().awaitHosts("x.com").map { it.host }) + } +} diff --git a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt index 845ef1bc8e..570f619337 100644 --- a/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt +++ b/apps/flipcash/shared/authentication/src/test/kotlin/com/flipcash/app/auth/AuthManagerTest.kt @@ -7,6 +7,7 @@ import com.flipcash.app.auth.internal.credentials.AccountMetadata import com.flipcash.app.auth.internal.credentials.LookupResult import com.flipcash.app.auth.internal.credentials.PassphraseCredentialManager import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.core.links.TrustedWebsites import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.persistence.PersistenceProvider import com.flipcash.app.push.PushTokenProvider @@ -558,4 +559,17 @@ class AuthManagerTest { coVerify { credentialManager.lookup() } verify { persistence.openDatabase(entropy) } } + + // The trusted-website list is device-level and must outlive Log Out and Switch Accounts, both of + // which go through resetStateForUser. Neither AuthManager nor the settings it resets can reach it. + @Test + fun `logout and switch accounts have no route to the trusted-website list`() { + listOf(AuthManager::class.java, AppSettingsCoordinator::class.java).forEach { type -> + val params = type.declaredConstructors.flatMap { it.parameterTypes.toList() } + assertTrue( + params.none { TrustedWebsites::class.java.isAssignableFrom(it) }, + "${type.simpleName} must not take TrustedWebsites", + ) + } + } } diff --git a/libs/messaging/src/main/kotlin/com/getcode/manager/BottomBarManager.kt b/libs/messaging/src/main/kotlin/com/getcode/manager/BottomBarManager.kt index 69458a6775..55a2a893f3 100644 --- a/libs/messaging/src/main/kotlin/com/getcode/manager/BottomBarManager.kt +++ b/libs/messaging/src/main/kotlin/com/getcode/manager/BottomBarManager.kt @@ -51,6 +51,16 @@ data class BottomBarAction( } +/** + * A tick box drawn between a [BottomBarManager.BottomBarMessage]'s subtitle and its actions. It + * starts unchecked each time the message shows; [onCheckedChange] hears every change, so an action's + * `onClick` can act on the state the box was in when it was tapped. + */ +data class BottomBarCheckbox( + val label: String, + val onCheckedChange: (checked: Boolean) -> Unit, +) + /** * Represents an action related to a selected bottom bar item. * @@ -80,6 +90,7 @@ object BottomBarManager { val timeoutSeconds: Int? = null, val id: Long = UUID.randomUUID().mostSignificantBits, val callSite: String? = null, + val checkbox: BottomBarCheckbox? = null, ) { constructor( title: String = "", @@ -138,8 +149,8 @@ object BottomBarManager { private val _messages: MutableStateFlow> = MutableStateFlow(emptyList()) val messages: StateFlow> get() = _messages.asStateFlow() - @PublishedApi - internal fun showMessage(bottomBarMessage: BottomBarMessage) { + /** Shows a message built in full, for one the `show*` helpers can't express (e.g. a [BottomBarCheckbox]). */ + fun showMessage(bottomBarMessage: BottomBarMessage) { _messages.update { currentMessages -> currentMessages + bottomBarMessage } diff --git a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt index 979d5001b5..915f696638 100644 --- a/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt +++ b/ui/components/src/main/kotlin/com/getcode/ui/components/bars/BottomBarContainer.kt @@ -11,7 +11,10 @@ import androidx.compose.animation.slideOutVertically import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.LocalMinimumInteractiveComponentSize import androidx.compose.ui.draw.clip +import androidx.compose.ui.semantics.Role import com.getcode.theme.White10 import androidx.compose.foundation.layout.Arrangement @@ -52,6 +55,7 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed import com.getcode.manager.BottomBarAction.Companion.OK_DESCRIPTOR +import com.getcode.manager.BottomBarCheckbox import com.getcode.manager.BottomBarManager import com.getcode.manager.SelectedBottomBarAction import com.getcode.theme.CodeTheme @@ -64,6 +68,7 @@ import androidx.compose.foundation.clickable import com.getcode.ui.core.noRippleClickable import com.getcode.ui.core.scaled import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeCheckbox import com.getcode.ui.theme.CodeButton import com.getcode.util.resources.R import kotlinx.coroutines.delay @@ -252,6 +257,9 @@ fun BottomBarView( } } } + bottomBarMessage.checkbox?.let { checkbox -> + BottomBarCheckboxRow(checkbox = checkbox, messageId = bottomBarMessage.id) + } if (bottomBarMessage.additionalInfo.isNotEmpty()) { var isExpanded by remember { mutableStateOf(false) } @@ -432,4 +440,34 @@ private fun BottomBarManager.BottomBarMessageType.backgroundColor(): Color = whe BottomBarManager.BottomBarMessageType.WARNING -> CodeTheme.colors.bannerWarning // use themed banner's for success modals too BottomBarManager.BottomBarMessageType.SUCCESS -> CodeTheme.colors.bannerThemed // CodeTheme.colors.bannerSuccess -} \ No newline at end of file +} + +/** + * The message's tick box and its label, the whole row toggling it. Keyed on [messageId] so each + * message starts unchecked. + */ +@Composable +private fun BottomBarCheckboxRow(checkbox: BottomBarCheckbox, messageId: Long) { + var checked by remember(messageId) { mutableStateOf(false) } + val toggle = { value: Boolean -> + checked = value + checkbox.onCheckedChange(value) + } + Row( + modifier = Modifier + .fillMaxWidth() + .toggleable(value = checked, role = Role.Checkbox, onValueChange = toggle), + horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x2), + verticalAlignment = Alignment.CenterVertically, + ) { + // The row is the touch target, so the box can drop its own 48dp minimum and sit flush. + CompositionLocalProvider(LocalMinimumInteractiveComponentSize provides 0.dp) { + CodeCheckbox(checked = checked, onCheckedChange = null) + } + Text( + text = checkbox.label, + style = CodeTheme.typography.textSmall, + color = White, + ) + } +}