mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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.user.UserFinderQueryState
|
||||
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.CachedRichTextParser
|
||||
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.model.Account
|
||||
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.preferences.UiSharedPreferences
|
||||
import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilder
|
||||
|
||||
+13
-13
@@ -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.signers.InMemoryNostrSignerPermissionStore
|
||||
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.model.cache.LocalCache
|
||||
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.model.Account
|
||||
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.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
@@ -250,13 +250,13 @@ class AccountCacheState(
|
||||
val mlsStore =
|
||||
try {
|
||||
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) {
|
||||
Log.e(
|
||||
"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,
|
||||
)
|
||||
InMemoryMlsGroupStateStore()
|
||||
@@ -267,11 +267,11 @@ class AccountCacheState(
|
||||
|
||||
val marmotMessageStore =
|
||||
try {
|
||||
AndroidMarmotMessageStore(accountDir)
|
||||
EncryptedMarmotMessageStore(accountDir)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"AccountCacheState",
|
||||
"Failed to initialize AndroidMarmotMessageStore (Marmot messages will NOT persist across restarts)",
|
||||
"Failed to initialize EncryptedMarmotMessageStore (Marmot messages will NOT persist across restarts)",
|
||||
e,
|
||||
)
|
||||
null
|
||||
@@ -279,11 +279,11 @@ class AccountCacheState(
|
||||
|
||||
val marmotKeyPackageStore =
|
||||
try {
|
||||
AndroidKeyPackageBundleStore(accountDir)
|
||||
EncryptedKeyPackageBundleStore(accountDir)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"AccountCacheState",
|
||||
"Failed to initialize AndroidKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)",
|
||||
"Failed to initialize EncryptedKeyPackageBundleStore (Marmot KeyPackages will NOT persist across restarts)",
|
||||
e,
|
||||
)
|
||||
null
|
||||
@@ -291,11 +291,11 @@ class AccountCacheState(
|
||||
|
||||
val marmotPublishObligationStore =
|
||||
try {
|
||||
AndroidPublishObligationStore(accountDir)
|
||||
EncryptedPublishObligationStore(accountDir)
|
||||
} catch (e: Exception) {
|
||||
Log.e(
|
||||
"AccountCacheState",
|
||||
"Failed to initialize AndroidPublishObligationStore " +
|
||||
"Failed to initialize EncryptedPublishObligationStore " +
|
||||
"(a Marmot commit interrupted mid-publish will NOT be retried after a restart)",
|
||||
e,
|
||||
)
|
||||
|
||||
+1
@@ -25,6 +25,7 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.produceState
|
||||
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.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
+1
-1
@@ -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.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.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.commons.service.broadcast.BroadcastTracker
|
||||
import com.vitorpamplona.amethyst.commons.service.http.EmptyRoleBasedHttpClientBuilder
|
||||
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.privacyOptions.RoleBasedHttpClientBuilder
|
||||
import com.vitorpamplona.amethyst.service.ClinkDebitPayer
|
||||
import com.vitorpamplona.amethyst.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.service.V4VPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.ZapPaymentHandler
|
||||
import com.vitorpamplona.amethyst.service.cashu.melt.MeltProcessor
|
||||
|
||||
+1
-1
@@ -27,8 +27,8 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
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.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.ui.layouts.DisappearingScaffold
|
||||
import com.vitorpamplona.amethyst.ui.note.LoadLiveActivityChannel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
+1
-1
@@ -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.muted.MutedAuthorsByOutboxTopNavFilter
|
||||
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.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
|
||||
+1
-1
@@ -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.new_threads
|
||||
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.feeds.FeedError
|
||||
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.TabRowHeight
|
||||
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.ui.feeds.ChannelFeedContentState
|
||||
import com.vitorpamplona.amethyst.ui.feeds.ChannelFeedState
|
||||
|
||||
+1
-1
@@ -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.emphChat.EphemeralChatChannel
|
||||
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.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
+1
-1
@@ -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.muted.MutedAuthorsByOutboxTopNavFilter
|
||||
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.service.OnlineChecker
|
||||
import com.vitorpamplona.amethyst.ui.dal.FilterByListParams
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
|
||||
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
|
||||
|
||||
+1
-1
@@ -41,6 +41,7 @@ import androidx.compose.ui.platform.LocalClipboard
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
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.RelayDragState
|
||||
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.ReactionRowHeightChatMaxWidth
|
||||
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.ui.note.RenderRelayIcon
|
||||
import com.vitorpamplona.amethyst.ui.note.UserPicture
|
||||
|
||||
+1
-1
@@ -23,11 +23,11 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
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.RelayDragState
|
||||
import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.commons.ui.screen.LocalDisplaySettings
|
||||
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
|
||||
@Composable
|
||||
|
||||
+1
-1
@@ -45,6 +45,7 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.add
|
||||
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.ThemeComparisonColumn
|
||||
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.mockAccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.client.stats.RelayStat
|
||||
|
||||
+1
-1
@@ -26,11 +26,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
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.screen.LocalDisplaySettings
|
||||
import com.vitorpamplona.amethyst.commons.ui.theme.DividerThickness
|
||||
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.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
|
||||
+1
-1
@@ -68,6 +68,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
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.cancel
|
||||
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.HorzHalfVertPadding
|
||||
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.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.mockAccountViewModel
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@ import com.vitorpamplona.quartz.utils.concurrent.ConcurrentMap
|
||||
/**
|
||||
* 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
|
||||
* Marmot group operations at least work within a single session instead of failing
|
||||
* with "Marmot not initialized".
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ import java.io.RandomAccessFile
|
||||
*
|
||||
* **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
|
||||
* lock around every method (see `AndroidMarmotMessageStore`), and one instance
|
||||
* lock around every method (see `EncryptedMarmotMessageStore`), and one instance
|
||||
* must own any given file.
|
||||
*
|
||||
* @param encrypt must produce a self-describing blob — it carries its own IV /
|
||||
|
||||
+7
-7
@@ -18,7 +18,7 @@
|
||||
* 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.model.marmot
|
||||
package com.vitorpamplona.amethyst.commons.marmot
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore
|
||||
@@ -30,7 +30,7 @@ import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
|
||||
/**
|
||||
* Android implementation of [KeyPackageBundleStore] using file-based encrypted storage.
|
||||
* File-backed [KeyPackageBundleStore], encrypted at rest.
|
||||
*
|
||||
* Storage layout:
|
||||
* ```
|
||||
@@ -40,10 +40,10 @@ import java.io.File
|
||||
* The blob contains private key material — init keys, encryption keys,
|
||||
* signature keys — that the MLS engine needs to process Welcome events
|
||||
* received days or weeks after the corresponding KeyPackage was published.
|
||||
* It is encrypted at rest with [SecretEncryption] (AES/GCM via Android
|
||||
* KeyStore), the same primitive used by [AndroidMlsGroupStateStore].
|
||||
* It is encrypted at rest with [SecretEncryption] (AES-256-GCM, keyed by the platform's
|
||||
* keystore), the same primitive used by [EncryptedMlsGroupStateStore].
|
||||
*/
|
||||
class AndroidKeyPackageBundleStore(
|
||||
class EncryptedKeyPackageBundleStore(
|
||||
private val rootDir: File,
|
||||
private val encryption: SecretEncryption = SecretEncryption(),
|
||||
) : KeyPackageBundleStore {
|
||||
@@ -51,7 +51,7 @@ class AndroidKeyPackageBundleStore(
|
||||
|
||||
init {
|
||||
Log.d(TAG) {
|
||||
"Initialized AndroidKeyPackageBundleStore: rootDir=${rootDir.absolutePath}"
|
||||
"Initialized EncryptedKeyPackageBundleStore: rootDir=${rootDir.absolutePath}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,6 @@ class AndroidKeyPackageBundleStore(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AndroidKeyPackageBundleStore"
|
||||
private const val TAG = "EncryptedKeyPackageBundleStore"
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -18,7 +18,7 @@
|
||||
* 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.model.marmot
|
||||
package com.vitorpamplona.amethyst.commons.marmot
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.marmot.EncryptedAppendLog
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption
|
||||
@@ -32,9 +32,9 @@ import kotlinx.coroutines.withContext
|
||||
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
|
||||
* ```
|
||||
@@ -44,7 +44,7 @@ import java.io.File
|
||||
* small encrypted segment instead of rewriting the conversation, which is what
|
||||
* keeps the cost of a send flat as the history grows.
|
||||
*/
|
||||
class AndroidMarmotMessageStore(
|
||||
class EncryptedMarmotMessageStore(
|
||||
private val rootDir: File,
|
||||
private val encryption: SecretEncryption = SecretEncryption(),
|
||||
) : MarmotMessageStore {
|
||||
@@ -52,7 +52,7 @@ class AndroidMarmotMessageStore(
|
||||
|
||||
init {
|
||||
Log.d(TAG) {
|
||||
"Initialized AndroidMarmotMessageStore: rootDir=${rootDir.absolutePath}"
|
||||
"Initialized EncryptedMarmotMessageStore: rootDir=${rootDir.absolutePath}"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -353,7 +353,7 @@ class AndroidMarmotMessageStore(
|
||||
) = log.rewrite(file, messages)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AndroidMarmotMessageStore"
|
||||
private const val TAG = "EncryptedMarmotMessageStore"
|
||||
private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$")
|
||||
}
|
||||
}
|
||||
+7
-7
@@ -18,7 +18,7 @@
|
||||
* 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.model.marmot
|
||||
package com.vitorpamplona.amethyst.commons.marmot
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
|
||||
@@ -29,10 +29,10 @@ import java.io.File
|
||||
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
|
||||
* 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:
|
||||
* ```
|
||||
@@ -41,13 +41,13 @@ import java.io.FileOutputStream
|
||||
* <rootDir>/mls_groups/<nostrGroupId>/ratchet — encrypted OwnSenderRatchet
|
||||
* ```
|
||||
*/
|
||||
class AndroidMlsGroupStateStore(
|
||||
class EncryptedMlsGroupStateStore(
|
||||
private val rootDir: File,
|
||||
private val encryption: SecretEncryption = SecretEncryption(),
|
||||
) : MlsGroupStateStore {
|
||||
init {
|
||||
Log.d(TAG) {
|
||||
"Initialized AndroidMlsGroupStateStore: rootDir=${rootDir.absolutePath}, " +
|
||||
"Initialized EncryptedMlsGroupStateStore: rootDir=${rootDir.absolutePath}, " +
|
||||
"mls_groups exists=${File(rootDir, "mls_groups").exists()}"
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ class AndroidMlsGroupStateStore(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AndroidMlsGroupStateStore"
|
||||
private const val TAG = "EncryptedMlsGroupStateStore"
|
||||
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
|
||||
tempFile.copyTo(target, overwrite = true)
|
||||
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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-4
@@ -18,7 +18,7 @@
|
||||
* 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.model.marmot
|
||||
package com.vitorpamplona.amethyst.commons.marmot
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.model.preferences.SecretEncryption
|
||||
import com.vitorpamplona.quartz.marmot.protocolCore.MarmotPublishObligationStore
|
||||
@@ -31,7 +31,7 @@ import kotlinx.coroutines.withContext
|
||||
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.
|
||||
*
|
||||
* ```
|
||||
@@ -49,7 +49,7 @@ import java.io.File
|
||||
* concurrently and resolve out of order, so removing one record must not
|
||||
* rewrite another's.
|
||||
*/
|
||||
class AndroidPublishObligationStore(
|
||||
class EncryptedPublishObligationStore(
|
||||
private val rootDir: File,
|
||||
private val encryption: SecretEncryption = SecretEncryption(),
|
||||
) : MarmotPublishObligationStore {
|
||||
@@ -184,6 +184,6 @@ class AndroidPublishObligationStore(
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AndroidPublishObligationStore"
|
||||
private const val TAG = "EncryptedPublishObligationStore"
|
||||
}
|
||||
}
|
||||
+8
-3
@@ -18,9 +18,9 @@
|
||||
* 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.model.nip11RelayInfo
|
||||
package com.vitorpamplona.amethyst.commons.relays.nip11
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.runtime.Stable
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.displayUrl
|
||||
@@ -33,7 +33,12 @@ class Nip11CachedRetriever(
|
||||
val okHttpClient: (NormalizedRelayUrl) -> OkHttpClient,
|
||||
) {
|
||||
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)
|
||||
|
||||
/**
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
* 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.model.nip11RelayInfo
|
||||
package com.vitorpamplona.amethyst.commons.relays.nip11
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.toHttp
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
* 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.model.nip11RelayInfo
|
||||
package com.vitorpamplona.amethyst.commons.relays.nip11
|
||||
|
||||
import com.vitorpamplona.quartz.nip11RelayInfo.Nip11RelayInformation
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
+2
-2
@@ -18,9 +18,9 @@
|
||||
* 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.service
|
||||
package com.vitorpamplona.amethyst.commons.service
|
||||
|
||||
import android.util.LruCache
|
||||
import androidx.collection.LruCache
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
+184
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -18,7 +18,7 @@
|
||||
* 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.model.nip11RelayInfo
|
||||
package com.vitorpamplona.amethyst.commons.relays.nip11
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import kotlinx.coroutines.runBlocking
|
||||
+117
@@ -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(" "))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user