From 0451f9921ddf5fa183a0e210861ee06ea9b5cdaa Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 25 Oct 2026 01:25:18 +0200 Subject: [PATCH 01/17] Keep following new messages in full chat timelines Public, channel and geohash timelines are capped at 1,000 messages, so once full each arrival evicts the oldest and the list size stops changing. The follow effect was keyed only on size, so it stopped auto-scrolling. Key it on the newest message id and the conversation as well, and add a Robolectric Compose regression test. --- app/build.gradle.kts | 2 + app/gradle.lockfile | 10 +- .../bitchat/android/ui/MessageComponents.kt | 7 +- .../android/ui/MessagesListFollowTest.kt | 118 ++++++++++++++++++ gradle/verification-metadata.xml | 5 + 5 files changed, 135 insertions(+), 7 deletions(-) create mode 100644 app/src/test/java/com/bitchat/android/ui/MessagesListFollowTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a85fb9698..35f69fb77 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -192,6 +192,8 @@ dependencies { // Testing testImplementation(libs.bundles.testing) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(libs.androidx.compose.ui.test.junit4) androidTestImplementation(libs.androidx.test.runner) diff --git a/app/gradle.lockfile b/app/gradle.lockfile index 420310107..fa7ae3c49 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -55,11 +55,11 @@ androidx.compose.ui:ui-geometry-android:1.11.4=debugAndroidTestCompileClasspath, androidx.compose.ui:ui-geometry:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-graphics-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-graphics:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-test-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-test:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +androidx.compose.ui:ui-test:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath androidx.compose.ui:ui-text-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-text:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-tooling-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index d398c397d..21418afe4 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -268,8 +268,11 @@ fun MessagesList( var hasScrolledToInitialPosition by remember(conversationKey) { mutableStateOf(false) } var followIncomingMessages by remember(conversationKey) { mutableStateOf(true) } - // Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away - LaunchedEffect(messages.size) { + // Smart scroll: auto-scroll to bottom for initial load, then follow unless user scrolls away. + // + // Keyed on the newest message as well as the count: a timeline at its retention cap evicts its + // oldest message for every arrival, so its size stops changing while messages keep coming in. + LaunchedEffect(conversationKey, messages.size, messages.lastOrNull()?.id) { if (messages.isNotEmpty()) { val isFirstLoad = !hasScrolledToInitialPosition if (isFirstLoad || followIncomingMessages) { diff --git a/app/src/test/java/com/bitchat/android/ui/MessagesListFollowTest.kt b/app/src/test/java/com/bitchat/android/ui/MessagesListFollowTest.kt new file mode 100644 index 000000000..b31fa43df --- /dev/null +++ b/app/src/test/java/com/bitchat/android/ui/MessagesListFollowTest.kt @@ -0,0 +1,118 @@ +package com.bitchat.android.ui + +import android.app.Application +import android.content.ComponentName +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.size +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performScrollToIndex +import androidx.compose.ui.unit.dp +import com.bitchat.android.mesh.MeshService +import com.bitchat.android.model.BitchatMessage +import java.util.Date +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.ExternalResource +import org.junit.rules.RuleChain +import org.junit.runner.RunWith +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * The message list follows new arrivals while the reader is at the latest message. + * + * Public, channel and geohash timelines are capped, so once one is full every arrival evicts the + * oldest message and the list size stays constant. Following must not depend on the size changing. + */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [35], application = Application::class) +class MessagesListFollowTest { + + private val compose = createComposeRule() + + // Unit tests run without merged Android resources, so Robolectric does not know the host + // activity the compose rule launches. Register it before the rule starts. + @get:Rule + val rules: RuleChain = RuleChain + .outerRule(object : ExternalResource() { + override fun before() { + val app = RuntimeEnvironment.getApplication() + shadowOf(app.packageManager) + .addActivityIfNotPresent(ComponentName(app, ComponentActivity::class.java)) + } + }) + .around(compose) + + private val meshService = mock { on { myPeerID } doReturn "0000000000000000" } + private var messages by mutableStateOf(List(TimelineSize) { msg(it) }) + private var scrolledUp: Boolean? = null + + private fun setContent() { + compose.setContent { + MessagesList( + messages = messages, + currentUserNickname = "me", + meshService = meshService, + modifier = Modifier.size(360.dp, 640.dp), + conversationKey = "geo:test", + onScrolledUpChanged = { scrolledUp = it } + ) + } + compose.waitForIdle() + } + + /** Mirrors the timeline retention cap: the oldest message makes room for the new one. */ + private fun arriveAtCapacity(count: Int) { + repeat(count) { + messages = messages.drop(1) + msg(TimelineSize + it) + compose.waitForIdle() + } + } + + @Test + fun `a full timeline keeps following arrivals that evict the oldest message`() { + setContent() + + arriveAtCapacity(10) + + assertFalse("following stopped once the size stopped changing", scrolledUp ?: false) + compose.onNodeWithText(body(TimelineSize + 9), substring = true).assertIsDisplayed() + } + + @Test + fun `a full timeline does not pull a reader back down after they scroll away`() { + setContent() + compose.onNode(hasScrollToIndexAction()).performScrollToIndex(TimelineSize - 1) + compose.waitForIdle() + + arriveAtCapacity(3) + + assertTrue("reader was yanked back to the latest message", scrolledUp ?: false) + } + + private fun msg(index: Int) = BitchatMessage( + id = "m$index", + sender = "alice", + content = body(index), + timestamp = Date(index * 1_000L), + ) + + private fun body(index: Int) = "synthetic message $index" + + private companion object { + const val TimelineSize = 40 + } +} diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a11f1a73d..4f56e9fae 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -4794,6 +4794,11 @@ + + + + + From 137ce6d01bba0b92f03ea49a9d6c9d3411a79706 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 25 Oct 2026 06:16:38 +0000 Subject: [PATCH 02/17] Automated update of relay data - Sun Oct 25 06:16:38 UTC 2026 --- app/src/main/assets/nostr_relays.csv | 764 +++++++++++++-------------- 1 file changed, 372 insertions(+), 392 deletions(-) diff --git a/app/src/main/assets/nostr_relays.csv b/app/src/main/assets/nostr_relays.csv index dfb4f2cc4..0948b5ff7 100644 --- a/app/src/main/assets/nostr_relays.csv +++ b/app/src/main/assets/nostr_relays.csv @@ -1,398 +1,378 @@ Relay URL,Latitude,Longitude -inbox.scuba323.com,40.8218,-74.45 -relay.pornstr.net,53.3498,-6.26031 -relay.btcforplebs.com,43.6532,-79.3832 -nostr-01.yakihonne.com,1.32123,103.695 -relay.agentry.com,42.8864,-78.8784 -offchain.pub:443,39.1585,-94.5728 -dev.relay.stream,43.6532,-79.3832 -cyberspace.nostr1.com,40.7057,-74.0136 -relay.shadowbip.com,54.352,18.6466 -nip85-staging.nosfabrica.com,39.0997,-94.5786 -portal-relay.pareto.space,53.5488,9.98717 -relay.ru.ac.th,13.7607,100.627 -nostr-relay.corb.net,39.6478,-104.988 -nostrelay.circum.space,52.2245,8.826 -relay.satsapp.me,50.4754,12.3683 -nostr-01.uid.ovh,50.9871,2.12554 -nostr.dzo-hadar.ts.net,35.6764,139.65 -vault.iris.to:443,43.6532,-79.3832 -relay.nmail.li,50.9871,2.12554 -nostr.janx.com,43.6532,-79.3832 -relay.satmaxt.xyz:443,43.6532,-79.3832 -nos.lol:443,50.4754,12.3683 -nostr.mad-social.net,50.4754,12.3683 -relay.flashapp.me,43.6548,-79.3885 -relay.layer.systems,43.6532,-79.3832 -relay.novospes.com,43.6532,-79.3832 -relay.satmaxt.xyz,43.6532,-79.3832 -relay.getvia.xyz,60.1699,24.9384 -vidar.nestoris.online,52.3676,4.90414 -nostr.yutakobayashi.com,43.6532,-79.3832 -dev-relay.nostreon.com,60.1699,24.9384 -nostrcity-club.fly.dev:443,38.7946,-106.535 -nostr.oxtr.dev,50.4754,12.3683 -relay.lacrypta.ar,43.6532,-79.3832 -node.kommonzenze.de,49.4521,11.0767 -bitchat.nostr1.com,40.7057,-74.0136 -nostr.trepechov.com,49.4543,11.0746 -articles.layer3.news:443,37.3387,-121.885 -relay.ditto.pub,43.6532,-79.3832 -treuzkas.branruz.com,48.8575,2.35138 -nostr.plantroon.com:443,50.1013,8.62643 -nostr.mas-family.eu,60.3478,15.7505 -relay.nostrfy.org,35.6916,139.768 -relay.nostrmap.net,60.1699,24.9384 -woods-leader-permits-yamaha.trycloudflare.com,43.6532,-79.3832 -nostr.middling.mydns.jp,35.8099,140.12 -adre.su,59.9311,30.3609 -relay.s-w.art,43.6532,-79.3832 -nostr.thebiglake.org,32.71,-96.6745 -relay.satlantis.io,39.0438,-77.4874 -relay.routstr.com,59.4016,17.9455 -relay.nostr.space,43.6532,-79.3832 -relay.wavlake.com:443,41.2619,-95.8608 -relay.islandbitcoin.com,20.3898,78.0903 -nostr.tabordalab.com,60.1699,24.9384 -nostr-relay.cbrx.io,43.6532,-79.3832 -testnet.samt.st,43.6532,-79.3832 -temp.iris.to,43.6532,-79.3832 -freelay.sovbit.host,60.1699,24.9384 -relay.grammstr.com,38.7946,-106.535 -relay.nostronchain.com,43.6532,-79.3832 -relay.hazeem.xyz,43.6532,-79.3832 -relayone.geektank.ai,39.0997,-94.5786 -nostr-relay.corb.net:443,39.6478,-104.988 -relay.olas.app,60.1699,24.9384 -relay.44billion.net,43.6532,-79.3832 -relay.mitosystem.net,-23.5558,-46.6396 -relay.ltgnet.work:8443,51.0511,-114.075 -nostr.chaima.info:443,50.1109,8.68213 -nostrcity-club.fly.dev,38.7946,-106.535 -nostr.sathoarder.com,48.5734,7.75211 -relay.fountain.fm:443,43.6532,-79.3832 -nostr.txlyre.website,60.1699,24.9384 -nostr.planix.org,43.6532,-79.3832 -relay.chorus.community,50.1109,8.68213 -dev-relay.nostr.space,43.6532,-79.3832 -relay.satsmarkt.club,52.3676,4.90414 -relay.bullishbounty.com,43.6532,-79.3832 -relay.lanacoin-eternity.com,40.8302,-74.1299 -social.amanah.eblessing.co,48.1046,11.6002 -relay-arg.zombi.cloudrodion.com,1.35208,103.82 -relay.primal.net,43.6532,-79.3832 -nostr-relay.zimage.com,34.282,-118.439 -relay.veganostr.com:443,60.1699,24.9384 -relay.hivetalk.org,40.8302,-74.1299 -nostr.whitenode45.ddns.net,40.55,-74.4758 -nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 -relay.edufeed.org,49.4521,11.0767 -nostr.pbfs.io:443,50.4754,12.3683 -relay.substation.ninja,43.6532,-79.3832 -nostr.liberty.fans,36.8767,-89.5879 -nostr-verified.wellorder.net,45.5201,-122.99 -relay.hivescope.xyz,40.4332,-3.62431 -nostr2.girino.org:443,43.6532,-79.3832 -relay.cloistr.xyz,42.337,-77.3178 -relay.agora.social,50.7383,15.0648 -relayone.soundhsa.com,39.0997,-94.5786 -nostr.0x7e.xyz,47.4949,8.71954 -nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 -relay-na1.metanomalist.com,43.6532,-79.3832 -relay.bitmacro.cloud,43.6532,-79.3832 -relay.iyou.me,64.1466,-21.9426 -nostr.bitcoiner.social,47.6743,-117.112 -relay.wisp.talk:443,49.4543,11.0746 -relay.valleirug.nl,52.0274,5.62427 -chat-relay.zap-work.com,43.6532,-79.3832 -nostr.2b9t.xyz,34.0549,-118.243 -groups.beginningend.com,35.2227,-97.4786 -relay.edufeed.org:443,49.4521,11.0767 -nrs-02.darkcloudarcade.com,39.9526,-75.1652 -relay.angor.io,48.1046,11.6002 -relay.jmoose.rocks,32.7767,-96.797 -nostr.mom,50.4754,12.3683 -x.kojira.io,43.6532,-79.3832 -cs-relay.nostrdev.com:443,50.4754,12.3683 -nostr.computingcache.com,45.5341,-122.956 -relay-dev.satlantis.io,39.0438,-77.4874 -nostr.jcera.eu,43.6532,-79.3832 -relay.aarpia.com,37.3986,-121.964 -relay.nostrian-conquest.com,41.223,-111.974 -relay.fiatdenier.com,43.7787,-79.5393 -relayrs.notoshi.win:443,43.6532,-79.3832 -bread.nostrsms.com,40.8218,-74.45 -test.relays.nuts.cash,52.3676,4.90414 -nostrrelay.taylorperron.com,45.5029,-73.5723 -relay.jmoose.rocks:443,32.7767,-96.797 -relay.tapestry.ninja,40.8054,-74.0241 -nostr.hetuniversum.nl,43.6532,-79.3832 -public.crostr.com:443,43.6532,-79.3832 -nostr.trevena.info,43.6532,-79.3832 -relay.chorus.community:443,50.1109,8.68213 -relay.sharegap.net,43.6532,-79.3832 -relayone.geektank.ai:443,39.0997,-94.5786 -nip85.nosfabrica.com,39.0997,-94.5786 -yabu.me,35.6096,139.73 -nostr.bitcoiner.social:443,47.6743,-117.112 -support.flotilla.social,32.9483,-96.7299 -strfry.bonsai.com:443,39.0438,-77.4874 -nostr-relayflare.enpitsulin.workers.dev,43.6532,-79.3832 -relay.crovaxon.de,53.0777,7.41516 -relay.froth.zone,60.1699,24.9384 -r.senx.io,50.6924,3.20113 -relay2.nostrasia.net,43.6532,-79.3832 -relay.decentralia.fr,48.122,11.589 -relay.mappingbitcoin.com,43.6532,-79.3832 -nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832 -chorus.mikedilger.com:444,-36.8906,174.794 -nostr.wild-vibes.ts.net,48.8566,2.35222 -spamspamspamspam.rest,43.6532,-79.3832 -relay.wisp.talk,49.4543,11.0746 -relay.bornheimer.app,50.1109,8.68213 -nostr-dev.wellorder.net,45.5201,-122.99 -strfry.shock.network:443,39.0438,-77.4874 -nostr.mikoshi.de,52.52,13.405 -nostr.christiansass.de,50.1109,8.68213 -relay.14898.hns.one,42.8864,-78.8784 -porchlight.social,43.6532,-79.3832 -nostr.twinkle.lol,51.902,7.6657 -nostr.pbfs.io,50.4754,12.3683 -zencrown.relay2aeon.quest,52.3061,4.6907 -21milionidinostr.duckdns.org,45.519,9.08615 -test-relay.uid.ovh,48.5734,7.75211 -nexus.libernet.app,43.6532,-79.3832 -bitcoiner.social,47.6743,-117.112 -syb.lol,43.6532,-79.3832 -relay.directsponsor.net,42.8864,-78.8784 -budabit.nostr1.com,40.7057,-74.0136 -relay.budabit.club,49.4543,11.0746 -relay.monostr.com,50.4754,12.3683 -relay.rebelbase.social,50.965,5.50082 -nostr.hifish.org,47.4009,8.57814 -nostr.21crypto.ch,47.5356,8.73209 -nostr.emanuelemiani.it,45.778,8.79414 -relay.kizuna-miki.com,35.694,139.754 -relay.binaryrobot.com:443,43.6532,-79.3832 -spurline.safebox.dev,43.6532,-79.3832 -buzz.cashu.space,43.6532,-79.3832 -relay.plebeian.market,50.1109,8.68213 -relay.mleku.dev,32.7767,-96.797 -nostr.purpura.cloud,43.6532,-79.3832 -relay.binaryrobot.com,43.6532,-79.3832 -relay.paulstephenborile.com,49.4543,11.0746 -relay2.fiatdenier.com,50.1013,8.62643 -cs-relay.nostrdev.com,50.4754,12.3683 -nostr.davenov.com,50.1109,8.68213 -relay.nostrhub.fr,48.1045,11.6004 -nostr.spaceshell.xyz,43.6532,-79.3832 -relay.gorrdy.cz,50.1109,8.68213 -relay.nostrmap.net:443,60.1699,24.9384 -relay.getsafebox.app,43.6532,-79.3832 -relay.lanavault.space,60.1699,24.9384 -relay.veganostr.com,60.1699,24.9384 -prl.plus,54.9486,21.7283 -relay.nostr.blockhenge.com,39.0438,-77.4874 -nostr.quali.chat,60.1699,24.9384 -nostr.nodesmap.com,59.3327,18.0656 -relay.beginningend.com,35.2227,-97.4786 -relay2.0xprivacy.online,43.6532,-79.3832 -relay1.privkey.io,25.7617,-80.1918 -relay-dev.gulugulu.moe:443,43.6532,-79.3832 -relay.lotek-distro.com,43.6532,-79.3832 -relay.noeudlibre.fr,50.6924,3.20113 -relay.cyberguy.fyi,52.3676,4.90414 -nostr-relay.xbytez.io,50.6924,3.20113 -relay.kaleidoswap.com,47.3769,8.54169 -relay.bnos.space,43.6532,-79.3832 -purplerelay.com,43.6532,-79.3832 -hasky.chat,40.7862,-74.0743 -relay.snort.social,53.3498,-6.26031 -nostr.linky.fit,50.1109,8.68213 -nostr.plantroon.com,50.1013,8.62643 -testr.nymble.world,40.8054,-74.0241 -nostr.tac.lol,47.4748,-122.273 -cdn.satellite.earth,40.8302,-74.1299 -dm-test-strfry-discovery.samt.st,43.6532,-79.3832 -wisp.djorivaltech.com,43.6532,-79.3832 -nostr.trcjr.xyz,35.3821,-78.5486 -offchain.pub,39.1585,-94.5728 -nostras.app,38.7946,-106.535 -relay.manneken.brussels,49.4543,11.0746 -relay.laantungir.net,-19.4692,-42.5315 -nostr-relay.xbytez.io:443,50.6924,3.20113 -relay.gulugulu.moe,43.6532,-79.3832 -relayrs.notoshi.win,43.6532,-79.3832 -vault.iris.to,43.6532,-79.3832 -relay.getsafebox.app:443,43.6532,-79.3832 -top.testrelay.top,43.6532,-79.3832 -relay.t6x.host,40.8302,-74.1299 -relay.vrtmrz.net:443,43.6532,-79.3832 -relay.ohstr.com,43.6532,-79.3832 -nostr.wecsats.io,43.6532,-79.3832 -ynostr.yael.at,60.1699,24.9384 -infinity-signal-relay.digitalforlifeagency.workers.dev,43.6532,-79.3832 -nostr.islandarea.net:443,35.4669,-97.6473 -relay.favillakey.io,-27.4705,153.026 -nostr.self-determined.de,51.4379,14.2355 -nostr-pub.wellorder.net,45.5201,-122.99 -relay.trotters.cc,43.6532,-79.3832 -nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 -nostr2.girino.org,43.6532,-79.3832 -nostr.rtvslawenia.com,49.4543,11.0746 -nostr.stakey.net:443,52.6485,4.73787 -relay.artx.market,43.6548,-79.3885 -nos.lol,50.4754,12.3683 -relay.nearhood.co.uk,51.5134,-0.0890675 -nostr.pro.aw,43.6532,-79.3832 -relay.piazza.today,48.122,11.589 -relay.underorion.se,51.4998,-0.011072 -nostr.oxtr.dev:443,50.4754,12.3683 -relay.bitos.space,43.6532,-79.3832 -nostrelay.circum.space:443,52.2245,8.826 -relay.klabo.world,47.674,-122.122 -nostr.spicyz.io,43.6532,-79.3832 -relay.pyramid.li,47.4093,8.46503 -strfry.shock.network,39.0438,-77.4874 -relay.lightning.pub:443,39.0438,-77.4874 -nostr.snowbla.de,50.4754,12.3683 -relay.wavlake.com,41.2619,-95.8608 -relay2.orangesync.tech,40.7128,-74.006 -relay.mostro.network,40.8302,-74.1299 -relay-dev.gulugulu.moe,43.6532,-79.3832 -relay.orangesync.tech,40.7128,-74.006 -chat-relay.zap-work.com:443,43.6532,-79.3832 -relay.wasabiwallet.io,43.6532,-79.3832 -relay.kilombino.com,43.6532,-79.3832 -bridge.tagomago.me,42.3601,-71.0589 -relay.loveisbitcoin.com,43.6532,-79.3832 -relay.staging.plebeian.market,51.5072,-0.127586 -relay.nostrcade.com,37.79,-122.401 -relay.nostr.com,50.1109,8.68213 -relay02.lnfi.network,35.6764,139.65 -nostr.debate.report,50.1109,8.68213 -relay.gulugulu.moe:443,43.6532,-79.3832 -n.bogs.ccwu.cc,43.6532,-79.3832 -relay.twentyone.ist,43.7696,-79.3309 -nostr.mom:443,50.4754,12.3683 -relay.czas.plus,50.1109,8.68213 -relay.bullishbounty.com:443,43.6532,-79.3832 -no.str.cr,8.96171,-83.5246 -nostr.88mph.life,52.1941,-2.21905 -relay.trotters.cc:443,43.6532,-79.3832 -nostr.unkn0wn.world,47.4464,8.21073 -relay.dyne.org,49.0291,8.35705 -nostr.n7ekb.net,47.4941,-122.294 -nostr.sathoarder.com:443,48.5734,7.75211 -relay.guggero.org,46.5971,9.59652 -relay.bowlafterbowl.com,32.9483,-96.7299 -wheat.oslim.dev,43.6532,-79.3832 -nostr.myshosholoza.co.za,52.3676,4.90414 -nostr.tagomago.me,42.3601,-71.0589 -relay.snotr.nl:49999,51.9758,4.31389 -relay.lightning.pub,39.0438,-77.4874 -0x-nostr-relay.fly.dev,38.7946,-106.535 -relay.nostrconnect.com,41.5695,-83.9786 -relay-can.zombi.cloudrodion.com,43.6532,-79.3832 -relay.nostr.dev.br,48.1046,11.6002 -relay.mrmave.work,43.6532,-79.3832 -nostr.rtvslawenia.com:443,49.4543,11.0746 -nostr.data.haus,50.4754,12.3683 -nostr.chaima.info,50.1109,8.68213 -nostr.relay.hedwig.sh,60.1699,24.9384 -social.hns.one,42.8864,-78.8784 -relay.pacsarcade.org,32.7767,-96.797 -auth.nostr1.com,40.7057,-74.0136 -relayone.soundhsa.com:443,39.0997,-94.5786 -nostr.azzamo.net,52.2633,21.0283 -nostr.eu.freedomrelay.dev,49.0524,14.4347 -basspistol.org,49.0291,8.35696 -relay.nostrops.com,32.71,-96.6745 -dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 -schnorr.me,43.6532,-79.3832 -no.str.cr:443,8.96171,-83.5246 -nostr.snowbla.de:443,50.4754,12.3683 -relay.openspecs.uid.ovh,50.9871,2.12554 -nostr.nestoris.online,55.568,37.5853 -relay.plebeian.market:443,50.1109,8.68213 -relay.mwaters.net,50.9871,2.12554 -relay.conduit.market,38.7946,-106.535 -relay.wellorder.net,45.5201,-122.99 -relay.nostriot.com,41.5695,-83.9786 -nostr.tbxnetworx.de,47.8226,10.0064 -relay.zone667.com,60.1699,24.9384 -public.crostr.com,43.6532,-79.3832 -relay.colombiap2p.com,43.6532,-79.3832 -soloco.nl,43.6532,-79.3832 -relay.orly.dev,32.7767,-96.797 -relay01.lnfi.network,35.6764,139.65 -relay-fra.zombi.cloudrodion.com,48.8566,2.35222 -nostr.notribe.net,40.8302,-74.1299 -relay.shychat.lol,39.8965,-80.1792 -relay.cypherguide.org,1.32123,103.695 -strfry.bonsai.com,39.0438,-77.4874 -relay.decentnewsroom.com,50.4754,12.3683 -relay.atsocy.com,43.6532,-79.3832 -scores.brainstorm.world,39.0997,-94.5786 -relay.nostr.bond,40.1885,29.061 -nostr.fullstackcash.net,45.5201,-122.99 -nostr.islandarea.net,35.4669,-97.6473 -relay.illuminodes.com,43.6532,-79.3832 -relay.samt.st,40.8302,-74.1299 -relay.pprgb.app,52.52,13.405 -relay.jcera.eu,43.6532,-79.3832 -relay.nostreon.com,60.1699,24.9384 -myvoiceourstory.org,44.8484,-123.234 -relay.mypathtofire.de,42.8864,-78.8784 -nostr.stakey.net,52.6485,4.73787 -schnorr.me:443,43.6532,-79.3832 -conduit-congee.fly.dev,38.7946,-106.535 -offchain.bostr.online,43.6532,-79.3832 -staging.yabu.me,35.6096,139.73 nostr.synalysis.com,50.4754,12.3683 -relay.scuba323.com,40.8218,-74.45 -nostr.hoppe-relay.it.com,42.8864,-78.8784 -relay.paulstephenborile.com:443,49.4543,11.0746 -memlay.v0l.io,53.3498,-6.26031 -nostr-02.uid.ovh,50.9871,2.12554 -articles.layer3.news,37.3387,-121.885 -nostr.2b9t.xyz:443,34.0549,-118.243 -relay.chatbett.de,40.7128,-74.006 -relay.staging.plebeian.market:443,51.5072,-0.127586 -relay.nostr.black,43.6532,-79.3832 -relay.solomonstr.com,43.6532,-79.3832 -relay.nostr.net,43.6532,-79.3832 -dm-test-strfry-generic.samt.st,43.6532,-79.3832 -nostr.girino.org,43.6532,-79.3832 -bcast.girino.org,43.6532,-79.3832 -n.kojira.io,43.6532,-79.3832 -relay.sigit.io,50.4754,12.3683 -bruh.samt.st,43.6532,-79.3832 -relay.i9.eti.br,43.6532,-79.3832 -relay.degmods.com,50.4754,12.3683 -pymd.kamp.site,38.9072,-77.0369 -relay.internationalright-wing.org,-22.4692,-48.9875 -relay.earthly.city,34.1749,-118.54 -relay-us.zombi.cloudrodion.com,40.7862,-74.0743 -relay.yoinekodo.jp,43.6532,-79.3832 -cache.trustr.ing,43.6548,-79.3885 -relay2.angor.io,48.1046,11.6002 -relay.lanacoin-eternity.com:443,40.8302,-74.1299 -relay.nostar.org,43.6532,-79.3832 -nostr-kyomu-haskell.onrender.com,37.7775,-122.397 -relay.nostx.io,43.6532,-79.3832 -relay.sincensura.org,43.6532,-79.3832 -relay.qstr.app,50.1109,8.68213 -relay2.veganostr.com,60.1699,24.9384 -denimroad.feeds.relay.tools,38.6327,-90.1961 +relay.nostr-check.me,43.6532,-79.3832 +nostr.eu.freedomrelay.dev,49.0524,14.4347 +relay.44billion.net,43.6532,-79.3832 +nostr.debate.report,50.1109,8.68213 +relayrs.notoshi.win:443,43.6532,-79.3832 +top.testrelay.top,43.6532,-79.3832 +relay.satsmarkt.club,52.3676,4.90414 +nostr.plantroon.com:443,50.1013,8.62643 +relay.lightning.pub,39.0438,-77.4874 +nostr-rs-relay-ishosta.phamthanh.me,43.6532,-79.3832 +auth.nostr1.com,40.7057,-74.0136 +offchain.pub:443,39.1585,-94.5728 relay.fundstr.me,42.3601,-71.0589 -openrelay.ziomc.com,41.8967,12.4822 -nostrride.io,37.3986,-121.964 -relay.vrtmrz.net,43.6532,-79.3832 -relay-testnet.k8s.layer3.news,37.3387,-121.885 -relay.keykeeper.world,40.7824,-74.0711 -relay.minibolt.info,43.6532,-79.3832 +relay.pacsarcade.org,32.7767,-96.797 +relay.tapestry.ninja,40.8054,-74.0241 +nostr.mom:443,50.4754,12.3683 +strfry.bonsai.com,39.0438,-77.4874 +relay.zone667.com,60.1699,24.9384 +relay.directsponsor.net,42.8864,-78.8784 +relay.veganostr.com,60.1699,24.9384 +relay.nostrmap.net:443,60.1699,24.9384 +relay.nostrconnect.com,41.5695,-83.9786 +nostr.hifish.org,47.4009,8.57814 +nostr.dzo-hadar.ts.net,35.6764,139.65 +relay.openspecs.uid.ovh,50.9871,2.12554 +no.str.cr,8.96171,-83.5246 +nostr.mikoshi.de,51.2829,6.7827 +nostr.chaima.info,50.1109,8.68213 +relay.wavlake.com:443,41.2619,-95.8608 +nostr-relay.xbytez.io,50.6924,3.20113 +relay5.bitransfer.org,43.6532,-79.3832 +relay.degmods.com,50.4754,12.3683 +nostr-relay.xbytez.io:443,50.6924,3.20113 +relay.lanacoin-eternity.com,40.8302,-74.1299 +relay.illuminodes.com,43.6532,-79.3832 +nostr.overmind.lol,43.6532,-79.3832 +nostr.hetuniversum.nl,43.6532,-79.3832 +relay.samt.st,40.8302,-74.1299 +nos.lol,50.4754,12.3683 +relay.valleirug.nl,52.0274,5.62427 +relay.nostar.org,43.6532,-79.3832 +wheat.oslim.dev,43.6532,-79.3832 +nostr.thebiglake.org,32.71,-96.6745 +relay.budabit.club,49.4543,11.0746 +cdn.satellite.earth,40.8302,-74.1299 +n.kojira.io,43.6532,-79.3832 +dm-test-strfry-discovery.samt.st,43.6532,-79.3832 +relay.nostrfy.org,35.6916,139.768 +nostr.janx.com,43.6532,-79.3832 +nostr.mad-social.net,50.4754,12.3683 +nostr.girino.org,43.6532,-79.3832 +relay.jmoose.rocks,32.7767,-96.797 +nostr.2b9t.xyz:443,34.0549,-118.243 +chat-relay.zap-work.com,43.6532,-79.3832 +relayrs.notoshi.win,43.6532,-79.3832 +relay1.privkey.io,25.7617,-80.1918 +nostr.emanuelemiani.it,45.778,8.79414 +relay.snort.social,53.3498,-6.26031 +relay.ohstr.com,43.6532,-79.3832 +relay.lightning.pub:443,39.0438,-77.4874 +nostr.islandarea.net:443,35.4669,-97.6473 +nostr.tagomago.me,42.3601,-71.0589 +fanfares.nostr1.com:443,40.7057,-74.0136 +nostr.snowbla.de:443,50.4754,12.3683 +vault.iris.to:443,43.6532,-79.3832 +test.relays.nuts.cash,52.3676,4.90414 +relay-us.zombi.cloudrodion.com,40.7862,-74.0743 +relay.nostrmap.net,60.1699,24.9384 +relay.nostrcade.com,37.79,-122.401 +relay.crovaxon.de,53.0777,7.41516 +relay.flashapp.me,43.6548,-79.3885 +nostr.oxtr.dev:443,50.4754,12.3683 +relay.decentnewsroom.com,50.4754,12.3683 +relay.liberbitworld.org,43.6532,-79.3832 +relayone.soundhsa.com:443,39.0997,-94.5786 bucket.coracle.social,37.7775,-122.397 -nrl.ceskar.xyz,50.5145,16.0119 +relay.lanavault.space,60.1699,24.9384 +public.crostr.com,43.6532,-79.3832 +relay.thehakon.com,43.6532,-79.3832 +relay.wisp.talk,49.4543,11.0746 +nostr.trevena.info,43.6532,-79.3832 +relay.qstr.app,50.1109,8.68213 +relay.chorus.community:443,50.1109,8.68213 +relay.plebeian.market:443,50.1109,8.68213 +relay.nostrops.com,32.71,-96.6745 +nostr.chaima.info:443,50.1109,8.68213 +relay.nostr.dev.br,48.1046,11.6002 +relay.camelus.app,45.5201,-122.99 +nostr-pub.wellorder.net,45.5201,-122.99 +cyberspace.nostr1.com,40.7057,-74.0136 +relay2.angor.io,48.1046,11.6002 +relay.edufeed.org:443,49.4521,11.0767 +relay.bnos.space,43.6532,-79.3832 +groups.beginningend.com,35.2227,-97.4786 +relay.gulugulu.moe:443,43.6532,-79.3832 +relayone.soundhsa.com,39.0997,-94.5786 +relay.noeudlibre.fr,50.6924,3.20113 +nostr.christiansass.de,50.1109,8.68213 +chat-relay.zap-work.com:443,43.6532,-79.3832 +conduit-congee.fly.dev,38.7946,-106.535 +relay.satmaxt.xyz:443,43.6532,-79.3832 +relay.rebelbase.social,50.965,5.50082 +nostr.relay.hedwig.sh,60.1699,24.9384 +dev.relay.stream,43.6532,-79.3832 +relay.edufeed.org,49.4521,11.0767 +relay.s-w.art,43.6532,-79.3832 +nostr.wecsats.io,43.6532,-79.3832 +relay.nostreon.com,60.1699,24.9384 +relay.agentry.com,42.8864,-78.8784 +denimroad.feeds.relay.tools,38.6327,-90.1961 +relay.artx.market,43.6548,-79.3885 +nostr.unkn0wn.world,47.4464,8.21073 +relay.mappingbitcoin.com,43.6532,-79.3832 +relay-na1.metanomalist.com,43.6532,-79.3832 +nostr.2b9t.xyz,34.0549,-118.243 +relay.artio.inf.unibe.ch,46.9501,7.43678 +relay.mostro.network,40.8302,-74.1299 +relay.nmail.li,50.9871,2.12554 +bruh.samt.st,43.6532,-79.3832 +relay.conduit.market,38.7946,-106.535 +relay.mwaters.net,50.9871,2.12554 +nostr.twinkle.lol,51.902,7.6657 +relay.selfrule.co.za,43.6532,-79.3832 +relay.btcforplebs.com,43.6532,-79.3832 +relay.plebeian.market,50.1109,8.68213 +nostr.n7ekb.net,47.4941,-122.294 +nostr.easycryptosend.it,43.6532,-79.3832 +relay.primal.net,43.6532,-79.3832 +dev-relay.nostreon.com,60.1699,24.9384 +nostr-kyomu-haskell.onrender.com,37.7775,-122.397 +relay.kilombino.com,43.6532,-79.3832 +nostr.bitcoiner.social:443,47.6743,-117.112 +relay.underorion.se,51.4998,-0.011072 +relay.paulstephenborile.com:443,49.4543,11.0746 +relay.nostr.net,43.6532,-79.3832 +syb.lol,43.6532,-79.3832 +articles.layer3.news,37.3387,-121.885 +ec1.f7z.io,60.1699,24.9384 +relay.manneken.brussels,49.4543,11.0746 +nostr.tac.lol,47.4748,-122.273 +relay.agora.social,50.7383,15.0648 +nostr.oxtr.dev,50.4754,12.3683 +relay.hivetalk.org,40.8302,-74.1299 +income-strongly-active-systematic.trycloudflare.com,43.6532,-79.3832 +nostr.jcera.eu,43.6532,-79.3832 +relay-dev.satlantis.io,39.0438,-77.4874 +nostr.sovereignservices.xyz,43.6532,-79.3832 +relay.lacrypta.ar,43.6532,-79.3832 +nostr.data.haus,50.4754,12.3683 +relay.monostr.com,50.4754,12.3683 +relay.nostrhub.fr,48.1045,11.6004 +relay.loveisbitcoin.com,43.6532,-79.3832 +portal-relay.pareto.space,53.5488,9.98717 +relay.minibolt.info,43.6532,-79.3832 +relay.czas.plus,50.1109,8.68213 +relay2.veganostr.com,60.1699,24.9384 +relay.gorrdy.cz,50.1109,8.68213 +relay.paulstephenborile.com,49.4543,11.0746 +temp.iris.to,43.6532,-79.3832 +relay.formstr.app,43.6532,-79.3832 +talon.quest,43.6532,-79.3832 +relay.ltgnet.work:8443,51.0511,-114.075 +relay.guggero.org,46.5971,9.59652 +relay.nostr.bond,40.1885,29.061 +bitchat.nostr1.com,40.7057,-74.0136 +prl.plus,54.9486,21.7283 +relay.sharegap.net,43.6532,-79.3832 +nostr.21crypto.ch,47.5356,8.73209 +relay.jcera.eu,43.6532,-79.3832 +r.senx.io,50.6924,3.20113 +porchlight.social,43.6532,-79.3832 +bread.nostrsms.com,40.8218,-74.45 +relay.nostrian-conquest.com,41.223,-111.974 +relay.getsafebox.app:443,43.6532,-79.3832 +nostr.quali.chat,60.1699,24.9384 +relay.laantungir.net,-19.4692,-42.5315 +relay.hivescope.xyz,40.4332,-3.62431 +nostr.fullstackcash.net,45.5201,-122.99 +nip85-staging.nosfabrica.com,39.0997,-94.5786 +relay-fra.zombi.cloudrodion.com,48.8566,2.35222 +relay.nostr.black,43.6532,-79.3832 +nostr.trepechov.com,49.4543,11.0746 +relay.binaryrobot.com:443,43.6532,-79.3832 +basspistol.org,49.0291,8.35696 +free.relayted.de,50.1109,8.68213 +relay.beginningend.com,35.2227,-97.4786 +nostrelay.circum.space:443,52.2245,8.826 +strfry.bonsai.com:443,39.0438,-77.4874 +relay.wasabiwallet.io,43.6532,-79.3832 +relay.scuba323.com,40.8218,-74.45 +vidar.nestoris.online,52.3676,4.90414 +nostr.pbfs.io:443,50.4754,12.3683 +relay.pyramid.li,47.4093,8.46503 +relay.olas.app,60.1699,24.9384 +relay.internationalright-wing.org,-22.4692,-48.9875 +nostr-relay.corb.net,39.6478,-104.988 +relay.plebdevs.com,40.7128,-74.006 +relay.ditto.pub,43.6532,-79.3832 +relay.i9.eti.br,43.6532,-79.3832 +nostr.pro.aw,43.6532,-79.3832 +relay.hazeem.xyz,43.6532,-79.3832 +zencrown.relay2aeon.quest,52.3061,4.6907 +relay.npubhaus.com,43.6532,-79.3832 +relay.nostriot.com,41.5695,-83.9786 +cs-relay.nostrdev.com,50.4754,12.3683 +relay.substation.ninja,43.6532,-79.3832 +relay1.nostrchat.io,60.1699,24.9384 +nostr-relay.cbrx.io,43.6532,-79.3832 +nostr.stakey.net:443,52.6485,4.73787 +relayone.geektank.ai:443,39.0997,-94.5786 +adre.su,59.9311,30.3609 +nostr.islandarea.net,35.4669,-97.6473 +relay.cypherguide.org,1.32123,103.695 +fanfares.nostr1.com,40.7057,-74.0136 +nostrride.io,43.6532,-79.3832 relay.littlebitstudios.com,34.8688,-86.5502 +relay.snotr.nl:49999,51.9758,4.31389 +relay02.lnfi.network,35.6764,139.65 +nostr.rtvslawenia.com,49.4543,11.0746 +relay.staging.plebeian.market:443,51.5072,-0.127586 +relay.earthly.city,34.1749,-118.54 +relay.binaryrobot.com,43.6532,-79.3832 +relay.artiostr.ch,43.6532,-79.3832 +nostr.nestoris.online,55.568,37.5853 +nostr.liberty.fans,36.8767,-89.5879 +n.bogs.ccwu.cc,43.6532,-79.3832 +relay.t6x.host,40.8302,-74.1299 +relay.nostrverse.net,43.6532,-79.3832 +relay.bullishbounty.com:443,43.6532,-79.3832 +nostr.mom,50.4754,12.3683 +scores.brainstorm.world,39.0997,-94.5786 +nostr.tabordalab.com,60.1699,24.9384 +relay.favillakey.io,-27.4705,153.026 +relay.nostr.blockhenge.com,39.0438,-77.4874 +relay.bullishbounty.com,43.6532,-79.3832 +vm-1734.lnvps.cloud,53.3498,-6.26031 +nostr.bitcoiner.social,47.6743,-117.112 +budabit.nostr1.com,40.7057,-74.0136 +soloco.nl,43.6532,-79.3832 +relay.iyou.me,64.1466,-21.9426 +dm-test-nostr-rs-42-disabled.samt.st,43.6532,-79.3832 +relay.cloistr.xyz,42.337,-77.3178 +relay.nearhood.co.uk,51.5134,-0.0890675 +offchain.bostr.online,43.6532,-79.3832 +relay.keykeeper.world,40.7824,-74.0711 +nostras.app,38.7946,-106.535 +nostr.wild-vibes.ts.net,48.8566,2.35222 +nostr-relayflare.enpitsulin.workers.dev,43.6532,-79.3832 +relay.lanacoin-eternity.com:443,40.8302,-74.1299 +greenart7c3.feeds.relay.tools,38.6327,-90.1961 +relay.mitosystem.net,-23.5558,-46.6396 +nostr.snowbla.de,50.4754,12.3683 +cs-relay.nostrdev.com:443,50.4754,12.3683 +nostrelay.circum.space,52.2245,8.826 +0x-nostr-relay.fly.dev,38.7946,-106.535 +nip85.nosfabrica.com,39.0997,-94.5786 +chorus.mikedilger.com:444,-36.8906,174.794 +nostr.rtvslawenia.com:443,49.4543,11.0746 +yabu.me,35.6096,139.73 +nostr.planix.org,43.6532,-79.3832 +relay2.orangesync.tech,40.7128,-74.006 +relay.dyne.org,49.0291,8.35705 +relay.aarpia.com,37.3986,-121.964 +relay.fiatdenier.com,43.7787,-79.5393 +relay.wisp.talk:443,49.4543,11.0746 +nostr.azzamo.net,52.2633,21.0283 +relay.satmaxt.xyz,43.6532,-79.3832 +relay.jmoose.rocks:443,32.7767,-96.797 +nostr-01.uid.ovh,50.9871,2.12554 +nostr-relay.corb.net:443,39.6478,-104.988 +buzz.cashu.space,43.6532,-79.3832 +dev-relay.nostr.space,43.6532,-79.3832 +relay.cyberguy.fyi,52.3676,4.90414 +freelay.sovbit.host,60.1699,24.9384 +hasky.chat,40.7862,-74.0743 +treuzkas.branruz.com,48.8575,2.35138 +nostr-verified.wellorder.net,45.5201,-122.99 +nexus.libernet.app,43.6532,-79.3832 +relay.gulugulu.moe,43.6532,-79.3832 +bitcoiner.social,47.6743,-117.112 +nostr.stakey.net,52.6485,4.73787 +schnorr.me,43.6532,-79.3832 +nos.xmark.cc,50.6916,3.20151 +vault.iris.to,43.6532,-79.3832 +nostr.nodesmap.com,59.3327,18.0656 +nostr.88mph.life,52.1941,-2.21905 +nostr.yutakobayashi.com,43.6532,-79.3832 +relay.layer.systems,43.6532,-79.3832 +relay-can.zombi.cloudrodion.com,43.6532,-79.3832 +bcast.girino.org,43.6532,-79.3832 +relay.chorus.community,50.1109,8.68213 +strfry.shock.network,39.0438,-77.4874 +ynostr.yael.at,60.1699,24.9384 +relay.staging.plebeian.market,51.5072,-0.127586 +cache.trustr.ing,43.6548,-79.3885 +nos.lol:443,50.4754,12.3683 +relay.shadowbip.com,54.352,18.6466 +nostr.computingcache.com,45.5341,-122.956 +memlay.v0l.io,53.3498,-6.26031 +relay.colombiap2p.com,43.6532,-79.3832 +dm-test-strfry-generic.samt.st,43.6532,-79.3832 +borgar.lol,52.3676,4.90414 +relay.wellorder.net,45.5201,-122.99 +node.kommonzenze.de,49.4521,11.0767 +relay.ru.ac.th,13.7607,100.627 +nostr.davenov.com,50.1109,8.68213 +nostr.linky.fit,50.1109,8.68213 +nostr-relay.zimage.com,34.282,-118.439 +relay.nostx.io,43.6532,-79.3832 +relay.dwadziesciajeden.pl,52.2297,21.0122 +relay.pornstr.net,53.3498,-6.26031 +nostr-rs-relay.dev.fedibtc.com,39.0438,-77.4874 +schnorr.me:443,43.6532,-79.3832 +nostr.plantroon.com,50.1013,8.62643 +relay.routstr.com,59.4016,17.9455 +relay.shychat.lol,39.8965,-80.1792 +nr.yay.so,46.2126,6.1154 +pymd.kamp.site,38.9072,-77.0369 +relay.decentralia.fr,48.122,11.589 +relay.klabo.world,47.674,-122.122 +spurline.safebox.dev,43.6532,-79.3832 +bridge.tagomago.me,42.3601,-71.0589 +inbox.scuba323.com,40.8218,-74.45 +no.str.cr:443,8.96171,-83.5246 +nostr2.girino.org:443,43.6532,-79.3832 +nostr-dev.wellorder.net,45.5201,-122.99 +relay.mypathtofire.de,42.8864,-78.8784 +nostr-02.uid.ovh,50.9871,2.12554 +support.flotilla.social,32.9483,-96.7299 +relay.mccormick.cx,52.3563,4.95714 +relay.getsafebox.app,43.6532,-79.3832 +relay.bitos.space,43.6532,-79.3832 +nostrcity-club.fly.dev,38.7946,-106.535 +spamspamspamspam.rest,43.6532,-79.3832 +relay.solomonstr.com,43.6532,-79.3832 +relay.angor.io,48.1046,11.6002 +relay.islandbitcoin.com,20.3898,78.0903 +relay.bornheimer.app,50.1109,8.68213 +relay.atsocy.com,43.6532,-79.3832 +relay.wavlake.com,41.2619,-95.8608 +relay.nostrcheck.me,43.6532,-79.3832 +relay.bowlafterbowl.com,32.9483,-96.7299 +relay.novospes.com,43.6532,-79.3832 +nostr.spaceshell.xyz,43.6532,-79.3832 +purplerelay.com,43.6532,-79.3832 +relay.satsapp.me,50.4754,12.3683 +nostr.pbfs.io,50.4754,12.3683 +relay2.nostrasia.net,43.6532,-79.3832 +relay.piazza.today,48.122,11.589 +relay.nostr.space,43.6532,-79.3832 relay.duck1123.com,43.6532,-79.3832 +relay.kizuna-miki.com,35.694,139.754 +relay.dreamith.to,43.6532,-79.3832 +relay.chatbett.de,40.7128,-74.006 +public.crostr.com:443,43.6532,-79.3832 +relay.satlantis.io,39.0438,-77.4874 +nostr2.girino.org,43.6532,-79.3832 +relay.lotek-distro.com,43.6532,-79.3832 +nostr-rs-relay.dev.fedibtc.com:443,39.0438,-77.4874 +nostrrelay.taylorperron.com,45.5029,-73.5723 +nosflare-leefcore.leefcore.workers.dev,43.6532,-79.3832 +nostr.spicyz.io,43.6532,-79.3832 +x.kojira.io,43.6532,-79.3832 +relay.veganostr.com:443,60.1699,24.9384 +nostr.alex71btc.com,43.6532,-79.3832 +relay.thewired.app,39.0438,-77.4874 +relay.getvia.xyz,60.1699,24.9384 +relayone.geektank.ai,39.0997,-94.5786 +nostr.hoppe-relay.it.com,42.8864,-78.8784 +relay2.0xprivacy.online,43.6532,-79.3832 +nostr-01.yakihonne.com,1.32123,103.695 +offchain.pub,39.1585,-94.5728 +nostrcity-club.fly.dev:443,38.7946,-106.535 +testr.nymble.world,40.8054,-74.0241 +relay.fountain.fm,43.6532,-79.3832 +relay.nostr.com,50.1109,8.68213 From 07d4c546ef85c3339301c5d8e05f2e58dd2e3938 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:32:09 +0100 Subject: [PATCH 03/17] Improve voice note waveform contrast in light mode Draw unplayed waveform bars in the surface text colour and played bars in the theme accent, and give the duration label an explicit colour so it stays readable in dark mode. --- .../java/com/bitchat/android/ui/media/VoiceNotePlayer.kt | 2 +- .../java/com/bitchat/android/ui/media/WaveformViews.kt | 9 +++++++-- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt index 1f3f567ed..131d1605b 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/VoiceNotePlayer.kt @@ -111,7 +111,7 @@ fun VoiceNotePlayer( onSeek = seekTo ) val durText = if (durationMs > 0) String.format("%02d:%02d", (durationMs / 1000) / 60, (durationMs / 1000) % 60) else "--:--" - Text(text = durText, fontFamily = BitchatFontFamily, fontSize = 12.sp) + Text(text = durText, fontFamily = BitchatFontFamily, fontSize = 12.sp, color = MaterialTheme.colorScheme.onSurface) } } diff --git a/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt b/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt index 64261d47a..8c598a62a 100644 --- a/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt +++ b/app/src/main/java/com/bitchat/android/ui/media/WaveformViews.kt @@ -2,6 +2,7 @@ package com.bitchat.android.ui.media import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.snapshots.SnapshotStateList @@ -50,6 +51,10 @@ fun WaveformPreview( path: String, sendProgress: Float?, playbackProgress: Float?, + // Unplayed bars follow the surrounding text colour so they read on any bubble tint or + // surface; the played portion uses the theme accent, which is tuned per light/dark scheme. + baseColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.35f), + playedColor: Color = MaterialTheme.colorScheme.primary, onLoaded: ((FloatArray) -> Unit)? = null, onSeek: ((Float) -> Unit)? = null ) { @@ -74,10 +79,10 @@ fun WaveformPreview( modifier = modifier, samples = stateSamples, fillProgress = if (stateSamples.isEmpty()) 0f else progress, - baseColor = Color(0x2200FF7F), + baseColor = baseColor, fillColor = when { sendProgress != null -> Color(0xFF1E88E5) // blue while sending - else -> Color(0xFF00C851) // green during playback + else -> playedColor }, onSeek = onSeek ) From 5996eb84c7c721dd5f821ace788166caaea317ca Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 25 Oct 2026 14:54:47 +0100 Subject: [PATCH 04/17] Add spam filter for public mesh and geohash chats Hide public messages from a sender who exceeds per-user token buckets (6 messages with 1 refilled every 10 s, 600 characters per minute, 2 links per minute), posts a third near-copy of a recent message, or sends a message that repeats a character or phrase (character autocorrelation). Buckets run on message timestamps, clamped to the last hour, so relay backfill and mesh sync bursts are not mistaken for floods. The filter applies to mesh public and channel messages at transport admission, to geohash messages in GeohashMessageHandler, and to the watch's mesh intake. Private messages are not filtered. All limits live in SpamFilterConfig; a Settings toggle (on by default) disables the filter. --- .../com/bitchat/android/BitchatApplication.kt | 3 + .../android/nostr/GeohashMessageHandler.kt | 8 +- .../services/IncomingMessageAdmission.kt | 11 +- .../com/bitchat/android/spam/LinkCounter.kt | 14 ++ .../android/spam/RepetitionDetector.kt | 55 +++++ .../com/bitchat/android/spam/SpamFilter.kt | 149 +++++++++++ .../bitchat/android/spam/SpamFilterConfig.kt | 57 +++++ .../android/spam/SpamFilterPreferences.kt | 43 ++++ .../android/spam/TokenBucketSchedule.kt | 77 ++++++ .../java/com/bitchat/android/ui/AboutSheet.kt | 18 ++ app/src/main/res/values/strings.xml | 2 + .../bitchat/android/spam/SpamFilterTest.kt | 231 ++++++++++++++++++ .../NostrBackgroundEventProcessorTest.kt | 6 + .../services/IncomingMessageAdmissionTest.kt | 24 ++ wear/build.gradle.kts | 3 + .../com/bitchat/watch/mesh/WearMeshService.kt | 3 + 16 files changed, 702 insertions(+), 2 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/spam/LinkCounter.kt create mode 100644 app/src/main/java/com/bitchat/android/spam/RepetitionDetector.kt create mode 100644 app/src/main/java/com/bitchat/android/spam/SpamFilter.kt create mode 100644 app/src/main/java/com/bitchat/android/spam/SpamFilterConfig.kt create mode 100644 app/src/main/java/com/bitchat/android/spam/SpamFilterPreferences.kt create mode 100644 app/src/main/java/com/bitchat/android/spam/TokenBucketSchedule.kt create mode 100644 app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt diff --git a/app/src/main/java/com/bitchat/android/BitchatApplication.kt b/app/src/main/java/com/bitchat/android/BitchatApplication.kt index 7b5926efc..b03bdd664 100644 --- a/app/src/main/java/com/bitchat/android/BitchatApplication.kt +++ b/app/src/main/java/com/bitchat/android/BitchatApplication.kt @@ -45,6 +45,9 @@ class BitchatApplication : Application() { com.bitchat.android.nostr.NostrIdentityBridge.getCurrentNostrIdentity(this) } catch (_: Exception) { } + // Restore the spam filter toggle before transports start admitting public messages + com.bitchat.android.spam.SpamFilterPreferences.init(this) + // Initialize theme preference ThemePreferenceManager.init(this) diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index a43194cbf..90fcfcfdc 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -3,6 +3,7 @@ package com.bitchat.android.nostr import android.app.Application import android.util.Log import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.spam.SpamFilter import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -14,7 +15,7 @@ import java.util.Date * GeohashMessageHandler * - Processes kind=20000 Nostr events for geohash channels * - Updates repository for participants + nicknames - * - Emits messages to MessageManager + * - Hides spam via [SpamFilter], then emits messages to MessageManager */ class GeohashMessageHandler( private val application: Application, @@ -111,6 +112,11 @@ class GeohashMessageHandler( if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null } catch (_: Exception) { null } ) + val verdict = SpamFilter.shared.check(msg, SpamFilter.nostrSenderKey(pubkey)) + if (verdict.isSpam) { + Log.d(TAG, "Hiding geohash message ${event.id.take(8)}...: $verdict") + return@launch + } addChannelMessage("geo:$subscribedGeohash", msg) } catch (e: Exception) { Log.e(TAG, "onEvent error: ${e.message}") diff --git a/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt b/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt index dc76a57dc..aa4706cd6 100644 --- a/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt +++ b/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt @@ -1,6 +1,7 @@ package com.bitchat.android.services import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.spam.SpamFilter import kotlinx.coroutines.runBlocking /** @@ -8,7 +9,8 @@ import kotlinx.coroutines.runBlocking * * Private-message admission is authoritative: a duplicate or a message rejected while panic mode * is wiping state must not continue to UI delegates, unread tracking, haptics, or notifications. - * Public and channel messages retain their existing best-effort behavior if state reflection fails. + * Public and channel messages retain their existing best-effort behavior if state reflection fails, + * and are refused (no timeline row, no UI callback) when the spam filter hides them. */ internal object IncomingMessageAdmission { fun admitToAppState(message: BitchatMessage): Boolean = try { @@ -24,6 +26,8 @@ internal object IncomingMessageAdmission { } } + isHiddenAsSpam(message) -> false + message.channel != null -> { AppStateStore.addChannelMessage(message.channel, message) true @@ -39,4 +43,9 @@ internal object IncomingMessageAdmission { // bypass private-message admission when persistence or canonicalization fails. !message.isPrivate } + + private fun isHiddenAsSpam(message: BitchatMessage): Boolean { + if (message.sender == "system") return false + return SpamFilter.shared.check(message, SpamFilter.meshSenderKey(message)).isSpam + } } diff --git a/app/src/main/java/com/bitchat/android/spam/LinkCounter.kt b/app/src/main/java/com/bitchat/android/spam/LinkCounter.kt new file mode 100644 index 000000000..b36b90bc5 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/spam/LinkCounter.kt @@ -0,0 +1,14 @@ +package com.bitchat.android.spam + +/** + * Counts links the way the chat linkifies them: scheme URLs, `www.` hosts and bare domains + * such as `example.com/path`. Pure Kotlin so it also runs on the watch and in JVM tests. + */ +internal object LinkCounter { + + private val LINK = Regex( + """(?i)(?]+|www\.[^\s<>]+|[a-z0-9\-]+(?:\.[a-z0-9\-]+)*\.[a-z]{2,}(?:/[^\s<>]*)?)(?![\w@])""" + ) + + fun count(text: String): Int = LINK.findAll(text).count() +} diff --git a/app/src/main/java/com/bitchat/android/spam/RepetitionDetector.kt b/app/src/main/java/com/bitchat/android/spam/RepetitionDetector.kt new file mode 100644 index 000000000..7d6fd446a --- /dev/null +++ b/app/src/main/java/com/bitchat/android/spam/RepetitionDetector.kt @@ -0,0 +1,55 @@ +package com.bitchat.android.spam + +/** + * Character autocorrelation measures for repeated-content spam. + * + * Text is compared as Unicode code points after [normalize], so case, spacing and emoji + * surrogate pairs don't hide a repeat. + */ +internal object RepetitionDetector { + + private val WHITESPACE = Regex("\\s+") + + /** Lowercases, collapses whitespace and caps the length to [maxLength] code points. */ + fun normalize(text: String, maxLength: Int): IntArray { + val codePoints = text.lowercase().replace(WHITESPACE, " ").trim().codePoints().toArray() + return if (codePoints.size > maxLength) codePoints.copyOf(maxLength) else codePoints + } + + /** + * Highest autocorrelation of [text] with itself over shifts 1..n/2: the fraction of + * overlapping positions where `text[i] == text[i + lag]`. A pattern repeated back to back + * ("aaaa", "buy now buy now") scores 1.0; ordinary prose stays well below 0.3. + */ + fun selfSimilarity(text: IntArray): Double { + val n = text.size + var best = 0.0 + for (lag in 1..n / 2) { + val overlap = n - lag + var matches = 0 + for (i in 0 until overlap) if (text[i] == text[i + lag]) matches++ + val score = matches.toDouble() / overlap + if (score > best) best = score + } + return best + } + + /** + * Highest cross-correlation of two texts over every alignment: matching positions divided by + * the longer length, so 1.0 means identical and a short text never matches a long one. + */ + fun crossSimilarity(a: IntArray, b: IntArray): Double { + if (a.isEmpty() || b.isEmpty()) return 0.0 + val longest = maxOf(a.size, b.size) + var best = 0 + for (shift in -(b.size - 1) until a.size) { + val from = maxOf(0, shift) + val to = minOf(a.size, b.size + shift) + if (to - from <= best) continue + var matches = 0 + for (i in from until to) if (a[i] == b[i - shift]) matches++ + if (matches > best) best = matches + } + return best.toDouble() / longest + } +} diff --git a/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt b/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt new file mode 100644 index 000000000..0fd1b294d --- /dev/null +++ b/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt @@ -0,0 +1,149 @@ +package com.bitchat.android.spam + +import com.bitchat.android.model.BitchatMessage +import com.bitchat.android.model.BitchatMessageType +import kotlin.math.abs + +enum class SpamVerdict { + ALLOWED, + MESSAGE_RATE, + CHARACTER_RATE, + LINK_RATE, + REPEATED_MESSAGE, + REPEATED_CHARACTERS; + + val isSpam: Boolean get() = this != ALLOWED +} + +/** + * Decides whether an incoming public message should be hidden. + * + * Each sender gets three token buckets (messages, characters, links; see [SpamFilterConfig]) + * plus two repetition checks: a message that repeats itself internally, and a message that is a + * near-copy of the sender's recent history. Hidden messages never spend tokens, but they do + * enter the repetition history so a spammer cannot cycle the same text. + * + * Verdicts are remembered by message ID, so the same message arriving over BLE and Wi-Fi Aware, + * or through both geohash handlers, is judged once and counted once. + */ +class SpamFilter( + private val config: SpamFilterConfig = SpamFilterConfig(), + private val clock: () -> Long = System::currentTimeMillis +) { + @Volatile + var enabled: Boolean = true + + private class HistoryEntry(val timeMs: Long, val text: IntArray) + + private inner class SenderState { + val rate = TokenBucketSchedule( + listOf(config.messageBucket, config.characterBucket, config.linkBucket) + ) + val history = ArrayDeque() + } + + private val senders = object : LinkedHashMap(64, 0.75f, true) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = + size > config.maxTrackedSenders + } + + private val verdicts = object : LinkedHashMap(256, 0.75f, false) { + override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = + size > config.maxRememberedVerdicts + } + + /** Checks a received message; [senderKey] must identify the author stably within a transport. */ + fun check(message: BitchatMessage, senderKey: String): SpamVerdict = check( + senderKey = senderKey, + messageId = message.id, + content = message.content, + timestampMs = message.timestamp.time, + isText = message.type == BitchatMessageType.Message + ) + + fun check( + senderKey: String, + messageId: String, + content: String, + timestampMs: Long, + isText: Boolean = true + ): SpamVerdict { + if (!enabled) return SpamVerdict.ALLOWED + synchronized(this) { + verdicts[messageId]?.let { return it } + + val now = clock() + val oldestTrusted = now - config.maxBackdateMs + val time = timestampMs.coerceIn(oldestTrusted, now) + val state = senders.getOrPut(senderKey) { SenderState() } + state.rate.prune(oldestTrusted) + + val repetition = config.repetition + val text = if (isText) RepetitionDetector.normalize(content, repetition.maxAnalyzedLength) else null + val verdict = when { + text != null && hasRepeatedCharacters(text) -> SpamVerdict.REPEATED_CHARACTERS + text != null && repeatsHistory(state, text, time) -> SpamVerdict.REPEATED_MESSAGE + else -> admitRate(state, content, time, isText) + } + + if (text != null) { + state.history.addLast(HistoryEntry(time, text)) + while (state.history.size > repetition.historySize) state.history.removeFirst() + } + verdicts[messageId] = verdict + return verdict + } + } + + /** Forgets all senders, e.g. when the filter is toggled or the app is wiped. */ + fun reset() { + synchronized(this) { + senders.clear() + verdicts.clear() + } + } + + private fun hasRepeatedCharacters(text: IntArray): Boolean { + val repetition = config.repetition + return text.size >= repetition.minRepeatedCharactersLength && + RepetitionDetector.selfSimilarity(text) >= repetition.repeatedCharactersThreshold + } + + private fun repeatsHistory(state: SenderState, text: IntArray, timeMs: Long): Boolean { + val repetition = config.repetition + if (text.size < repetition.minRepeatedMessageLength) return false + val copies = state.history.count { entry -> + abs(entry.timeMs - timeMs) <= repetition.historyWindowMs && + lengthsCanMatch(entry.text.size, text.size) && + RepetitionDetector.crossSimilarity(entry.text, text) >= repetition.repeatedMessageSimilarity + } + return copies > repetition.allowedRepeats + } + + // Cross-similarity is bounded by shorter/longer, so skip pairs that can never reach it. + private fun lengthsCanMatch(a: Int, b: Int): Boolean = + minOf(a, b).toDouble() / maxOf(a, b) >= config.repetition.repeatedMessageSimilarity + + private fun admitRate(state: SenderState, content: String, timeMs: Long, isText: Boolean): SpamVerdict { + val characters = if (isText) content.codePointCount(0, content.length).toDouble() else 0.0 + val links = if (isText) LinkCounter.count(content).toDouble() else 0.0 + return when (state.rate.tryAdmit(timeMs, doubleArrayOf(1.0, characters, links))) { + -1 -> SpamVerdict.ALLOWED + 0 -> SpamVerdict.MESSAGE_RATE + 1 -> SpamVerdict.CHARACTER_RATE + else -> SpamVerdict.LINK_RATE + } + } + + companion object { + /** Process-wide filter shared by every transport and geohash handler. */ + val shared = SpamFilter() + + /** Key for public mesh and mesh channel messages. */ + fun meshSenderKey(message: BitchatMessage): String = + "mesh:" + (message.senderPeerID ?: message.sender) + + /** Key for geohash messages, by the sender's per-geohash Nostr pubkey. */ + fun nostrSenderKey(pubkeyHex: String): String = "nostr:" + pubkeyHex.lowercase() + } +} diff --git a/app/src/main/java/com/bitchat/android/spam/SpamFilterConfig.kt b/app/src/main/java/com/bitchat/android/spam/SpamFilterConfig.kt new file mode 100644 index 000000000..b19f0b38f --- /dev/null +++ b/app/src/main/java/com/bitchat/android/spam/SpamFilterConfig.kt @@ -0,0 +1,57 @@ +package com.bitchat.android.spam + +/** + * Tunables for [SpamFilter]. These are code-level knobs only; the user can just turn the + * filter on or off. + */ +data class SpamFilterConfig( + /** Messages per sender: a burst of 6, then one more every 10 seconds. */ + val messageBucket: TokenBucketConfig = TokenBucketConfig( + capacity = 6.0, + refillPerSecond = 1.0 / 10.0 + ), + /** Characters per sender: 600 per minute, refilled continuously (10 per second). */ + val characterBucket: TokenBucketConfig = TokenBucketConfig( + capacity = 600.0, + refillPerSecond = 600.0 / 60.0 + ), + /** Links per sender: 2 per minute, refilled continuously (one every 30 seconds). */ + val linkBucket: TokenBucketConfig = TokenBucketConfig( + capacity = 2.0, + refillPerSecond = 2.0 / 60.0 + ), + val repetition: RepetitionConfig = RepetitionConfig(), + /** + * How far in the past a message timestamp is trusted. Relay backfill (geohash subscribes to + * the last hour) and mesh sync deliver history in bursts, so buckets run on message time; + * older or future timestamps are clamped into [now - maxBackdateMs, now]. + */ + val maxBackdateMs: Long = 60 * 60 * 1000L, + /** Least recently active senders beyond this are forgotten. */ + val maxTrackedSenders: Int = 1_000, + /** Verdicts remembered by message ID so multi-transport duplicates are judged once. */ + val maxRememberedVerdicts: Int = 4_000 +) + +data class TokenBucketConfig( + val capacity: Double, + val refillPerSecond: Double +) + +data class RepetitionConfig( + /** Messages shorter than this (in characters) are never checked for repeated characters. */ + val minRepeatedCharactersLength: Int = 32, + /** Hide when some shift of the message matches itself at this fraction of positions. */ + val repeatedCharactersThreshold: Double = 0.85, + /** Messages shorter than this are never compared with the sender's history. */ + val minRepeatedMessageLength: Int = 10, + /** Two messages count as the same when their best alignment matches this fraction. */ + val repeatedMessageSimilarity: Double = 0.8, + /** How many near-copies of one text a sender may post within [historyWindowMs]. */ + val allowedRepeats: Int = 1, + /** Recent messages per sender kept for comparison. */ + val historySize: Int = 10, + val historyWindowMs: Long = 10 * 60 * 1000L, + /** Longer messages are analyzed on their first N characters only, bounding CPU per message. */ + val maxAnalyzedLength: Int = 1_000 +) diff --git a/app/src/main/java/com/bitchat/android/spam/SpamFilterPreferences.kt b/app/src/main/java/com/bitchat/android/spam/SpamFilterPreferences.kt new file mode 100644 index 000000000..43ff9f231 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/spam/SpamFilterPreferences.kt @@ -0,0 +1,43 @@ +package com.bitchat.android.spam + +import android.content.Context +import android.content.SharedPreferences +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Persists the user's on/off choice for [SpamFilter.shared]. The filter is on by default. + */ +object SpamFilterPreferences { + + private const val PREFS_NAME = "spam_filter_preferences" + private const val KEY_ENABLED = "spam_filter_enabled" + private const val DEFAULT_ENABLED = true + + private val _enabled = MutableStateFlow(DEFAULT_ENABLED) + val enabled: StateFlow = _enabled.asStateFlow() + + @Volatile + private var sharedPrefs: SharedPreferences? = null + + fun init(context: Context) { + if (sharedPrefs != null) return + val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + sharedPrefs = prefs + apply(prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)) + } + + fun setEnabled(enabled: Boolean) { + if (_enabled.value == enabled) return + sharedPrefs?.edit()?.putBoolean(KEY_ENABLED, enabled)?.apply() + apply(enabled) + // Start from a clean slate so budgets don't carry over from before the toggle. + SpamFilter.shared.reset() + } + + private fun apply(enabled: Boolean) { + _enabled.value = enabled + SpamFilter.shared.enabled = enabled + } +} diff --git a/app/src/main/java/com/bitchat/android/spam/TokenBucketSchedule.kt b/app/src/main/java/com/bitchat/android/spam/TokenBucketSchedule.kt new file mode 100644 index 000000000..622fda6f2 --- /dev/null +++ b/app/src/main/java/com/bitchat/android/spam/TokenBucketSchedule.kt @@ -0,0 +1,77 @@ +package com.bitchat.android.spam + +/** + * A set of token buckets for one sender, evaluated on message time instead of arrival order. + * + * Relay backfill and mesh sync hand over a sender's recent history in one burst, often newest + * first, so a plain arrival-time bucket would hide an ordinary conversation. Instead each + * admitted message stays on a timeline, and a candidate is admitted only if replaying every + * bucket over the timeline with the candidate inserted never runs dry. For live, in-order + * traffic this behaves exactly like a classic token bucket. Rejected messages cost nothing. + * + * Entries older than the backdate window are folded into a checkpoint, and nothing can be + * inserted before the checkpoint, so the replay stays short and exact. + */ +internal class TokenBucketSchedule(private val buckets: List) { + + private class Entry(val timeMs: Long, val costs: DoubleArray) + + private val entries = ArrayList() + private var checkpointMs: Long? = null + private val checkpointTokens = DoubleArray(buckets.size) { buckets[it].capacity } + + /** + * Admits and records the message when every bucket can pay [costs] at [timeMs]. + * Returns -1 on success, otherwise the index of the first bucket that ran dry. + */ + fun tryAdmit(timeMs: Long, costs: DoubleArray): Int { + require(costs.size == buckets.size) + val time = checkpointMs?.let { maxOf(it, timeMs) } ?: timeMs + var insertAt = entries.size + while (insertAt > 0 && entries[insertAt - 1].timeMs > time) insertAt-- + val candidate = Entry(time, costs) + + val tokens = checkpointTokens.copyOf() + var lastMs = checkpointMs + for (i in 0..entries.size) { + val entry = when { + i < insertAt -> entries[i] + i == insertAt -> candidate + else -> entries[i - 1] + } + refill(tokens, lastMs, entry.timeMs) + lastMs = entry.timeMs + for (b in buckets.indices) { + tokens[b] -= entry.costs[b] + if (tokens[b] < -EPSILON) return b + } + } + entries.add(insertAt, candidate) + return -1 + } + + /** Folds entries before [cutoffMs] into the checkpoint. */ + fun prune(cutoffMs: Long) { + var folded = 0 + while (folded < entries.size && entries[folded].timeMs < cutoffMs) { + val entry = entries[folded] + refill(checkpointTokens, checkpointMs, entry.timeMs) + for (b in buckets.indices) checkpointTokens[b] -= entry.costs[b] + checkpointMs = entry.timeMs + folded++ + } + if (folded > 0) entries.subList(0, folded).clear() + } + + private fun refill(tokens: DoubleArray, fromMs: Long?, toMs: Long) { + if (fromMs == null || toMs <= fromMs) return + val seconds = (toMs - fromMs) / 1000.0 + for (b in buckets.indices) { + tokens[b] = minOf(buckets[b].capacity, tokens[b] + seconds * buckets[b].refillPerSecond) + } + } + + private companion object { + const val EPSILON = 1e-9 + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt index 54ab03185..4fc570293 100644 --- a/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/AboutSheet.kt @@ -15,6 +15,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Bluetooth import androidx.compose.material.icons.filled.Security +import androidx.compose.material.icons.filled.FilterAlt import androidx.compose.material.icons.filled.Speed import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -77,6 +78,7 @@ import com.bitchat.android.net.TorMode import com.bitchat.android.net.TorPreferenceManager import com.bitchat.android.nostr.NostrProofOfWork import com.bitchat.android.nostr.PoWPreferenceManager +import com.bitchat.android.spam.SpamFilterPreferences import com.bitchat.android.ui.theme.BitchatMotion import com.bitchat.android.ui.theme.LocalBitchatPalette import com.bitchat.android.util.ShareableApkVariant @@ -508,6 +510,7 @@ fun AboutSheet( LaunchedEffect(Unit) { PoWPreferenceManager.init(context) } val powEnabled by PoWPreferenceManager.powEnabled.collectAsState() val powDifficulty by PoWPreferenceManager.powDifficulty.collectAsState() + val spamFilterEnabled by SpamFilterPreferences.enabled.collectAsState() var backgroundEnabled by remember { mutableStateOf(com.bitchat.android.service.MeshServicePreferences.isBackgroundEnabled(true)) } var liveVoiceEnabled by remember { mutableStateOf(com.bitchat.android.features.voice.LiveVoicePreferences.isEnabled(context)) @@ -582,6 +585,21 @@ fun AboutSheet( color = colorScheme.outlineVariant ) + // Spam Filter Toggle + SettingsToggleRow( + icon = Icons.Filled.FilterAlt, + title = stringResource(R.string.about_spam_filter_title), + subtitle = stringResource(R.string.about_spam_filter_desc), + checked = spamFilterEnabled, + onCheckedChange = { SpamFilterPreferences.setEnabled(it) } + ) + + HorizontalDivider( + modifier = Modifier.padding(start = 54.dp), + thickness = 1.dp, + color = colorScheme.outlineVariant + ) + // Tor Toggle SettingsToggleRow( icon = Icons.Filled.Security, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a46d596f3..d656fdb82 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -202,6 +202,8 @@ PoW Off PoW On Add proof of work to geohash messages for spam deterrence. + Spam Filter + Hide public messages from people who post too fast, repeat themselves, or flood links. Difficulty: %1$d bits (~%2$s) Difficulty %1$d requires ~%2$s hash attempts No proof of work required diff --git a/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt b/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt new file mode 100644 index 000000000..98af2a572 --- /dev/null +++ b/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt @@ -0,0 +1,231 @@ +package com.bitchat.android.spam + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import kotlin.random.Random + +class SpamFilterTest { + + private var now = 1_000_000_000L + private var nextId = 0 + private val random = Random(7) + private val filter = SpamFilter(clock = { now }) + + private fun send( + content: String = prose(), + sender: String = "alice", + timestampMs: Long = now, + id: String = "m${nextId++}" + ) = filter.check(sender, id, content, timestampMs) + + /** Unrelated synthetic words, so only the rule under test can fire. */ + private fun prose(length: Int = 40): String = buildString { + while (this.length < length) { + if (isNotEmpty()) append(' ') + repeat(random.nextInt(2, 8)) { append('a' + random.nextInt(26)) } + } + }.take(length) + + private fun advanceSeconds(seconds: Int) { + now += seconds * 1000L + } + + @Test + fun `sixth message in a burst is shown and seventh is hidden`() { + repeat(6) { assertEquals(SpamVerdict.ALLOWED, send()) } + assertEquals(SpamVerdict.MESSAGE_RATE, send()) + } + + @Test + fun `message budget refills one token every ten seconds`() { + repeat(6) { send() } + advanceSeconds(9) + assertEquals(SpamVerdict.MESSAGE_RATE, send()) + advanceSeconds(1) + assertEquals(SpamVerdict.ALLOWED, send()) + assertEquals(SpamVerdict.MESSAGE_RATE, send()) + } + + @Test + fun `hidden messages do not drain the budget of a flooding sender`() { + var shown = 0 + repeat(120) { + if (send() == SpamVerdict.ALLOWED) shown++ + advanceSeconds(1) + } + // A burst of 6, then one every 10 seconds for the remaining two minutes. + assertEquals(6 + 11, shown) + } + + @Test + fun `character budget allows six hundred characters per minute`() { + assertEquals(SpamVerdict.ALLOWED, send(prose(300))) + assertEquals(SpamVerdict.ALLOWED, send(prose(300))) + assertEquals(SpamVerdict.CHARACTER_RATE, send(prose(20))) + advanceSeconds(2) + assertEquals(SpamVerdict.ALLOWED, send(prose(20))) + } + + @Test + fun `message longer than the character budget is hidden`() { + assertEquals(SpamVerdict.CHARACTER_RATE, send(prose(601))) + } + + @Test + fun `characters are counted as code points`() { + val emoji = "😀" + val sentence = (0 until 600).joinToString("") { if (it % 3 == 0) emoji else "${'a' + random.nextInt(26)}" } + assertEquals(SpamVerdict.ALLOWED, send(sentence)) + } + + @Test + fun `third link within a minute is hidden`() { + assertEquals(SpamVerdict.ALLOWED, send("see https://example.org/a for details")) + assertEquals(SpamVerdict.ALLOWED, send("mirror at www.example.net too")) + assertEquals(SpamVerdict.LINK_RATE, send("and example.com/promo as well")) + assertEquals(SpamVerdict.ALLOWED, send("no link in this one at all")) + advanceSeconds(30) + assertEquals(SpamVerdict.ALLOWED, send("ok one more https://example.org/b")) + } + + @Test + fun `message with three links is hidden even for a fresh sender`() { + assertEquals( + SpamVerdict.LINK_RATE, + send("https://alpha.example/one and bravo.example plus www.charlie.example/three") + ) + } + + @Test + fun `link counter ignores email addresses and abbreviations`() { + assertEquals(0, LinkCounter.count("mail me at someone@example.com e.g. today")) + assertEquals(0, LinkCounter.count("pi is 3.14, version v1.2.3")) + assertEquals(3, LinkCounter.count("https://a.example/x, www.b.example and c.example.")) + } + + @Test + fun `third copy of a message within ten minutes is hidden`() { + val text = "anyone near the north gate tonight?" + assertEquals(SpamVerdict.ALLOWED, send(text)) + advanceSeconds(60) + assertEquals(SpamVerdict.ALLOWED, send(text)) + advanceSeconds(60) + assertEquals(SpamVerdict.REPEATED_MESSAGE, send(text)) + } + + @Test + fun `near copies with a changing suffix count as repeats`() { + assertEquals(SpamVerdict.ALLOWED, send("FREE SATS at the link below 101")) + advanceSeconds(20) + assertEquals(SpamVerdict.ALLOWED, send("free sats at the link below 202")) + advanceSeconds(20) + assertEquals(SpamVerdict.REPEATED_MESSAGE, send("free sats at the link below 303")) + } + + @Test + fun `repeats spread beyond the history window are allowed`() { + val text = "good morning everyone, coffee anyone?" + send(text) + advanceSeconds(11 * 60) + send(text) + advanceSeconds(11 * 60) + assertEquals(SpamVerdict.ALLOWED, send(text)) + } + + @Test + fun `short replies may repeat freely`() { + repeat(5) { + assertEquals(SpamVerdict.ALLOWED, send("lol yes")) + advanceSeconds(10) + } + } + + @Test + fun `message repeating a character is hidden`() { + assertEquals(SpamVerdict.REPEATED_CHARACTERS, send("A".repeat(40))) + assertEquals(SpamVerdict.REPEATED_CHARACTERS, send("hey " + "!".repeat(60))) + } + + @Test + fun `message repeating a phrase is hidden`() { + assertEquals(SpamVerdict.REPEATED_CHARACTERS, send("buy now ".repeat(6))) + } + + @Test + fun `ordinary long sentence is not mistaken for repetition`() { + val text = "The market by the old bridge opens at nine, bring cash because the card reader is flaky." + assertTrue(RepetitionDetector.selfSimilarity(RepetitionDetector.normalize(text, 1000)) < 0.3) + assertEquals(SpamVerdict.ALLOWED, send(text)) + } + + @Test + fun `senders have independent budgets`() { + repeat(6) { send(sender = "alice") } + assertEquals(SpamVerdict.MESSAGE_RATE, send(sender = "alice")) + assertEquals(SpamVerdict.ALLOWED, send(sender = "bob")) + } + + @Test + fun `same message ID arriving twice is judged once`() { + repeat(5) { send() } + assertEquals(SpamVerdict.ALLOWED, send(id = "dup")) + assertEquals(SpamVerdict.ALLOWED, send(id = "dup")) + assertEquals(SpamVerdict.MESSAGE_RATE, send()) + } + + @Test + fun `backfilled history delivered newest first is shown`() { + // Ten messages, one per minute over the last ten minutes, arriving in one burst. + val verdicts = (0 until 10).map { minutesAgo -> + send(timestampMs = now - minutesAgo * 60_000L) + } + assertTrue(verdicts.all { it == SpamVerdict.ALLOWED }) + } + + @Test + fun `backfilled burst is still rate limited on its own timestamps`() { + val start = now - 5 * 60_000L + val verdicts = (0 until 10).map { send(timestampMs = start + it * 1000L) } + assertEquals(6, verdicts.count { it == SpamVerdict.ALLOWED }) + } + + @Test + fun `backfill cannot be squeezed in between already shown messages`() { + repeat(6) { send(timestampMs = now - 60_000L) } + // A message claiming to be from just before the burst would overdraw the bucket. + assertEquals(SpamVerdict.MESSAGE_RATE, send(timestampMs = now - 61_000L)) + } + + @Test + fun `timestamps older than the backdate window are clamped`() { + // Spreading fake timestamps over days must not buy extra budget. + val verdicts = (0 until 10).map { send(timestampMs = now - (10 - it) * 86_400_000L) } + assertEquals(6, verdicts.count { it == SpamVerdict.ALLOWED }) + } + + @Test + fun `future timestamps are clamped to now`() { + val verdicts = (0 until 10).map { send(timestampMs = now + it * 60_000L) } + assertEquals(6, verdicts.count { it == SpamVerdict.ALLOWED }) + } + + @Test + fun `disabled filter allows everything`() { + filter.enabled = false + repeat(20) { assertEquals(SpamVerdict.ALLOWED, send("A".repeat(40))) } + } + + @Test + fun `limits are configurable`() { + val strict = SpamFilter( + config = SpamFilterConfig(messageBucket = TokenBucketConfig(capacity = 2.0, refillPerSecond = 1.0)), + clock = { now } + ) + assertEquals(SpamVerdict.ALLOWED, strict.check("a", "1", prose(), now)) + assertEquals(SpamVerdict.ALLOWED, strict.check("a", "2", prose(), now)) + assertEquals(SpamVerdict.MESSAGE_RATE, strict.check("a", "3", prose(), now)) + advanceSeconds(1) + assertEquals(SpamVerdict.ALLOWED, strict.check("a", "4", prose(), now)) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt index 152d4ad0a..598ec6553 100644 --- a/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrBackgroundEventProcessorTest.kt @@ -3,6 +3,7 @@ package com.bitchat.android.nostr import android.app.Application import androidx.test.core.app.ApplicationProvider import com.bitchat.android.services.AppStateStore +import com.bitchat.android.spam.SpamFilter import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -23,12 +24,17 @@ class NostrBackgroundEventProcessorTest { @Before fun setUp() { AppStateStore.clear() + // This test floods one sender to measure throughput; that is exactly what the spam + // filter hides, so it is covered by SpamFilterTest instead. + SpamFilter.shared.enabled = false scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) } @After fun tearDown() { scope.cancel() + SpamFilter.shared.enabled = true + SpamFilter.shared.reset() AppStateStore.clear() } diff --git a/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt b/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt index a430eb9ab..1b67dd28c 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt @@ -276,6 +276,30 @@ class IncomingMessageAdmissionTest { assertTrue(state.getPrivateChatsValue().isEmpty()) } + @Test + fun `public flood from one peer is hidden while private messages still arrive`() { + val peer = "flood-${UUID.randomUUID()}" + val admitted = (1..7).map { index -> + IncomingMessageAdmission.admitToAppState( + BitchatMessage( + id = "$peer-$index", + sender = "flooder", + content = "hi $index", + timestamp = Date(), + senderPeerID = peer + ) + ) + } + + assertEquals(listOf(true, true, true, true, true, true, false), admitted) + assertFalse(AppStateStore.publicMessages.value.any { it.id == "$peer-7" }) + assertTrue( + IncomingMessageAdmission.admitToAppState( + privateMessage(id = "$peer-dm").copy(senderPeerID = peer) + ) + ) + } + private fun privateMessage(id: String) = BitchatMessage( id = id, sender = "alice", diff --git a/wear/build.gradle.kts b/wear/build.gradle.kts index e6d4d9779..4f0414323 100644 --- a/wear/build.gradle.kts +++ b/wear/build.gradle.kts @@ -105,6 +105,7 @@ val sharedSourceIncludes = listOf( "com/bitchat/android/services/VerificationService.kt", "com/bitchat/android/services/meshgraph/**", "com/bitchat/android/service/TransportBridgeService.kt", + "com/bitchat/android/spam/**", "com/bitchat/android/nostr/Bech32.kt", "com/bitchat/android/nostr/GeohashAliasRegistry.kt", "com/bitchat/android/features/file/FileUtils.kt", @@ -119,6 +120,8 @@ val sharedSourceIncludes = listOf( ) val sharedSourceExcludes = listOf( "com/bitchat/android/model/FileSharingManager.kt", + // The watch has no spam filter toggle, so it keeps the filter's default (on). + "com/bitchat/android/spam/SpamFilterPreferences.kt", // Legacy phone monolith and Wi-Fi Aware multiplexer; the watch composes its own service // (MeshCore-style) in M2 instead of reusing these. "com/bitchat/android/mesh/BluetoothMeshService.kt", diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt index eeb09295b..0a61d0c4f 100644 --- a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -13,6 +13,7 @@ import com.bitchat.android.mesh.MeshTransport import com.bitchat.android.model.RoutedPacket import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.services.AppStateStore +import com.bitchat.android.spam.SpamFilter import com.bitchat.android.sync.GossipSyncManager import com.bitchat.android.util.AppConstants import kotlinx.coroutines.CoroutineScope @@ -238,6 +239,8 @@ class WearMeshService private constructor(private val context: Context) { try { onPrivateMessage?.invoke(message) } catch (_: Exception) { } true } + message.sender != "system" && + SpamFilter.shared.check(message, SpamFilter.meshSenderKey(message)).isSpam -> false message.channel != null -> { AppStateStore.addChannelMessage(message.channel, message) true From 445650937ca0a961717469497b9a7a5d97a0e804 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:24:54 +0100 Subject: [PATCH 05/17] Add spam filter debug settings Add a Spam filter section to debug settings with sliders for every limit (message burst and refill, characters and links per minute, repeat tolerance, similarity and window, repeated-character threshold and minimum length) and a reset to defaults. Tuned values persist and apply immediately; changing them gives every sender a full budget. A debug bypass keeps spam in the chat instead of hiding it and marks it with a red background and the reason it would have been hidden, in both matrix and bubble mode. Transports now ask SpamFilter.shouldHide, which records bypassed verdicts in a flow the message list observes. --- .../android/nostr/GeohashMessageHandler.kt | 5 +- .../services/IncomingMessageAdmission.kt | 2 +- .../com/bitchat/android/spam/SpamFilter.kt | 47 ++++++- .../android/spam/SpamFilterPreferences.kt | 74 +++++++++- .../com/bitchat/android/spam/SpamTuning.kt | 58 ++++++++ .../bitchat/android/ui/MessageComponents.kt | 36 ++++- .../android/ui/debug/DebugSettingsSheet.kt | 3 + .../ui/debug/SpamFilterDebugSection.kt | 133 ++++++++++++++++++ .../bitchat/android/spam/SpamFilterTest.kt | 42 ++++++ .../com/bitchat/watch/mesh/WearMeshService.kt | 2 +- 10 files changed, 391 insertions(+), 11 deletions(-) create mode 100644 app/src/main/java/com/bitchat/android/spam/SpamTuning.kt create mode 100644 app/src/main/java/com/bitchat/android/ui/debug/SpamFilterDebugSection.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index 90fcfcfdc..e287c5249 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -112,9 +112,8 @@ class GeohashMessageHandler( if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null } catch (_: Exception) { null } ) - val verdict = SpamFilter.shared.check(msg, SpamFilter.nostrSenderKey(pubkey)) - if (verdict.isSpam) { - Log.d(TAG, "Hiding geohash message ${event.id.take(8)}...: $verdict") + if (SpamFilter.shared.shouldHide(msg, SpamFilter.nostrSenderKey(pubkey))) { + Log.d(TAG, "Hiding geohash spam ${event.id.take(8)}...") return@launch } addChannelMessage("geo:$subscribedGeohash", msg) diff --git a/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt b/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt index aa4706cd6..c4ce242ac 100644 --- a/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt +++ b/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt @@ -46,6 +46,6 @@ internal object IncomingMessageAdmission { private fun isHiddenAsSpam(message: BitchatMessage): Boolean { if (message.sender == "system") return false - return SpamFilter.shared.check(message, SpamFilter.meshSenderKey(message)).isSpam + return SpamFilter.shared.shouldHide(message, SpamFilter.meshSenderKey(message)) } } diff --git a/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt b/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt index 0fd1b294d..37d52a693 100644 --- a/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt +++ b/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt @@ -2,6 +2,10 @@ package com.bitchat.android.spam import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import kotlin.math.abs enum class SpamVerdict { @@ -27,12 +31,32 @@ enum class SpamVerdict { * or through both geohash handlers, is judged once and counted once. */ class SpamFilter( - private val config: SpamFilterConfig = SpamFilterConfig(), + config: SpamFilterConfig = SpamFilterConfig(), private val clock: () -> Long = System::currentTimeMillis ) { @Volatile var enabled: Boolean = true + /** Changing the limits forgets every sender, so new budgets start full. */ + @Volatile + var config: SpamFilterConfig = config + set(value) { + synchronized(this) { + field = value + senders.clear() + verdicts.clear() + } + } + + /** Debug: let spam through and record its verdict in [flagged] so the chat can mark it. */ + @Volatile + var debugBypass: Boolean = false + + private val _flagged = MutableStateFlow>(emptyMap()) + + /** Verdicts of spam shown because of [debugBypass], by message ID. */ + val flagged: StateFlow> = _flagged.asStateFlow() + private class HistoryEntry(val timeMs: Long, val text: IntArray) private inner class SenderState { @@ -52,6 +76,24 @@ class SpamFilter( size > config.maxRememberedVerdicts } + /** + * Returns true when [message] must be kept out of the chat. With [debugBypass] on, spam is + * let through and recorded in [flagged] instead. + */ + fun shouldHide(message: BitchatMessage, senderKey: String): Boolean { + val verdict = check(message, senderKey) + if (!verdict.isSpam) return false + if (!debugBypass) return true + _flagged.update { current -> + if (current[message.id] == verdict) current + else (current + (message.id to verdict)).entries + .toList() + .takeLast(MAX_FLAGGED) + .associate { it.key to it.value } + } + return false + } + /** Checks a received message; [senderKey] must identify the author stably within a transport. */ fun check(message: BitchatMessage, senderKey: String): SpamVerdict = check( senderKey = senderKey, @@ -101,6 +143,7 @@ class SpamFilter( senders.clear() verdicts.clear() } + _flagged.value = emptyMap() } private fun hasRepeatedCharacters(text: IntArray): Boolean { @@ -136,6 +179,8 @@ class SpamFilter( } companion object { + private const val MAX_FLAGGED = 500 + /** Process-wide filter shared by every transport and geohash handler. */ val shared = SpamFilter() diff --git a/app/src/main/java/com/bitchat/android/spam/SpamFilterPreferences.kt b/app/src/main/java/com/bitchat/android/spam/SpamFilterPreferences.kt index 43ff9f231..3d9d4e8d9 100644 --- a/app/src/main/java/com/bitchat/android/spam/SpamFilterPreferences.kt +++ b/app/src/main/java/com/bitchat/android/spam/SpamFilterPreferences.kt @@ -7,17 +7,34 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow /** - * Persists the user's on/off choice for [SpamFilter.shared]. The filter is on by default. + * Persists the user's on/off choice for [SpamFilter.shared] (on by default), plus the debug + * settings: tuned limits and the bypass that marks spam instead of hiding it. */ object SpamFilterPreferences { private const val PREFS_NAME = "spam_filter_preferences" private const val KEY_ENABLED = "spam_filter_enabled" + private const val KEY_DEBUG_BYPASS = "spam_filter_debug_bypass" + private const val KEY_MESSAGE_BURST = "spam_filter_message_burst" + private const val KEY_SECONDS_PER_MESSAGE = "spam_filter_seconds_per_message" + private const val KEY_CHARACTERS_PER_MINUTE = "spam_filter_characters_per_minute" + private const val KEY_LINKS_PER_MINUTE = "spam_filter_links_per_minute" + private const val KEY_ALLOWED_REPEATS = "spam_filter_allowed_repeats" + private const val KEY_REPEAT_SIMILARITY = "spam_filter_repeat_similarity" + private const val KEY_REPEAT_WINDOW_MINUTES = "spam_filter_repeat_window_minutes" + private const val KEY_SELF_MATCH_THRESHOLD = "spam_filter_self_match_threshold" + private const val KEY_SELF_MATCH_MIN_LENGTH = "spam_filter_self_match_min_length" private const val DEFAULT_ENABLED = true private val _enabled = MutableStateFlow(DEFAULT_ENABLED) val enabled: StateFlow = _enabled.asStateFlow() + private val _debugBypass = MutableStateFlow(false) + val debugBypass: StateFlow = _debugBypass.asStateFlow() + + private val _tuning = MutableStateFlow(SpamTuning.DEFAULT) + val tuning: StateFlow = _tuning.asStateFlow() + @Volatile private var sharedPrefs: SharedPreferences? = null @@ -25,19 +42,68 @@ object SpamFilterPreferences { if (sharedPrefs != null) return val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) sharedPrefs = prefs - apply(prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)) + applyEnabled(prefs.getBoolean(KEY_ENABLED, DEFAULT_ENABLED)) + applyDebugBypass(prefs.getBoolean(KEY_DEBUG_BYPASS, false)) + applyTuning(readTuning(prefs)) } fun setEnabled(enabled: Boolean) { if (_enabled.value == enabled) return sharedPrefs?.edit()?.putBoolean(KEY_ENABLED, enabled)?.apply() - apply(enabled) + applyEnabled(enabled) // Start from a clean slate so budgets don't carry over from before the toggle. SpamFilter.shared.reset() } - private fun apply(enabled: Boolean) { + fun setDebugBypass(enabled: Boolean) { + sharedPrefs?.edit()?.putBoolean(KEY_DEBUG_BYPASS, enabled)?.apply() + applyDebugBypass(enabled) + } + + fun setTuning(tuning: SpamTuning) { + if (_tuning.value == tuning) return + sharedPrefs?.edit()?.apply { + putInt(KEY_MESSAGE_BURST, tuning.messageBurst) + putInt(KEY_SECONDS_PER_MESSAGE, tuning.secondsPerMessage) + putInt(KEY_CHARACTERS_PER_MINUTE, tuning.charactersPerMinute) + putInt(KEY_LINKS_PER_MINUTE, tuning.linksPerMinute) + putInt(KEY_ALLOWED_REPEATS, tuning.allowedRepeats) + putInt(KEY_REPEAT_SIMILARITY, tuning.repeatSimilarityPercent) + putInt(KEY_REPEAT_WINDOW_MINUTES, tuning.repeatWindowMinutes) + putInt(KEY_SELF_MATCH_THRESHOLD, tuning.selfMatchPercent) + putInt(KEY_SELF_MATCH_MIN_LENGTH, tuning.selfMatchMinLength) + apply() + } + applyTuning(tuning) + } + + private fun readTuning(prefs: SharedPreferences): SpamTuning { + val d = SpamTuning.DEFAULT + return SpamTuning( + messageBurst = prefs.getInt(KEY_MESSAGE_BURST, d.messageBurst), + secondsPerMessage = prefs.getInt(KEY_SECONDS_PER_MESSAGE, d.secondsPerMessage), + charactersPerMinute = prefs.getInt(KEY_CHARACTERS_PER_MINUTE, d.charactersPerMinute), + linksPerMinute = prefs.getInt(KEY_LINKS_PER_MINUTE, d.linksPerMinute), + allowedRepeats = prefs.getInt(KEY_ALLOWED_REPEATS, d.allowedRepeats), + repeatSimilarityPercent = prefs.getInt(KEY_REPEAT_SIMILARITY, d.repeatSimilarityPercent), + repeatWindowMinutes = prefs.getInt(KEY_REPEAT_WINDOW_MINUTES, d.repeatWindowMinutes), + selfMatchPercent = prefs.getInt(KEY_SELF_MATCH_THRESHOLD, d.selfMatchPercent), + selfMatchMinLength = prefs.getInt(KEY_SELF_MATCH_MIN_LENGTH, d.selfMatchMinLength) + ) + } + + private fun applyEnabled(enabled: Boolean) { _enabled.value = enabled SpamFilter.shared.enabled = enabled } + + private fun applyDebugBypass(enabled: Boolean) { + _debugBypass.value = enabled + SpamFilter.shared.debugBypass = enabled + } + + private fun applyTuning(tuning: SpamTuning) { + _tuning.value = tuning + SpamFilter.shared.config = tuning.toConfig() + } } diff --git a/app/src/main/java/com/bitchat/android/spam/SpamTuning.kt b/app/src/main/java/com/bitchat/android/spam/SpamTuning.kt new file mode 100644 index 000000000..1c3515e8c --- /dev/null +++ b/app/src/main/java/com/bitchat/android/spam/SpamTuning.kt @@ -0,0 +1,58 @@ +package com.bitchat.android.spam + +/** + * The [SpamFilterConfig] knobs exposed in debug settings, in whole units that suit sliders. + * [DEFAULT] matches the code defaults in [SpamFilterConfig]. + */ +data class SpamTuning( + val messageBurst: Int, + val secondsPerMessage: Int, + val charactersPerMinute: Int, + val linksPerMinute: Int, + val allowedRepeats: Int, + val repeatSimilarityPercent: Int, + val repeatWindowMinutes: Int, + val selfMatchPercent: Int, + val selfMatchMinLength: Int +) { + fun toConfig(): SpamFilterConfig { + val base = SpamFilterConfig() + return base.copy( + messageBucket = TokenBucketConfig( + capacity = messageBurst.toDouble(), + refillPerSecond = 1.0 / secondsPerMessage.coerceAtLeast(1) + ), + characterBucket = TokenBucketConfig( + capacity = charactersPerMinute.toDouble(), + refillPerSecond = charactersPerMinute / 60.0 + ), + linkBucket = TokenBucketConfig( + capacity = linksPerMinute.toDouble(), + refillPerSecond = linksPerMinute / 60.0 + ), + repetition = base.repetition.copy( + allowedRepeats = allowedRepeats, + repeatedMessageSimilarity = repeatSimilarityPercent / 100.0, + historyWindowMs = repeatWindowMinutes * 60_000L, + repeatedCharactersThreshold = selfMatchPercent / 100.0, + minRepeatedCharactersLength = selfMatchMinLength + ) + ) + } + + companion object { + val DEFAULT: SpamTuning = SpamFilterConfig().let { config -> + SpamTuning( + messageBurst = config.messageBucket.capacity.toInt(), + secondsPerMessage = Math.round(1.0 / config.messageBucket.refillPerSecond).toInt(), + charactersPerMinute = config.characterBucket.capacity.toInt(), + linksPerMinute = config.linkBucket.capacity.toInt(), + allowedRepeats = config.repetition.allowedRepeats, + repeatSimilarityPercent = Math.round(config.repetition.repeatedMessageSimilarity * 100).toInt(), + repeatWindowMinutes = (config.repetition.historyWindowMs / 60_000L).toInt(), + selfMatchPercent = Math.round(config.repetition.repeatedCharactersThreshold * 100).toInt(), + selfMatchMinLength = config.repetition.minRepeatedCharactersLength + ) + } + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index d398c397d..9d6123c06 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -87,6 +87,8 @@ import com.bitchat.android.mesh.MeshService import com.bitchat.android.model.BitchatMessage import com.bitchat.android.model.BitchatMessageType import com.bitchat.android.model.DeliveryStatus +import com.bitchat.android.spam.SpamFilter +import com.bitchat.android.spam.SpamVerdict import com.bitchat.android.ui.media.FileMessageItem import com.bitchat.android.ui.theme.BASE_FONT_SIZE import com.bitchat.android.ui.theme.BitchatMotion @@ -253,6 +255,8 @@ fun MessagesList( // Collected once here so individual rows never subscribe to the preference flow; a mode // switch simply recomposes the list against the new layout. val bubbles by ChatUiModeManager.modeFlow.collectAsState() + // Spam let through by the debug bypass; empty unless that debug setting is on. + val spamFlags by SpamFilter.shared.flagged.collectAsState() // A fresh scroll position per conversation. Sharing one state meant a switch inherited the // previous channel's offset and then had to correct itself, which is what the jump was. @@ -384,6 +388,7 @@ fun MessagesList( mentionPeerIdentities = resolvedMentionPeerIdentities, showSender = !isGrouped, bubbles = bubbles.isBubbles, + spamVerdict = spamFlags[message.id], topSpacing = MessageGrouping.topSpacingFor( isGrouped = isGrouped, isFirstInList = originalIndex == 0 @@ -418,6 +423,8 @@ fun MessageItem( mentionPeerIdentities: Map = emptyMap(), showSender: Boolean = true, bubbles: Boolean = false, + /** Set when the spam filter's debug bypass let this message through; tints the row red. */ + spamVerdict: SpamVerdict? = null, topSpacing: Dp = 0.dp, onNicknameClick: ((String) -> Unit)? = null, onMessageLongPress: ((BitchatMessage) -> Unit)? = null, @@ -427,11 +434,19 @@ fun MessageItem( ) { val colorScheme = MaterialTheme.colorScheme val timeFormatter = remember { SimpleDateFormat(CHAT_TIMESTAMP_PATTERN, Locale.getDefault()) } + val spamModifier = if (spamVerdict != null) { + Modifier + .background(colorScheme.error.copy(alpha = 0.18f), RoundedCornerShape(8.dp)) + .padding(horizontal = 6.dp, vertical = 4.dp) + } else { + Modifier + } Column( modifier = modifier .fillMaxWidth() - .padding(top = topSpacing), + .padding(top = topSpacing) + .then(spamModifier), verticalArrangement = Arrangement.spacedBy(0.dp) ) { Box(modifier = Modifier.fillMaxWidth()) { @@ -497,9 +512,28 @@ fun MessageItem( } // Link previews removed; links are now highlighted inline and clickable within the message text + + if (spamVerdict != null) { + Text( + text = "spam filter would hide this: ${spamVerdict.debugLabel()}", + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = colorScheme.error, + modifier = Modifier.padding(top = 2.dp) + ) + } } } +private fun SpamVerdict.debugLabel(): String = when (this) { + SpamVerdict.ALLOWED -> "allowed" + SpamVerdict.MESSAGE_RATE -> "too many messages" + SpamVerdict.CHARACTER_RATE -> "too many characters" + SpamVerdict.LINK_RATE -> "too many links" + SpamVerdict.REPEATED_MESSAGE -> "repeated message" + SpamVerdict.REPEATED_CHARACTERS -> "repeated characters" +} + @OptIn(ExperimentalFoundationApi::class) @Composable private fun MessageTextWithClickableNicknames( diff --git a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt index 9c8c48b5e..4be7a992b 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/DebugSettingsSheet.kt @@ -355,6 +355,9 @@ fun DebugSettingsSheet( item { DistributionInfoSection(distributionInfo) } + item { + SpamFilterDebugSection() + } // Verbose logging toggle item { Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { diff --git a/app/src/main/java/com/bitchat/android/ui/debug/SpamFilterDebugSection.kt b/app/src/main/java/com/bitchat/android/ui/debug/SpamFilterDebugSection.kt new file mode 100644 index 000000000..351821eaa --- /dev/null +++ b/app/src/main/java/com/bitchat/android/ui/debug/SpamFilterDebugSection.kt @@ -0,0 +1,133 @@ +package com.bitchat.android.ui.debug + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.FilterAlt +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Slider +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bitchat.android.spam.SpamFilter +import com.bitchat.android.spam.SpamFilterPreferences +import com.bitchat.android.spam.SpamTuning +import com.bitchat.android.ui.theme.BitchatFontFamily +import kotlin.math.roundToInt + +/** Debug controls for the spam filter: live limit tuning and the mark-instead-of-hide bypass. */ +@Composable +fun SpamFilterDebugSection() { + val colorScheme = MaterialTheme.colorScheme + val filterEnabled by SpamFilterPreferences.enabled.collectAsState() + val bypass by SpamFilterPreferences.debugBypass.collectAsState() + val tuning by SpamFilterPreferences.tuning.collectAsState() + val flagged by SpamFilter.shared.flagged.collectAsState() + val update: (SpamTuning) -> Unit = SpamFilterPreferences::setTuning + + Surface(shape = RoundedCornerShape(12.dp), color = colorScheme.surfaceVariant.copy(alpha = 0.2f)) { + Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon(Icons.Filled.FilterAlt, contentDescription = null, tint = colorScheme.error) + Text("Spam filter", fontFamily = BitchatFontFamily, fontSize = 14.sp, fontWeight = FontWeight.Medium) + Spacer(Modifier.weight(1f)) + Switch(checked = filterEnabled, onCheckedChange = SpamFilterPreferences::setEnabled) + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Column(Modifier.weight(1f)) { + Text("Show spam in red instead of hiding it", fontFamily = BitchatFontFamily, fontSize = 12.sp) + Text( + "Bypass for debugging. ${flagged.size} marked since the last reset or limit change.", + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + } + Switch(checked = bypass, onCheckedChange = SpamFilterPreferences::setDebugBypass, enabled = filterEnabled) + } + + Text( + "Limits per sender. Changing one starts every sender with a full budget.", + fontFamily = BitchatFontFamily, + fontSize = 11.sp, + color = colorScheme.onSurface.copy(alpha = 0.7f) + ) + TuningSlider("Message burst", "${tuning.messageBurst} messages", tuning.messageBurst, 1..30) { + update(tuning.copy(messageBurst = it)) + } + TuningSlider("Message refill", "1 every ${tuning.secondsPerMessage} s", tuning.secondsPerMessage, 1..60) { + update(tuning.copy(secondsPerMessage = it)) + } + TuningSlider("Characters", "${tuning.charactersPerMinute} per minute", tuning.charactersPerMinute, 100..3000, step = 50) { + update(tuning.copy(charactersPerMinute = it)) + } + TuningSlider("Links", "${tuning.linksPerMinute} per minute", tuning.linksPerMinute, 0..10) { + update(tuning.copy(linksPerMinute = it)) + } + TuningSlider("Repeats allowed", "${tuning.allowedRepeats} near-copies", tuning.allowedRepeats, 0..10) { + update(tuning.copy(allowedRepeats = it)) + } + TuningSlider("Near-copy match", "≥ ${tuning.repeatSimilarityPercent}%", tuning.repeatSimilarityPercent, 50..100) { + update(tuning.copy(repeatSimilarityPercent = it)) + } + TuningSlider("Repeat window", "${tuning.repeatWindowMinutes} min", tuning.repeatWindowMinutes, 1..60) { + update(tuning.copy(repeatWindowMinutes = it)) + } + TuningSlider("Repeated characters", "≥ ${tuning.selfMatchPercent}% self-match", tuning.selfMatchPercent, 50..100) { + update(tuning.copy(selfMatchPercent = it)) + } + TuningSlider("Repeated characters from", "${tuning.selfMatchMinLength} chars", tuning.selfMatchMinLength, 8..200, step = 4) { + update(tuning.copy(selfMatchMinLength = it)) + } + + Row { + Spacer(Modifier.weight(1f)) + TextButton(onClick = { update(SpamTuning.DEFAULT) }, enabled = tuning != SpamTuning.DEFAULT) { + Text("Reset to defaults", fontFamily = BitchatFontFamily, fontSize = 12.sp) + } + } + } + } +} + +@Composable +private fun TuningSlider( + label: String, + valueText: String, + value: Int, + range: IntRange, + step: Int = 1, + onChange: (Int) -> Unit +) { + val colorScheme = MaterialTheme.colorScheme + Column { + Row { + Text(label, fontFamily = BitchatFontFamily, fontSize = 11.sp, color = colorScheme.onSurface.copy(alpha = 0.7f)) + Spacer(Modifier.weight(1f)) + Text(valueText, fontFamily = BitchatFontFamily, fontSize = 11.sp) + } + Slider( + value = value.toFloat(), + onValueChange = { raw -> + val snapped = range.first + ((raw - range.first) / step).roundToInt() * step + onChange(snapped.coerceIn(range)) + }, + valueRange = range.first.toFloat()..range.last.toFloat(), + steps = ((range.last - range.first) / step - 1).coerceAtLeast(0) + ) + } +} diff --git a/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt b/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt index 98af2a572..1ed7c6546 100644 --- a/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt +++ b/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt @@ -1,6 +1,8 @@ package com.bitchat.android.spam +import com.bitchat.android.model.BitchatMessage import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Test import kotlin.random.Random @@ -228,4 +230,44 @@ class SpamFilterTest { advanceSeconds(1) assertEquals(SpamVerdict.ALLOWED, strict.check("a", "4", prose(), now)) } + + @Test + fun `debug bypass shows spam and records why it would be hidden`() { + filter.debugBypass = true + val messages = (1..7).map { + BitchatMessage(id = "b$it", sender = "flooder", content = "hi $it", timestamp = java.util.Date(now)) + } + + val hidden = messages.map { filter.shouldHide(it, "flooder") } + + assertTrue(hidden.none { it }) + assertEquals(mapOf("b7" to SpamVerdict.MESSAGE_RATE), filter.flagged.value) + } + + @Test + fun `spam is hidden without the debug bypass`() { + val messages = (1..7).map { + BitchatMessage(id = "h$it", sender = "flooder", content = "hi $it", timestamp = java.util.Date(now)) + } + + assertEquals(listOf(false, false, false, false, false, false, true), messages.map { filter.shouldHide(it, "flooder") }) + assertTrue(filter.flagged.value.isEmpty()) + } + + @Test + fun `changing limits starts every sender with a full budget`() { + repeat(6) { send() } + assertEquals(SpamVerdict.MESSAGE_RATE, send()) + + filter.config = SpamTuning.DEFAULT.copy(messageBurst = 10).toConfig() + + repeat(10) { assertEquals(SpamVerdict.ALLOWED, send()) } + assertEquals(SpamVerdict.MESSAGE_RATE, send()) + } + + @Test + fun `default tuning matches the code defaults`() { + assertEquals(SpamFilterConfig(), SpamTuning.DEFAULT.toConfig()) + assertFalse(SpamTuning.DEFAULT.copy(linksPerMinute = 5).toConfig() == SpamFilterConfig()) + } } diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt index 0a61d0c4f..c83e97d5e 100644 --- a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -240,7 +240,7 @@ class WearMeshService private constructor(private val context: Context) { true } message.sender != "system" && - SpamFilter.shared.check(message, SpamFilter.meshSenderKey(message)).isSpam -> false + SpamFilter.shared.shouldHide(message, SpamFilter.meshSenderKey(message)) -> false message.channel != null -> { AppStateStore.addChannelMessage(message.channel, message) true From 4b7c034f4360b694f4fbe186e343a32a67735dd5 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 25 Oct 2026 19:16:14 +0100 Subject: [PATCH 06/17] Detect copied messages across users Spammers can rotate through unlimited npubs, so per-sender repeat limits miss a swarm posting the same text once each. Keep the last 200 messages of 16+ characters from all senders and hide a message once more than 2 near-copies from other senders appeared within the repeat window. Plain cross-correlation keeps only the best shift, so one inserted or deleted character split a copy in two (a "!!" insertion scored 54%). Similarity now combines up to 3 correlation peaks, where the extra peaks count only runs of 3+ matching characters and no character is used twice; the same example scores 95% while unrelated text stays under 40%. Comparisons cover the first 280 characters and skip pairs sharing under 25% of their trigrams, bounding the cost per message. Both cross-user limits are tunable in the debug Spam section. --- .../android/spam/RepetitionDetector.kt | 121 ++++++++++++++++-- .../com/bitchat/android/spam/SpamFilter.kt | 74 ++++++++--- .../bitchat/android/spam/SpamFilterConfig.kt | 19 ++- .../android/spam/SpamFilterPreferences.kt | 6 + .../com/bitchat/android/spam/SpamTuning.kt | 6 + .../bitchat/android/ui/MessageComponents.kt | 1 + .../ui/debug/SpamFilterDebugSection.kt | 6 + .../bitchat/android/spam/SpamFilterTest.kt | 89 +++++++++++++ 8 files changed, 292 insertions(+), 30 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/spam/RepetitionDetector.kt b/app/src/main/java/com/bitchat/android/spam/RepetitionDetector.kt index 7d6fd446a..ca151b03c 100644 --- a/app/src/main/java/com/bitchat/android/spam/RepetitionDetector.kt +++ b/app/src/main/java/com/bitchat/android/spam/RepetitionDetector.kt @@ -34,22 +34,115 @@ internal object RepetitionDetector { return best } + /** One diagonal of the cross-correlation: `b[j]` lined up under `a[j + shift]`. */ + data class Peak(val shift: Int, val matches: Int) + + /** How two texts line up: the peaks used and the share of the longer text they cover. */ + data class Alignment(val peaks: List, val matches: Int, val score: Double) + + fun crossSimilarity(a: IntArray, b: IntArray, maxPeaks: Int = 3, minRun: Int = 3): Double = + align(a, b, maxPeaks, minRun).score + /** - * Highest cross-correlation of two texts over every alignment: matching positions divided by - * the longer length, so 1.0 means identical and a short text never matches a long one. + * Cross-correlation of two texts that tolerates small edits. + * + * The first peak is the classic correlation maximum, the shift where the most characters + * match. An inserted or deleted character moves the rest of the text onto a neighboring + * shift, so up to [maxPeaks] - 1 further peaks add matches from other shifts. Those count + * only runs of at least [minRun] consecutive characters, so coincidental single letters + * don't add up, and no character of either text is used twice. The score is the matched + * characters over the longer length: 1.0 for identical texts, and a short text never + * matches a long one. */ - fun crossSimilarity(a: IntArray, b: IntArray): Double { - if (a.isEmpty() || b.isEmpty()) return 0.0 - val longest = maxOf(a.size, b.size) - var best = 0 - for (shift in -(b.size - 1) until a.size) { - val from = maxOf(0, shift) - val to = minOf(a.size, b.size + shift) - if (to - from <= best) continue - var matches = 0 - for (i in from until to) if (a[i] == b[i - shift]) matches++ - if (matches > best) best = matches + fun align(a: IntArray, b: IntArray, maxPeaks: Int = 3, minRun: Int = 3): Alignment { + if (a.isEmpty() || b.isEmpty()) return Alignment(emptyList(), 0, 0.0) + val usedA = BooleanArray(a.size) + val usedB = BooleanArray(b.size) + val peaks = ArrayList() + var total = 0 + while (peaks.size < maxPeaks) { + val run = if (peaks.isEmpty()) 1 else minRun + var bestGain = 0 + var bestShift = 0 + for (shift in -(b.size - 1) until a.size) { + val from = maxOf(0, -shift) + val to = minOf(b.size, a.size - shift) + if (to - from <= bestGain) continue + val gain = runMatches(a, b, shift, from, to, usedA, usedB, run, mark = false) + if (gain > bestGain) { + bestGain = gain + bestShift = shift + } + } + if (bestGain < run) break + val from = maxOf(0, -bestShift) + val to = minOf(b.size, a.size - bestShift) + runMatches(a, b, bestShift, from, to, usedA, usedB, run, mark = true) + peaks += Peak(bestShift, bestGain) + total += bestGain } - return best.toDouble() / longest + return Alignment(peaks, total, total.toDouble() / maxOf(a.size, b.size)) + } + + /** Unused matches at [shift] that sit in runs of at least [minRun]; [mark] claims them. */ + private fun runMatches( + a: IntArray, + b: IntArray, + shift: Int, + from: Int, + to: Int, + usedA: BooleanArray, + usedB: BooleanArray, + minRun: Int, + mark: Boolean + ): Int { + var gain = 0 + var runStart = -1 + for (j in from..to) { + val hit = j < to && !usedB[j] && !usedA[j + shift] && a[j + shift] == b[j] + if (hit) { + if (runStart < 0) runStart = j + } else if (runStart >= 0) { + val length = j - runStart + if (length >= minRun) { + gain += length + if (mark) for (k in runStart until j) { + usedB[k] = true + usedA[k + shift] = true + } + } + runStart = -1 + } + } + return gain + } + + /** Distinct character trigrams as sorted hashes, for a cheap pre-check before [align]. */ + fun trigrams(text: IntArray): IntArray { + if (text.size < 3) return IntArray(0) + return IntArray(text.size - 2) { i -> (text[i] * 31 + text[i + 1]) * 31 + text[i + 2] } + .distinct() + .sorted() + .toIntArray() + } + + /** + * Shared trigrams over the larger trigram set. Near-copies share most of theirs, unrelated + * texts few, so pairs below a low bar can skip the quadratic [align]. Texts too short to + * have trigrams always pass. + */ + fun trigramOverlap(a: IntArray, b: IntArray): Double { + if (a.isEmpty() || b.isEmpty()) return 1.0 + var i = 0 + var j = 0 + var shared = 0 + while (i < a.size && j < b.size) { + when { + a[i] == b[j] -> { shared++; i++; j++ } + a[i] < b[j] -> i++ + else -> j++ + } + } + return shared.toDouble() / maxOf(a.size, b.size) } } diff --git a/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt b/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt index 37d52a693..8003d0c83 100644 --- a/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt +++ b/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt @@ -14,6 +14,7 @@ enum class SpamVerdict { CHARACTER_RATE, LINK_RATE, REPEATED_MESSAGE, + REPEATED_ACROSS_USERS, REPEATED_CHARACTERS; val isSpam: Boolean get() = this != ALLOWED @@ -23,9 +24,11 @@ enum class SpamVerdict { * Decides whether an incoming public message should be hidden. * * Each sender gets three token buckets (messages, characters, links; see [SpamFilterConfig]) - * plus two repetition checks: a message that repeats itself internally, and a message that is a - * near-copy of the sender's recent history. Hidden messages never spend tokens, but they do - * enter the repetition history so a spammer cannot cycle the same text. + * plus three repetition checks: a message that repeats itself internally, a near-copy of the + * sender's own recent history, and a near-copy of what *other* senders posted recently. The last + * one catches spam spread over many throwaway identities, which per-sender limits cannot see. + * Hidden messages never spend tokens, but they do enter the repetition history so a spammer + * cannot cycle the same text. * * Verdicts are remembered by message ID, so the same message arriving over BLE and Wi-Fi Aware, * or through both geohash handlers, is judged once and counted once. @@ -45,6 +48,7 @@ class SpamFilter( field = value senders.clear() verdicts.clear() + recentTexts.clear() } } @@ -57,13 +61,16 @@ class SpamFilter( /** Verdicts of spam shown because of [debugBypass], by message ID. */ val flagged: StateFlow> = _flagged.asStateFlow() - private class HistoryEntry(val timeMs: Long, val text: IntArray) + /** A message prepared for comparison: its leading characters and their trigrams. */ + private class Fingerprint(val timeMs: Long, val text: IntArray, val trigrams: IntArray) + + private class SharedEntry(val senderKey: String, val fingerprint: Fingerprint) private inner class SenderState { val rate = TokenBucketSchedule( listOf(config.messageBucket, config.characterBucket, config.linkBucket) ) - val history = ArrayDeque() + val history = ArrayDeque() } private val senders = object : LinkedHashMap(64, 0.75f, true) { @@ -71,6 +78,9 @@ class SpamFilter( size > config.maxTrackedSenders } + /** Recent texts from every sender, for the cross-user repeat check. */ + private val recentTexts = ArrayDeque() + private val verdicts = object : LinkedHashMap(256, 0.75f, false) { override fun removeEldestEntry(eldest: MutableMap.MutableEntry) = size > config.maxRememberedVerdicts @@ -122,15 +132,22 @@ class SpamFilter( val repetition = config.repetition val text = if (isText) RepetitionDetector.normalize(content, repetition.maxAnalyzedLength) else null + val fingerprint = text?.let { fingerprint(it, time) } val verdict = when { text != null && hasRepeatedCharacters(text) -> SpamVerdict.REPEATED_CHARACTERS - text != null && repeatsHistory(state, text, time) -> SpamVerdict.REPEATED_MESSAGE + text != null && repeatsHistory(state, text.size, fingerprint!!) -> SpamVerdict.REPEATED_MESSAGE + text != null && repeatsOtherSenders(senderKey, text.size, fingerprint!!) -> + SpamVerdict.REPEATED_ACROSS_USERS else -> admitRate(state, content, time, isText) } - if (text != null) { - state.history.addLast(HistoryEntry(time, text)) + if (text != null && fingerprint != null) { + state.history.addLast(fingerprint) while (state.history.size > repetition.historySize) state.history.removeFirst() + if (text.size >= repetition.crossUserMinLength) { + recentTexts.addLast(SharedEntry(senderKey, fingerprint)) + while (recentTexts.size > repetition.crossUserHistorySize) recentTexts.removeFirst() + } } verdicts[messageId] = verdict return verdict @@ -142,6 +159,7 @@ class SpamFilter( synchronized(this) { senders.clear() verdicts.clear() + recentTexts.clear() } _flagged.value = emptyMap() } @@ -152,17 +170,43 @@ class SpamFilter( RepetitionDetector.selfSimilarity(text) >= repetition.repeatedCharactersThreshold } - private fun repeatsHistory(state: SenderState, text: IntArray, timeMs: Long): Boolean { + private fun fingerprint(text: IntArray, timeMs: Long): Fingerprint { + val max = config.repetition.maxComparedLength + val compared = if (text.size > max) text.copyOf(max) else text + return Fingerprint(timeMs, compared, RepetitionDetector.trigrams(compared)) + } + + private fun repeatsHistory(state: SenderState, length: Int, message: Fingerprint): Boolean { val repetition = config.repetition - if (text.size < repetition.minRepeatedMessageLength) return false - val copies = state.history.count { entry -> - abs(entry.timeMs - timeMs) <= repetition.historyWindowMs && - lengthsCanMatch(entry.text.size, text.size) && - RepetitionDetector.crossSimilarity(entry.text, text) >= repetition.repeatedMessageSimilarity - } + if (length < repetition.minRepeatedMessageLength) return false + val copies = state.history.count { isNearCopy(it, message) } return copies > repetition.allowedRepeats } + private fun repeatsOtherSenders(senderKey: String, length: Int, message: Fingerprint): Boolean { + val repetition = config.repetition + if (length < repetition.crossUserMinLength) return false + var copies = 0 + for (entry in recentTexts) { + if (entry.senderKey == senderKey || !isNearCopy(entry.fingerprint, message)) continue + if (++copies > repetition.crossUserAllowedRepeats) return true + } + return false + } + + private fun isNearCopy(earlier: Fingerprint, message: Fingerprint): Boolean { + val repetition = config.repetition + return abs(earlier.timeMs - message.timeMs) <= repetition.historyWindowMs && + lengthsCanMatch(earlier.text.size, message.text.size) && + RepetitionDetector.trigramOverlap(earlier.trigrams, message.trigrams) >= repetition.minSharedTrigrams && + RepetitionDetector.crossSimilarity( + earlier.text, + message.text, + repetition.maxAlignmentPeaks, + repetition.minPeakRun + ) >= repetition.repeatedMessageSimilarity + } + // Cross-similarity is bounded by shorter/longer, so skip pairs that can never reach it. private fun lengthsCanMatch(a: Int, b: Int): Boolean = minOf(a, b).toDouble() / maxOf(a, b) >= config.repetition.repeatedMessageSimilarity diff --git a/app/src/main/java/com/bitchat/android/spam/SpamFilterConfig.kt b/app/src/main/java/com/bitchat/android/spam/SpamFilterConfig.kt index b19f0b38f..ffdce1da3 100644 --- a/app/src/main/java/com/bitchat/android/spam/SpamFilterConfig.kt +++ b/app/src/main/java/com/bitchat/android/spam/SpamFilterConfig.kt @@ -49,9 +49,26 @@ data class RepetitionConfig( val repeatedMessageSimilarity: Double = 0.8, /** How many near-copies of one text a sender may post within [historyWindowMs]. */ val allowedRepeats: Int = 1, + /** + * How many near-copies of a text *other* senders may already have posted within + * [historyWindowMs] before it is hidden. Catches spam spread over many identities. + */ + val crossUserAllowedRepeats: Int = 2, + /** Messages shorter than this are never compared across senders; short replies coincide. */ + val crossUserMinLength: Int = 16, + /** Recent messages from all senders kept for the cross-user comparison. */ + val crossUserHistorySize: Int = 200, /** Recent messages per sender kept for comparison. */ val historySize: Int = 10, val historyWindowMs: Long = 10 * 60 * 1000L, /** Longer messages are analyzed on their first N characters only, bounding CPU per message. */ - val maxAnalyzedLength: Int = 1_000 + val maxAnalyzedLength: Int = 1_000, + /** Messages are compared with others on their first N characters only. */ + val maxComparedLength: Int = 280, + /** Correlation peaks combined per comparison; extra peaks absorb inserted or deleted text. */ + val maxAlignmentPeaks: Int = 3, + /** Extra peaks only count runs of at least this many matching characters. */ + val minPeakRun: Int = 3, + /** Pairs sharing fewer trigrams than this fraction skip the full comparison. */ + val minSharedTrigrams: Double = 0.25 ) diff --git a/app/src/main/java/com/bitchat/android/spam/SpamFilterPreferences.kt b/app/src/main/java/com/bitchat/android/spam/SpamFilterPreferences.kt index 3d9d4e8d9..f3191c9e7 100644 --- a/app/src/main/java/com/bitchat/android/spam/SpamFilterPreferences.kt +++ b/app/src/main/java/com/bitchat/android/spam/SpamFilterPreferences.kt @@ -20,6 +20,8 @@ object SpamFilterPreferences { private const val KEY_CHARACTERS_PER_MINUTE = "spam_filter_characters_per_minute" private const val KEY_LINKS_PER_MINUTE = "spam_filter_links_per_minute" private const val KEY_ALLOWED_REPEATS = "spam_filter_allowed_repeats" + private const val KEY_CROSS_USER_ALLOWED_REPEATS = "spam_filter_cross_user_allowed_repeats" + private const val KEY_CROSS_USER_MIN_LENGTH = "spam_filter_cross_user_min_length" private const val KEY_REPEAT_SIMILARITY = "spam_filter_repeat_similarity" private const val KEY_REPEAT_WINDOW_MINUTES = "spam_filter_repeat_window_minutes" private const val KEY_SELF_MATCH_THRESHOLD = "spam_filter_self_match_threshold" @@ -68,6 +70,8 @@ object SpamFilterPreferences { putInt(KEY_CHARACTERS_PER_MINUTE, tuning.charactersPerMinute) putInt(KEY_LINKS_PER_MINUTE, tuning.linksPerMinute) putInt(KEY_ALLOWED_REPEATS, tuning.allowedRepeats) + putInt(KEY_CROSS_USER_ALLOWED_REPEATS, tuning.crossUserAllowedRepeats) + putInt(KEY_CROSS_USER_MIN_LENGTH, tuning.crossUserMinLength) putInt(KEY_REPEAT_SIMILARITY, tuning.repeatSimilarityPercent) putInt(KEY_REPEAT_WINDOW_MINUTES, tuning.repeatWindowMinutes) putInt(KEY_SELF_MATCH_THRESHOLD, tuning.selfMatchPercent) @@ -85,6 +89,8 @@ object SpamFilterPreferences { charactersPerMinute = prefs.getInt(KEY_CHARACTERS_PER_MINUTE, d.charactersPerMinute), linksPerMinute = prefs.getInt(KEY_LINKS_PER_MINUTE, d.linksPerMinute), allowedRepeats = prefs.getInt(KEY_ALLOWED_REPEATS, d.allowedRepeats), + crossUserAllowedRepeats = prefs.getInt(KEY_CROSS_USER_ALLOWED_REPEATS, d.crossUserAllowedRepeats), + crossUserMinLength = prefs.getInt(KEY_CROSS_USER_MIN_LENGTH, d.crossUserMinLength), repeatSimilarityPercent = prefs.getInt(KEY_REPEAT_SIMILARITY, d.repeatSimilarityPercent), repeatWindowMinutes = prefs.getInt(KEY_REPEAT_WINDOW_MINUTES, d.repeatWindowMinutes), selfMatchPercent = prefs.getInt(KEY_SELF_MATCH_THRESHOLD, d.selfMatchPercent), diff --git a/app/src/main/java/com/bitchat/android/spam/SpamTuning.kt b/app/src/main/java/com/bitchat/android/spam/SpamTuning.kt index 1c3515e8c..1da951ee2 100644 --- a/app/src/main/java/com/bitchat/android/spam/SpamTuning.kt +++ b/app/src/main/java/com/bitchat/android/spam/SpamTuning.kt @@ -10,6 +10,8 @@ data class SpamTuning( val charactersPerMinute: Int, val linksPerMinute: Int, val allowedRepeats: Int, + val crossUserAllowedRepeats: Int, + val crossUserMinLength: Int, val repeatSimilarityPercent: Int, val repeatWindowMinutes: Int, val selfMatchPercent: Int, @@ -32,6 +34,8 @@ data class SpamTuning( ), repetition = base.repetition.copy( allowedRepeats = allowedRepeats, + crossUserAllowedRepeats = crossUserAllowedRepeats, + crossUserMinLength = crossUserMinLength, repeatedMessageSimilarity = repeatSimilarityPercent / 100.0, historyWindowMs = repeatWindowMinutes * 60_000L, repeatedCharactersThreshold = selfMatchPercent / 100.0, @@ -48,6 +52,8 @@ data class SpamTuning( charactersPerMinute = config.characterBucket.capacity.toInt(), linksPerMinute = config.linkBucket.capacity.toInt(), allowedRepeats = config.repetition.allowedRepeats, + crossUserAllowedRepeats = config.repetition.crossUserAllowedRepeats, + crossUserMinLength = config.repetition.crossUserMinLength, repeatSimilarityPercent = Math.round(config.repetition.repeatedMessageSimilarity * 100).toInt(), repeatWindowMinutes = (config.repetition.historyWindowMs / 60_000L).toInt(), selfMatchPercent = Math.round(config.repetition.repeatedCharactersThreshold * 100).toInt(), diff --git a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt index 9d6123c06..6d7f56ae4 100644 --- a/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt +++ b/app/src/main/java/com/bitchat/android/ui/MessageComponents.kt @@ -531,6 +531,7 @@ private fun SpamVerdict.debugLabel(): String = when (this) { SpamVerdict.CHARACTER_RATE -> "too many characters" SpamVerdict.LINK_RATE -> "too many links" SpamVerdict.REPEATED_MESSAGE -> "repeated message" + SpamVerdict.REPEATED_ACROSS_USERS -> "same text from other users" SpamVerdict.REPEATED_CHARACTERS -> "repeated characters" } diff --git a/app/src/main/java/com/bitchat/android/ui/debug/SpamFilterDebugSection.kt b/app/src/main/java/com/bitchat/android/ui/debug/SpamFilterDebugSection.kt index 351821eaa..0a97a8999 100644 --- a/app/src/main/java/com/bitchat/android/ui/debug/SpamFilterDebugSection.kt +++ b/app/src/main/java/com/bitchat/android/ui/debug/SpamFilterDebugSection.kt @@ -81,6 +81,12 @@ fun SpamFilterDebugSection() { TuningSlider("Repeats allowed", "${tuning.allowedRepeats} near-copies", tuning.allowedRepeats, 0..10) { update(tuning.copy(allowedRepeats = it)) } + TuningSlider("Copies from other users allowed", "${tuning.crossUserAllowedRepeats} near-copies", tuning.crossUserAllowedRepeats, 0..10) { + update(tuning.copy(crossUserAllowedRepeats = it)) + } + TuningSlider("Compare across users from", "${tuning.crossUserMinLength} chars", tuning.crossUserMinLength, 4..100) { + update(tuning.copy(crossUserMinLength = it)) + } TuningSlider("Near-copy match", "≥ ${tuning.repeatSimilarityPercent}%", tuning.repeatSimilarityPercent, 50..100) { update(tuning.copy(repeatSimilarityPercent = it)) } diff --git a/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt b/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt index 1ed7c6546..262610d36 100644 --- a/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt +++ b/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt @@ -270,4 +270,93 @@ class SpamFilterTest { assertEquals(SpamFilterConfig(), SpamTuning.DEFAULT.toConfig()) assertFalse(SpamTuning.DEFAULT.copy(linksPerMinute = 5).toConfig() == SpamFilterConfig()) } + + @Test + fun `same text from many throwaway identities is hidden after three copies`() { + val text = "Free sats for everyone at freesats dot example" + val verdicts = (1..6).map { send(text, sender = "npub$it") } + + assertEquals( + listOf(SpamVerdict.ALLOWED, SpamVerdict.ALLOWED, SpamVerdict.ALLOWED) + + List(3) { SpamVerdict.REPEATED_ACROSS_USERS }, + verdicts + ) + } + + @Test + fun `near copies with small changes from different identities are hidden`() { + // Each variant is one small edit (a suffix, an insertion, a substitution) off the first. + val variants = listOf( + "Free sats for everyone, claim yours now", + "free sats for everyone, claim yours now 1", + "Free sats for everyone!! claim yours now", + "free sats 4 everyone, claim yours now", + "free sats for everyone, claim yours now 55" + ) + val verdicts = variants.mapIndexed { i, text -> send(text, sender = "npub$i") } + + assertEquals(List(4) { SpamVerdict.ALLOWED } + SpamVerdict.REPEATED_ACROSS_USERS, verdicts) + } + + @Test + fun `short common replies from many users are not compared`() { + repeat(8) { assertEquals(SpamVerdict.ALLOWED, send("good morning!", sender = "user$it")) } + } + + @Test + fun `different messages on the same topic from many users are allowed`() { + val messages = listOf( + "is the bakery on the corner still open?", + "the bakery closes at eight I think", + "bakery was still open when I walked by", + "anyone know if the bakery has bread left?", + "the corner bakery is open until eight" + ) + messages.forEachIndexed { i, text -> assertEquals(SpamVerdict.ALLOWED, send(text, sender = "user$i")) } + } + + @Test + fun `copies from other users outside the window do not count`() { + val text = "anyone up for chess at the tables later?" + repeat(3) { + send(text, sender = "user$it") + advanceSeconds(4 * 60) + } + // Only the copy from 4 minutes ago and the one from 8 minutes ago are in the window. + assertEquals(SpamVerdict.ALLOWED, send(text, sender = "late")) + } + + @Test + fun `a sender's own copies do not count toward the cross-user limit`() { + val text = "anyone near the north gate tonight?" + send(text, sender = "alice") + advanceSeconds(60) + send(text, sender = "alice") + assertEquals(SpamVerdict.ALLOWED, send(text, sender = "bob")) + assertEquals(SpamVerdict.REPEATED_ACROSS_USERS, send(text, sender = "carol")) + } + + @Test + fun `best alignment reports where the texts line up`() { + val a = RepetitionDetector.normalize("xx hello world", 1000) + val b = RepetitionDetector.normalize("hello world", 1000) + val alignment = RepetitionDetector.align(a, b) + assertEquals(3, alignment.peaks.first().shift) + assertEquals(11, alignment.matches) + } + + @Test + fun `an inserted character does not break the match`() { + val a = RepetitionDetector.normalize("free sats for everyone, claim yours now", 1000) + val b = RepetitionDetector.normalize("free sats for everyone!!, claim yours now", 1000) + assertTrue(RepetitionDetector.align(a, b, maxPeaks = 1).score < 0.8) + assertTrue(RepetitionDetector.align(a, b).score > 0.9) + } + + @Test + fun `unrelated sentences stay far below the near-copy threshold`() { + val a = RepetitionDetector.normalize("the bakery closes at eight I think", 1000) + val b = RepetitionDetector.normalize("the corner bakery is open until eight", 1000) + assertTrue(RepetitionDetector.align(a, b).score < 0.6) + } } From 78af907c69e52e9df79ddb112be18d4ea535dd69 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:04:46 +0100 Subject: [PATCH 07/17] Raise default character budget to 1200 per minute 600 characters per minute hid ordinary long messages too eagerly. The bucket now holds 1200 characters and refills 20 per second. --- .../java/com/bitchat/android/spam/SpamFilterConfig.kt | 6 +++--- .../java/com/bitchat/android/spam/SpamFilterTest.kt | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/spam/SpamFilterConfig.kt b/app/src/main/java/com/bitchat/android/spam/SpamFilterConfig.kt index ffdce1da3..8a50c6e7b 100644 --- a/app/src/main/java/com/bitchat/android/spam/SpamFilterConfig.kt +++ b/app/src/main/java/com/bitchat/android/spam/SpamFilterConfig.kt @@ -10,10 +10,10 @@ data class SpamFilterConfig( capacity = 6.0, refillPerSecond = 1.0 / 10.0 ), - /** Characters per sender: 600 per minute, refilled continuously (10 per second). */ + /** Characters per sender: 1200 per minute, refilled continuously (20 per second). */ val characterBucket: TokenBucketConfig = TokenBucketConfig( - capacity = 600.0, - refillPerSecond = 600.0 / 60.0 + capacity = 1200.0, + refillPerSecond = 1200.0 / 60.0 ), /** Links per sender: 2 per minute, refilled continuously (one every 30 seconds). */ val linkBucket: TokenBucketConfig = TokenBucketConfig( diff --git a/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt b/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt index 262610d36..af43804b6 100644 --- a/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt +++ b/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt @@ -61,9 +61,9 @@ class SpamFilterTest { } @Test - fun `character budget allows six hundred characters per minute`() { - assertEquals(SpamVerdict.ALLOWED, send(prose(300))) - assertEquals(SpamVerdict.ALLOWED, send(prose(300))) + fun `character budget allows twelve hundred characters per minute`() { + assertEquals(SpamVerdict.ALLOWED, send(prose(600))) + assertEquals(SpamVerdict.ALLOWED, send(prose(600))) assertEquals(SpamVerdict.CHARACTER_RATE, send(prose(20))) advanceSeconds(2) assertEquals(SpamVerdict.ALLOWED, send(prose(20))) @@ -71,13 +71,13 @@ class SpamFilterTest { @Test fun `message longer than the character budget is hidden`() { - assertEquals(SpamVerdict.CHARACTER_RATE, send(prose(601))) + assertEquals(SpamVerdict.CHARACTER_RATE, send(prose(1201))) } @Test fun `characters are counted as code points`() { val emoji = "😀" - val sentence = (0 until 600).joinToString("") { if (it % 3 == 0) emoji else "${'a' + random.nextInt(26)}" } + val sentence = (0 until 1200).joinToString("") { if (it % 3 == 0) emoji else "${'a' + random.nextInt(26)}" } assertEquals(SpamVerdict.ALLOWED, send(sentence)) } From d3b168f425e6139145dad5e219b4685b0b1be8f5 Mon Sep 17 00:00:00 2001 From: Erik <78821053+swedishfrenchpress@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:06:41 +0100 Subject: [PATCH 08/17] fix(ui): match network sheet QR action to top bar chrome The QR action next to the network sheet's close button was the only grey, oversized icon in any top bar, and sat 16dp away from the close button. Align it with the conversation header's action pattern: - tint with colorScheme.primary, like the close button beside it - size to HeaderIconSize (19dp), like the private chat header actions - space the close button 2dp from a preceding action; the 16dp end inset is unchanged, so the close button does not move on any sheet Co-Authored-By: Claude Opus 5.5 --- .../android/core/ui/component/sheet/BitchatSheetTopBar.kt | 6 ++++-- .../main/java/com/bitchat/android/ui/MeshPeerListSheet.kt | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt index b8a027dc3..fe0e9c06c 100644 --- a/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt +++ b/app/src/main/java/com/bitchat/android/core/ui/component/sheet/BitchatSheetTopBar.kt @@ -34,7 +34,8 @@ fun BitchatSheetTopBar( val dismiss = LocalSheetDismiss.current CloseButton( onClick = { dismiss?.invoke() ?: onClose() }, - modifier = Modifier.padding(horizontal = 16.dp) + // 2.dp from any preceding action, matching the conversation header's action spacing. + modifier = Modifier.padding(start = 2.dp, end = 16.dp) ) }, colors = TopAppBarDefaults.topAppBarColors( @@ -64,7 +65,8 @@ fun BitchatSheetCenterTopBar( val dismiss = LocalSheetDismiss.current CloseButton( onClick = { dismiss?.invoke() ?: onClose() }, - modifier = Modifier.padding(horizontal = 16.dp) + // 2.dp from any preceding action, matching the conversation header's action spacing. + modifier = Modifier.padding(start = 2.dp, end = 16.dp) ) }, colors = TopAppBarDefaults.topAppBarColors( diff --git a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt index c6233e1c1..6ef54f806 100644 --- a/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/MeshPeerListSheet.kt @@ -464,8 +464,8 @@ fun MeshPeerListSheet( Icon( imageVector = Icons.Outlined.QrCode, contentDescription = stringResource(R.string.verify_title), - tint = MaterialTheme.colorScheme.onSurfaceVariant, - modifier = Modifier.size(22.dp) + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(HeaderIconSize) ) } } From a9c96388261a40406750f80802d8c8ee6e56e9f4 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:09:59 +0100 Subject: [PATCH 09/17] Harden spam filter exemptions and threading A mesh peer could skip every spam rule by choosing the nickname "system", since the exemption for local notices checked only the peer-controlled display name. Exempt only messages without a sender peer ID: every message read off the wire carries one, while local notices such as "X favorited you" do not. Applies to the phone and watch admission paths. The foreground geohash handler runs on the main thread, so the spam checks could stall the UI during a relay backfill. Run them on a background dispatcher (the signature verification one by default). --- .../android/nostr/GeohashMessageHandler.kt | 9 ++- .../services/IncomingMessageAdmission.kt | 2 +- .../com/bitchat/android/spam/SpamFilter.kt | 8 ++ .../nostr/GeohashMessageHandlerSpamTest.kt | 79 +++++++++++++++++++ .../services/IncomingMessageAdmissionTest.kt | 35 ++++++++ .../com/bitchat/watch/mesh/WearMeshService.kt | 2 +- 6 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/GeohashMessageHandlerSpamTest.kt diff --git a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt index e287c5249..06ca4a053 100644 --- a/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt +++ b/app/src/main/java/com/bitchat/android/nostr/GeohashMessageHandler.kt @@ -23,7 +23,9 @@ class GeohashMessageHandler( private val scope: CoroutineScope, private val dataManager: com.bitchat.android.ui.DataManager, private val addChannelMessage: (String, BitchatMessage) -> Unit, - private val signatureVerificationDispatcher: CoroutineDispatcher = Dispatchers.Default + private val signatureVerificationDispatcher: CoroutineDispatcher = Dispatchers.Default, + // The foreground handler runs on the main thread; the spam checks are CPU work. + private val spamFilterDispatcher: CoroutineDispatcher = signatureVerificationDispatcher ) { companion object { private const val TAG = "GeohashMessageHandler" } @@ -112,7 +114,10 @@ class GeohashMessageHandler( if (hasNonce) NostrProofOfWork.calculateDifficulty(event.id).takeIf { it > 0 } else null } catch (_: Exception) { null } ) - if (SpamFilter.shared.shouldHide(msg, SpamFilter.nostrSenderKey(pubkey))) { + val hide = withContext(spamFilterDispatcher) { + SpamFilter.shared.shouldHide(msg, SpamFilter.nostrSenderKey(pubkey)) + } + if (hide) { Log.d(TAG, "Hiding geohash spam ${event.id.take(8)}...") return@launch } diff --git a/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt b/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt index c4ce242ac..de0b0797f 100644 --- a/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt +++ b/app/src/main/java/com/bitchat/android/services/IncomingMessageAdmission.kt @@ -45,7 +45,7 @@ internal object IncomingMessageAdmission { } private fun isHiddenAsSpam(message: BitchatMessage): Boolean { - if (message.sender == "system") return false + if (SpamFilter.isLocalNotice(message)) return false return SpamFilter.shared.shouldHide(message, SpamFilter.meshSenderKey(message)) } } diff --git a/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt b/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt index 8003d0c83..c3eb50946 100644 --- a/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt +++ b/app/src/main/java/com/bitchat/android/spam/SpamFilter.kt @@ -228,6 +228,14 @@ class SpamFilter( /** Process-wide filter shared by every transport and geohash handler. */ val shared = SpamFilter() + /** + * True for notices this device generated itself, such as "X favorited you". Every message + * read off the wire carries the sender's peer ID; only local notices have none. The + * nickname is no signal, since any peer can call itself "system". + */ + fun isLocalNotice(message: BitchatMessage): Boolean = + message.senderPeerID == null && message.sender == "system" + /** Key for public mesh and mesh channel messages. */ fun meshSenderKey(message: BitchatMessage): String = "mesh:" + (message.senderPeerID ?: message.sender) diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/GeohashMessageHandlerSpamTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/GeohashMessageHandlerSpamTest.kt new file mode 100644 index 000000000..fac5b94c9 --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/GeohashMessageHandlerSpamTest.kt @@ -0,0 +1,79 @@ +package com.bitchat.android.nostr + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.bitchat.android.spam.SpamFilter +import com.bitchat.android.ui.ChatState +import com.bitchat.android.ui.DataManager +import com.bitchat.android.ui.MessageManager +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class GeohashMessageHandlerSpamTest { + + private val application: Application = ApplicationProvider.getApplicationContext() + + @OptIn(ExperimentalCoroutinesApi::class) + private val testDispatcher = UnconfinedTestDispatcher() + private val testScope = TestScope(testDispatcher) + private val geohash = "zzzzzz" + + private lateinit var chatState: ChatState + private lateinit var handler: GeohashMessageHandler + + @Before + fun setup() { + com.bitchat.android.services.AppStateStore.clear() + SpamFilter.shared.reset() + chatState = ChatState(scope = testScope) + val dataManager = DataManager(context = application) + handler = GeohashMessageHandler( + application = application, + repo = GeohashRepository(application, chatState, dataManager, testScope), + scope = testScope, + dataManager = dataManager, + addChannelMessage = MessageManager(state = chatState)::addChannelMessage, + signatureVerificationDispatcher = testDispatcher, + spamFilterDispatcher = testDispatcher + ) + } + + @After + fun tearDown() { + SpamFilter.shared.reset() + com.bitchat.android.services.AppStateStore.clear() + } + + private fun event(identity: NostrIdentity, content: String) = NostrEvent( + pubkey = identity.publicKeyHex, + createdAt = (System.currentTimeMillis() / 1000).toInt(), + kind = NostrKind.EPHEMERAL_EVENT, + tags = listOf(listOf("g", geohash)), + content = content + ).sign(identity.privateKeyHex) + + @Test + fun `flood from one pubkey is cut off after the burst`() { + val flooder = NostrIdentity.generate() + (1..8).forEach { handler.onEvent(event(flooder, "hi $it"), geohash) } + + assertEquals(6, chatState.getChannelMessagesValue()["geo:$geohash"]?.size) + } + + @Test + fun `same text from many pubkeys is cut off after three copies`() { + repeat(5) { + handler.onEvent(event(NostrIdentity.generate(), "Free sats for everyone, claim yours now"), geohash) + } + + assertEquals(3, chatState.getChannelMessagesValue()["geo:$geohash"]?.size) + } +} diff --git a/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt b/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt index 1b67dd28c..444a52011 100644 --- a/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/services/IncomingMessageAdmissionTest.kt @@ -300,6 +300,41 @@ class IncomingMessageAdmissionTest { ) } + @Test + fun `peer calling itself system is still spam filtered`() { + val peer = "system-nick-${UUID.randomUUID()}" + val admitted = (1..7).map { index -> + IncomingMessageAdmission.admitToAppState( + BitchatMessage( + id = "$peer-$index", + sender = "system", + content = "hi $index", + timestamp = Date(), + senderPeerID = peer + ) + ) + } + + assertFalse(admitted.last()) + } + + @Test + fun `local system notices are never spam filtered`() { + val run = UUID.randomUUID().toString() + val admitted = (1..10).map { index -> + IncomingMessageAdmission.admitToAppState( + BitchatMessage( + id = "notice-$run-$index", + sender = "system", + content = "peer$index favorited you — favorite back to continue DMs later.", + timestamp = Date() + ) + ) + } + + assertTrue(admitted.all { it }) + } + private fun privateMessage(id: String) = BitchatMessage( id = id, sender = "alice", diff --git a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt index c83e97d5e..86fe86a1f 100644 --- a/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt +++ b/wear/src/main/java/com/bitchat/watch/mesh/WearMeshService.kt @@ -239,7 +239,7 @@ class WearMeshService private constructor(private val context: Context) { try { onPrivateMessage?.invoke(message) } catch (_: Exception) { } true } - message.sender != "system" && + !SpamFilter.isLocalNotice(message) && SpamFilter.shared.shouldHide(message, SpamFilter.meshSenderKey(message)) -> false message.channel != null -> { AppStateStore.addChannelMessage(message.channel, message) From fb7ee683dc6187416c34d6f0e9247e7f9c70800e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:16:09 +0100 Subject: [PATCH 10/17] Wipe spam filter history on panic and add Mesh Lab spam scenario The spam filter keeps recent message text and sender keys for its repeat checks, and panic clear left them in memory. Reset the shared filter as part of the panic wipe. Add a broadcast_spam Mesh Lab scenario so messaging validation covers both admitted and filtered public messages: B must show two near-copies from A, hide the third, and still show A's next ordinary message. It runs phone-to-phone and phone-to-watch. --- .../com/bitchat/android/ui/ChatViewModel.kt | 3 +++ docs/release-gate-runbook.md | 1 + tools/release_gate/mesh_lab.py | 26 +++++++++++++++++++ 3 files changed, 30 insertions(+) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index c7b4a8a6a..e08df748c 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1482,6 +1482,9 @@ class ChatViewModel( try { com.bitchat.android.services.MessageRouter.tryGetInstance()?.clearAll() } catch (_: Exception) { } + + // The spam filter keeps recent message text and sender keys for its repeat checks + com.bitchat.android.spam.SpamFilter.shared.reset() // Clear all cryptographic data clearAllCryptographicData() diff --git a/docs/release-gate-runbook.md b/docs/release-gate-runbook.md index 8de1e0637..a8e10c87d 100644 --- a/docs/release-gate-runbook.md +++ b/docs/release-gate-runbook.md @@ -298,6 +298,7 @@ python3 tools/release_gate/mesh_lab.py scenario all \ | `dm` | Noise handshake both ways, encrypted DM round trips with content match | | `favorite_verification` | favorite signal, orange-outline/filled mutual state, and peer fingerprint verification | | `broadcast` | public mesh message A→B | +| `broadcast_spam` | spam filter on B: two near-copies from A shown, the third hidden, A's next ordinary message shown (B needs the filter on, default limits, debug bypass off) | | `ptt_dm` | Noise-encrypted 440 Hz PTT in both directions; asserts real-time capture, zero sequence gaps, decoded PCM duration/energy/continuity, and finalized-note absorption | | `ptt_broadcast` | signed public 440 Hz PTT with the same bidirectional packet and decoded-audio quality assertions | | `file` | 1 KB broadcast file, receiver SHA-256 matches fixture | diff --git a/tools/release_gate/mesh_lab.py b/tools/release_gate/mesh_lab.py index 7abe8085b..2ddb4200a 100644 --- a/tools/release_gate/mesh_lab.py +++ b/tools/release_gate/mesh_lab.py @@ -532,6 +532,30 @@ def scenario_broadcast(a: Device, b: Device) -> dict: return {"send": send_result, "recv": recv_result} +def scenario_broadcast_spam(a: Device, b: Device) -> dict: + """Spam filter on B: two near-copies from A are shown, the third is hidden, and A's next + ordinary message is still shown. Needs B's spam filter on, default limits, bypass off.""" + id_a = whoami(a)["peer_id"] + # A fresh 32-character body per run keeps earlier runs out of B's ten-minute repeat window. + token = uuid.uuid4().hex[:16] + copies = [f"{token}{token} copy {mark}" for mark in "ABC"] + control = f"control {token} with different words, still delivered" + expected = [True, True, False, True] + with concurrent.futures.ThreadPoolExecutor(max_workers=4) as pool: + recvs = [pool.submit(b.cmd, "msg_recv", 45_000, contains=text) for text in [*copies, control]] + time.sleep(2) + for text in [*copies, control]: + a.cmd_ok("broadcast_msg", 30_000, content=text) + results = [recv.result() for recv in recvs] + delivered = [result.get("status") == "ok" for result in results] + if delivered != expected: + raise MeshLabError(f"expected delivery {expected} for copies A-C and control, got {delivered}") + for result in results: + if result.get("status") == "ok": + assert result["from"] == id_a, result + return {"delivered": delivered} + + def _ptt_one_way( sender: Device, receiver: Device, @@ -869,6 +893,7 @@ SCENARIOS = { "dm": scenario_dm, "favorite_verification": scenario_favorite_verification, "broadcast": scenario_broadcast, + "broadcast_spam": scenario_broadcast_spam, "ptt_dm": scenario_ptt_dm, "ptt_broadcast": scenario_ptt_broadcast, # Broadcast transfers are receiver-capped at 256 fragments (~120 KB); only @@ -899,6 +924,7 @@ WATCH_SCENARIOS = [ "dm", "favorite_verification", "broadcast", + "broadcast_spam", "ptt_dm", "ptt_broadcast", "raw", From 8a7718cc7ecff0107247a1a5c1c2123e2bcadc27 Mon Sep 17 00:00:00 2001 From: Erik <78821053+swedishfrenchpress@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:20:50 +0100 Subject: [PATCH 11/17] fix(ui): stack verify sheet over the network sheet Tapping the QR action closed the network sheet, briefly showed the chat screen, then opened the verify sheet; closing verify reopened the network sheet through a reopenSidebarAfterVerification flag. Open the verify sheet on top of the network sheet instead, the way the private chat sheet already hosts security verification, so dismissing it (close button or back) simply reveals the network sheet underneath. The reopen flag and the fromSidebar parameter are no longer needed. Co-Authored-By: Claude Opus 5.5 --- .../main/java/com/bitchat/android/ui/ChatScreen.kt | 6 ++---- .../main/java/com/bitchat/android/ui/ChatViewModel.kt | 11 +---------- 2 files changed, 3 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt index 5b773dfdc..80527d1a9 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatScreen.kt @@ -901,10 +901,8 @@ private fun ChatDialogs( isPresented = showMeshPeerListSheet, viewModel = viewModel, onDismiss = onMeshPeerListDismiss, - onShowVerification = { - onMeshPeerListDismiss() - viewModel.showVerificationSheet(fromSidebar = true) - } + // Stacks on top of the network sheet, so dismissing it lands back here. + onShowVerification = viewModel::showVerificationSheet ) } diff --git a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt index c7b4a8a6a..ac51ce04a 100644 --- a/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt +++ b/app/src/main/java/com/bitchat/android/ui/ChatViewModel.kt @@ -1300,21 +1300,12 @@ class ChatViewModel( notificationManager.clearMeshMentionNotifications() } - private var reopenSidebarAfterVerification = false - - fun showVerificationSheet(fromSidebar: Boolean = false) { - if (fromSidebar) { - reopenSidebarAfterVerification = true - } + fun showVerificationSheet() { state.setShowVerificationSheet(true) } fun hideVerificationSheet() { state.setShowVerificationSheet(false) - if (reopenSidebarAfterVerification) { - reopenSidebarAfterVerification = false - state.setShowMeshPeerList(true) - } } fun showSecurityVerificationSheet() { From 844239d384d626096419df49327b4068d709e016 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:25:17 +0100 Subject: [PATCH 12/17] See through disguised copies in repeat checks A copy with a zero-width space between letters, an accent on every letter, or math-bold or fullwidth letters looks the same to a reader but defeated both the per-sender and cross-user repeat checks. Normalize text for comparison by applying NFKD (which folds fullwidth and styled letters to plain ones and splits off accents), dropping combining marks and invisible characters, collapsing whitespace and lowercasing. Displayed and stored text is unchanged. Input is capped before normalizing to bound the work per message. --- .../android/spam/RepetitionDetector.kt | 53 ++++++++++++-- .../bitchat/android/spam/SpamFilterTest.kt | 70 +++++++++++++++++++ 2 files changed, 116 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/bitchat/android/spam/RepetitionDetector.kt b/app/src/main/java/com/bitchat/android/spam/RepetitionDetector.kt index ca151b03c..9132360cb 100644 --- a/app/src/main/java/com/bitchat/android/spam/RepetitionDetector.kt +++ b/app/src/main/java/com/bitchat/android/spam/RepetitionDetector.kt @@ -1,19 +1,58 @@ package com.bitchat.android.spam +import java.text.Normalizer + /** * Character autocorrelation measures for repeated-content spam. * - * Text is compared as Unicode code points after [normalize], so case, spacing and emoji - * surrogate pairs don't hide a repeat. + * Text is compared as Unicode code points after [normalize], so case, spacing, look-alike + * letters, accents and invisible characters don't hide a repeat. */ internal object RepetitionDetector { - private val WHITESPACE = Regex("\\s+") - - /** Lowercases, collapses whitespace and caps the length to [maxLength] code points. */ + /** + * Folds a message to what a reader sees, for comparison only (never for display): NFKD + * turns fullwidth and math-styled letters into plain ones and splits off accents, then + * combining marks and invisible characters are dropped, whitespace runs become one space + * and the result is lowercased and capped to [maxLength] code points. + */ fun normalize(text: String, maxLength: Int): IntArray { - val codePoints = text.lowercase().replace(WHITESPACE, " ").trim().codePoints().toArray() - return if (codePoints.size > maxLength) codePoints.copyOf(maxLength) else codePoints + // Disguises at most a few code units per visible character; bound the work up front. + val head = if (text.length > maxLength * 4) text.substring(0, maxLength * 4) else text + val decomposed = Normalizer.normalize(head, Normalizer.Form.NFKD) + val out = IntArray(minOf(maxLength, decomposed.length)) + var size = 0 + var pendingSpace = false + var offset = 0 + while (offset < decomposed.length && size < out.size) { + val point = decomposed.codePointAt(offset) + offset += Character.charCount(point) + when { + isMark(point) || isInvisible(point) -> continue + isWhitespace(point) -> pendingSpace = size > 0 + else -> { + if (pendingSpace && size < out.size - 1) out[size++] = ' '.code + pendingSpace = false + out[size++] = Character.toLowerCase(point) + } + } + } + return out.copyOf(size) + } + + private fun isWhitespace(point: Int): Boolean = + Character.isWhitespace(point) || Character.isSpaceChar(point) + + /** Zero-width and direction controls, soft hyphens and other non-printing characters. */ + private fun isInvisible(point: Int): Boolean = + Character.getType(point) == Character.FORMAT.toInt() || + (Character.isISOControl(point) && !isWhitespace(point)) + + private fun isMark(point: Int): Boolean = when (Character.getType(point)) { + Character.NON_SPACING_MARK.toInt(), + Character.COMBINING_SPACING_MARK.toInt(), + Character.ENCLOSING_MARK.toInt() -> true + else -> false } /** diff --git a/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt b/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt index af43804b6..3c5feabd5 100644 --- a/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt +++ b/app/src/test/java/com/bitchat/android/spam/SpamFilterTest.kt @@ -359,4 +359,74 @@ class SpamFilterTest { val b = RepetitionDetector.normalize("the corner bakery is open until eight", 1000) assertTrue(RepetitionDetector.align(a, b).score < 0.6) } + + private val disguiseBase = "Free sats for everyone, claim yours now" + + /** The same text a reader sees, written so that a plain comparison would miss it. */ + private val disguises = mapOf( + "zero-width spaces" to disguiseBase.map { "$it​" }.joinToString(""), + "mixed invisible characters" to disguiseBase.mapIndexed { i, c -> + "$c" + listOf("‌", "‍", "⁠", "­", "‎")[i % 5] + }.joinToString(""), + "an accent on every letter" to disguiseBase.map { if (it.isLetter()) "$it́" else "$it" }.joinToString(""), + "math bold letters" to disguiseBase.map { c -> + when (c) { + in 'a'..'z' -> String(Character.toChars(0x1D41A + (c - 'a'))) + in 'A'..'Z' -> String(Character.toChars(0x1D400 + (c - 'A'))) + else -> "$c" + } + }.joinToString(""), + "fullwidth letters" to disguiseBase.map { c -> if (c in '!'..'~') (c.code + 0xFEE0).toChar() else c }.joinToString(""), + "non-breaking spaces" to disguiseBase.replace(' ', ' ') + ) + + @Test + fun `normalization folds disguises to the text a reader sees`() { + val plain = RepetitionDetector.normalize(disguiseBase, 1000).toList() + disguises.forEach { (label, text) -> + assertEquals(label, plain, RepetitionDetector.normalize(text, 1000).toList()) + } + } + + @Test + fun `disguised copies from other users are still caught`() { + disguises.forEach { (label, text) -> + val fresh = SpamFilter(clock = { now }) + repeat(3) { fresh.check("npub$it", "$label-$it", disguiseBase, now) } + assertEquals(label, SpamVerdict.REPEATED_ACROSS_USERS, fresh.check("npub9", "$label-x", text, now)) + } + } + + @Test + fun `disguised copies from the same user are still caught`() { + disguises.forEach { (label, text) -> + val fresh = SpamFilter(clock = { now }) + fresh.check("alice", "$label-1", disguiseBase, now) + fresh.check("alice", "$label-2", disguiseBase, now) + assertEquals(label, SpamVerdict.REPEATED_MESSAGE, fresh.check("alice", "$label-3", text, now)) + } + } + + @Test + fun `invisible characters cannot break up a repeated character run`() { + assertEquals(SpamVerdict.REPEATED_CHARACTERS, send("A​".repeat(40))) + } + + @Test + fun `ordinary text with accents and other scripts is still allowed`() { + listOf( + "Tiếng Việt có dấu, chúng tôi đang ở quán cà phê gần chợ", + "क्या कोई पास में चाय की दुकान जानता है? मैं स्टेशन के पास हूँ", + "Café au lait, crème brûlée, s'il vous plaît", + "家族と一緒に駅の近くのカフェにいます、誰か来ますか" + ).forEachIndexed { i, text -> assertEquals(text, SpamVerdict.ALLOWED, send(text, sender = "user$i")) } + } + + @Test + fun `different sentences in another script are not mistaken for copies`() { + assertEquals(SpamVerdict.ALLOWED, send("क्या कोई पास में चाय की दुकान जानता है?", sender = "a")) + assertEquals(SpamVerdict.ALLOWED, send("मैं स्टेशन के पास हूँ, बारिश हो रही है", sender = "b")) + assertEquals(SpamVerdict.ALLOWED, send("आज बाज़ार कितने बजे बंद होगा?", sender = "c")) + assertEquals(SpamVerdict.ALLOWED, send("क्या किसी के पास छाता है जो मैं ले सकूँ?", sender = "d")) + } } From ab57e209a140bf4fa2d82b900a7d24822be0bc43 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Sun, 25 Oct 2026 23:51:59 +0100 Subject: [PATCH 13/17] Complete missing locale resources --- app/src/main/res/values-ar/strings.xml | 2 ++ app/src/main/res/values-bn/strings.xml | 2 ++ app/src/main/res/values-de/strings.xml | 2 ++ app/src/main/res/values-es/strings.xml | 2 ++ app/src/main/res/values-eu/strings.xml | 6 ++++++ app/src/main/res/values-fa/strings.xml | 2 ++ app/src/main/res/values-fil/strings.xml | 2 ++ app/src/main/res/values-fr/strings.xml | 2 ++ app/src/main/res/values-he/strings.xml | 2 ++ app/src/main/res/values-hi/strings.xml | 2 ++ app/src/main/res/values-id/strings.xml | 2 ++ app/src/main/res/values-it/strings.xml | 2 ++ app/src/main/res/values-ja/strings.xml | 2 ++ app/src/main/res/values-ka/strings.xml | 2 ++ app/src/main/res/values-ko/strings.xml | 2 ++ app/src/main/res/values-mg/strings.xml | 2 ++ app/src/main/res/values-ms/strings.xml | 2 ++ app/src/main/res/values-ne/strings.xml | 2 ++ app/src/main/res/values-nl/strings.xml | 2 ++ app/src/main/res/values-pa-rPK/strings.xml | 2 ++ app/src/main/res/values-pl/strings.xml | 2 ++ app/src/main/res/values-pt-rBR/strings.xml | 2 ++ app/src/main/res/values-pt/strings.xml | 2 ++ app/src/main/res/values-ru/strings.xml | 2 ++ app/src/main/res/values-sv/strings.xml | 2 ++ app/src/main/res/values-ta/strings.xml | 2 ++ app/src/main/res/values-th/strings.xml | 2 ++ app/src/main/res/values-tr/strings.xml | 2 ++ app/src/main/res/values-uk/strings.xml | 2 ++ app/src/main/res/values-ur/strings.xml | 2 ++ app/src/main/res/values-vi/strings.xml | 2 ++ app/src/main/res/values-zh-rCN/strings.xml | 2 ++ app/src/main/res/values-zh-rTW/strings.xml | 2 ++ app/src/main/res/values-zh/strings.xml | 2 ++ 34 files changed, 72 insertions(+) diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 772e48a5e..4b4d1fa77 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -393,4 +393,6 @@ لغة التطبيق إعداد النظام الافتراضي اختر اللغة + فلتر الرسائل المزعجة + إخفاء الرسائل العامة من الأشخاص الذين يرسلون بسرعة مفرطة أو يكررون رسائلهم أو يغرقون الدردشة بالروابط. diff --git a/app/src/main/res/values-bn/strings.xml b/app/src/main/res/values-bn/strings.xml index b6557fe2d..746ca763f 100644 --- a/app/src/main/res/values-bn/strings.xml +++ b/app/src/main/res/values-bn/strings.xml @@ -380,4 +380,6 @@ অ্যাপের ভাষা সিস্টেম ডিফল্ট ভাষা নির্বাচন করুন + স্প্যাম ফিল্টার + যারা খুব দ্রুত বার্তা পাঠান, একই কথা বারবার বলেন বা অতিরিক্ত লিঙ্ক পাঠান, তাঁদের প্রকাশ্য বার্তা লুকান। diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 75cc8927e..fe7933126 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -394,4 +394,6 @@ App-Sprache Systemstandard Sprache auswählen + Spamfilter + Öffentliche Nachrichten von Personen ausblenden, die zu schnell schreiben, sich wiederholen oder zu viele Links senden. diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 3f08ead06..e427714ff 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -393,4 +393,6 @@ Idioma de la app Predeterminado del sistema Seleccionar idioma + Filtro de spam + Oculta los mensajes públicos de quienes publican demasiado rápido, se repiten o envían demasiados enlaces. diff --git a/app/src/main/res/values-eu/strings.xml b/app/src/main/res/values-eu/strings.xml index af49e1638..7e9c21894 100644 --- a/app/src/main/res/values-eu/strings.xml +++ b/app/src/main/res/values-eu/strings.xml @@ -619,4 +619,10 @@ Aplikazioaren hizkuntza Sistemaren lehenetsia Hautatu hizkuntza + bitchat + 📄 %1$s + v%1$s + image/* + Spam-iragazkia + Ezkutatu azkarregi idazten duten, mezuak errepikatzen dituzten edo esteka gehiegi bidaltzen dituzten pertsonen mezu publikoak. diff --git a/app/src/main/res/values-fa/strings.xml b/app/src/main/res/values-fa/strings.xml index 1e092b641..fa86d3d41 100644 --- a/app/src/main/res/values-fa/strings.xml +++ b/app/src/main/res/values-fa/strings.xml @@ -380,4 +380,6 @@ زبان برنامه پیش‌فرض سیستم انتخاب زبان + فیلتر هرزنامه + پیام‌های عمومی افرادی را که خیلی سریع پیام می‌فرستند، خود را تکرار می‌کنند یا پیوندهای زیادی می‌فرستند پنهان کن. diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index fa4aea0cf..283079381 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -391,4 +391,6 @@ Wika ng app Default ng system Pumili ng wika + Filter ng spam + Itago ang mga pampublikong mensahe mula sa mga taong masyadong mabilis mag-post, paulit-ulit ang sinasabi, o nagpapadala ng napakaraming link. diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index a884d35e1..4f5b0d287 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -405,4 +405,6 @@ Langue de l’appli Langue du système Choisir la langue + Filtre antispam + Masquer les messages publics des personnes qui publient trop vite, se répètent ou inondent le chat de liens. diff --git a/app/src/main/res/values-he/strings.xml b/app/src/main/res/values-he/strings.xml index f742042ef..da4fd7fde 100644 --- a/app/src/main/res/values-he/strings.xml +++ b/app/src/main/res/values-he/strings.xml @@ -392,4 +392,6 @@ שפת האפליקציה ברירת המחדל של המערכת בחירת שפה + מסנן ספאם + הסתר הודעות ציבוריות מאנשים ששולחים מהר מדי, חוזרים על עצמם או מציפים בקישורים. diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index 4cb18e38c..42a5db6b6 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -393,4 +393,6 @@ ऐप की भाषा सिस्टम डिफ़ॉल्ट भाषा चुनें + स्पैम फ़िल्टर + बहुत तेज़ी से संदेश भेजने, एक ही बात दोहराने या बहुत अधिक लिंक भेजने वाले लोगों के सार्वजनिक संदेश छिपाएँ। diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 02c86a683..48f1056bf 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -393,4 +393,6 @@ Bahasa aplikasi Default sistem Pilih bahasa + Filter spam + Sembunyikan pesan publik dari orang yang mengirim terlalu cepat, mengulang pesan, atau membanjiri obrolan dengan tautan. diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index af59fad3e..489013089 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -420,4 +420,6 @@ Lingua dell’app Predefinita di sistema Seleziona lingua + Filtro antispam + Nascondi i messaggi pubblici di chi scrive troppo velocemente, si ripete o invia troppi link. diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 17841dcc5..24c0ab07f 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -393,4 +393,6 @@ アプリの言語 システムのデフォルト 言語を選択 + スパムフィルター + 短時間に大量のメッセージを送信したり、同じ内容を繰り返したり、リンクを大量に投稿したりする人の公開メッセージを非表示にします。 diff --git a/app/src/main/res/values-ka/strings.xml b/app/src/main/res/values-ka/strings.xml index 8fe186cc7..b62b40465 100644 --- a/app/src/main/res/values-ka/strings.xml +++ b/app/src/main/res/values-ka/strings.xml @@ -380,4 +380,6 @@ აპის ენა სისტემის ნაგულისხმევი ენის არჩევა + სპამის ფილტრი + დამალე იმ ადამიანების საჯარო შეტყობინებები, რომლებიც ზედმეტად სწრაფად წერენ, იმეორებენ შეტყობინებებს ან აგზავნიან ზედმეტად ბევრ ბმულს. diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 030165d6f..f1c095258 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -393,4 +393,6 @@ 앱 언어 시스템 기본값 언어 선택 + 스팸 필터 + 너무 빠르게 메시지를 보내거나, 같은 내용을 반복하거나, 링크를 과도하게 보내는 사람의 공개 메시지를 숨깁니다. diff --git a/app/src/main/res/values-mg/strings.xml b/app/src/main/res/values-mg/strings.xml index b7182e096..0475af611 100644 --- a/app/src/main/res/values-mg/strings.xml +++ b/app/src/main/res/values-mg/strings.xml @@ -400,4 +400,6 @@ Fitenin’ny app Fitenin’ny rafitra Safidio ny fiteny + Sivana spam + Afeno ny hafatra ampahibemaso avy amin’ireo olona mandefa haingana loatra, mamerimberina hafatra, na mandefa rohy be loatra. diff --git a/app/src/main/res/values-ms/strings.xml b/app/src/main/res/values-ms/strings.xml index 79bf5b1c2..74bf035f2 100644 --- a/app/src/main/res/values-ms/strings.xml +++ b/app/src/main/res/values-ms/strings.xml @@ -433,4 +433,6 @@ Bahasa aplikasi Lalai sistem Pilih bahasa + Penapis spam + Sembunyikan mesej awam daripada orang yang menghantar terlalu pantas, mengulang mesej atau membanjiri perbualan dengan pautan. diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 59d528532..87b2f1e22 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -391,4 +391,6 @@ एपको भाषा प्रणालीको पूर्वनिर्धारित भाषा छान्नुहोस् + स्प्याम फिल्टर + धेरै छिटो सन्देश पठाउने, एउटै कुरा दोहोर्‍याउने वा धेरै लिङ्क पठाउने व्यक्तिका सार्वजनिक सन्देशहरू लुकाउनुहोस्। diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 1e46f196f..6637e3869 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -418,4 +418,6 @@ App-taal Systeemstandaard Taal selecteren + Spamfilter + Verberg openbare berichten van mensen die te snel posten, zichzelf herhalen of te veel links sturen. diff --git a/app/src/main/res/values-pa-rPK/strings.xml b/app/src/main/res/values-pa-rPK/strings.xml index a3f677355..3e37dfbf9 100644 --- a/app/src/main/res/values-pa-rPK/strings.xml +++ b/app/src/main/res/values-pa-rPK/strings.xml @@ -380,4 +380,6 @@ ایپ دی بولی سسٹم دی ڈیفالٹ بولی چݨو + سپیم فلٹر + اوہناں لوکاں دے عوامی پیغام لُکاؤ جو بہت تیزی نال پیغام بھیجدے، گلّاں دُہراوندے یا بہت سارے لنک بھیجدے نیں۔ diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 3ec84dd46..331134801 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -398,4 +398,6 @@ Język aplikacji Domyślny systemu Wybierz język + Filtr spamu + Ukrywaj publiczne wiadomości od osób, które piszą zbyt szybko, powtarzają się lub wysyłają zbyt wiele linków. diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 423408577..3f2fbfc0f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -392,4 +392,6 @@ Idioma do app Padrão do sistema Selecionar idioma + Filtro de spam + Ocultar mensagens públicas de pessoas que enviam mensagens rápido demais, se repetem ou enviam links em excesso. diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 0e01bb078..0127bf346 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -393,4 +393,6 @@ Idioma da aplicação Predefinição do sistema Selecionar idioma + Filtro de spam + Ocultar mensagens públicas de pessoas que publicam demasiado depressa, se repetem ou enviam demasiadas ligações. diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 02b1175de..e7738dc6b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -380,4 +380,6 @@ Язык приложения Системный язык Выбрать язык + Спам-фильтр + Скрывать публичные сообщения от тех, кто пишет слишком быстро, повторяется или отправляет слишком много ссылок. diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index b958faad6..2cc58f35e 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -378,4 +378,6 @@ Appspråk Systemstandard Välj språk + Spamfilter + Dölj offentliga meddelanden från personer som skriver för snabbt, upprepar sig eller skickar för många länkar. diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml index 243f21128..6b1b6f2da 100644 --- a/app/src/main/res/values-ta/strings.xml +++ b/app/src/main/res/values-ta/strings.xml @@ -390,4 +390,6 @@ பயன்பாட்டு மொழி கணினி இயல்புநிலை மொழியைத் தேர்ந்தெடுக்கவும் + ஸ்பேம் வடிகட்டி + மிக வேகமாகச் செய்திகளை அனுப்புபவர்கள், ஒரே கருத்தைத் திரும்பத் திரும்பக் கூறுபவர்கள் அல்லது அதிக இணைப்புகளை அனுப்புபவர்களின் பொதுச் செய்திகளை மறைக்கவும். diff --git a/app/src/main/res/values-th/strings.xml b/app/src/main/res/values-th/strings.xml index b21049c13..a6e22fde8 100644 --- a/app/src/main/res/values-th/strings.xml +++ b/app/src/main/res/values-th/strings.xml @@ -380,4 +380,6 @@ ภาษาของแอป ค่าเริ่มต้นของระบบ เลือกภาษา + ตัวกรองสแปม + ซ่อนข้อความสาธารณะจากผู้ที่ส่งข้อความเร็วเกินไป ส่งข้อความซ้ำ หรือส่งลิงก์จำนวนมากเกินไป diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 251d6a462..e2787768c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -378,4 +378,6 @@ Uygulama dili Sistem varsayılanı Dil seçin + Spam filtresi + Çok hızlı mesaj gönderen, kendini tekrarlayan veya çok fazla bağlantı paylaşan kişilerin herkese açık mesajlarını gizle. diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index f248ff71d..8ac22faca 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -400,4 +400,6 @@ Мова застосунку Системна мова Вибрати мову + Спам-фільтр + Приховувати публічні повідомлення від тих, хто пише надто швидко, повторюється або надсилає забагато посилань. diff --git a/app/src/main/res/values-ur/strings.xml b/app/src/main/res/values-ur/strings.xml index 785338fbe..4be8ca54b 100644 --- a/app/src/main/res/values-ur/strings.xml +++ b/app/src/main/res/values-ur/strings.xml @@ -393,4 +393,6 @@ ایپ کی زبان سسٹم ڈیفالٹ زبان منتخب کریں + اسپام فلٹر + ایسے لوگوں کے عوامی پیغامات چھپائیں جو بہت تیزی سے پیغامات بھیجتے ہیں، اپنی بات دہراتے ہیں یا بہت زیادہ لنکس بھیجتے ہیں۔ diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index b97c3fc57..10073675e 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -380,4 +380,6 @@ Ngôn ngữ ứng dụng Mặc định hệ thống Chọn ngôn ngữ + Bộ lọc thư rác + Ẩn tin nhắn công khai từ những người gửi quá nhanh, lặp lại nội dung hoặc gửi quá nhiều liên kết. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index fca6b07b7..b2bac216c 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -388,4 +388,6 @@ 应用语言 跟随系统 选择语言 + 垃圾消息过滤器 + 隐藏发送过快、重复发言或发送过多链接的用户的公开消息。 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index f427dbaf9..ed78c193e 100644 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -389,4 +389,6 @@ 應用程式語言 跟隨系統 選擇語言 + 垃圾訊息篩選器 + 隱藏傳送過快、重複發言或傳送過多連結的使用者的公開訊息。 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 35f7477af..6fc8dce19 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -404,4 +404,6 @@ 应用语言 跟随系统 选择语言 + 垃圾消息过滤器 + 隐藏发送过快、重复发言或发送过多链接的用户的公开消息。 From ec1a92fcfe3b11531c7acf2467e6ed5bacd01fe7 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:50:36 +0100 Subject: [PATCH 14/17] Fix minified release scanner initialization --- app/build.gradle.kts | 14 +- app/gradle.lockfile | 404 +++++++++--------- app/proguard-rules.pro | 8 + app/release-instrumentation.pro | 9 + .../ui/BarcodeScannerInstrumentedTest.kt | 122 ++++++ .../android/ui/ReleaseScannerTestRunner.kt | 29 ++ .../bitchat/android/ui/VerificationSheet.kt | 2 +- .../scanner_release_regression.sh | 42 ++ 8 files changed, 426 insertions(+), 204 deletions(-) create mode 100644 app/release-instrumentation.pro create mode 100644 app/src/androidTest/java/com/bitchat/android/ui/BarcodeScannerInstrumentedTest.kt create mode 100644 app/src/androidTest/java/com/bitchat/android/ui/ReleaseScannerTestRunner.kt create mode 100644 tools/release_gate/scanner_release_regression.sh diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a85fb9698..b9f1c64bc 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,9 @@ plugins { alias(libs.plugins.kotlin.compose) } +// Opt-in instrumentation of the minified variant; production release signing is unchanged. +val releaseUiTests = providers.gradleProperty("releaseUiTests").orNull == "true" + val githubReleaseCertSha256 = providers .environmentVariable("BITCHAT_GITHUB_RELEASE_CERT_SHA256") .orElse(providers.gradleProperty("BITCHAT_GITHUB_RELEASE_CERT_SHA256")) @@ -25,6 +28,7 @@ android { namespace = "com.bitchat.android" compileSdk = libs.versions.compileSdk.get().toInt() buildToolsVersion = libs.versions.buildTools.get() + if (releaseUiTests) testBuildType = "release" defaultConfig { applicationId = "com.bitchat.droid" @@ -38,7 +42,11 @@ android { "\"$normalizedGithubReleaseCertSha256\"" ) - testInstrumentationRunner = "com.bitchat.android.ui.PerformanceTestRunner" + testInstrumentationRunner = if (releaseUiTests) { + "com.bitchat.android.ui.ReleaseScannerTestRunner" + } else { + "com.bitchat.android.ui.PerformanceTestRunner" + } vectorDrawables { useSupportLibrary = true } @@ -59,6 +67,10 @@ android { } } release { + if (releaseUiTests) { + signingConfig = signingConfigs.getByName("debug") + proguardFiles("release-instrumentation.pro") + } isMinifyEnabled = true isShrinkResources = true proguardFiles( diff --git a/app/gradle.lockfile b/app/gradle.lockfile index 420310107..5b64db905 100644 --- a/app/gradle.lockfile +++ b/app/gradle.lockfile @@ -2,178 +2,178 @@ # Manual edits can break the build and are not advised. # This file is expected to be part of source control. # To regenerate this file, run: ./gradlew :app:dependencies --write-locks -androidx.activity:activity-compose:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.activity:activity-ktx:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.activity:activity:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.annotation:annotation-jvm:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.annotation:annotation:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.appcompat:appcompat-resources:1.7.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.appcompat:appcompat:1.7.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.activity:activity-compose:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.activity:activity-ktx:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.activity:activity:1.13.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-experimental:1.4.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation-jvm:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.annotation:annotation:1.10.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.appcompat:appcompat-resources:1.7.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.appcompat:appcompat:1.7.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.arch.core:core-common:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.arch.core:core-runtime:2.2.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.autofill:autofill:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.camera.featurecombinationquery:featurecombinationquery:1.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.camera.viewfinder:viewfinder-compose:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.camera.viewfinder:viewfinder-core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera.viewfinder:viewfinder-compose:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera.viewfinder:viewfinder-core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.camera:camera-camera2-pipe:1.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.camera:camera-camera2:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.camera:camera-compose:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.camera:camera-core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.camera:camera-lifecycle:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation-core-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation-core:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.animation:animation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation-layout-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation-layout:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.foundation:foundation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material:material-icons-core-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath +androidx.camera:camera-camera2:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-compose:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-core:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.camera:camera-lifecycle:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection-jvm:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection-ktx:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.collection:collection:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation-core:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.animation:animation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation-layout:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.foundation:foundation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material3:material3:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-icons-core-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-core-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath -androidx.compose.material:material-icons-core:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material:material-icons-extended-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseRuntimeClasspath +androidx.compose.material:material-icons-core:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-icons-extended-android:1.7.8=debugAndroidTestCompileClasspath,debugCompileClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseRuntimeClasspath androidx.compose.material:material-icons-extended-desktop:1.7.8=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugUnitTestLintChecksClasspath,releaseLintChecksClasspath -androidx.compose.material:material-icons-extended:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material:material-ripple-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.material:material-ripple:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-annotation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-annotation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-retain-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-retain:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-saveable-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime-saveable:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.runtime:runtime:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-geometry-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-geometry:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-graphics-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-graphics:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-test-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.compose.material:material-icons-extended:1.7.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.material:material-ripple:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-annotation:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-retain:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime-saveable:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.runtime:runtime:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-geometry:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-graphics:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-test-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath +androidx.compose.ui:ui-test-junit4:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath androidx.compose.ui:ui-test-manifest:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-test:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.compose.ui:ui-text-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-text:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-test:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath +androidx.compose.ui:ui-text-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-text:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-tooling-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath androidx.compose.ui:ui-tooling-data-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath androidx.compose.ui:ui-tooling-data:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-tooling-preview-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-tooling-preview:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling-preview-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-tooling-preview:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.compose.ui:ui-tooling:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.compose.ui:ui-unit-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-unit:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-util-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui-util:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose.ui:ui:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.compose:compose-bom:2026.06.01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-unit-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-unit:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util-android:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui-util:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose.ui:ui:1.11.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.compose:compose-bom:2026.06.01=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures-ktx:1.1.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures-ktx:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.concurrent:concurrent-futures:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.concurrent:concurrent-futures:1.2.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath androidx.core:core-backported-fixes:1.0.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.core:core-ktx:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.core:core:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.documentfile:documentfile:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.dynamicanimation:dynamicanimation:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core-ktx:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core-viewtree:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.core:core:1.19.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.cursoradapter:cursoradapter:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.customview:customview-poolingcontainer:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.customview:customview:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.documentfile:documentfile:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.drawerlayout:drawerlayout:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.dynamicanimation:dynamicanimation:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.emoji2:emoji2-views-helper:1.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.exifinterface:exifinterface:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.fragment:fragment:1.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.interpolator:interpolator:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.legacy:legacy-support-core-utils:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.emoji2:emoji2:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.exifinterface:exifinterface:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.fragment:fragment:1.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.graphics:graphics-path:1.0.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.interpolator:interpolator:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.legacy:legacy-support-core-utils:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-common-java8:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-common-jvm:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-common:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-livedata-core:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-process:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-runtime-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-runtime-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-runtime-ktx-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-runtime-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-runtime:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common-jvm:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-common:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata-core:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-livedata:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-process:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-runtime:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.lifecycle:lifecycle-service:2.11.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-viewmodel-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-viewmodel-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.lifecycle:lifecycle-viewmodel:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.navigation:navigation-common-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.navigation:navigation-common:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.navigation:navigation-compose-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.navigation:navigation-compose:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.navigation:navigation-runtime-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.navigation:navigation-runtime:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.print:print:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-compose-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-compose:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-ktx:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate-android:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel-savedstate:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.lifecycle:lifecycle-viewmodel:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.loader:loader:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.localbroadcastmanager:localbroadcastmanager:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigation:navigation-common-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigation:navigation-common:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigation:navigation-compose-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigation:navigation-compose:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigation:navigation-runtime-android:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigation:navigation-runtime:2.9.8=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigationevent:navigationevent-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigationevent:navigationevent-compose-android:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigationevent:navigationevent-compose:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.navigationevent:navigationevent:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.print:print:1.0.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.profileinstaller:profileinstaller:1.4.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.resourceinspection:resourceinspection-annotation:1.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.room:room-common:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.room:room-ktx:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.room:room-runtime:2.6.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.savedstate:savedstate-compose-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.savedstate:savedstate-compose:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.security:security-crypto:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-compose-android:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-compose:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate-ktx:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.savedstate:savedstate:1.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.security:security-crypto:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.sqlite:sqlite-framework:2.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.sqlite:sqlite:2.4.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseCompileClasspath -androidx.startup:startup-runtime:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.test.espresso:espresso-core:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.startup:startup-runtime:1.1.1=debugAndroidTestCompileClasspath,debugCompileClasspath,debugUnitTestCompileClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath +androidx.startup:startup-runtime:1.2.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.test.espresso:espresso-core:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath androidx.test.espresso:espresso-core:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.test.espresso:espresso-idling-resource:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test.espresso:espresso-idling-resource:3.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath androidx.test.espresso:espresso-idling-resource:3.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.test.ext:junit:1.1.5=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test.ext:junit:1.1.5=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath androidx.test.ext:junit:1.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.test.services:storage:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test.services:storage:1.4.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath androidx.test.services:storage:1.6.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.test:annotation:1.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -androidx.test:core:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:annotation:1.0.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath +androidx.test:core:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath androidx.test:core:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.test:monitor:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:monitor:1.6.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath androidx.test:monitor:1.8.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.test:runner:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +androidx.test:runner:1.5.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath androidx.test:runner:1.7.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -androidx.tracing:tracing-android:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.tracing:tracing-android:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath androidx.tracing:tracing-ktx:1.3.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.tracing:tracing:1.0.0=debugAndroidTestCompileClasspath +androidx.tracing:tracing:1.0.0=debugAndroidTestCompileClasspath,releaseAndroidTestCompileClasspath androidx.tracing:tracing:1.1.0=debugUnitTestCompileClasspath -androidx.tracing:tracing:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.transition:transition:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.vectordrawable:vectordrawable:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.viewpager:viewpager:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.window:window-core-android:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.window:window-core:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.window:window:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.work:work-runtime-ktx:2.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -androidx.work:work-runtime:2.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.tracing:tracing:1.3.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.transition:transition:1.6.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.vectordrawable:vectordrawable-animated:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.vectordrawable:vectordrawable:1.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.versionedparcelable:versionedparcelable:1.1.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.viewpager:viewpager:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.window:window-core-android:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.window:window-core:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.window:window:1.5.0=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.work:work-runtime-ktx:2.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +androidx.work:work-runtime:2.10.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.almworks.sqlite4java:sqlite4java:1.0.392=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath com.android.tools.analytics-library:protos:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools.analytics-library:shared:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle @@ -218,16 +218,16 @@ com.android.tools:play-sdk-proto:32.3.1=androidLintTool com.android.tools:repository:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools:sdk-common:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.android.tools:sdklib:32.3.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle -com.google.accompanist:accompanist-permissions:0.37.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.datatransport:transport-api:2.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.datatransport:transport-backend-cct:2.3.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.datatransport:transport-runtime:2.2.6=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.gms:play-services-base:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.gms:play-services-basement:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.gms:play-services-location:21.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.gms:play-services-tasks:18.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.android.odml:image:1.0.0-beta1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.accompanist:accompanist-permissions:0.37.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.datatransport:transport-api:2.2.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.datatransport:transport-backend-cct:2.3.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.datatransport:transport-runtime:2.2.6=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-base:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-basement:18.9.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-location:21.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-mlkit-barcode-scanning:18.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.gms:play-services-tasks:18.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.android.odml:image:1.0.0-beta1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.android:annotations:4.1.1.4=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-core com.google.api.grpc:proto-google-common-protos:2.17.0=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core com.google.api.grpc:proto-google-common-protos:2.48.0=unified-test-platform-android-test-plugin-host-emulator-control @@ -236,39 +236,39 @@ com.google.auto.service:auto-service:1.1.1=unified-test-platform-android-device- com.google.auto.value:auto-value-annotations:1.11.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath com.google.auto.value:auto-value-annotations:1.6.3=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.auto:auto-common:1.2.1=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle -com.google.code.findbugs:jsr305:3.0.2=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +com.google.code.findbugs:jsr305:3.0.2=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher com.google.code.gson:gson:2.10.1=unified-test-platform-core,unified-test-platform-gradle-work-action com.google.code.gson:gson:2.11.0=androidLintTool,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-result-listener-gradle -com.google.code.gson:gson:2.14.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.code.gson:gson:2.14.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.code.gson:gson:2.8.9=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher -com.google.crypto.tink:tink-android:1.23.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.crypto.tink:tink-android:1.23.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.crypto.tink:tink:1.18.0=unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action com.google.dagger:dagger:2.48=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action com.google.dagger:dagger:2.59=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.errorprone:error_prone_annotation:2.38.0=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath com.google.errorprone:error_prone_annotations:2.23.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher com.google.errorprone:error_prone_annotations:2.36.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action -com.google.errorprone:error_prone_annotations:2.48.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.firebase:firebase-annotations:16.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.firebase:firebase-components:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.firebase:firebase-encoders-json:17.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.firebase:firebase-encoders:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.errorprone:error_prone_annotations:2.48.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-annotations:16.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-components:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-encoders-json:17.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.firebase:firebase-encoders:16.1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.guava:failureaccess:1.0.1=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher com.google.guava:failureaccess:1.0.2=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action com.google.guava:failureaccess:1.0.3=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath com.google.guava:guava:32.0.1-jre=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher com.google.guava:guava:33.4.0-jre=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action com.google.guava:guava:33.4.8-jre=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.guava:listenablefuture:1.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=androidLintTool,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher com.google.j2objc:j2objc-annotations:2.8=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher com.google.j2objc:j2objc-annotations:3.0.0=androidLintTool,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action com.google.jimfs:jimfs:1.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle -com.google.mlkit:barcode-scanning-common:17.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.mlkit:barcode-scanning:17.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.mlkit:common:18.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.mlkit:vision-common:17.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.google.mlkit:vision-interfaces:16.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.mlkit:barcode-scanning-common:17.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.mlkit:barcode-scanning:17.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.mlkit:common:18.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.mlkit:vision-common:17.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.mlkit:vision-interfaces:16.3.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.google.protobuf:protobuf-java-util:3.22.3=unified-test-platform-core com.google.protobuf:protobuf-java-util:4.28.3=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher com.google.protobuf:protobuf-java:3.25.5=androidLintTool @@ -281,14 +281,14 @@ com.google.testing.platform:core-proto:0.0.9-alpha04=unified-test-platform-andro com.google.testing.platform:core:0.0.9-alpha04=unified-test-platform-core com.google.testing.platform:launcher:0.0.9-alpha04=unified-test-platform-gradle-work-action,unified-test-platform-launcher com.google.testparameterinjector:test-parameter-injector:1.18=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -com.google.zxing:core:3.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.google.zxing:core:3.5.4=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath com.ibm.icu:icu4j:77.1=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath com.squareup.okhttp3:mockwebserver3:5.4.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -com.squareup.okhttp3:okhttp-android:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.squareup.okhttp3:okhttp:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.squareup.okio:okio-jvm:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.squareup.okio:okio:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -com.squareup:javawriter:2.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath +com.squareup.okhttp3:okhttp-android:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.squareup.okhttp3:okhttp:5.4.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.squareup.okio:okio-jvm:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.squareup.okio:okio:3.17.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +com.squareup:javawriter:2.1.1=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath com.sun.istack:istack-commons-runtime:3.0.8=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle com.sun.xml.fastinfoset:FastInfoset:1.2.16=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle commons-codec:commons-codec:1.17.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle @@ -340,14 +340,14 @@ jakarta.activation:jakarta.activation-api:1.2.1=androidLintTool,unified-test-pla jakarta.inject:jakarta.inject-api:2.0.1=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath jakarta.xml.bind:jakarta.xml.bind-api:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle javax.annotation:javax.annotation-api:1.3.2=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,unified-test-platform-android-test-plugin-host-emulator-control -javax.inject:javax.inject:1=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action -junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +javax.inject:javax.inject:1=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action +junit:junit:4.13.2=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath net.bytebuddy:byte-buddy-agent:1.17.7=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath net.bytebuddy:byte-buddy:1.17.7=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath net.java.dev.jna:jna-platform:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action net.java.dev.jna:jna:5.6.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action net.sf.kxml:kxml2:2.3.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action -no.nordicsemi.android:ble:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +no.nordicsemi.android:ble:2.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.apache.commons:commons-compress:1.27.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.apache.commons:commons-lang3:3.16.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.apache.httpcomponents:httpclient:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle @@ -355,7 +355,7 @@ org.apache.httpcomponents:httpcore:4.4.16=androidLintTool,unified-test-platform- org.apache.httpcomponents:httpmime:4.5.6=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.bouncycastle:bcpkix-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.bouncycastle:bcprov-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle -org.bouncycastle:bcprov-jdk18on:1.85=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.bouncycastle:bcprov-jdk18on:1.85=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.bouncycastle:bcutil-jdk18on:1.79=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.checkerframework:checker-qual:3.33.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-core,unified-test-platform-launcher org.checkerframework:checker-qual:3.43.0=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action @@ -365,34 +365,34 @@ org.codehaus.mojo:animal-sniffer-annotations:1.24=unified-test-platform-android- org.conscrypt:conscrypt-openjdk-uber:2.5.2=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.glassfish.jaxb:jaxb-runtime:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.glassfish.jaxb:txw2:2.3.2=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle -org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -org.hamcrest:hamcrest-integration:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath -org.hamcrest:hamcrest-library:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath +org.hamcrest:hamcrest-core:1.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath +org.hamcrest:hamcrest-integration:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath +org.hamcrest:hamcrest-library:1.3=debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestRuntimeClasspath org.jetbrains.kotlin:compose-group-mapping:2.4.10=composeMappingProducerClasspath org.jetbrains.kotlin:kotlin-build-tools-api:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-build-tools-api:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-build-tools-api:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseAndroidTest org.jetbrains.kotlin:kotlin-build-tools-compat:2.4.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-build-tools-cri-impl:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-build-tools-impl:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-compiler-embeddable:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseAndroidTest org.jetbrains.kotlin:kotlin-compiler-runner:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-compiler-runner:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath -org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.4.10=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-compose-compiler-plugin-embeddable:2.4.10=kotlin-extension,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseAndroidTest org.jetbrains.kotlin:kotlin-daemon-client:2.4.0=kotlinAbiValidationCompatClasspath org.jetbrains.kotlin:kotlin-daemon-client:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease -org.jetbrains.kotlin:kotlin-parcelize-compiler:2.4.10=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease -org.jetbrains.kotlin:kotlin-parcelize-runtime:2.4.10=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugRuntimeClasspathCopy,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseRuntimeClasspathCopy -org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-daemon-embeddable:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseAndroidTest +org.jetbrains.kotlin:kotlin-parcelize-compiler:2.4.10=kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseAndroidTest +org.jetbrains.kotlin:kotlin-parcelize-runtime:2.4.10=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugRuntimeClasspathCopy,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseRuntimeClasspathCopy +org.jetbrains.kotlin:kotlin-reflect:1.6.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseAndroidTest org.jetbrains.kotlin:kotlin-reflect:1.8.21=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher org.jetbrains.kotlin:kotlin-reflect:2.1.20=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.jetbrains.kotlin:kotlin-reflect:2.2.10=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.jetbrains.kotlin:kotlin-script-runtime:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-script-runtime:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlin:kotlin-script-runtime:2.4.10=kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseAndroidTest org.jetbrains.kotlin:kotlin-stdlib-common:1.8.21=unified-test-platform-android-test-plugin,unified-test-platform-core org.jetbrains.kotlin:kotlin-stdlib-common:1.9.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-launcher org.jetbrains.kotlin:kotlin-stdlib-common:2.2.10=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-gradle-work-action @@ -408,35 +408,35 @@ org.jetbrains.kotlin:kotlin-stdlib:1.9.0=unified-test-platform-android-driver-in org.jetbrains.kotlin:kotlin-stdlib:2.2.10=androidLintTool,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-gradle-work-action org.jetbrains.kotlin:kotlin-stdlib:2.2.21=composeMappingProducerClasspath org.jetbrains.kotlin:kotlin-stdlib:2.4.0=kotlinAbiValidationCompatClasspath -org.jetbrains.kotlin:kotlin-stdlib:2.4.10=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugRuntimeClasspathCopy,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseRuntimeClasspathCopy +org.jetbrains.kotlin:kotlin-stdlib:2.4.10=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugRuntimeClasspathCopy,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseAndroidTest,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,releaseRuntimeClasspathCopy org.jetbrains.kotlin:kotlin-tooling-core:2.4.10=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath org.jetbrains.kotlinx:atomicfu-jvm:0.22.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher org.jetbrains.kotlinx:atomicfu-jvm:0.28.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:atomicfu:0.22.0=unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-gradle-work-action,unified-test-platform-launcher org.jetbrains.kotlinx:atomicfu:0.28.0=debugAndroidTestLintChecksClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher org.jetbrains.kotlinx:kotlinx-coroutines-bom:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher -org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease +org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.8.0=kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseAndroidTest org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle -org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-core:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3=unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher org.jetbrains.kotlinx:kotlinx-coroutines-core:1.9.0=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle -org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath -org.jetbrains:annotations:13.0=composeMappingProducerClasspath,debugRuntimeClasspathCopy,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,releaseRuntimeClasspathCopy -org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher -org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test-jvm:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-coroutines-test:1.11.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-bom:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core-jvm:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains.kotlinx:kotlinx-serialization-core:1.7.3=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.jetbrains:annotations:13.0=composeMappingProducerClasspath,debugRuntimeClasspathCopy,kotlinAbiValidationCompatClasspath,kotlinBuildToolsApiClasspath,kotlinCompilerClasspath,kotlinCompilerPluginClasspathDebug,kotlinCompilerPluginClasspathDebugAndroidTest,kotlinCompilerPluginClasspathDebugUnitTest,kotlinCompilerPluginClasspathRelease,kotlinCompilerPluginClasspathReleaseAndroidTest,releaseRuntimeClasspathCopy +org.jetbrains:annotations:23.0.0=androidLintTool,debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath,unified-test-platform-android-device-provider-ddmlib,unified-test-platform-android-driver-instrumentation,unified-test-platform-android-test-plugin,unified-test-platform-android-test-plugin-host-additional-test-output,unified-test-platform-android-test-plugin-host-apk-installer,unified-test-platform-android-test-plugin-host-coverage,unified-test-platform-android-test-plugin-host-device-info,unified-test-platform-android-test-plugin-host-emulator-control,unified-test-platform-android-test-plugin-host-logcat,unified-test-platform-android-test-plugin-result-listener-gradle,unified-test-platform-core,unified-test-platform-gradle-work-action,unified-test-platform-launcher +org.jspecify:jspecify:1.0.0=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugAndroidTestRuntimeClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseAndroidTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.jvnet.staxex:stax-ex:1.8.1=androidLintTool,unified-test-platform-android-test-plugin-result-listener-gradle org.mockito.kotlin:mockito-kotlin:6.3.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.mockito:mockito-core:5.23.0=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -org.nanohttpd:nanohttpd:2.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath +org.nanohttpd:nanohttpd:2.3.1=debugAndroidTestCompileClasspath,debugAndroidTestLintChecksClasspath,debugCompileClasspath,debugLintChecksClasspath,debugRuntimeClasspath,debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath,releaseAndroidTestCompileClasspath,releaseCompileClasspath,releaseLintChecksClasspath,releaseRuntimeClasspath org.objenesis:objenesis:3.3=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.ow2.asm:asm-analysis:9.9=androidLintTool org.ow2.asm:asm-commons:9.8=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath @@ -461,4 +461,4 @@ org.robolectric:shadows-framework:4.15=debugUnitTestCompileClasspath,debugUnitTe org.robolectric:utils-reflector:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.robolectric:utils:4.15=debugUnitTestCompileClasspath,debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath org.yaml:snakeyaml:2.4=debugUnitTestLintChecksClasspath,debugUnitTestRuntimeClasspath -empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,lintChecks,lintPublish,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues +empty=androidApis,androidJdkImage,androidTestUtil,coreLibraryDesugaring,debugAndroidTestAnnotationProcessorClasspath,debugAndroidTestImplementationDependenciesMetadata,debugAnnotationProcessorClasspath,debugImplementationDependenciesMetadata,debugReverseMetadataValues,debugUnitTestAnnotationProcessorClasspath,debugUnitTestImplementationDependenciesMetadata,kotlinCompilerPluginClasspath,lintChecks,lintPublish,releaseAndroidTestAnnotationProcessorClasspath,releaseAnnotationProcessorClasspath,releaseImplementationDependenciesMetadata,releaseReverseMetadataValues diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 4363120a0..74d1a32e8 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -24,6 +24,14 @@ (); } +# ML Kit discovers Firebase component registrars from manifest metadata and calls +# their no-argument constructors reflectively. Keeping the class alone does not +# retain that constructor in R8 full mode, leaving BarcodeScanning without its +# registered components and crashing when the verification scanner opens. +-keepclassmembers class * implements com.google.firebase.components.ComponentRegistrar { + public (); +} + # Keep Tor implementation (always included) -keep class com.bitchat.android.net.RealTorProvider { *; } diff --git a/app/release-instrumentation.pro b/app/release-instrumentation.pro new file mode 100644 index 000000000..67142eb02 --- /dev/null +++ b/app/release-instrumentation.pro @@ -0,0 +1,9 @@ +# Keep only the real analyzer entry point for tests. Do not keep ML Kit internals: +# doing so would hide regressions in the production dependency-discovery graph. +-keep class com.bitchat.android.ui.QRCodeAnalyzer { + (kotlin.jvm.functions.Function1); + public void analyze(androidx.camera.core.ImageProxy); +} +# CameraX's public frame interfaces are implemented by the synthetic test source. +-keep interface androidx.camera.core.ImageProxy { *; } +-keep interface androidx.camera.core.ImageInfo { *; } diff --git a/app/src/androidTest/java/com/bitchat/android/ui/BarcodeScannerInstrumentedTest.kt b/app/src/androidTest/java/com/bitchat/android/ui/BarcodeScannerInstrumentedTest.kt new file mode 100644 index 000000000..819ddaf45 --- /dev/null +++ b/app/src/androidTest/java/com/bitchat/android/ui/BarcodeScannerInstrumentedTest.kt @@ -0,0 +1,122 @@ +package com.bitchat.android.ui + +import android.graphics.ImageFormat +import android.media.ImageReader +import android.media.ImageWriter +import android.os.Handler +import android.os.HandlerThread +import androidx.camera.core.ImageInfo +import androidx.camera.core.ImageProxy +import java.lang.reflect.Proxy +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicReference +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Calls the same analyzer constructor as ScannerView, with minified ML Kit dependencies. */ +class BarcodeScannerInstrumentedTest { + @Test + fun scannerCreationCompletesAfterColdStart() { + QRCodeAnalyzer { } + } + + @Test + fun realAnalyzerDecodesSyntheticCameraFrameAndClosesIt() { + val decoded = AtomicReference() + val codeReceived = CountDownLatch(1) + val frameClosed = CountDownLatch(1) + val analyzer = QRCodeAnalyzer { + decoded.set(it) + codeReceived.countDown() + } + val thread = HandlerThread("synthetic-scanner-frame").apply { start() } + // A fixed, synthetic QR encodes "bitchat:test-scanner". No camera or permissions needed. + val modules = java.util.ArrayList() + val rows = java.util.StringTokenizer(QR_MODULES) + while (rows.hasMoreTokens()) modules.add(rows.nextToken()) + val scale = 8 + val size = modules.size * scale + val reader = ImageReader.newInstance(size, size, ImageFormat.YUV_420_888, 2) + val writer = ImageWriter.newInstance(reader.surface, 2) + try { + reader.setOnImageAvailableListener({ source -> + val image = source.acquireNextImage() ?: return@setOnImageAvailableListener + val info = Proxy.newProxyInstance(ImageInfo::class.java.classLoader, + arrayOf(ImageInfo::class.java)) { _, method, _ -> + when (method.name) { + "getRotationDegrees" -> 0 + else -> error("Unexpected frame info: ${method.name}") + } + } as ImageInfo + val proxy = Proxy.newProxyInstance(ImageProxy::class.java.classLoader, + arrayOf(ImageProxy::class.java)) { _, method, _ -> + when (method.name) { + "getImage" -> image + "getImageInfo" -> info + "close" -> { image.close(); frameClosed.countDown(); null } + else -> error("Unexpected frame access: ${method.name}") + } + } as ImageProxy + analyzer.analyze(proxy) + }, Handler(thread.looper)) + val input = writer.dequeueInputImage() + input.planes.forEachIndexed { planeIndex, plane -> + val planeSize = if (planeIndex == 0) size else size / 2 + for (y in 0 until planeSize) for (x in 0 until planeSize) { + val value = if (planeIndex != 0) 128 else + if (modules[y / scale][x / scale] == '1') 0 else 255 + plane.buffer.put(y * plane.rowStride + x * plane.pixelStride, value.toByte()) + } + } + writer.queueInputImage(input) + assertTrue("Real release scanner must decode the frame", codeReceived.await(20, TimeUnit.SECONDS)) + assertEquals("bitchat:test-scanner", decoded.get()) + assertTrue("Analyzer must release the camera frame", frameClosed.await(5, TimeUnit.SECONDS)) + } finally { + writer.close() + reader.close() + thread.quitSafely() + thread.join(5_000) + } + } + + private companion object { + const val QR_MODULES = """ + 000000000000000000000000000000000 + 000000000000000000000000000000000 + 000000000000000000000000000000000 + 000000000000000000000000000000000 + 000011111110110001001011111110000 + 000010000010000001000010000010000 + 000010111010100111010010111010000 + 000010111010110101100010111010000 + 000010111010111100111010111010000 + 000010000010010011100010000010000 + 000011111110101010101011111110000 + 000000000000010100000000000000000 + 000011110010100000101100111010000 + 000011001001110000101001000100000 + 000011101010000001101000100000000 + 000010001001000111011100011100000 + 000010100011110100010111111000000 + 000000110101011101111011100110000 + 000001101110100010100001110100000 + 000010111101011011000100100000000 + 000000101111110100001111101100000 + 000000000000101011011000100010000 + 000011111110000110001010110110000 + 000010000010010010011000110100000 + 000010111010010111001111100100000 + 000010111010100011100111110110000 + 000010111010101111011010110100000 + 000010000010110000000101011000000 + 000011111110101010110011001110000 + 000000000000000000000000000000000 + 000000000000000000000000000000000 + 000000000000000000000000000000000 + 000000000000000000000000000000000 + """ + } +} diff --git a/app/src/androidTest/java/com/bitchat/android/ui/ReleaseScannerTestRunner.kt b/app/src/androidTest/java/com/bitchat/android/ui/ReleaseScannerTestRunner.kt new file mode 100644 index 000000000..1ab8b64ed --- /dev/null +++ b/app/src/androidTest/java/com/bitchat/android/ui/ReleaseScannerTestRunner.kt @@ -0,0 +1,29 @@ +package com.bitchat.android.ui + +import android.app.Application +import android.app.Instrumentation +import android.content.Context +import android.os.Bundle +import org.junit.runner.JUnitCore + +/** Minimal runner avoids instrumentation-only tracing dependencies removed by target R8. */ +class ReleaseScannerTestRunner : Instrumentation() { + override fun newApplication(cl: ClassLoader, className: String, context: Context): Application = + super.newApplication(cl, Application::class.java.name, context) + + override fun onCreate(arguments: Bundle?) { + super.onCreate(arguments) + start() + } + + override fun onStart() { + val result = JUnitCore.runClasses(BarcodeScannerInstrumentedTest::class.java) + val report = Bundle().apply { + putString("stream", buildString { + append("Tests run: ${result.runCount}, failures: ${result.failureCount}\n") + result.failures.forEach { append(it.trace).append('\n') } + }) + } + finish(if (result.wasSuccessful()) -1 else 0, report) + } +} diff --git a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt index a84c3c947..fca163999 100644 --- a/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt +++ b/app/src/main/java/com/bitchat/android/ui/VerificationSheet.kt @@ -512,7 +512,7 @@ private fun bitmapFromMatrix(matrix: BitMatrix): Bitmap { return bitmap } -private class QRCodeAnalyzer( +internal class QRCodeAnalyzer( private val onCode: (String) -> Unit ) : ImageAnalysis.Analyzer { private val scanner = BarcodeScanning.getClient( diff --git a/tools/release_gate/scanner_release_regression.sh b/tools/release_gate/scanner_release_regression.sh new file mode 100644 index 000000000..a190b3484 --- /dev/null +++ b/tools/release_gate/scanner_release_regression.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# Reproduce/verify the real scanner entry point after production R8 shrinking. +# Usage: bash tools/release_gate/scanner_release_regression.sh +# Requires JDK 21, the pinned Android SDK, and adb on PATH. Never use publishable +# signing keys here: -PreleaseUiTests=true uses the debug key for test APKs only. +set -euo pipefail + +if [[ $# != 1 || "$1" != emulator-* ]]; then + echo "Usage: $0 " >&2 + exit 2 +fi +scanner_emulator="$1" +if [[ "$(adb -s "$scanner_emulator" shell getprop ro.kernel.qemu | tr -d '\r')" != 1 ]]; then + echo "Refusing to install: target is not an emulator." >&2 + exit 2 +fi +scanner_abi="$(adb -s "$scanner_emulator" shell getprop ro.product.cpu.abi | tr -d '\r')" +case "$scanner_abi" in + arm64-v8a|x86_64|armeabi-v7a|x86) ;; + *) echo "Unsupported emulator ABI." >&2; exit 2 ;; +esac + +cd "$(git rev-parse --show-toplevel)" +# This must be a disposable emulator. Keep the fixture offline and location off; +# no real camera, identities, messages, or transport startup are involved. +adb -s "$scanner_emulator" shell svc wifi disable +adb -s "$scanner_emulator" shell svc data disable +adb -s "$scanner_emulator" shell settings put secure location_mode 0 +./gradlew -PreleaseUiTests=true :app:assembleRelease :app:assembleReleaseAndroidTest +adb -s "$scanner_emulator" install -r "app/build/outputs/apk/release/app-${scanner_abi}-release.apk" +adb -s "$scanner_emulator" install -r app/build/outputs/apk/androidTest/release/app-release-androidTest.apk + +scanner_evidence="$(mktemp -d "${TMPDIR:-/tmp}/scanner-release.XXXXXX")" +chmod 700 "$scanner_evidence" +adb -s "$scanner_emulator" shell am instrument -w -r \ + com.bitchat.droid.test/com.bitchat.android.ui.ReleaseScannerTestRunner \ + > "$scanner_evidence/result.txt" +cat "$scanner_evidence/result.txt" +# adb itself exits successfully even when instrumentation tests fail. +grep -q '^INSTRUMENTATION_RESULT: stream=Tests run: 2, failures: 0' "$scanner_evidence/result.txt" +grep -q '^INSTRUMENTATION_CODE: -1' "$scanner_evidence/result.txt" +echo "Release scanner creation, synthetic QR decoding, and frame cleanup passed." From f5419f0e908d79e1d1742f37b4de8a35a78bf12a Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:51:58 +0100 Subject: [PATCH 15/17] Fix concurrent security cache and relay snapshots --- .../bitchat/android/mesh/SecurityManager.kt | 95 +++++++++++-------- .../android/nostr/NostrRelayManager.kt | 7 +- .../android/mesh/SecurityManagerTest.kt | 73 ++++++++++++++ .../android/nostr/NostrRelaySnapshotTest.kt | 55 +++++++++++ 4 files changed, 186 insertions(+), 44 deletions(-) create mode 100644 app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySnapshotTest.kt diff --git a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt index 6fab9f031..19dd66d8a 100644 --- a/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt +++ b/app/src/main/java/com/bitchat/android/mesh/SecurityManager.kt @@ -97,8 +97,10 @@ class SecurityManager(private val encryptionService: EncryptionService, private // Record only authenticated packets. Recording an attacker-controlled // invalid packet first would let it poison duplicate detection for a // later legitimate packet with the same timestamp and payload. - processedMessages.add(messageID) - messageTimestamps[messageID] = currentTime + synchronized(messageTimestamps) { + processedMessages.add(messageID) + messageTimestamps[messageID] = currentTime + } return true } @@ -136,8 +138,10 @@ class SecurityManager(private val encryptionService: EncryptionService, private // flow and reports whether this exact frame completed authentication. Never infer that // from ambient session state: a rejected replacement may leave the old session active. val result = encryptionService.processHandshakeMessageWithResult(packet.payload, peerID) - processedKeyExchanges.add(exchangeKey) - keyExchangeTimestamps[exchangeKey] = System.currentTimeMillis() + synchronized(keyExchangeTimestamps) { + processedKeyExchanges.add(exchangeKey) + keyExchangeTimestamps[exchangeKey] = System.currentTimeMillis() + } if (result.response != null) { // Send handshake response through delegate @@ -389,42 +393,45 @@ class SecurityManager(private val encryptionService: EncryptionService, private internal fun cleanupOldData(nowMs: Long = System.currentTimeMillis()) { val cutoffTime = nowMs - MESSAGE_TIMEOUT - // Clean up old message timestamps and corresponding processed messages - val messagesToRemove = messageTimestamps.entries.filter { (_, timestamp) -> - timestamp < cutoffTime - }.map { it.key } - - messagesToRemove.forEach { messageId -> - messageTimestamps.remove(messageId) - processedMessages.remove(messageId) + // Synchronized collection wrappers protect individual operations, not iteration. + // Keep each timestamp/set pair locked through expiry and capacity eviction so + // cleanup cannot remove a freshly recorded entry or traverse a mutating set. + synchronized(messageTimestamps) { + synchronized(processedMessages) { + val messagesToRemove = messageTimestamps.entries.filter { (_, timestamp) -> + timestamp < cutoffTime + }.map { it.key } + messagesToRemove.forEach { messageId -> + messageTimestamps.remove(messageId) + processedMessages.remove(messageId) + } + if (processedMessages.size > MAX_PROCESSED_MESSAGES) { + val excess = processedMessages.size - MAX_PROCESSED_MESSAGES + val toRemove = processedMessages.take(excess) + processedMessages.removeAll(toRemove.toSet()) + removeFromMessageTimestamps(toRemove) + } + } } - // Limit the size of processed messages set - if (processedMessages.size > MAX_PROCESSED_MESSAGES) { - val excess = processedMessages.size - MAX_PROCESSED_MESSAGES - val toRemove = processedMessages.take(excess) - processedMessages.removeAll(toRemove.toSet()) - removeFromMessageTimestamps(toRemove) - } - - // Expire handshake dedup entries by time so a delayed same-ephemeral delivery - // (e.g. a re-handshake retry after a failed attempt) is not blocked forever. - val keyExchangeCutoff = nowMs - KEY_EXCHANGE_DEDUP_TIMEOUT - val keyExchangesToRemove = keyExchangeTimestamps.entries.filter { (_, timestamp) -> - timestamp < keyExchangeCutoff - }.map { it.key } - - keyExchangesToRemove.forEach { exchangeKey -> - keyExchangeTimestamps.remove(exchangeKey) - processedKeyExchanges.remove(exchangeKey) - } - - // Limit the size of processed key exchanges set - if (processedKeyExchanges.size > MAX_PROCESSED_KEY_EXCHANGES) { - val excess = processedKeyExchanges.size - MAX_PROCESSED_KEY_EXCHANGES - val toRemove = processedKeyExchanges.take(excess) - processedKeyExchanges.removeAll(toRemove.toSet()) - toRemove.forEach { keyExchangeTimestamps.remove(it) } + synchronized(keyExchangeTimestamps) { + synchronized(processedKeyExchanges) { + // Expire delayed handshake retries as well as bounding the cache size. + val keyExchangeCutoff = nowMs - KEY_EXCHANGE_DEDUP_TIMEOUT + val keyExchangesToRemove = keyExchangeTimestamps.entries.filter { (_, timestamp) -> + timestamp < keyExchangeCutoff + }.map { it.key } + keyExchangesToRemove.forEach { exchangeKey -> + keyExchangeTimestamps.remove(exchangeKey) + processedKeyExchanges.remove(exchangeKey) + } + if (processedKeyExchanges.size > MAX_PROCESSED_KEY_EXCHANGES) { + val excess = processedKeyExchanges.size - MAX_PROCESSED_KEY_EXCHANGES + val toRemove = processedKeyExchanges.take(excess) + processedKeyExchanges.removeAll(toRemove.toSet()) + toRemove.forEach { keyExchangeTimestamps.remove(it) } + } + } } } @@ -441,10 +448,14 @@ class SecurityManager(private val encryptionService: EncryptionService, private * Clear all security data */ fun clearAllData() { - processedMessages.clear() - processedKeyExchanges.clear() - messageTimestamps.clear() - keyExchangeTimestamps.clear() + synchronized(messageTimestamps) { + processedMessages.clear() + messageTimestamps.clear() + } + synchronized(keyExchangeTimestamps) { + processedKeyExchanges.clear() + keyExchangeTimestamps.clear() + } } /** diff --git a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt index 81c1cac6a..18f4952a4 100644 --- a/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt +++ b/app/src/main/java/com/bitchat/android/nostr/NostrRelayManager.kt @@ -424,7 +424,7 @@ class NostrRelayManager private constructor() { liveLocationConnectionJobs.forEach(Job::cancel) liveLocationConnectionJobs.clear() - val sockets = connections.values.toList() + val sockets = buildList { connections.values.forEach { add(it) } } connections.clear() sockets.forEach { webSocket -> webSocket.close(1000, "Manual disconnect") @@ -514,7 +514,10 @@ class NostrRelayManager private constructor() { scope.launch { if (!isNetworkActionAllowed(subscriptionInfo.liveLocationToken)) return@launch - val targetRelays = subscriptionInfo.targetRelayUrls?.toList() ?: connections.keys.toList() + // A concurrent view can shrink between Collection.size and iterator.next. + // Build by iteration instead of Kotlin's size-based toList fast paths. + val targetRelays = subscriptionInfo.targetRelayUrls?.toList() + ?: buildList { connections.keys.forEach { add(it) } } targetRelays.forEach { relayUrl -> val webSocket = connections[relayUrl] diff --git a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt index e43e11b59..43ad0f874 100644 --- a/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt +++ b/app/src/test/kotlin/com/bitchat/android/mesh/SecurityManagerTest.kt @@ -10,6 +10,10 @@ import com.bitchat.android.noise.NoiseSessionError import com.bitchat.android.protocol.BitchatPacket import com.bitchat.android.protocol.MessageType import kotlinx.coroutines.runBlocking +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import org.junit.After import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -99,6 +103,75 @@ class SecurityManagerTest { } } + @Test + fun `cleanup serializes timestamp traversal with concurrent packet tracking`() { + assertCleanupSerializesWrites("messageTimestamps") + } + + @Test + fun `cleanup serializes timestamp traversal with concurrent handshake tracking`() { + assertCleanupSerializesWrites("keyExchangeTimestamps") + } + + private fun assertCleanupSerializesWrites(fieldName: String) { + val allowWrite = CountDownLatch(1) + val writeFinished = CountDownLatch(1) + lateinit var timestamps: MutableMap + val backing = object : LinkedHashMap() { + override val entries: MutableSet> + get() { + val original = super.entries + return object : AbstractMutableSet>() { + override val size get() = original.size + override fun add(element: MutableMap.MutableEntry) = + error("Not used") + override fun iterator(): MutableIterator> { + val iterator = original.iterator() + var first = true + return object : MutableIterator> { + override fun hasNext() = iterator.hasNext() + override fun remove() = iterator.remove() + override fun next(): MutableMap.MutableEntry { + if (first) { + first = false + allowWrite.countDown() + // A correct synchronized traversal blocks the writer. An + // unguarded traversal deterministically exposes the CME. + if (!Thread.holdsLock(timestamps)) { + assertTrue(writeFinished.await(5, TimeUnit.SECONDS)) + } + } + return iterator.next() + } + } + } + } + } + } + timestamps = Collections.synchronizedMap(backing) + timestamps["expired-a"] = 1L + timestamps["expired-b"] = 2L + SecurityManager::class.java.getDeclaredField(fieldName).apply { + isAccessible = true + set(securityManager, timestamps) + } + val now = 10_000_000L + val executor = Executors.newSingleThreadExecutor() + val writer = executor.submit { + check(allowWrite.await(5, TimeUnit.SECONDS)) + timestamps["fresh"] = now + writeFinished.countDown() + } + try { + securityManager.cleanupOldData(now) + writer.get(5, TimeUnit.SECONDS) + assertTrue(timestamps.keys == setOf("fresh")) + } finally { + allowWrite.countDown() + executor.shutdownNow() + } + } + @Test fun `validatePacket - rejects packet with missing signature`() { val packet = BitchatPacket( diff --git a/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySnapshotTest.kt b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySnapshotTest.kt new file mode 100644 index 000000000..f3334c47d --- /dev/null +++ b/app/src/test/kotlin/com/bitchat/android/nostr/NostrRelaySnapshotTest.kt @@ -0,0 +1,55 @@ +package com.bitchat.android.nostr + +import java.util.concurrent.ConcurrentHashMap +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import okhttp3.WebSocket +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class NostrRelaySnapshotTest { + @Test + fun `subscription tolerates last relay disconnecting between size and iteration`() { + val manager = NostrRelayManager::class.java.getDeclaredConstructor().apply { + isAccessible = true + }.newInstance() + val errors = mutableListOf() + val scope = CoroutineScope(Dispatchers.Unconfined + SupervisorJob() + + CoroutineExceptionHandler { _, error -> errors.add(error) }) + val connections = object : ConcurrentHashMap() { + override val size: Int + get() { + val previous = super.size + // The real concurrent key view delegates size() to its backing map. + // Disconnect exactly after observing the last connection. + clear() + return previous + } + } + NostrRelayManager::class.java.getDeclaredField("scope").apply { + isAccessible = true + (get(manager) as CoroutineScope).cancel() + set(manager, scope) + } + NostrRelayManager::class.java.getDeclaredField("connections").apply { + isAccessible = true + set(manager, connections) + } + connections["wss://relay.example"] = mock() + try { + manager.subscribe(NostrFilter(kinds = listOf(NostrKind.TEXT_NOTE)), + id = "synthetic-subscription", handler = {}) + assertTrue("Subscription coroutine failed: $errors", errors.isEmpty()) + } finally { + scope.cancel() + connections.clear() + } + } +} From 404fe4a588ff9d4d229177b30023c1883297fd2e Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:33:12 +0100 Subject: [PATCH 16/17] feat(wear): add swipe-right app menu with quit option Swiping the public mesh chat to the right reveals an app menu page (Wear HorizontalPagerScaffold). The menu shows mesh status and a "Quit bitchat" button that, after confirmation, stops the mesh foreground service and closes the task. Swiping from the menu's left edge still falls through to system swipe-to-dismiss. Page swipes are disabled while push-to-talk or the full-screen image viewer owns the gesture. --- .../java/com/bitchat/watch/MainActivity.kt | 32 +++- .../com/bitchat/watch/ui/ChatMenuScreen.kt | 151 ++++++++++++++++++ .../java/com/bitchat/watch/ui/ChatScreen.kt | 11 +- 3 files changed, 189 insertions(+), 5 deletions(-) create mode 100644 wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt diff --git a/wear/src/main/java/com/bitchat/watch/MainActivity.kt b/wear/src/main/java/com/bitchat/watch/MainActivity.kt index dc07a949c..41de1a11a 100644 --- a/wear/src/main/java/com/bitchat/watch/MainActivity.kt +++ b/wear/src/main/java/com/bitchat/watch/MainActivity.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.runtime.saveable.listSaver import androidx.compose.runtime.saveable.rememberSaveable @@ -39,6 +40,8 @@ import androidx.wear.compose.material3.OutlinedButton import com.bitchat.watch.mesh.WearMeshService import com.bitchat.watch.notification.WearNotificationCoordinator import com.bitchat.watch.service.WearMeshForegroundService +import com.bitchat.watch.ui.ChatHomePager +import com.bitchat.watch.ui.ChatMenuScreen import com.bitchat.watch.ui.ChatScreen import com.bitchat.watch.ui.DmScreen import com.bitchat.watch.ui.NicknameSetupScreen @@ -110,6 +113,7 @@ class MainActivity : ComponentActivity() { ) else -> WearNavHost( launchRequest = pendingLaunchRequest, + onQuit = ::quit, onLaunchRequestHandled = { requestID -> if (pendingLaunchRequest?.id == requestID) { pendingLaunchRequest = null @@ -177,6 +181,15 @@ class MainActivity : ComponentActivity() { startForegroundService(Intent(this, WearMeshForegroundService::class.java)) } + /** + * Quit from the in-app menu: stopping the foreground service tears the mesh down (see + * [WearMeshForegroundService.onDestroy]); the next launch starts it again. + */ + private fun quit() { + stopService(Intent(this, WearMeshForegroundService::class.java)) + finishAndRemoveTask() + } + private fun consumePrivateMessagePeer(intent: Intent?): String? { val peerID = privateMessagePeerFromIntent(intent) intent?.removeExtra(WearNotificationCoordinator.EXTRA_OPEN_DM) @@ -370,6 +383,7 @@ internal class WearNavigationState( @Composable internal fun WearNavHost( launchRequest: WearLaunchRequest?, + onQuit: () -> Unit, onLaunchRequestHandled: (Long) -> Unit ) { val navigation = rememberSaveable(saver = WearNavigationState.Saver) { @@ -397,10 +411,20 @@ internal fun WearNavHost( label = "screenTransition" ) { current -> when (current) { - is WearScreen.Chat -> ChatScreen( - onOpenPeople = { navigation.navigate(WearScreen.People) }, - onOpenTextInput = { navigation.navigate(WearScreen.TextInput(null)) } - ) + is WearScreen.Chat -> { + var chatOwnsGesture by remember { mutableStateOf(false) } + ChatHomePager( + swipeEnabled = !chatOwnsGesture, + menu = { ChatMenuScreen(onQuit = onQuit) }, + chat = { + ChatScreen( + onOpenPeople = { navigation.navigate(WearScreen.People) }, + onOpenTextInput = { navigation.navigate(WearScreen.TextInput(null)) }, + onHorizontalGestureActive = { chatOwnsGesture = it } + ) + } + ) + } is WearScreen.People -> PeopleScreen( onOpenDm = { navigation.navigate(WearScreen.Dm(it)) }, onEditNickname = { navigation.navigate(WearScreen.Nickname) } diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt new file mode 100644 index 000000000..a2bcf0cc2 --- /dev/null +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt @@ -0,0 +1,151 @@ +package com.bitchat.watch.ui + +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.PowerSettingsNew +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.wear.compose.foundation.lazy.ScalingLazyColumn +import androidx.wear.compose.foundation.lazy.rememberScalingLazyListState +import androidx.wear.compose.foundation.pager.HorizontalPager +import androidx.wear.compose.foundation.pager.rememberPagerState +import androidx.wear.compose.material3.AlertDialog +import androidx.wear.compose.material3.AlertDialogDefaults +import androidx.wear.compose.material3.FilledTonalButton +import androidx.wear.compose.material3.HorizontalPagerScaffold +import androidx.wear.compose.material3.Icon +import androidx.wear.compose.material3.ListHeader +import androidx.wear.compose.material3.MaterialTheme +import androidx.wear.compose.material3.ScreenScaffold +import androidx.wear.compose.material3.Text +import com.bitchat.android.services.AppStateStore +import com.bitchat.watch.ui.theme.ChatVisualTokens +import com.bitchat.watch.ui.theme.LocalBitchatPalette + +private const val MENU_PAGE = 0 +private const val CHAT_PAGE = 1 + +/** + * Home of the app: the public mesh chat, with an app menu one page to its left. Swiping the + * chat to the right reveals the menu (the Wear pattern used by Fitbit, Health and similar + * apps); swiping right again from the menu's left edge falls through to the system + * swipe-to-dismiss, so leaving the app still works as everywhere else on the watch. + * + * [swipeEnabled] is false while the chat owns a horizontal gesture (push-to-talk drag, + * full-screen image), so the pager never steals the pointer mid-gesture. + */ +@Composable +fun ChatHomePager( + swipeEnabled: Boolean, + menu: @Composable () -> Unit, + chat: @Composable () -> Unit, +) { + val pagerState = rememberPagerState(initialPage = CHAT_PAGE) { 2 } + HorizontalPagerScaffold(pagerState = pagerState) { + HorizontalPager( + state = pagerState, + userScrollEnabled = swipeEnabled, + // The crown scrolls chat history, never pages. + rotaryScrollableBehavior = null, + ) { page -> + when (page) { + MENU_PAGE -> menu() + CHAT_PAGE -> chat() + } + } + } +} + +@Composable +fun ChatMenuScreen(onQuit: () -> Unit) { + val palette = LocalBitchatPalette.current + val peers by AppStateStore.peers.collectAsState() + val listState = rememberScalingLazyListState() + var confirmQuit by rememberSaveable { mutableStateOf(false) } + + ScreenScaffold(scrollState = listState) { scaffoldPadding -> + ScalingLazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + contentPadding = scaffoldPadding, + ) { + item { + ListHeader { + Text( + text = "bitchat", + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.Bold, + ) + } + } + item { + Text( + text = if (peers.isEmpty()) "Mesh running" else meshPeersText(peers.size), + style = ChatVisualTokens.SystemActionStyle, + color = palette.textTertiary, + textAlign = TextAlign.Center, + modifier = Modifier.fillMaxWidth().padding(bottom = 6.dp), + ) + } + item { + FilledTonalButton( + onClick = { confirmQuit = true }, + modifier = Modifier.fillMaxWidth(), + icon = { + Icon( + imageVector = Icons.Filled.PowerSettingsNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + }, + label = { Text("Quit bitchat") }, + secondaryLabel = { + Text( + text = "Stop the mesh", + color = palette.textTertiary, + ) + }, + ) + } + } + } + + AlertDialog( + visible = confirmQuit, + onDismissRequest = { confirmQuit = false }, + icon = { + Icon( + imageVector = Icons.Filled.PowerSettingsNew, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier.size(32.dp), + ) + }, + title = { Text("Quit bitchat?") }, + confirmButton = { + AlertDialogDefaults.ConfirmButton( + onClick = { + confirmQuit = false + onQuit() + }, + ) + }, + dismissButton = { + AlertDialogDefaults.DismissButton(onClick = { confirmQuit = false }) + }, + ) +} + +private fun meshPeersText(count: Int): String = + if (count == 1) "Mesh running · 1 peer" else "Mesh running · $count peers" diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt index d576cd03c..1c0dde16b 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatScreen.kt @@ -57,7 +57,11 @@ import java.util.Date import java.util.Locale @Composable -fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) { +fun ChatScreen( + onOpenPeople: () -> Unit, + onOpenTextInput: () -> Unit, + onHorizontalGestureActive: (Boolean) -> Unit = {}, +) { val context = LocalContext.current val messages by AppStateStore.publicMessages.collectAsState() val peers by AppStateStore.peers.collectAsState() @@ -82,6 +86,11 @@ fun ChatScreen(onOpenPeople: () -> Unit, onOpenTextInput: () -> Unit) { } ) { path -> mesh?.let { sendVoiceNote(it, null, path) } } + // Push-to-talk drags and the image viewer own the whole screen; the home pager must not + // turn them into a page swipe. + val horizontalGestureActive = voice.recording || viewerPath != null + LaunchedEffect(horizontalGestureActive) { onHorizontalGestureActive(horizontalGestureActive) } + androidx.compose.runtime.DisposableEffect(liveVoiceManager) { liveVoiceManager.showPublicMesh() onDispose { liveVoiceManager.clearVisibleConversation() } From 822213b5513669a7532e623ba426fc140cbd2cb6 Mon Sep 17 00:00:00 2001 From: callebtc <93376500+callebtc@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:36:55 +0100 Subject: [PATCH 17/17] fix(wear): hide page indicator on chat home pager --- wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt b/wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt index a2bcf0cc2..8f3fb7f55 100644 --- a/wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt +++ b/wear/src/main/java/com/bitchat/watch/ui/ChatMenuScreen.kt @@ -52,7 +52,9 @@ fun ChatHomePager( chat: @Composable () -> Unit, ) { val pagerState = rememberPagerState(initialPage = CHAT_PAGE) { 2 } - HorizontalPagerScaffold(pagerState = pagerState) { + // No page indicator: the menu is a peek-behind page, not a peer of the chat, and the + // dots would crowd the chat's edge action bar. + HorizontalPagerScaffold(pagerState = pagerState, pageIndicator = null) { HorizontalPager( state = pagerState, userScrollEnabled = swipeEnabled,