Merge origin/main into claude/loving-wright-aqlrvd

Resolves strings.xml (main moved most strings to shared resources; this
branch's backup strings are kept on top of main's file) and a
LocalPreferences import conflict, and follows main's timeAgoNoDot
signature change.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSUcWindkv8zskRCjewj9g
This commit is contained in:
Claude
2026-09-23 14:50:39 +00:00
851 changed files with 97852 additions and 89183 deletions
@@ -26,6 +26,9 @@ import androidx.core.graphics.createBitmap
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.service.http.DefaultContentTypeInterceptor
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
@@ -34,9 +37,6 @@ import com.vitorpamplona.amethyst.service.uploads.ImageDownloader
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.service.uploads.nip96.ServerInfoRetriever
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
@@ -40,9 +40,14 @@ import androidx.media3.common.PlaybackException
import androidx.media3.common.Player
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_video_open_in_browser
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed_description
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import io.mockk.mockk
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
@@ -107,7 +112,7 @@ class PlaybackErrorOverlayFitTest {
private fun assertButtonUsable() {
rule
.onNodeWithText(stringRes(targetContext, R.string.error_video_open_in_browser))
.onNodeWithText(runBlocking { loadStringRes(Res.string.error_video_open_in_browser) })
.assertIsDisplayed()
.assertHeightIsAtLeast(ButtonDefaults.MinHeight)
}
@@ -117,7 +122,7 @@ class PlaybackErrorOverlayFitTest {
renderInBox(width = 322.dp, height = 181.dp)
assertButtonUsable()
rule
.onNodeWithText(stringRes(targetContext, R.string.error_video_playback_failed))
.onNodeWithText(runBlocking { loadStringRes(Res.string.error_video_playback_failed) })
.assertIsDisplayed()
}
@@ -137,8 +142,7 @@ class PlaybackErrorOverlayFitTest {
rule
.onNodeWithText(
stringRes(
targetContext,
R.string.error_video_playback_failed_description,
Res.string.error_video_playback_failed_description,
"ERROR_CODE_PARSING_MANIFEST_MALFORMED",
),
).assertDoesNotExist()
@@ -225,7 +229,7 @@ class PlaybackErrorOverlayFitTest {
private fun titleHeight(): Dp {
val bounds =
rule
.onNodeWithText(stringRes(targetContext, R.string.error_video_playback_failed))
.onNodeWithText(runBlocking { loadStringRes(Res.string.error_video_playback_failed) })
.getUnclippedBoundsInRoot()
return bounds.bottom - bounds.top
}
@@ -233,7 +237,7 @@ class PlaybackErrorOverlayFitTest {
private fun buttonHeight(): Dp {
val bounds =
rule
.onNodeWithText(stringRes(targetContext, R.string.error_video_open_in_browser))
.onNodeWithText(runBlocking { loadStringRes(Res.string.error_video_open_in_browser) })
.getUnclippedBoundsInRoot()
return bounds.bottom - bounds.top
}
@@ -30,6 +30,7 @@ import coil3.memory.MemoryCache
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.BitcoinExplorerEndpoint
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.commons.model.nip03Timestamp.TorAwareOkHttpOtsResolverBuilder
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.relayClient.BlockedRelayFilteringClient
@@ -59,7 +60,6 @@ import com.vitorpamplona.amethyst.connectedApps.nip46.DataStoreNip46ClientStore
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.accountsCache.AccountCacheState
import com.vitorpamplona.amethyst.model.nip03Timestamp.IncomingOtsEventVerifier
import com.vitorpamplona.amethyst.model.nip11RelayInfo.Nip11CachedRetriever
import com.vitorpamplona.amethyst.model.preferences.BuzzAttestationPreferences
import com.vitorpamplona.amethyst.model.preferences.BuzzChannelStarPreferences
@@ -29,6 +29,8 @@ import com.vitorpamplona.amethyst.commons.model.HomeFeedType
import com.vitorpamplona.amethyst.commons.model.chats.ChatFeedType
import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntry
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntry
import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcWalletEntryNorm
@@ -37,9 +39,8 @@ import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.model.UiSettings
import com.vitorpamplona.amethyst.model.backups.BackupConflictStorage
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
@@ -972,6 +973,7 @@ object LocalPreferences {
return@with AccountSettings(
keyPair = keyPair,
transientAccount = false,
cashuCounters = CashuPreferences.forAccount(keyPair.pubKey.toNpub()),
externalSignerPackageName = externalSignerPackageName,
localRelayServers = MutableStateFlow(localRelayServers),
defaultFileServer = defaultFileServerResolved,
@@ -52,12 +52,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
@@ -66,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_block
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_button
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_how_handle
@@ -272,7 +271,7 @@ private fun SignerConnectScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.cancel))
Text(stringRes(Res.string.cancel))
}
Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) {
Text(stringRes(Res.string.napplet_connect_button))
@@ -22,8 +22,9 @@ package com.vitorpamplona.amethyst.connectedApps.consent
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_connect_title
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@@ -88,7 +89,7 @@ object SignerConnectCoordinator {
activityClass = SignerConnectActivity::class.java,
extraKey = EXTRA_TOKEN,
token = token,
titleRes = R.string.nip46_signer_notif_connect_title,
titleRes = Res.string.nip46_signer_notif_connect_title,
)
return try {
@@ -60,8 +60,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -69,7 +67,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
@@ -93,12 +90,16 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_signer_deny_op
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_allow
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_deny
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_remember
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_select_all
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_select_none
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_signing_as
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_title
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -483,7 +484,7 @@ private fun BatchedConsentDialog(
}
Column(modifier = Modifier.weight(1f)) {
Text(
pluralStringResource(R.plurals.nip46_signer_batch_title, pending.size, pending.size),
pluralStringRes(Res.plurals.nip46_signer_batch_title, pending.size, pending.size),
style = MaterialTheme.typography.titleLarge,
)
account.accountName?.let { name ->
@@ -504,8 +505,8 @@ private fun BatchedConsentDialog(
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 2.dp),
) {
Text(
stringResource(
if (selected.size == pending.size) R.string.nip46_signer_batch_select_none else R.string.nip46_signer_batch_select_all,
stringRes(
if (selected.size == pending.size) Res.string.nip46_signer_batch_select_none else Res.string.nip46_signer_batch_select_all,
),
style = MaterialTheme.typography.labelLarge,
)
@@ -22,9 +22,10 @@ package com.vitorpamplona.amethyst.connectedApps.consent
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_sign_title
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlinx.coroutines.CompletableDeferred
@@ -140,7 +141,7 @@ object SignerConsentCoordinator {
activityClass = SignerConsentActivity::class.java,
extraKey = EXTRA_TOKEN,
token = "nip46-signer-consent",
titleRes = R.string.nip46_signer_notif_sign_title,
titleRes = Res.string.nip46_signer_notif_sign_title,
)
}
@@ -28,7 +28,12 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_channel_desc
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_channel_name
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_tap
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import org.jetbrains.compose.resources.StringResource
/**
* Surfaces a signer consent/connect [android.app.Activity] from the **background**.
@@ -51,17 +56,17 @@ import com.vitorpamplona.amethyst.ui.stringRes
object SignerConsentNotifier {
private const val CHANNEL_ID = "com.vitorpamplona.amethyst.SIGNER_CONSENT_CHANNEL"
private fun ensureChannel(context: Context): NotificationChannel {
private suspend fun ensureChannel(context: Context): NotificationChannel {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.getNotificationChannel(CHANNEL_ID)?.let { return it }
val channel =
NotificationChannel(
CHANNEL_ID,
stringRes(context, R.string.nip46_signer_notif_channel_name),
loadStringRes(Res.string.nip46_signer_notif_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = stringRes(context, R.string.nip46_signer_notif_channel_desc)
description = loadStringRes(Res.string.nip46_signer_notif_channel_desc)
}
manager.createNotificationChannel(channel)
return channel
@@ -72,12 +77,12 @@ object SignerConsentNotifier {
* [activityClass] carrying [token]. Returns the notification id to pass to [cancel] once the
* request resolves.
*/
fun show(
suspend fun show(
context: Context,
activityClass: Class<*>,
extraKey: String,
token: String,
titleRes: Int,
titleRes: StringResource,
): Int {
// When Amethyst already owns the foreground the direct startActivity opens the dialog, so a
// heads-up notification would just be redundant noise on top of it. Only fall back to the
@@ -104,8 +109,8 @@ object SignerConsentNotifier {
NotificationCompat
.Builder(context, channel.id)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(stringRes(context, titleRes))
.setContentText(stringRes(context, R.string.nip46_signer_notif_tap))
.setContentTitle(loadStringRes(titleRes))
.setContentText(loadStringRes(Res.string.nip46_signer_notif_tap))
.setContentIntent(pendingIntent)
.setFullScreenIntent(pendingIntent, true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -26,7 +26,6 @@ import android.content.res.Configuration
import android.os.Bundle
import android.widget.Toast
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
@@ -57,13 +56,20 @@ import com.vitorpamplona.quartz.utils.Log
* failing silently.
*/
object FavoriteAppLauncher {
/**
* [stillLoading] is the toast shown when a NostrApp's defining event hasn't
* arrived yet. It is passed in already resolved because the launcher runs from
* plain onClick lambdas, where neither string accessor - composable or
* suspend - can be called.
*/
fun launch(
context: Context,
app: FavoriteApp,
stillLoading: String,
) {
when (app) {
is FavoriteApp.WebApp -> launchUrl(context, app.url)
is FavoriteApp.NostrApp -> launchNostrApp(context, app.coordinate)
is FavoriteApp.NostrApp -> launchNostrApp(context, app.coordinate, stillLoading)
}
}
@@ -113,6 +119,7 @@ object FavoriteAppLauncher {
private fun launchNostrApp(
context: Context,
coordinate: String,
stillLoading: String,
) {
val event = LocalCache.getAddressableNoteIfExists(coordinate)?.event
when (event) {
@@ -146,7 +153,7 @@ object FavoriteAppLauncher {
)
else -> {
Log.w("FavoriteAppLauncher") { "Favorited app not resolvable yet: $coordinate" }
Toast.makeText(context, R.string.favorite_app_still_loading, Toast.LENGTH_SHORT).show()
Toast.makeText(context, stillLoading, Toast.LENGTH_SHORT).show()
}
}
}
@@ -54,6 +54,8 @@ import com.vitorpamplona.amethyst.commons.model.edits.PrivateStorageRelayListDec
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatChannel
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatListState
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem
import com.vitorpamplona.amethyst.commons.model.nip18Reposts.RepostAction
import com.vitorpamplona.amethyst.commons.model.nip25Reactions.ReactionAction
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatChannel
@@ -68,16 +70,27 @@ import com.vitorpamplona.amethyst.commons.model.nip47WalletConnect.NwcInfoCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.BookmarkListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.GitRepositoryListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.OldBookmarkListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.PinListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockPeopleList.BlockPeopleListState
import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.geohashLists.GeohashListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.hashtagLists.HashtagListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.muteList.MuteListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayFeeds.RelayFeedsListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip56Reports.ReportAction
import com.vitorpamplona.amethyst.commons.model.nip62Vanish.VanishRequestsState
import com.vitorpamplona.amethyst.commons.model.nip72Communities.CommunityListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.ContactCardsState
import com.vitorpamplona.amethyst.commons.model.nip85TrustedAssertions.TrustProviderListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.nipBCOnchainZaps.OnchainWalletState
import com.vitorpamplona.amethyst.commons.model.privateChatLastReadRoute
import com.vitorpamplona.amethyst.commons.model.privateChats.hasEncryptedContent
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.FeedDecryptionCaches
@@ -133,38 +146,27 @@ import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState
import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.amethyst.model.nip51Lists.HiddenUsersState
import com.vitorpamplona.amethyst.model.nip51Lists.PinListState
import com.vitorpamplona.amethyst.model.nip51Lists.blockPeopleList.BlockPeopleListState
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays.BlockedRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays.BroadcastRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.favoriteAlgoFeedsLists.FavoriteAlgoFeedsListState
import com.vitorpamplona.amethyst.model.nip51Lists.geohashLists.GeohashListState
import com.vitorpamplona.amethyst.model.nip51Lists.hashtagLists.HashtagListState
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.indexerRelays.IndexerRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.interestSets.InterestSetsState
import com.vitorpamplona.amethyst.model.nip51Lists.labeledBookmarkLists.LabeledBookmarkListsState
import com.vitorpamplona.amethyst.model.nip51Lists.muteList.MuteListState
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.FollowListsState
import com.vitorpamplona.amethyst.model.nip51Lists.peopleList.PeopleListsState
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays.ProxyRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds.RelayFeedListState
import com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds.RelayFeedsListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.searchRelays.SearchRelayListState
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays.TrustedRelayListState
import com.vitorpamplona.amethyst.model.nip62Vanish.VanishRequestsState
import com.vitorpamplona.amethyst.model.nip65RelayList.Nip65RelayListState
import com.vitorpamplona.amethyst.model.nip72Communities.CommunityListState
import com.vitorpamplona.amethyst.model.nip78AppSpecific.AppSpecificState
import com.vitorpamplona.amethyst.model.nip89AppHandlers.AppRecommendationsState
import com.vitorpamplona.amethyst.model.nipA3PaymentTargets.NipA3PaymentTargetsState
import com.vitorpamplona.amethyst.model.nipB7Blossom.BlossomServerListState
import com.vitorpamplona.amethyst.model.nipBCOnchainZaps.OnchainWalletState
import com.vitorpamplona.amethyst.model.serverList.AssumedRelayListsState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowListsState
import com.vitorpamplona.amethyst.model.serverList.MergedFollowPlusMineRelayListsState
@@ -176,8 +178,6 @@ import com.vitorpamplona.amethyst.model.trustedAssertions.TrustProviderListState
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.uploads.FileHeader
import com.vitorpamplona.amethyst.ui.actions.NewMessageTagger
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.EventProcessor
import com.vitorpamplona.marmotquic.QuicAgentTextStreamTransport
import com.vitorpamplona.quartz.buzz.threading.buzzThread
@@ -851,7 +851,7 @@ class Account(
* relay subscription only runs while at least one opener is active.
*/
val cashuMintDirectoryState =
com.vitorpamplona.amethyst.model.nip60Cashu.CashuMintDirectoryState(
com.vitorpamplona.amethyst.commons.model.nip60Cashu.CashuMintDirectoryState(
cache = cache,
scope = scope,
assembler = cashuMintDirectoryFilterAssembler(),
@@ -3014,6 +3014,15 @@ class Account(
wraps: List<GiftWrapEvent>,
displayedNoteId: HexKey? = null,
) {
if (displayedNoteId != null) {
// Before the note is indexed below, so the bubble never renders a
// frame without its state. Republishing the wraps we already hold
// is the whole retry: they are signed and sealed, so nothing has
// to be rebuilt and the recipients' copies keep the ids they were
// given.
chatDeliveryTracker.markSending(displayedNoteId) { broadcastPrivately(wraps, displayedNoteId) }
}
val mine = wraps.filter { (it.recipientPubKey() == signer.pubKey) }
mine.forEach { giftWrap ->
@@ -3023,6 +3032,16 @@ class Account(
val id = mine.firstOrNull()?.id
val mineNote = if (id == null) null else cache.getNoteIfExists(id)
// Unwrap and index the self-copy BEFORE the publish loop, not after it
// and not via the newEventBundles batcher (up to ~1s): the bubble is
// then on screen while the wraps are still going out, and acceptances
// land directly on the rumor note the chat renders instead of parking
// on the wrap. The batcher re-delivers this note later; the
// processor's replay path and the chatroom add are both idempotent.
mineNote?.let { newNotesPreProcessor.consume(it) }
var published = false
wraps.forEach { wrap ->
// Creates an alias
if (mineNote != null && wrap.recipientPubKey() != signer.pubKey) {
@@ -3043,16 +3062,21 @@ class Account(
}
}
if (relayList.isNotEmpty()) published = true
client.publish(wrap, relayList)
}
// Unwrap and index the self-copy right away instead of waiting for the
// newEventBundles batcher (up to ~1s): the sent message reaches the
// chatroom before the first relay OK, so acceptances land directly on
// the rumor note the chat renders instead of parking on the wrap. The
// batcher re-delivers this note later; the processor's replay path and
// the chatroom add are both idempotent.
mineNote?.let { newNotesPreProcessor.consume(it) }
if (displayedNoteId != null) {
// Every wrap resolved to an empty relay set, so nothing was sent
// anywhere and no OK will ever arrive. That used to be invisible —
// the bubble simply sat on a clock forever. Say so, and let the
// retry re-publish the same wraps once the user has DM relays.
if (published) {
chatDeliveryTracker.markSent(displayedNoteId)
} else {
chatDeliveryTracker.markFailed(displayedNoteId)
}
}
}
/**
@@ -613,8 +613,7 @@ class AccountConcordActions(
else ->
ConcordActions.buildChannelMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, TimeUtils.now(), emojiTags)
}
trackConcordDelivery(entry, channelKey, wrap)
publishConcordWrap(entry, wrap)
sendConcordChannelWrap(entry, channelKey, wrap)
return true
}
@@ -642,8 +641,7 @@ class AccountConcordActions(
.map { it.toTagArray() }
.toTypedArray()
val wrap = ConcordActions.buildChannelImageMessage(account.signer, channelKey, channelIdHex, entry.rootEpoch, text, imetas, TimeUtils.now(), emojiTags)
trackConcordDelivery(entry, channelKey, wrap)
publishConcordWrap(entry, wrap)
sendConcordChannelWrap(entry, channelKey, wrap)
return true
}
@@ -752,20 +750,53 @@ class AccountConcordActions(
}
/**
* Registers an own Concord channel message with the delivery tracker so its chat
* bubble shows relay-acceptance ticks. Relays OK the encrypted [wrap], but the feed
* shows the inner rumor, so we re-open the wrap (we just built it, so this always
* succeeds) to key the tracker by the rumor id the bubble is drawn from. Reactions
* and typing wraps skip this — they never become a feed row.
* Echo an own Concord channel message into its feed and publish it, driving
* the bubble's send state as it goes.
*
* The echo comes first and is what makes the message appear immediately;
* the send state is what says whether it actually got out. Relays OK the
* encrypted [wrap] but the feed shows the inner rumor, so the tracker is
* keyed by the rumor id — re-opening the wrap to find it always succeeds,
* since we just built it. Reactions and typing wraps go through
* [publishConcordWrap] instead: they never become a feed row, so there is
* no bubble for a send state to live on.
*/
private fun trackConcordDelivery(
private fun sendConcordChannelWrap(
entry: ConcordCommunityListEntry,
channelKey: GroupKey,
wrap: Event,
) {
val rumorId = ConcordStreamEnvelope.openOrNull(wrap, channelKey)?.rumor?.id ?: return
val rumorId = ConcordStreamEnvelope.openOrNull(wrap, channelKey)?.rumor?.id
if (rumorId == null) {
publishConcordWrap(entry, wrap)
return
}
account.chatDeliveryTracker.markSending(rumorId) { publishTrackedConcordWrap(entry, wrap, rumorId) }
account.concordSessions.ingest(wrap)
publishTrackedConcordWrap(entry, wrap, rumorId)
}
/**
* The publish half of [sendConcordChannelWrap], without the local echo, so
* a retry re-publishes the wrap instead of folding it into the session a
* second time. Relays are re-resolved on every attempt: a community whose
* relay list was empty is the one case worth retrying.
*/
private fun publishTrackedConcordWrap(
entry: ConcordCommunityListEntry,
wrap: Event,
rumorId: HexKey,
) {
val relays = entry.relays.mapNotNullTo(mutableSetOf()) { RelayUrlNormalizer.normalizeOrNull(it) }
if (relays.isEmpty()) {
Log.w("Concord") { "No usable relay for ${entry.id}: channel message ${wrap.id} has nowhere to go" }
account.chatDeliveryTracker.markFailed(rumorId)
return
}
account.chatDeliveryTracker.trackWrappedPublic(rumorId, wrap.id, relays)
account.client.publish(wrap, relays)
account.chatDeliveryTracker.markSent(rumorId)
}
// ── Concord roles & moderation (CORD-04) ─────────────────────────────────
@@ -31,11 +31,46 @@ import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageFetcher
import com.vitorpamplona.quartz.marmot.protocolCore.GroupLifecycleState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.relay.client.accessories.publishAndConfirm
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer
import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.TimeUtils
import kotlin.coroutines.cancellation.CancellationException
/**
* How long a [LatestKeyPackageOwner.NONE] answer may be reused.
*
* Much shorter than a definite answer's life, because NONE is ambiguous:
* `fetchAll` returns an empty list for a device that reached no relay at all
* rather than throwing, so "nobody has published one" and "we are offline"
* arrive identically. Long enough to stop repeated entries from re-fanning the
* query, short enough that the banner is not suppressed for a quarter of an
* hour after the network comes back.
*/
private const val NONE_MAX_AGE_SECONDS = 60L
/**
* Which install owns the newest KeyPackage currently on the account's relays.
*
* An inviter picks the highest `created_at` kind:30443 and nothing else
* ([KeyPackageFetcher.fetchKeyPackage]), and only the install holding that
* bundle's private keys can open the Welcome it produces. With the same
* account signed in twice, the two installs publish under different random
* d-tag slots, so both KeyPackages persist and the most recent publisher
* silently owns every future invite.
*/
enum class LatestKeyPackageOwner {
/** This install holds the bundle — invites land here. */
THIS_DEVICE,
/** A newer KeyPackage we have no private keys for — invites land elsewhere. */
OTHER_DEVICE,
/** Nothing published for this account, or nowhere to ask. */
NONE,
}
/**
* Marmot (MLS encrypted groups) orchestration for an [Account]: group create/
* leave/reset, member add/remove via key-package fetch, admin grant/revoke,
@@ -47,6 +82,9 @@ import kotlin.coroutines.cancellation.CancellationException
class AccountMarmotActions(
private val account: Account,
) {
/** Last (timestamp, answer) from [latestKeyPackageOwner], for passive callers. */
private var lastOwnerCheck: Pair<Long, LatestKeyPackageOwner>? = null
/**
* Resolve the relay set for a Marmot group. Prefer the relays carried in
* the MLS GroupContext metadata so every member converges on the same
@@ -70,8 +108,87 @@ class AccountMarmotActions(
}
/**
* Send a message to a Marmot MLS group.
* Encrypts the inner event and publishes the GroupEvent to group relays.
* Index an own outbound inner event into the cache and the group's
* chatroom right now, before any MLS work happens.
*
* The bubble the user sees is drawn from the inner rumor, and that rumor
* exists the moment the composer builds it — everything after this point
* (ratchet step, group-state write, outer wrap, relay hand-off) only
* decides *when it leaves*, not what is shown. Doing this first is what
* makes a send feel instant.
*
* Idempotent by construction: the self-decrypt of our own kind:445 runs
* these same two calls a moment later, and `LocalCache.justConsume` and
* `MarmotGroupChatroom.addMessageSync` both dedupe.
*/
fun showOwnMessageLocally(
nostrGroupId: HexKey,
innerEvent: Event,
) {
// wasVerified=true: MIP-03 inner events are unsigned rumors, so
// Schnorr verification would reject every one. This one we built
// ourselves, which is as authenticated as it gets.
val isNew = account.cache.justConsume(innerEvent, null, true)
val innerNote = account.cache.getOrCreateNote(innerEvent.id)
if (isNew) innerNote.event = innerEvent
account.marmotGroupList.addMessage(nostrGroupId, innerNote)
// Sending a message moves the group out of "New Requests" into
// "Known" — do this eagerly before the relay round-trip so the UI
// updates immediately.
account.marmotGroupList.markAsKnown(nostrGroupId)
}
/**
* Put an outgoing message on screen in its sending state, synchronously.
*
* Split out of [sendMarmotGroupMessage] so a UI caller can run it in the
* user's own interaction context and hand the rest to a scope that
* outlives the screen: the composer is then free to clear the input the
* moment this returns, with the bubble already visible and already
* pulsing. [sendMarmotGroupMessage] calls it too, so a headless caller
* gets the same behaviour without a second entry point, and calling both
* is harmless.
*/
fun beginMarmotGroupMessage(
nostrGroupId: HexKey,
innerEvent: Event,
) {
// The same two guards sendMarmotGroupMessage returns on. Checked here
// too, because marking a message as sending and then returning early
// would leave the bubble pulsing forever with no failure glyph and
// therefore no way to retry it.
if (account.marmotManager == null || !account.isWriteable()) {
Log.w("MarmotDbg") {
"beginMarmotGroupMessage: cannot send in ${nostrGroupId.take(8)}… (no manager, or a read-only account)"
}
showOwnMessageLocally(nostrGroupId, innerEvent)
account.chatDeliveryTracker.markFailed(innerEvent.id)
return
}
// Marked before the note is indexed, so the bubble never renders a
// frame without its state. Re-resolving the relay set on retry is the
// point of taking the id rather than the relays: the commonest reason
// a Marmot send fails is that the group had none, and that is exactly
// what the user may have just fixed.
account.chatDeliveryTracker.markSending(innerEvent.id) {
sendMarmotGroupMessage(nostrGroupId, innerEvent, marmotGroupRelays(nostrGroupId))
}
showOwnMessageLocally(nostrGroupId, innerEvent)
}
/**
* Send a message to a Marmot MLS group: show it, encrypt it, publish it.
*
* The message is on screen from the first line — see [showOwnMessageLocally]
* — and carries a sending state until the envelope reaches the relay pool.
* A failure leaves the bubble in place with a retry attached rather than
* making what the user typed disappear.
*
* Suspends until the send is done, so headless callers (the notification
* reply receiver, the push-token responder) still finish their work before
* returning. UI callers get their responsiveness from the optimistic insert
* plus running this on the account scope, not from it returning early.
*/
suspend fun sendMarmotGroupMessage(
nostrGroupId: HexKey,
@@ -85,7 +202,28 @@ class AccountMarmotActions(
val manager = account.marmotManager ?: return
if (!account.isWriteable()) return
val outbound = manager.buildGroupMessage(nostrGroupId, innerEvent)
beginMarmotGroupMessage(nostrGroupId, innerEvent)
if (groupRelays.isEmpty()) {
// Previously this only logged and the message was silently
// dropped. It now surfaces on the bubble as a failed send the
// user can retry once the group has relays.
Log.w("MarmotDbg") {
"sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — nothing to publish to"
}
account.chatDeliveryTracker.markFailed(innerEvent.id)
return
}
val outbound =
try {
manager.buildGroupMessage(nostrGroupId, innerEvent)
} catch (e: Exception) {
if (e is CancellationException) throw e
Log.w("MarmotDbg", "sendMarmotGroupMessage: could not build the envelope for $nostrGroupId", e)
account.chatDeliveryTracker.markFailed(innerEvent.id)
throw e
}
Log.d("MarmotDbg") {
"sendMarmotGroupMessage: built outer kind:${outbound.signedEvent.kind} id=${outbound.signedEvent.id.take(8)}…"
}
@@ -94,16 +232,18 @@ class AccountMarmotActions(
// LocalCache.addRelayToNoteAndInners).
outbound.signedEvent.innerEventId = innerEvent.id
account.cache.justConsumeMyOwnEvent(outbound.signedEvent)
// Sending a message moves the group out of "New Requests" into
// "Known" — do this eagerly before relay round-trip so the UI
// updates immediately.
account.marmotGroupList.markAsKnown(nostrGroupId)
if (groupRelays.isEmpty()) {
Log.w("MarmotDbg") {
"sendMarmotGroupMessage: NO group relays for group=${nostrGroupId.take(8)}… — message will be silently dropped"
}
}
// Relays OK the outer kind:445, but the feed shows the inner rumor, so
// the tick has to be keyed by the rumor id through the envelope id.
account.chatDeliveryTracker.trackWrappedPublic(innerEvent.id, outbound.signedEvent.id, groupRelays)
account.client.publish(outbound.signedEvent, groupRelays)
account.chatDeliveryTracker.markSent(innerEvent.id)
// The decrypt of our own envelope only persists a message it decrypted
// for the first time, and the optimistic insert above has already put
// this one in the cache — so that path will skip it and the message
// would never reach the on-disk log. Write it here instead. Last,
// because it rewrites the whole log and nothing on screen waits for it.
manager.persistDecryptedMessage(nostrGroupId, innerEvent.toJson())
}
/**
@@ -330,6 +470,10 @@ class AccountMarmotActions(
}
account.client.publish(event, relays)
}
// A rotation we just published makes this device the newest owner.
// Leaving the old answer in place would keep the banner warning for
// up to its full life about a state that no longer exists.
lastOwnerCheck = null
}
}
@@ -350,6 +494,9 @@ class AccountMarmotActions(
}
account.cache.justConsumeMyOwnEvent(event)
account.client.publish(event, relays)
// Same as the rotation path: we have just changed who owns the newest
// KeyPackage, so any cached answer is stale by construction.
lastOwnerCheck = null
}
/**
@@ -400,6 +547,98 @@ class AccountMarmotActions(
return manager.hasActiveKeyPackages()
}
/**
* Ask the relays which install currently owns this account's invites.
*
* Deliberately a read, never a self-correcting one. Republishing whenever
* the answer is [LatestKeyPackageOwner.OTHER_DEVICE] would deadlock two
* installs against each other — each device's correction is the other's
* trigger, and neither ever settles — so the decision belongs to the user,
* with this as the evidence.
*
* Queries the same set we publish our own KeyPackages to, which is the
* inviter's view of us minus their own outbox: `fetchRelaysFor` unions our
* NIP-65 write set and our legacy kind:10051 with the inviter's outbox, and
* the first two are exactly [keyPackagePublishRelays].
*/
suspend fun latestKeyPackageOwner(maxAgeSeconds: Long = 0L): LatestKeyPackageOwner {
val manager = account.marmotManager ?: return LatestKeyPackageOwner.NONE
val relays = keyPackagePublishRelays()
if (relays.isEmpty()) return LatestKeyPackageOwner.NONE
// A passive caller (the groups-screen banner) may reuse a recent answer.
// Without this, every entry to that screen fanned a REQ out across the
// whole write set — and the thing it asks about only changes when
// another device publishes, which is rare enough to cache.
val cached = lastOwnerCheck
if (maxAgeSeconds > 0 && cached != null) {
val maxAge =
if (cached.second == LatestKeyPackageOwner.NONE) {
minOf(maxAgeSeconds, NONE_MAX_AGE_SECONDS)
} else {
maxAgeSeconds
}
if (TimeUtils.now() - cached.first <= maxAge) return cached.second
}
val latest = KeyPackageFetcher.fetchKeyPackage(account.client, account.signer.pubKey, relays)
val owner =
when {
latest == null -> LatestKeyPackageOwner.NONE
manager.ownsKeyPackage(latest) -> LatestKeyPackageOwner.THIS_DEVICE
else -> LatestKeyPackageOwner.OTHER_DEVICE
}
Log.d("MarmotDbg") {
"latestKeyPackageOwner: newest KeyPackage id=${latest?.id?.take(8)}… " +
"createdAt=${latest?.createdAt} owner=$owner"
}
// Cached even when nothing was found: that answer cost the same fan-out
// as any other, so leaving it uncached would re-run the whole query on
// every entry for exactly the accounts with nothing on their relays.
lastOwnerCheck = TimeUtils.now() to owner
return owner
}
/**
* The user-initiated republish behind the invite-device banner and the
* settings row. Returns whether a relay actually accepted the KeyPackage.
*
* Deliberately not [publishMarmotKeyPackage]. That one is the best-effort
* startup path: it early-returns in silence for a read-only account or an
* empty relay set and then hands the event to a fire-and-forget
* `client.publish`, so a caller reporting the outcome to someone watching
* would call every one of those failures a success.
*/
suspend fun republishKeyPackageConfirmed(): Boolean {
val manager = account.marmotManager ?: return false
if (!account.isWriteable()) return false
val relays = keyPackagePublishRelays()
if (relays.isEmpty()) return false
// Minting no longer destroys the displaced bundle — the rotation
// manager retains it, keyed by the event id it was published as — but
// every regeneration still costs a keypair, a relay round trip, and a
// slot in the bounded retention map, where it can evict a bundle
// someone is about to invite us through. Republishing when we already
// own the newest KeyPackage buys none of that back, since the answer
// cannot change, so that case is a no-op reporting success truthfully.
if (latestKeyPackageOwner() == LatestKeyPackageOwner.THIS_DEVICE) {
Log.d("MarmotDbg") { "republishKeyPackageConfirmed: already the newest; not minting" }
return true
}
val event = manager.generateKeyPackageEvent(relays.toList())
account.cache.justConsumeMyOwnEvent(event)
val accepted = account.client.publishAndConfirm(event, relays)
Log.d("MarmotDbg") {
"republishKeyPackageConfirmed: id=${event.id.take(8)}… accepted=$accepted on ${relays.size} relay(s)"
}
// The answer we just changed; a stale cache would keep the banner up.
lastOwnerCheck = if (accepted) TimeUtils.now() to LatestKeyPackageOwner.THIS_DEVICE else null
return accepted
}
/**
* Create a new Marmot MLS group under the CURRENT profile.
*
@@ -22,6 +22,8 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore
import com.vitorpamplona.amethyst.commons.cashu.UnavailableCashuKeysetCounterStore
import com.vitorpamplona.amethyst.commons.model.HomeFeedType
import com.vitorpamplona.amethyst.commons.model.backups.BackupConflictGuard
import com.vitorpamplona.amethyst.commons.model.backups.backupSlotOf
@@ -31,7 +33,12 @@ import com.vitorpamplona.amethyst.commons.model.clink.ClinkDebitWalletEntryNorm
import com.vitorpamplona.amethyst.commons.model.concord.ConcordListRepository
import com.vitorpamplona.amethyst.commons.model.concord.ConcordViewMode
import com.vitorpamplona.amethyst.commons.model.emphChat.EphemeralChatRepository
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mergeMutedPublicChats
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.model.navigation.DrawerItemVisibility
import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem
import com.vitorpamplona.amethyst.commons.model.nip28PublicChats.PublicChatListRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupRepository
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupViewMode
@@ -41,13 +48,6 @@ import com.vitorpamplona.amethyst.commons.model.payments.PaymentSourceResolver
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.TopFilter
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.model.nip60Cashu.CashuPreferences
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.screen.FeedDefinition
import com.vitorpamplona.quartz.concord.cord02Community.ConcordCommunityListEvent
import com.vitorpamplona.quartz.experimental.ephemChat.list.EphemeralChatListEvent
import com.vitorpamplona.quartz.experimental.nipA3.PaymentTargetsEvent
@@ -60,7 +60,6 @@ import com.vitorpamplona.quartz.nip01Core.metadata.MetadataEvent
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip17Dm.settings.ChatMessageRelayListEvent
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import com.vitorpamplona.quartz.nip28PublicChat.list.ChannelListEvent
import com.vitorpamplona.quartz.nip37Drafts.DraftWrapEvent
import com.vitorpamplona.quartz.nip37Drafts.privateOutbox.PrivateOutboxRelayListEvent
@@ -239,6 +238,14 @@ class AccountSettings(
* keysets is fine because the derivation includes the keyset id.
*/
var cashuKeysetCounters: MutableMap<String, Long> = mutableMapOf(),
/**
* Durable NUT-13 counter store for this account, supplied by the host.
*
* Defaulted to the failing stand-in rather than an in-memory map: the preview
* and test factories below never mint, and a host that does mint must wire a
* real store or hear about it on the first reservation.
*/
val cashuCounters: CashuKeysetCounterStore = UnavailableCashuKeysetCounterStore,
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val dismissedPollNoteIds: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
@@ -772,10 +779,6 @@ class AccountSettings(
if (changed) saveAccountSettings()
}
fun changeDefaultHomeFollowList(name: FeedDefinition) {
changeDefaultHomeFollowList(name.code)
}
fun changeDefaultHomeFollowList(name: TopFilter) {
if (defaultHomeFollowList.value != name) {
defaultHomeFollowList.tryEmit(name)
@@ -783,10 +786,6 @@ class AccountSettings(
}
}
fun changeDefaultStoriesFollowList(name: FeedDefinition) {
changeDefaultStoriesFollowList(name.code)
}
fun changeDefaultStoriesFollowList(name: TopFilter) {
if (defaultStoriesFollowList.value != name) {
defaultStoriesFollowList.tryEmit(name)
@@ -794,10 +793,6 @@ class AccountSettings(
}
}
fun changeDefaultNotificationFollowList(name: FeedDefinition) {
changeDefaultNotificationFollowList(name.code)
}
fun changeDefaultNotificationFollowList(name: TopFilter) {
if (defaultNotificationFollowList.value != name) {
defaultNotificationFollowList.tryEmit(name)
@@ -805,10 +800,6 @@ class AccountSettings(
}
}
fun changeDefaultDiscoveryFollowList(name: FeedDefinition) {
changeDefaultDiscoveryFollowList(name.code)
}
fun changeDefaultDiscoveryFollowList(name: TopFilter) {
if (defaultDiscoveryFollowList.value != name) {
defaultDiscoveryFollowList.tryEmit(name)
@@ -816,10 +807,6 @@ class AccountSettings(
}
}
fun changeDefaultPollsFollowList(name: FeedDefinition) {
changeDefaultPollsFollowList(name.code)
}
fun changeDefaultPollsFollowList(name: TopFilter) {
if (defaultPollsFollowList.value != name) {
defaultPollsFollowList.tryEmit(name)
@@ -827,10 +814,6 @@ class AccountSettings(
}
}
fun changeDefaultCommunitiesFollowList(name: FeedDefinition) {
changeDefaultCommunitiesFollowList(name.code)
}
fun changeDefaultCommunitiesFollowList(name: TopFilter) {
if (defaultCommunitiesFollowList.value != name) {
defaultCommunitiesFollowList.tryEmit(name)
@@ -838,10 +821,6 @@ class AccountSettings(
}
}
fun changeDefaultPicturesFollowList(name: FeedDefinition) {
changeDefaultPicturesFollowList(name.code)
}
fun changeDefaultPicturesFollowList(name: TopFilter) {
if (defaultPicturesFollowList.value != name) {
defaultPicturesFollowList.tryEmit(name)
@@ -849,10 +828,6 @@ class AccountSettings(
}
}
fun changeDefaultRelayGroupsDiscoveryFollowList(name: FeedDefinition) {
changeDefaultRelayGroupsDiscoveryFollowList(name.code)
}
fun changeDefaultRelayGroupsDiscoveryFollowList(name: TopFilter) {
if (defaultRelayGroupsDiscoveryFollowList.value != name) {
defaultRelayGroupsDiscoveryFollowList.tryEmit(name)
@@ -860,10 +835,6 @@ class AccountSettings(
}
}
fun changeDefaultNappletsFollowList(name: FeedDefinition) {
changeDefaultNappletsFollowList(name.code)
}
fun changeDefaultNappletsFollowList(name: TopFilter) {
if (defaultNappletsFollowList.value != name) {
defaultNappletsFollowList.tryEmit(name)
@@ -871,10 +842,6 @@ class AccountSettings(
}
}
fun changeDefaultNsitesFollowList(name: FeedDefinition) {
changeDefaultNsitesFollowList(name.code)
}
fun changeDefaultNsitesFollowList(name: TopFilter) {
if (defaultNsitesFollowList.value != name) {
defaultNsitesFollowList.tryEmit(name)
@@ -882,10 +849,6 @@ class AccountSettings(
}
}
fun changeDefaultWorkoutsFollowList(name: FeedDefinition) {
changeDefaultWorkoutsFollowList(name.code)
}
fun changeDefaultWorkoutsFollowList(name: TopFilter) {
if (defaultWorkoutsFollowList.value != name) {
defaultWorkoutsFollowList.tryEmit(name)
@@ -893,10 +856,6 @@ class AccountSettings(
}
}
fun changeDefaultGitRepositoriesFollowList(name: FeedDefinition) {
changeDefaultGitRepositoriesFollowList(name.code)
}
fun changeDefaultGitRepositoriesFollowList(name: TopFilter) {
if (defaultGitRepositoriesFollowList.value != name) {
defaultGitRepositoriesFollowList.tryEmit(name)
@@ -904,10 +863,6 @@ class AccountSettings(
}
}
fun changeDefaultHighlightsFollowList(name: FeedDefinition) {
changeDefaultHighlightsFollowList(name.code)
}
fun changeDefaultHighlightsFollowList(name: TopFilter) {
if (defaultHighlightsFollowList.value != name) {
defaultHighlightsFollowList.tryEmit(name)
@@ -915,10 +870,6 @@ class AccountSettings(
}
}
fun changeDefaultCalendarsFollowList(name: FeedDefinition) {
changeDefaultCalendarsFollowList(name.code)
}
fun changeDefaultCalendarsFollowList(name: TopFilter) {
if (defaultCalendarsFollowList.value != name) {
defaultCalendarsFollowList.tryEmit(name)
@@ -926,10 +877,6 @@ class AccountSettings(
}
}
fun changeDefaultProductsFollowList(name: FeedDefinition) {
changeDefaultProductsFollowList(name.code)
}
fun changeDefaultProductsFollowList(name: TopFilter) {
if (defaultProductsFollowList.value != name) {
defaultProductsFollowList.tryEmit(name)
@@ -937,10 +884,6 @@ class AccountSettings(
}
}
fun changeDefaultGeocachesFollowList(name: FeedDefinition) {
changeDefaultGeocachesFollowList(name.code)
}
fun changeDefaultGeocachesFollowList(name: TopFilter) {
if (defaultGeocachesFollowList.value != name) {
defaultGeocachesFollowList.tryEmit(name)
@@ -948,10 +891,6 @@ class AccountSettings(
}
}
fun changeDefaultShortsFollowList(name: FeedDefinition) {
changeDefaultShortsFollowList(name.code)
}
fun changeDefaultShortsFollowList(name: TopFilter) {
if (defaultShortsFollowList.value != name) {
defaultShortsFollowList.tryEmit(name)
@@ -959,10 +898,6 @@ class AccountSettings(
}
}
fun changeDefaultPublicChatsFollowList(name: FeedDefinition) {
changeDefaultPublicChatsFollowList(name.code)
}
fun changeDefaultPublicChatsFollowList(name: TopFilter) {
if (defaultPublicChatsFollowList.value != name) {
defaultPublicChatsFollowList.tryEmit(name)
@@ -970,10 +905,6 @@ class AccountSettings(
}
}
fun changeDefaultLiveStreamsFollowList(name: FeedDefinition) {
changeDefaultLiveStreamsFollowList(name.code)
}
fun changeDefaultLiveStreamsFollowList(name: TopFilter) {
if (defaultLiveStreamsFollowList.value != name) {
defaultLiveStreamsFollowList.tryEmit(name)
@@ -981,10 +912,6 @@ class AccountSettings(
}
}
fun changeDefaultNestsFollowList(name: FeedDefinition) {
changeDefaultNestsFollowList(name.code)
}
fun changeDefaultNestsFollowList(name: TopFilter) {
if (defaultNestsFollowList.value != name) {
defaultNestsFollowList.tryEmit(name)
@@ -992,10 +919,6 @@ class AccountSettings(
}
}
fun changeDefaultLongsFollowList(name: FeedDefinition) {
changeDefaultLongsFollowList(name.code)
}
fun changeDefaultLongsFollowList(name: TopFilter) {
if (defaultLongsFollowList.value != name) {
defaultLongsFollowList.tryEmit(name)
@@ -1003,10 +926,6 @@ class AccountSettings(
}
}
fun changeDefaultArticlesFollowList(name: FeedDefinition) {
changeDefaultArticlesFollowList(name.code)
}
fun changeDefaultArticlesFollowList(name: TopFilter) {
if (defaultArticlesFollowList.value != name) {
defaultArticlesFollowList.tryEmit(name)
@@ -1014,10 +933,6 @@ class AccountSettings(
}
}
fun changeDefaultMusicTracksFollowList(name: FeedDefinition) {
changeDefaultMusicTracksFollowList(name.code)
}
fun changeDefaultMusicTracksFollowList(name: TopFilter) {
if (defaultMusicTracksFollowList.value != name) {
defaultMusicTracksFollowList.tryEmit(name)
@@ -1025,10 +940,6 @@ class AccountSettings(
}
}
fun changeDefaultMusicPlaylistsFollowList(name: FeedDefinition) {
changeDefaultMusicPlaylistsFollowList(name.code)
}
fun changeDefaultMusicPlaylistsFollowList(name: TopFilter) {
if (defaultMusicPlaylistsFollowList.value != name) {
defaultMusicPlaylistsFollowList.tryEmit(name)
@@ -1036,10 +947,6 @@ class AccountSettings(
}
}
fun changeDefaultPodcastEpisodesFollowList(name: FeedDefinition) {
changeDefaultPodcastEpisodesFollowList(name.code)
}
fun changeDefaultPodcastEpisodesFollowList(name: TopFilter) {
if (defaultPodcastEpisodesFollowList.value != name) {
defaultPodcastEpisodesFollowList.tryEmit(name)
@@ -1047,10 +954,6 @@ class AccountSettings(
}
}
fun changeDefaultPodcastsFollowList(name: FeedDefinition) {
changeDefaultPodcastsFollowList(name.code)
}
fun changeDefaultPodcastsFollowList(name: TopFilter) {
if (defaultPodcastsFollowList.value != name) {
defaultPodcastsFollowList.tryEmit(name)
@@ -1058,10 +961,6 @@ class AccountSettings(
}
}
fun changeDefaultSoftwareAppsFollowList(name: FeedDefinition) {
changeDefaultSoftwareAppsFollowList(name.code)
}
fun changeDefaultSoftwareAppsFollowList(name: TopFilter) {
if (defaultSoftwareAppsFollowList.value != name) {
defaultSoftwareAppsFollowList.tryEmit(name)
@@ -1069,10 +968,6 @@ class AccountSettings(
}
}
fun changeDefaultBadgesFollowList(name: FeedDefinition) {
changeDefaultBadgesFollowList(name.code)
}
fun changeDefaultBadgesFollowList(name: TopFilter) {
if (defaultBadgesFollowList.value != name) {
defaultBadgesFollowList.tryEmit(name)
@@ -1080,10 +975,6 @@ class AccountSettings(
}
}
fun changeDefaultBrowseEmojiSetsFollowList(name: FeedDefinition) {
changeDefaultBrowseEmojiSetsFollowList(name.code)
}
fun changeDefaultBrowseEmojiSetsFollowList(name: TopFilter) {
if (defaultBrowseEmojiSetsFollowList.value != name) {
defaultBrowseEmojiSetsFollowList.tryEmit(name)
@@ -1091,10 +982,6 @@ class AccountSettings(
}
}
fun changeDefaultFollowPacksFollowList(name: FeedDefinition) {
changeDefaultFollowPacksFollowList(name.code)
}
fun changeDefaultFollowPacksFollowList(name: TopFilter) {
if (defaultFollowPacksFollowList.value != name) {
defaultFollowPacksFollowList.tryEmit(name)
@@ -1102,10 +989,6 @@ class AccountSettings(
}
}
fun changeDefaultAppRecommendationsFollowList(name: FeedDefinition) {
changeDefaultAppRecommendationsFollowList(name.code)
}
fun changeDefaultAppRecommendationsFollowList(name: TopFilter) {
if (defaultAppRecommendationsFollowList.value != name) {
defaultAppRecommendationsFollowList.tryEmit(name)
@@ -1334,24 +1217,11 @@ class AccountSettings(
}
}
/**
* NUT-13 keyset counters live in [CashuPreferences], a dedicated
* SharedPreferences file with synchronous (`commit = true`) writes.
* AccountSettings goes through a 1-second debounce on its own save
* path; the cashu counter cannot tolerate that window because the
* mint persists signed (keyset, blind_message) pairs the moment it
* sees them, so any local lag → "outputs already signed" on retry.
* See [CashuPreferences] for the full rationale.
*/
private val cashuPrefs: CashuPreferences by lazy {
CashuPreferences.forAccount(keyPair.pubKey.toNpub())
}
/**
* Reserve [count] consecutive NUT-13 counters for [keysetId],
* returning the first one. Caller derives `(secret, r)` from
* `(seed, keysetId, i)` for `i in [returned .. returned+count-1]`.
* Persisted synchronously before returning — see [CashuPreferences].
* Persisted synchronously before returning — see [CashuKeysetCounterStore].
*
* One-time migration: when this keyset has a non-zero value in the
* legacy [cashuKeysetCounters] map (from a build that persisted
@@ -1364,18 +1234,18 @@ class AccountSettings(
count: Int,
): Long {
migrateLegacyCashuCounter(keysetId)
return cashuPrefs.reserveCounters(keysetId, count)
return cashuCounters.reserve(keysetId, count)
}
/** Inspect the next counter for [keysetId] without consuming any. */
fun peekCashuCounter(keysetId: String): Long {
migrateLegacyCashuCounter(keysetId)
return cashuPrefs.peekCounter(keysetId)
return cashuCounters.peek(keysetId)
}
private fun migrateLegacyCashuCounter(keysetId: String) {
val legacy = cashuKeysetCounters[keysetId] ?: return
cashuPrefs.seedCounterIfMissing(keysetId, legacy)
cashuCounters.seedIfMissing(keysetId, legacy)
}
fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) {
@@ -22,13 +22,13 @@ package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.model.navigation.DrawerItemVisibility
import com.vitorpamplona.amethyst.commons.model.navigation.NavBarItem
import com.vitorpamplona.amethyst.commons.model.navigation.navBarItemsFromNames
import com.vitorpamplona.amethyst.commons.model.navigation.toNames
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.NavBarItem
import com.vitorpamplona.amethyst.ui.navigation.bottombars.navBarItemsFromNames
import com.vitorpamplona.amethyst.ui.navigation.bottombars.toNames
import com.vitorpamplona.amethyst.ui.navigation.drawer.DrawerItemVisibility
import com.vitorpamplona.amethyst.ui.screen.loggedIn.notifications.equalImmutableLists
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.model
import android.content.res.Resources
import androidx.core.os.ConfigurationCompat
import com.vitorpamplona.amethyst.commons.model.navigation.BottomBarEntry
import com.vitorpamplona.amethyst.commons.service.pow.PoWCategory
import com.vitorpamplona.amethyst.ui.navigation.bottombars.BottomBarEntry
import com.vitorpamplona.amethyst.ui.navigation.bottombars.DefaultBottomBarEntries
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import kotlinx.serialization.Serializable
@@ -20,7 +20,6 @@
*/
package com.vitorpamplona.amethyst.model
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
@@ -29,6 +28,9 @@ import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendResult
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSendStage
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapSender
import com.vitorpamplona.amethyst.commons.onchain.OnchainZapShare
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.bolt12_zap_invalid_receipt
import com.vitorpamplona.amethyst.commons.resources.bolt12_zap_paid_no_receipt
import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -52,6 +54,7 @@ import com.vitorpamplona.quartz.nipB1Bolt12Zaps.builder.Bolt12ZapBuilder
import com.vitorpamplona.quartz.nipB1Bolt12Zaps.verify.Bolt12ZapValidation
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.StringResource
import java.math.BigDecimal
import kotlin.coroutines.cancellation.CancellationException
@@ -204,7 +207,7 @@ class AccountZapActions(
message: String,
zapType: LnZapEvent.ZapType,
// (messageResId, detail) — the caller localizes; detail carries a wallet error, if any.
onError: (Int, String?) -> Unit,
onError: (StringResource, String?) -> Unit,
// (code, detail) — the wallet refused or failed the payment; no funds moved.
onNotPaid: suspend (NwcErrorCode?, String?) -> Unit,
onTimeout: () -> Unit,
@@ -250,27 +253,27 @@ class AccountZapActions(
is PaySuccessResponse -> {
val proof = response.result?.payer_proof
if (proof.isNullOrBlank()) {
onError(R.string.bolt12_zap_paid_no_receipt, null)
onError(Res.string.bolt12_zap_paid_no_receipt, null)
} else {
val zap = Bolt12ZapBuilder.buildZap(zapSigner, intent, proof, anonymous)
if (account.cache.bolt12ZapValidator.validate(zap, verifyEventSignature = false) is Bolt12ZapValidation.Valid) {
account.cache.justConsumeMyOwnEvent(zap)
account.client.publish(zap, account.broadcaster.computeRelayListToBroadcast(zap))
} else {
onError(R.string.bolt12_zap_invalid_receipt, null)
onError(Res.string.bolt12_zap_invalid_receipt, null)
}
}
}
is IErrorResponseLike -> onNotPaid(response.nwcErrorCode(), response.errorMessage())
else -> onError(R.string.bolt12_zap_paid_no_receipt, null)
else -> onError(Res.string.bolt12_zap_paid_no_receipt, null)
}
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
Log.w("Account", "BOLT12 zap receipt assembly failed after payment", e)
onError(R.string.bolt12_zap_paid_no_receipt, null)
onError(Res.string.bolt12_zap_paid_no_receipt, null)
} finally {
onProcessed()
}
@@ -21,8 +21,37 @@
package com.vitorpamplona.amethyst.model
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.accent_color_blue
import com.vitorpamplona.amethyst.commons.resources.accent_color_green
import com.vitorpamplona.amethyst.commons.resources.accent_color_orange
import com.vitorpamplona.amethyst.commons.resources.accent_color_pink
import com.vitorpamplona.amethyst.commons.resources.accent_color_purple
import com.vitorpamplona.amethyst.commons.resources.accent_color_red
import com.vitorpamplona.amethyst.commons.resources.connectivity_type_always
import com.vitorpamplona.amethyst.commons.resources.connectivity_type_never
import com.vitorpamplona.amethyst.commons.resources.connectivity_type_unmetered_wifi_only
import com.vitorpamplona.amethyst.commons.resources.content_warning_hide_all_sensitive_content_option
import com.vitorpamplona.amethyst.commons.resources.content_warning_see_warnings_option
import com.vitorpamplona.amethyst.commons.resources.content_warning_show_all_sensitive_content_option
import com.vitorpamplona.amethyst.commons.resources.dark
import com.vitorpamplona.amethyst.commons.resources.font_family_monospace
import com.vitorpamplona.amethyst.commons.resources.font_family_sans_serif
import com.vitorpamplona.amethyst.commons.resources.font_family_serif
import com.vitorpamplona.amethyst.commons.resources.font_family_system
import com.vitorpamplona.amethyst.commons.resources.font_size_huge
import com.vitorpamplona.amethyst.commons.resources.font_size_large
import com.vitorpamplona.amethyst.commons.resources.font_size_normal
import com.vitorpamplona.amethyst.commons.resources.font_size_small
import com.vitorpamplona.amethyst.commons.resources.gallery_type_classic
import com.vitorpamplona.amethyst.commons.resources.gallery_type_modern
import com.vitorpamplona.amethyst.commons.resources.light
import com.vitorpamplona.amethyst.commons.resources.system
import com.vitorpamplona.amethyst.commons.resources.ui_feature_set_type_complete
import com.vitorpamplona.amethyst.commons.resources.ui_feature_set_type_performance
import com.vitorpamplona.amethyst.commons.resources.ui_feature_set_type_simplified
import kotlinx.serialization.Serializable
import org.jetbrains.compose.resources.StringResource
@Stable
@Serializable
@@ -69,11 +98,11 @@ data class UiSettings(
enum class ThemeType(
val screenCode: Int,
val resourceId: Int,
val resourceId: StringResource,
) {
SYSTEM(0, R.string.system),
LIGHT(1, R.string.light),
DARK(2, R.string.dark),
SYSTEM(0, Res.string.system),
LIGHT(1, Res.string.light),
DARK(2, Res.string.dark),
}
fun parseThemeType(code: Int?): ThemeType =
@@ -86,14 +115,14 @@ fun parseThemeType(code: Int?): ThemeType =
enum class AccentColorType(
val screenCode: Int,
val resourceId: Int,
val resourceId: StringResource,
) {
PURPLE(0, R.string.accent_color_purple),
BLUE(1, R.string.accent_color_blue),
GREEN(2, R.string.accent_color_green),
ORANGE(3, R.string.accent_color_orange),
RED(4, R.string.accent_color_red),
PINK(5, R.string.accent_color_pink),
PURPLE(0, Res.string.accent_color_purple),
BLUE(1, Res.string.accent_color_blue),
GREEN(2, Res.string.accent_color_green),
ORANGE(3, Res.string.accent_color_orange),
RED(4, Res.string.accent_color_red),
PINK(5, Res.string.accent_color_pink),
}
fun parseAccentColorType(screenCode: Int): AccentColorType =
@@ -109,12 +138,12 @@ fun parseAccentColorType(screenCode: Int): AccentColorType =
enum class FontFamilyType(
val screenCode: Int,
val resourceId: Int,
val resourceId: StringResource,
) {
SYSTEM(0, R.string.font_family_system),
SANS_SERIF(1, R.string.font_family_sans_serif),
SERIF(2, R.string.font_family_serif),
MONOSPACE(3, R.string.font_family_monospace),
SYSTEM(0, Res.string.font_family_system),
SANS_SERIF(1, Res.string.font_family_sans_serif),
SERIF(2, Res.string.font_family_serif),
MONOSPACE(3, Res.string.font_family_monospace),
}
fun parseFontFamilyType(screenCode: Int): FontFamilyType =
@@ -129,12 +158,12 @@ fun parseFontFamilyType(screenCode: Int): FontFamilyType =
enum class FontSizeType(
val scale: Float,
val screenCode: Int,
val resourceId: Int,
val resourceId: StringResource,
) {
SMALL(0.85f, 0, R.string.font_size_small),
NORMAL(1.0f, 1, R.string.font_size_normal),
LARGE(1.15f, 2, R.string.font_size_large),
HUGE(1.3f, 3, R.string.font_size_huge),
SMALL(0.85f, 0, Res.string.font_size_small),
NORMAL(1.0f, 1, Res.string.font_size_normal),
LARGE(1.15f, 2, Res.string.font_size_large),
HUGE(1.3f, 3, Res.string.font_size_huge),
}
fun parseFontSizeType(screenCode: Int): FontSizeType =
@@ -149,28 +178,28 @@ fun parseFontSizeType(screenCode: Int): FontSizeType =
enum class ConnectivityType(
val prefCode: Boolean?,
val screenCode: Int,
val resourceId: Int,
val resourceId: StringResource,
) {
ALWAYS(null, 0, R.string.connectivity_type_always),
WIFI_ONLY(true, 1, R.string.connectivity_type_unmetered_wifi_only),
NEVER(false, 2, R.string.connectivity_type_never),
ALWAYS(null, 0, Res.string.connectivity_type_always),
WIFI_ONLY(true, 1, Res.string.connectivity_type_unmetered_wifi_only),
NEVER(false, 2, Res.string.connectivity_type_never),
}
enum class FeatureSetType(
val screenCode: Int,
val resourceId: Int,
val resourceId: StringResource,
) {
COMPLETE(0, R.string.ui_feature_set_type_complete),
SIMPLIFIED(1, R.string.ui_feature_set_type_simplified),
PERFORMANCE(2, R.string.ui_feature_set_type_performance),
COMPLETE(0, Res.string.ui_feature_set_type_complete),
SIMPLIFIED(1, Res.string.ui_feature_set_type_simplified),
PERFORMANCE(2, Res.string.ui_feature_set_type_performance),
}
enum class ProfileGalleryType(
val screenCode: Int,
val resourceId: Int,
val resourceId: StringResource,
) {
CLASSIC(0, R.string.gallery_type_classic),
MODERN(1, R.string.gallery_type_modern),
CLASSIC(0, Res.string.gallery_type_classic),
MODERN(1, Res.string.gallery_type_modern),
}
fun parseConnectivityType(code: Boolean?): ConnectivityType =
@@ -207,10 +236,10 @@ fun parseGalleryType(screenCode: Int): ProfileGalleryType =
enum class BooleanType(
val prefCode: Boolean?,
val screenCode: Int,
val reourceId: Int,
val reourceId: StringResource,
) {
ALWAYS(null, 0, R.string.connectivity_type_always),
NEVER(false, 1, R.string.connectivity_type_never),
ALWAYS(null, 0, Res.string.connectivity_type_always),
NEVER(false, 1, Res.string.connectivity_type_never),
}
fun parseBooleanType(code: Boolean?): BooleanType =
@@ -230,11 +259,11 @@ fun parseBooleanType(screenCode: Int): BooleanType =
enum class WarningType(
val prefCode: Boolean?,
val screenCode: Int,
val resourceId: Int,
val resourceId: StringResource,
) {
WARN(null, 0, R.string.content_warning_see_warnings_option),
SHOW(true, 1, R.string.content_warning_show_all_sensitive_content_option),
HIDE(false, 2, R.string.content_warning_hide_all_sensitive_content_option),
WARN(null, 0, Res.string.content_warning_see_warnings_option),
SHOW(true, 1, Res.string.content_warning_show_all_sensitive_content_option),
HIDE(false, 2, Res.string.content_warning_hide_all_sensitive_content_option),
}
fun parseWarningType(screenCode: Int): WarningType =
@@ -28,16 +28,16 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSig
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore
import com.vitorpamplona.amethyst.commons.marmot.InMemoryMlsGroupStateStore
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.marmot.AndroidIngestDedupStore
import com.vitorpamplona.amethyst.commons.model.marmot.AndroidPushStateStore
import com.vitorpamplona.amethyst.commons.relayClient.nip47WalletConnect.NWCPaymentFilterAssembler
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.AndroidIngestDedupStore
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.model.marmot.AndroidPushStateStore
import com.vitorpamplona.amethyst.service.location.LocationState
import com.vitorpamplona.amethyst.service.relayClient.authCommand.model.DataStoreRelayAuthPermissionStore
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.marmot
import com.vitorpamplona.amethyst.commons.marmot.EncryptedAppendLog
import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -38,24 +39,16 @@ import java.io.File
* <rootDir>/mls_groups/<nostrGroupId>/messages — encrypted message log
* ```
*
* The on-disk format (after decryption) is a sequence of length-prefixed
* UTF-8 entries:
* ```
* uint32 count
* for each entry:
* uint32 length
* byte[length] utf8
* ```
*
* The whole blob is rewritten on each append (via atomic rename) — this
* keeps encryption simple (one GCM nonce per write) and is acceptable for
* conversation-scale histories.
* Every file here is an [EncryptedAppendLog], which owns the on-disk format and
* the migration from the original whole-blob one. Recording a message appends a
* small encrypted segment instead of rewriting the conversation, which is what
* keeps the cost of a send flat as the history grows.
*/
class AndroidMarmotMessageStore(
private val rootDir: File,
private val encryption: KeyStoreEncryption = KeyStoreEncryption(),
) : MarmotMessageStore {
private val writeMutex = Mutex()
private val logMutex = Mutex()
init {
Log.d(TAG) {
@@ -76,17 +69,16 @@ class AndroidMarmotMessageStore(
nostrGroupId: String,
innerEventJson: String,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
logMutex.withLock {
try {
val existing = readAll(nostrGroupId).toMutableList()
if (innerEventJson in existing) {
val file = messagesFile(nostrGroupId)
if (log.contains(file, innerEventJson)) {
Log.d(TAG) { "appendMessage($nostrGroupId): duplicate entry skipped" }
return@withLock
}
existing.add(innerEventJson)
writeAll(nostrGroupId, existing)
log.append(file, innerEventJson)
Log.d(TAG) {
"appendMessage($nostrGroupId): now ${existing.size} message(s) persisted"
"appendMessage($nostrGroupId): now ${log.readAll(file).size} message(s) persisted"
}
} catch (e: Exception) {
Log.e(TAG, "appendMessage($nostrGroupId) FAILED: ${e.message}", e)
@@ -98,7 +90,7 @@ class AndroidMarmotMessageStore(
override suspend fun loadMessages(nostrGroupId: String): List<String> =
withContext(Dispatchers.IO) {
try {
val messages = readAll(nostrGroupId)
val messages = logMutex.withLock { readAll(nostrGroupId) }
Log.d(TAG) {
"loadMessages($nostrGroupId): loaded ${messages.size} message(s)"
}
@@ -111,8 +103,9 @@ class AndroidMarmotMessageStore(
override suspend fun delete(nostrGroupId: String) {
withContext(Dispatchers.IO) {
writeMutex.withLock {
logMutex.withLock {
for (file in listOf(messagesFile(nostrGroupId), epochsFile(nostrGroupId), snapshotFile(nostrGroupId), expiriesFile(nostrGroupId), epochRetentionsFile(nostrGroupId))) {
log.forget(file)
if (file.exists() && !file.delete()) {
Log.w(TAG) { "delete($nostrGroupId): failed to remove ${file.absolutePath}" }
}
@@ -138,7 +131,7 @@ class AndroidMarmotMessageStore(
innerEventId: String,
epoch: Long,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
logMutex.withLock {
try {
val line = "$innerEventId $epoch"
val existing = readAllFrom(epochsFile(nostrGroupId)).toMutableList()
@@ -154,7 +147,8 @@ class AndroidMarmotMessageStore(
override suspend fun loadEpochs(nostrGroupId: String): Map<String, Long> =
withContext(Dispatchers.IO) {
try {
readAllFrom(epochsFile(nostrGroupId))
logMutex
.withLock { readAllFrom(epochsFile(nostrGroupId)) }
.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
@@ -185,7 +179,7 @@ class AndroidMarmotMessageStore(
innerEventId: String,
expiresAtSecs: Long,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
logMutex.withLock {
try {
val existing = readAllFrom(expiriesFile(nostrGroupId)).toMutableList()
if (existing.any { it.substringBefore(' ') == innerEventId }) return@withLock
@@ -200,7 +194,8 @@ class AndroidMarmotMessageStore(
override suspend fun loadExpiries(nostrGroupId: String): Map<String, Long> =
withContext(Dispatchers.IO) {
try {
readAllFrom(expiriesFile(nostrGroupId))
logMutex
.withLock { readAllFrom(expiriesFile(nostrGroupId)) }
.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
@@ -225,7 +220,7 @@ class AndroidMarmotMessageStore(
innerEventIds: Set<String>,
) = withContext(Dispatchers.IO) {
if (innerEventIds.isEmpty()) return@withContext
writeMutex.withLock {
logMutex.withLock {
try {
val kept =
readAll(nostrGroupId).filter { json ->
@@ -257,7 +252,7 @@ class AndroidMarmotMessageStore(
epoch: Long,
retentionSecs: Long,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
logMutex.withLock {
try {
val existing = readAllFrom(epochRetentionsFile(nostrGroupId)).toMutableList()
if (existing.any { it.substringBefore(' ') == epoch.toString() }) return@withLock
@@ -272,7 +267,8 @@ class AndroidMarmotMessageStore(
override suspend fun loadEpochRetentions(nostrGroupId: String): Map<Long, Long> =
withContext(Dispatchers.IO) {
try {
readAllFrom(epochRetentionsFile(nostrGroupId))
logMutex
.withLock { readAllFrom(epochRetentionsFile(nostrGroupId)) }
.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
@@ -302,7 +298,7 @@ class AndroidMarmotMessageStore(
nostrGroupId: String,
snapshotJson: String,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
logMutex.withLock {
try {
writeAllTo(snapshotFile(nostrGroupId), listOf(snapshotJson))
} catch (e: Exception) {
@@ -314,42 +310,37 @@ class AndroidMarmotMessageStore(
override suspend fun loadGroupSnapshot(nostrGroupId: String): String? =
withContext(Dispatchers.IO) {
try {
readAllFrom(snapshotFile(nostrGroupId)).firstOrNull()
logMutex.withLock { readAllFrom(snapshotFile(nostrGroupId)) }.firstOrNull()
} catch (e: Exception) {
Log.e(TAG, "loadGroupSnapshot($nostrGroupId) FAILED: ${e.message}", e)
null
}
}
// The segmented, constant-time-append log every file here is stored as.
// Guarded by [logMutex]: it caches decrypted entries so an append never has
// to read the log back, and that cache assumes a single owner.
private val log =
EncryptedAppendLog(
encrypt = { encryption.encrypt(it) },
// EncryptedAppendLog requires null, not a throw, for a segment it
// cannot open — KeyStoreEncryption.decrypt rethrows. Without this
// one bad segment would abort the whole read, and a caller that
// then sees an empty log can overwrite a history that was merely
// unreadable.
decrypt = {
try {
encryption.decrypt(it)
} catch (e: Exception) {
Log.w(TAG, "a log segment could not be decrypted and was skipped: ${e.message}", e)
null
}
},
)
private fun readAll(nostrGroupId: String): List<String> = readAllFrom(messagesFile(nostrGroupId))
private fun readAllFrom(file: File): List<String> {
if (!file.exists()) return emptyList()
val encrypted = file.readBytes()
val plain = encryption.decrypt(encrypted) ?: return emptyList()
if (plain.size < 4) return emptyList()
var offset = 0
val count =
((plain[offset++].toInt() and 0xFF) shl 24) or
((plain[offset++].toInt() and 0xFF) shl 16) or
((plain[offset++].toInt() and 0xFF) shl 8) or
(plain[offset++].toInt() and 0xFF)
val result = ArrayList<String>(count.coerceAtMost(MAX_MESSAGES))
for (i in 0 until count) {
if (offset + 4 > plain.size) break
val len =
((plain[offset++].toInt() and 0xFF) shl 24) or
((plain[offset++].toInt() and 0xFF) shl 16) or
((plain[offset++].toInt() and 0xFF) shl 8) or
(plain[offset++].toInt() and 0xFF)
if (len < 0 || offset + len > plain.size) break
result.add(plain.copyOfRange(offset, offset + len).decodeToString())
offset += len
}
return result
}
private fun readAllFrom(file: File): List<String> = log.readAll(file)
private fun writeAll(
nostrGroupId: String,
@@ -359,51 +350,10 @@ class AndroidMarmotMessageStore(
private fun writeAllTo(
file: File,
messages: List<String>,
) {
file.parentFile?.mkdirs()
val encodedEntries = messages.map { it.encodeToByteArray() }
val totalSize = 4 + encodedEntries.sumOf { 4 + it.size }
val buffer = ByteArray(totalSize)
var offset = 0
val count = encodedEntries.size
buffer[offset++] = (count shr 24).toByte()
buffer[offset++] = (count shr 16).toByte()
buffer[offset++] = (count shr 8).toByte()
buffer[offset++] = count.toByte()
for (entry in encodedEntries) {
val len = entry.size
buffer[offset++] = (len shr 24).toByte()
buffer[offset++] = (len shr 16).toByte()
buffer[offset++] = (len shr 8).toByte()
buffer[offset++] = len.toByte()
entry.copyInto(buffer, offset)
offset += len
}
val encrypted = encryption.encrypt(buffer)
atomicWrite(file, encrypted)
}
private fun atomicWrite(
target: File,
data: ByteArray,
) {
val tempFile = File(target.parentFile, "${target.name}.tmp")
tempFile.writeBytes(data)
if (!tempFile.renameTo(target)) {
tempFile.copyTo(target, overwrite = true)
if (!tempFile.delete()) {
Log.w(TAG) { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" }
}
}
}
) = log.rewrite(file, messages)
companion object {
private const val TAG = "AndroidMarmotMessageStore"
private const val MAX_MESSAGES = 1_000_000
private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$")
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
/**
* Android implementation of [MlsGroupStateStore] using file-based encrypted storage.
@@ -35,8 +36,9 @@ import java.io.File
*
* Storage layout:
* ```
* <rootDir>/mls_groups/<nostrGroupId>/state — encrypted MlsGroupState
* <rootDir>/mls_groups/<nostrGroupId>/state — encrypted MlsGroupState
* <rootDir>/mls_groups/<nostrGroupId>/retained — encrypted retained epoch secrets
* <rootDir>/mls_groups/<nostrGroupId>/ratchet — encrypted OwnSenderRatchet
* ```
*/
class AndroidMlsGroupStateStore(
@@ -67,6 +69,8 @@ class AndroidMlsGroupStateStore(
private fun retainedFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "retained")
private fun ratchetFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "ratchet")
override suspend fun save(
nostrGroupId: String,
state: ByteArray,
@@ -166,6 +170,66 @@ class AndroidMlsGroupStateStore(
atomicWrite(file, encryption.encrypt(buffer))
}
/**
* The per-send ratchet position, written on its own so a message does not
* re-encrypt the whole group state to record ~72 bytes.
*
* Written atomically, through a temp file and a rename. An in-place write
* looked adequate — the record is small and written whole — but the failure
* it allows is the one this record exists to prevent. A torn record is
* discarded on load, which falls back to the position in the full state:
* that is the position as of the last COMMIT, behind by every send since.
* The next send would then re-emit every generation in between, reusing
* AEAD key+nonce pairs. With a rename, a crash leaves the previous complete
* record instead, which is at worst one send behind — and that send's
* ciphertext was never published, because publishing waits for this write.
*/
override suspend fun saveSenderRatchet(
nostrGroupId: String,
state: ByteArray,
): Boolean =
withContext(Dispatchers.IO) {
val file = ratchetFile(nostrGroupId)
try {
file.parentFile?.mkdirs()
val encrypted = encryption.encrypt(state)
val tempFile = File(file.parentFile, "${file.name}.tmp")
FileOutputStream(tempFile).use { out ->
out.write(encrypted)
out.fd.sync()
}
if (!tempFile.renameTo(file)) {
tempFile.copyTo(file, overwrite = true)
if (!tempFile.delete()) {
Log.w(TAG) { "Failed to delete the temp ratchet file: ${tempFile.absolutePath}" }
}
}
true
} catch (e: Exception) {
// Returning false sends the caller to a full state write, which
// is slower but carries the same guarantee. Swallowing this and
// returning true would drop the position on the floor.
Log.w(TAG, "saveSenderRatchet($nostrGroupId) FAILED, falling back to a full state write: ${e.message}", e)
false
}
}
override suspend fun loadSenderRatchet(nostrGroupId: String): ByteArray? =
withContext(Dispatchers.IO) {
val file = ratchetFile(nostrGroupId)
if (!file.exists()) return@withContext null
try {
encryption.decrypt(file.readBytes())
} catch (e: Exception) {
// Serious: the fallback is the full state's position, which is
// BEHIND, and a behind position re-emits used generations. The
// atomic write above is what should make this unreachable, so
// if it ever fires the group's key material is suspect.
Log.e(TAG, "loadSenderRatchet($nostrGroupId) unreadable — the ratchet may rewind: ${e.message}", e)
null
}
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> =
withContext(Dispatchers.IO) {
val file = retainedFile(nostrGroupId)
@@ -21,12 +21,16 @@
package com.vitorpamplona.amethyst.model.nip46Signer
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_for
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_decrypt_failed
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_remote_app
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
@@ -65,7 +69,7 @@ object Nip46ConsentBridge {
): AppConnectResult {
val context = Amethyst.instance.appContext
val meta = request.clientMetadata
val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val title = meta?.name?.ifBlank { null } ?: loadStringRes(Res.string.nip46_signer_remote_app)
val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "…")
// The identity being connected to lives in the coordinate; show it as an avatar + name.
val face = accountFace(coordinate)
@@ -100,7 +104,7 @@ object Nip46ConsentBridge {
requestedOps: List<NostrSignerOp>,
): AppConnectResult {
val context = Amethyst.instance.appContext
val title = name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val title = name?.ifBlank { null } ?: loadStringRes(Res.string.nip46_signer_remote_app)
val domain = url?.ifBlank { null } ?: (Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)?.take(12)?.plus("…") ?: "")
val face = accountFace(coordinate)
val info =
@@ -138,7 +142,7 @@ object Nip46ConsentBridge {
): SignerOpGrant {
val context = Amethyst.instance.appContext
val info = runCatching { Amethyst.instance.nip46ClientStore.load(coordinate) }.getOrNull()
val title = info?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val title = info?.name?.ifBlank { null } ?: loadStringRes(Res.string.nip46_signer_remote_app)
val consentInfo =
Nip46ConsentInfoBuilder.build(
@@ -152,8 +156,8 @@ object Nip46ConsentBridge {
strings =
Nip46ConsentStrings(
opLabel = { it.label(context) },
allowAlwaysFor = { context.getString(R.string.nip46_signer_allow_always_for, it) },
decryptFailed = context.getString(R.string.nip46_signer_decrypt_failed),
allowAlwaysFor = { loadStringRes(Res.string.nip46_signer_allow_always_for, it) },
decryptFailed = loadStringRes(Res.string.nip46_signer_decrypt_failed),
),
decrypt = { decryptWithAccountSigner(signer, it) },
)
@@ -46,9 +46,9 @@ data class SignerFace(
*/
class Nip46ConsentStrings(
/** Human-readable label for an op, e.g. "read your private messages with Alice". */
val opLabel: (NostrSignerOp) -> String,
val opLabel: suspend (NostrSignerOp) -> String,
/** Button text for the counterparty-scoped grant; the argument is the counterparty's name. */
val allowAlwaysFor: (String) -> String,
val allowAlwaysFor: suspend (String) -> String,
/** Shown as the preview when Amethyst itself could not decrypt the message. */
val decryptFailed: String,
)
@@ -27,6 +27,8 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger
import com.vitorpamplona.amethyst.commons.model.nip46Signer.Nip46ActivityEntry
import com.vitorpamplona.amethyst.commons.model.nip46Signer.Nip46ActivityLog
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.blockedRelays
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.blockedRelays.BlockedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.broadcastRelays
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.broadcastRelays.BroadcastRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultIndexerRelayList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.indexerRelays.IndexerRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.HexKey
@@ -28,6 +28,7 @@ import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cache.filter
import com.vitorpamplona.amethyst.commons.model.eventIdSet
import com.vitorpamplona.amethyst.commons.model.events
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleList
import com.vitorpamplona.amethyst.commons.model.nip51Lists.peopleList.PeopleListDecryptionCache
import com.vitorpamplona.amethyst.commons.model.updateFlow
import com.vitorpamplona.amethyst.model.Account
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.proxyRelays
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.proxyRelays.ProxyRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.relayFeeds
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.relayFeeds.RelayFeedsListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -24,6 +24,7 @@ import com.vitorpamplona.amethyst.commons.defaults.DefaultSearchRelayList
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.searchRelays.SearchRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -23,6 +23,7 @@ package com.vitorpamplona.amethyst.model.nip51Lists.trustedRelays
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip51Lists.trustedRelays.TrustedRelayListDecryptionCache
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -25,6 +25,7 @@ import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.commons.cashu.CashuKeysetCounterStore
/**
* Per-account Cashu state that needs durable, synchronous persistence —
@@ -70,10 +71,10 @@ import com.vitorpamplona.amethyst.Amethyst
*/
class CashuPreferences(
private val prefs: SharedPreferences,
) {
) : CashuKeysetCounterStore {
/** Inspect the next free counter for [keysetId] without advancing it. */
@Synchronized
fun peekCounter(keysetId: String): Long = prefs.getLong(counterKey(keysetId), 0L)
override fun peek(keysetId: String): Long = prefs.getLong(counterKey(keysetId), 0L)
/**
* Atomically reserve [count] consecutive NUT-13 counters for
@@ -83,12 +84,12 @@ class CashuPreferences(
*/
@Synchronized
@SuppressLint("ApplySharedPref")
fun reserveCounters(
override fun reserve(
keysetId: String,
count: Int,
): Long {
require(count > 0) { "Counter reservation must be positive" }
val current = peekCounter(keysetId)
val current = peek(keysetId)
val next = current + count.toLong()
prefs.edit(commit = true) { putLong(counterKey(keysetId), next) }
return current
@@ -103,12 +104,12 @@ class CashuPreferences(
*/
@Synchronized
@SuppressLint("ApplySharedPref")
fun seedCounterIfMissing(
override fun seedIfMissing(
keysetId: String,
legacyValue: Long,
) {
if (legacyValue <= 0L) return
val current = peekCounter(keysetId)
val current = peek(keysetId)
if (current >= legacyValue) return
prefs.edit(commit = true) { putLong(counterKey(keysetId), legacyValue) }
}
@@ -23,10 +23,10 @@ package com.vitorpamplona.amethyst.model.nipB7Blossom
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.NoteState
import com.vitorpamplona.amethyst.commons.model.cache.ICacheProvider
import com.vitorpamplona.amethyst.commons.model.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.model.AccountSettings
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nipB7Blossom.BlossomAuthorizationEvent
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.preferences
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.stringPreferencesKey
import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect
import com.vitorpamplona.quartz.utils.cache.LargeCache
@@ -24,6 +24,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.map
@@ -24,6 +24,7 @@ import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.emptyPreferences
import com.vitorpamplona.amethyst.commons.model.preferences.UpdatablePropertyFlow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.firstOrNull
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.preferences
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyInfo
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import com.vitorpamplona.quartz.utils.Log
@@ -29,6 +30,7 @@ import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
class KeyStoreEncryption {
@@ -45,12 +47,65 @@ class KeyStoreEncryption {
private const val GCM_TAG_LENGTH_BITS = 128
}
private val cipher = Cipher.getInstance(TRANSFORMATION)
// One Cipher per thread rather than one shared instance. A Cipher carries
// the state of the operation in progress, so two coroutines encrypting on
// different Dispatchers.IO threads through the same object would corrupt
// each other's output.
private val ciphers = ThreadLocal.withInitial { Cipher.getInstance(TRANSFORMATION) }
private val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
private fun getKey(): SecretKey {
// The key handle never changes for the life of the alias, but fetching it
// is a round trip to the keystore daemon. Every encrypted store here reads
// and writes through this class, so doing that per operation put an IPC in
// front of every Marmot group-state write and every message appended.
@Volatile
private var cachedKey: SecretKey? = null
private fun getKey(): SecretKey =
cachedKey ?: synchronized(this) {
cachedKey ?: loadOrCreateKey().also { cachedKey = it }
}
private fun loadOrCreateKey(): SecretKey {
val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
return existingKey?.secretKey ?: createKey()
return (existingKey?.secretKey ?: createKey()).also { logSecurityLevel(it) }
}
/**
* Say once, per process, where this key actually lives.
*
* It decides the cost of everything encrypted at rest here, and it is not
* knowable from the code: [createKeyStrongBoxIfAvailable] is tried first, so
* a device with a secure element gets one, and `getEntry` then returns
* whatever that device created — possibly years ago, under different code.
*
* The difference is not small. A secure element runs AES-GCM at roughly
* 68 KB/s (1 MiB in ~15s on a Pixel 8), against tens of MB/s for the TEE.
* Anything bulk that shows up slow on one device and fine on another is
* explained by this line.
*/
private fun logSecurityLevel(key: SecretKey) {
try {
val factory = SecretKeyFactory.getInstance(key.algorithm, ANDROID_KEY_STORE)
val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo
val level =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
when (info.securityLevel) {
KeyProperties.SECURITY_LEVEL_STRONGBOX -> "STRONGBOX (bulk crypto here is ~68 KB/s)"
KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> "TEE"
KeyProperties.SECURITY_LEVEL_SOFTWARE -> "SOFTWARE"
else -> "UNKNOWN(${info.securityLevel})"
}
} else {
@Suppress("DEPRECATION")
if (info.isInsideSecureHardware) "SECURE_HARDWARE (TEE or StrongBox)" else "SOFTWARE"
}
Log.i(TAG) { "$KEY_ALIAS security level: $level" }
} catch (e: Exception) {
// Purely diagnostic — never let it interfere with having a key.
Log.d(TAG) { "Could not determine the security level of $KEY_ALIAS: ${e.message}" }
}
}
private fun createKeyStrongBoxIfAvailable(): SecretKey? =
@@ -95,11 +150,15 @@ class KeyStoreEncryption {
fun encrypt(bytes: ByteArray): ByteArray {
try {
// Initializes the cipher in encrypt mode and encrypts data
val cipher = ciphers.get()
cipher.init(Cipher.ENCRYPT_MODE, getKey())
val iv = cipher.iv
val encrypted = cipher.doFinal(bytes)
return iv + encrypted
} catch (e: Exception) {
// A key the system has retired (a wipe, a credential reset) keeps
// failing until it is re-read, so the cached handle goes with it.
cachedKey = null
Log.e(TAG, "encrypt() failed: ${e.message}", e)
throw e
}
@@ -112,9 +171,11 @@ class KeyStoreEncryption {
// IvParameterSpec), so we must pass the 128-bit auth tag length.
val iv = bytes.copyOfRange(0, GCM_IV_LENGTH)
val data = bytes.copyOfRange(GCM_IV_LENGTH, bytes.size)
val cipher = ciphers.get()
cipher.init(Cipher.DECRYPT_MODE, getKey(), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv))
return cipher.doFinal(data)
} catch (e: Exception) {
cachedKey = null
Log.e(TAG, "decrypt() failed (input ${bytes.size} bytes): ${e.message}", e)
throw e
}
@@ -20,13 +20,73 @@
*/
package com.vitorpamplona.amethyst.napplet
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_identity
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_identity_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_inc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_inc_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_keys
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_keys_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_notify
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_notify_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_relay
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_relay_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_resource
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_resource_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_signer
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_signer_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_storage
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_storage_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_theme
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_theme_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_upload
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_upload_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_value
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_value_desc
import org.jetbrains.compose.resources.StringResource
/** Localized display name for a capability, shared by the consent dialog and the permissions screen. */
@StringRes
fun NappletCapability.labelRes(): Int =
fun NappletCapability.labelRes(): StringResource =
when (this) {
NappletCapability.IDENTITY -> Res.string.napplet_cap_identity
NappletCapability.KEYS -> Res.string.napplet_cap_keys
NappletCapability.RELAY -> Res.string.napplet_cap_relay
NappletCapability.SIGNER -> Res.string.napplet_cap_signer
NappletCapability.STORAGE -> Res.string.napplet_cap_storage
NappletCapability.VALUE -> Res.string.napplet_cap_value
NappletCapability.RESOURCE -> Res.string.napplet_cap_resource
NappletCapability.UPLOAD -> Res.string.napplet_cap_upload
NappletCapability.THEME -> Res.string.napplet_cap_theme
NappletCapability.NOTIFY -> Res.string.napplet_cap_notify
NappletCapability.INC -> Res.string.napplet_cap_inc
}
/** Localized one-line description of what a capability lets a napplet do. */
fun NappletCapability.descriptionRes(): StringResource =
when (this) {
NappletCapability.IDENTITY -> Res.string.napplet_cap_identity_desc
NappletCapability.KEYS -> Res.string.napplet_cap_keys_desc
NappletCapability.RELAY -> Res.string.napplet_cap_relay_desc
NappletCapability.SIGNER -> Res.string.napplet_cap_signer_desc
NappletCapability.STORAGE -> Res.string.napplet_cap_storage_desc
NappletCapability.VALUE -> Res.string.napplet_cap_value_desc
NappletCapability.RESOURCE -> Res.string.napplet_cap_resource_desc
NappletCapability.UPLOAD -> Res.string.napplet_cap_upload_desc
NappletCapability.THEME -> Res.string.napplet_cap_theme_desc
NappletCapability.NOTIFY -> Res.string.napplet_cap_notify_desc
NappletCapability.INC -> Res.string.napplet_cap_inc_desc
}
/**
* Android-resource twin of [labelRes], for [NappletLauncher].
*
* Launch parameters are minted from a plain onClick, which can call neither the
* composable nor the suspend accessor, and the sandbox process has no resources
* of its own - so the labels have to be resolved here and passed across.
*/
fun NappletCapability.labelResId(): Int =
when (this) {
NappletCapability.IDENTITY -> R.string.napplet_cap_identity
NappletCapability.KEYS -> R.string.napplet_cap_keys
@@ -40,20 +100,3 @@ fun NappletCapability.labelRes(): Int =
NappletCapability.NOTIFY -> R.string.napplet_cap_notify
NappletCapability.INC -> R.string.napplet_cap_inc
}
/** Localized one-line description of what a capability lets a napplet do. */
@StringRes
fun NappletCapability.descriptionRes(): Int =
when (this) {
NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc
NappletCapability.KEYS -> R.string.napplet_cap_keys_desc
NappletCapability.RELAY -> R.string.napplet_cap_relay_desc
NappletCapability.SIGNER -> R.string.napplet_cap_signer_desc
NappletCapability.STORAGE -> R.string.napplet_cap_storage_desc
NappletCapability.VALUE -> R.string.napplet_cap_value_desc
NappletCapability.RESOURCE -> R.string.napplet_cap_resource_desc
NappletCapability.UPLOAD -> R.string.napplet_cap_upload_desc
NappletCapability.THEME -> R.string.napplet_cap_theme_desc
NappletCapability.NOTIFY -> R.string.napplet_cap_notify_desc
NappletCapability.INC -> R.string.napplet_cap_inc_desc
}
@@ -21,22 +21,57 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_follow_added
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_follow_one
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_follow_removed
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_follows
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_joiner
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_mute_added
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_mute_one
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_mute_removed
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_mutes
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_no_baseline
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_none
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_relay_added
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_relay_removed
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_relays
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_unfollow_one
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_unmute_one
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_effect_deletes
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_effect_tags
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_get_pubkey
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_identity_read
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_nip44_decrypt
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_nip44_encrypt
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_notify
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_pay_amount
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_pay_no_amount
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_publish
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_publish_encrypted
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_publish_preview
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_query
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_resource
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_sign
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_storage
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_upload
import com.vitorpamplona.amethyst.commons.resources.napplet_fallback_title
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import org.jetbrains.compose.resources.PluralStringResource
import org.jetbrains.compose.resources.StringResource
/**
* Turns a pending [NappletRequest] into the human-readable [NappletConsentInfo] the consent dialog
@@ -51,12 +86,12 @@ class NappletConsentSummary(
private val context: Context,
private val account: Account,
) {
fun info(
suspend fun info(
identity: NappletIdentity,
capability: NappletCapability,
request: NappletRequest,
): NappletConsentInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val untitled = loadStringRes(Res.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
@@ -68,7 +103,7 @@ class NappletConsentSummary(
return NappletConsentInfo(
appletTitle = title,
coordinate = identity.coordinate,
capabilityLabel = context.getString(capability.labelRes()),
capabilityLabel = loadStringRes(capability.labelRes()),
operationSummary = listOfNotNull(summaryFor(request).ifBlank { null }, consequence?.text).joinToString("\n\n"),
allowAlways = capability.canGrantAlways,
iconUrl = iconUrl,
@@ -118,14 +153,14 @@ class NappletConsentSummary(
* the dialog reads "publish a kind 3 event" while the user is actually about to replace their
* whole social graph — the summary would be technically true and practically useless.
*/
private fun consequenceFor(request: NappletRequest): Consequence? =
private suspend fun consequenceFor(request: NappletRequest): Consequence? =
when (request) {
is NappletRequest.Publish -> consequenceFor(request.kind, request.tags)
is NappletRequest.SignEvent -> consequenceFor(request.kind, request.tags)
else -> null
}
private fun consequenceFor(
private suspend fun consequenceFor(
kind: Int,
tags: Array<Array<String>>,
): Consequence? =
@@ -135,20 +170,20 @@ class NappletConsentSummary(
current = account.kind3FollowList.getFollowListEvent()?.tags,
proposed = tags,
tagName = "p",
template = R.string.napplet_consent_diff_follows,
added = R.plurals.napplet_consent_diff_follow_added,
removed = R.plurals.napplet_consent_diff_follow_removed,
oneAdded = R.string.napplet_consent_diff_follow_one,
oneRemoved = R.string.napplet_consent_diff_unfollow_one,
template = Res.string.napplet_consent_diff_follows,
added = Res.plurals.napplet_consent_diff_follow_added,
removed = Res.plurals.napplet_consent_diff_follow_removed,
oneAdded = Res.string.napplet_consent_diff_follow_one,
oneRemoved = Res.string.napplet_consent_diff_unfollow_one,
)
AdvertisedRelayListEvent.KIND ->
diffOf(
current = account.nip65RelayList.getNIP65RelayList()?.tags,
proposed = tags,
tagName = "r",
template = R.string.napplet_consent_diff_relays,
added = R.plurals.napplet_consent_diff_relay_added,
removed = R.plurals.napplet_consent_diff_relay_removed,
template = Res.string.napplet_consent_diff_relays,
added = Res.plurals.napplet_consent_diff_relay_added,
removed = Res.plurals.napplet_consent_diff_relay_removed,
)
// Public entries only: a mute list also carries encrypted ones, which are not in `tags`
// and so cannot be diffed here.
@@ -157,21 +192,21 @@ class NappletConsentSummary(
current = account.muteList.getMuteList()?.tags,
proposed = tags,
tagName = "p",
template = R.string.napplet_consent_diff_mutes,
added = R.plurals.napplet_consent_diff_mute_added,
removed = R.plurals.napplet_consent_diff_mute_removed,
oneAdded = R.string.napplet_consent_diff_mute_one,
oneRemoved = R.string.napplet_consent_diff_unmute_one,
template = Res.string.napplet_consent_diff_mutes,
added = Res.plurals.napplet_consent_diff_mute_added,
removed = Res.plurals.napplet_consent_diff_mute_removed,
oneAdded = Res.string.napplet_consent_diff_mute_one,
oneRemoved = Res.string.napplet_consent_diff_unmute_one,
)
// Deletions have no prior version to compare against — the tags are the whole request.
DeletionEvent.KIND ->
pluralFor(R.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
pluralFor(Res.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
?.let { Consequence(it) }
// Any other kind: at least tell the user tags exist and can be inspected, so an empty
// content preview never reads as "there is nothing else here".
else ->
if (tags.isNotEmpty()) {
pluralFor(R.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) }
pluralFor(Res.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) }
} else {
null
}
@@ -183,45 +218,45 @@ class NappletConsentSummary(
* that silently drops 130 follows, and only a diff surfaces that. Falls back to the total when
* nothing is cached to compare against.
*/
private fun diffOf(
private suspend fun diffOf(
current: Array<Array<String>>?,
proposed: Array<Array<String>>,
tagName: String,
@StringRes template: Int,
@PluralsRes added: Int,
@PluralsRes removed: Int,
@StringRes oneAdded: Int? = null,
@StringRes oneRemoved: Int? = null,
template: StringResource,
added: PluralStringResource,
removed: PluralStringResource,
oneAdded: StringResource? = null,
oneRemoved: StringResource? = null,
): Consequence {
val next = valuesOf(proposed, tagName)
val previous =
current?.let { valuesOf(it, tagName) }
?: return Consequence(pluralStringRes(context, R.plurals.napplet_consent_diff_no_baseline, next.size, next.size))
?: return Consequence(loadPluralStringRes(Res.plurals.napplet_consent_diff_no_baseline, next.size, next.size))
val addedKeys = next.filter { it !in previous }
val removedKeys = previous.filter { it !in next }
if (addedKeys.isEmpty() && removedKeys.isEmpty()) {
return Consequence(context.getString(R.string.napplet_consent_diff_none))
return Consequence(loadStringRes(Res.string.napplet_consent_diff_none))
}
// The overwhelmingly common edit is a single follow/unfollow. Naming and picturing that one
// account is far more use than "follows 1 new account" — the user can tell at a glance
// whether it is who they expected.
if (oneAdded != null && addedKeys.size == 1 && removedKeys.isEmpty()) {
subjectOf(addedKeys.first())?.let { return Consequence(context.getString(oneAdded, it.name), it) }
subjectOf(addedKeys.first())?.let { return Consequence(loadStringRes(oneAdded, it.name), it) }
}
if (oneRemoved != null && removedKeys.size == 1 && addedKeys.isEmpty()) {
subjectOf(removedKeys.first())?.let { return Consequence(context.getString(oneRemoved, it.name), it) }
subjectOf(removedKeys.first())?.let { return Consequence(loadStringRes(oneRemoved, it.name), it) }
}
val parts = listOfNotNull(pluralFor(added, addedKeys.size), pluralFor(removed, removedKeys.size))
val summary =
if (parts.size == 2) {
context.getString(R.string.napplet_consent_diff_joiner, parts[0], parts[1])
loadStringRes(Res.string.napplet_consent_diff_joiner, parts[0], parts[1])
} else {
parts.first()
}
return Consequence(context.getString(template, summary))
return Consequence(loadStringRes(template, summary))
}
/** Resolves a pubkey to a name + picture for the dialog, or null when the user isn't cached. */
@@ -244,67 +279,67 @@ class NappletConsentSummary(
return out
}
private fun pluralFor(
resId: Int,
private suspend fun pluralFor(
resId: PluralStringResource,
count: Int,
): String? = if (count <= 0) null else pluralStringRes(context, resId, count, count)
): String? = if (count <= 0) null else loadPluralStringRes(resId, count, count)
private fun countTag(
tags: Array<Array<String>>,
name: String,
): Int = tags.count { it.isNotEmpty() && it[0] == name }
private fun summaryFor(request: NappletRequest): String =
private suspend fun summaryFor(request: NappletRequest): String =
when (request) {
is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey)
is NappletRequest.IdentityRead -> context.getString(R.string.napplet_consent_identity_read)
is NappletRequest.GetPublicKey -> loadStringRes(Res.string.napplet_consent_get_pubkey)
is NappletRequest.IdentityRead -> loadStringRes(Res.string.napplet_consent_identity_read)
is NappletRequest.Publish -> {
val preview = request.content.take(160).trim()
if (preview.isEmpty()) {
context.getString(R.string.napplet_consent_publish, request.kind)
loadStringRes(Res.string.napplet_consent_publish, request.kind)
} else {
context.getString(R.string.napplet_consent_publish_preview, request.kind) + "\n“$preview”"
loadStringRes(Res.string.napplet_consent_publish_preview, request.kind) + "\n“$preview”"
}
}
is NappletRequest.SignEvent -> {
val preview = request.content.take(160).trim()
if (preview.isEmpty()) {
context.getString(R.string.napplet_consent_sign, request.kind)
loadStringRes(Res.string.napplet_consent_sign, request.kind)
} else {
context.getString(R.string.napplet_consent_sign, request.kind) + "\n“$preview”"
loadStringRes(Res.string.napplet_consent_sign, request.kind) + "\n“$preview”"
}
}
is NappletRequest.PublishEncrypted -> context.getString(R.string.napplet_consent_publish_encrypted)
is NappletRequest.PublishEncrypted -> loadStringRes(Res.string.napplet_consent_publish_encrypted)
is NappletRequest.Nip44Encrypt -> {
val preview = request.plaintext.take(160).trim()
val summary = context.getString(R.string.napplet_consent_nip44_encrypt, counterpartyLabel(request.peer))
val summary = loadStringRes(Res.string.napplet_consent_nip44_encrypt, counterpartyLabel(request.peer))
if (preview.isEmpty()) summary else summary + "\n\u201C$preview\u201D"
}
// The ciphertext is meaningless to show, so name the counterparty instead — that is
// the decision the user is actually making ("let this page read messages from X").
is NappletRequest.Nip44Decrypt -> context.getString(R.string.napplet_consent_nip44_decrypt, counterpartyLabel(request.peer))
is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> context.getString(R.string.napplet_consent_query)
is NappletRequest.Nip44Decrypt -> loadStringRes(Res.string.napplet_consent_nip44_decrypt, counterpartyLabel(request.peer))
is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> loadStringRes(Res.string.napplet_consent_query)
is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove, is NappletRequest.StorageKeys ->
context.getString(R.string.napplet_consent_storage)
loadStringRes(Res.string.napplet_consent_storage)
is NappletRequest.NotifyCreate -> {
val preview = request.title.take(80).trim()
if (preview.isEmpty()) context.getString(R.string.napplet_consent_notify) else context.getString(R.string.napplet_consent_notify) + "\n“$preview”"
if (preview.isEmpty()) loadStringRes(Res.string.napplet_consent_notify) else loadStringRes(Res.string.napplet_consent_notify) + "\n“$preview”"
}
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> context.getString(R.string.napplet_consent_notify)
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> loadStringRes(Res.string.napplet_consent_notify)
is NappletRequest.PayInvoice -> {
// getAmountInSats returns ZERO (not null, not a throw) for an amountless BOLT11, so a
// naive read renders "pay 0 sats" — telling the user a payment is free when the amount
// is in fact unspecified and decided by the payee. Treat non-positive as "no amount".
val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull()
if (sats != null && sats > 0) {
pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
loadPluralStringRes(Res.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
} else {
context.getString(R.string.napplet_consent_pay_no_amount)
loadStringRes(Res.string.napplet_consent_pay_no_amount)
}
}
NappletRequest.ResourceInfo, is NappletRequest.ResourceBytes, is NappletRequest.ResourceBytesMany ->
context.getString(R.string.napplet_consent_resource)
is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload)
loadStringRes(Res.string.napplet_consent_resource)
is NappletRequest.UploadBlob -> loadStringRes(Res.string.napplet_consent_upload)
// Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown.
is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.verify
@@ -176,7 +177,7 @@ object NappletLauncher {
val useTor = if (profile.exposesNetwork) NappletNetworkRegistry.useTor(identity.coordinate) else true
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
val capLabels = declared.map { context.getString(it.labelRes()) }
val capLabels = declared.map { stringRes(context, it.labelResId()) }
val themeType = Amethyst.instance.uiPrefs.value.theme.value
val theme =
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
@@ -30,6 +29,14 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.napplet.protocol.counterpartyPubKey
import com.vitorpamplona.amethyst.commons.napplet.protocol.toNarrowSignerOp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_fallback_title
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt_from
import com.vitorpamplona.amethyst.commons.resources.napplet_op_encrypt
import com.vitorpamplona.amethyst.commons.resources.napplet_op_sign_kind_named
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_for
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
@@ -42,12 +49,12 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.utils.TimeUtils
/** Human-readable label for a [NostrSignerOp]. */
fun NostrSignerOp.label(context: Context): String =
suspend fun NostrSignerOp.label(context: Context): String =
when (this) {
is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind)
NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt)
NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt)
is NostrSignerOp.DecryptFrom -> context.getString(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
is NostrSignerOp.SignKind -> loadStringRes(Res.string.napplet_op_sign_kind_named, kindNameFor(kind), kind)
NostrSignerOp.Encrypt -> loadStringRes(Res.string.napplet_op_encrypt)
NostrSignerOp.Decrypt -> loadStringRes(Res.string.napplet_op_decrypt)
is NostrSignerOp.DecryptFrom -> loadStringRes(Res.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
}
/**
@@ -66,13 +73,13 @@ fun counterpartyLabel(pubKeyHex: HexKey): String {
}
/** Builds the [SignerConsentInfo] needed by the per-op consent dialog. */
fun buildSignerConsentInfo(
suspend fun buildSignerConsentInfo(
context: Context,
identity: NappletIdentity,
op: NostrSignerOp,
request: NappletRequest,
): SignerConsentInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val untitled = loadStringRes(Res.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
@@ -156,7 +163,7 @@ fun buildSignerConsentInfo(
// are present, so deriving them from one value keeps them from disagreeing.
narrowOpLabel =
(narrowOp as? NostrSignerOp.DecryptFrom)?.let {
context.getString(R.string.nip46_signer_allow_always_for, counterpartyLabel(it.counterparty))
loadStringRes(Res.string.nip46_signer_allow_always_for, counterpartyLabel(it.counterparty))
},
)
}
@@ -167,12 +174,12 @@ fun buildSignerConsentInfo(
* [SignerConnectInfo.requestedPermissions] — otherwise the dialog would be asking the user to
* approve a set it never showed them.
*/
fun buildConnectInfo(
suspend fun buildConnectInfo(
context: Context,
identity: NappletIdentity,
declared: Set<NappletCapability> = emptySet(),
): SignerConnectInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val untitled = loadStringRes(Res.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
@@ -191,7 +198,7 @@ fun buildConnectInfo(
val preGranted =
declared
.filter { it.requiresConsent && !it.requiresPerUseConsent }
.map { context.getString(it.labelRes()) }
.map { loadStringRes(it.labelRes()) }
.sorted()
return SignerConnectInfo(
appletTitle = title,
@@ -46,6 +46,9 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway
import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult
import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_notify
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
import com.vitorpamplona.amethyst.model.Account
@@ -168,7 +171,7 @@ class AccountNappletGateways(
* grant just no-ops). The in-app registry in [NappletNotificationStore] is the source of truth for
* the napplet's own `notify.list`/`dismiss`; this is the user-visible surface.
*/
private fun postSystemNotification(
private suspend fun postSystemNotification(
id: String,
title: String,
body: String,
@@ -179,14 +182,14 @@ class AccountNappletGateways(
val channel =
NotificationChannelCompat
.Builder(NOTIFY_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(context.getString(R.string.napplet_cap_notify))
.setName(loadStringRes(Res.string.napplet_cap_notify))
.build()
manager.createNotificationChannel(channel)
val notification =
NotificationCompat
.Builder(context, NOTIFY_CHANNEL_ID)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(title.ifBlank { context.getString(R.string.napplet_cap_notify) })
.setContentTitle(title.ifBlank { loadStringRes(Res.string.napplet_cap_notify) })
.setContentText(body)
.setAutoCancel(true)
.build()
@@ -20,41 +20,71 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.http_status_400
import com.vitorpamplona.amethyst.commons.resources.http_status_401
import com.vitorpamplona.amethyst.commons.resources.http_status_402
import com.vitorpamplona.amethyst.commons.resources.http_status_403
import com.vitorpamplona.amethyst.commons.resources.http_status_404
import com.vitorpamplona.amethyst.commons.resources.http_status_405
import com.vitorpamplona.amethyst.commons.resources.http_status_406
import com.vitorpamplona.amethyst.commons.resources.http_status_407
import com.vitorpamplona.amethyst.commons.resources.http_status_408
import com.vitorpamplona.amethyst.commons.resources.http_status_409
import com.vitorpamplona.amethyst.commons.resources.http_status_410
import com.vitorpamplona.amethyst.commons.resources.http_status_411
import com.vitorpamplona.amethyst.commons.resources.http_status_412
import com.vitorpamplona.amethyst.commons.resources.http_status_413
import com.vitorpamplona.amethyst.commons.resources.http_status_414
import com.vitorpamplona.amethyst.commons.resources.http_status_415
import com.vitorpamplona.amethyst.commons.resources.http_status_416
import com.vitorpamplona.amethyst.commons.resources.http_status_417
import com.vitorpamplona.amethyst.commons.resources.http_status_426
import com.vitorpamplona.amethyst.commons.resources.http_status_500
import com.vitorpamplona.amethyst.commons.resources.http_status_501
import com.vitorpamplona.amethyst.commons.resources.http_status_502
import com.vitorpamplona.amethyst.commons.resources.http_status_503
import com.vitorpamplona.amethyst.commons.resources.http_status_504
import com.vitorpamplona.amethyst.commons.resources.http_status_505
import com.vitorpamplona.amethyst.commons.resources.http_status_506
import com.vitorpamplona.amethyst.commons.resources.http_status_507
import com.vitorpamplona.amethyst.commons.resources.http_status_508
import com.vitorpamplona.amethyst.commons.resources.http_status_511
import org.jetbrains.compose.resources.StringResource
class HttpStatusMessages {
companion object {
fun resourceIdFor(statusCode: Int = 0): Int? =
fun resourceIdFor(statusCode: Int = 0): StringResource? =
when (statusCode) {
400 -> R.string.http_status_400
401 -> R.string.http_status_401
402 -> R.string.http_status_402
403 -> R.string.http_status_403
404 -> R.string.http_status_404
405 -> R.string.http_status_405
406 -> R.string.http_status_406
407 -> R.string.http_status_407
408 -> R.string.http_status_408
409 -> R.string.http_status_409
410 -> R.string.http_status_410
411 -> R.string.http_status_411
412 -> R.string.http_status_412
413 -> R.string.http_status_413
414 -> R.string.http_status_414
415 -> R.string.http_status_415
416 -> R.string.http_status_416
417 -> R.string.http_status_417
426 -> R.string.http_status_426
500 -> R.string.http_status_500
501 -> R.string.http_status_501
502 -> R.string.http_status_502
503 -> R.string.http_status_503
504 -> R.string.http_status_504
505 -> R.string.http_status_505
506 -> R.string.http_status_506
507 -> R.string.http_status_507
508 -> R.string.http_status_508
511 -> R.string.http_status_511
400 -> Res.string.http_status_400
401 -> Res.string.http_status_401
402 -> Res.string.http_status_402
403 -> Res.string.http_status_403
404 -> Res.string.http_status_404
405 -> Res.string.http_status_405
406 -> Res.string.http_status_406
407 -> Res.string.http_status_407
408 -> Res.string.http_status_408
409 -> Res.string.http_status_409
410 -> Res.string.http_status_410
411 -> Res.string.http_status_411
412 -> Res.string.http_status_412
413 -> Res.string.http_status_413
414 -> Res.string.http_status_414
415 -> Res.string.http_status_415
416 -> Res.string.http_status_416
417 -> Res.string.http_status_417
426 -> Res.string.http_status_426
500 -> Res.string.http_status_500
501 -> Res.string.http_status_501
502 -> Res.string.http_status_502
503 -> Res.string.http_status_503
504 -> Res.string.http_status_504
505 -> Res.string.http_status_505
506 -> Res.string.http_status_506
507 -> Res.string.http_status_507
508 -> Res.string.http_status_508
511 -> Res.string.http_status_511
else -> null
}
}
@@ -21,14 +21,21 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.clink_debit_no_response
import com.vitorpamplona.amethyst.commons.resources.error_dialog_pay_invoice_error
import com.vitorpamplona.amethyst.commons.resources.error_parsing_error_message
import com.vitorpamplona.amethyst.commons.resources.error_unable_to_fetch_invoice
import com.vitorpamplona.amethyst.commons.resources.podcast_value_error_title
import com.vitorpamplona.amethyst.commons.resources.podcast_value_keysend_requires_nwc
import com.vitorpamplona.amethyst.commons.resources.podcast_value_no_recipients
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
@@ -41,6 +48,7 @@ import com.vitorpamplona.quartz.podcasts.PodcastValueShare
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
@@ -88,8 +96,8 @@ class V4VPaymentHandler(
val shares = value.computeShares(totalMilliSats)
if (shares.isEmpty()) {
onError(
stringRes(context, R.string.podcast_value_error_title),
stringRes(context, R.string.podcast_value_no_recipients),
loadStringRes(Res.string.podcast_value_error_title),
loadStringRes(Res.string.podcast_value_no_recipients),
)
return@withContext
}
@@ -105,8 +113,8 @@ class V4VPaymentHandler(
payNodeSharesViaKeysend(nodeShares, boostagram, context, onError)
} else {
onError(
stringRes(context, R.string.podcast_value_error_title),
stringRes(context, R.string.podcast_value_keysend_requires_nwc),
loadStringRes(Res.string.podcast_value_error_title),
loadStringRes(Res.string.podcast_value_keysend_requires_nwc),
)
}
}
@@ -133,7 +141,7 @@ class V4VPaymentHandler(
}
/** Hex-encodes a TLV value string as NIP-47 `pay_keysend` requires (UTF-8 bytes → hex). */
private fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey()
private suspend fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey()
private suspend fun payNodeSharesViaKeysend(
shares: List<PodcastValueShare>,
@@ -163,12 +171,16 @@ class V4VPaymentHandler(
account.zaps.sendNwcRequest(
request = request,
onResponse = { response: Response? ->
response.nwcFailureDetail(context)?.let { detail ->
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), detail)
account.scope.launch {
response.nwcFailureDetail()?.let { detail ->
onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), detail)
}
}
},
onTimeout = {
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context))
account.scope.launch {
onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), nwcTimeoutMessage())
}
},
)
}
@@ -231,8 +243,8 @@ class V4VPaymentHandler(
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
e.message ?: stringRes(context, R.string.error_parsing_error_message),
loadStringRes(Res.string.error_unable_to_fetch_invoice),
e.message ?: loadStringRes(Res.string.error_parsing_error_message),
)
null
}
@@ -257,12 +269,16 @@ class V4VPaymentHandler(
bolt11 = payable.invoice,
zappedNote = zappedNote,
onResponse = { response ->
response.nwcFailureDetail(context)?.let { detail ->
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), detail)
account.scope.launch {
response.nwcFailureDetail()?.let { detail ->
onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), detail)
}
}
},
onTimeout = {
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context))
account.scope.launch {
onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), nwcTimeoutMessage())
}
},
)
done++
@@ -276,9 +292,9 @@ class V4VPaymentHandler(
val response = ClinkDebitPayer.payInvoice(account, source.wallet.pointer, payable.invoice)
if (response?.isOk() != true) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
loadStringRes(Res.string.error_dialog_pay_invoice_error),
response?.failureDetail()
?: stringRes(context, R.string.clink_debit_no_response),
?: loadStringRes(Res.string.clink_debit_no_response),
)
}
done++
@@ -22,16 +22,27 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.bolt12_payment_failed
import com.vitorpamplona.amethyst.commons.resources.bolt12_zap_error
import com.vitorpamplona.amethyst.commons.resources.clink_debit_no_response
import com.vitorpamplona.amethyst.commons.resources.error_dialog_pay_invoice_error
import com.vitorpamplona.amethyst.commons.resources.error_dialog_zap_error
import com.vitorpamplona.amethyst.commons.resources.error_unable_to_fetch_invoice
import com.vitorpamplona.amethyst.commons.resources.missing_lud16
import com.vitorpamplona.amethyst.commons.resources.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error
import com.vitorpamplona.amethyst.commons.resources.user_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.wallet_connect_pay_invoice_error_error
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
@@ -53,6 +64,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jetbrains.compose.resources.StringResource
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.round
@@ -210,17 +222,16 @@ class ZapPaymentHandler(
errors.forEach {
val message =
if (it.user != null) {
stringRes(
context,
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
loadStringRes(
Res.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
it.user.toBestDisplayName(),
)
} else {
stringRes(context, R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)
loadStringRes(Res.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)
}
onError(
stringRes(context, R.string.missing_lud16),
loadStringRes(Res.string.missing_lud16),
message,
it.user,
)
@@ -337,7 +348,7 @@ class ZapPaymentHandler(
}
}
private fun calculateZapValue(
private suspend fun calculateZapValue(
amountMilliSats: Long,
weight: Double,
totalWeight: Double,
@@ -430,13 +441,11 @@ class ZapPaymentHandler(
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(
context,
R.string.error_unable_to_fetch_invoice,
loadStringRes(
Res.string.error_unable_to_fetch_invoice,
),
stringRes(
context,
R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
loadStringRes(
Res.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
e.message,
),
null,
@@ -474,21 +483,25 @@ class ZapPaymentHandler(
comment = payable.message,
),
onResponse = { response ->
progress.step()
response.nwcFailureDetail(context)?.let { detail ->
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
stringRes(context, R.string.wallet_connect_pay_invoice_error_error, detail),
payable.info.user,
)
account.scope.launch {
progress.step()
response.nwcFailureDetail()?.let { detail ->
onError(
loadStringRes(Res.string.error_dialog_pay_invoice_error),
loadStringRes(Res.string.wallet_connect_pay_invoice_error_error, detail),
payable.info.user,
)
}
}
},
onTimeout = {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
nwcTimeoutMessage(context),
payable.info.user,
)
account.scope.launch {
onError(
loadStringRes(Res.string.error_dialog_pay_invoice_error),
nwcTimeoutMessage(),
payable.info.user,
)
}
},
)
@@ -532,12 +545,12 @@ class ZapPaymentHandler(
val progress = PaymentProgress(recipients.size, onProgress)
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
fun reportBolt12Error(
msgRes: Int,
suspend fun reportBolt12Error(
msgRes: StringResource,
detail: String?,
) {
val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes)
onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user)
val msg = if (detail != null) loadStringRes(msgRes, detail) else loadStringRes(msgRes)
onError(loadStringRes(Res.string.bolt12_zap_error), msg, recipient.user)
}
account.zaps.sendBolt12Zap(
@@ -547,7 +560,7 @@ class ZapPaymentHandler(
amountMillisats = calculateZapValue(totalAmountMilliSats, recipient.weight, totalWeight),
message = message,
zapType = zapType,
onError = ::reportBolt12Error,
onError = { msgRes, detail -> account.scope.launch { reportBolt12Error(msgRes, detail) } },
onNotPaid = { code, detail ->
val lnAddress = recipient.lnAddress
if (lnAddress != null && Bolt12LightningFallback.shouldRetry(code)) {
@@ -575,18 +588,20 @@ class ZapPaymentHandler(
// Nothing was paid on either rail. Report it as the lightning failure it
// is, rather than letting [sendBolt12Zap]'s catch call it "paid, no receipt".
Log.w("ZapPaymentHandler", "BOLT11 fallback failed after a refused BOLT12 offer", e)
onError(stringRes(context, R.string.error_dialog_zap_error), e.message ?: e.toString(), recipient.user)
onError(loadStringRes(Res.string.error_dialog_zap_error), e.message ?: e.toString(), recipient.user)
}
} else {
// bolt12_payment_failed always formats a detail; a wallet may send neither
// message nor a recognised code.
reportBolt12Error(R.string.bolt12_payment_failed, detail ?: (code ?: NwcErrorCode.OTHER).name)
reportBolt12Error(Res.string.bolt12_payment_failed, detail ?: (code ?: NwcErrorCode.OTHER).name)
}
},
onTimeout = {
// No response callback will fire, so account for the settlement step here.
reportBolt12Error(R.string.bolt12_payment_failed, nwcTimeoutMessage(context))
progress.step()
account.scope.launch {
// No response callback will fire, so account for the settlement step here.
reportBolt12Error(Res.string.bolt12_payment_failed, nwcTimeoutMessage())
progress.step()
}
},
onProcessed = { progress.step() },
)
@@ -639,9 +654,9 @@ class ZapPaymentHandler(
progress.step()
if (response?.isOk() != true) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
loadStringRes(Res.string.error_dialog_pay_invoice_error),
response?.failureDetail()
?: stringRes(context, R.string.clink_debit_no_response),
?: loadStringRes(Res.string.clink_debit_no_response),
payable.info.user,
)
}
@@ -29,8 +29,12 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_channel_description
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_channel_id
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_channel_name
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Posts user-visible "starting soon" notifications for NIP-52 appointments the user has RSVP'd
@@ -52,7 +56,7 @@ object CalendarReminderNotifier {
* calendar detail screen (CalendarTimeSlotEvent / CalendarDateSlotEvent
* branches in RouteMaker resolve to Route.CalendarEventDetail).
*/
fun notifyReminder(
suspend fun notifyReminder(
context: Context,
eventId: String,
title: String,
@@ -61,7 +65,7 @@ object CalendarReminderNotifier {
) {
ensureChannel(context)
val notId = idFor(eventId)
val channelId = stringRes(context, R.string.calendar_reminder_channel_id)
val channelId = loadStringRes(Res.string.calendar_reminder_channel_id)
val tapIntent =
Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
@@ -100,15 +104,15 @@ object CalendarReminderNotifier {
}
}
fun ensureChannel(context: Context) {
suspend fun ensureChannel(context: Context) {
if (channel != null) return
channel =
NotificationChannel(
stringRes(context, R.string.calendar_reminder_channel_id),
stringRes(context, R.string.calendar_reminder_channel_name),
loadStringRes(Res.string.calendar_reminder_channel_id),
loadStringRes(Res.string.calendar_reminder_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = stringRes(context, R.string.calendar_reminder_channel_description)
description = loadStringRes(Res.string.calendar_reminder_channel_description)
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel!!)
@@ -27,12 +27,14 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_body
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_default_title
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
@@ -98,12 +100,11 @@ class CalendarReminderWorker(
// enters the lead window — the old notification stays valid in the system tray.
if (store.wasNotified(eventId, start)) return@forEach
val title = view.title ?: stringRes(applicationContext, R.string.calendar_reminder_default_title)
val title = view.title ?: loadStringRes(Res.string.calendar_reminder_default_title)
val minutesAway = ((start - now).coerceAtLeast(0L) / 60L).toInt()
val body =
pluralStringRes(
applicationContext,
R.plurals.calendar_reminder_body,
loadPluralStringRes(
Res.plurals.calendar_reminder_body,
minutesAway,
minutesAway,
)
@@ -33,9 +33,15 @@ import coil3.request.ImageRequest
import coil3.request.allowHardware
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_calls_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_calls_channel_name
import com.vitorpamplona.amethyst.commons.resources.call_accept
import com.vitorpamplona.amethyst.commons.resources.call_incoming
import com.vitorpamplona.amethyst.commons.resources.call_reject
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.call.CallNotificationReceiver
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import kotlinx.coroutines.Dispatchers
@@ -63,16 +69,16 @@ object CallNotifier {
const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
private const val CALL_NOTIFICATION_ID = 0x50000
fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
suspend fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
callChannel?.let { return it }
val channel =
NotificationChannel(
CALL_CHANNEL_ID,
stringRes(applicationContext, R.string.app_notification_calls_channel_name),
loadStringRes(Res.string.app_notification_calls_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = stringRes(applicationContext, R.string.app_notification_calls_channel_description)
description = loadStringRes(Res.string.app_notification_calls_channel_description)
// Silence the notification sound — CallAudioManager plays the ringtone
setSound(null, null)
enableVibration(false)
@@ -91,7 +97,7 @@ object CallNotifier {
* full-screen intent that opens [CallActivity] when the device is
* unlocked, and provides Accept / Reject actions.
*/
fun send(
suspend fun send(
callerName: String,
callerBitmap: Bitmap?,
applicationContext: Context,
@@ -148,7 +154,7 @@ object CallNotifier {
NotificationCompat
.Builder(applicationContext, channel.id)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(stringRes(applicationContext, R.string.call_incoming))
.setContentTitle(loadStringRes(Res.string.call_incoming))
.setContentText(callerName)
.setLargeIcon(callerBitmap)
.setContentIntent(contentPendingIntent)
@@ -159,8 +165,8 @@ object CallNotifier {
.setOngoing(true)
.setTimeoutAfter(60_000)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), rejectPendingIntent)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), acceptPendingIntent)
.addAction(R.drawable.amethyst, loadStringRes(Res.string.call_reject), rejectPendingIntent)
.addAction(R.drawable.amethyst, loadStringRes(Res.string.call_accept), acceptPendingIntent)
notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build())
}
@@ -21,10 +21,14 @@
package com.vitorpamplona.amethyst.service.cashu.melt
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cashu.melt.MeltResult
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cashu_failed_redemption
import com.vitorpamplona.amethyst.commons.resources.cashu_failed_redemption_explainer_error_msg
import com.vitorpamplona.amethyst.commons.resources.cashu_unsafe_mint_url
import com.vitorpamplona.amethyst.commons.resources.cashu_unsafe_mint_url_explainer
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintUrlException
@@ -85,10 +89,9 @@ class MeltProcessor {
val sendable = token.totalAmount - fees
if (sendable <= 0) {
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
stringRes(
context,
R.string.cashu_failed_redemption_explainer_error_msg,
loadStringRes(Res.string.cashu_failed_redemption),
loadStringRes(
Res.string.cashu_failed_redemption_explainer_error_msg,
"Token value ${token.totalAmount} does not cover fees $fees",
),
)
@@ -121,13 +124,13 @@ class MeltProcessor {
// message, not the mint's, so don't dress it up as "the mint said".
if (e is MintUrlException) {
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_unsafe_mint_url),
stringRes(context, R.string.cashu_unsafe_mint_url_explainer, e.message),
loadStringRes(Res.string.cashu_unsafe_mint_url),
loadStringRes(Res.string.cashu_unsafe_mint_url_explainer, e.message),
)
}
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
loadStringRes(Res.string.cashu_failed_redemption),
loadStringRes(Res.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
@@ -34,11 +34,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.crashreport_found
import com.vitorpamplona.amethyst.commons.resources.crashreport_found_send
import com.vitorpamplona.amethyst.commons.resources.would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared
@@ -76,7 +76,7 @@ fun DisplayCrashMessages(
},
dismissButton = {
TextButton(onClick = { stackTrace.value = null }) {
Text(stringRes(R.string.cancel))
Text(stringRes(Res.string.cancel))
}
},
confirmButton = {
@@ -22,9 +22,20 @@ package com.vitorpamplona.amethyst.service.lnurl
import android.content.Context
import com.fasterxml.jackson.databind.ObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user
import com.vitorpamplona.amethyst.commons.resources.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup
import com.vitorpamplona.amethyst.commons.resources.could_not_fetch_invoice_from_details
import com.vitorpamplona.amethyst.commons.resources.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception
import com.vitorpamplona.amethyst.commons.resources.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user
import com.vitorpamplona.amethyst.commons.resources.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user
import com.vitorpamplona.amethyst.commons.resources.error_unable_to_fetch_invoice
import com.vitorpamplona.amethyst.commons.resources.incorrect_invoice_amount_sats_from_it_should_have_been
import com.vitorpamplona.amethyst.commons.resources.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct
import com.vitorpamplona.amethyst.commons.resources.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user
import com.vitorpamplona.amethyst.commons.resources.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
@@ -69,8 +80,8 @@ class LightningAddressResolver {
): String {
val url =
assembleUrl(lnAddress) ?: throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(context, R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, lnAddress),
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(Res.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, lnAddress),
)
val client = okHttpClient(url)
@@ -88,11 +99,9 @@ class LightningAddressResolver {
response.body.string()
} else {
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
url,
lnAddress,
errorMessage(response, context),
@@ -104,11 +113,9 @@ class LightningAddressResolver {
} catch (e: Exception) {
if (e is CancellationException) throw e
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
url,
lnAddress,
e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message,
@@ -151,15 +158,15 @@ class LightningAddressResolver {
response.body.string()
} else {
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(response, context)),
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(Res.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(response, context)),
)
}
}
}
}
fun errorMessage(
suspend fun errorMessage(
response: Response,
context: Context,
): String {
@@ -196,7 +203,7 @@ class LightningAddressResolver {
}
return errorMessage
?: HttpStatusMessages.resourceIdFor(response.code)?.let { stringRes(context, it) }
?: HttpStatusMessages.resourceIdFor(response.code)?.let { loadStringRes(it) }
?: response.message.ifBlank { null }
?: response.code.toString()
}
@@ -237,10 +244,9 @@ class LightningAddressResolver {
} catch (t: Throwable) {
if (t is CancellationException) throw t
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
lnAddress,
),
)
@@ -250,10 +256,9 @@ class LightningAddressResolver {
if (callbackUrl == null) {
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
lnAddress,
),
)
@@ -297,11 +302,9 @@ class LightningAddressResolver {
} catch (t: Throwable) {
if (t is CancellationException) throw t
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
lnAddress,
),
)
@@ -315,22 +318,18 @@ class LightningAddressResolver {
if (reason != null) {
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
lnAddress,
reason,
),
)
} else {
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
lnAddress,
),
)
@@ -346,10 +345,9 @@ class LightningAddressResolver {
if (invoiceAmount.toLong() != expectedAmountInSats) {
onProgress(0.0f)
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.incorrect_invoice_amount_sats_from_it_should_have_been,
invoiceAmount.toLong().toString(),
lnAddress,
expectedAmountInSats.toString(),
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusBus
import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusState
import com.vitorpamplona.amethyst.commons.viewmodels.NestNetworkChangeBus
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
/**
@@ -389,19 +390,19 @@ class NestForegroundService : Service() {
val title =
if (promoted) {
getString(R.string.nest_notification_broadcasting)
stringRes(this, R.string.nest_notification_broadcasting)
} else {
getString(R.string.nest_notification_listening)
stringRes(this, R.string.nest_notification_listening)
}
return NotificationCompat
.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(getString(R.string.nest_notification_text))
.setContentText(stringRes(this, R.string.nest_notification_text))
.setSmallIcon(R.drawable.amethyst)
.setOngoing(true)
.setContentIntent(openIntent)
.addAction(0, getString(R.string.nest_notification_stop), stopIntent)
.addAction(0, stringRes(this, R.string.nest_notification_stop), stopIntent)
.setCategory(NotificationCompat.CATEGORY_CALL)
.build()
}
@@ -412,10 +413,10 @@ class NestForegroundService : Service() {
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
getString(R.string.nest_notification_channel),
stringRes(this, R.string.nest_notification_channel),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.nest_notification_channel_description)
description = stringRes(this@NestForegroundService, R.string.nest_notification_channel_description)
setShowBadge(false)
},
)
@@ -28,9 +28,10 @@ import androidx.core.content.pm.ShortcutInfoCompat
import androidx.core.content.pm.ShortcutManagerCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_shortcut_account_removed
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
/**
@@ -140,7 +141,7 @@ object ConversationShortcuts {
* the cache is what keeps a conversation alive after it falls out of the top slots, so
* dropping only the dynamic copy would leave it recoverable.
*/
fun removeForAccount(
suspend fun removeForAccount(
context: Context,
accountNpub: String,
) {
@@ -170,7 +171,7 @@ object ConversationShortcuts {
ShortcutManagerCompat.disableShortcuts(
context,
mine,
stringRes(context, R.string.app_notification_shortcut_account_removed),
loadStringRes(Res.string.app_notification_shortcut_account_removed),
)
ShortcutManagerCompat.removeLongLivedShortcuts(context, mine)
} catch (e: Exception) {
@@ -30,13 +30,16 @@ import coil3.request.ImageRequest
import coil3.request.allowHardware
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.LocalPreferences
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.isMutedPublicChatMessage
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_challenge_accepted
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_your_turn
import com.vitorpamplona.amethyst.commons.resources.app_notification_poll_channel_message
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.notifications.renderers.ArticleNotification
@@ -275,7 +278,7 @@ class EventNotificationConsumer(
is VideoVerticalEvent,
-> MediaNotification.notify(applicationContext, account, event)
is PollEvent -> MentionNotification.notify(applicationContext, account, event, titleRes = R.string.app_notification_poll_channel_message)
is PollEvent -> MentionNotification.notify(applicationContext, account, event, titleRes = Res.string.app_notification_poll_channel_message)
is HighlightEvent,
is LongTextNoteEvent,
@@ -292,8 +295,8 @@ class EventNotificationConsumer(
is GitStatusClosedEvent -> CodeNotification.notify(applicationContext, account, event)
is GitStatusDraftEvent -> CodeNotification.notify(applicationContext, account, event)
is LiveChessGameAcceptEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_challenge_accepted)
is LiveChessMoveEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_your_turn)
is LiveChessGameAcceptEvent -> ChessNotification.notify(applicationContext, account, event, Res.string.app_notification_chess_challenge_accepted)
is LiveChessMoveEvent -> ChessNotification.notify(applicationContext, account, event, Res.string.app_notification_chess_your_turn)
}
}
@@ -27,7 +27,12 @@ import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_reply_failed
import com.vitorpamplona.amethyst.commons.resources.app_notification_reply_retry
import com.vitorpamplona.amethyst.commons.resources.app_notification_reply_sending
import com.vitorpamplona.amethyst.commons.resources.app_notification_reply_unconfirmed
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.quartz.utils.Log
/**
@@ -87,7 +92,7 @@ sealed interface ReplyState {
* Returns false when nothing is posted under [notId] any more — the user swiped it away while
* the reply was in flight. Nothing is re-posted in that case: they are done with it.
*/
fun NotificationManager.renderReplyState(
suspend fun NotificationManager.renderReplyState(
applicationContext: Context,
notId: Int,
state: ReplyState,
@@ -105,7 +110,7 @@ fun NotificationManager.renderReplyState(
private const val TAG = "InlineReplyFeedback"
private fun NotificationManager.renderReplyStateOrThrow(
private suspend fun NotificationManager.renderReplyStateOrThrow(
applicationContext: Context,
notId: Int,
state: ReplyState,
@@ -126,7 +131,7 @@ private fun NotificationManager.renderReplyStateOrThrow(
// `null` attributes the message to the MessagingStyle's own user.
style?.addMessage(state.text, System.currentTimeMillis(), null as Person?)
?: builder.setRemoteInputHistory(arrayOf(state.text))
builder.setSubText(stringRes(applicationContext, R.string.app_notification_reply_sending))
builder.setSubText(loadStringRes(Res.string.app_notification_reply_sending))
}
ReplyState.Sent -> {
@@ -136,14 +141,14 @@ private fun NotificationManager.renderReplyStateOrThrow(
is ReplyState.Unconfirmed -> {
if (style == null) builder.setRemoteInputHistory(arrayOf(state.text))
builder.setSubText(stringRes(applicationContext, R.string.app_notification_reply_unconfirmed))
builder.setSubText(loadStringRes(Res.string.app_notification_reply_unconfirmed))
}
is ReplyState.Failed -> {
if (style == null) builder.setRemoteInputHistory(arrayOf(state.text))
builder.setSubText(stringRes(applicationContext, R.string.app_notification_reply_failed))
builder.setSubText(loadStringRes(Res.string.app_notification_reply_failed))
val retryLabel = stringRes(applicationContext, R.string.app_notification_reply_retry)
val retryLabel = loadStringRes(Res.string.app_notification_reply_retry)
// Rebuilt from the posted notification, so its actions come with it — and a bare
// clearActions() would take Reply and Mark Read with them, leaving a failed reply
@@ -25,11 +25,66 @@ import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_dms_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_dms_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_dms_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_dms_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_content
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_developer
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_games
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_messages
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_payments
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_social
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_replies_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_replies_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_replies_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_replies_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_summary
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import org.jetbrains.compose.resources.StringResource
/**
* Logical grouping of notification channels, shown as a section header in the
@@ -39,14 +94,14 @@ import com.vitorpamplona.amethyst.ui.stringRes
*/
enum class NotifChannelGroup(
val id: String,
@param:StringRes val nameRes: Int,
val nameRes: StringResource,
) {
MESSAGES("com.vitorpamplona.amethyst.group.messages", R.string.app_notification_group_messages),
SOCIAL("com.vitorpamplona.amethyst.group.social", R.string.app_notification_group_social),
PAYMENTS("com.vitorpamplona.amethyst.group.payments", R.string.app_notification_group_payments),
CONTENT("com.vitorpamplona.amethyst.group.content", R.string.app_notification_group_content),
DEVELOPER("com.vitorpamplona.amethyst.group.developer", R.string.app_notification_group_developer),
GAMES("com.vitorpamplona.amethyst.group.games", R.string.app_notification_group_games),
MESSAGES("com.vitorpamplona.amethyst.group.messages", Res.string.app_notification_group_messages),
SOCIAL("com.vitorpamplona.amethyst.group.social", Res.string.app_notification_group_social),
PAYMENTS("com.vitorpamplona.amethyst.group.payments", Res.string.app_notification_group_payments),
CONTENT("com.vitorpamplona.amethyst.group.content", Res.string.app_notification_group_content),
DEVELOPER("com.vitorpamplona.amethyst.group.developer", Res.string.app_notification_group_developer),
GAMES("com.vitorpamplona.amethyst.group.games", Res.string.app_notification_group_games),
}
/**
@@ -66,10 +121,10 @@ enum class NotifChannelGroup(
* table-driven definition every renderer reads from.
*/
enum class NotificationCategory(
@param:StringRes val channelIdRes: Int,
@param:StringRes val channelNameRes: Int,
@param:StringRes val channelDescriptionRes: Int,
@param:StringRes val summaryTextRes: Int,
val channelIdRes: StringResource,
val channelNameRes: StringResource,
val channelDescriptionRes: StringResource,
val summaryTextRes: StringResource,
val importance: Int,
val color: Int,
@param:DrawableRes val smallIcon: Int,
@@ -81,10 +136,10 @@ enum class NotificationCategory(
val colorized: Boolean = false,
) {
DIRECT_MESSAGE(
channelIdRes = R.string.app_notification_dms_channel_id,
channelNameRes = R.string.app_notification_dms_channel_name,
channelDescriptionRes = R.string.app_notification_dms_channel_description,
summaryTextRes = R.string.app_notification_dms_summary,
channelIdRes = Res.string.app_notification_dms_channel_id,
channelNameRes = Res.string.app_notification_dms_channel_name,
channelDescriptionRes = Res.string.app_notification_dms_channel_description,
summaryTextRes = Res.string.app_notification_dms_summary,
importance = NotificationManager.IMPORTANCE_HIGH,
color = 0xFF2196F3.toInt(), // blue
smallIcon = R.drawable.ic_notif_message,
@@ -94,10 +149,10 @@ enum class NotificationCategory(
summaryId = 0x10000,
),
REPLY(
channelIdRes = R.string.app_notification_replies_channel_id,
channelNameRes = R.string.app_notification_replies_channel_name,
channelDescriptionRes = R.string.app_notification_replies_channel_description,
summaryTextRes = R.string.app_notification_replies_summary,
channelIdRes = Res.string.app_notification_replies_channel_id,
channelNameRes = Res.string.app_notification_replies_channel_name,
channelDescriptionRes = Res.string.app_notification_replies_channel_description,
summaryTextRes = Res.string.app_notification_replies_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF7C4DFF.toInt(), // deep purple
smallIcon = R.drawable.ic_notif_reply,
@@ -109,10 +164,10 @@ enum class NotificationCategory(
summaryId = 0x50000,
),
MENTION(
channelIdRes = R.string.app_notification_mentions_channel_id,
channelNameRes = R.string.app_notification_mentions_channel_name,
channelDescriptionRes = R.string.app_notification_mentions_channel_description,
summaryTextRes = R.string.app_notification_mentions_summary,
channelIdRes = Res.string.app_notification_mentions_channel_id,
channelNameRes = Res.string.app_notification_mentions_channel_name,
channelDescriptionRes = Res.string.app_notification_mentions_channel_description,
summaryTextRes = Res.string.app_notification_mentions_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF9C27B0.toInt(), // purple
smallIcon = R.drawable.ic_notif_mention,
@@ -122,10 +177,10 @@ enum class NotificationCategory(
summaryId = 0x60000,
),
REACTION(
channelIdRes = R.string.app_notification_reactions_channel_id,
channelNameRes = R.string.app_notification_reactions_channel_name,
channelDescriptionRes = R.string.app_notification_reactions_channel_description,
summaryTextRes = R.string.app_notification_reactions_summary,
channelIdRes = Res.string.app_notification_reactions_channel_id,
channelNameRes = Res.string.app_notification_reactions_channel_name,
channelDescriptionRes = Res.string.app_notification_reactions_channel_description,
summaryTextRes = Res.string.app_notification_reactions_summary,
importance = NotificationManager.IMPORTANCE_LOW,
color = 0xFFE91E63.toInt(), // pink / heart
smallIcon = R.drawable.ic_notif_reaction,
@@ -135,10 +190,10 @@ enum class NotificationCategory(
summaryId = 0x40000,
),
REPOST(
channelIdRes = R.string.app_notification_reposts_channel_id,
channelNameRes = R.string.app_notification_reposts_channel_name,
channelDescriptionRes = R.string.app_notification_reposts_channel_description,
summaryTextRes = R.string.app_notification_reposts_summary,
channelIdRes = Res.string.app_notification_reposts_channel_id,
channelNameRes = Res.string.app_notification_reposts_channel_name,
channelDescriptionRes = Res.string.app_notification_reposts_channel_description,
summaryTextRes = Res.string.app_notification_reposts_summary,
importance = NotificationManager.IMPORTANCE_LOW,
color = 0xFF4CAF50.toInt(), // green
smallIcon = R.drawable.ic_notif_repost,
@@ -148,10 +203,10 @@ enum class NotificationCategory(
summaryId = 0x70000,
),
ZAP(
channelIdRes = R.string.app_notification_zaps_channel_id,
channelNameRes = R.string.app_notification_zaps_channel_name,
channelDescriptionRes = R.string.app_notification_zaps_channel_description,
summaryTextRes = R.string.app_notification_zaps_summary,
channelIdRes = Res.string.app_notification_zaps_channel_id,
channelNameRes = Res.string.app_notification_zaps_channel_name,
channelDescriptionRes = Res.string.app_notification_zaps_channel_description,
summaryTextRes = Res.string.app_notification_zaps_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFFF7931A.toInt(), // bitcoin orange
smallIcon = R.drawable.ic_notif_zap,
@@ -162,10 +217,10 @@ enum class NotificationCategory(
colorized = true,
),
MEDIA(
channelIdRes = R.string.app_notification_media_channel_id,
channelNameRes = R.string.app_notification_media_channel_name,
channelDescriptionRes = R.string.app_notification_media_channel_description,
summaryTextRes = R.string.app_notification_media_summary,
channelIdRes = Res.string.app_notification_media_channel_id,
channelNameRes = Res.string.app_notification_media_channel_name,
channelDescriptionRes = Res.string.app_notification_media_channel_description,
summaryTextRes = Res.string.app_notification_media_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF00BCD4.toInt(), // cyan
smallIcon = R.drawable.ic_notif_media,
@@ -175,10 +230,10 @@ enum class NotificationCategory(
summaryId = 0x80000,
),
ARTICLE(
channelIdRes = R.string.app_notification_articles_channel_id,
channelNameRes = R.string.app_notification_articles_channel_name,
channelDescriptionRes = R.string.app_notification_articles_channel_description,
summaryTextRes = R.string.app_notification_articles_summary,
channelIdRes = Res.string.app_notification_articles_channel_id,
channelNameRes = Res.string.app_notification_articles_channel_name,
channelDescriptionRes = Res.string.app_notification_articles_channel_description,
summaryTextRes = Res.string.app_notification_articles_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF3F51B5.toInt(), // indigo
smallIcon = R.drawable.ic_notif_article,
@@ -188,10 +243,10 @@ enum class NotificationCategory(
summaryId = 0x90000,
),
CODE(
channelIdRes = R.string.app_notification_code_channel_id,
channelNameRes = R.string.app_notification_code_channel_name,
channelDescriptionRes = R.string.app_notification_code_channel_description,
summaryTextRes = R.string.app_notification_code_summary,
channelIdRes = Res.string.app_notification_code_channel_id,
channelNameRes = Res.string.app_notification_code_channel_name,
channelDescriptionRes = Res.string.app_notification_code_channel_description,
summaryTextRes = Res.string.app_notification_code_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF607D8B.toInt(), // slate
smallIcon = R.drawable.ic_notif_code,
@@ -201,10 +256,10 @@ enum class NotificationCategory(
summaryId = 0xA0000,
),
BADGE(
channelIdRes = R.string.app_notification_badges_channel_id,
channelNameRes = R.string.app_notification_badges_channel_name,
channelDescriptionRes = R.string.app_notification_badges_channel_description,
summaryTextRes = R.string.app_notification_badges_summary,
channelIdRes = Res.string.app_notification_badges_channel_id,
channelNameRes = Res.string.app_notification_badges_channel_name,
channelDescriptionRes = Res.string.app_notification_badges_channel_description,
summaryTextRes = Res.string.app_notification_badges_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFFFFC107.toInt(), // amber / gold
smallIcon = R.drawable.ic_notif_badge,
@@ -214,10 +269,10 @@ enum class NotificationCategory(
summaryId = 0xB0000,
),
CHESS(
channelIdRes = R.string.app_notification_chess_channel_id,
channelNameRes = R.string.app_notification_chess_channel_name,
channelDescriptionRes = R.string.app_notification_chess_channel_description,
summaryTextRes = R.string.app_notification_chess_summary,
channelIdRes = Res.string.app_notification_chess_channel_id,
channelNameRes = Res.string.app_notification_chess_channel_name,
channelDescriptionRes = Res.string.app_notification_chess_channel_description,
summaryTextRes = Res.string.app_notification_chess_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF795548.toInt(), // brown
smallIcon = R.drawable.ic_notif_chess,
@@ -227,10 +282,10 @@ enum class NotificationCategory(
summaryId = 0x30000,
),
PAYMENT_RECEIVED(
channelIdRes = R.string.app_notification_payments_channel_id,
channelNameRes = R.string.app_notification_payments_channel_name,
channelDescriptionRes = R.string.app_notification_payments_channel_description,
summaryTextRes = R.string.app_notification_payments_summary,
channelIdRes = Res.string.app_notification_payments_channel_id,
channelNameRes = Res.string.app_notification_payments_channel_name,
channelDescriptionRes = Res.string.app_notification_payments_channel_description,
summaryTextRes = Res.string.app_notification_payments_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF16B979.toInt(), // lightning green — distinct from the gold zap channel
smallIcon = R.drawable.ic_notif_zap,
@@ -241,7 +296,7 @@ enum class NotificationCategory(
),
;
fun channelId(context: Context): String = stringRes(context, channelIdRes)
suspend fun channelId(context: Context): String = loadStringRes(channelIdRes)
@Volatile private var channelEnsured = false
@@ -252,18 +307,23 @@ enum class NotificationCategory(
* successful creation instead of paying two IPCs on every post and re-render.
* Returns the channel id to post on.
*/
fun ensureChannel(context: Context): String {
suspend fun ensureChannel(context: Context): String {
val id = channelId(context)
if (channelEnsured) return id
// Resolved before the monitor: reading the catalog suspends, and a suspension
// point inside a synchronized block would park the thread holding the lock.
val groupName = loadStringRes(channelGroup.nameRes)
val channelName = loadStringRes(channelNameRes)
val channelDescription = loadStringRes(channelDescriptionRes)
synchronized(this) {
if (!channelEnsured) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannelGroup(
NotificationChannelGroup(channelGroup.id, stringRes(context, channelGroup.nameRes)),
NotificationChannelGroup(channelGroup.id, groupName),
)
val channel =
NotificationChannel(id, stringRes(context, channelNameRes), importance).apply {
description = stringRes(context, channelDescriptionRes)
NotificationChannel(id, channelName, importance).apply {
description = channelDescription
group = channelGroup.id
}
nm.createNotificationChannel(channel)
@@ -25,14 +25,18 @@ import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_calls_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_name
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.scheduledposts.AndroidScheduledPostNotifier
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import org.jetbrains.compose.resources.StringResource
/**
* Registry of user-facing notification channels and helpers to read their
@@ -57,10 +61,10 @@ object NotificationChannels {
* settings page has something to open even before the first notification fires.
*/
data class Entry(
val nameRes: Int,
val nameRes: StringResource,
val icon: MaterialSymbol,
val channelId: (Context) -> String,
val ensure: (Context) -> Unit,
val channelId: suspend (Context) -> String,
val ensure: suspend (Context) -> Unit,
)
/** The per-kind content channels, derived from [NotificationCategory], in a
@@ -81,13 +85,13 @@ object NotificationChannels {
} +
listOf(
Entry(
nameRes = R.string.app_notification_scheduled_posts_channel_name,
nameRes = Res.string.app_notification_scheduled_posts_channel_name,
icon = MaterialSymbols.Schedule,
channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) },
channelId = { loadStringRes(Res.string.app_notification_scheduled_posts_channel_id) },
ensure = { AndroidScheduledPostNotifier.ensureChannel(it) },
),
Entry(
nameRes = R.string.app_notification_calls_channel_name,
nameRes = Res.string.app_notification_calls_channel_name,
icon = MaterialSymbols.Call,
channelId = { CallNotifier.CALL_CHANNEL_ID },
ensure = { CallNotifier.getOrCreateCallChannel(it) },
@@ -349,6 +349,22 @@ class NotificationRelayService : Service() {
}
private fun updateNotification(connectedRelays: Int) {
postNotification(connectedRelays)
// The breakdown names each job from the shared string catalog, whose only
// non-composable accessor suspends - so it is recomputed off this path and
// the card reposted once the text actually changes.
if (detailsExpanded) {
scope.launch {
val fresh = RelayPurposeSummary.lines()
if (fresh.isNotEmpty() && fresh != lastBreakdown) {
lastBreakdown = fresh
postNotification(connectedRelays)
}
}
}
}
private fun postNotification(connectedRelays: Int) {
val notification = buildNotification(connectedRelays)
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, notification)
@@ -398,14 +414,7 @@ class NotificationRelayService : Service() {
//
// Computed only while expanded: walking every connected relay's active requests once a
// second is wasted work when nobody has asked to see the result.
val breakdown =
if (detailsExpanded) {
val fresh = RelayPurposeSummary.lines(this)
if (fresh.isNotEmpty()) lastBreakdown = fresh
lastBreakdown.takeIf { it.isNotEmpty() }
} else {
null
}
val breakdown = if (detailsExpanded) lastBreakdown.takeIf { it.isNotEmpty() } else null
val detailsIntent =
Intent(this, NotificationRelayService::class.java).apply {
@@ -298,16 +298,17 @@ class NotificationReplyReceiver : BroadcastReceiver() {
// LocalCache hasn't been rehydrated yet (cold-process broadcast
// receiver: Account.restoreAll runs async on init and may not have
// finished by the time we get here).
val bundle =
manager.buildTextMessage(
nostrGroupId = nostrGroupId,
// Only the rumor here: sendMarmotGroupMessage does the MLS encryption
// and the group-state write, and building an envelope we would throw
// away would ratchet the group an extra step for nothing.
val innerEvent =
manager.buildTextRumor(
text = replyText,
replyToEventId = replyToInnerEventId,
replyToAuthorPubKey = replyToInnerAuthor,
persistOwn = false,
)
account.marmot.sendMarmotGroupMessage(nostrGroupId, bundle.innerEvent, account.marmot.marmotGroupRelays(nostrGroupId))
account.marmot.sendMarmotGroupMessage(nostrGroupId, innerEvent, account.marmot.marmotGroupRelays(nostrGroupId))
}
private suspend fun sendPublicReply(
@@ -46,8 +46,13 @@ import coil3.asDrawable
import coil3.request.ImageRequest
import coil3.request.allowHardware
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_mark_read_label
import com.vitorpamplona.amethyst.commons.resources.app_notification_me
import com.vitorpamplona.amethyst.commons.resources.app_notification_private_message
import com.vitorpamplona.amethyst.commons.resources.app_notification_reply_label
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@@ -243,7 +248,7 @@ object NotificationUtils {
.setSmallIcon(category.smallIcon)
.setColor(category.color)
.setContentTitle(messageTitle)
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
.setContentText(loadStringRes(Res.string.app_notification_private_message))
.setContentIntent(contentPendingIntent)
.setPriority(category.priority())
.setAutoCancel(true)
@@ -330,7 +335,7 @@ object NotificationUtils {
val me =
Person
.Builder()
.setName(stringRes(applicationContext, R.string.app_notification_me))
.setName(loadStringRes(Res.string.app_notification_me))
.apply { accountAvatar?.let { setIcon(IconCompat.createWithBitmap(it)) } }
.build()
@@ -373,7 +378,7 @@ object NotificationUtils {
.setSmallIcon(category.smallIcon)
.setColor(category.color)
.setContentTitle(senderName)
.setContentText(stringRes(applicationContext, R.string.app_notification_private_message))
.setContentText(loadStringRes(Res.string.app_notification_private_message))
.setLargeIcon(avatar)
.setContentIntent(contentPendingIntent)
.setPriority(category.priority())
@@ -469,13 +474,13 @@ object NotificationUtils {
)
}
private fun replyRemoteInput(applicationContext: Context): RemoteInput =
private suspend fun replyRemoteInput(applicationContext: Context): RemoteInput =
RemoteInput
.Builder(KEY_REPLY_TEXT)
.setLabel(stringRes(applicationContext, R.string.app_notification_reply_label))
.setLabel(loadStringRes(Res.string.app_notification_reply_label))
.build()
private fun buildReplyAction(
private suspend fun buildReplyAction(
applicationContext: Context,
notId: Int,
intent: Intent,
@@ -488,14 +493,14 @@ object NotificationUtils {
PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Action
.Builder(R.drawable.ic_action_reply, stringRes(applicationContext, R.string.app_notification_reply_label), replyPendingIntent)
.Builder(R.drawable.ic_action_reply, loadStringRes(Res.string.app_notification_reply_label), replyPendingIntent)
.addRemoteInput(replyRemoteInput(applicationContext))
.setAllowGeneratedReplies(true)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY)
.build()
}
private fun dmReplyAction(
private suspend fun dmReplyAction(
applicationContext: Context,
notId: Int,
eventId: String,
@@ -512,7 +517,7 @@ object NotificationUtils {
return buildReplyAction(applicationContext, notId, intent)
}
private fun marmotReplyAction(
private suspend fun marmotReplyAction(
applicationContext: Context,
notId: Int,
eventId: String,
@@ -531,7 +536,7 @@ object NotificationUtils {
return buildReplyAction(applicationContext, notId, intent)
}
private fun publicReplyAction(
private suspend fun publicReplyAction(
applicationContext: Context,
notId: Int,
eventId: String,
@@ -548,7 +553,7 @@ object NotificationUtils {
return buildReplyAction(applicationContext, notId, intent)
}
private fun markReadAction(
private suspend fun markReadAction(
applicationContext: Context,
notId: Int,
eventId: String,
@@ -567,7 +572,7 @@ object NotificationUtils {
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
return NotificationCompat.Action
.Builder(R.drawable.ic_action_mark_read, stringRes(applicationContext, R.string.app_notification_mark_read_label), markReadPendingIntent)
.Builder(R.drawable.ic_action_mark_read, loadStringRes(Res.string.app_notification_mark_read_label), markReadPendingIntent)
.setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_MARK_AS_READ)
.build()
}
@@ -676,7 +681,7 @@ object NotificationUtils {
* it alone. It costs nothing visually: the shade hides any group with fewer than
* two children and shows the child on its own.
*/
private fun NotificationManager.sendGroupSummary(
private suspend fun NotificationManager.sendGroupSummary(
category: NotificationCategory,
groupKey: String,
summaryId: Int,
@@ -703,7 +708,7 @@ object NotificationUtils {
.setStyle(
NotificationCompat
.InboxStyle()
.setSummaryText(stringRes(applicationContext, category.summaryTextRes)),
.setSummaryText(loadStringRes(category.summaryTextRes)),
)
notify(summaryId, summaryBuilder.build())
@@ -20,12 +20,14 @@
*/
package com.vitorpamplona.amethyst.service.notifications
import android.content.Context
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_purpose_browsing
import com.vitorpamplona.amethyst.commons.resources.relay_purpose_line
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -51,7 +53,7 @@ object RelayPurposeSummary {
* Lines for the expanded notification, busiest first. Empty when nothing is attributed yet —
* the caller must then fall back to the bare count rather than render an empty section.
*/
fun lines(ctx: Context): List<String> {
suspend fun lines(): List<String> {
val client = Amethyst.instance.client
val named = mutableMapOf<SubPurpose, MutableSet<NormalizedRelayUrl>>()
val browsing = mutableSetOf<NormalizedRelayUrl>()
@@ -75,11 +77,11 @@ object RelayPurposeSummary {
named.entries
.sortedWith(compareByDescending<Map.Entry<SubPurpose, Set<NormalizedRelayUrl>>> { it.value.size }.thenBy { it.key.name })
.map { (purpose, relays) ->
pluralStringRes(ctx, R.plurals.relay_purpose_line, relays.size, ctx.getString(SubPurposeLabels.labelOf(purpose)), relays.size)
loadPluralStringRes(Res.plurals.relay_purpose_line, relays.size, loadStringRes(SubPurposeLabels.labelOf(purpose)), relays.size)
}.toMutableList()
if (browsing.isNotEmpty()) {
lines.add(pluralStringRes(ctx, R.plurals.relay_purpose_line, browsing.size, ctx.getString(R.string.relay_purpose_browsing), browsing.size))
lines.add(loadPluralStringRes(Res.plurals.relay_purpose_line, browsing.size, loadStringRes(Res.string.relay_purpose_browsing), browsing.size))
}
return lines
}
@@ -21,8 +21,11 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_message
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_message_highlight
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -30,7 +33,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
@@ -56,9 +58,9 @@ object ArticleNotification {
val isHighlight = event is HighlightEvent
val titleRes =
if (isHighlight) {
R.string.app_notification_articles_channel_message_highlight
Res.string.app_notification_articles_channel_message_highlight
} else {
R.string.app_notification_articles_channel_message
Res.string.app_notification_articles_channel_message
}
val bodySource = if (event is HighlightEvent) event.quote() else event.content
val rendered = NotificationContent.renderNoteText(bodySource)
@@ -80,7 +82,7 @@ object ArticleNotification {
nm.postStandard(
category = NotificationCategory.ARTICLE,
id = event.id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageTitle = loadStringRes(titleRes, author.toBestDisplayName()),
messageBody = body.text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
@@ -21,15 +21,17 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_message
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_message_from
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
/**
@@ -61,8 +63,8 @@ object BadgeNotification {
nm.postStandard(
category = NotificationCategory.BADGE,
id = event.id,
messageTitle = stringRes(context, R.string.app_notification_badges_channel_message),
messageBody = stringRes(context, R.string.app_notification_badges_channel_message_from, issuer.toBestDisplayName()),
messageTitle = loadStringRes(Res.string.app_notification_badges_channel_message),
messageBody = loadStringRes(Res.string.app_notification_badges_channel_message_from, issuer.toBestDisplayName()),
time = event.createdAt,
pictureUrl = issuer.profilePicture(),
uri = uri,
@@ -21,10 +21,12 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip29RelayGroups.RelayGroupChannel
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_new_message
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
@@ -32,7 +34,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.buzz.workspace.buzzParticipants
import com.vitorpamplona.quartz.buzz.workspace.isBuzzDm
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -78,7 +79,7 @@ object BuzzDmNotification {
val channel = buzzDmChannelForMe(note, account.signer.pubKey) ?: return
val sender = LocalCache.getOrCreateUser(event.pubKey)
val body = event.content.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.app_notification_new_message)
val body = event.content.takeIf { it.isNotBlank() } ?: loadStringRes(Res.string.app_notification_new_message)
val accountNpub = NotificationRoutes.accountNpub(account)
// The channel's kind-39000 naddr routes straight to the chatroom via the
@@ -21,9 +21,10 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_channel_name
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
@@ -31,8 +32,8 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent
import org.jetbrains.compose.resources.StringResource
/**
* Chess game notifications — NIP-64 challenge-accepted and move events. Rendered
@@ -44,7 +45,7 @@ object ChessNotification {
context: Context,
account: Account,
event: BaseChessEvent,
@StringRes contentRes: Int,
contentRes: StringResource,
) {
// NIP-64 chess is a debug-only feature for now; don't notify in release.
if (!isDebug) return
@@ -65,8 +66,8 @@ object ChessNotification {
nm.postStandard(
category = NotificationCategory.CHESS,
id = event.id,
messageTitle = stringRes(context, R.string.app_notification_chess_channel_name),
messageBody = stringRes(context, contentRes, author.toBestDisplayName()),
messageTitle = loadStringRes(Res.string.app_notification_chess_channel_name),
messageBody = loadStringRes(contentRes, author.toBestDisplayName()),
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
@@ -21,8 +21,24 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_issue
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_patch
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_pr
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_pr_update
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_reply
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_applied
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_applied_issue
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_applied_patch
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_applied_pr
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_closed
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_closed_issue
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_closed_patch
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_closed_pr
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_draft
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_open
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -30,7 +46,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
@@ -40,6 +55,7 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
import org.jetbrains.compose.resources.StringResource
/**
* Git / code notifications — NIP-34 issues (1621), patches (1617), pull requests
@@ -61,25 +77,25 @@ object CodeNotification {
context: Context,
account: Account,
event: GitIssueEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_issue, event.subject() ?: event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_issue, event.subject() ?: event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitPatchEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_patch, event.subject() ?: event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_patch, event.subject() ?: event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitPullRequestEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr, event.subject() ?: event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_pr, event.subject() ?: event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitPullRequestUpdateEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_pr_update, event.content)
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
// events already on relays still arrive and still have to be rendered.
@@ -88,13 +104,13 @@ object CodeNotification {
context: Context,
account: Account,
event: GitReplyEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_reply, event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_reply, event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitStatusOpenEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_open, event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_status_open, event.content)
suspend fun notify(
context: Context,
@@ -109,10 +125,10 @@ object CodeNotification {
titleRes =
titleForStatusOnTarget(
event.rootEventId(),
pr = R.string.app_notification_code_channel_message_status_applied_pr,
patch = R.string.app_notification_code_channel_message_status_applied_patch,
issue = R.string.app_notification_code_channel_message_status_applied_issue,
fallback = R.string.app_notification_code_channel_message_status_applied,
pr = Res.string.app_notification_code_channel_message_status_applied_pr,
patch = Res.string.app_notification_code_channel_message_status_applied_patch,
issue = Res.string.app_notification_code_channel_message_status_applied_issue,
fallback = Res.string.app_notification_code_channel_message_status_applied,
),
subject = event.content,
)
@@ -130,10 +146,10 @@ object CodeNotification {
titleRes =
titleForStatusOnTarget(
event.rootEventId(),
pr = R.string.app_notification_code_channel_message_status_closed_pr,
patch = R.string.app_notification_code_channel_message_status_closed_patch,
issue = R.string.app_notification_code_channel_message_status_closed_issue,
fallback = R.string.app_notification_code_channel_message_status_closed,
pr = Res.string.app_notification_code_channel_message_status_closed_pr,
patch = Res.string.app_notification_code_channel_message_status_closed_patch,
issue = Res.string.app_notification_code_channel_message_status_closed_issue,
fallback = Res.string.app_notification_code_channel_message_status_closed,
),
subject = event.content,
)
@@ -142,7 +158,7 @@ object CodeNotification {
context: Context,
account: Account,
event: GitStatusDraftEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_draft, event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_status_draft, event.content)
/**
* Pick a title string for a status event based on the *target*'s kind, so
@@ -153,11 +169,11 @@ object CodeNotification {
*/
private fun titleForStatusOnTarget(
rootId: String?,
pr: Int,
patch: Int,
issue: Int,
fallback: Int,
): Int {
pr: StringResource,
patch: StringResource,
issue: StringResource,
fallback: StringResource,
): StringResource {
val targetKind = rootId?.let { LocalCache.getNoteIfExists(it)?.event?.kind } ?: return fallback
return when (targetKind) {
GitPullRequestEvent.KIND -> pr
@@ -173,7 +189,7 @@ object CodeNotification {
id: String,
createdAt: Long,
authorPubkey: String,
titleRes: Int,
titleRes: StringResource,
subject: String?,
) {
val note = LocalCache.getNoteIfExists(id) ?: return
@@ -196,7 +212,7 @@ object CodeNotification {
nm.postStandard(
category = NotificationCategory.CODE,
id = id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageTitle = loadStringRes(titleRes, author.toBestDisplayName()),
messageBody = body,
time = createdAt,
pictureUrl = author.profilePicture(),
@@ -21,8 +21,11 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_added_to_group
import com.vitorpamplona.amethyst.commons.resources.app_notification_new_message
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
@@ -32,7 +35,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyA
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
import com.vitorpamplona.quartz.nipC7Chats.ChatEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -58,7 +60,7 @@ object GroupMessageNotification {
val chatroom = account.marmotGroupList.getOrCreateGroup(nostrGroupId)
val groupName = chatroom.displayName.value?.takeIf { it.isNotBlank() } ?: DEFAULT_GROUP_NAME
val sender = LocalCache.getOrCreateUser(innerEvent.pubKey)
val fallbackBody = innerEvent.content.takeIf { it.isNotBlank() } ?: stringRes(context, R.string.app_notification_new_message)
val fallbackBody = innerEvent.content.takeIf { it.isNotBlank() } ?: loadStringRes(Res.string.app_notification_new_message)
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.marmotUri(nostrGroupId, accountNpub)
@@ -140,7 +142,7 @@ object GroupMessageNotification {
id = event.id,
senderName = inviter.toBestDisplayName(),
pictureUrl = inviter.profilePicture(),
messageBody = stringRes(context, R.string.app_notification_added_to_group, groupName),
messageBody = loadStringRes(Res.string.app_notification_added_to_group, groupName),
time = event.createdAt,
uri = uri,
applicationContext = context,
@@ -21,8 +21,11 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_message_photo
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_message_video
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -30,7 +33,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip71Video.VideoEvent
@@ -72,14 +74,14 @@ object MediaNotification {
val user = author.toBestDisplayName()
val titleRes =
if (isVideo) {
R.string.app_notification_media_channel_message_video
Res.string.app_notification_media_channel_message_video
} else {
R.string.app_notification_media_channel_message_photo
Res.string.app_notification_media_channel_message_photo
}
nm.postStandard(
category = NotificationCategory.MEDIA,
id = event.id,
messageTitle = stringRes(context, titleRes, user),
messageTitle = loadStringRes(titleRes, user),
messageBody = NotificationContent.resolveMentions(event.content, 140).text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
@@ -21,9 +21,10 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_message
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -31,8 +32,8 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import org.jetbrains.compose.resources.StringResource
/**
* Text-mention notifications — someone mentioned, quoted, or cited you in a note
@@ -49,7 +50,7 @@ object MentionNotification {
account: Account,
event: Event,
category: NotificationCategory = NotificationCategory.MENTION,
@StringRes titleRes: Int = R.string.app_notification_mentions_channel_message,
titleRes: StringResource = Res.string.app_notification_mentions_channel_message,
) {
val note = LocalCache.getNoteIfExists(event.id) ?: return
if (!account.isAcceptable(note)) return
@@ -80,7 +81,7 @@ object MentionNotification {
nm.postStandard(
category = category,
id = event.id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageTitle = loadStringRes(titleRes, author.toBestDisplayName()),
messageBody = body.text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
@@ -21,14 +21,15 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_message
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -57,7 +58,7 @@ object NwcPaymentNotifier {
val id = tx.payment_hash ?: tx.invoice ?: tx.created_at?.toString() ?: return
val time = tx.settled_at ?: tx.created_at ?: TimeUtils.now()
val title = stringRes(context, R.string.app_notification_payments_channel_message, amount)
val title = loadStringRes(Res.string.app_notification_payments_channel_message, amount)
val comment = tx.parsedMetadata()?.displayComment() ?: tx.displayDescription()
val body = comment ?: title
@@ -21,8 +21,11 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_message
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_message_for
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -30,7 +33,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
@@ -81,9 +83,9 @@ object ReactionNotification {
val reactedContent = NotificationContent.excerpt(reactedNote?.event?.content, 140)
val body =
if (reactedContent.isNotBlank()) {
stringRes(context, R.string.app_notification_reactions_channel_message_for, reactedContent)
loadStringRes(Res.string.app_notification_reactions_channel_message_for, reactedContent)
} else {
stringRes(context, R.string.app_notification_reactions_channel_message, user)
loadStringRes(Res.string.app_notification_reactions_channel_message, user)
}
nm.postStandard(
category = NotificationCategory.REACTION,
@@ -21,9 +21,11 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_me
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -35,7 +37,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postCo
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.replyGroupKeyFor
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.replySummaryIdFor
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
@@ -95,7 +96,7 @@ object ReplyNotification {
ParentMessage(
senderName =
if (parentIsFromMe || parentAuthor == null) {
stringRes(context, R.string.app_notification_me)
loadStringRes(Res.string.app_notification_me)
} else {
parentAuthor.toBestDisplayName()
},
@@ -21,8 +21,10 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_message
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -30,7 +32,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -79,7 +80,7 @@ object RepostNotification {
nm.postStandard(
category = NotificationCategory.REPOST,
id = id,
messageTitle = stringRes(context, R.string.app_notification_reposts_channel_message, booster.toBestDisplayName()),
messageTitle = loadStringRes(Res.string.app_notification_reposts_channel_message, booster.toBestDisplayName()),
messageBody = NotificationContent.excerpt(boostedNote?.event?.content, 140),
time = createdAt,
pictureUrl = booster.profilePicture(),
@@ -21,10 +21,16 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_nutzap_channel_message_from
import com.vitorpamplona.amethyst.commons.resources.app_notification_onchain_channel_message_from
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_message
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_message_for
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_message_from
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -33,11 +39,11 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import org.jetbrains.compose.resources.StringResource
import java.math.BigDecimal
/**
@@ -76,7 +82,7 @@ object ZapNotification {
sender = sender,
zappedNote = zappedNote,
title = { _ -> zapTitle(context, amount, comment) },
body = { user, excerpt -> fromLine(context, R.string.app_notification_zaps_channel_message_from, user, excerpt) },
body = { user, excerpt -> fromLine(context, Res.string.app_notification_zaps_channel_message_from, user, excerpt) },
)
}
@@ -96,7 +102,7 @@ object ZapNotification {
createdAt = event.createdAt,
sender = sender,
zappedNote = zappedNote,
title = { user -> stringRes(context, R.string.app_notification_nutzap_channel_message_from, user) },
title = { user -> loadStringRes(Res.string.app_notification_nutzap_channel_message_from, user) },
body = { user, excerpt -> excerpt.ifBlank { user } },
)
}
@@ -120,12 +126,12 @@ object ZapNotification {
zappedNote = zappedNote,
title = { user ->
if (sats != null) {
stringRes(context, R.string.app_notification_zaps_channel_message, showAmount(sats.toBigDecimal()))
loadStringRes(Res.string.app_notification_zaps_channel_message, showAmount(sats.toBigDecimal()))
} else {
stringRes(context, R.string.app_notification_onchain_channel_message_from, user)
loadStringRes(Res.string.app_notification_onchain_channel_message_from, user)
}
},
body = { user, excerpt -> fromLine(context, R.string.app_notification_onchain_channel_message_from, user, excerpt) },
body = { user, excerpt -> fromLine(context, Res.string.app_notification_onchain_channel_message_from, user, excerpt) },
)
}
@@ -136,8 +142,8 @@ object ZapNotification {
createdAt: Long,
sender: User,
zappedNote: Note?,
title: (String) -> String,
body: (String, String) -> String,
title: suspend (String) -> String,
body: suspend (String, String) -> String,
) {
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.notificationsUri(accountNpub, id)
@@ -167,24 +173,24 @@ object ZapNotification {
}
}
private fun zapTitle(
private suspend fun zapTitle(
context: Context,
amount: String,
comment: String?,
): String {
val base = stringRes(context, R.string.app_notification_zaps_channel_message, amount)
val base = loadStringRes(Res.string.app_notification_zaps_channel_message, amount)
return if (comment != null) "$base ($comment)" else base
}
private fun fromLine(
private suspend fun fromLine(
context: Context,
fromRes: Int,
fromRes: StringResource,
user: String,
excerpt: String,
): String {
var content = stringRes(context, fromRes, user)
var content = loadStringRes(fromRes, user)
if (excerpt.isNotBlank()) {
content += " " + stringRes(context, R.string.app_notification_zaps_channel_message_for, excerpt)
content += " " + loadStringRes(Res.string.app_notification_zaps_channel_message_for, excerpt)
}
return content
}
@@ -43,9 +43,12 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_video_open_in_browser
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed_description
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
@@ -109,7 +112,7 @@ fun RenderPlaybackError(
}
Text(
text = stringRes(R.string.error_video_playback_failed),
text = stringRes(Res.string.error_video_playback_failed),
color = Color.White,
style = MaterialTheme.typography.titleSmall,
textAlign = TextAlign.Center,
@@ -119,7 +122,7 @@ fun RenderPlaybackError(
Spacer(Modifier.height(4.dp))
Text(
text = stringRes(R.string.error_video_playback_failed_description, errorCodeName),
text = stringRes(Res.string.error_video_playback_failed_description, errorCodeName),
color = Color.White.copy(alpha = 0.85f),
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
@@ -143,7 +146,7 @@ fun RenderPlaybackError(
modifier = Modifier.size(18.dp),
)
Spacer(Modifier.size(8.dp))
Text(stringRes(R.string.error_video_open_in_browser))
Text(stringRes(Res.string.error_video_open_in_browser))
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.annotation.OptIn
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
@@ -39,7 +40,9 @@ import androidx.media3.common.Player
import androidx.media3.common.TrackSelectionOverride
import androidx.media3.common.Tracks
import androidx.media3.common.util.UnstableApi
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.captions_off
import com.vitorpamplona.amethyst.commons.resources.captions_unnamed_track
import com.vitorpamplona.amethyst.ui.stringRes
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
@@ -130,7 +133,7 @@ internal fun CaptionLanguagePopup(
val baseColors = ButtonDefaults.textButtonColors()
val contentColor = MaterialTheme.colorScheme.onBackground
val colors = remember(baseColors, contentColor) { baseColors.copy(contentColor = contentColor) }
val unnamed = stringRes(R.string.captions_unnamed_track)
val unnamed = stringRes(Res.string.captions_unnamed_track)
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.background),
@@ -139,7 +142,7 @@ internal fun CaptionLanguagePopup(
) {
TextButton(colors = colors, onClick = onSelectOff) {
Text(
stringRes(R.string.captions_off),
stringRes(Res.string.captions_off),
fontWeight = if (!captionsEnabled) FontWeight(1000) else FontWeight(400),
)
}
@@ -38,9 +38,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.mute_button
import com.vitorpamplona.amethyst.commons.resources.muted_button
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
@@ -123,7 +125,7 @@ fun MuteButton(
fun MutedIcon() {
Icon(
symbol = MaterialSymbols.AutoMirrored.VolumeOff,
contentDescription = stringRes(id = R.string.muted_button),
contentDescription = stringRes(id = Res.string.muted_button),
tint = MaterialTheme.colorScheme.onBackground,
modifier = Size30Modifier,
)
@@ -133,7 +135,7 @@ fun MutedIcon() {
fun MuteIcon() {
Icon(
symbol = MaterialSymbols.AutoMirrored.VolumeUp,
contentDescription = stringRes(id = R.string.mute_button),
contentDescription = stringRes(id = Res.string.mute_button),
tint = MaterialTheme.colorScheme.onBackground,
modifier = Size30Modifier,
)
@@ -37,14 +37,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.call_settings_video_quality
import com.vitorpamplona.amethyst.commons.resources.download_to_phone
import com.vitorpamplona.amethyst.commons.resources.more_options
import com.vitorpamplona.amethyst.commons.resources.mute_button
import com.vitorpamplona.amethyst.commons.resources.muted_button
import com.vitorpamplona.amethyst.commons.resources.picture_in_picture
import com.vitorpamplona.amethyst.commons.resources.playback_actions_dialog_title
import com.vitorpamplona.amethyst.commons.resources.share_or_save
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_fullscreen
import com.vitorpamplona.amethyst.model.VideoPlayerAction
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
@@ -165,7 +169,7 @@ fun OverflowMenuButton(
) {
Icon(
symbol = MaterialSymbols.MoreVert,
contentDescription = stringRes(R.string.more_options),
contentDescription = stringRes(Res.string.more_options),
tint = MaterialTheme.colorScheme.onBackground,
modifier = Size20Modifier,
)
@@ -195,7 +199,7 @@ fun OverflowMenuButton(
VideoPlayerAction.Mute -> {
M3ActionRow(
icon = if (startingMuteState) MaterialSymbols.AutoMirrored.VolumeOff else MaterialSymbols.AutoMirrored.VolumeUp,
text = if (startingMuteState) stringRes(R.string.muted_button) else stringRes(R.string.mute_button),
text = if (startingMuteState) stringRes(Res.string.muted_button) else stringRes(Res.string.mute_button),
) {
menuExpanded.value = false
onMuteClick()
@@ -215,7 +219,7 @@ fun OverflowMenuButton(
VideoPlayerAction.Share -> {
M3ActionRow(
icon = MaterialSymbols.Share,
text = stringRes(R.string.share_or_save),
text = stringRes(Res.string.share_or_save),
) {
menuExpanded.value = false
onShareClick()
@@ -225,7 +229,7 @@ fun OverflowMenuButton(
VideoPlayerAction.Download -> {
M3ActionRow(
icon = MaterialSymbols.SaveAlt,
text = stringRes(R.string.download_to_phone),
text = stringRes(Res.string.download_to_phone),
) {
menuExpanded.value = false
onSaveClick()
@@ -39,9 +39,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.pause
import com.vitorpamplona.amethyst.commons.resources.play
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PlayIconSize
@@ -117,13 +119,13 @@ fun PlayPauseButton(
Icon(
symbol = MaterialSymbols.PlayArrow,
modifier = Size50Modifier,
contentDescription = stringRes(R.string.play),
contentDescription = stringRes(Res.string.play),
)
} else {
Icon(
symbol = MaterialSymbols.Pause,
modifier = Size50Modifier,
contentDescription = stringRes(R.string.pause),
contentDescription = stringRes(Res.string.pause),
)
}
}
@@ -52,12 +52,17 @@ import androidx.media3.common.Tracks
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.BuildConfig
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.captions_turn_off
import com.vitorpamplona.amethyst.commons.resources.captions_turn_on
import com.vitorpamplona.amethyst.commons.resources.cast_stop_casting
import com.vitorpamplona.amethyst.commons.resources.cast_to_device
import com.vitorpamplona.amethyst.commons.resources.download_to_phone
import com.vitorpamplona.amethyst.commons.resources.picture_in_picture
import com.vitorpamplona.amethyst.commons.resources.share_or_save
import com.vitorpamplona.amethyst.commons.richtext.MediaUrlVideo
import com.vitorpamplona.amethyst.model.VideoButtonLocation
import com.vitorpamplona.amethyst.model.VideoPlayerAction
@@ -306,7 +311,7 @@ fun RenderTopButtons(
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
val captionsIcon = if (captionsEnabled) MaterialSymbols.ClosedCaption else MaterialSymbols.ClosedCaptionDisabled
val captionsContentDescription =
stringRes(if (captionsEnabled) R.string.captions_turn_off else R.string.captions_turn_on)
stringRes(if (captionsEnabled) Res.string.captions_turn_off else Res.string.captions_turn_on)
val shareDialogVisible = remember { mutableStateOf(false) }
val castDialogVisible = remember { mutableStateOf(false) }
val castSessionState by Amethyst.instance.castRegistry.sessionState
@@ -315,7 +320,7 @@ fun RenderTopButtons(
(castSessionState as? CastSessionState.Casting)?.request?.url == mediaData.videoUri
val castIcon = if (isThisVideoCasting) MaterialSymbols.CastConnected else MaterialSymbols.Cast
val castContentDescription =
stringRes(if (isThisVideoCasting) R.string.cast_stop_casting else R.string.cast_to_device)
stringRes(if (isThisVideoCasting) Res.string.cast_stop_casting else Res.string.cast_to_device)
val onCastButtonClick =
remember(isThisVideoCasting) {
{
@@ -419,7 +424,7 @@ fun RenderTopButtons(
AnimatedTopBarIconButton(
controllerVisible = controllerVisible,
symbol = MaterialSymbols.Share,
contentDescription = stringRes(R.string.share_or_save),
contentDescription = stringRes(Res.string.share_or_save),
onClick = { shareDialogVisible.value = true },
)
}
@@ -428,7 +433,7 @@ fun RenderTopButtons(
AnimatedTopBarIconButton(
controllerVisible = controllerVisible,
symbol = MaterialSymbols.SaveAlt,
contentDescription = stringRes(R.string.download_to_phone),
contentDescription = stringRes(Res.string.download_to_phone),
onClick = saveAction,
)
}
@@ -46,9 +46,12 @@ import androidx.compose.ui.tooling.preview.Preview
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.download_to_phone
import com.vitorpamplona.amethyst.commons.resources.video_download_has_started_toast
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
@@ -100,7 +103,7 @@ fun rememberSaveMediaAction(onSaveClick: (Context) -> Unit): () -> Unit {
Toast
.makeText(
context,
stringRes(context, R.string.video_download_has_started_toast),
loadStringRes(Res.string.video_download_has_started_toast),
Toast.LENGTH_SHORT,
).show()
}
@@ -131,7 +134,7 @@ fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) {
Icon(
symbol = MaterialSymbols.Download,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.download_to_phone),
contentDescription = stringRes(Res.string.download_to_phone),
)
}
}
@@ -38,9 +38,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.share_or_save
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
@@ -99,7 +100,7 @@ fun ShareButton(innerAction: @Composable (MutableState<Boolean>, () -> Unit) ->
Icon(
symbol = MaterialSymbols.Share,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.share_or_save),
contentDescription = stringRes(Res.string.share_or_save),
)
innerAction(popupExpanded) { popupExpanded.value = false }
@@ -33,9 +33,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.skip_back
import com.vitorpamplona.amethyst.commons.resources.skip_forward
import com.vitorpamplona.amethyst.service.playback.composable.SKIP_SECONDS
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -89,7 +91,7 @@ fun SkipButton(
onClick: () -> Unit,
) {
val icon = if (isForward) MaterialSymbols.Forward10 else MaterialSymbols.Replay10
val label = if (isForward) stringRes(R.string.skip_forward, SKIP_SECONDS) else stringRes(R.string.skip_back, SKIP_SECONDS)
val label = if (isForward) stringRes(Res.string.skip_forward, SKIP_SECONDS) else stringRes(Res.string.skip_back, SKIP_SECONDS)
val tint = if (enabled) Color.White else Color.White.copy(alpha = 0.38f)
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(48.dp)) {
Icon(symbol = icon, contentDescription = label, tint = tint, modifier = Modifier.size(32.dp))
@@ -61,7 +61,7 @@ fun createMuteAction(
} else {
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_volume_off)
}
val title = if (isMuted) stringRes(context, R.string.muted_button) else stringRes(context, R.string.mute_button)
val title = stringRes(context, if (isMuted) R.string.muted_button else R.string.mute_button)
val intent =
PendingIntent.getBroadcast(
@@ -83,7 +83,7 @@ fun createPlayPauseAction(
} else {
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_pause)
}
val title = if (!isPlaying) stringRes(context, R.string.play) else stringRes(context, R.string.pause)
val title = stringRes(context, if (!isPlaying) R.string.play else R.string.pause)
val intent =
PendingIntent.getBroadcast(
context,
@@ -21,11 +21,23 @@
package com.vitorpamplona.amethyst.service.pow
import android.content.Context
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_days
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_hours
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_instant
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_minutes
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_seconds
import com.vitorpamplona.amethyst.commons.resources.pow_time_left
import com.vitorpamplona.amethyst.commons.resources.pow_time_left_soon
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import org.jetbrains.compose.resources.PluralStringResource
import kotlin.math.roundToLong
/**
@@ -46,6 +58,75 @@ suspend fun deviceHashesPerSecond(): Double = PoWEstimator.hashesPerSecond(PoWPo
* and are the statistical mean of a memoryless search — any single post can be
* much luckier or unluckier, so always present these as approximations.
*/
@Composable
fun formatApproxDuration(seconds: Double): String {
@Composable
fun quantity(
id: PluralStringResource,
count: Long,
) = pluralStringRes(id, count.toInt(), count.toInt())
return when {
seconds < 1.0 -> stringRes(Res.string.pow_estimate_instant)
seconds < 90.0 -> quantity(Res.plurals.pow_estimate_seconds, seconds.roundToLong())
seconds < 90.0 * 60.0 -> quantity(Res.plurals.pow_estimate_minutes, (seconds / 60.0).roundToLong())
seconds < 48.0 * 3600.0 -> quantity(Res.plurals.pow_estimate_hours, (seconds / 3600.0).roundToLong())
else -> quantity(Res.plurals.pow_estimate_days, (seconds / 86400.0).roundToLong())
}
}
/**
* "≈ 10 minutes left" while [elapsedSec] is inside the [expectedSec] mean,
* "any moment now" once past it — a memoryless search has no shrinking
* remainder, so past the mean the only honest claim is "soon".
*/
@Composable
fun formatTimeLeft(
expectedSec: Double,
elapsedSec: Long,
): String {
val remaining = expectedSec - elapsedSec
return if (remaining > 1.0) {
stringRes(Res.string.pow_time_left, formatApproxDuration(remaining))
} else {
stringRes(Res.string.pow_time_left_soon)
}
}
/** Suspend twin of [formatApproxDuration] for the mining foreground service,
* which builds its notification text outside composition. */
suspend fun loadApproxDuration(seconds: Double): String {
suspend fun quantity(
id: PluralStringResource,
count: Long,
) = loadPluralStringRes(id, count.toInt(), count.toInt())
return when {
seconds < 1.0 -> loadStringRes(Res.string.pow_estimate_instant)
seconds < 90.0 -> quantity(Res.plurals.pow_estimate_seconds, seconds.roundToLong())
seconds < 90.0 * 60.0 -> quantity(Res.plurals.pow_estimate_minutes, (seconds / 60.0).roundToLong())
seconds < 48.0 * 3600.0 -> quantity(Res.plurals.pow_estimate_hours, (seconds / 3600.0).roundToLong())
else -> quantity(Res.plurals.pow_estimate_days, (seconds / 86400.0).roundToLong())
}
}
/** Suspend twin of [formatTimeLeft]; see [loadApproxDuration]. */
suspend fun loadTimeLeft(
expectedSec: Double,
elapsedSec: Long,
): String {
val remaining = expectedSec - elapsedSec
return if (remaining > 1.0) {
loadStringRes(Res.string.pow_time_left, loadApproxDuration(remaining))
} else {
loadStringRes(Res.string.pow_time_left_soon)
}
}
/**
* Android-resource twin of [formatApproxDuration]; see [powKindLabelResId] for why
* the mining notification cannot read from the Compose catalog.
*/
fun formatApproxDuration(
context: Context,
seconds: Double,
@@ -64,11 +145,7 @@ fun formatApproxDuration(
}
}
/**
* "≈ 10 minutes left" while [elapsedSec] is inside the [expectedSec] mean,
* "any moment now" once past it — a memoryless search has no shrinking
* remainder, so past the mean the only honest claim is "soon".
*/
/** Android-resource twin of [formatTimeLeft]. */
fun formatTimeLeft(
context: Context,
expectedSec: Double,
@@ -20,8 +20,16 @@
*/
package com.vitorpamplona.amethyst.service.pow
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.boost
import com.vitorpamplona.amethyst.commons.resources.post
import com.vitorpamplona.amethyst.commons.resources.pow_kind_chat_message
import com.vitorpamplona.amethyst.commons.resources.pow_kind_report
import com.vitorpamplona.amethyst.commons.resources.private_message
import com.vitorpamplona.amethyst.commons.resources.reaction
import com.vitorpamplona.amethyst.commons.resources.voice_post
import com.vitorpamplona.amethyst.commons.resources.voice_reply
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
@@ -31,14 +39,33 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import org.jetbrains.compose.resources.StringResource
/**
* The one user-facing label for "what is being mined": shared by the
* broadcast banner, the mining foreground notification, and failure toasts
* so a job is described the same way everywhere it appears.
*/
@StringRes
fun powKindLabelRes(kind: Int): Int =
fun powKindLabelRes(kind: Int): StringResource =
when (kind) {
ReactionEvent.KIND -> Res.string.reaction
RepostEvent.KIND, GenericRepostEvent.KIND -> Res.string.boost
VoiceEvent.KIND -> Res.string.voice_post
VoiceReplyEvent.KIND -> Res.string.voice_reply
ReportEvent.KIND -> Res.string.pow_kind_report
GiftWrapEvent.KIND -> Res.string.private_message
ChannelMessageEvent.KIND, LiveActivitiesChatMessageEvent.KIND -> Res.string.pow_kind_chat_message
else -> Res.string.post
}
/**
* Android-resource twin of [powKindLabelRes], for [com.vitorpamplona.amethyst.service.pow.PowMiningForegroundService].
*
* A foreground-service notification is built inside Android's startForeground()
* timeout, so its text has to resolve synchronously - the Compose catalog only
* offers a composable and a suspend accessor, neither of which can run there.
*/
fun powKindLabelResId(kind: Int): Int =
when (kind) {
ReactionEvent.KIND -> R.string.reaction
RepostEvent.KIND, GenericRepostEvent.KIND -> R.string.boost
@@ -116,7 +116,7 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
val base =
current?.let {
pluralStringRes(this, R.plurals.pow_mining_job, it.difficulty, stringRes(this, powKindLabelRes(it.kind)), it.difficulty)
pluralStringRes(this, R.plurals.pow_mining_job, it.difficulty, stringRes(this, powKindLabelResId(it.kind)), it.difficulty)
} ?: stringRes(this, R.string.pow_mining_title)
val text =
if (expectedSec != null && elapsedSec != null) {
@@ -26,11 +26,21 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_my_inbox
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_my_own_relay
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_notify_inbox
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_other
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_post_venue
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_read_outbox
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_read_venue
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_send_dm
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_thread
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import org.jetbrains.compose.resources.StringResource
/**
* Loads [pubkey] from the local cache, get-or-creating (and subscribing) if absent, then hands the
@@ -58,15 +68,15 @@ internal fun LoadRelayAuthUser(
* deciding right now ("It won't serve … to readers it can't identify"); these caption a past login in
* the settings log, where a full sentence per row would be the wall of text this screen just lost.
*/
internal fun relayAuthPurposeLabelRes(kind: AuthPurposeKind): Int =
internal fun relayAuthPurposeLabelRes(kind: AuthPurposeKind): StringResource =
when (kind) {
AuthPurposeKind.SEND_DM -> R.string.relay_auth_purpose_send_dm
AuthPurposeKind.NOTIFY_INBOX -> R.string.relay_auth_purpose_notify_inbox
AuthPurposeKind.READ_OUTBOX -> R.string.relay_auth_purpose_read_outbox
AuthPurposeKind.POST_VENUE -> R.string.relay_auth_purpose_post_venue
AuthPurposeKind.READ_VENUE -> R.string.relay_auth_purpose_read_venue
AuthPurposeKind.MY_INBOX -> R.string.relay_auth_purpose_my_inbox
AuthPurposeKind.THREAD -> R.string.relay_auth_purpose_thread
AuthPurposeKind.MY_OWN_RELAY -> R.string.relay_auth_purpose_my_own_relay
AuthPurposeKind.OTHER -> R.string.relay_auth_purpose_other
AuthPurposeKind.SEND_DM -> Res.string.relay_auth_purpose_send_dm
AuthPurposeKind.NOTIFY_INBOX -> Res.string.relay_auth_purpose_notify_inbox
AuthPurposeKind.READ_OUTBOX -> Res.string.relay_auth_purpose_read_outbox
AuthPurposeKind.POST_VENUE -> Res.string.relay_auth_purpose_post_venue
AuthPurposeKind.READ_VENUE -> Res.string.relay_auth_purpose_read_venue
AuthPurposeKind.MY_INBOX -> Res.string.relay_auth_purpose_my_inbox
AuthPurposeKind.THREAD -> Res.string.relay_auth_purpose_thread
AuthPurposeKind.MY_OWN_RELAY -> Res.string.relay_auth_purpose_my_own_relay
AuthPurposeKind.OTHER -> Res.string.relay_auth_purpose_other
}
@@ -51,7 +51,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -63,7 +62,6 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthPrompt
@@ -72,8 +70,19 @@ import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.relay_auth_also_holding_back
import com.vitorpamplona.amethyst.commons.resources.relay_auth_always_allow_everywhere
import com.vitorpamplona.amethyst.commons.resources.relay_auth_always_everywhere_body
import com.vitorpamplona.amethyst.commons.resources.relay_auth_always_everywhere_title
import com.vitorpamplona.amethyst.commons.resources.relay_auth_always_log_in
import com.vitorpamplona.amethyst.commons.resources.relay_auth_log_in
import com.vitorpamplona.amethyst.commons.resources.relay_auth_login_as
import com.vitorpamplona.amethyst.commons.resources.relay_auth_name_and_n_others
import com.vitorpamplona.amethyst.commons.resources.relay_auth_never
import com.vitorpamplona.amethyst.commons.resources.relay_auth_never_allow_everywhere
import com.vitorpamplona.amethyst.commons.resources.relay_auth_never_everywhere_body
import com.vitorpamplona.amethyst.commons.resources.relay_auth_never_everywhere_title
import com.vitorpamplona.amethyst.commons.resources.relay_auth_not_now
import com.vitorpamplona.amethyst.commons.resources.relay_auth_relay_asks
import com.vitorpamplona.amethyst.commons.resources.relay_auth_remember_relay
@@ -93,6 +102,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
@@ -300,11 +310,11 @@ private fun RelayAuthPromptDialog(
} else {
ButtonDefaults.outlinedButtonColors()
},
) { Text(if (rememberRelay) stringRes(R.string.relay_auth_never) else stringRes(Res.string.relay_auth_not_now)) }
) { Text(if (rememberRelay) stringRes(Res.string.relay_auth_never) else stringRes(Res.string.relay_auth_not_now)) }
Button(
onClick = { onChoice(if (rememberRelay) UserAuthChoice.ALWAYS_ALLOW else UserAuthChoice.ALLOW_ONCE) },
modifier = Modifier.weight(1f),
) { Text(if (rememberRelay) stringRes(R.string.relay_auth_always_log_in) else stringRes(Res.string.relay_auth_log_in)) }
) { Text(if (rememberRelay) stringRes(Res.string.relay_auth_always_log_in) else stringRes(Res.string.relay_auth_log_in)) }
}
Row(
modifier = Modifier.fillMaxWidth(),
@@ -313,10 +323,10 @@ private fun RelayAuthPromptDialog(
TextButton(
onClick = { confirming = UserAuthChoice.NEVER_ALLOW_EVERYWHERE },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) { Text(stringRes(R.string.relay_auth_never_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
) { Text(stringRes(Res.string.relay_auth_never_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
TextButton(
onClick = { confirming = UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE },
) { Text(stringRes(R.string.relay_auth_always_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
) { Text(stringRes(Res.string.relay_auth_always_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
}
}
},
@@ -343,12 +353,12 @@ private fun PolicyEverywhereConfirmation(
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(stringRes(if (always) R.string.relay_auth_always_everywhere_title else R.string.relay_auth_never_everywhere_title))
Text(stringRes(if (always) Res.string.relay_auth_always_everywhere_title else Res.string.relay_auth_never_everywhere_title))
},
text = {
Text(
stringRes(
if (always) R.string.relay_auth_always_everywhere_body else R.string.relay_auth_never_everywhere_body,
if (always) Res.string.relay_auth_always_everywhere_body else Res.string.relay_auth_never_everywhere_body,
accountName,
),
)
@@ -370,10 +380,10 @@ private fun PolicyEverywhereConfirmation(
)
},
) {
Text(stringRes(if (always) R.string.relay_auth_always_allow_everywhere else R.string.relay_auth_never_allow_everywhere))
Text(stringRes(if (always) Res.string.relay_auth_always_allow_everywhere else Res.string.relay_auth_never_allow_everywhere))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } },
)
}
@@ -558,7 +568,7 @@ private fun secondaryLine(
): String? {
val others = purposes.filter { it !== primary }
if (others.isEmpty()) return null
return pluralStringResource(R.plurals.relay_auth_also_holding_back, others.size, others.size)
return pluralStringRes(Res.plurals.relay_auth_also_holding_back, others.size, others.size)
}
/** A short label for a set of counterparties: the first person's name, or "Alice and 4 others". */
@@ -571,7 +581,7 @@ private fun counterpartyLabel(
val name = rememberCounterpartyName(first, accountViewModel)
if (pubkeys.size == 1) return name
val others = pubkeys.size - 1
return pluralStringResource(R.plurals.relay_auth_name_and_n_others, others, name, others)
return pluralStringRes(Res.plurals.relay_auth_name_and_n_others, others, name, others)
}
/**
@@ -22,8 +22,9 @@ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.relayClient.notify.NotifyRequestsCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.payment_required_title
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -50,7 +51,7 @@ fun DisplayNotifyMessages(
NotifyRequestDialog(
title =
stringRes(
id = R.string.payment_required_title,
id = Res.string.payment_required_title,
request.relayUrl.displayUrl(),
),
textContent = request.description,
@@ -36,12 +36,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_dialog_button_ok
import com.vitorpamplona.amethyst.commons.resources.notify_block_relay
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
@@ -119,7 +119,7 @@ fun NotifyRequestDialog(
)
Spacer(StdHorzSpacer)
Text(
text = stringRes(R.string.notify_block_relay),
text = stringRes(Res.string.notify_block_relay),
color = MaterialTheme.colorScheme.error,
)
}
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.relayClient.reqCommand
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.cachedDvmAnnouncements
import com.vitorpamplona.amethyst.commons.relayClient.apps.recommendations.ProfileAppRecommendationsFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.articles.ArticlesFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.assemblers.CashuMintDirectoryFilterAssembler
@@ -71,7 +72,6 @@ import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderFilterAssem
import com.vitorpamplona.amethyst.commons.relayClient.video.VideoFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.wallet.OnchainZapsFilterAssembler
import com.vitorpamplona.amethyst.commons.relayClient.workouts.WorkoutsFilterAssembler
import com.vitorpamplona.amethyst.model.cachedDvmAnnouncements
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.account.AccountForegroundFilterAssembler
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.ChannelFinderFilterAssemblyGroup
@@ -30,19 +30,24 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.collectMemorySnapshot
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_message
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_not_now
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_opt_out
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_send
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_title
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_bg_data
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_churn
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_conn_time
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_reconnects
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_wakelock
import com.vitorpamplona.amethyst.commons.service.crashreports.DEV_REPORT_PUBKEY
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -111,7 +116,7 @@ fun DisplayResourceUsageAlert(
text = {
Text(
stringRes(
R.string.resource_usage_alert_message,
Res.string.resource_usage_alert_message,
reasonDescription(found),
),
)
@@ -163,32 +168,32 @@ private fun reasonDescription(alert: ResourceUsageAlerts.Alert): String =
when (alert.reason) {
ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_DATA ->
stringRes(
R.string.resource_usage_reason_bg_data,
Res.string.resource_usage_reason_bg_data,
ResourceUsageReportAssembler.formatBytes(alert.value),
)
ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_CONNECTION_TIME ->
stringRes(
R.string.resource_usage_reason_conn_time,
Res.string.resource_usage_reason_conn_time,
ResourceUsageReportAssembler.formatConnHours(alert.value),
)
ResourceUsageAlerts.Reason.WAKELOCK_TIME ->
stringRes(
R.string.resource_usage_reason_wakelock,
Res.string.resource_usage_reason_wakelock,
ResourceUsageReportAssembler.formatDurationMs(alert.value),
)
ResourceUsageAlerts.Reason.PROCESS_CHURN ->
pluralStringResource(
R.plurals.resource_usage_reason_churn,
pluralStringRes(
Res.plurals.resource_usage_reason_churn,
alert.value.toInt(),
alert.value.toInt(),
)
ResourceUsageAlerts.Reason.RECONNECT_CHURN ->
pluralStringResource(
R.plurals.resource_usage_reason_reconnects,
pluralStringRes(
Res.plurals.resource_usage_reason_reconnects,
alert.value.toInt(),
alert.value.toInt(),
)
@@ -28,11 +28,18 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_name
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_error_prefix
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_notification_failed_title
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_notification_sent_title
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostNotifier
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.extractContentPreview
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Android [ScheduledPostNotifier]: posts a user-visible system notification when a
@@ -43,17 +50,17 @@ import com.vitorpamplona.amethyst.ui.stringRes
class AndroidScheduledPostNotifier(
private val context: Context,
) : ScheduledPostNotifier {
override fun notifySent(post: ScheduledPost) {
override suspend fun notifySent(post: ScheduledPost) {
ensureChannel(context)
post(
context = context,
notId = idFor(post.id),
title = stringRes(context, R.string.scheduled_posts_notification_sent_title),
title = loadStringRes(Res.string.scheduled_posts_notification_sent_title),
body = extractContentPreview(post, 120),
)
}
override fun notifyFailed(
override suspend fun notifyFailed(
post: ScheduledPost,
error: String?,
) {
@@ -63,23 +70,23 @@ class AndroidScheduledPostNotifier(
if (error.isNullOrBlank()) {
snippet
} else {
"$snippet\n${stringRes(context, R.string.scheduled_posts_error_prefix, error)}"
"$snippet\n${loadStringRes(Res.string.scheduled_posts_error_prefix, error)}"
}
post(
context = context,
notId = idFor(post.id),
title = stringRes(context, R.string.scheduled_posts_notification_failed_title),
title = loadStringRes(Res.string.scheduled_posts_notification_failed_title),
body = body,
)
}
private fun post(
private suspend fun post(
context: Context,
notId: Int,
title: String,
body: String,
) {
val channelId = stringRes(context, R.string.app_notification_scheduled_posts_channel_id)
val channelId = loadStringRes(Res.string.app_notification_scheduled_posts_channel_id)
val tapIntent =
Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
@@ -126,15 +133,15 @@ class AndroidScheduledPostNotifier(
private var channel: NotificationChannel? = null
private const val SCHEDULED_POST_NOT_ID_BASE = 0x70000
fun ensureChannel(context: Context) {
suspend fun ensureChannel(context: Context) {
if (channel != null) return
channel =
NotificationChannel(
stringRes(context, R.string.app_notification_scheduled_posts_channel_id),
stringRes(context, R.string.app_notification_scheduled_posts_channel_name),
loadStringRes(Res.string.app_notification_scheduled_posts_channel_id),
loadStringRes(Res.string.app_notification_scheduled_posts_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = stringRes(context, R.string.app_notification_scheduled_posts_channel_description)
description = loadStringRes(Res.string.app_notification_scheduled_posts_channel_description)
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel!!)
@@ -22,8 +22,8 @@ package com.vitorpamplona.amethyst.service.uploads
import android.content.Context
import androidx.compose.runtime.Stable
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
@@ -23,15 +23,25 @@ package com.vitorpamplona.amethyst.service.uploads
import android.content.Context
import android.net.Uri
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerName
import com.vitorpamplona.amethyst.commons.model.mediaServers.ServerType
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.avif_metadata_strip_failed
import com.vitorpamplona.amethyst.commons.resources.blossom_payment_required
import com.vitorpamplona.amethyst.commons.resources.could_not_check_downloaded_file
import com.vitorpamplona.amethyst.commons.resources.could_not_download_from_the_server
import com.vitorpamplona.amethyst.commons.resources.could_not_open_the_compressed_file
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_upload
import com.vitorpamplona.amethyst.commons.resources.media_too_big_for_nip95
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.resources.upload_cancelled
import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.UploadingState.UploadingFinalState
import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader
import com.vitorpamplona.amethyst.service.uploads.nip96.Nip96Uploader
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerType
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import com.vitorpamplona.quartz.nip98HttpAuth.HTTPAuthorizationEvent
@@ -44,6 +54,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.jetbrains.compose.resources.StringResource
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
@@ -67,7 +78,7 @@ sealed class UploadingState {
) : UploadingFinalState()
class Error(
val errorResource: Int,
val errorResource: StringResource,
val params: Array<out String>,
) : UploadingFinalState()
}
@@ -82,7 +93,7 @@ class UploadOrchestrator {
}
fun error(
resId: Int,
resId: StringResource,
vararg params: String,
) = UploadingState.Error(resId, params).also { updateState(0.0, it) }
@@ -115,7 +126,7 @@ class UploadOrchestrator {
if (bytes != null) {
if (bytes.size > 80000) {
return error(R.string.media_too_big_for_nip95)
return error(Res.string.media_too_big_for_nip95)
}
updateState(0.8, UploadingState.Hashing)
@@ -132,11 +143,11 @@ class UploadOrchestrator {
return finish(OrchestratorResult.NIP95Result(it, bytes, originalContentType, originalHash))
},
onFailure = {
return error(R.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName)
return error(Res.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName)
},
)
} else {
return error(R.string.could_not_open_the_compressed_file)
return error(Res.string.could_not_open_the_compressed_file)
}
}
@@ -184,10 +195,10 @@ class UploadOrchestrator {
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
)
} catch (_: SignerExceptions.ReadOnlyException) {
error(R.string.login_with_a_private_key_to_be_able_to_upload)
error(Res.string.login_with_a_private_key_to_be_able_to_upload)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
error(Res.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
}
}
@@ -260,13 +271,13 @@ class UploadOrchestrator {
finalState
} catch (_: SignerExceptions.ReadOnlyException) {
error(R.string.login_with_a_private_key_to_be_able_to_upload)
error(Res.string.login_with_a_private_key_to_be_able_to_upload)
} catch (e: BlossomPaymentException) {
// BUD-07: the server wants payment before it will store the blob.
error(R.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl)
error(Res.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(R.string.failed_to_upload_media, e.message?.ifBlank { null } ?: e.javaClass.simpleName)
error(Res.string.failed_to_upload_media, e.message?.ifBlank { null } ?: e.javaClass.simpleName)
}
}
@@ -318,7 +329,7 @@ class UploadOrchestrator {
okHttpClient: (String) -> OkHttpClient,
): UploadingFinalState {
if (uploadResult.url.isNullOrBlank()) {
return error(R.string.server_did_not_provide_a_url_after_uploading)
return error(Res.string.server_did_not_provide_a_url_after_uploading)
}
updateState(0.6, UploadingState.Downloading)
@@ -326,7 +337,7 @@ class UploadOrchestrator {
// Use streaming verification for memory efficiency with large files
val verification =
ImageDownloader().waitAndVerifyStream(uploadResult.url, okHttpClient)
?: return error(R.string.could_not_download_from_the_server)
?: return error(Res.string.could_not_download_from_the_server)
updateState(0.8, UploadingState.Hashing)
@@ -458,10 +469,10 @@ class UploadOrchestrator {
try {
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
} catch (e: AvifMetadataNotVerifiableException) {
return error(R.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName).also {
return error(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName).also {
deleteTempUri(compressed.uri, uri)
}
} ?: return error(R.string.upload_cancelled).also {
} ?: return error(Res.string.upload_cancelled).also {
deleteTempUri(compressed.uri, uri)
}
@@ -500,10 +511,10 @@ class UploadOrchestrator {
try {
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
} catch (e: AvifMetadataNotVerifiableException) {
return error(R.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName).also {
return error(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName).also {
deleteTempUri(compressed.uri, uri)
}
} ?: return error(R.string.upload_cancelled).also {
} ?: return error(Res.string.upload_cancelled).also {
deleteTempUri(compressed.uri, uri)
}
@@ -25,14 +25,16 @@ import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_delete_with_message
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_to_server_with_message
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
import com.vitorpamplona.amethyst.service.uploads.extensionFromMimeType
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -215,11 +217,11 @@ class BlossomUploader {
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (errorMessage != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage))
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage))
} else if (explanation != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), stringRes(context, explanation)))
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), loadStringRes(explanation)))
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString()))
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString()))
}
}
}
@@ -274,9 +276,9 @@ class BlossomUploader {
} else {
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (explanation != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation)))
throw RuntimeException(loadStringRes(Res.string.failed_to_delete_with_message, loadStringRes(explanation)))
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code))
throw RuntimeException(loadStringRes(Res.string.failed_to_delete_with_message, response.code))
}
}
}
@@ -23,13 +23,16 @@ package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.no_blossom_apps_found_description
import com.vitorpamplona.amethyst.commons.resources.no_blossom_apps_found_title
import org.jetbrains.compose.resources.StringResource
import kotlin.coroutines.cancellation.CancellationException
fun openBlossomUriAsIntent(
context: Context,
blossomUri: String,
onError: (Int, Int) -> Unit,
onError: (StringResource, StringResource) -> Unit,
) {
try {
val intent = Intent(Intent.ACTION_VIEW, blossomUri.toUri())
@@ -38,6 +41,6 @@ fun openBlossomUriAsIntent(
context.startActivity(intent)
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(R.string.no_blossom_apps_found_title, R.string.no_blossom_apps_found_description)
onError(Res.string.no_blossom_apps_found_title, Res.string.no_blossom_apps_found_description)
}
}

Some files were not shown because too many files have changed in this diff Show More