mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
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:
@@ -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
|
||||
|
||||
+11
-7
@@ -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,
|
||||
|
||||
+2
-3
@@ -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))
|
||||
|
||||
+3
-2
@@ -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 {
|
||||
|
||||
+7
-6
@@ -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,
|
||||
)
|
||||
|
||||
+3
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+13
-8
@@ -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
|
||||
|
||||
+1
-1
@@ -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 =
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+49
-99
@@ -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]+$")
|
||||
}
|
||||
}
|
||||
|
||||
+65
-1
@@ -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)
|
||||
|
||||
+10
-6
@@ -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) },
|
||||
)
|
||||
|
||||
+2
-2
@@ -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,
|
||||
)
|
||||
|
||||
+2
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+7
-6
@@ -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) }
|
||||
}
|
||||
|
||||
+3
-3
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+64
-3
@@ -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
|
||||
}
|
||||
|
||||
+63
-20
@@ -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
|
||||
}
|
||||
|
||||
+92
-57
@@ -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,
|
||||
|
||||
+6
-3
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+11
-7
@@ -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!!)
|
||||
|
||||
+8
-7
@@ -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,
|
||||
)
|
||||
|
||||
+14
-8
@@ -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())
|
||||
}
|
||||
|
||||
+13
-10
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -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 = {
|
||||
|
||||
+43
-45
@@ -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(),
|
||||
|
||||
+7
-6
@@ -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)
|
||||
},
|
||||
)
|
||||
|
||||
+5
-4
@@ -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) {
|
||||
|
||||
+7
-4
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+12
-7
@@ -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
|
||||
|
||||
+126
-66
@@ -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)
|
||||
|
||||
+12
-8
@@ -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) },
|
||||
|
||||
+17
-8
@@ -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 {
|
||||
|
||||
+6
-5
@@ -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(
|
||||
|
||||
+20
-15
@@ -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())
|
||||
|
||||
+8
-6
@@ -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
|
||||
}
|
||||
|
||||
+7
-5
@@ -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(),
|
||||
|
||||
+6
-4
@@ -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,
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
+7
-6
@@ -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,
|
||||
|
||||
+40
-24
@@ -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(),
|
||||
|
||||
+6
-4
@@ -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,
|
||||
|
||||
+7
-5
@@ -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(),
|
||||
|
||||
+6
-5
@@ -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(),
|
||||
|
||||
+4
-3
@@ -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
|
||||
|
||||
|
||||
+6
-4
@@ -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,
|
||||
|
||||
+4
-3
@@ -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()
|
||||
},
|
||||
|
||||
+4
-3
@@ -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
-15
@@ -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
|
||||
}
|
||||
|
||||
+7
-4
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -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),
|
||||
)
|
||||
}
|
||||
|
||||
+5
-3
@@ -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,
|
||||
)
|
||||
|
||||
+9
-5
@@ -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()
|
||||
|
||||
+5
-3
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+6
-3
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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 }
|
||||
|
||||
+4
-2
@@ -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))
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+21
-11
@@ -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
|
||||
}
|
||||
|
||||
+22
-12
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-2
@@ -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,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+15
-10
@@ -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(),
|
||||
)
|
||||
|
||||
+19
-12
@@ -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!!)
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+30
-19
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+9
-7
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -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
Reference in New Issue
Block a user