refactor(commons): move the marmot encrypted stores and the NIP-11 / online caches

Two clusters from the survey of what was left in amethyst/, both cases of code
that was shared-layer already and just happened to live in the app.

The four Marmot stores go to commons/jvmAndroid/marmot, next to the
EncryptedAppendLog they were already built on. Nothing in them was Android
despite the prefix: no android.* import, no Context, just
(rootDir: File, encryption: SecretEncryption) and the quartz interfaces. One
call site, AccountCacheState. The KDoc lines claiming "Android implementation"
and "AES/GCM via Android KeyStore" are reworded: SecretEncryption is
expect/actual, AndroidKeyStore on Android and a key file on desktop, so the old
wording is now only half true. Commons already referred to two of them by name
in its own KDoc; those references are updated.

They are Encrypted* rather than File*, and that is not cosmetic. cli/stores
already declares FileMlsGroupStateStore, FileMarmotMessageStore,
FileKeyPackageBundleStore and FilePublishObligationStore — deliberately
UNENCRYPTED test-harness stores, in a module that depends on commons. Two
same-named classes with opposite encryption semantics, one import away from each
other, is how MLS state ends up written in plaintext. Encrypted* is also what
these actually are, and reads correctly next to EncryptedAppendLog.

Nip11CachedRetriever + Nip11Retriever + RetrieveResult go to
commons/relays/nip11, and OnlineChecker to commons/service. RetrieveResult had
to travel: the cache is typed on it and commons cannot import from amethyst.
Nip11RetrieverTest travels too — it sat in the same package and used the class
with no import line. LoadRelayInfo and RelaySupportsNip stay behind; both reach
Amethyst.instance.

android.util.LruCache -> androidx.collection.LruCache is not the pure import
swap it looks like, and the compiler said so: androidx bounds V to Any, while
android.util.LruCache is a Java platform type that accepted
LruCache<NormalizedRelayUrl, RetrieveResult?>. Checked all four put() sites
first — every one stores a concrete RetrieveResult, so the nullable argument
never meant anything and get() still returns null on a miss, which is what the
readers already branch on. Dropping it is behaviour-preserving.

NotifyCoordinator does NOT move despite being grouped with the other two:
android.util.LruCache really is its only platform import, but it takes
accountForPubkey: (HexKey) -> Account?, and Account lives in amethyst/model. It
needs that abstraction, not an import swap.

17 new tests. OnlineChecker's predicates decide whether the UI shows a player or
an offline placeholder, so the five-minute TTL is pinned in both directions — a
stale online entry must stop reading online, and a stale offline one must stop
suppressing retries — along with resetIfOfflineToRetry dropping only failures,
since evicting good entries would refetch every URL that already worked. Its
suspend probe is left alone: it needs a real OkHttp round trip and commons has
no MockWebServer, so there is no honest way to drive it. Same reason
Nip11CachedRetriever gets nothing new here; its fetch and error-caching paths
are all network.

The Marmot tests cover what restart depends on: group state, sender ratchet and
message log surviving a new store over the same directory, deletes removing both
the state and the listing, and two account directories not seeing each other.
Writing them found validation I had not noticed reading the code — group ids
must be hex, which is a path-traversal guard — after a first pass using readable
labels failed every test on it. That guard is pinned now too: "../escape" and
friends are rejected rather than resolved to a path.

Verified: :commons:jvmTest (2549, 0 failures), :amethyst:testPlayDebugUnitTest,
:cli:compileKotlin, :commons:verifyKmpPurity,
:commons:compileCommonMainKotlinMetadata, :amethyst:compilePlayDebugKotlin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AXvKXakvup4inNFfAhhr4L
This commit is contained in:
Claude
2026-09-25 21:14:33 +00:00
parent 2cd6569fe9
commit f5fc432e95
27 changed files with 366 additions and 59 deletions
@@ -55,6 +55,7 @@ import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryStat
import com.vitorpamplona.amethyst.commons.relayClient.speedLogger.RelaySpeedLogger import com.vitorpamplona.amethyst.commons.relayClient.speedLogger.RelaySpeedLogger
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
import com.vitorpamplona.amethyst.commons.relays.health.TorCircuitHealthTracker import com.vitorpamplona.amethyst.commons.relays.health.TorCircuitHealthTracker
import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever
import com.vitorpamplona.amethyst.commons.richtext.CachedAsciiDocToMarkdown import com.vitorpamplona.amethyst.commons.richtext.CachedAsciiDocToMarkdown
import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser import com.vitorpamplona.amethyst.commons.richtext.CachedRichTextParser
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
@@ -75,7 +76,6 @@ import com.vitorpamplona.amethyst.commons.state.UiSettingsState
import com.vitorpamplona.amethyst.commons.tor.TorSettings import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences import com.vitorpamplona.amethyst.model.preferences.UiSharedPreferences
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
@@ -26,6 +26,10 @@ import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46Clien
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.marmot.EncryptedKeyPackageBundleStore
import com.vitorpamplona.amethyst.commons.marmot.EncryptedMarmotMessageStore
import com.vitorpamplona.amethyst.commons.marmot.EncryptedMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.marmot.EncryptedPublishObligationStore
import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.marmot.AndroidIngestDedupStore import com.vitorpamplona.amethyst.commons.model.marmot.AndroidIngestDedupStore
@@ -36,10 +40,6 @@ import com.vitorpamplona.amethyst.commons.relayauth.DataStoreRelayAuthPermission
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.AccountSettings import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.marmot.AndroidKeyPackageBundleStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMarmotMessageStore
import com.vitorpamplona.amethyst.model.marmot.AndroidMlsGroupStateStore
import com.vitorpamplona.amethyst.model.marmot.AndroidPublishObligationStore
import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -250,13 +250,13 @@ class AccountCacheState(
val mlsStore = val mlsStore =
try { try {
Log.d("AccountCacheState") { Log.d("AccountCacheState") {
"Initializing AndroidMlsGroupStateStore for ${signer.pubKey.take(8)}… at ${accountDir.absolutePath}" "Initializing EncryptedMlsGroupStateStore for ${signer.pubKey.take(8)}… at ${accountDir.absolutePath}"
} }
AndroidMlsGroupStateStore(accountDir) EncryptedMlsGroupStateStore(accountDir)
} catch (e: Exception) { } catch (e: Exception) {
Log.e( Log.e(
"AccountCacheState", "AccountCacheState",
"Failed to initialize AndroidMlsGroupStateStore, falling back to in-memory store (Marmot groups will NOT persist across restarts)", "Failed to initialize EncryptedMlsGroupStateStore, falling back to in-memory store (Marmot groups will NOT persist across restarts)",
e, e,
) )
InMemoryMlsGroupStateStore() InMemoryMlsGroupStateStore()
@@ -267,11 +267,11 @@ class AccountCacheState(
val marmotMessageStore = val marmotMessageStore =
try { try {
AndroidMarmotMessageStore(accountDir) EncryptedMarmotMessageStore(accountDir)
} catch (e: Exception) { } catch (e: Exception) {
Log.e( Log.e(
"AccountCacheState", "AccountCacheState",
"Failed to initialize AndroidMarmotMessageStore (Marmot messages will NOT persist across restarts)", "Failed to initialize EncryptedMarmotMessageStore (Marmot messages will NOT persist across restarts)",
e, e,
) )
null null
@@ -279,11 +279,11 @@ class AccountCacheState(
val marmotKeyPackageStore = val marmotKeyPackageStore =
try { try {
AndroidKeyPackageBundleStore(accountDir) EncryptedKeyPackageBundleStore(accountDir)
} catch (e: Exception) { } catch (e: Exception) {
Log.e( Log.e(
"AccountCacheState", "AccountCacheState",
"Failed to initialize AndroidKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)", "Failed to initialize EncryptedKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)",
e, e,
) )
null null
@@ -291,11 +291,11 @@ class AccountCacheState(
val marmotPublishObligationStore = val marmotPublishObligationStore =
try { try {
AndroidPublishObligationStore(accountDir) EncryptedPublishObligationStore(accountDir)
} catch (e: Exception) { } catch (e: Exception) {
Log.e( Log.e(
"AccountCacheState", "AccountCacheState",
"Failed to initialize AndroidPublishObligationStore " + "Failed to initialize EncryptedPublishObligationStore " +
"(a Marmot commit interrupted mid-publish will NOT be retried after a restart)", "(a Marmot commit interrupted mid-publish will NOT be retried after a restart)",
e, e,
) )
@@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State import androidx.compose.runtime.State
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
@@ -96,6 +96,7 @@ import com.vitorpamplona.amethyst.commons.resources.unauthorized_exception
import com.vitorpamplona.amethyst.commons.resources.unauthorized_exception_description import com.vitorpamplona.amethyst.commons.resources.unauthorized_exception_description
import com.vitorpamplona.amethyst.commons.resources.user_x_does_not_have_a_lightning_address_setup_to_receive_sats import com.vitorpamplona.amethyst.commons.resources.user_x_does_not_have_a_lightning_address_setup_to_receive_sats
import com.vitorpamplona.amethyst.commons.resources.video_saved_to_the_gallery import com.vitorpamplona.amethyst.commons.resources.video_saved_to_the_gallery
import com.vitorpamplona.amethyst.commons.service.OnlineChecker
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker
import com.vitorpamplona.amethyst.commons.service.http.EmptyRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.commons.service.http.EmptyRoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.commons.service.http.IRoleBasedHttpClientBuilder
@@ -116,7 +117,6 @@ import com.vitorpamplona.amethyst.model.LatestKeyPackageOwner
import com.vitorpamplona.amethyst.model.UrlCachedPreviewer import com.vitorpamplona.amethyst.model.UrlCachedPreviewer
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
import com.vitorpamplona.amethyst.service.ClinkDebitPayer import com.vitorpamplona.amethyst.service.ClinkDebitPayer
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.V4VPaymentHandler import com.vitorpamplona.amethyst.service.V4VPaymentHandler
import com.vitorpamplona.amethyst.service.ZapPaymentHandler import com.vitorpamplona.amethyst.service.ZapPaymentHandler
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
@@ -27,8 +27,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.service.OnlineChecker
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.commons.service.OnlineChecker
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
@@ -74,6 +74,7 @@ import com.vitorpamplona.amethyst.commons.resources.feed_is_empty
import com.vitorpamplona.amethyst.commons.resources.home_tab_everything import com.vitorpamplona.amethyst.commons.resources.home_tab_everything
import com.vitorpamplona.amethyst.commons.resources.new_threads import com.vitorpamplona.amethyst.commons.resources.new_threads
import com.vitorpamplona.amethyst.commons.resources.refresh import com.vitorpamplona.amethyst.commons.resources.refresh
import com.vitorpamplona.amethyst.commons.service.OnlineChecker
import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled import com.vitorpamplona.amethyst.commons.ui.components.CrossfadeIfEnabled
import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError import com.vitorpamplona.amethyst.commons.ui.feeds.FeedError
import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed import com.vitorpamplona.amethyst.commons.ui.feeds.LoadingFeed
@@ -94,7 +95,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size5dp
import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer import com.vitorpamplona.amethyst.commons.ui.theme.StdVertSpacer
import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight import com.vitorpamplona.amethyst.commons.ui.theme.TabRowHeight
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonRow
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.service.location.LocationState import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedContentState
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState
@@ -32,7 +32,7 @@ import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel import com.vitorpamplona.amethyst.commons.model.nip53LiveActivities.LiveActivitiesChannel
import com.vitorpamplona.amethyst.service.OnlineChecker import com.vitorpamplona.amethyst.commons.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
@@ -32,8 +32,8 @@ import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.author.Aut
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.community.SingleCommunityTopNavFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByOutboxTopNavFilter
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter import com.vitorpamplona.amethyst.commons.model.topNavFeeds.noteBased.muted.MutedAuthorsByProxyTopNavFilter
import com.vitorpamplona.amethyst.commons.service.OnlineChecker
import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.OnlineChecker
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
@@ -41,6 +41,7 @@ import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever
import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult
import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState
import com.vitorpamplona.amethyst.commons.relays.ui.RelayEventCountRow import com.vitorpamplona.amethyst.commons.relays.ui.RelayEventCountRow
@@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Height25Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.LargeRelayIconModifier import com.vitorpamplona.amethyst.commons.ui.theme.LargeRelayIconModifier
import com.vitorpamplona.amethyst.commons.ui.theme.ReactionRowHeightChatMaxWidth import com.vitorpamplona.amethyst.commons.ui.theme.ReactionRowHeightChatMaxWidth
import com.vitorpamplona.amethyst.commons.ui.theme.Size25dp import com.vitorpamplona.amethyst.commons.ui.theme.Size25dp
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo import com.vitorpamplona.amethyst.model.nip11RelayInfo.loadRelayInfo
import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon import com.vitorpamplona.amethyst.ui.note.RenderRelayIcon
import com.vitorpamplona.amethyst.ui.note.UserPicture import com.vitorpamplona.amethyst.ui.note.UserPicture
@@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.commons.model.navigation.Route import com.vitorpamplona.amethyst.commons.model.navigation.Route
import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever
import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult import com.vitorpamplona.amethyst.commons.relays.ui.RelayCountResult
import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState import com.vitorpamplona.amethyst.commons.relays.ui.RelayDragState
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
@Composable @Composable
@@ -45,6 +45,7 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add import com.vitorpamplona.amethyst.commons.resources.add
import com.vitorpamplona.amethyst.commons.resources.add_a_relay import com.vitorpamplona.amethyst.commons.resources.add_a_relay
@@ -58,7 +59,6 @@ import com.vitorpamplona.amethyst.commons.ui.theme.PopupUpEffect
import com.vitorpamplona.amethyst.commons.ui.theme.StdEndPadding import com.vitorpamplona.amethyst.commons.ui.theme.StdEndPadding
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText import com.vitorpamplona.amethyst.commons.ui.theme.placeholderText
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat
@@ -26,11 +26,11 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.HalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HalfVertPadding
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -68,6 +68,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.relays.nip11.Nip11CachedRetriever
import com.vitorpamplona.amethyst.commons.resources.Res import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.confirm import com.vitorpamplona.amethyst.commons.resources.confirm
@@ -96,7 +97,6 @@ import com.vitorpamplona.amethyst.commons.ui.stringRes
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding import com.vitorpamplona.amethyst.commons.ui.theme.HorzHalfVertPadding
import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn import com.vitorpamplona.amethyst.commons.ui.theme.ThemeComparisonColumn
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime import com.vitorpamplona.amethyst.ui.note.formatMediumDateTime
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
@@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
/** /**
* In-memory fallback implementation of [MlsGroupStateStore]. * In-memory fallback implementation of [MlsGroupStateStore].
* *
* Used only when [AndroidMlsGroupStateStore] cannot be initialized (e.g., when the * Used only when [EncryptedMlsGroupStateStore] cannot be initialized (e.g., when the
* Android KeyStore is unavailable). State is lost on app restart, but this lets * Android KeyStore is unavailable). State is lost on app restart, but this lets
* Marmot group operations at least work within a single session instead of failing * Marmot group operations at least work within a single session instead of failing
* with "Marmot not initialized". * with "Marmot not initialized".
@@ -58,7 +58,7 @@ import java.io.RandomAccessFile
* *
* **Not thread-safe.** Entries are cached in memory so an append never has to * **Not thread-safe.** Entries are cached in memory so an append never has to
* read the log back, and that cache assumes one owner. Callers hold their own * read the log back, and that cache assumes one owner. Callers hold their own
* lock around every method (see `AndroidMarmotMessageStore`), and one instance * lock around every method (see `EncryptedMarmotMessageStore`), and one instance
* must own any given file. * must own any given file.
* *
* @param encrypt must produce a self-describing blob — it carries its own IV / * @param encrypt must produce a self-describing blob — it carries its own IV /
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model.marmot package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
@@ -30,7 +30,7 @@ import kotlinx.coroutines.withContext
import java.io.File import java.io.File
/** /**
* Android implementation of [KeyPackageBundleStore] using file-based encrypted storage. * File-backed [KeyPackageBundleStore], encrypted at rest.
* *
* Storage layout: * Storage layout:
* ``` * ```
@@ -40,10 +40,10 @@ import java.io.File
* The blob contains private key material — init keys, encryption keys, * The blob contains private key material — init keys, encryption keys,
* signature keys — that the MLS engine needs to process Welcome events * signature keys — that the MLS engine needs to process Welcome events
* received days or weeks after the corresponding KeyPackage was published. * received days or weeks after the corresponding KeyPackage was published.
* It is encrypted at rest with [SecretEncryption] (AES/GCM via Android * It is encrypted at rest with [SecretEncryption] (AES-256-GCM, keyed by the platform's
* KeyStore), the same primitive used by [AndroidMlsGroupStateStore]. * keystore), the same primitive used by [EncryptedMlsGroupStateStore].
*/ */
class AndroidKeyPackageBundleStore( class EncryptedKeyPackageBundleStore(
private val rootDir: File, private val rootDir: File,
private val encryption: SecretEncryption = SecretEncryption(), private val encryption: SecretEncryption = SecretEncryption(),
) : KeyPackageBundleStore { ) : KeyPackageBundleStore {
@@ -51,7 +51,7 @@ class AndroidKeyPackageBundleStore(
init { init {
Log.d(TAG) { Log.d(TAG) {
"Initialized AndroidKeyPackageBundleStore: rootDir=${rootDir.absolutePath}" "Initialized EncryptedKeyPackageBundleStore: rootDir=${rootDir.absolutePath}"
} }
} }
@@ -121,6 +121,6 @@ class AndroidKeyPackageBundleStore(
} }
companion object { companion object {
private const val TAG = "AndroidKeyPackageBundleStore" private const val TAG = "EncryptedKeyPackageBundleStore"
} }
} }
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model.marmot package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.amethyst.commons.marmot.EncryptedAppendLog import com.vitorpamplona.amethyst.commons.marmot.EncryptedAppendLog
import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption
@@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext
import java.io.File import java.io.File
/** /**
* Android implementation of [MarmotMessageStore] using file-based encrypted storage. * File-backed [MarmotMessageStore], encrypted at rest.
* *
* Stored alongside the [AndroidMlsGroupStateStore] data: * Stored alongside the [EncryptedMlsGroupStateStore] data:
* ``` * ```
* <rootDir>/mls_groups/<nostrGroupId>/messages — encrypted message log * <rootDir>/mls_groups/<nostrGroupId>/messages — encrypted message log
* ``` * ```
@@ -44,7 +44,7 @@ import java.io.File
* small encrypted segment instead of rewriting the conversation, which is what * small encrypted segment instead of rewriting the conversation, which is what
* keeps the cost of a send flat as the history grows. * keeps the cost of a send flat as the history grows.
*/ */
class AndroidMarmotMessageStore( class EncryptedMarmotMessageStore(
private val rootDir: File, private val rootDir: File,
private val encryption: SecretEncryption = SecretEncryption(), private val encryption: SecretEncryption = SecretEncryption(),
) : MarmotMessageStore { ) : MarmotMessageStore {
@@ -52,7 +52,7 @@ class AndroidMarmotMessageStore(
init { init {
Log.d(TAG) { Log.d(TAG) {
"Initialized AndroidMarmotMessageStore: rootDir=${rootDir.absolutePath}" "Initialized EncryptedMarmotMessageStore: rootDir=${rootDir.absolutePath}"
} }
} }
@@ -353,7 +353,7 @@ class AndroidMarmotMessageStore(
) = log.rewrite(file, messages) ) = log.rewrite(file, messages)
companion object { companion object {
private const val TAG = "AndroidMarmotMessageStore" private const val TAG = "EncryptedMarmotMessageStore"
private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$") private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$")
} }
} }
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model.marmot package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
@@ -29,10 +29,10 @@ import java.io.File
import java.io.FileOutputStream import java.io.FileOutputStream
/** /**
* Android implementation of [MlsGroupStateStore] using file-based encrypted storage. * File-backed [MlsGroupStateStore], encrypted at rest.
* *
* All MLS group state (containing private keys and epoch secrets) is encrypted * All MLS group state (containing private keys and epoch secrets) is encrypted
* at rest using [SecretEncryption] (AES/GCM backed by Android KeyStore). * at rest using [SecretEncryption] (AES-256-GCM, keyed by the platform's keystore).
* *
* Storage layout: * Storage layout:
* ``` * ```
@@ -41,13 +41,13 @@ import java.io.FileOutputStream
* <rootDir>/mls_groups/<nostrGroupId>/ratchet — encrypted OwnSenderRatchet * <rootDir>/mls_groups/<nostrGroupId>/ratchet — encrypted OwnSenderRatchet
* ``` * ```
*/ */
class AndroidMlsGroupStateStore( class EncryptedMlsGroupStateStore(
private val rootDir: File, private val rootDir: File,
private val encryption: SecretEncryption = SecretEncryption(), private val encryption: SecretEncryption = SecretEncryption(),
) : MlsGroupStateStore { ) : MlsGroupStateStore {
init { init {
Log.d(TAG) { Log.d(TAG) {
"Initialized AndroidMlsGroupStateStore: rootDir=${rootDir.absolutePath}, " + "Initialized EncryptedMlsGroupStateStore: rootDir=${rootDir.absolutePath}, " +
"mls_groups exists=${File(rootDir, "mls_groups").exists()}" "mls_groups exists=${File(rootDir, "mls_groups").exists()}"
} }
} }
@@ -61,7 +61,7 @@ class AndroidMlsGroupStateStore(
} }
companion object { companion object {
private const val TAG = "AndroidMlsGroupStateStore" private const val TAG = "EncryptedMlsGroupStateStore"
private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$") private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$")
} }
@@ -274,7 +274,7 @@ class AndroidMlsGroupStateStore(
// Fallback: if rename fails (e.g., cross-filesystem), copy and delete // Fallback: if rename fails (e.g., cross-filesystem), copy and delete
tempFile.copyTo(target, overwrite = true) tempFile.copyTo(target, overwrite = true)
if (!tempFile.delete()) { if (!tempFile.delete()) {
Log.w("AndroidMlsGroupStateStore") { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" } Log.w("EncryptedMlsGroupStateStore") { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" }
} }
} }
} }
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model.marmot package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore
@@ -31,7 +31,7 @@ import kotlinx.coroutines.withContext
import java.io.File import java.io.File
/** /**
* Android implementation of [MarmotPublishObligationStore], encrypted at rest * File-backed [MarmotPublishObligationStore], encrypted at rest
* with [SecretEncryption] like the group-state and KeyPackage stores. * with [SecretEncryption] like the group-state and KeyPackage stores.
* *
* ``` * ```
@@ -49,7 +49,7 @@ import java.io.File
* concurrently and resolve out of order, so removing one record must not * concurrently and resolve out of order, so removing one record must not
* rewrite another's. * rewrite another's.
*/ */
class AndroidPublishObligationStore( class EncryptedPublishObligationStore(
private val rootDir: File, private val rootDir: File,
private val encryption: SecretEncryption = SecretEncryption(), private val encryption: SecretEncryption = SecretEncryption(),
) : MarmotPublishObligationStore { ) : MarmotPublishObligationStore {
@@ -184,6 +184,6 @@ class AndroidPublishObligationStore(
} }
companion object { companion object {
private const val TAG = "AndroidPublishObligationStore" private const val TAG = "EncryptedPublishObligationStore"
} }
} }
@@ -18,9 +18,9 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model.nip11RelayInfo package com.vitorpamplona.amethyst.commons.relays.nip11
import android.util.LruCache import androidx.collection.LruCache
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
@@ -33,7 +33,12 @@ class Nip11CachedRetriever(
val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient, val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient,
) { ) {
private val relayInformationEmptyCache = LruCache<NormalizedRelayUrl, Nip11RelayInformation>(1000) private val relayInformationEmptyCache = LruCache<NormalizedRelayUrl, Nip11RelayInformation>(1000)
private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult?>(1000)
// Value type is non-null: androidx.collection.LruCache bounds V to Any, and every put here
// stores a concrete RetrieveResult. The old android.util.LruCache was a Java platform type, so
// the nullable argument compiled but never meant anything — get() returns null on a miss either
// way, which is what the readers below already branch on.
private val relayInformationDocumentCache = LruCache<NormalizedRelayUrl, RetrieveResult>(1000)
private val retriever = Nip11Retriever(okHttpClient) private val retriever = Nip11Retriever(okHttpClient)
/** /**
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model.nip11RelayInfo package com.vitorpamplona.amethyst.commons.relays.nip11
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model.nip11RelayInfo package com.vitorpamplona.amethyst.commons.relays.nip11
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
import com.vitorpamplona.quartz.utils.TimeUtils import com.vitorpamplona.quartz.utils.TimeUtils
@@ -18,9 +18,9 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.service package com.vitorpamplona.amethyst.commons.service
import android.util.LruCache import androidx.collection.LruCache
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.utils.Log import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.RandomInstance import com.vitorpamplona.quartz.utils.RandomInstance
@@ -0,0 +1,184 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.marmot
import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
/**
* Round trips for the file-backed Marmot stores, which had no coverage while they sat in `amethyst/`
* behind an `Android` prefix they never earned.
*
* What matters here is that the bytes survive a restart: these hold MLS group state and the message
* log, so a store that writes but cannot read back loses a group's history and its ratchet — and MLS
* state that cannot be reloaded is not recoverable from the relays.
*/
class EncryptedMarmotStoresTest {
@get:Rule
val folder = TemporaryFolder()
private var seq = 0
/** A fresh account directory plus its own key file, so tests cannot read each other's data. */
private fun accountDir(): Pair<File, SecretEncryption> {
val n = seq++
val dir = folder.newFolder("account_$n")
return dir to SecretEncryption(File(folder.root, "secret_$n.key"))
}
// Group ids must be hex: both stores validate, which is what stops a crafted id escaping the
// account directory. Using a realistic 64-char id rather than a label keeps the tests honest.
private val groupId = "a".repeat(63) + "1"
private val otherGroupId = "b".repeat(63) + "2"
@Test
fun groupStateSurvivesANewStoreOverTheSameDirectory() =
runTest {
val (dir, encryption) = accountDir()
val payload = byteArrayOf(1, 2, 3, 4, 5)
EncryptedMlsGroupStateStore(dir, encryption).save(groupId, payload)
val reopened = EncryptedMlsGroupStateStore(dir, encryption).load(groupId)
assertEquals("the same bytes come back", payload.toList(), reopened?.toList())
}
@Test
fun anUnknownGroupLoadsAsNull() =
runTest {
val (dir, encryption) = accountDir()
assertNull(EncryptedMlsGroupStateStore(dir, encryption).load("c".repeat(63) + "3"))
}
@Test
fun deletingAGroupRemovesItFromTheListing() =
runTest {
val (dir, encryption) = accountDir()
val store = EncryptedMlsGroupStateStore(dir, encryption)
store.save(groupId, byteArrayOf(9))
store.save(otherGroupId, byteArrayOf(8))
store.delete(groupId)
assertEquals("only the other group is left", listOf(otherGroupId), store.listGroups())
assertNull("and its state is gone", store.load(groupId))
}
/** The sender ratchet is stored separately from the group blob; losing it breaks decryption. */
@Test
fun theSenderRatchetRoundTripsIndependentlyOfTheGroupState() =
runTest {
val (dir, encryption) = accountDir()
val store = EncryptedMlsGroupStateStore(dir, encryption)
store.save(groupId, byteArrayOf(1))
store.saveSenderRatchet(groupId, byteArrayOf(7, 7, 7))
val reopened = EncryptedMlsGroupStateStore(dir, encryption)
assertEquals("ratchet preserved", listOf<Byte>(7, 7, 7), reopened.loadSenderRatchet(groupId)?.toList())
assertEquals("and the group blob is untouched", listOf<Byte>(1), reopened.load(groupId)?.toList())
}
@Test
fun appendedMessagesComeBackInOrderAfterAReopen() =
runTest {
val (dir, encryption) = accountDir()
val store = EncryptedMarmotMessageStore(dir, encryption)
store.appendMessage(groupId, """{"id":"one"}""")
store.appendMessage(groupId, """{"id":"two"}""")
val reopened = EncryptedMarmotMessageStore(dir, encryption).loadMessages(groupId)
assertEquals("both, in append order", listOf("""{"id":"one"}""", """{"id":"two"}"""), reopened)
}
@Test
fun aGroupWithNoMessagesLoadsEmptyRatherThanFailing() =
runTest {
val (dir, encryption) = accountDir()
assertTrue(EncryptedMarmotMessageStore(dir, encryption).loadMessages("d".repeat(63) + "4").isEmpty())
}
@Test
fun deletingAGroupDropsItsMessageLog() =
runTest {
val (dir, encryption) = accountDir()
val store = EncryptedMarmotMessageStore(dir, encryption)
store.appendMessage(groupId, """{"id":"one"}""")
store.delete(groupId)
assertTrue(EncryptedMarmotMessageStore(dir, encryption).loadMessages(groupId).isEmpty())
}
/** The group snapshot is what a cold start restores from before replaying the log. */
@Test
fun theGroupSnapshotRoundTrips() =
runTest {
val (dir, encryption) = accountDir()
val store = EncryptedMarmotMessageStore(dir, encryption)
store.recordGroupSnapshot(groupId, """{"epoch":4}""")
assertEquals("""{"epoch":4}""", EncryptedMarmotMessageStore(dir, encryption).loadGroupSnapshot(groupId))
}
/** Two accounts are two directories: one must never read the other's groups. */
@Test
fun twoAccountDirectoriesDoNotSeeEachOther() =
runTest {
val (dirA, encA) = accountDir()
val (dirB, encB) = accountDir()
EncryptedMlsGroupStateStore(dirA, encA).save(groupId, byteArrayOf(1))
assertNull("B cannot see A's group", EncryptedMlsGroupStateStore(dirB, encB).load(groupId))
assertTrue("nor list it", EncryptedMlsGroupStateStore(dirB, encB).listGroups().isEmpty())
}
/** The hex check is a path-traversal guard: a crafted id must not be able to leave the account dir. */
@Test
fun aNonHexGroupIdIsRejected() =
runTest {
val (dir, encryption) = accountDir()
val store = EncryptedMlsGroupStateStore(dir, encryption)
listOf("../escape", "not hex", "abc/def", "").forEach { bad ->
val thrown =
try {
store.load(bad)
false
} catch (e: IllegalArgumentException) {
true
}
assertTrue("\"$bad\" must be rejected, not resolved to a path", thrown)
}
}
}
@@ -18,7 +18,7 @@
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/ */
package com.vitorpamplona.amethyst.model.nip11RelayInfo package com.vitorpamplona.amethyst.commons.relays.nip11
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
@@ -0,0 +1,117 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service
import com.vitorpamplona.quartz.utils.TimeUtils
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* [OnlineChecker]'s cache predicates — the part that decides, without touching the network, whether a
* media URL is known-good, known-bad, or unknown.
*
* These decide whether the UI shows a player or a "this is offline" placeholder, and a stale entry
* being trusted is the difference between a video that plays and one that never gets retried. Nothing
* here had coverage while the object sat in `amethyst/`.
*
* The suspend `isOnline` probe is deliberately not exercised: it needs a real OkHttp round trip and
* `commons` has no MockWebServer, so there is no honest way to drive it from here.
*/
class OnlineCheckerTest {
private val url = "https://example.com/video.mp4"
@Before
fun clearSharedCache() {
// OnlineChecker is an object, so its LruCache outlives each test.
OnlineChecker.checkOnlineCache.evictAll()
}
private fun seed(
online: Boolean,
ageSeconds: Long,
) {
OnlineChecker.checkOnlineCache.put(url, OnlineCheckResult(TimeUtils.now() - ageSeconds, online))
}
@Test
fun anUnknownUrlIsNeitherOnlineNorKnownOffline() {
assertFalse("nothing cached, so not known online", OnlineChecker.isOnlineCached(url))
assertFalse("and not known offline either", OnlineChecker.isCachedAndOffline(url))
}
@Test
fun aFreshOnlineEntryReadsOnline() {
seed(online = true, ageSeconds = 10)
assertTrue(OnlineChecker.isOnlineCached(url))
assertFalse("an online entry is not 'cached and offline'", OnlineChecker.isCachedAndOffline(url))
}
@Test
fun aFreshOfflineEntryReadsOffline() {
seed(online = false, ageSeconds = 10)
assertTrue(OnlineChecker.isCachedAndOffline(url))
assertFalse("and must not read as online", OnlineChecker.isOnlineCached(url))
}
/**
* The five-minute TTL in both directions. Trusting a stale *online* entry shows a player for
* something that has since gone; trusting a stale *offline* one never retries a URL that came back.
*/
@Test
fun anEntryOlderThanFiveMinutesIsTrustedForNothing() {
seed(online = true, ageSeconds = 301)
assertFalse("a stale online entry is no longer online", OnlineChecker.isOnlineCached(url))
seed(online = false, ageSeconds = 301)
assertFalse("and a stale offline entry no longer counts as known-offline", OnlineChecker.isCachedAndOffline(url))
}
@Test
fun anEntryJustInsideFiveMinutesIsStillTrusted() {
seed(online = true, ageSeconds = 290)
assertTrue(OnlineChecker.isOnlineCached(url))
}
/** Retry is for failures only: dropping a good entry would refetch every URL that already worked. */
@Test
fun resetIfOfflineToRetryDropsOnlyTheOfflineEntries() {
seed(online = false, ageSeconds = 10)
OnlineChecker.resetIfOfflineToRetry(url)
assertFalse("the offline entry is gone, so the next check refetches", OnlineChecker.isCachedAndOffline(url))
seed(online = true, ageSeconds = 10)
OnlineChecker.resetIfOfflineToRetry(url)
assertTrue("the online entry survived", OnlineChecker.isOnlineCached(url))
}
@Test
fun aBlankOrNullUrlIsNeverOnline() {
assertFalse(OnlineChecker.isOnlineCached(null))
assertFalse(OnlineChecker.isOnlineCached(" "))
assertFalse(OnlineChecker.isCachedAndOffline(null))
assertFalse(OnlineChecker.isCachedAndOffline(" "))
}
}