Merge remote-tracking branch 'origin/main' into claude/epic-ptolemy-48n37b

# Conflicts:
#	commonsUI/src/commonMain/composeResources/values/strings.xml
This commit is contained in:
Claude
2026-09-22 23:11:48 +00:00
786 changed files with 103124 additions and 88933 deletions
@@ -138,6 +138,7 @@ Separator legend: **NL** = `joinToString("\n")`, **SP** = `joinToString(" ")`.
| 39000 | GroupMetadataEvent | nip29RelayGroups/metadata | `listOfNotNull(name(), about())` NL |
| 39089 | FollowListEvent | nip51Lists/followList | `listOfNotNull(title(), description())` NL |
| 39092 | MediaStarterPackEvent | nip51Lists/mediaStarterPack | `listOfNotNull(title(), description())` NL |
| 39307 | TextTrackEvent | nip71Video/textTrack | `content` (the WebVTT cue text) |
| 39701 | WebBookmarkEvent | nipB0WebBookmarks | `listOfNotNull(title(), description())` NL |
| 40002 | StreamMessageV2Event | buzz/stream | `content` |
| 40100 | CanvasEvent | buzz/stream | `content` |
@@ -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
}
@@ -52,12 +52,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
@@ -66,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_block
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_button
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_how_handle
@@ -272,7 +271,7 @@ private fun SignerConnectScreen(
horizontalArrangement = Arrangement.spacedBy(12.dp),
) {
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) {
Text(stringResource(R.string.cancel))
Text(stringRes(Res.string.cancel))
}
Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) {
Text(stringRes(Res.string.napplet_connect_button))
@@ -22,8 +22,9 @@ package com.vitorpamplona.amethyst.connectedApps.consent
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_connect_title
import kotlinx.coroutines.CompletableDeferred
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
@@ -88,7 +89,7 @@ object SignerConnectCoordinator {
activityClass = SignerConnectActivity::class.java,
extraKey = EXTRA_TOKEN,
token = token,
titleRes = R.string.nip46_signer_notif_connect_title,
titleRes = Res.string.nip46_signer_notif_connect_title,
)
return try {
@@ -60,8 +60,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
@@ -69,7 +67,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
@@ -93,12 +90,16 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_signer_deny_op
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_allow
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_deny
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_remember
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_select_all
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_select_none
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_signing_as
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_title
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
import com.vitorpamplona.amethyst.ui.note.NoteCompose
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -483,7 +484,7 @@ private fun BatchedConsentDialog(
}
Column(modifier = Modifier.weight(1f)) {
Text(
pluralStringResource(R.plurals.nip46_signer_batch_title, pending.size, pending.size),
pluralStringRes(Res.plurals.nip46_signer_batch_title, pending.size, pending.size),
style = MaterialTheme.typography.titleLarge,
)
account.accountName?.let { name ->
@@ -504,8 +505,8 @@ private fun BatchedConsentDialog(
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 2.dp),
) {
Text(
stringResource(
if (selected.size == pending.size) R.string.nip46_signer_batch_select_none else R.string.nip46_signer_batch_select_all,
stringRes(
if (selected.size == pending.size) Res.string.nip46_signer_batch_select_none else Res.string.nip46_signer_batch_select_all,
),
style = MaterialTheme.typography.labelLarge,
)
@@ -22,9 +22,10 @@ package com.vitorpamplona.amethyst.connectedApps.consent
import android.content.Context
import android.content.Intent
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_sign_title
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlinx.coroutines.CompletableDeferred
@@ -140,7 +141,7 @@ object SignerConsentCoordinator {
activityClass = SignerConsentActivity::class.java,
extraKey = EXTRA_TOKEN,
token = "nip46-signer-consent",
titleRes = R.string.nip46_signer_notif_sign_title,
titleRes = Res.string.nip46_signer_notif_sign_title,
)
}
@@ -28,7 +28,12 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_channel_desc
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_channel_name
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_tap
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import org.jetbrains.compose.resources.StringResource
/**
* Surfaces a signer consent/connect [android.app.Activity] from the **background**.
@@ -51,17 +56,17 @@ import com.vitorpamplona.amethyst.ui.stringRes
object SignerConsentNotifier {
private const val CHANNEL_ID = "com.vitorpamplona.amethyst.SIGNER_CONSENT_CHANNEL"
private fun ensureChannel(context: Context): NotificationChannel {
private suspend fun ensureChannel(context: Context): NotificationChannel {
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.getNotificationChannel(CHANNEL_ID)?.let { return it }
val channel =
NotificationChannel(
CHANNEL_ID,
stringRes(context, R.string.nip46_signer_notif_channel_name),
loadStringRes(Res.string.nip46_signer_notif_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = stringRes(context, R.string.nip46_signer_notif_channel_desc)
description = loadStringRes(Res.string.nip46_signer_notif_channel_desc)
}
manager.createNotificationChannel(channel)
return channel
@@ -72,12 +77,12 @@ object SignerConsentNotifier {
* [activityClass] carrying [token]. Returns the notification id to pass to [cancel] once the
* request resolves.
*/
fun show(
suspend fun show(
context: Context,
activityClass: Class<*>,
extraKey: String,
token: String,
titleRes: Int,
titleRes: StringResource,
): Int {
// When Amethyst already owns the foreground the direct startActivity opens the dialog, so a
// heads-up notification would just be redundant noise on top of it. Only fall back to the
@@ -104,8 +109,8 @@ object SignerConsentNotifier {
NotificationCompat
.Builder(context, channel.id)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(stringRes(context, titleRes))
.setContentText(stringRes(context, R.string.nip46_signer_notif_tap))
.setContentTitle(loadStringRes(titleRes))
.setContentText(loadStringRes(Res.string.nip46_signer_notif_tap))
.setContentIntent(pendingIntent)
.setFullScreenIntent(pendingIntent, true)
.setPriority(NotificationCompat.PRIORITY_HIGH)
@@ -26,7 +26,6 @@ import android.content.res.Configuration
import android.os.Bundle
import android.widget.Toast
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.model.ThemeType
@@ -57,13 +56,20 @@ import com.vitorpamplona.quartz.utils.Log
* failing silently.
*/
object FavoriteAppLauncher {
/**
* [stillLoading] is the toast shown when a NostrApp's defining event hasn't
* arrived yet. It is passed in already resolved because the launcher runs from
* plain onClick lambdas, where neither string accessor - composable or
* suspend - can be called.
*/
fun launch(
context: Context,
app: FavoriteApp,
stillLoading: String,
) {
when (app) {
is FavoriteApp.WebApp -> launchUrl(context, app.url)
is FavoriteApp.NostrApp -> launchNostrApp(context, app.coordinate)
is FavoriteApp.NostrApp -> launchNostrApp(context, app.coordinate, stillLoading)
}
}
@@ -113,6 +119,7 @@ object FavoriteAppLauncher {
private fun launchNostrApp(
context: Context,
coordinate: String,
stillLoading: String,
) {
val event = LocalCache.getAddressableNoteIfExists(coordinate)?.event
when (event) {
@@ -146,7 +153,7 @@ object FavoriteAppLauncher {
)
else -> {
Log.w("FavoriteAppLauncher") { "Favorited app not resolvable yet: $coordinate" }
Toast.makeText(context, R.string.favorite_app_still_loading, Toast.LENGTH_SHORT).show()
Toast.makeText(context, stillLoading, Toast.LENGTH_SHORT).show()
}
}
}
@@ -316,6 +316,7 @@ import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.PubkeyRuleTag
import com.vitorpamplona.quartz.nip72ModCommunities.rules.tags.WotTag
import com.vitorpamplona.quartz.nip85TrustedAssertions.list.tags.ServiceProviderTag
import com.vitorpamplona.quartz.nip88Polls.poll.PollEvent
import com.vitorpamplona.quartz.nip88Polls.poll.tags.PollType
import com.vitorpamplona.quartz.nip88Polls.response.PollResponseEvent
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.NostrSignerWithClientTag
import com.vitorpamplona.quartz.nip89AppHandlers.clientTag.withoutClientTag
@@ -1209,6 +1210,12 @@ class Account(
}
}
suspend fun changeCaptionsEnabled(enabled: Boolean) {
if (settings.changeCaptionsEnabled(enabled)) {
sendNewAppSpecificData()
}
}
suspend fun changeVideoPlayerButtonItems(items: List<VideoPlayerButtonItem>) {
if (settings.changeVideoPlayerButtonItems(items)) {
sendNewAppSpecificData()
@@ -2309,7 +2316,15 @@ class Account(
val poll = cache.getOrCreateNote(event.id).toEventHint<PollEvent>()
if (poll != null) {
val template = PollResponseEvent.build(poll, responses)
// The poll decides how its answers are counted, so it decides which builder applies:
// a single-choice answer is the first tag only, and sending several would record
// whichever one the reader saw first.
val template =
when (event.pollType()) {
PollType.SINGLE_CHOICE ->
responses.firstOrNull()?.let { PollResponseEvent.buildSingleChoice(poll, it) } ?: return
PollType.MULTI_CHOICE -> PollResponseEvent.buildMultipleChoice(poll, responses)
}
val signedEvent = signer.sign(template)
@@ -2963,6 +2978,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 ->
@@ -2972,6 +2996,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) {
@@ -2992,16 +3026,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.
*
@@ -410,6 +410,15 @@ class AccountSettings(
return false
}
fun changeCaptionsEnabled(enabled: Boolean): Boolean {
if (syncedSettings.videoPlayer.captionsEnabled.value != enabled) {
syncedSettings.videoPlayer.captionsEnabled.tryEmit(enabled)
saveAccountSettings()
return true
}
return false
}
fun changeAudioVisualizer(style: VisualizerStyle): Boolean {
if (syncedSettings.media.audioVisualizer.value != style) {
syncedSettings.media.audioVisualizer.tryEmit(style)
@@ -70,6 +70,7 @@ class AccountSyncedSettings(
val videoPlayer =
AccountVideoPlayerPreferences(
MutableStateFlow(mergeWithDefaultVideoPlayerButtons(internalSettings.videoPlayer.buttonItems).toImmutableList()),
MutableStateFlow(internalSettings.videoPlayer.captionsEnabled),
)
val media =
AccountMediaPreferences(
@@ -118,7 +119,7 @@ class AccountSyncedSettings(
security.sendKind0EventsToLocalRelay.value,
security.addClientTag.value,
),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value),
videoPlayer = AccountVideoPlayerPreferencesInternal(videoPlayer.buttonItems.value, videoPlayer.captionsEnabled.value),
media = AccountMediaPreferencesInternal(media.audioVisualizer.value.name),
chats =
AccountChatPreferencesInternal(
@@ -208,6 +209,10 @@ class AccountSyncedSettings(
videoPlayer.buttonItems.tryEmit(newVideoPlayerButtonItems)
}
if (videoPlayer.captionsEnabled.value != syncedSettingsInternal.videoPlayer.captionsEnabled) {
videoPlayer.captionsEnabled.tryEmit(syncedSettingsInternal.videoPlayer.captionsEnabled)
}
val newAudioVisualizer = VisualizerStyle.fromName(syncedSettingsInternal.media.audioVisualizer)
if (media.audioVisualizer.value != newAudioVisualizer) {
media.audioVisualizer.tryEmit(newAudioVisualizer)
@@ -254,6 +259,7 @@ class AccountReactionPreferences(
@Stable
class AccountVideoPlayerPreferences(
var buttonItems: MutableStateFlow<ImmutableList<VideoPlayerButtonItem>>,
val captionsEnabled: MutableStateFlow<Boolean>,
)
@Stable
@@ -107,6 +107,7 @@ enum class VideoPlayerAction {
Download,
PictureInPicture,
Cast,
Captions,
}
@Serializable
@@ -127,6 +128,7 @@ val DefaultVideoPlayerButtonItems =
VideoPlayerButtonItem(VideoPlayerAction.Mute, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Quality, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Cast, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Captions, VideoButtonLocation.TopBar),
VideoPlayerButtonItem(VideoPlayerAction.Share, VideoButtonLocation.OverflowMenu),
VideoPlayerButtonItem(VideoPlayerAction.Download, VideoButtonLocation.OverflowMenu),
VideoPlayerButtonItem(VideoPlayerAction.PictureInPicture, VideoButtonLocation.OverflowMenu),
@@ -184,6 +186,10 @@ class AccountNavigationPreferencesInternal(
@Serializable
class AccountVideoPlayerPreferencesInternal(
var buttonItems: List<VideoPlayerButtonItem> = DefaultVideoPlayerButtonItems,
// Feed videos autoplay muted, so captions are what make them legible — on by default, and
// remembered once the viewer says otherwise. Defaulted rather than required so a blob written
// by a client that predates the field still decodes.
var captionsEnabled: Boolean = true,
)
@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 =
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.model.marmot
import com.vitorpamplona.amethyst.commons.marmot.EncryptedAppendLog
import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -38,24 +39,16 @@ import java.io.File
* <rootDir>/mls_groups/<nostrGroupId>/messages — encrypted message log
* ```
*
* The on-disk format (after decryption) is a sequence of length-prefixed
* UTF-8 entries:
* ```
* uint32 count
* for each entry:
* uint32 length
* byte[length] utf8
* ```
*
* The whole blob is rewritten on each append (via atomic rename) — this
* keeps encryption simple (one GCM nonce per write) and is acceptable for
* conversation-scale histories.
* Every file here is an [EncryptedAppendLog], which owns the on-disk format and
* the migration from the original whole-blob one. Recording a message appends a
* small encrypted segment instead of rewriting the conversation, which is what
* keeps the cost of a send flat as the history grows.
*/
class AndroidMarmotMessageStore(
private val rootDir: File,
private val encryption: KeyStoreEncryption = KeyStoreEncryption(),
) : MarmotMessageStore {
private val writeMutex = Mutex()
private val logMutex = Mutex()
init {
Log.d(TAG) {
@@ -76,17 +69,16 @@ class AndroidMarmotMessageStore(
nostrGroupId: String,
innerEventJson: String,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
logMutex.withLock {
try {
val existing = readAll(nostrGroupId).toMutableList()
if (innerEventJson in existing) {
val file = messagesFile(nostrGroupId)
if (log.contains(file, innerEventJson)) {
Log.d(TAG) { "appendMessage($nostrGroupId): duplicate entry skipped" }
return@withLock
}
existing.add(innerEventJson)
writeAll(nostrGroupId, existing)
log.append(file, innerEventJson)
Log.d(TAG) {
"appendMessage($nostrGroupId): now ${existing.size} message(s) persisted"
"appendMessage($nostrGroupId): now ${log.readAll(file).size} message(s) persisted"
}
} catch (e: Exception) {
Log.e(TAG, "appendMessage($nostrGroupId) FAILED: ${e.message}", e)
@@ -98,7 +90,7 @@ class AndroidMarmotMessageStore(
override suspend fun loadMessages(nostrGroupId: String): List<String> =
withContext(Dispatchers.IO) {
try {
val messages = readAll(nostrGroupId)
val messages = logMutex.withLock { readAll(nostrGroupId) }
Log.d(TAG) {
"loadMessages($nostrGroupId): loaded ${messages.size} message(s)"
}
@@ -111,8 +103,9 @@ class AndroidMarmotMessageStore(
override suspend fun delete(nostrGroupId: String) {
withContext(Dispatchers.IO) {
writeMutex.withLock {
logMutex.withLock {
for (file in listOf(messagesFile(nostrGroupId), epochsFile(nostrGroupId), snapshotFile(nostrGroupId), expiriesFile(nostrGroupId), epochRetentionsFile(nostrGroupId))) {
log.forget(file)
if (file.exists() && !file.delete()) {
Log.w(TAG) { "delete($nostrGroupId): failed to remove ${file.absolutePath}" }
}
@@ -138,7 +131,7 @@ class AndroidMarmotMessageStore(
innerEventId: String,
epoch: Long,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
logMutex.withLock {
try {
val line = "$innerEventId $epoch"
val existing = readAllFrom(epochsFile(nostrGroupId)).toMutableList()
@@ -154,7 +147,8 @@ class AndroidMarmotMessageStore(
override suspend fun loadEpochs(nostrGroupId: String): Map<String, Long> =
withContext(Dispatchers.IO) {
try {
readAllFrom(epochsFile(nostrGroupId))
logMutex
.withLock { readAllFrom(epochsFile(nostrGroupId)) }
.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
@@ -185,7 +179,7 @@ class AndroidMarmotMessageStore(
innerEventId: String,
expiresAtSecs: Long,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
logMutex.withLock {
try {
val existing = readAllFrom(expiriesFile(nostrGroupId)).toMutableList()
if (existing.any { it.substringBefore(' ') == innerEventId }) return@withLock
@@ -200,7 +194,8 @@ class AndroidMarmotMessageStore(
override suspend fun loadExpiries(nostrGroupId: String): Map<String, Long> =
withContext(Dispatchers.IO) {
try {
readAllFrom(expiriesFile(nostrGroupId))
logMutex
.withLock { readAllFrom(expiriesFile(nostrGroupId)) }
.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
@@ -225,7 +220,7 @@ class AndroidMarmotMessageStore(
innerEventIds: Set<String>,
) = withContext(Dispatchers.IO) {
if (innerEventIds.isEmpty()) return@withContext
writeMutex.withLock {
logMutex.withLock {
try {
val kept =
readAll(nostrGroupId).filter { json ->
@@ -257,7 +252,7 @@ class AndroidMarmotMessageStore(
epoch: Long,
retentionSecs: Long,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
logMutex.withLock {
try {
val existing = readAllFrom(epochRetentionsFile(nostrGroupId)).toMutableList()
if (existing.any { it.substringBefore(' ') == epoch.toString() }) return@withLock
@@ -272,7 +267,8 @@ class AndroidMarmotMessageStore(
override suspend fun loadEpochRetentions(nostrGroupId: String): Map<Long, Long> =
withContext(Dispatchers.IO) {
try {
readAllFrom(epochRetentionsFile(nostrGroupId))
logMutex
.withLock { readAllFrom(epochRetentionsFile(nostrGroupId)) }
.mapNotNull { line ->
val parts = line.trim().split(' ')
if (parts.size != 2) return@mapNotNull null
@@ -302,7 +298,7 @@ class AndroidMarmotMessageStore(
nostrGroupId: String,
snapshotJson: String,
) = withContext(Dispatchers.IO) {
writeMutex.withLock {
logMutex.withLock {
try {
writeAllTo(snapshotFile(nostrGroupId), listOf(snapshotJson))
} catch (e: Exception) {
@@ -314,42 +310,37 @@ class AndroidMarmotMessageStore(
override suspend fun loadGroupSnapshot(nostrGroupId: String): String? =
withContext(Dispatchers.IO) {
try {
readAllFrom(snapshotFile(nostrGroupId)).firstOrNull()
logMutex.withLock { readAllFrom(snapshotFile(nostrGroupId)) }.firstOrNull()
} catch (e: Exception) {
Log.e(TAG, "loadGroupSnapshot($nostrGroupId) FAILED: ${e.message}", e)
null
}
}
// The segmented, constant-time-append log every file here is stored as.
// Guarded by [logMutex]: it caches decrypted entries so an append never has
// to read the log back, and that cache assumes a single owner.
private val log =
EncryptedAppendLog(
encrypt = { encryption.encrypt(it) },
// EncryptedAppendLog requires null, not a throw, for a segment it
// cannot open — KeyStoreEncryption.decrypt rethrows. Without this
// one bad segment would abort the whole read, and a caller that
// then sees an empty log can overwrite a history that was merely
// unreadable.
decrypt = {
try {
encryption.decrypt(it)
} catch (e: Exception) {
Log.w(TAG, "a log segment could not be decrypted and was skipped: ${e.message}", e)
null
}
},
)
private fun readAll(nostrGroupId: String): List<String> = readAllFrom(messagesFile(nostrGroupId))
private fun readAllFrom(file: File): List<String> {
if (!file.exists()) return emptyList()
val encrypted = file.readBytes()
val plain = encryption.decrypt(encrypted) ?: return emptyList()
if (plain.size < 4) return emptyList()
var offset = 0
val count =
((plain[offset++].toInt() and 0xFF) shl 24) or
((plain[offset++].toInt() and 0xFF) shl 16) or
((plain[offset++].toInt() and 0xFF) shl 8) or
(plain[offset++].toInt() and 0xFF)
val result = ArrayList<String>(count.coerceAtMost(MAX_MESSAGES))
for (i in 0 until count) {
if (offset + 4 > plain.size) break
val len =
((plain[offset++].toInt() and 0xFF) shl 24) or
((plain[offset++].toInt() and 0xFF) shl 16) or
((plain[offset++].toInt() and 0xFF) shl 8) or
(plain[offset++].toInt() and 0xFF)
if (len < 0 || offset + len > plain.size) break
result.add(plain.copyOfRange(offset, offset + len).decodeToString())
offset += len
}
return result
}
private fun readAllFrom(file: File): List<String> = log.readAll(file)
private fun writeAll(
nostrGroupId: String,
@@ -359,51 +350,10 @@ class AndroidMarmotMessageStore(
private fun writeAllTo(
file: File,
messages: List<String>,
) {
file.parentFile?.mkdirs()
val encodedEntries = messages.map { it.encodeToByteArray() }
val totalSize = 4 + encodedEntries.sumOf { 4 + it.size }
val buffer = ByteArray(totalSize)
var offset = 0
val count = encodedEntries.size
buffer[offset++] = (count shr 24).toByte()
buffer[offset++] = (count shr 16).toByte()
buffer[offset++] = (count shr 8).toByte()
buffer[offset++] = count.toByte()
for (entry in encodedEntries) {
val len = entry.size
buffer[offset++] = (len shr 24).toByte()
buffer[offset++] = (len shr 16).toByte()
buffer[offset++] = (len shr 8).toByte()
buffer[offset++] = len.toByte()
entry.copyInto(buffer, offset)
offset += len
}
val encrypted = encryption.encrypt(buffer)
atomicWrite(file, encrypted)
}
private fun atomicWrite(
target: File,
data: ByteArray,
) {
val tempFile = File(target.parentFile, "${target.name}.tmp")
tempFile.writeBytes(data)
if (!tempFile.renameTo(target)) {
tempFile.copyTo(target, overwrite = true)
if (!tempFile.delete()) {
Log.w(TAG) { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" }
}
}
}
) = log.rewrite(file, messages)
companion object {
private const val TAG = "AndroidMarmotMessageStore"
private const val MAX_MESSAGES = 1_000_000
private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$")
}
}
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
/**
* Android implementation of [MlsGroupStateStore] using file-based encrypted storage.
@@ -35,8 +36,9 @@ import java.io.File
*
* Storage layout:
* ```
* <rootDir>/mls_groups/<nostrGroupId>/state — encrypted MlsGroupState
* <rootDir>/mls_groups/<nostrGroupId>/state — encrypted MlsGroupState
* <rootDir>/mls_groups/<nostrGroupId>/retained — encrypted retained epoch secrets
* <rootDir>/mls_groups/<nostrGroupId>/ratchet — encrypted OwnSenderRatchet
* ```
*/
class AndroidMlsGroupStateStore(
@@ -67,6 +69,8 @@ class AndroidMlsGroupStateStore(
private fun retainedFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "retained")
private fun ratchetFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "ratchet")
override suspend fun save(
nostrGroupId: String,
state: ByteArray,
@@ -166,6 +170,66 @@ class AndroidMlsGroupStateStore(
atomicWrite(file, encryption.encrypt(buffer))
}
/**
* The per-send ratchet position, written on its own so a message does not
* re-encrypt the whole group state to record ~72 bytes.
*
* Written atomically, through a temp file and a rename. An in-place write
* looked adequate — the record is small and written whole — but the failure
* it allows is the one this record exists to prevent. A torn record is
* discarded on load, which falls back to the position in the full state:
* that is the position as of the last COMMIT, behind by every send since.
* The next send would then re-emit every generation in between, reusing
* AEAD key+nonce pairs. With a rename, a crash leaves the previous complete
* record instead, which is at worst one send behind — and that send's
* ciphertext was never published, because publishing waits for this write.
*/
override suspend fun saveSenderRatchet(
nostrGroupId: String,
state: ByteArray,
): Boolean =
withContext(Dispatchers.IO) {
val file = ratchetFile(nostrGroupId)
try {
file.parentFile?.mkdirs()
val encrypted = encryption.encrypt(state)
val tempFile = File(file.parentFile, "${file.name}.tmp")
FileOutputStream(tempFile).use { out ->
out.write(encrypted)
out.fd.sync()
}
if (!tempFile.renameTo(file)) {
tempFile.copyTo(file, overwrite = true)
if (!tempFile.delete()) {
Log.w(TAG) { "Failed to delete the temp ratchet file: ${tempFile.absolutePath}" }
}
}
true
} catch (e: Exception) {
// Returning false sends the caller to a full state write, which
// is slower but carries the same guarantee. Swallowing this and
// returning true would drop the position on the floor.
Log.w(TAG, "saveSenderRatchet($nostrGroupId) FAILED, falling back to a full state write: ${e.message}", e)
false
}
}
override suspend fun loadSenderRatchet(nostrGroupId: String): ByteArray? =
withContext(Dispatchers.IO) {
val file = ratchetFile(nostrGroupId)
if (!file.exists()) return@withContext null
try {
encryption.decrypt(file.readBytes())
} catch (e: Exception) {
// Serious: the fallback is the full state's position, which is
// BEHIND, and a behind position re-emits used generations. The
// atomic write above is what should make this unreachable, so
// if it ever fires the group's key material is suspect.
Log.e(TAG, "loadSenderRatchet($nostrGroupId) unreadable — the ratchet may rewind: ${e.message}", e)
null
}
}
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> =
withContext(Dispatchers.IO) {
val file = retainedFile(nostrGroupId)
@@ -21,12 +21,16 @@
package com.vitorpamplona.amethyst.model.nip46Signer
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_for
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_decrypt_failed
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_remote_app
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
@@ -65,7 +69,7 @@ object Nip46ConsentBridge {
): AppConnectResult {
val context = Amethyst.instance.appContext
val meta = request.clientMetadata
val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val title = meta?.name?.ifBlank { null } ?: loadStringRes(Res.string.nip46_signer_remote_app)
val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "…")
// The identity being connected to lives in the coordinate; show it as an avatar + name.
val face = accountFace(coordinate)
@@ -100,7 +104,7 @@ object Nip46ConsentBridge {
requestedOps: List<NostrSignerOp>,
): AppConnectResult {
val context = Amethyst.instance.appContext
val title = name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val title = name?.ifBlank { null } ?: loadStringRes(Res.string.nip46_signer_remote_app)
val domain = url?.ifBlank { null } ?: (Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)?.take(12)?.plus("…") ?: "")
val face = accountFace(coordinate)
val info =
@@ -138,7 +142,7 @@ object Nip46ConsentBridge {
): SignerOpGrant {
val context = Amethyst.instance.appContext
val info = runCatching { Amethyst.instance.nip46ClientStore.load(coordinate) }.getOrNull()
val title = info?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
val title = info?.name?.ifBlank { null } ?: loadStringRes(Res.string.nip46_signer_remote_app)
val consentInfo =
Nip46ConsentInfoBuilder.build(
@@ -152,8 +156,8 @@ object Nip46ConsentBridge {
strings =
Nip46ConsentStrings(
opLabel = { it.label(context) },
allowAlwaysFor = { context.getString(R.string.nip46_signer_allow_always_for, it) },
decryptFailed = context.getString(R.string.nip46_signer_decrypt_failed),
allowAlwaysFor = { loadStringRes(Res.string.nip46_signer_allow_always_for, it) },
decryptFailed = loadStringRes(Res.string.nip46_signer_decrypt_failed),
),
decrypt = { decryptWithAccountSigner(signer, it) },
)
@@ -46,9 +46,9 @@ data class SignerFace(
*/
class Nip46ConsentStrings(
/** Human-readable label for an op, e.g. "read your private messages with Alice". */
val opLabel: (NostrSignerOp) -> String,
val opLabel: suspend (NostrSignerOp) -> String,
/** Button text for the counterparty-scoped grant; the argument is the counterparty's name. */
val allowAlwaysFor: (String) -> String,
val allowAlwaysFor: suspend (String) -> String,
/** Shown as the preview when Amethyst itself could not decrypt the message. */
val decryptFailed: String,
)
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.preferences
import android.os.Build
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyInfo
import android.security.keystore.KeyProperties
import android.security.keystore.StrongBoxUnavailableException
import com.vitorpamplona.quartz.utils.Log
@@ -29,6 +30,7 @@ import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.SecretKeyFactory
import javax.crypto.spec.GCMParameterSpec
class KeyStoreEncryption {
@@ -45,12 +47,65 @@ class KeyStoreEncryption {
private const val GCM_TAG_LENGTH_BITS = 128
}
private val cipher = Cipher.getInstance(TRANSFORMATION)
// One Cipher per thread rather than one shared instance. A Cipher carries
// the state of the operation in progress, so two coroutines encrypting on
// different Dispatchers.IO threads through the same object would corrupt
// each other's output.
private val ciphers = ThreadLocal.withInitial { Cipher.getInstance(TRANSFORMATION) }
private val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
private fun getKey(): SecretKey {
// The key handle never changes for the life of the alias, but fetching it
// is a round trip to the keystore daemon. Every encrypted store here reads
// and writes through this class, so doing that per operation put an IPC in
// front of every Marmot group-state write and every message appended.
@Volatile
private var cachedKey: SecretKey? = null
private fun getKey(): SecretKey =
cachedKey ?: synchronized(this) {
cachedKey ?: loadOrCreateKey().also { cachedKey = it }
}
private fun loadOrCreateKey(): SecretKey {
val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
return existingKey?.secretKey ?: createKey()
return (existingKey?.secretKey ?: createKey()).also { logSecurityLevel(it) }
}
/**
* Say once, per process, where this key actually lives.
*
* It decides the cost of everything encrypted at rest here, and it is not
* knowable from the code: [createKeyStrongBoxIfAvailable] is tried first, so
* a device with a secure element gets one, and `getEntry` then returns
* whatever that device created — possibly years ago, under different code.
*
* The difference is not small. A secure element runs AES-GCM at roughly
* 68 KB/s (1 MiB in ~15s on a Pixel 8), against tens of MB/s for the TEE.
* Anything bulk that shows up slow on one device and fine on another is
* explained by this line.
*/
private fun logSecurityLevel(key: SecretKey) {
try {
val factory = SecretKeyFactory.getInstance(key.algorithm, ANDROID_KEY_STORE)
val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo
val level =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
when (info.securityLevel) {
KeyProperties.SECURITY_LEVEL_STRONGBOX -> "STRONGBOX (bulk crypto here is ~68 KB/s)"
KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> "TEE"
KeyProperties.SECURITY_LEVEL_SOFTWARE -> "SOFTWARE"
else -> "UNKNOWN(${info.securityLevel})"
}
} else {
@Suppress("DEPRECATION")
if (info.isInsideSecureHardware) "SECURE_HARDWARE (TEE or StrongBox)" else "SOFTWARE"
}
Log.i(TAG) { "$KEY_ALIAS security level: $level" }
} catch (e: Exception) {
// Purely diagnostic — never let it interfere with having a key.
Log.d(TAG) { "Could not determine the security level of $KEY_ALIAS: ${e.message}" }
}
}
private fun createKeyStrongBoxIfAvailable(): SecretKey? =
@@ -95,11 +150,15 @@ class KeyStoreEncryption {
fun encrypt(bytes: ByteArray): ByteArray {
try {
// Initializes the cipher in encrypt mode and encrypts data
val cipher = ciphers.get()
cipher.init(Cipher.ENCRYPT_MODE, getKey())
val iv = cipher.iv
val encrypted = cipher.doFinal(bytes)
return iv + encrypted
} catch (e: Exception) {
// A key the system has retired (a wipe, a credential reset) keeps
// failing until it is re-read, so the cached handle goes with it.
cachedKey = null
Log.e(TAG, "encrypt() failed: ${e.message}", e)
throw e
}
@@ -112,9 +171,11 @@ class KeyStoreEncryption {
// IvParameterSpec), so we must pass the 128-bit auth tag length.
val iv = bytes.copyOfRange(0, GCM_IV_LENGTH)
val data = bytes.copyOfRange(GCM_IV_LENGTH, bytes.size)
val cipher = ciphers.get()
cipher.init(Cipher.DECRYPT_MODE, getKey(), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv))
return cipher.doFinal(data)
} catch (e: Exception) {
cachedKey = null
Log.e(TAG, "decrypt() failed (input ${bytes.size} bytes): ${e.message}", e)
throw e
}
@@ -20,13 +20,73 @@
*/
package com.vitorpamplona.amethyst.napplet
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_identity
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_identity_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_inc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_inc_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_keys
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_keys_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_notify
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_notify_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_relay
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_relay_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_resource
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_resource_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_signer
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_signer_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_storage
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_storage_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_theme
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_theme_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_upload
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_upload_desc
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_value
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_value_desc
import org.jetbrains.compose.resources.StringResource
/** Localized display name for a capability, shared by the consent dialog and the permissions screen. */
@StringRes
fun NappletCapability.labelRes(): Int =
fun NappletCapability.labelRes(): StringResource =
when (this) {
NappletCapability.IDENTITY -> Res.string.napplet_cap_identity
NappletCapability.KEYS -> Res.string.napplet_cap_keys
NappletCapability.RELAY -> Res.string.napplet_cap_relay
NappletCapability.SIGNER -> Res.string.napplet_cap_signer
NappletCapability.STORAGE -> Res.string.napplet_cap_storage
NappletCapability.VALUE -> Res.string.napplet_cap_value
NappletCapability.RESOURCE -> Res.string.napplet_cap_resource
NappletCapability.UPLOAD -> Res.string.napplet_cap_upload
NappletCapability.THEME -> Res.string.napplet_cap_theme
NappletCapability.NOTIFY -> Res.string.napplet_cap_notify
NappletCapability.INC -> Res.string.napplet_cap_inc
}
/** Localized one-line description of what a capability lets a napplet do. */
fun NappletCapability.descriptionRes(): StringResource =
when (this) {
NappletCapability.IDENTITY -> Res.string.napplet_cap_identity_desc
NappletCapability.KEYS -> Res.string.napplet_cap_keys_desc
NappletCapability.RELAY -> Res.string.napplet_cap_relay_desc
NappletCapability.SIGNER -> Res.string.napplet_cap_signer_desc
NappletCapability.STORAGE -> Res.string.napplet_cap_storage_desc
NappletCapability.VALUE -> Res.string.napplet_cap_value_desc
NappletCapability.RESOURCE -> Res.string.napplet_cap_resource_desc
NappletCapability.UPLOAD -> Res.string.napplet_cap_upload_desc
NappletCapability.THEME -> Res.string.napplet_cap_theme_desc
NappletCapability.NOTIFY -> Res.string.napplet_cap_notify_desc
NappletCapability.INC -> Res.string.napplet_cap_inc_desc
}
/**
* Android-resource twin of [labelRes], for [NappletLauncher].
*
* Launch parameters are minted from a plain onClick, which can call neither the
* composable nor the suspend accessor, and the sandbox process has no resources
* of its own - so the labels have to be resolved here and passed across.
*/
fun NappletCapability.labelResId(): Int =
when (this) {
NappletCapability.IDENTITY -> R.string.napplet_cap_identity
NappletCapability.KEYS -> R.string.napplet_cap_keys
@@ -40,20 +100,3 @@ fun NappletCapability.labelRes(): Int =
NappletCapability.NOTIFY -> R.string.napplet_cap_notify
NappletCapability.INC -> R.string.napplet_cap_inc
}
/** Localized one-line description of what a capability lets a napplet do. */
@StringRes
fun NappletCapability.descriptionRes(): Int =
when (this) {
NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc
NappletCapability.KEYS -> R.string.napplet_cap_keys_desc
NappletCapability.RELAY -> R.string.napplet_cap_relay_desc
NappletCapability.SIGNER -> R.string.napplet_cap_signer_desc
NappletCapability.STORAGE -> R.string.napplet_cap_storage_desc
NappletCapability.VALUE -> R.string.napplet_cap_value_desc
NappletCapability.RESOURCE -> R.string.napplet_cap_resource_desc
NappletCapability.UPLOAD -> R.string.napplet_cap_upload_desc
NappletCapability.THEME -> R.string.napplet_cap_theme_desc
NappletCapability.NOTIFY -> R.string.napplet_cap_notify_desc
NappletCapability.INC -> R.string.napplet_cap_inc_desc
}
@@ -21,22 +21,57 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_follow_added
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_follow_one
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_follow_removed
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_follows
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_joiner
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_mute_added
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_mute_one
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_mute_removed
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_mutes
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_no_baseline
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_none
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_relay_added
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_relay_removed
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_relays
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_unfollow_one
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_unmute_one
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_effect_deletes
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_effect_tags
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_get_pubkey
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_identity_read
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_nip44_decrypt
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_nip44_encrypt
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_notify
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_pay_amount
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_pay_no_amount
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_publish
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_publish_encrypted
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_publish_preview
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_query
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_resource
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_sign
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_storage
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_upload
import com.vitorpamplona.amethyst.commons.resources.napplet_fallback_title
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
import org.jetbrains.compose.resources.PluralStringResource
import org.jetbrains.compose.resources.StringResource
/**
* Turns a pending [NappletRequest] into the human-readable [NappletConsentInfo] the consent dialog
@@ -51,12 +86,12 @@ class NappletConsentSummary(
private val context: Context,
private val account: Account,
) {
fun info(
suspend fun info(
identity: NappletIdentity,
capability: NappletCapability,
request: NappletRequest,
): NappletConsentInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val untitled = loadStringRes(Res.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
@@ -68,7 +103,7 @@ class NappletConsentSummary(
return NappletConsentInfo(
appletTitle = title,
coordinate = identity.coordinate,
capabilityLabel = context.getString(capability.labelRes()),
capabilityLabel = loadStringRes(capability.labelRes()),
operationSummary = listOfNotNull(summaryFor(request).ifBlank { null }, consequence?.text).joinToString("\n\n"),
allowAlways = capability.canGrantAlways,
iconUrl = iconUrl,
@@ -118,14 +153,14 @@ class NappletConsentSummary(
* the dialog reads "publish a kind 3 event" while the user is actually about to replace their
* whole social graph — the summary would be technically true and practically useless.
*/
private fun consequenceFor(request: NappletRequest): Consequence? =
private suspend fun consequenceFor(request: NappletRequest): Consequence? =
when (request) {
is NappletRequest.Publish -> consequenceFor(request.kind, request.tags)
is NappletRequest.SignEvent -> consequenceFor(request.kind, request.tags)
else -> null
}
private fun consequenceFor(
private suspend fun consequenceFor(
kind: Int,
tags: Array<Array<String>>,
): Consequence? =
@@ -135,20 +170,20 @@ class NappletConsentSummary(
current = account.kind3FollowList.getFollowListEvent()?.tags,
proposed = tags,
tagName = "p",
template = R.string.napplet_consent_diff_follows,
added = R.plurals.napplet_consent_diff_follow_added,
removed = R.plurals.napplet_consent_diff_follow_removed,
oneAdded = R.string.napplet_consent_diff_follow_one,
oneRemoved = R.string.napplet_consent_diff_unfollow_one,
template = Res.string.napplet_consent_diff_follows,
added = Res.plurals.napplet_consent_diff_follow_added,
removed = Res.plurals.napplet_consent_diff_follow_removed,
oneAdded = Res.string.napplet_consent_diff_follow_one,
oneRemoved = Res.string.napplet_consent_diff_unfollow_one,
)
AdvertisedRelayListEvent.KIND ->
diffOf(
current = account.nip65RelayList.getNIP65RelayList()?.tags,
proposed = tags,
tagName = "r",
template = R.string.napplet_consent_diff_relays,
added = R.plurals.napplet_consent_diff_relay_added,
removed = R.plurals.napplet_consent_diff_relay_removed,
template = Res.string.napplet_consent_diff_relays,
added = Res.plurals.napplet_consent_diff_relay_added,
removed = Res.plurals.napplet_consent_diff_relay_removed,
)
// Public entries only: a mute list also carries encrypted ones, which are not in `tags`
// and so cannot be diffed here.
@@ -157,21 +192,21 @@ class NappletConsentSummary(
current = account.muteList.getMuteList()?.tags,
proposed = tags,
tagName = "p",
template = R.string.napplet_consent_diff_mutes,
added = R.plurals.napplet_consent_diff_mute_added,
removed = R.plurals.napplet_consent_diff_mute_removed,
oneAdded = R.string.napplet_consent_diff_mute_one,
oneRemoved = R.string.napplet_consent_diff_unmute_one,
template = Res.string.napplet_consent_diff_mutes,
added = Res.plurals.napplet_consent_diff_mute_added,
removed = Res.plurals.napplet_consent_diff_mute_removed,
oneAdded = Res.string.napplet_consent_diff_mute_one,
oneRemoved = Res.string.napplet_consent_diff_unmute_one,
)
// Deletions have no prior version to compare against — the tags are the whole request.
DeletionEvent.KIND ->
pluralFor(R.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
pluralFor(Res.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
?.let { Consequence(it) }
// Any other kind: at least tell the user tags exist and can be inspected, so an empty
// content preview never reads as "there is nothing else here".
else ->
if (tags.isNotEmpty()) {
pluralFor(R.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) }
pluralFor(Res.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) }
} else {
null
}
@@ -183,45 +218,45 @@ class NappletConsentSummary(
* that silently drops 130 follows, and only a diff surfaces that. Falls back to the total when
* nothing is cached to compare against.
*/
private fun diffOf(
private suspend fun diffOf(
current: Array<Array<String>>?,
proposed: Array<Array<String>>,
tagName: String,
@StringRes template: Int,
@PluralsRes added: Int,
@PluralsRes removed: Int,
@StringRes oneAdded: Int? = null,
@StringRes oneRemoved: Int? = null,
template: StringResource,
added: PluralStringResource,
removed: PluralStringResource,
oneAdded: StringResource? = null,
oneRemoved: StringResource? = null,
): Consequence {
val next = valuesOf(proposed, tagName)
val previous =
current?.let { valuesOf(it, tagName) }
?: return Consequence(pluralStringRes(context, R.plurals.napplet_consent_diff_no_baseline, next.size, next.size))
?: return Consequence(loadPluralStringRes(Res.plurals.napplet_consent_diff_no_baseline, next.size, next.size))
val addedKeys = next.filter { it !in previous }
val removedKeys = previous.filter { it !in next }
if (addedKeys.isEmpty() && removedKeys.isEmpty()) {
return Consequence(context.getString(R.string.napplet_consent_diff_none))
return Consequence(loadStringRes(Res.string.napplet_consent_diff_none))
}
// The overwhelmingly common edit is a single follow/unfollow. Naming and picturing that one
// account is far more use than "follows 1 new account" — the user can tell at a glance
// whether it is who they expected.
if (oneAdded != null && addedKeys.size == 1 && removedKeys.isEmpty()) {
subjectOf(addedKeys.first())?.let { return Consequence(context.getString(oneAdded, it.name), it) }
subjectOf(addedKeys.first())?.let { return Consequence(loadStringRes(oneAdded, it.name), it) }
}
if (oneRemoved != null && removedKeys.size == 1 && addedKeys.isEmpty()) {
subjectOf(removedKeys.first())?.let { return Consequence(context.getString(oneRemoved, it.name), it) }
subjectOf(removedKeys.first())?.let { return Consequence(loadStringRes(oneRemoved, it.name), it) }
}
val parts = listOfNotNull(pluralFor(added, addedKeys.size), pluralFor(removed, removedKeys.size))
val summary =
if (parts.size == 2) {
context.getString(R.string.napplet_consent_diff_joiner, parts[0], parts[1])
loadStringRes(Res.string.napplet_consent_diff_joiner, parts[0], parts[1])
} else {
parts.first()
}
return Consequence(context.getString(template, summary))
return Consequence(loadStringRes(template, summary))
}
/** Resolves a pubkey to a name + picture for the dialog, or null when the user isn't cached. */
@@ -244,67 +279,67 @@ class NappletConsentSummary(
return out
}
private fun pluralFor(
resId: Int,
private suspend fun pluralFor(
resId: PluralStringResource,
count: Int,
): String? = if (count <= 0) null else pluralStringRes(context, resId, count, count)
): String? = if (count <= 0) null else loadPluralStringRes(resId, count, count)
private fun countTag(
tags: Array<Array<String>>,
name: String,
): Int = tags.count { it.isNotEmpty() && it[0] == name }
private fun summaryFor(request: NappletRequest): String =
private suspend fun summaryFor(request: NappletRequest): String =
when (request) {
is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey)
is NappletRequest.IdentityRead -> context.getString(R.string.napplet_consent_identity_read)
is NappletRequest.GetPublicKey -> loadStringRes(Res.string.napplet_consent_get_pubkey)
is NappletRequest.IdentityRead -> loadStringRes(Res.string.napplet_consent_identity_read)
is NappletRequest.Publish -> {
val preview = request.content.take(160).trim()
if (preview.isEmpty()) {
context.getString(R.string.napplet_consent_publish, request.kind)
loadStringRes(Res.string.napplet_consent_publish, request.kind)
} else {
context.getString(R.string.napplet_consent_publish_preview, request.kind) + "\n“$preview”"
loadStringRes(Res.string.napplet_consent_publish_preview, request.kind) + "\n“$preview”"
}
}
is NappletRequest.SignEvent -> {
val preview = request.content.take(160).trim()
if (preview.isEmpty()) {
context.getString(R.string.napplet_consent_sign, request.kind)
loadStringRes(Res.string.napplet_consent_sign, request.kind)
} else {
context.getString(R.string.napplet_consent_sign, request.kind) + "\n“$preview”"
loadStringRes(Res.string.napplet_consent_sign, request.kind) + "\n“$preview”"
}
}
is NappletRequest.PublishEncrypted -> context.getString(R.string.napplet_consent_publish_encrypted)
is NappletRequest.PublishEncrypted -> loadStringRes(Res.string.napplet_consent_publish_encrypted)
is NappletRequest.Nip44Encrypt -> {
val preview = request.plaintext.take(160).trim()
val summary = context.getString(R.string.napplet_consent_nip44_encrypt, counterpartyLabel(request.peer))
val summary = loadStringRes(Res.string.napplet_consent_nip44_encrypt, counterpartyLabel(request.peer))
if (preview.isEmpty()) summary else summary + "\n\u201C$preview\u201D"
}
// The ciphertext is meaningless to show, so name the counterparty instead — that is
// the decision the user is actually making ("let this page read messages from X").
is NappletRequest.Nip44Decrypt -> context.getString(R.string.napplet_consent_nip44_decrypt, counterpartyLabel(request.peer))
is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> context.getString(R.string.napplet_consent_query)
is NappletRequest.Nip44Decrypt -> loadStringRes(Res.string.napplet_consent_nip44_decrypt, counterpartyLabel(request.peer))
is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> loadStringRes(Res.string.napplet_consent_query)
is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove, is NappletRequest.StorageKeys ->
context.getString(R.string.napplet_consent_storage)
loadStringRes(Res.string.napplet_consent_storage)
is NappletRequest.NotifyCreate -> {
val preview = request.title.take(80).trim()
if (preview.isEmpty()) context.getString(R.string.napplet_consent_notify) else context.getString(R.string.napplet_consent_notify) + "\n“$preview”"
if (preview.isEmpty()) loadStringRes(Res.string.napplet_consent_notify) else loadStringRes(Res.string.napplet_consent_notify) + "\n“$preview”"
}
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> context.getString(R.string.napplet_consent_notify)
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> loadStringRes(Res.string.napplet_consent_notify)
is NappletRequest.PayInvoice -> {
// getAmountInSats returns ZERO (not null, not a throw) for an amountless BOLT11, so a
// naive read renders "pay 0 sats" — telling the user a payment is free when the amount
// is in fact unspecified and decided by the payee. Treat non-positive as "no amount".
val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull()
if (sats != null && sats > 0) {
pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
loadPluralStringRes(Res.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
} else {
context.getString(R.string.napplet_consent_pay_no_amount)
loadStringRes(Res.string.napplet_consent_pay_no_amount)
}
}
NappletRequest.ResourceInfo, is NappletRequest.ResourceBytes, is NappletRequest.ResourceBytesMany ->
context.getString(R.string.napplet_consent_resource)
is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload)
loadStringRes(Res.string.napplet_consent_resource)
is NappletRequest.UploadBlob -> loadStringRes(Res.string.napplet_consent_upload)
// Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown.
is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
}
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.model.ThemeType
import com.vitorpamplona.amethyst.napplethost.HostProfile
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.crypto.verify
@@ -176,7 +177,7 @@ object NappletLauncher {
val useTor = if (profile.exposesNetwork) NappletNetworkRegistry.useTor(identity.coordinate) else true
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
val capLabels = declared.map { context.getString(it.labelRes()) }
val capLabels = declared.map { stringRes(context, it.labelResId()) }
val themeType = Amethyst.instance.uiPrefs.value.theme.value
val theme =
@@ -21,7 +21,6 @@
package com.vitorpamplona.amethyst.napplet
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
@@ -30,6 +29,14 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
import com.vitorpamplona.amethyst.commons.napplet.protocol.counterpartyPubKey
import com.vitorpamplona.amethyst.commons.napplet.protocol.toNarrowSignerOp
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_fallback_title
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt_from
import com.vitorpamplona.amethyst.commons.resources.napplet_op_encrypt
import com.vitorpamplona.amethyst.commons.resources.napplet_op_sign_kind_named
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_for
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
@@ -42,12 +49,12 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
import com.vitorpamplona.quartz.utils.TimeUtils
/** Human-readable label for a [NostrSignerOp]. */
fun NostrSignerOp.label(context: Context): String =
suspend fun NostrSignerOp.label(context: Context): String =
when (this) {
is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind)
NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt)
NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt)
is NostrSignerOp.DecryptFrom -> context.getString(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
is NostrSignerOp.SignKind -> loadStringRes(Res.string.napplet_op_sign_kind_named, kindNameFor(kind), kind)
NostrSignerOp.Encrypt -> loadStringRes(Res.string.napplet_op_encrypt)
NostrSignerOp.Decrypt -> loadStringRes(Res.string.napplet_op_decrypt)
is NostrSignerOp.DecryptFrom -> loadStringRes(Res.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
}
/**
@@ -66,13 +73,13 @@ fun counterpartyLabel(pubKeyHex: HexKey): String {
}
/** Builds the [SignerConsentInfo] needed by the per-op consent dialog. */
fun buildSignerConsentInfo(
suspend fun buildSignerConsentInfo(
context: Context,
identity: NappletIdentity,
op: NostrSignerOp,
request: NappletRequest,
): SignerConsentInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val untitled = loadStringRes(Res.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
@@ -156,7 +163,7 @@ fun buildSignerConsentInfo(
// are present, so deriving them from one value keeps them from disagreeing.
narrowOpLabel =
(narrowOp as? NostrSignerOp.DecryptFrom)?.let {
context.getString(R.string.nip46_signer_allow_always_for, counterpartyLabel(it.counterparty))
loadStringRes(Res.string.nip46_signer_allow_always_for, counterpartyLabel(it.counterparty))
},
)
}
@@ -167,12 +174,12 @@ fun buildSignerConsentInfo(
* [SignerConnectInfo.requestedPermissions] — otherwise the dialog would be asking the user to
* approve a set it never showed them.
*/
fun buildConnectInfo(
suspend fun buildConnectInfo(
context: Context,
identity: NappletIdentity,
declared: Set<NappletCapability> = emptySet(),
): SignerConnectInfo {
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
val untitled = loadStringRes(Res.string.napplet_fallback_title, identity.authorPubKey.take(8))
val (title, iconUrl) =
if (identity.authorPubKey == "browser") {
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
@@ -191,7 +198,7 @@ fun buildConnectInfo(
val preGranted =
declared
.filter { it.requiresConsent && !it.requiresPerUseConsent }
.map { context.getString(it.labelRes()) }
.map { loadStringRes(it.labelRes()) }
.sorted()
return SignerConnectInfo(
appletTitle = title,
@@ -46,6 +46,9 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway
import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult
import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_notify
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
import com.vitorpamplona.amethyst.model.Account
@@ -168,7 +171,7 @@ class AccountNappletGateways(
* grant just no-ops). The in-app registry in [NappletNotificationStore] is the source of truth for
* the napplet's own `notify.list`/`dismiss`; this is the user-visible surface.
*/
private fun postSystemNotification(
private suspend fun postSystemNotification(
id: String,
title: String,
body: String,
@@ -179,14 +182,14 @@ class AccountNappletGateways(
val channel =
NotificationChannelCompat
.Builder(NOTIFY_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
.setName(context.getString(R.string.napplet_cap_notify))
.setName(loadStringRes(Res.string.napplet_cap_notify))
.build()
manager.createNotificationChannel(channel)
val notification =
NotificationCompat
.Builder(context, NOTIFY_CHANNEL_ID)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(title.ifBlank { context.getString(R.string.napplet_cap_notify) })
.setContentTitle(title.ifBlank { loadStringRes(Res.string.napplet_cap_notify) })
.setContentText(body)
.setAutoCancel(true)
.build()
@@ -20,41 +20,71 @@
*/
package com.vitorpamplona.amethyst.service
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.http_status_400
import com.vitorpamplona.amethyst.commons.resources.http_status_401
import com.vitorpamplona.amethyst.commons.resources.http_status_402
import com.vitorpamplona.amethyst.commons.resources.http_status_403
import com.vitorpamplona.amethyst.commons.resources.http_status_404
import com.vitorpamplona.amethyst.commons.resources.http_status_405
import com.vitorpamplona.amethyst.commons.resources.http_status_406
import com.vitorpamplona.amethyst.commons.resources.http_status_407
import com.vitorpamplona.amethyst.commons.resources.http_status_408
import com.vitorpamplona.amethyst.commons.resources.http_status_409
import com.vitorpamplona.amethyst.commons.resources.http_status_410
import com.vitorpamplona.amethyst.commons.resources.http_status_411
import com.vitorpamplona.amethyst.commons.resources.http_status_412
import com.vitorpamplona.amethyst.commons.resources.http_status_413
import com.vitorpamplona.amethyst.commons.resources.http_status_414
import com.vitorpamplona.amethyst.commons.resources.http_status_415
import com.vitorpamplona.amethyst.commons.resources.http_status_416
import com.vitorpamplona.amethyst.commons.resources.http_status_417
import com.vitorpamplona.amethyst.commons.resources.http_status_426
import com.vitorpamplona.amethyst.commons.resources.http_status_500
import com.vitorpamplona.amethyst.commons.resources.http_status_501
import com.vitorpamplona.amethyst.commons.resources.http_status_502
import com.vitorpamplona.amethyst.commons.resources.http_status_503
import com.vitorpamplona.amethyst.commons.resources.http_status_504
import com.vitorpamplona.amethyst.commons.resources.http_status_505
import com.vitorpamplona.amethyst.commons.resources.http_status_506
import com.vitorpamplona.amethyst.commons.resources.http_status_507
import com.vitorpamplona.amethyst.commons.resources.http_status_508
import com.vitorpamplona.amethyst.commons.resources.http_status_511
import org.jetbrains.compose.resources.StringResource
class HttpStatusMessages {
companion object {
fun resourceIdFor(statusCode: Int = 0): Int? =
fun resourceIdFor(statusCode: Int = 0): StringResource? =
when (statusCode) {
400 -> R.string.http_status_400
401 -> R.string.http_status_401
402 -> R.string.http_status_402
403 -> R.string.http_status_403
404 -> R.string.http_status_404
405 -> R.string.http_status_405
406 -> R.string.http_status_406
407 -> R.string.http_status_407
408 -> R.string.http_status_408
409 -> R.string.http_status_409
410 -> R.string.http_status_410
411 -> R.string.http_status_411
412 -> R.string.http_status_412
413 -> R.string.http_status_413
414 -> R.string.http_status_414
415 -> R.string.http_status_415
416 -> R.string.http_status_416
417 -> R.string.http_status_417
426 -> R.string.http_status_426
500 -> R.string.http_status_500
501 -> R.string.http_status_501
502 -> R.string.http_status_502
503 -> R.string.http_status_503
504 -> R.string.http_status_504
505 -> R.string.http_status_505
506 -> R.string.http_status_506
507 -> R.string.http_status_507
508 -> R.string.http_status_508
511 -> R.string.http_status_511
400 -> Res.string.http_status_400
401 -> Res.string.http_status_401
402 -> Res.string.http_status_402
403 -> Res.string.http_status_403
404 -> Res.string.http_status_404
405 -> Res.string.http_status_405
406 -> Res.string.http_status_406
407 -> Res.string.http_status_407
408 -> Res.string.http_status_408
409 -> Res.string.http_status_409
410 -> Res.string.http_status_410
411 -> Res.string.http_status_411
412 -> Res.string.http_status_412
413 -> Res.string.http_status_413
414 -> Res.string.http_status_414
415 -> Res.string.http_status_415
416 -> Res.string.http_status_416
417 -> Res.string.http_status_417
426 -> Res.string.http_status_426
500 -> Res.string.http_status_500
501 -> Res.string.http_status_501
502 -> Res.string.http_status_502
503 -> Res.string.http_status_503
504 -> Res.string.http_status_504
505 -> Res.string.http_status_505
506 -> Res.string.http_status_506
507 -> Res.string.http_status_507
508 -> Res.string.http_status_508
511 -> Res.string.http_status_511
else -> null
}
}
@@ -21,14 +21,21 @@
package com.vitorpamplona.amethyst.service
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.clink_debit_no_response
import com.vitorpamplona.amethyst.commons.resources.error_dialog_pay_invoice_error
import com.vitorpamplona.amethyst.commons.resources.error_parsing_error_message
import com.vitorpamplona.amethyst.commons.resources.error_unable_to_fetch_invoice
import com.vitorpamplona.amethyst.commons.resources.podcast_value_error_title
import com.vitorpamplona.amethyst.commons.resources.podcast_value_keysend_requires_nwc
import com.vitorpamplona.amethyst.commons.resources.podcast_value_no_recipients
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
@@ -41,6 +48,7 @@ import com.vitorpamplona.quartz.podcasts.PodcastValueShare
import com.vitorpamplona.quartz.utils.mapNotNullAsync
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
@@ -88,8 +96,8 @@ class V4VPaymentHandler(
val shares = value.computeShares(totalMilliSats)
if (shares.isEmpty()) {
onError(
stringRes(context, R.string.podcast_value_error_title),
stringRes(context, R.string.podcast_value_no_recipients),
loadStringRes(Res.string.podcast_value_error_title),
loadStringRes(Res.string.podcast_value_no_recipients),
)
return@withContext
}
@@ -105,8 +113,8 @@ class V4VPaymentHandler(
payNodeSharesViaKeysend(nodeShares, boostagram, context, onError)
} else {
onError(
stringRes(context, R.string.podcast_value_error_title),
stringRes(context, R.string.podcast_value_keysend_requires_nwc),
loadStringRes(Res.string.podcast_value_error_title),
loadStringRes(Res.string.podcast_value_keysend_requires_nwc),
)
}
}
@@ -133,7 +141,7 @@ class V4VPaymentHandler(
}
/** Hex-encodes a TLV value string as NIP-47 `pay_keysend` requires (UTF-8 bytes → hex). */
private fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey()
private suspend fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey()
private suspend fun payNodeSharesViaKeysend(
shares: List<PodcastValueShare>,
@@ -163,12 +171,16 @@ class V4VPaymentHandler(
account.zaps.sendNwcRequest(
request = request,
onResponse = { response: Response? ->
response.nwcFailureDetail(context)?.let { detail ->
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), detail)
account.scope.launch {
response.nwcFailureDetail()?.let { detail ->
onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), detail)
}
}
},
onTimeout = {
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context))
account.scope.launch {
onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), nwcTimeoutMessage())
}
},
)
}
@@ -231,8 +243,8 @@ class V4VPaymentHandler(
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
e.message ?: stringRes(context, R.string.error_parsing_error_message),
loadStringRes(Res.string.error_unable_to_fetch_invoice),
e.message ?: loadStringRes(Res.string.error_parsing_error_message),
)
null
}
@@ -257,12 +269,16 @@ class V4VPaymentHandler(
bolt11 = payable.invoice,
zappedNote = zappedNote,
onResponse = { response ->
response.nwcFailureDetail(context)?.let { detail ->
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), detail)
account.scope.launch {
response.nwcFailureDetail()?.let { detail ->
onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), detail)
}
}
},
onTimeout = {
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context))
account.scope.launch {
onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), nwcTimeoutMessage())
}
},
)
done++
@@ -276,9 +292,9 @@ class V4VPaymentHandler(
val response = ClinkDebitPayer.payInvoice(account, source.wallet.pointer, payable.invoice)
if (response?.isOk() != true) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
loadStringRes(Res.string.error_dialog_pay_invoice_error),
response?.failureDetail()
?: stringRes(context, R.string.clink_debit_no_response),
?: loadStringRes(Res.string.clink_debit_no_response),
)
}
done++
@@ -22,16 +22,27 @@ package com.vitorpamplona.amethyst.service
import android.content.Context
import androidx.compose.runtime.Immutable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.bolt12_payment_failed
import com.vitorpamplona.amethyst.commons.resources.bolt12_zap_error
import com.vitorpamplona.amethyst.commons.resources.clink_debit_no_response
import com.vitorpamplona.amethyst.commons.resources.error_dialog_pay_invoice_error
import com.vitorpamplona.amethyst.commons.resources.error_dialog_zap_error
import com.vitorpamplona.amethyst.commons.resources.error_unable_to_fetch_invoice
import com.vitorpamplona.amethyst.commons.resources.missing_lud16
import com.vitorpamplona.amethyst.commons.resources.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error
import com.vitorpamplona.amethyst.commons.resources.user_does_not_have_a_lightning_address_setup_to_receive_sats
import com.vitorpamplona.amethyst.commons.resources.user_x_does_not_have_a_lightning_address_setup_to_receive_sats
import com.vitorpamplona.amethyst.commons.resources.wallet_connect_pay_invoice_error_error
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
@@ -53,6 +64,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import org.jetbrains.compose.resources.StringResource
import java.util.concurrent.atomic.AtomicInteger
import kotlin.math.round
@@ -210,17 +222,16 @@ class ZapPaymentHandler(
errors.forEach {
val message =
if (it.user != null) {
stringRes(
context,
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
loadStringRes(
Res.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
it.user.toBestDisplayName(),
)
} else {
stringRes(context, R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)
loadStringRes(Res.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)
}
onError(
stringRes(context, R.string.missing_lud16),
loadStringRes(Res.string.missing_lud16),
message,
it.user,
)
@@ -337,7 +348,7 @@ class ZapPaymentHandler(
}
}
private fun calculateZapValue(
private suspend fun calculateZapValue(
amountMilliSats: Long,
weight: Double,
totalWeight: Double,
@@ -430,13 +441,11 @@ class ZapPaymentHandler(
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(
stringRes(
context,
R.string.error_unable_to_fetch_invoice,
loadStringRes(
Res.string.error_unable_to_fetch_invoice,
),
stringRes(
context,
R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
loadStringRes(
Res.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
e.message,
),
null,
@@ -474,21 +483,25 @@ class ZapPaymentHandler(
comment = payable.message,
),
onResponse = { response ->
progress.step()
response.nwcFailureDetail(context)?.let { detail ->
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
stringRes(context, R.string.wallet_connect_pay_invoice_error_error, detail),
payable.info.user,
)
account.scope.launch {
progress.step()
response.nwcFailureDetail()?.let { detail ->
onError(
loadStringRes(Res.string.error_dialog_pay_invoice_error),
loadStringRes(Res.string.wallet_connect_pay_invoice_error_error, detail),
payable.info.user,
)
}
}
},
onTimeout = {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
nwcTimeoutMessage(context),
payable.info.user,
)
account.scope.launch {
onError(
loadStringRes(Res.string.error_dialog_pay_invoice_error),
nwcTimeoutMessage(),
payable.info.user,
)
}
},
)
@@ -532,12 +545,12 @@ class ZapPaymentHandler(
val progress = PaymentProgress(recipients.size, onProgress)
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
fun reportBolt12Error(
msgRes: Int,
suspend fun reportBolt12Error(
msgRes: StringResource,
detail: String?,
) {
val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes)
onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user)
val msg = if (detail != null) loadStringRes(msgRes, detail) else loadStringRes(msgRes)
onError(loadStringRes(Res.string.bolt12_zap_error), msg, recipient.user)
}
account.zaps.sendBolt12Zap(
@@ -547,7 +560,7 @@ class ZapPaymentHandler(
amountMillisats = calculateZapValue(totalAmountMilliSats, recipient.weight, totalWeight),
message = message,
zapType = zapType,
onError = ::reportBolt12Error,
onError = { msgRes, detail -> account.scope.launch { reportBolt12Error(msgRes, detail) } },
onNotPaid = { code, detail ->
val lnAddress = recipient.lnAddress
if (lnAddress != null && Bolt12LightningFallback.shouldRetry(code)) {
@@ -575,18 +588,20 @@ class ZapPaymentHandler(
// Nothing was paid on either rail. Report it as the lightning failure it
// is, rather than letting [sendBolt12Zap]'s catch call it "paid, no receipt".
Log.w("ZapPaymentHandler", "BOLT11 fallback failed after a refused BOLT12 offer", e)
onError(stringRes(context, R.string.error_dialog_zap_error), e.message ?: e.toString(), recipient.user)
onError(loadStringRes(Res.string.error_dialog_zap_error), e.message ?: e.toString(), recipient.user)
}
} else {
// bolt12_payment_failed always formats a detail; a wallet may send neither
// message nor a recognised code.
reportBolt12Error(R.string.bolt12_payment_failed, detail ?: (code ?: NwcErrorCode.OTHER).name)
reportBolt12Error(Res.string.bolt12_payment_failed, detail ?: (code ?: NwcErrorCode.OTHER).name)
}
},
onTimeout = {
// No response callback will fire, so account for the settlement step here.
reportBolt12Error(R.string.bolt12_payment_failed, nwcTimeoutMessage(context))
progress.step()
account.scope.launch {
// No response callback will fire, so account for the settlement step here.
reportBolt12Error(Res.string.bolt12_payment_failed, nwcTimeoutMessage())
progress.step()
}
},
onProcessed = { progress.step() },
)
@@ -639,9 +654,9 @@ class ZapPaymentHandler(
progress.step()
if (response?.isOk() != true) {
onError(
stringRes(context, R.string.error_dialog_pay_invoice_error),
loadStringRes(Res.string.error_dialog_pay_invoice_error),
response?.failureDetail()
?: stringRes(context, R.string.clink_debit_no_response),
?: loadStringRes(Res.string.clink_debit_no_response),
payable.info.user,
)
}
@@ -29,8 +29,12 @@ import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_channel_description
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_channel_id
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_channel_name
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Posts user-visible "starting soon" notifications for NIP-52 appointments the user has RSVP'd
@@ -52,7 +56,7 @@ object CalendarReminderNotifier {
* calendar detail screen (CalendarTimeSlotEvent / CalendarDateSlotEvent
* branches in RouteMaker resolve to Route.CalendarEventDetail).
*/
fun notifyReminder(
suspend fun notifyReminder(
context: Context,
eventId: String,
title: String,
@@ -61,7 +65,7 @@ object CalendarReminderNotifier {
) {
ensureChannel(context)
val notId = idFor(eventId)
val channelId = stringRes(context, R.string.calendar_reminder_channel_id)
val channelId = loadStringRes(Res.string.calendar_reminder_channel_id)
val tapIntent =
Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
@@ -100,15 +104,15 @@ object CalendarReminderNotifier {
}
}
fun ensureChannel(context: Context) {
suspend fun ensureChannel(context: Context) {
if (channel != null) return
channel =
NotificationChannel(
stringRes(context, R.string.calendar_reminder_channel_id),
stringRes(context, R.string.calendar_reminder_channel_name),
loadStringRes(Res.string.calendar_reminder_channel_id),
loadStringRes(Res.string.calendar_reminder_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = stringRes(context, R.string.calendar_reminder_channel_description)
description = loadStringRes(Res.string.calendar_reminder_channel_description)
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel!!)
@@ -27,12 +27,14 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_body
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_default_title
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
@@ -98,12 +100,11 @@ class CalendarReminderWorker(
// enters the lead window — the old notification stays valid in the system tray.
if (store.wasNotified(eventId, start)) return@forEach
val title = view.title ?: stringRes(applicationContext, R.string.calendar_reminder_default_title)
val title = view.title ?: loadStringRes(Res.string.calendar_reminder_default_title)
val minutesAway = ((start - now).coerceAtLeast(0L) / 60L).toInt()
val body =
pluralStringRes(
applicationContext,
R.plurals.calendar_reminder_body,
loadPluralStringRes(
Res.plurals.calendar_reminder_body,
minutesAway,
minutesAway,
)
@@ -33,9 +33,15 @@ import coil3.request.ImageRequest
import coil3.request.allowHardware
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_calls_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_calls_channel_name
import com.vitorpamplona.amethyst.commons.resources.call_accept
import com.vitorpamplona.amethyst.commons.resources.call_incoming
import com.vitorpamplona.amethyst.commons.resources.call_reject
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.call.CallNotificationReceiver
import com.vitorpamplona.amethyst.ui.call.CallActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip19Bech32.toNpub
import kotlinx.coroutines.Dispatchers
@@ -63,16 +69,16 @@ object CallNotifier {
const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
private const val CALL_NOTIFICATION_ID = 0x50000
fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
suspend fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
callChannel?.let { return it }
val channel =
NotificationChannel(
CALL_CHANNEL_ID,
stringRes(applicationContext, R.string.app_notification_calls_channel_name),
loadStringRes(Res.string.app_notification_calls_channel_name),
NotificationManager.IMPORTANCE_HIGH,
).apply {
description = stringRes(applicationContext, R.string.app_notification_calls_channel_description)
description = loadStringRes(Res.string.app_notification_calls_channel_description)
// Silence the notification sound — CallAudioManager plays the ringtone
setSound(null, null)
enableVibration(false)
@@ -91,7 +97,7 @@ object CallNotifier {
* full-screen intent that opens [CallActivity] when the device is
* unlocked, and provides Accept / Reject actions.
*/
fun send(
suspend fun send(
callerName: String,
callerBitmap: Bitmap?,
applicationContext: Context,
@@ -148,7 +154,7 @@ object CallNotifier {
NotificationCompat
.Builder(applicationContext, channel.id)
.setSmallIcon(R.drawable.amethyst)
.setContentTitle(stringRes(applicationContext, R.string.call_incoming))
.setContentTitle(loadStringRes(Res.string.call_incoming))
.setContentText(callerName)
.setLargeIcon(callerBitmap)
.setContentIntent(contentPendingIntent)
@@ -159,8 +165,8 @@ object CallNotifier {
.setOngoing(true)
.setTimeoutAfter(60_000)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), rejectPendingIntent)
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), acceptPendingIntent)
.addAction(R.drawable.amethyst, loadStringRes(Res.string.call_reject), rejectPendingIntent)
.addAction(R.drawable.amethyst, loadStringRes(Res.string.call_accept), acceptPendingIntent)
notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build())
}
@@ -21,10 +21,14 @@
package com.vitorpamplona.amethyst.service.cashu.melt
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.cashu.melt.MeltResult
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cashu_failed_redemption
import com.vitorpamplona.amethyst.commons.resources.cashu_failed_redemption_explainer_error_msg
import com.vitorpamplona.amethyst.commons.resources.cashu_unsafe_mint_url
import com.vitorpamplona.amethyst.commons.resources.cashu_unsafe_mint_url_explainer
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintUrlException
@@ -85,10 +89,9 @@ class MeltProcessor {
val sendable = token.totalAmount - fees
if (sendable <= 0) {
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
stringRes(
context,
R.string.cashu_failed_redemption_explainer_error_msg,
loadStringRes(Res.string.cashu_failed_redemption),
loadStringRes(
Res.string.cashu_failed_redemption_explainer_error_msg,
"Token value ${token.totalAmount} does not cover fees $fees",
),
)
@@ -121,13 +124,13 @@ class MeltProcessor {
// message, not the mint's, so don't dress it up as "the mint said".
if (e is MintUrlException) {
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_unsafe_mint_url),
stringRes(context, R.string.cashu_unsafe_mint_url_explainer, e.message),
loadStringRes(Res.string.cashu_unsafe_mint_url),
loadStringRes(Res.string.cashu_unsafe_mint_url_explainer, e.message),
)
}
throw LightningAddressResolver.LightningAddressError(
stringRes(context, R.string.cashu_failed_redemption),
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
loadStringRes(Res.string.cashu_failed_redemption),
loadStringRes(Res.string.cashu_failed_redemption_explainer_error_msg, e.message),
)
}
}
@@ -34,11 +34,11 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.crashreport_found
import com.vitorpamplona.amethyst.commons.resources.crashreport_found_send
import com.vitorpamplona.amethyst.commons.resources.would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared
@@ -76,7 +76,7 @@ fun DisplayCrashMessages(
},
dismissButton = {
TextButton(onClick = { stackTrace.value = null }) {
Text(stringRes(R.string.cancel))
Text(stringRes(Res.string.cancel))
}
},
confirmButton = {
@@ -22,9 +22,20 @@ package com.vitorpamplona.amethyst.service.lnurl
import android.content.Context
import com.fasterxml.jackson.databind.ObjectMapper
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user
import com.vitorpamplona.amethyst.commons.resources.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup
import com.vitorpamplona.amethyst.commons.resources.could_not_fetch_invoice_from_details
import com.vitorpamplona.amethyst.commons.resources.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception
import com.vitorpamplona.amethyst.commons.resources.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user
import com.vitorpamplona.amethyst.commons.resources.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user
import com.vitorpamplona.amethyst.commons.resources.error_unable_to_fetch_invoice
import com.vitorpamplona.amethyst.commons.resources.incorrect_invoice_amount_sats_from_it_should_have_been
import com.vitorpamplona.amethyst.commons.resources.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct
import com.vitorpamplona.amethyst.commons.resources.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user
import com.vitorpamplona.amethyst.commons.resources.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
import com.vitorpamplona.quartz.lightning.Lud06
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
@@ -69,8 +80,8 @@ class LightningAddressResolver {
): String {
val url =
assembleUrl(lnAddress) ?: throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(context, R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, lnAddress),
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(Res.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, lnAddress),
)
val client = okHttpClient(url)
@@ -88,11 +99,9 @@ class LightningAddressResolver {
response.body.string()
} else {
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
url,
lnAddress,
errorMessage(response, context),
@@ -104,11 +113,9 @@ class LightningAddressResolver {
} catch (e: Exception) {
if (e is CancellationException) throw e
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
url,
lnAddress,
e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message,
@@ -151,15 +158,15 @@ class LightningAddressResolver {
response.body.string()
} else {
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(response, context)),
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(Res.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(response, context)),
)
}
}
}
}
fun errorMessage(
suspend fun errorMessage(
response: Response,
context: Context,
): String {
@@ -196,7 +203,7 @@ class LightningAddressResolver {
}
return errorMessage
?: HttpStatusMessages.resourceIdFor(response.code)?.let { stringRes(context, it) }
?: HttpStatusMessages.resourceIdFor(response.code)?.let { loadStringRes(it) }
?: response.message.ifBlank { null }
?: response.code.toString()
}
@@ -237,10 +244,9 @@ class LightningAddressResolver {
} catch (t: Throwable) {
if (t is CancellationException) throw t
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
lnAddress,
),
)
@@ -250,10 +256,9 @@ class LightningAddressResolver {
if (callbackUrl == null) {
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
lnAddress,
),
)
@@ -297,11 +302,9 @@ class LightningAddressResolver {
} catch (t: Throwable) {
if (t is CancellationException) throw t
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
lnAddress,
),
)
@@ -315,22 +318,18 @@ class LightningAddressResolver {
if (reason != null) {
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
lnAddress,
reason,
),
)
} else {
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
lnAddress,
),
)
@@ -346,10 +345,9 @@ class LightningAddressResolver {
if (invoiceAmount.toLong() != expectedAmountInSats) {
onProgress(0.0f)
throw LightningAddressError(
stringRes(context, R.string.error_unable_to_fetch_invoice),
stringRes(
context,
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
loadStringRes(Res.string.error_unable_to_fetch_invoice),
loadStringRes(
Res.string.incorrect_invoice_amount_sats_from_it_should_have_been,
invoiceAmount.toLong().toString(),
lnAddress,
expectedAmountInSats.toString(),
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusBus
import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusState
import com.vitorpamplona.amethyst.commons.viewmodels.NestNetworkChangeBus
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
/**
@@ -389,19 +390,19 @@ class NestForegroundService : Service() {
val title =
if (promoted) {
getString(R.string.nest_notification_broadcasting)
stringRes(this, R.string.nest_notification_broadcasting)
} else {
getString(R.string.nest_notification_listening)
stringRes(this, R.string.nest_notification_listening)
}
return NotificationCompat
.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(getString(R.string.nest_notification_text))
.setContentText(stringRes(this, R.string.nest_notification_text))
.setSmallIcon(R.drawable.amethyst)
.setOngoing(true)
.setContentIntent(openIntent)
.addAction(0, getString(R.string.nest_notification_stop), stopIntent)
.addAction(0, stringRes(this, R.string.nest_notification_stop), stopIntent)
.setCategory(NotificationCompat.CATEGORY_CALL)
.build()
}
@@ -412,10 +413,10 @@ class NestForegroundService : Service() {
mgr.createNotificationChannel(
NotificationChannel(
CHANNEL_ID,
getString(R.string.nest_notification_channel),
stringRes(this, R.string.nest_notification_channel),
NotificationManager.IMPORTANCE_LOW,
).apply {
description = getString(R.string.nest_notification_channel_description)
description = stringRes(this@NestForegroundService, R.string.nest_notification_channel_description)
setShowBadge(false)
},
)
@@ -0,0 +1,181 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import androidx.core.app.Person
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.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.quartz.utils.Log
/**
* The long-lived shortcut that turns a MessagingStyle notification into an actual
* *conversation* as far as the system is concerned.
*
* MessagingStyle on its own is only half the contract. Since Android 11 the shade sorts a
* notification into the Conversations section — and offers the per-conversation controls
* that come with it (mark a person Priority, silence one room without silencing every DM) —
* only when the notification names a **long-lived shortcut** through `setShortcutId`.
* Without one it is ranked as an ordinary alerting notification, which is what every DM,
* group message and Buzz room got before this existed, despite the KDoc claiming otherwise.
*
* The same shortcut is the prerequisite for two things worth having later: bubbles
* (`setBubbleMetadata`) and Direct Share targets. Neither is wired yet.
*
* ### What gets published
*
* One shortcut per conversation, keyed by [NotificationUtils.Conversation.id] — which
* includes the account, so the same counterparty under two logins does not collapse into one
* launcher entry pointing at the wrong inbox. The intent is the same deep link the
* notification taps through ([NotificationRoutes]), so the launcher entry and the tap agree.
*
* ### Why this is gated on a setting
*
* A dynamic shortcut is visible in the launcher's long-press menu: publishing one puts a
* contact's name and avatar outside the app, where someone holding the phone can read it
* without unlocking anything of ours. That is the disclosure `showMessagesInNotifications`
* exists to control, so callers pass [NotificationUtils.Conversation] only when it is on.
*/
object ConversationShortcuts {
private const val TAG = "ConversationShortcuts"
/**
* Publishes (or refreshes) the conversation's shortcut and returns its id, or null when
* the system would not take it.
*
* Null matters: a `setShortcutId` pointing at a shortcut that does not exist is worse
* than none — the system logs it and still refuses the conversation treatment — so the
* caller must only stamp the notification when this succeeded.
*
* [ShortcutManagerCompat.pushDynamicShortcut] already handles the two things that bite
* here: it evicts the least-recently-used shortcut when the per-activity cap is reached,
* and it is rate-limit aware. It can still throw on a malformed shortcut, so the whole
* call is guarded — a conversation that cannot be published is a notification that
* renders normally, never a crash on the notification path.
*/
fun push(
context: Context,
conversation: NotificationUtils.Conversation,
uri: String,
person: Person,
icon: Bitmap?,
): String? =
try {
val target =
Intent(context, MainActivity::class.java).apply {
// A shortcut intent without an action is rejected outright.
action = Intent.ACTION_VIEW
data = uri.toUri()
}
val shortcut =
ShortcutInfoCompat
.Builder(context, conversation.id)
.setShortLabel(conversation.label)
.setLongLabel(conversation.label)
.setIntent(target)
// Without this the system drops the shortcut as soon as it leaves the
// dynamic list, and the conversation loses its history and its ranking.
.setLongLived(true)
.apply {
// A group is not a person. Attaching the speaker would make the
// launcher entry's face flip to whoever last spoke while the label
// stayed the group, and would rank the chat as that one contact.
if (!conversation.isGroup) setPerson(person)
// Plain, not adaptive: `icon` is already circle-cropped, and the
// adaptive mask would crop that circle a second time — zoomed, with
// the edges clipped off.
icon?.let { setIcon(IconCompat.createWithBitmap(it)) }
}.build()
if (ShortcutManagerCompat.pushDynamicShortcut(context, shortcut)) {
conversation.id
} else {
Log.d(TAG) { "System declined the shortcut for ${conversation.label}" }
null
}
} catch (e: Exception) {
// Builder.build() throws on a malformed shortcut (an empty label, say) and it is
// inside the guard for that reason: this runs from postConversation, whose caller
// swallows exceptions, so a throw escaping here would silently drop the whole
// notification rather than just its conversation status.
Log.d(TAG) { "Could not publish a shortcut for ${conversation.label}: ${e.message}" }
null
}
/**
* Drops every conversation this account published into the launcher.
*
* Logging out has to take these with it. They are the one part of an account that lives
* outside the app's own storage — names and avatars of people it talked to, sitting in the
* launcher's long-press menu — so an account that is gone from Amethyst but still lists its
* contacts on the home screen would be a worse leak than not publishing them at all.
*
* Long-lived shortcuts are also removed from the system's cache, not just the dynamic list:
* 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.
*/
suspend fun removeForAccount(
context: Context,
accountNpub: String,
) {
val scope = ":$accountNpub:"
try {
// Every list the system keeps, not just the dynamic one. `pushDynamicShortcut`
// evicts the least-recently-used shortcut from the dynamic list when the cap is
// reached, and a long-lived shortcut that has been used in a notification is kept
// in the system *cache* rather than deleted — so the conversations most likely to
// have fallen out of the dynamic list are exactly the ones a dynamic-only sweep
// would leave behind, which is the leak this is here to close.
val mine =
ShortcutManagerCompat
.getShortcuts(
context,
ShortcutManagerCompat.FLAG_MATCH_DYNAMIC or
ShortcutManagerCompat.FLAG_MATCH_CACHED or
ShortcutManagerCompat.FLAG_MATCH_PINNED,
).map { it.id }
.filter { it.contains(scope) }
if (mine.isEmpty()) return
// A shortcut the user pinned to their home screen cannot be deleted by us, only
// disabled — so disable first, then remove. Without this a pinned conversation
// would survive the logout as a live tile.
ShortcutManagerCompat.disableShortcuts(
context,
mine,
loadStringRes(Res.string.app_notification_shortcut_account_removed),
)
ShortcutManagerCompat.removeLongLivedShortcuts(context, mine)
} catch (e: Exception) {
Log.d(TAG) { "Could not clear shortcuts for $accountNpub: ${e.message}" }
}
}
}
@@ -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)
}
}
@@ -0,0 +1,176 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.notifications
import android.app.Notification
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import com.vitorpamplona.amethyst.R
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
/**
* What became of a reply the user typed into the shade.
*
* The three outcomes are not two: a send that has neither returned nor thrown is its own
* thing, and saying so is the only honest option — see [Unconfirmed].
*/
sealed interface ReplyState {
/** On its way. [text] joins the thread so the user can see what they sent. */
data class Sending(
val text: String,
) : ReplyState
/** It went out — drop the marker and leave the thread as it now reads. */
data object Sent : ReplyState
/**
* Long enough that "Sending…" has stopped being true, without an answer either way.
*
* A broadcast receiver is killed around ten seconds in, and a NIP-17 send can exceed that
* on its own: an Amber round trip, a relay publish, proof-of-work mining. Whatever the
* shade is showing when the process dies is what it shows until the conversation is read,
* so it must not be a claim we cannot stand behind.
*
* Deliberately offers no Retry. The message may well have gone out — re-sending would
* deliver it twice, which is worse than leaving the user to open the app and look.
*/
data class Unconfirmed(
val text: String,
) : ReplyState
/**
* It threw. [retry] re-sends the same text on one tap, carrying the text itself because a
* RemoteInput cannot be pre-filled and re-typing it is the thing this prevents.
*/
data class Failed(
val text: String,
val retry: PendingIntent,
) : ReplyState
}
/**
* Re-renders the live notification for [notId] to say what happened to an inline reply.
*
* Until this existed a reply typed in the shade was posted into silence: the send either
* worked, and the notification vanished with no sign the message had gone, or it threw, and
* nothing at all happened — same notification, text gone, user believing it sent. A denied
* signature or a dead socket was indistinguishable from delivery.
*
* The live notification is the only place the reply can be shown, so it is rebuilt from
* itself rather than from scratch: [NotificationCompat.Builder] can recover a builder from a
* posted [Notification], and MessagingStyle can be extracted and extended. Callers that are
* not conversations (BigText notifications carrying an inline reply) have no thread to append
* to and get `setRemoteInputHistory`, which is the same idea in the shape that style supports.
*
* 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.
*/
suspend fun NotificationManager.renderReplyState(
applicationContext: Context,
notId: Int,
state: ReplyState,
): Boolean =
try {
renderReplyStateOrThrow(applicationContext, notId, state)
} catch (e: Exception) {
// Feedback must never break the thing it reports on. Reading the live notification
// and posting it back both go through the system, and both can throw (a revoked
// POST_NOTIFICATIONS between check and notify, say) — and this runs either side of
// the send, where an escape would cost the user the reply itself.
Log.w(TAG, "Could not render the reply state", e)
false
}
private const val TAG = "InlineReplyFeedback"
private suspend fun NotificationManager.renderReplyStateOrThrow(
applicationContext: Context,
notId: Int,
state: ReplyState,
): Boolean {
val existing = activeNotifications.firstOrNull { it.id == notId }?.notification ?: return false
val builder =
NotificationCompat
.Builder(applicationContext, existing)
// The thread already alerted when the message arrived; an update about the user's
// own reply must not buzz again.
.setOnlyAlertOnce(true)
val style = NotificationCompat.MessagingStyle.extractMessagingStyleFromNotification(existing)
when (state) {
is ReplyState.Sending -> {
// `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(loadStringRes(Res.string.app_notification_reply_sending))
}
ReplyState.Sent -> {
// Sending already put the text in the thread — appending here would double it.
builder.setSubText(null)
}
is ReplyState.Unconfirmed -> {
if (style == null) builder.setRemoteInputHistory(arrayOf(state.text))
builder.setSubText(loadStringRes(Res.string.app_notification_reply_unconfirmed))
}
is ReplyState.Failed -> {
if (style == null) builder.setRemoteInputHistory(arrayOf(state.text))
builder.setSubText(loadStringRes(Res.string.app_notification_reply_failed))
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
// with no way to write a different one. Keep them, drop any Retry left by an
// earlier attempt so repeated failures don't stack, then add this one.
val kept =
(0 until NotificationCompat.getActionCount(existing))
.mapNotNull { NotificationCompat.getAction(existing, it) }
.filter { it.title?.toString() != retryLabel }
builder.clearActions()
kept.forEach { builder.addAction(it) }
builder.addAction(
NotificationCompat.Action
.Builder(R.drawable.ic_action_reply, retryLabel, state.retry)
.build(),
)
}
}
style?.let { builder.setStyle(it) }
notify(notId, builder.build())
return true
}
@@ -25,11 +25,66 @@ import android.app.NotificationChannelGroup
import android.app.NotificationManager
import android.content.Context
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_dms_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_dms_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_dms_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_dms_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_content
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_developer
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_games
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_messages
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_payments
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_social
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_replies_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_replies_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_replies_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_replies_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_summary
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_summary
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import org.jetbrains.compose.resources.StringResource
/**
* Logical grouping of notification channels, shown as a section header in the
@@ -39,14 +94,14 @@ import com.vitorpamplona.amethyst.ui.stringRes
*/
enum class NotifChannelGroup(
val id: String,
@param:StringRes val nameRes: Int,
val nameRes: StringResource,
) {
MESSAGES("com.vitorpamplona.amethyst.group.messages", R.string.app_notification_group_messages),
SOCIAL("com.vitorpamplona.amethyst.group.social", R.string.app_notification_group_social),
PAYMENTS("com.vitorpamplona.amethyst.group.payments", R.string.app_notification_group_payments),
CONTENT("com.vitorpamplona.amethyst.group.content", R.string.app_notification_group_content),
DEVELOPER("com.vitorpamplona.amethyst.group.developer", R.string.app_notification_group_developer),
GAMES("com.vitorpamplona.amethyst.group.games", R.string.app_notification_group_games),
MESSAGES("com.vitorpamplona.amethyst.group.messages", Res.string.app_notification_group_messages),
SOCIAL("com.vitorpamplona.amethyst.group.social", Res.string.app_notification_group_social),
PAYMENTS("com.vitorpamplona.amethyst.group.payments", Res.string.app_notification_group_payments),
CONTENT("com.vitorpamplona.amethyst.group.content", Res.string.app_notification_group_content),
DEVELOPER("com.vitorpamplona.amethyst.group.developer", Res.string.app_notification_group_developer),
GAMES("com.vitorpamplona.amethyst.group.games", Res.string.app_notification_group_games),
}
/**
@@ -66,10 +121,10 @@ enum class NotifChannelGroup(
* table-driven definition every renderer reads from.
*/
enum class NotificationCategory(
@param:StringRes val channelIdRes: Int,
@param:StringRes val channelNameRes: Int,
@param:StringRes val channelDescriptionRes: Int,
@param:StringRes val summaryTextRes: Int,
val channelIdRes: StringResource,
val channelNameRes: StringResource,
val channelDescriptionRes: StringResource,
val summaryTextRes: StringResource,
val importance: Int,
val color: Int,
@param:DrawableRes val smallIcon: Int,
@@ -81,10 +136,10 @@ enum class NotificationCategory(
val colorized: Boolean = false,
) {
DIRECT_MESSAGE(
channelIdRes = R.string.app_notification_dms_channel_id,
channelNameRes = R.string.app_notification_dms_channel_name,
channelDescriptionRes = R.string.app_notification_dms_channel_description,
summaryTextRes = R.string.app_notification_dms_summary,
channelIdRes = Res.string.app_notification_dms_channel_id,
channelNameRes = Res.string.app_notification_dms_channel_name,
channelDescriptionRes = Res.string.app_notification_dms_channel_description,
summaryTextRes = Res.string.app_notification_dms_summary,
importance = NotificationManager.IMPORTANCE_HIGH,
color = 0xFF2196F3.toInt(), // blue
smallIcon = R.drawable.ic_notif_message,
@@ -94,10 +149,10 @@ enum class NotificationCategory(
summaryId = 0x10000,
),
REPLY(
channelIdRes = R.string.app_notification_replies_channel_id,
channelNameRes = R.string.app_notification_replies_channel_name,
channelDescriptionRes = R.string.app_notification_replies_channel_description,
summaryTextRes = R.string.app_notification_replies_summary,
channelIdRes = Res.string.app_notification_replies_channel_id,
channelNameRes = Res.string.app_notification_replies_channel_name,
channelDescriptionRes = Res.string.app_notification_replies_channel_description,
summaryTextRes = Res.string.app_notification_replies_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF7C4DFF.toInt(), // deep purple
smallIcon = R.drawable.ic_notif_reply,
@@ -109,10 +164,10 @@ enum class NotificationCategory(
summaryId = 0x50000,
),
MENTION(
channelIdRes = R.string.app_notification_mentions_channel_id,
channelNameRes = R.string.app_notification_mentions_channel_name,
channelDescriptionRes = R.string.app_notification_mentions_channel_description,
summaryTextRes = R.string.app_notification_mentions_summary,
channelIdRes = Res.string.app_notification_mentions_channel_id,
channelNameRes = Res.string.app_notification_mentions_channel_name,
channelDescriptionRes = Res.string.app_notification_mentions_channel_description,
summaryTextRes = Res.string.app_notification_mentions_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF9C27B0.toInt(), // purple
smallIcon = R.drawable.ic_notif_mention,
@@ -122,10 +177,10 @@ enum class NotificationCategory(
summaryId = 0x60000,
),
REACTION(
channelIdRes = R.string.app_notification_reactions_channel_id,
channelNameRes = R.string.app_notification_reactions_channel_name,
channelDescriptionRes = R.string.app_notification_reactions_channel_description,
summaryTextRes = R.string.app_notification_reactions_summary,
channelIdRes = Res.string.app_notification_reactions_channel_id,
channelNameRes = Res.string.app_notification_reactions_channel_name,
channelDescriptionRes = Res.string.app_notification_reactions_channel_description,
summaryTextRes = Res.string.app_notification_reactions_summary,
importance = NotificationManager.IMPORTANCE_LOW,
color = 0xFFE91E63.toInt(), // pink / heart
smallIcon = R.drawable.ic_notif_reaction,
@@ -135,10 +190,10 @@ enum class NotificationCategory(
summaryId = 0x40000,
),
REPOST(
channelIdRes = R.string.app_notification_reposts_channel_id,
channelNameRes = R.string.app_notification_reposts_channel_name,
channelDescriptionRes = R.string.app_notification_reposts_channel_description,
summaryTextRes = R.string.app_notification_reposts_summary,
channelIdRes = Res.string.app_notification_reposts_channel_id,
channelNameRes = Res.string.app_notification_reposts_channel_name,
channelDescriptionRes = Res.string.app_notification_reposts_channel_description,
summaryTextRes = Res.string.app_notification_reposts_summary,
importance = NotificationManager.IMPORTANCE_LOW,
color = 0xFF4CAF50.toInt(), // green
smallIcon = R.drawable.ic_notif_repost,
@@ -148,10 +203,10 @@ enum class NotificationCategory(
summaryId = 0x70000,
),
ZAP(
channelIdRes = R.string.app_notification_zaps_channel_id,
channelNameRes = R.string.app_notification_zaps_channel_name,
channelDescriptionRes = R.string.app_notification_zaps_channel_description,
summaryTextRes = R.string.app_notification_zaps_summary,
channelIdRes = Res.string.app_notification_zaps_channel_id,
channelNameRes = Res.string.app_notification_zaps_channel_name,
channelDescriptionRes = Res.string.app_notification_zaps_channel_description,
summaryTextRes = Res.string.app_notification_zaps_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFFF7931A.toInt(), // bitcoin orange
smallIcon = R.drawable.ic_notif_zap,
@@ -162,10 +217,10 @@ enum class NotificationCategory(
colorized = true,
),
MEDIA(
channelIdRes = R.string.app_notification_media_channel_id,
channelNameRes = R.string.app_notification_media_channel_name,
channelDescriptionRes = R.string.app_notification_media_channel_description,
summaryTextRes = R.string.app_notification_media_summary,
channelIdRes = Res.string.app_notification_media_channel_id,
channelNameRes = Res.string.app_notification_media_channel_name,
channelDescriptionRes = Res.string.app_notification_media_channel_description,
summaryTextRes = Res.string.app_notification_media_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF00BCD4.toInt(), // cyan
smallIcon = R.drawable.ic_notif_media,
@@ -175,10 +230,10 @@ enum class NotificationCategory(
summaryId = 0x80000,
),
ARTICLE(
channelIdRes = R.string.app_notification_articles_channel_id,
channelNameRes = R.string.app_notification_articles_channel_name,
channelDescriptionRes = R.string.app_notification_articles_channel_description,
summaryTextRes = R.string.app_notification_articles_summary,
channelIdRes = Res.string.app_notification_articles_channel_id,
channelNameRes = Res.string.app_notification_articles_channel_name,
channelDescriptionRes = Res.string.app_notification_articles_channel_description,
summaryTextRes = Res.string.app_notification_articles_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF3F51B5.toInt(), // indigo
smallIcon = R.drawable.ic_notif_article,
@@ -188,10 +243,10 @@ enum class NotificationCategory(
summaryId = 0x90000,
),
CODE(
channelIdRes = R.string.app_notification_code_channel_id,
channelNameRes = R.string.app_notification_code_channel_name,
channelDescriptionRes = R.string.app_notification_code_channel_description,
summaryTextRes = R.string.app_notification_code_summary,
channelIdRes = Res.string.app_notification_code_channel_id,
channelNameRes = Res.string.app_notification_code_channel_name,
channelDescriptionRes = Res.string.app_notification_code_channel_description,
summaryTextRes = Res.string.app_notification_code_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF607D8B.toInt(), // slate
smallIcon = R.drawable.ic_notif_code,
@@ -201,10 +256,10 @@ enum class NotificationCategory(
summaryId = 0xA0000,
),
BADGE(
channelIdRes = R.string.app_notification_badges_channel_id,
channelNameRes = R.string.app_notification_badges_channel_name,
channelDescriptionRes = R.string.app_notification_badges_channel_description,
summaryTextRes = R.string.app_notification_badges_summary,
channelIdRes = Res.string.app_notification_badges_channel_id,
channelNameRes = Res.string.app_notification_badges_channel_name,
channelDescriptionRes = Res.string.app_notification_badges_channel_description,
summaryTextRes = Res.string.app_notification_badges_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFFFFC107.toInt(), // amber / gold
smallIcon = R.drawable.ic_notif_badge,
@@ -214,10 +269,10 @@ enum class NotificationCategory(
summaryId = 0xB0000,
),
CHESS(
channelIdRes = R.string.app_notification_chess_channel_id,
channelNameRes = R.string.app_notification_chess_channel_name,
channelDescriptionRes = R.string.app_notification_chess_channel_description,
summaryTextRes = R.string.app_notification_chess_summary,
channelIdRes = Res.string.app_notification_chess_channel_id,
channelNameRes = Res.string.app_notification_chess_channel_name,
channelDescriptionRes = Res.string.app_notification_chess_channel_description,
summaryTextRes = Res.string.app_notification_chess_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF795548.toInt(), // brown
smallIcon = R.drawable.ic_notif_chess,
@@ -227,10 +282,10 @@ enum class NotificationCategory(
summaryId = 0x30000,
),
PAYMENT_RECEIVED(
channelIdRes = R.string.app_notification_payments_channel_id,
channelNameRes = R.string.app_notification_payments_channel_name,
channelDescriptionRes = R.string.app_notification_payments_channel_description,
summaryTextRes = R.string.app_notification_payments_summary,
channelIdRes = Res.string.app_notification_payments_channel_id,
channelNameRes = Res.string.app_notification_payments_channel_name,
channelDescriptionRes = Res.string.app_notification_payments_channel_description,
summaryTextRes = Res.string.app_notification_payments_summary,
importance = NotificationManager.IMPORTANCE_DEFAULT,
color = 0xFF16B979.toInt(), // lightning green — distinct from the gold zap channel
smallIcon = R.drawable.ic_notif_zap,
@@ -241,7 +296,7 @@ enum class NotificationCategory(
),
;
fun channelId(context: Context): String = stringRes(context, channelIdRes)
suspend fun channelId(context: Context): String = loadStringRes(channelIdRes)
@Volatile private var channelEnsured = false
@@ -252,18 +307,23 @@ enum class NotificationCategory(
* successful creation instead of paying two IPCs on every post and re-render.
* Returns the channel id to post on.
*/
fun ensureChannel(context: Context): String {
suspend fun ensureChannel(context: Context): String {
val id = channelId(context)
if (channelEnsured) return id
// Resolved before the monitor: reading the catalog suspends, and a suspension
// point inside a synchronized block would park the thread holding the lock.
val groupName = loadStringRes(channelGroup.nameRes)
val channelName = loadStringRes(channelNameRes)
val channelDescription = loadStringRes(channelDescriptionRes)
synchronized(this) {
if (!channelEnsured) {
val nm = context.getSystemService(NotificationManager::class.java)
nm.createNotificationChannelGroup(
NotificationChannelGroup(channelGroup.id, stringRes(context, channelGroup.nameRes)),
NotificationChannelGroup(channelGroup.id, groupName),
)
val channel =
NotificationChannel(id, stringRes(context, channelNameRes), importance).apply {
description = stringRes(context, channelDescriptionRes)
NotificationChannel(id, channelName, importance).apply {
description = channelDescription
group = channelGroup.id
}
nm.createNotificationChannel(channel)
@@ -25,14 +25,18 @@ import android.content.Context
import android.content.Intent
import android.provider.Settings
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_calls_channel_name
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_name
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
import com.vitorpamplona.amethyst.service.scheduledposts.AndroidScheduledPostNotifier
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.Log
import org.jetbrains.compose.resources.StringResource
/**
* Registry of user-facing notification channels and helpers to read their
@@ -57,10 +61,10 @@ object NotificationChannels {
* settings page has something to open even before the first notification fires.
*/
data class Entry(
val nameRes: Int,
val nameRes: StringResource,
val icon: MaterialSymbol,
val channelId: (Context) -> String,
val ensure: (Context) -> Unit,
val channelId: suspend (Context) -> String,
val ensure: suspend (Context) -> Unit,
)
/** The per-kind content channels, derived from [NotificationCategory], in a
@@ -81,13 +85,13 @@ object NotificationChannels {
} +
listOf(
Entry(
nameRes = R.string.app_notification_scheduled_posts_channel_name,
nameRes = Res.string.app_notification_scheduled_posts_channel_name,
icon = MaterialSymbols.Schedule,
channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) },
channelId = { loadStringRes(Res.string.app_notification_scheduled_posts_channel_id) },
ensure = { AndroidScheduledPostNotifier.ensureChannel(it) },
),
Entry(
nameRes = R.string.app_notification_calls_channel_name,
nameRes = Res.string.app_notification_calls_channel_name,
icon = MaterialSymbols.Call,
channelId = { CallNotifier.CALL_CHANNEL_ID },
ensure = { CallNotifier.getOrCreateCallChannel(it) },
@@ -349,6 +349,22 @@ class NotificationRelayService : Service() {
}
private fun updateNotification(connectedRelays: Int) {
postNotification(connectedRelays)
// The breakdown names each job from the shared string catalog, whose only
// non-composable accessor suspends - so it is recomputed off this path and
// the card reposted once the text actually changes.
if (detailsExpanded) {
scope.launch {
val fresh = RelayPurposeSummary.lines()
if (fresh.isNotEmpty() && fresh != lastBreakdown) {
lastBreakdown = fresh
postNotification(connectedRelays)
}
}
}
}
private fun postNotification(connectedRelays: Int) {
val notification = buildNotification(connectedRelays)
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
notificationManager.notify(NOTIFICATION_ID, notification)
@@ -398,14 +414,7 @@ class NotificationRelayService : Service() {
//
// Computed only while expanded: walking every connected relay's active requests once a
// second is wasted work when nobody has asked to see the result.
val breakdown =
if (detailsExpanded) {
val fresh = RelayPurposeSummary.lines(this)
if (fresh.isNotEmpty()) lastBreakdown = fresh
lastBreakdown.takeIf { it.isNotEmpty() }
} else {
null
}
val breakdown = if (detailsExpanded) lastBreakdown.takeIf { it.isNotEmpty() } else null
val detailsIntent =
Intent(this, NotificationRelayService::class.java).apply {
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst.service.notifications
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
@@ -46,11 +47,22 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import kotlin.coroutines.cancellation.CancellationException
class NotificationReplyReceiver : BroadcastReceiver() {
companion object {
/**
* How long a send may run before the notification stops claiming it is sending. Set
* under the ten seconds a foreground broadcast gets, so the honest state is rendered
* while this process is still alive to render it.
*/
private const val UNCONFIRMED_AFTER_MS = 8_000L
}
override fun onReceive(
context: Context,
intent: Intent,
@@ -88,12 +100,7 @@ class NotificationReplyReceiver : BroadcastReceiver() {
}
NotificationUtils.REPLY_ACTION -> {
val replyText =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
val replyText = replyTextFrom(intent)
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
@@ -102,35 +109,25 @@ class NotificationReplyReceiver : BroadcastReceiver() {
if (members.isEmpty()) return
runOnRelay(notificationManager, notificationId, eventId) {
runOnRelay(context, notificationManager, notificationId, eventId, replyText, intent) {
sendReply(accountNpub, members, replyText)
}
}
NotificationUtils.PUBLIC_REPLY_ACTION -> {
val replyText =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
val replyText = replyTextFrom(intent)
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
val targetEventId = intent.getStringExtra(NotificationUtils.KEY_TARGET_EVENT_ID) ?: return
runOnRelay(notificationManager, notificationId, eventId) {
runOnRelay(context, notificationManager, notificationId, eventId, replyText, intent) {
sendPublicReply(accountNpub, targetEventId, replyText)
}
}
NotificationUtils.MARMOT_REPLY_ACTION -> {
val replyText =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
val replyText = replyTextFrom(intent)
if (replyText.isNullOrBlank()) return
val accountNpub = intent.getStringExtra(NotificationUtils.KEY_ACCOUNT_NPUB) ?: return
@@ -138,22 +135,51 @@ class NotificationReplyReceiver : BroadcastReceiver() {
val replyToInnerId = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_ID)
val replyToInnerAuthor = intent.getStringExtra(NotificationUtils.KEY_MARMOT_REPLY_TO_INNER_AUTHOR)
runOnRelay(notificationManager, notificationId, eventId) {
runOnRelay(context, notificationManager, notificationId, eventId, replyText, intent) {
sendMarmotReply(accountNpub, nostrGroupId, replyToInnerId, replyToInnerAuthor, replyText)
}
}
}
}
/**
* The text of an inline reply: typed into the shade, or carried by a Retry re-sending one
* that failed. A RemoteInput cannot be pre-filled, so a retry has to bring its own copy.
*/
private fun replyTextFrom(intent: Intent): String? =
RemoteInput
.getResultsFromIntent(intent)
?.getCharSequence(NotificationUtils.KEY_REPLY_TEXT)
?.toString()
?: intent.getStringExtra(NotificationUtils.KEY_REPLY_TEXT)
/**
* Sends [block] and keeps the notification honest about how it went.
*
* The notification stays up rather than being cancelled on success. That is what every
* other messenger does — the reply appears in the thread you replied to — and it is what
* makes a failure visible at all: there is something left on screen to put the error on.
* It clears the usual way, when the conversation is read in the app.
*
* The dismissal guard is recorded on **both** outcomes. On success it stops the enrichment
* window resurrecting the pre-reply version seconds later; on failure it stops that same
* re-render overwriting the error and the Retry that carries the user's text. A nicer
* avatar is not worth losing an unsent message to.
*/
private fun runOnRelay(
context: Context,
notificationManager: NotificationManager,
notificationId: Int,
eventId: String?,
replyText: String,
source: Intent,
block: suspend () -> Unit,
) {
val pendingResult = goAsync()
val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
val appContext = context.applicationContext
scope.launch {
val collectionJob =
scope.launch {
@@ -161,13 +187,48 @@ class NotificationReplyReceiver : BroadcastReceiver() {
.collect()
}
// Stops "Sending…" becoming a lie the shade keeps telling. A broadcast receiver is
// killed around ten seconds in, and a NIP-17 send can outlive that on its own — an
// Amber round trip, a relay publish, proof-of-work mining — so whatever is on
// screen at that moment is what the user is left with.
//
// A watchdog, deliberately, not a `withTimeout`: timing the send out would *cancel*
// it, and a publish cancelled halfway is a worse outcome than a slow one. This only
// changes what the notification says. If the send does finish afterwards, the Sent
// or Failed render below overwrites it.
val watchdog =
scope.launch {
delay(UNCONFIRMED_AFTER_MS)
notificationManager.renderReplyState(
appContext,
notificationId,
ReplyState.Unconfirmed(replyText),
)
}
try {
// Inside the try: everything from here on must reach the `finally`, which is
// what calls pendingResult.finish() and releases the broadcast.
notificationManager.renderReplyState(appContext, notificationId, ReplyState.Sending(replyText))
block()
// Joined, not just cancelled: if the watchdog is already inside its render, the
// two notify() calls would land in an undefined order and the shade could keep
// "Still sending…" over a message that went out.
watchdog.cancelAndJoin()
eventId?.let { NotificationUtils.markDismissed(it) }
notificationManager.cancelAndPrune(notificationId)
notificationManager.renderReplyState(appContext, notificationId, ReplyState.Sent)
} catch (e: Exception) {
// Rethrown first: joining is a suspend call, which an already-cancelled
// coroutine cannot make. `scope.cancel()` below takes the watchdog with it.
if (e is CancellationException) throw e
watchdog.cancelAndJoin()
Log.e("NotificationReply") { "Failed to send reply: ${e.message}" }
eventId?.let { NotificationUtils.markDismissed(it) }
notificationManager.renderReplyState(
appContext,
notificationId,
ReplyState.Failed(replyText, retryIntent(appContext, notificationId, replyText, source)),
)
} finally {
pendingResult.finish()
collectionJob.cancel()
@@ -176,6 +237,37 @@ class NotificationReplyReceiver : BroadcastReceiver() {
}
}
/**
* A one-tap re-send of exactly what the user typed.
*
* A copy of [source] — which already carries the action and the account, room, group or
* target this reply was addressed to — plus the text. Keeping the original action is what
* makes this free: [onReceive] routes it back to the branch it came from with no alias to
* resolve, the dismissal guard already treats it as the reply action it is, and
* [replyTextFrom] already prefers a RemoteInput result and falls back to this extra.
*/
private fun retryIntent(
applicationContext: Context,
notificationId: Int,
replyText: String,
source: Intent,
): PendingIntent {
val intent =
Intent(source).apply {
setClass(applicationContext, NotificationReplyReceiver::class.java)
putExtra(NotificationUtils.KEY_REPLY_TEXT, replyText)
}
return PendingIntent.getBroadcast(
applicationContext,
// The other three request codes for this notification are notId, +1 (mark read)
// and +2 (dismiss).
notificationId + 3,
intent,
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
}
private suspend fun sendReply(
accountNpub: String,
chatroomMembers: List<String>,
@@ -206,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(
@@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip19Bech32.toNpub
/** Deep-link URIs consumed by `MainActivity.uriToRoute` when a notification is tapped. */
@@ -38,11 +39,88 @@ object NotificationRoutes {
.hexToByteArray()
.toNpub()
/** Opens the note directly (used for replies, mentions, DMs, media, git). */
/**
* Opens the note directly (used for replies, mentions, media, git).
*
* A note delivered inside an envelope (a NIP-17 private note or reply) cannot be
* addressed this way — see [privateNoteUri].
*/
fun noteUri(
note: Note,
accountNpub: String,
): String = note.toNEvent() + ACCOUNT + accountNpub
): String =
if (note.rumorHost != null) {
privateNoteUri(note.idHex, accountNpub)
} else {
note.toNEvent() + ACCOUNT + accountNpub
}
/**
* Opens a NIP-17 private note or reply by the rumor's own id.
*
* [Note.toNEvent] cannot address one: for a rumor it encodes the kind-1059 gift wrap
* that delivered it, because the rumor id must never appear in anything that leaves
* for a relay (`RumorHostCitationTest` pins that, and [Note.isPrivateRumor] guards the
* publish paths). Two things then go wrong on a tap. The wrap is unfetchable — the
* general event finder asks the account's read relays for an id only the DM inbox
* relays ever held. And the wrap note emits exactly once, from `consumeRegularEvent`,
* *before* it is decrypted, so `routeFor` sees a null `innerEventId` and the redirect
* never fires again (the later `event = copyNoContent()` is a plain assignment).
*
* The rumor id has neither problem: its note emits when the rumor is consumed, and the
* always-on one-week gift-wrap tail re-delivers and re-unwraps the envelope on every
* cold start, so a push-recent message always comes back on its own. This URI is read
* only by `MainActivity.uriToRoute`, inside our own process, and the route it produces
* never puts the id in a REQ — see `Route.EventRedirect.isPrivate`.
*/
fun privateNoteUri(
rumorId: String,
accountNpub: String,
): String = "privatenote?id=$rumorId&account=$accountNpub"
/**
* Opens a NIP-17 / NIP-04 private chatroom (DM notifications).
*
* A DM must NOT deep-link through its own note: [Note.toNEvent] cites the kind-1059
* gift wrap that delivered the rumor (it has to — the rumor id is the private event's
* identity and resolves to nothing on a public relay, see `RumorHostCitationTest`).
* Resolving that nevent back to a room needs the wrap *and* seal notes to still be in
* the in-memory `LocalCache`. A push usually wakes a cold process, so by the time the
* user taps, the cache is empty — and no relay will ever serve that wrap again, so the
* tap landed on a `Route.EventRedirect` that could never resolve.
*
* The room key is derived locally and needs no event at all, so this survives the
* process dying. Same idea as [relayGroupUri], which routes a Buzz DM through the
* channel's naddr rather than the message.
*/
fun chatroomUri(
room: ChatroomKey,
accountNpub: String,
): String = "chatroom?id=${room.users.joinToString(",")}&account=$accountNpub"
// ---------------------------------------------------------------------
// Conversation shortcut ids
//
// Stable for the life of the chat and scoped to the account, so the same counterparty
// under two logins does not collapse into one launcher entry pointing at the wrong
// inbox. Members are sorted because a room key is a set — ordering must not create a
// second shortcut for a chat that already has one.
// ---------------------------------------------------------------------
fun chatroomShortcutId(
room: ChatroomKey,
accountNpub: String,
): String = "dm:$accountNpub:" + room.users.sorted().joinToString(",")
fun marmotShortcutId(
nostrGroupId: String,
accountNpub: String,
): String = "marmot:$accountNpub:$nostrGroupId"
fun relayGroupShortcutId(
channelNAddr: String,
accountNpub: String,
): String = "relaygroup:$accountNpub:$channelNAddr"
/** Opens the Notifications tab, scrolled to [scrollToId] (used for zaps, reactions, chess). */
fun notificationsUri(
@@ -50,7 +128,13 @@ object NotificationRoutes {
scrollToId: String,
): String = "notifications$ACCOUNT$accountNpub$SCROLL_TO$scrollToId"
/** Opens a Marmot group chatroom (welcome + group message). */
/**
* Opens a Marmot group chatroom (welcome + group message).
*
* Note the query here rides on an *opaque* URI (a scheme with no `//`), so its
* `?account=` can only be read by [String.findQueryParameterValue] — see the note
* there; `java.net.URI.rawQuery` is null for this shape.
*/
fun marmotUri(
nostrGroupId: String,
accountNpub: String,
@@ -37,6 +37,7 @@ import android.service.notification.StatusBarNotification
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import androidx.core.app.RemoteInput
import androidx.core.content.LocusIdCompat
import androidx.core.graphics.createBitmap
import androidx.core.graphics.drawable.IconCompat
import androidx.core.net.toUri
@@ -45,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
@@ -80,6 +86,7 @@ object NotificationUtils {
* [KEY_TARGET_EVENT_ID], which is the note an inline reply is addressed to.
*/
const val KEY_EVENT_ID = "key_event_id"
const val KEY_ACCOUNT_NPUB = "key_account_npub"
const val KEY_CHATROOM_MEMBERS = "key_chatroom_members"
const val KEY_TARGET_EVENT_ID = "key_target_event_id"
@@ -166,6 +173,29 @@ object NotificationUtils {
) : ReplyAction
}
/**
* Identity of the chat a conversation notification belongs to, so the system can treat it
* as one — see [ConversationShortcuts] for what that buys and why it needs a shortcut.
*
* [id] must be stable for the life of the conversation and unique across accounts; [label]
* is what the conversation is called, which is not always the sender (a group message is
* from a person but belongs to the group).
*
* Callers pass this only when the account allows message content in notifications: the
* shortcut it publishes is readable from the launcher.
*/
data class Conversation(
val id: String,
val label: String,
/**
* The chat's own picture, when it has one. Null falls back to the message sender's
* avatar, which is the right face for a one-to-one DM and the wrong one for a group —
* hence [isGroup], which suppresses that fallback along with the sender `Person`.
*/
val iconUrl: String? = null,
val isGroup: Boolean = false,
)
/** A prior message rendered above the main one in a MessagingStyle notification (thread context). */
data class ParentMessage(
val senderName: String,
@@ -218,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)
@@ -281,6 +311,7 @@ object NotificationUtils {
replyAction: ReplyAction? = null,
publicInlineReply: InlineReplyTarget? = null,
addMarkRead: Boolean = true,
conversation: Conversation? = null,
groupKey: String = category.group,
summaryId: Int = category.summaryId,
) {
@@ -304,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()
@@ -330,13 +361,24 @@ object NotificationUtils {
val contentPendingIntent = contentIntent(applicationContext, notId, uri)
// Published before the notification that names it: a shortcutId the system cannot
// resolve is worse than none, so the id is only stamped below when this succeeded.
val shortcutId =
conversation?.let {
val conversationIcon =
it.iconUrl?.let { url -> loadBitmap(url, applicationContext)?.let { bmp -> circleCrop(bmp) } }
?: avatar.takeUnless { _ -> it.isGroup }
ConversationShortcuts.push(applicationContext, it, uri, sender, conversationIcon)
}
val builderPublic =
NotificationCompat
.Builder(applicationContext, channelId)
.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())
@@ -360,6 +402,15 @@ object NotificationUtils {
.setOnlyAlertOnce(true)
.setWhen(time * 1000)
// The three halves of the conversation contract: the shortcut the shade resolves, the
// locus that ties this notification to it, and the people it is with. All three have to
// be present or the notification is ranked as an ordinary alert.
if (shortcutId != null) {
builder.setShortcutId(shortcutId)
builder.setLocusId(LocusIdCompat(shortcutId))
}
builder.addPerson(sender)
when (replyAction) {
is ReplyAction.Dm -> builder.addAction(dmReplyAction(applicationContext, notId, id, replyAction))
is ReplyAction.Marmot -> builder.addAction(marmotReplyAction(applicationContext, notId, id, replyAction))
@@ -423,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,
@@ -442,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,
@@ -466,7 +517,7 @@ object NotificationUtils {
return buildReplyAction(applicationContext, notId, intent)
}
private fun marmotReplyAction(
private suspend fun marmotReplyAction(
applicationContext: Context,
notId: Int,
eventId: String,
@@ -485,7 +536,7 @@ object NotificationUtils {
return buildReplyAction(applicationContext, notId, intent)
}
private fun publicReplyAction(
private suspend fun publicReplyAction(
applicationContext: Context,
notId: Int,
eventId: String,
@@ -502,7 +553,7 @@ object NotificationUtils {
return buildReplyAction(applicationContext, notId, intent)
}
private fun markReadAction(
private suspend fun markReadAction(
applicationContext: Context,
notId: Int,
eventId: String,
@@ -521,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()
}
@@ -630,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,
@@ -657,7 +708,7 @@ object NotificationUtils {
.setStyle(
NotificationCompat
.InboxStyle()
.setSummaryText(stringRes(applicationContext, category.summaryTextRes)),
.setSummaryText(loadStringRes(category.summaryTextRes)),
)
notify(summaryId, summaryBuilder.build())
@@ -20,12 +20,14 @@
*/
package com.vitorpamplona.amethyst.service.notifications
import android.content.Context
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_purpose_browsing
import com.vitorpamplona.amethyst.commons.resources.relay_purpose_line
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
@@ -51,7 +53,7 @@ object RelayPurposeSummary {
* Lines for the expanded notification, busiest first. Empty when nothing is attributed yet —
* the caller must then fall back to the bare count rather than render an empty section.
*/
fun lines(ctx: Context): List<String> {
suspend fun lines(): List<String> {
val client = Amethyst.instance.client
val named = mutableMapOf<SubPurpose, MutableSet<NormalizedRelayUrl>>()
val browsing = mutableSetOf<NormalizedRelayUrl>()
@@ -75,11 +77,11 @@ object RelayPurposeSummary {
named.entries
.sortedWith(compareByDescending<Map.Entry<SubPurpose, Set<NormalizedRelayUrl>>> { it.value.size }.thenBy { it.key.name })
.map { (purpose, relays) ->
pluralStringRes(ctx, R.plurals.relay_purpose_line, relays.size, ctx.getString(SubPurposeLabels.labelOf(purpose)), relays.size)
loadPluralStringRes(Res.plurals.relay_purpose_line, relays.size, loadStringRes(SubPurposeLabels.labelOf(purpose)), relays.size)
}.toMutableList()
if (browsing.isNotEmpty()) {
lines.add(pluralStringRes(ctx, R.plurals.relay_purpose_line, browsing.size, ctx.getString(R.string.relay_purpose_browsing), browsing.size))
lines.add(loadPluralStringRes(Res.plurals.relay_purpose_line, browsing.size, loadStringRes(Res.string.relay_purpose_browsing), browsing.size))
}
return lines
}
@@ -21,8 +21,11 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_message
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_message_highlight
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -30,7 +33,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
@@ -56,9 +58,9 @@ object ArticleNotification {
val isHighlight = event is HighlightEvent
val titleRes =
if (isHighlight) {
R.string.app_notification_articles_channel_message_highlight
Res.string.app_notification_articles_channel_message_highlight
} else {
R.string.app_notification_articles_channel_message
Res.string.app_notification_articles_channel_message
}
val bodySource = if (event is HighlightEvent) event.quote() else event.content
val rendered = NotificationContent.renderNoteText(bodySource)
@@ -80,7 +82,7 @@ object ArticleNotification {
nm.postStandard(
category = NotificationCategory.ARTICLE,
id = event.id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageTitle = loadStringRes(titleRes, author.toBestDisplayName()),
messageBody = body.text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
@@ -21,15 +21,17 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_message
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_message_from
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
/**
@@ -61,8 +63,8 @@ object BadgeNotification {
nm.postStandard(
category = NotificationCategory.BADGE,
id = event.id,
messageTitle = stringRes(context, R.string.app_notification_badges_channel_message),
messageBody = stringRes(context, R.string.app_notification_badges_channel_message_from, issuer.toBestDisplayName()),
messageTitle = loadStringRes(Res.string.app_notification_badges_channel_message),
messageBody = loadStringRes(Res.string.app_notification_badges_channel_message_from, issuer.toBestDisplayName()),
time = event.createdAt,
pictureUrl = issuer.profilePicture(),
uri = uri,
@@ -21,17 +21,19 @@
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
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
@@ -77,15 +79,28 @@ 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
// existing naddr → Route.RelayGroup path (no message load needed on tap).
val channelNAddr = channel.toNAddr()
val uri =
channel.toNAddr()?.let { NotificationRoutes.relayGroupUri(it, accountNpub) }
channelNAddr?.let { NotificationRoutes.relayGroupUri(it, accountNpub) }
?: NotificationRoutes.noteUri(note, accountNpub)
// Only the naddr identifies the room durably; without it there is no conversation to
// pin a shortcut to. The message-content gate is the early return at the top.
val conversation =
channelNAddr?.let {
Conversation(
id = NotificationRoutes.relayGroupShortcutId(it, accountNpub),
label = channel.toBestDisplayName(),
iconUrl = channel.profilePicture(),
isGroup = true,
)
}
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
@@ -107,6 +122,7 @@ object BuzzDmNotification {
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
replyAction = null,
conversation = conversation,
)
}
}
@@ -21,9 +21,10 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_channel_name
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.isDebug
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
@@ -31,8 +32,8 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent
import org.jetbrains.compose.resources.StringResource
/**
* Chess game notifications — NIP-64 challenge-accepted and move events. Rendered
@@ -44,7 +45,7 @@ object ChessNotification {
context: Context,
account: Account,
event: BaseChessEvent,
@StringRes contentRes: Int,
contentRes: StringResource,
) {
// NIP-64 chess is a debug-only feature for now; don't notify in release.
if (!isDebug) return
@@ -65,8 +66,8 @@ object ChessNotification {
nm.postStandard(
category = NotificationCategory.CHESS,
id = event.id,
messageTitle = stringRes(context, R.string.app_notification_chess_channel_name),
messageBody = stringRes(context, contentRes, author.toBestDisplayName()),
messageTitle = loadStringRes(Res.string.app_notification_chess_channel_name),
messageBody = loadStringRes(contentRes, author.toBestDisplayName()),
time = event.createdAt,
pictureUrl = author.profilePicture(),
uri = uri,
@@ -21,8 +21,24 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_issue
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_patch
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_pr
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_pr_update
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_reply
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_applied
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_applied_issue
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_applied_patch
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_applied_pr
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_closed
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_closed_issue
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_closed_patch
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_closed_pr
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_draft
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_open
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -30,7 +46,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
@@ -40,6 +55,7 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
import org.jetbrains.compose.resources.StringResource
/**
* Git / code notifications — NIP-34 issues (1621), patches (1617), pull requests
@@ -61,25 +77,25 @@ object CodeNotification {
context: Context,
account: Account,
event: GitIssueEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_issue, event.subject() ?: event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_issue, event.subject() ?: event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitPatchEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_patch, event.subject() ?: event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_patch, event.subject() ?: event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitPullRequestEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr, event.subject() ?: event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_pr, event.subject() ?: event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitPullRequestUpdateEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_pr_update, event.content)
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
// events already on relays still arrive and still have to be rendered.
@@ -88,13 +104,13 @@ object CodeNotification {
context: Context,
account: Account,
event: GitReplyEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_reply, event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_reply, event.content)
suspend fun notify(
context: Context,
account: Account,
event: GitStatusOpenEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_open, event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_status_open, event.content)
suspend fun notify(
context: Context,
@@ -109,10 +125,10 @@ object CodeNotification {
titleRes =
titleForStatusOnTarget(
event.rootEventId(),
pr = R.string.app_notification_code_channel_message_status_applied_pr,
patch = R.string.app_notification_code_channel_message_status_applied_patch,
issue = R.string.app_notification_code_channel_message_status_applied_issue,
fallback = R.string.app_notification_code_channel_message_status_applied,
pr = Res.string.app_notification_code_channel_message_status_applied_pr,
patch = Res.string.app_notification_code_channel_message_status_applied_patch,
issue = Res.string.app_notification_code_channel_message_status_applied_issue,
fallback = Res.string.app_notification_code_channel_message_status_applied,
),
subject = event.content,
)
@@ -130,10 +146,10 @@ object CodeNotification {
titleRes =
titleForStatusOnTarget(
event.rootEventId(),
pr = R.string.app_notification_code_channel_message_status_closed_pr,
patch = R.string.app_notification_code_channel_message_status_closed_patch,
issue = R.string.app_notification_code_channel_message_status_closed_issue,
fallback = R.string.app_notification_code_channel_message_status_closed,
pr = Res.string.app_notification_code_channel_message_status_closed_pr,
patch = Res.string.app_notification_code_channel_message_status_closed_patch,
issue = Res.string.app_notification_code_channel_message_status_closed_issue,
fallback = Res.string.app_notification_code_channel_message_status_closed,
),
subject = event.content,
)
@@ -142,7 +158,7 @@ object CodeNotification {
context: Context,
account: Account,
event: GitStatusDraftEvent,
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_draft, event.content)
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_status_draft, event.content)
/**
* Pick a title string for a status event based on the *target*'s kind, so
@@ -153,11 +169,11 @@ object CodeNotification {
*/
private fun titleForStatusOnTarget(
rootId: String?,
pr: Int,
patch: Int,
issue: Int,
fallback: Int,
): Int {
pr: StringResource,
patch: StringResource,
issue: StringResource,
fallback: StringResource,
): StringResource {
val targetKind = rootId?.let { LocalCache.getNoteIfExists(it)?.event?.kind } ?: return fallback
return when (targetKind) {
GitPullRequestEvent.KIND -> pr
@@ -173,7 +189,7 @@ object CodeNotification {
id: String,
createdAt: Long,
authorPubkey: String,
titleRes: Int,
titleRes: StringResource,
subject: String?,
) {
val note = LocalCache.getNoteIfExists(id) ?: return
@@ -196,7 +212,7 @@ object CodeNotification {
nm.postStandard(
category = NotificationCategory.CODE,
id = id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageTitle = loadStringRes(titleRes, author.toBestDisplayName()),
messageBody = body,
time = createdAt,
pictureUrl = author.profilePicture(),
@@ -21,12 +21,14 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postConversation
import com.vitorpamplona.amethyst.service.notifications.notificationManager
@@ -96,7 +98,23 @@ object DirectMessageNotification {
}
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.noteUri(chatNote, accountNpub)
// The room, not the message: a rumor's nevent cites the gift wrap that delivered it,
// which is gone from LocalCache as soon as the process dies and is unfetchable from
// any relay. See [NotificationRoutes.chatroomUri].
val uri = NotificationRoutes.chatroomUri(chatRoom, accountNpub)
// Names the chat for the shade's Conversations section. Withheld when the account has
// turned message content off, because publishing it puts the counterparty's name and
// avatar in the launcher — see [ConversationShortcuts].
val conversation =
if (account.settings.showMessagesInNotifications.value) {
Conversation(
id = NotificationRoutes.chatroomShortcutId(chatRoom, accountNpub),
label = roomLabel(chatRoom, author),
)
} else {
null
}
val replyAction =
if (decrypt) {
null // NIP-04 is read-only in the tray
@@ -125,7 +143,22 @@ object DirectMessageNotification {
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
replyAction = replyAction,
conversation = conversation,
)
}
}
/**
* What to call the chat. A one-to-one room is the other person, which is also the sender;
* a group room is everyone in it, so the sender's name alone would mislabel it.
*/
private fun roomLabel(
chatRoom: ChatroomKey,
author: User,
): String =
if (chatRoom.users.size <= 1) {
author.toBestDisplayName()
} else {
chatRoom.users.joinToString(", ") { LocalCache.getOrCreateUser(it).toBestDisplayName() }
}
}
@@ -21,17 +21,20 @@
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
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.Conversation
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.ReplyAction
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
@@ -57,10 +60,24 @@ 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)
// Withheld when the account has message content turned off — the shortcut it publishes
// names the group in the launcher. See [ConversationShortcuts].
val conversation =
if (account.settings.showMessagesInNotifications.value) {
// No iconUrl: a Marmot group's avatar is an encrypted Blossom blob, not a
// URL an image loader can take.
Conversation(
id = NotificationRoutes.marmotShortcutId(nostrGroupId, accountNpub),
label = groupName,
isGroup = true,
)
} else {
null
}
val nm = context.notificationManager()
NotificationEnricher.enrichAndPost(
@@ -81,6 +98,7 @@ object GroupMessageNotification {
uri = uri,
applicationContext = context,
accountPictureUrl = account.userProfile().profilePicture(),
conversation = conversation,
replyAction =
ReplyAction.Marmot(
accountNpub = accountNpub,
@@ -124,7 +142,7 @@ object GroupMessageNotification {
id = event.id,
senderName = inviter.toBestDisplayName(),
pictureUrl = inviter.profilePicture(),
messageBody = stringRes(context, R.string.app_notification_added_to_group, groupName),
messageBody = loadStringRes(Res.string.app_notification_added_to_group, groupName),
time = event.createdAt,
uri = uri,
applicationContext = context,
@@ -21,8 +21,11 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_message_photo
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_message_video
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -30,7 +33,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip71Video.VideoEvent
@@ -72,14 +74,14 @@ object MediaNotification {
val user = author.toBestDisplayName()
val titleRes =
if (isVideo) {
R.string.app_notification_media_channel_message_video
Res.string.app_notification_media_channel_message_video
} else {
R.string.app_notification_media_channel_message_photo
Res.string.app_notification_media_channel_message_photo
}
nm.postStandard(
category = NotificationCategory.MEDIA,
id = event.id,
messageTitle = stringRes(context, titleRes, user),
messageTitle = loadStringRes(titleRes, user),
messageBody = NotificationContent.resolveMentions(event.content, 140).text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
@@ -21,9 +21,10 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_message
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -31,8 +32,8 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
import org.jetbrains.compose.resources.StringResource
/**
* Text-mention notifications — someone mentioned, quoted, or cited you in a note
@@ -49,7 +50,7 @@ object MentionNotification {
account: Account,
event: Event,
category: NotificationCategory = NotificationCategory.MENTION,
@StringRes titleRes: Int = R.string.app_notification_mentions_channel_message,
titleRes: StringResource = Res.string.app_notification_mentions_channel_message,
) {
val note = LocalCache.getNoteIfExists(event.id) ?: return
if (!account.isAcceptable(note)) return
@@ -80,7 +81,7 @@ object MentionNotification {
nm.postStandard(
category = category,
id = event.id,
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
messageTitle = loadStringRes(titleRes, author.toBestDisplayName()),
messageBody = body.text,
time = event.createdAt,
pictureUrl = author.profilePicture(),
@@ -21,14 +21,15 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_message
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -57,7 +58,7 @@ object NwcPaymentNotifier {
val id = tx.payment_hash ?: tx.invoice ?: tx.created_at?.toString() ?: return
val time = tx.settled_at ?: tx.created_at ?: TimeUtils.now()
val title = stringRes(context, R.string.app_notification_payments_channel_message, amount)
val title = loadStringRes(Res.string.app_notification_payments_channel_message, amount)
val comment = tx.parsedMetadata()?.displayComment() ?: tx.displayDescription()
val body = comment ?: title
@@ -21,8 +21,11 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_message
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_message_for
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -30,7 +33,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
@@ -81,9 +83,9 @@ object ReactionNotification {
val reactedContent = NotificationContent.excerpt(reactedNote?.event?.content, 140)
val body =
if (reactedContent.isNotBlank()) {
stringRes(context, R.string.app_notification_reactions_channel_message_for, reactedContent)
loadStringRes(Res.string.app_notification_reactions_channel_message_for, reactedContent)
} else {
stringRes(context, R.string.app_notification_reactions_channel_message, user)
loadStringRes(Res.string.app_notification_reactions_channel_message, user)
}
nm.postStandard(
category = NotificationCategory.REACTION,
@@ -21,9 +21,11 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_me
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -35,7 +37,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postCo
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.replyGroupKeyFor
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.replySummaryIdFor
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.Event
/**
@@ -95,7 +96,7 @@ object ReplyNotification {
ParentMessage(
senderName =
if (parentIsFromMe || parentAuthor == null) {
stringRes(context, R.string.app_notification_me)
loadStringRes(Res.string.app_notification_me)
} else {
parentAuthor.toBestDisplayName()
},
@@ -21,8 +21,10 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_message
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -30,7 +32,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
@@ -79,7 +80,7 @@ object RepostNotification {
nm.postStandard(
category = NotificationCategory.REPOST,
id = id,
messageTitle = stringRes(context, R.string.app_notification_reposts_channel_message, booster.toBestDisplayName()),
messageTitle = loadStringRes(Res.string.app_notification_reposts_channel_message, booster.toBestDisplayName()),
messageBody = NotificationContent.excerpt(boostedNote?.event?.content, 140),
time = createdAt,
pictureUrl = booster.profilePicture(),
@@ -21,10 +21,16 @@
package com.vitorpamplona.amethyst.service.notifications.renderers
import android.content.Context
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_nutzap_channel_message_from
import com.vitorpamplona.amethyst.commons.resources.app_notification_onchain_channel_message_from
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_message
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_message_for
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_message_from
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
@@ -33,11 +39,11 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
import com.vitorpamplona.amethyst.service.notifications.notificationManager
import com.vitorpamplona.amethyst.ui.note.showAmount
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
import org.jetbrains.compose.resources.StringResource
import java.math.BigDecimal
/**
@@ -76,7 +82,7 @@ object ZapNotification {
sender = sender,
zappedNote = zappedNote,
title = { _ -> zapTitle(context, amount, comment) },
body = { user, excerpt -> fromLine(context, R.string.app_notification_zaps_channel_message_from, user, excerpt) },
body = { user, excerpt -> fromLine(context, Res.string.app_notification_zaps_channel_message_from, user, excerpt) },
)
}
@@ -96,7 +102,7 @@ object ZapNotification {
createdAt = event.createdAt,
sender = sender,
zappedNote = zappedNote,
title = { user -> stringRes(context, R.string.app_notification_nutzap_channel_message_from, user) },
title = { user -> loadStringRes(Res.string.app_notification_nutzap_channel_message_from, user) },
body = { user, excerpt -> excerpt.ifBlank { user } },
)
}
@@ -120,12 +126,12 @@ object ZapNotification {
zappedNote = zappedNote,
title = { user ->
if (sats != null) {
stringRes(context, R.string.app_notification_zaps_channel_message, showAmount(sats.toBigDecimal()))
loadStringRes(Res.string.app_notification_zaps_channel_message, showAmount(sats.toBigDecimal()))
} else {
stringRes(context, R.string.app_notification_onchain_channel_message_from, user)
loadStringRes(Res.string.app_notification_onchain_channel_message_from, user)
}
},
body = { user, excerpt -> fromLine(context, R.string.app_notification_onchain_channel_message_from, user, excerpt) },
body = { user, excerpt -> fromLine(context, Res.string.app_notification_onchain_channel_message_from, user, excerpt) },
)
}
@@ -136,8 +142,8 @@ object ZapNotification {
createdAt: Long,
sender: User,
zappedNote: Note?,
title: (String) -> String,
body: (String, String) -> String,
title: suspend (String) -> String,
body: suspend (String, String) -> String,
) {
val accountNpub = NotificationRoutes.accountNpub(account)
val uri = NotificationRoutes.notificationsUri(accountNpub, id)
@@ -167,24 +173,24 @@ object ZapNotification {
}
}
private fun zapTitle(
private suspend fun zapTitle(
context: Context,
amount: String,
comment: String?,
): String {
val base = stringRes(context, R.string.app_notification_zaps_channel_message, amount)
val base = loadStringRes(Res.string.app_notification_zaps_channel_message, amount)
return if (comment != null) "$base ($comment)" else base
}
private fun fromLine(
private suspend fun fromLine(
context: Context,
fromRes: Int,
fromRes: StringResource,
user: String,
excerpt: String,
): String {
var content = stringRes(context, fromRes, user)
var content = loadStringRes(fromRes, user)
if (excerpt.isNotBlank()) {
content += " " + stringRes(context, R.string.app_notification_zaps_channel_message_for, excerpt)
content += " " + loadStringRes(Res.string.app_notification_zaps_channel_message_for, excerpt)
}
return content
}
@@ -70,6 +70,16 @@ fun GetVideoController(
// warm-pool fast path keeps position and buffered data intact.
val keepAlive = remember { mutableStateOf(true) }
// Captions can resolve *after* playback starts: a `text-track` tag naming a kind-39307
// coordinate is only turned into a SubtitleConfiguration once that event arrives, which
// rebuilds `mediaItem` with the same videoUri and a different subtitle list. The mediaId is
// the video URI either way, so the warm-pool fast path below would read "already loaded" and
// the new tracks would never reach the player. A MediaController doesn't carry the item's
// `localConfiguration` across the service IPC, so the subtitles can't be read back off the
// controller to compare — we remember what we last pushed instead. Deliberately keyless so it
// survives the keyed remember below being rebuilt by the very change it has to detect.
val lastAppliedCaptions = remember { mutableStateOf<Int?>(null) }
val controllerState by remember(mediaItem, keepAlive.value) {
if (keepAlive.value) {
PlaybackServiceClient
@@ -118,6 +128,7 @@ fun GetVideoController(
// We still re-prepare if the player ended up IDLE somehow (e.g. it was demoted
// to cold and resurfaced, or hit an error before we attached).
val targetMediaId = mediaItem.item.mediaId
val captionsFingerprint = mediaItem.src.captions.hashCode()
val needsLoad = state.controller.currentMediaItem?.mediaId != targetMediaId
if (needsLoad) {
// Cold load: a fresh decoder/codec instance gets allocated here. If a
@@ -127,6 +138,20 @@ fun GetVideoController(
Log.d("PlaybackService") { "Cold load (setMediaItem+prepare) for $targetMediaId" }
state.controller.setMediaItem(mediaItem.item)
state.controller.prepare()
lastAppliedCaptions.value = captionsFingerprint
} else if (lastAppliedCaptions.value != null && lastAppliedCaptions.value != captionsFingerprint) {
// Same video, new caption set, and this composable is the one that loaded
// it: re-set the item so the side-loaded subtitle sources get merged in.
// Restarting from the current position keeps the rebuffer down to whatever
// the cache can't serve. The `!= null` guard is what keeps a warm player
// that arrived already carrying someone else's item from being re-prepared
// on a fingerprint we never pushed — that's the rebuffer the pool exists
// to avoid, and its captions are whatever that mount applied.
val resumeAt = state.controller.currentPosition
Log.d("PlaybackService") { "Captions changed for $targetMediaId - re-applying at $resumeAt" }
state.controller.setMediaItem(mediaItem.item, resumeAt)
state.controller.prepare()
lastAppliedCaptions.value = captionsFingerprint
} else if (state.controller.playbackState == Player.STATE_IDLE) {
Log.d("PlaybackService") { "Warm controller in STATE_IDLE — re-preparing" }
state.controller.prepare()
@@ -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))
}
}
}
@@ -51,6 +51,7 @@ import com.vitorpamplona.amethyst.service.playback.composable.controls.Fullscree
import com.vitorpamplona.amethyst.service.playback.composable.controls.LogVideoQualitySelection
import com.vitorpamplona.amethyst.service.playback.composable.controls.METERED_MAX_SHORT_SIDE_PX
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderAnimatedBottomInfo
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCaptions
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderCenterButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.RenderTopButtons
import com.vitorpamplona.amethyst.service.playback.composable.controls.TopGradientOverlay
@@ -217,6 +218,15 @@ fun RenderVideoPlayer(
shutter = {},
)
// Above the picture but below the controls, so the transport bar and the bottom gradient
// draw over the cues rather than the other way around.
if (mediaItem.src.captions.isNotEmpty()) {
RenderCaptions(
controllerState = controllerState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
RenderPlaybackError(
controllerState = controllerState,
videoUri = mediaItem.src.videoUri,
@@ -37,6 +37,7 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.model.MediaAspectRatioCache
import com.vitorpamplona.amethyst.ui.components.DisplayBlurHash
import com.vitorpamplona.amethyst.ui.components.ImageUrlWithDownloadButton
@@ -46,6 +47,8 @@ import com.vitorpamplona.amethyst.ui.theme.Size75dp
import com.vitorpamplona.amethyst.ui.theme.imageModifier
import com.vitorpamplona.amethyst.ui.theme.videoGalleryModifier
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
class WaveformData(
@@ -73,6 +76,7 @@ fun VideoView(
thumbhash: String? = null,
isLiveStream: Boolean = false,
hash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
) {
val borderModifier =
if (roundedCorner) {
@@ -83,7 +87,7 @@ fun VideoView(
Modifier
}
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash, isLiveStream = isLiveStream, hash = hash)
VideoView(videoUri, mimeType, title, thumb, borderModifier, contentScale, waveform, artworkUri, authorName, dimensions, blurhash, nostrUriCallback, onDialog, alwaysShowVideo, accountViewModel = accountViewModel, thumbhash = thumbhash, isLiveStream = isLiveStream, hash = hash, captions = captions)
}
@Composable
@@ -107,6 +111,7 @@ fun VideoView(
accountViewModel: AccountViewModel,
thumbhash: String? = null,
hash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
) {
val initialAutoStart = if (alwaysShowVideo) true else accountViewModel.settings.startVideoPlayback()
// Reset the manual-show toggle when the video URI changes so a recycled feed slot
@@ -180,6 +185,7 @@ fun VideoView(
dim = dimensions,
hash = hash,
thumbhash = thumbhash,
captions = captions,
accountViewModel = accountViewModel,
showControls = showControls,
)
@@ -236,6 +242,7 @@ fun VideoView(
dim = dimensions,
hash = hash,
thumbhash = thumbhash,
captions = captions,
accountViewModel = accountViewModel,
showControls = showControls,
)
@@ -26,10 +26,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.service.playback.composable.mainVideo.VideoPlayerActiveMutex
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.GetMediaItem
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
val DEFAULT_MUTED_SETTING = mutableStateOf(true)
@@ -57,6 +60,7 @@ fun VideoViewInner(
dim: DimensionTag? = null,
hash: String? = null,
thumbhash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
accountViewModel: AccountViewModel,
) {
// keeps a copy of the value to avoid recompositions here when the DEFAULT value changes
@@ -83,6 +87,7 @@ fun VideoViewInner(
dim = dim,
hash = hash,
thumbhash = thumbhash,
captions = captions,
) { mediaItem ->
GetVideoController(
mediaItem = mediaItem,
@@ -0,0 +1,160 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.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
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupProperties
import androidx.media3.common.C
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.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
import java.util.Locale
/**
* The text tracks a player has, in the order the media declared them.
*
* A NIP-71 video can carry a `text-track` per language, so "captions" is not one thing to switch
* on — it is a list to choose from. Groups the player reports as unsupported are dropped: offering
* a language the decoder will refuse is worse than not offering it.
*/
@OptIn(UnstableApi::class)
internal fun getTextTrackChoices(tracks: Tracks): ImmutableList<CaptionChoice> {
val choices = mutableListOf<CaptionChoice>()
tracks.groups
.filter { it.type == C.TRACK_TYPE_TEXT }
.forEach { group ->
for (i in 0 until group.length) {
if (!group.isTrackSupported(i)) continue
val format = group.getTrackFormat(i)
choices.add(
CaptionChoice(
group = group,
trackIndex = i,
language = format.language,
label = format.label,
isSelected = group.isTrackSelected(i),
),
)
}
}
return choices.toImmutableList()
}
internal data class CaptionChoice(
val group: Tracks.Group,
val trackIndex: Int,
val language: String?,
val label: String?,
val isSelected: Boolean,
) {
/**
* What to print for this track.
*
* The publisher's own label wins when there is one. Otherwise the language code is expanded
* into the reader's locale — "en" is not a word to most people, "English" is — and only a
* track that names neither falls back to a generic term.
*/
fun displayName(fallback: String): String =
label?.takeIf { it.isNotBlank() }
?: language?.takeIf { it.isNotBlank() }?.let { Locale.forLanguageTag(it).displayLanguage.ifBlank { it } }
?: fallback
}
@OptIn(UnstableApi::class)
internal fun selectTextTrack(
player: Player,
choice: CaptionChoice,
) {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
// Clearing the type disable as well: picking a language is a request to see captions,
// so it has to undo an earlier "off" rather than silently select a track nothing draws.
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, false)
.setOverrideForType(TrackSelectionOverride(choice.group.mediaTrackGroup, choice.trackIndex))
.build()
}
/**
* The language menu, shown in place of a plain on/off toggle when a video offers more than one
* caption track.
*/
@Composable
internal fun CaptionLanguagePopup(
choices: ImmutableList<CaptionChoice>,
captionsEnabled: Boolean,
onSelectOff: () -> Unit,
onSelectTrack: (CaptionChoice) -> Unit,
onDismiss: () -> Unit,
) {
Popup(
alignment = Alignment.BottomCenter,
onDismissRequest = onDismiss,
properties = PopupProperties(focusable = true),
) {
val baseColors = ButtonDefaults.textButtonColors()
val contentColor = MaterialTheme.colorScheme.onBackground
val colors = remember(baseColors, contentColor) { baseColors.copy(contentColor = contentColor) }
val unnamed = stringRes(Res.string.captions_unnamed_track)
Column(
modifier = Modifier.background(MaterialTheme.colorScheme.background),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
TextButton(colors = colors, onClick = onSelectOff) {
Text(
stringRes(Res.string.captions_off),
fontWeight = if (!captionsEnabled) FontWeight(1000) else FontWeight(400),
)
}
choices.forEach { choice ->
TextButton(colors = colors, onClick = { onSelectTrack(choice) }) {
Text(
choice.displayName(unnamed),
fontWeight = if (captionsEnabled && choice.isSelected) FontWeight(1000) else FontWeight(400),
)
}
}
}
}
}
@@ -38,9 +38,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.mute_button
import com.vitorpamplona.amethyst.commons.resources.muted_button
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
@@ -123,7 +125,7 @@ fun MuteButton(
fun MutedIcon() {
Icon(
symbol = MaterialSymbols.AutoMirrored.VolumeOff,
contentDescription = stringRes(id = R.string.muted_button),
contentDescription = stringRes(id = Res.string.muted_button),
tint = MaterialTheme.colorScheme.onBackground,
modifier = Size30Modifier,
)
@@ -133,7 +135,7 @@ fun MutedIcon() {
fun MuteIcon() {
Icon(
symbol = MaterialSymbols.AutoMirrored.VolumeUp,
contentDescription = stringRes(id = R.string.mute_button),
contentDescription = stringRes(id = Res.string.mute_button),
tint = MaterialTheme.colorScheme.onBackground,
modifier = Size30Modifier,
)
@@ -37,14 +37,18 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.call_settings_video_quality
import com.vitorpamplona.amethyst.commons.resources.download_to_phone
import com.vitorpamplona.amethyst.commons.resources.more_options
import com.vitorpamplona.amethyst.commons.resources.mute_button
import com.vitorpamplona.amethyst.commons.resources.muted_button
import com.vitorpamplona.amethyst.commons.resources.picture_in_picture
import com.vitorpamplona.amethyst.commons.resources.playback_actions_dialog_title
import com.vitorpamplona.amethyst.commons.resources.share_or_save
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_fullscreen
import com.vitorpamplona.amethyst.model.VideoPlayerAction
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
@@ -79,6 +83,9 @@ fun OverflowMenuButtonPreview() {
onCastClick = {},
castIcon = MaterialSymbols.Cast,
castContentDescription = "",
onCaptionsClick = {},
captionsIcon = MaterialSymbols.ClosedCaption,
captionsContentDescription = "",
)
}
}
@@ -98,6 +105,9 @@ fun AnimatedOverflowMenuButton(
onCastClick: () -> Unit,
castIcon: MaterialSymbol,
castContentDescription: String,
onCaptionsClick: () -> Unit,
captionsIcon: MaterialSymbol,
captionsContentDescription: String,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
@@ -118,6 +128,9 @@ fun AnimatedOverflowMenuButton(
onCastClick = onCastClick,
castIcon = castIcon,
castContentDescription = castContentDescription,
onCaptionsClick = onCaptionsClick,
captionsIcon = captionsIcon,
captionsContentDescription = captionsContentDescription,
)
}
}
@@ -135,6 +148,9 @@ fun OverflowMenuButton(
onCastClick: () -> Unit,
castIcon: MaterialSymbol,
castContentDescription: String,
onCaptionsClick: () -> Unit,
captionsIcon: MaterialSymbol,
captionsContentDescription: String,
) {
val menuExpanded = remember { mutableStateOf(false) }
@@ -153,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,
)
@@ -183,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()
@@ -203,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()
@@ -213,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()
@@ -239,6 +255,16 @@ fun OverflowMenuButton(
onCastClick()
}
}
VideoPlayerAction.Captions -> {
M3ActionRow(
icon = captionsIcon,
text = captionsContentDescription,
) {
menuExpanded.value = false
onCaptionsClick()
}
}
}
}
}
@@ -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),
)
}
}
@@ -0,0 +1,103 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.playback.composable.controls
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import androidx.media3.common.text.CueGroup
import com.vitorpamplona.amethyst.service.playback.composable.MediaControllerState
/**
* Draws the cues of the currently selected text track over the player.
*
* media3's Compose UI has no subtitle view — that lives in the View-based `PlayerView`, which this
* app does not use — so the cues are collected from the player and laid out here. Only the cue
* text is honoured: a WebVTT region/position would need the player's video rect, and every
* NIP-71 caption seen in the wild is plain bottom-centred dialogue.
*
* Nothing renders when no text track is selected, so this composable costs one listener on videos
* without captions.
*/
@Composable
fun RenderCaptions(
controllerState: MediaControllerState,
modifier: Modifier = Modifier,
) {
val player = controllerState.controller
var lines by remember(player) { mutableStateOf<List<String>>(emptyList()) }
DisposableEffect(player) {
lines =
player.currentCues.cues
.mapNotNull { it.text?.toString() }
.filter { it.isNotBlank() }
val listener =
object : Player.Listener {
override fun onCues(cueGroup: CueGroup) {
lines = cueGroup.cues.mapNotNull { it.text?.toString() }.filter { it.isNotBlank() }
}
}
player.addListener(listener)
onDispose { player.removeListener(listener) }
}
if (lines.isEmpty()) return
Column(
modifier = modifier.padding(horizontal = 12.dp, vertical = 8.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(2.dp),
) {
lines.forEach { line ->
Text(
text = line,
color = Color.White,
textAlign = TextAlign.Center,
style = MaterialTheme.typography.bodyMedium,
// A scrim behind the glyphs rather than a full-width bar: captions sit over the
// picture, and a translucent box only where there is text keeps the frame visible.
modifier =
Modifier
.background(CaptionScrim, MaterialTheme.shapes.extraSmall)
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
}
private val CaptionScrim = Color.Black.copy(alpha = 0.6f)
@@ -46,17 +46,23 @@ import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.media3.common.C
import androidx.media3.common.Player
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
@@ -98,6 +104,8 @@ fun RenderTopButtonsPreview() {
onPictureInPictureClick = {},
onZoomClick = {},
onOverflowQualityClick = {},
captionsEnabled = true,
onCaptionsClick = {},
modifier = Modifier,
accountViewModel = mockAccountViewModel(),
)
@@ -143,6 +151,43 @@ fun RenderTopButtons(
val videoGroup = getVideoTrackGroup(tracks)
val hasMultipleQualities = videoGroup != null && videoGroup.length > 1
// Captions are side-loaded from the event's `text-track` tags (MediaItemCache flags the first
// one default). The account setting is the source of truth, not the player: a player comes
// out of the warm pool carrying whatever track selection the previous video left on it, so
// reading the preference off the instance would make the button's state depend on which
// player this video happened to be handed.
val captionsEnabled by accountViewModel.captionsEnabledFlow().collectAsStateWithLifecycle()
// Push the preference onto whichever player is attached, and re-push when either changes.
// Disabling the whole track type rather than deselecting one group: the type-level switch
// survives the player later picking a different track.
LaunchedEffect(player, captionsEnabled) {
val alreadyDisabled = player.trackSelectionParameters.disabledTrackTypes.contains(C.TRACK_TYPE_TEXT)
if (alreadyDisabled == captionsEnabled) {
player.trackSelectionParameters =
player.trackSelectionParameters
.buildUpon()
.setTrackTypeDisabled(C.TRACK_TYPE_TEXT, !captionsEnabled)
.build()
}
}
// A video with one track has nothing to choose between, so the button stays a toggle. Only a
// multi-language video opens a menu — paying a popup for a single "English" row would be a
// worse answer to the same tap.
val captionChoices = remember(tracks) { getTextTrackChoices(tracks) }
val captionsPopupOpen = remember { mutableStateOf(false) }
val onCaptionsClick =
remember(captionsEnabled, captionChoices) {
{
if (captionChoices.size > 1) {
captionsPopupOpen.value = true
} else {
accountViewModel.setCaptionsEnabled(!captionsEnabled)
}
}
}
val overflowQualityOpen = remember { mutableStateOf(false) }
// Pause local playback while this video is casting so audio doesn't
@@ -210,10 +255,31 @@ fun RenderTopButtons(
}
},
onOverflowQualityClick = { overflowQualityOpen.value = true },
captionsEnabled = captionsEnabled,
onCaptionsClick = onCaptionsClick,
modifier = modifier,
accountViewModel = accountViewModel,
)
if (captionsPopupOpen.value) {
CaptionLanguagePopup(
choices = captionChoices,
captionsEnabled = captionsEnabled,
onSelectOff = {
accountViewModel.setCaptionsEnabled(false)
captionsPopupOpen.value = false
},
onSelectTrack = { choice ->
// The override goes straight onto the player (it is per-video), while the
// preference is what persists the fact that captions are wanted at all.
selectTextTrack(player, choice)
accountViewModel.setCaptionsEnabled(true)
captionsPopupOpen.value = false
},
onDismiss = { captionsPopupOpen.value = false },
)
}
if (overflowQualityOpen.value && videoGroup != null) {
VideoQualityPopup(
player = player,
@@ -237,10 +303,15 @@ fun RenderTopButtons(
onPictureInPictureClick: () -> Unit,
onZoomClick: (() -> Unit)?,
onOverflowQualityClick: () -> Unit,
captionsEnabled: Boolean,
onCaptionsClick: () -> Unit,
modifier: Modifier,
accountViewModel: AccountViewModel,
) {
val buttonItems by accountViewModel.videoPlayerButtonItemsFlow().collectAsStateWithLifecycle()
val captionsIcon = if (captionsEnabled) MaterialSymbols.ClosedCaption else MaterialSymbols.ClosedCaptionDisabled
val captionsContentDescription =
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
@@ -249,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) {
{
@@ -297,21 +368,28 @@ fun RenderTopButtons(
VideoPlayerAction.Cast -> {
BuildConfig.IS_CASTING_AVAILABLE && mediaData.videoUri.startsWith("http", ignoreCase = true)
}
// A video with no `text-track` has nothing to toggle, so the button stays out of the
// row entirely rather than sitting there inert.
VideoPlayerAction.Captions -> {
mediaData.captions.isNotEmpty()
}
}
val canFullscreen = onZoomClick != null
val hasCaptions = mediaData.captions.isNotEmpty()
// ImmutableList so Compose can treat the action lists as stable parameters when they're
// passed through to AnimatedOverflowMenuButton — a plain List is unstable and forces the
// overflow tree to recompose whenever any unrelated parent state ticks.
val topBarActions =
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported, hasCaptions) {
buttonItems
.filter { it.location == VideoButtonLocation.TopBar && isAvailable(it.action) }
.map { it.action }
.toImmutableList()
}
val overflowActions =
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported) {
remember(buttonItems, canFullscreen, hasMultipleQualities, isLive, pipSupported, hasCaptions) {
buttonItems
.filter { it.location == VideoButtonLocation.OverflowMenu && isAvailable(it.action) }
.map { it.action }
@@ -346,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 },
)
}
@@ -355,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,
)
}
@@ -377,6 +455,15 @@ fun RenderTopButtons(
onClick = onCastButtonClick,
)
}
VideoPlayerAction.Captions -> {
AnimatedTopBarIconButton(
controllerVisible = controllerVisible,
symbol = captionsIcon,
contentDescription = captionsContentDescription,
onClick = onCaptionsClick,
)
}
}
}
@@ -394,6 +481,9 @@ fun RenderTopButtons(
onCastClick = onCastButtonClick,
castIcon = castIcon,
castContentDescription = castContentDescription,
onCaptionsClick = onCaptionsClick,
captionsIcon = captionsIcon,
captionsContentDescription = captionsContentDescription,
)
}
@@ -46,9 +46,12 @@ import androidx.compose.ui.tooling.preview.Preview
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.isGranted
import com.google.accompanist.permissions.rememberPermissionState
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.download_to_phone
import com.vitorpamplona.amethyst.commons.resources.video_download_has_started_toast
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
@@ -100,7 +103,7 @@ fun rememberSaveMediaAction(onSaveClick: (Context) -> Unit): () -> Unit {
Toast
.makeText(
context,
stringRes(context, R.string.video_download_has_started_toast),
loadStringRes(Res.string.video_download_has_started_toast),
Toast.LENGTH_SHORT,
).show()
}
@@ -131,7 +134,7 @@ fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) {
Icon(
symbol = MaterialSymbols.Download,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.download_to_phone),
contentDescription = stringRes(Res.string.download_to_phone),
)
}
}
@@ -38,9 +38,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.tooling.preview.Preview
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.share_or_save
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
@@ -99,7 +100,7 @@ fun ShareButton(innerAction: @Composable (MutableState<Boolean>, () -> Unit) ->
Icon(
symbol = MaterialSymbols.Share,
modifier = Size20Modifier,
contentDescription = stringRes(R.string.share_or_save),
contentDescription = stringRes(Res.string.share_or_save),
)
innerAction(popupExpanded) { popupExpanded.value = false }
@@ -33,9 +33,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.skip_back
import com.vitorpamplona.amethyst.commons.resources.skip_forward
import com.vitorpamplona.amethyst.service.playback.composable.SKIP_SECONDS
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
@@ -89,7 +91,7 @@ fun SkipButton(
onClick: () -> Unit,
) {
val icon = if (isForward) MaterialSymbols.Forward10 else MaterialSymbols.Replay10
val label = if (isForward) stringRes(R.string.skip_forward, SKIP_SECONDS) else stringRes(R.string.skip_back, SKIP_SECONDS)
val label = if (isForward) stringRes(Res.string.skip_forward, SKIP_SECONDS) else stringRes(Res.string.skip_back, SKIP_SECONDS)
val tint = if (enabled) Color.White else Color.White.copy(alpha = 0.38f)
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(48.dp)) {
Icon(symbol = icon, contentDescription = label, tint = tint, modifier = Modifier.size(32.dp))
@@ -21,11 +21,16 @@
package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.ui.state.produceCachedState
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
val mediaItemCache = MediaItemCache()
@@ -46,10 +51,21 @@ fun GetMediaItem(
dim: DimensionTag? = null,
hash: String? = null,
thumbhash: String? = null,
captions: ImmutableList<CaptionTrack> = persistentListOf(),
inner: @Composable (LoadedMediaItem) -> Unit,
) {
val data =
remember(videoUri) {
// Captions join the key. They resolve asynchronously — a `text-track` that names a
// kind-39307 coordinate has to reach LocalCache first — so keying on the URI alone would
// lock in the caption-less item built on the first frame. The list only ever goes empty ->
// resolved, so this rebuilds at most once.
//
// What that rebuild reaches depends on when it lands. A feed video is mounted long before
// it is scrolled into view and played, so the item carrying the tracks is the one the
// controller loads. For a video already playing, GetVideoController's warm-path check sees
// the same mediaId and deliberately does not re-set the item, so the late tracks wait for
// the next mount rather than restarting playback under the viewer.
remember(videoUri, captions) {
MediaItemData(
videoUri = videoUri,
authorName = authorName,
@@ -66,6 +82,7 @@ fun GetMediaItem(
dim = dim,
hash = hash,
thumbhash = thumbhash,
captions = captions,
)
}
@@ -79,7 +96,19 @@ fun GetMediaItem(
) {
val mediaItem by produceCachedState(cache = mediaItemCache, key = data)
mediaItem?.let {
// produceCachedState re-remembers on a new key and starts at null while the replacement is
// built off-thread. Rendering that null would drop the whole player subtree for a frame and
// rebuild it — a visible blink — and for the one case that changes the key mid-playback
// (captions resolving after the first frame) the warm-pool check then declines to re-set an
// item carrying the same mediaId, so the blink would buy nothing. Keep drawing the item we
// already have until its replacement exists.
val previous = remember { mutableStateOf<LoadedMediaItem?>(null) }
val current = mediaItem
if (current != null && previous.value !== current) {
SideEffect { previous.value = current }
}
(current ?: previous.value)?.let {
inner(it)
}
}
@@ -22,9 +22,11 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import android.os.Bundle
import androidx.core.net.toUri
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import androidx.media3.common.MimeTypes
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.commons.ui.state.GenericBaseCache
import com.vitorpamplona.quartz.utils.Log
import kotlinx.coroutines.Dispatchers
@@ -61,6 +63,25 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
return null
}
// A side-loaded track needs its media type declared: a NIP-71 `text-track` says nothing
// about the format, and a blossom-hosted one is `https://host/<sha256>` with no extension
// for ExoPlayer to infer from either. A track flagged default is the one ExoPlayer
// selects without being asked.
internal fun CaptionTrack.toSubtitleConfiguration(isDefault: Boolean = false): MediaItem.SubtitleConfiguration =
MediaItem.SubtitleConfiguration
.Builder(url.toUri())
.setMimeType(mimeType)
.setLanguage(language)
.setLabel(label())
.setSelectionFlags(if (isDefault) C.SELECTION_FLAG_DEFAULT else 0)
.build()
private fun CaptionTrack.label(): String? =
when {
language != null && type != null -> "$language \u00B7 $type"
else -> language ?: type
}
// Restrict `.m3u8` matching to the path component so a query param or
// fragment that happens to mention .m3u8 (e.g. `?ref=a.m3u8`) on an
// MP4 URI doesn't misroute to HlsMediaSource.
@@ -83,7 +104,15 @@ class MediaItemCache : GenericBaseCache<MediaItemData, LoadedMediaItem>(20) {
.setMediaId(key.videoUri)
.setUri(key.videoUri)
.apply { normalizedMime?.let { setMimeType(it) } }
.setMediaMetadata(
.setSubtitleConfigurations(
// Flagging the first track default is what decides WHICH track plays
// once text is enabled. WHETHER it is enabled is the account's
// captionsEnabled preference, which RenderTopButtons pushes onto the
// player as a track-type switch — the two are independent.
key.captions.mapIndexed { index, track ->
track.toSubtitleConfiguration(isDefault = index == 0)
},
).setMediaMetadata(
MediaMetadata
.Builder()
.setArtist(key.authorName?.ifBlank { null })
@@ -22,8 +22,11 @@ package com.vitorpamplona.amethyst.service.playback.composable.mediaitem
import androidx.compose.runtime.Immutable
import androidx.media3.common.MediaItem
import com.vitorpamplona.amethyst.commons.model.nip71Video.CaptionTrack
import com.vitorpamplona.amethyst.service.playback.composable.WaveformData
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@Immutable
data class MediaItemData(
@@ -48,6 +51,9 @@ data class MediaItemData(
val dim: DimensionTag? = null,
val hash: String? = null,
val thumbhash: String? = null,
// NIP-71 `text-track` captions to side-load. A data class is the cache key, so this has to be
// a value that compares by content — ImmutableList keeps that true and keeps the key @Stable.
val captions: ImmutableList<CaptionTrack> = persistentListOf(),
)
@Immutable
@@ -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,
@@ -29,6 +29,8 @@ import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.MergingMediaSource
import androidx.media3.exoplayer.source.SingleSampleMediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import com.vitorpamplona.amethyst.service.playback.PLAYBACK_DIAG_TAG
import com.vitorpamplona.amethyst.service.playback.composable.mediaitem.MediaItemCache
@@ -104,6 +106,7 @@ class CustomMediaSourceFactory(
dataSourceFactory: DataSource.Factory,
) : MediaSource.Factory {
private val cachingDataSource: DataSource.Factory = videoCache.get(dataSourceFactory)
private val plainDataSource: DataSource.Factory = dataSourceFactory
private val cachingFactory: MediaSource.Factory =
DefaultMediaSourceFactory(cachingDataSource)
@@ -119,9 +122,10 @@ class CustomMediaSourceFactory(
//
// The cost of bypassing it: HLS items skip what DefaultMediaSourceFactory wraps around the
// source — side-loaded subtitleConfigurations (MergingMediaSource), clipping, ad insertion, and
// live target-offset defaults. None are reachable today (MediaItemCache sets none of them, and
// the live setters aren't on the MediaSource.Factory interface), but anything added later must
// be mirrored here.
// live target-offset defaults. Subtitles ARE reachable now (MediaItemCache side-loads NIP-71
// `text-track` captions), so createMediaSource below mirrors that wrap for the HLS path;
// clipping and ad insertion remain unused, and the live setters aren't on the
// MediaSource.Factory interface. Anything else added later must be mirrored here too.
private val cachingHlsFactory: MediaSource.Factory = hlsFactory(cachingDataSource)
private val nonCachingHlsFactory: MediaSource.Factory = hlsFactory(dataSourceFactory)
@@ -137,7 +141,13 @@ class CustomMediaSourceFactory(
return this
}
// Retained as well as forwarded: the subtitle sources in withSideLoadedSubtitles are built per
// call rather than living in allFactories, so this is the only way they can be given the same
// retry/backoff behaviour the player configured for everything else.
private var loadErrorHandlingPolicy: LoadErrorHandlingPolicy? = null
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
this.loadErrorHandlingPolicy = loadErrorHandlingPolicy
allFactories.forEach { it.setLoadErrorHandlingPolicy(loadErrorHandlingPolicy) }
return this
}
@@ -161,7 +171,16 @@ class CustomMediaSourceFactory(
} else {
if (bypassCache) nonCachingFactory else cachingFactory
}
val source = factory.createMediaSource(mediaItem)
// DefaultMediaSourceFactory is what merges side-loaded subtitle tracks into the playback
// source. The explicit HLS factory above does not, so on that path a caption track would
// load into nothing; mirror the wrap here. Progressive items already got it from the
// Default factory, so they must NOT be wrapped again.
val source =
if (hls) {
withSideLoadedSubtitles(factory.createMediaSource(mediaItem), mediaItem, bypassCache)
} else {
factory.createMediaSource(mediaItem)
}
// Logs the routing inputs directly rather than a re-derived label, so it can't drift from
// shouldBypassCache.
@@ -172,6 +191,38 @@ class CustomMediaSourceFactory(
return source
}
/**
* Rebuilds what [DefaultMediaSourceFactory] does for `subtitleConfigurations`: each side-loaded
* track becomes its own single-sample source, merged alongside the video.
*
* Subtitle files are small, immutable documents, so they follow the video's cache decision —
* a track fetched beside a cached VOD playlist is worth keeping too, and one beside a live
* stream is not worth a cache entry for a stream that is never replayed.
*/
private fun withSideLoadedSubtitles(
source: MediaSource,
mediaItem: MediaItem,
bypassCache: Boolean,
): MediaSource {
val subtitles = mediaItem.localConfiguration?.subtitleConfigurations.orEmpty()
if (subtitles.isEmpty()) return source
val dataSource = if (bypassCache) plainDataSource else cachingDataSource
val sources =
Array(subtitles.size + 1) { index ->
if (index == 0) {
source
} else {
SingleSampleMediaSource
.Factory(dataSource)
.apply { loadErrorHandlingPolicy?.let { setLoadErrorHandlingPolicy(it) } }
.createMediaSource(subtitles[index - 1], C.TIME_UNSET)
}
}
return MergingMediaSource(*sources)
}
// Only the explicit event-kind flag (kind:30311 live activities). Returns false when the flag
// is absent or false; the HLS URL check in createMediaSource covers everything else.
private fun isFlaggedLive(mediaItem: MediaItem): Boolean {
@@ -21,11 +21,23 @@
package com.vitorpamplona.amethyst.service.pow
import android.content.Context
import androidx.compose.runtime.Composable
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_days
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_hours
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_instant
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_minutes
import com.vitorpamplona.amethyst.commons.resources.pow_estimate_seconds
import com.vitorpamplona.amethyst.commons.resources.pow_time_left
import com.vitorpamplona.amethyst.commons.resources.pow_time_left_soon
import com.vitorpamplona.amethyst.commons.service.pow.PoWEstimator
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.stringRes
import org.jetbrains.compose.resources.PluralStringResource
import kotlin.math.roundToLong
/**
@@ -46,6 +58,75 @@ suspend fun deviceHashesPerSecond(): Double = PoWEstimator.hashesPerSecond(PoWPo
* and are the statistical mean of a memoryless search — any single post can be
* much luckier or unluckier, so always present these as approximations.
*/
@Composable
fun formatApproxDuration(seconds: Double): String {
@Composable
fun quantity(
id: PluralStringResource,
count: Long,
) = pluralStringRes(id, count.toInt(), count.toInt())
return when {
seconds < 1.0 -> stringRes(Res.string.pow_estimate_instant)
seconds < 90.0 -> quantity(Res.plurals.pow_estimate_seconds, seconds.roundToLong())
seconds < 90.0 * 60.0 -> quantity(Res.plurals.pow_estimate_minutes, (seconds / 60.0).roundToLong())
seconds < 48.0 * 3600.0 -> quantity(Res.plurals.pow_estimate_hours, (seconds / 3600.0).roundToLong())
else -> quantity(Res.plurals.pow_estimate_days, (seconds / 86400.0).roundToLong())
}
}
/**
* "≈ 10 minutes left" while [elapsedSec] is inside the [expectedSec] mean,
* "any moment now" once past it — a memoryless search has no shrinking
* remainder, so past the mean the only honest claim is "soon".
*/
@Composable
fun formatTimeLeft(
expectedSec: Double,
elapsedSec: Long,
): String {
val remaining = expectedSec - elapsedSec
return if (remaining > 1.0) {
stringRes(Res.string.pow_time_left, formatApproxDuration(remaining))
} else {
stringRes(Res.string.pow_time_left_soon)
}
}
/** Suspend twin of [formatApproxDuration] for the mining foreground service,
* which builds its notification text outside composition. */
suspend fun loadApproxDuration(seconds: Double): String {
suspend fun quantity(
id: PluralStringResource,
count: Long,
) = loadPluralStringRes(id, count.toInt(), count.toInt())
return when {
seconds < 1.0 -> loadStringRes(Res.string.pow_estimate_instant)
seconds < 90.0 -> quantity(Res.plurals.pow_estimate_seconds, seconds.roundToLong())
seconds < 90.0 * 60.0 -> quantity(Res.plurals.pow_estimate_minutes, (seconds / 60.0).roundToLong())
seconds < 48.0 * 3600.0 -> quantity(Res.plurals.pow_estimate_hours, (seconds / 3600.0).roundToLong())
else -> quantity(Res.plurals.pow_estimate_days, (seconds / 86400.0).roundToLong())
}
}
/** Suspend twin of [formatTimeLeft]; see [loadApproxDuration]. */
suspend fun loadTimeLeft(
expectedSec: Double,
elapsedSec: Long,
): String {
val remaining = expectedSec - elapsedSec
return if (remaining > 1.0) {
loadStringRes(Res.string.pow_time_left, loadApproxDuration(remaining))
} else {
loadStringRes(Res.string.pow_time_left_soon)
}
}
/**
* Android-resource twin of [formatApproxDuration]; see [powKindLabelResId] for why
* the mining notification cannot read from the Compose catalog.
*/
fun formatApproxDuration(
context: Context,
seconds: Double,
@@ -64,11 +145,7 @@ fun formatApproxDuration(
}
}
/**
* "≈ 10 minutes left" while [elapsedSec] is inside the [expectedSec] mean,
* "any moment now" once past it — a memoryless search has no shrinking
* remainder, so past the mean the only honest claim is "soon".
*/
/** Android-resource twin of [formatTimeLeft]. */
fun formatTimeLeft(
context: Context,
expectedSec: Double,
@@ -20,8 +20,16 @@
*/
package com.vitorpamplona.amethyst.service.pow
import androidx.annotation.StringRes
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.boost
import com.vitorpamplona.amethyst.commons.resources.post
import com.vitorpamplona.amethyst.commons.resources.pow_kind_chat_message
import com.vitorpamplona.amethyst.commons.resources.pow_kind_report
import com.vitorpamplona.amethyst.commons.resources.private_message
import com.vitorpamplona.amethyst.commons.resources.reaction
import com.vitorpamplona.amethyst.commons.resources.voice_post
import com.vitorpamplona.amethyst.commons.resources.voice_reply
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
@@ -31,14 +39,33 @@ import com.vitorpamplona.quartz.nip56Reports.ReportEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceEvent
import com.vitorpamplona.quartz.nipA0VoiceMessages.VoiceReplyEvent
import org.jetbrains.compose.resources.StringResource
/**
* The one user-facing label for "what is being mined": shared by the
* broadcast banner, the mining foreground notification, and failure toasts
* so a job is described the same way everywhere it appears.
*/
@StringRes
fun powKindLabelRes(kind: Int): Int =
fun powKindLabelRes(kind: Int): StringResource =
when (kind) {
ReactionEvent.KIND -> Res.string.reaction
RepostEvent.KIND, GenericRepostEvent.KIND -> Res.string.boost
VoiceEvent.KIND -> Res.string.voice_post
VoiceReplyEvent.KIND -> Res.string.voice_reply
ReportEvent.KIND -> Res.string.pow_kind_report
GiftWrapEvent.KIND -> Res.string.private_message
ChannelMessageEvent.KIND, LiveActivitiesChatMessageEvent.KIND -> Res.string.pow_kind_chat_message
else -> Res.string.post
}
/**
* Android-resource twin of [powKindLabelRes], for [com.vitorpamplona.amethyst.service.pow.PowMiningForegroundService].
*
* A foreground-service notification is built inside Android's startForeground()
* timeout, so its text has to resolve synchronously - the Compose catalog only
* offers a composable and a suspend accessor, neither of which can run there.
*/
fun powKindLabelResId(kind: Int): Int =
when (kind) {
ReactionEvent.KIND -> R.string.reaction
RepostEvent.KIND, GenericRepostEvent.KIND -> R.string.boost
@@ -116,7 +116,7 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
val base =
current?.let {
pluralStringRes(this, R.plurals.pow_mining_job, it.difficulty, stringRes(this, powKindLabelRes(it.kind)), it.difficulty)
pluralStringRes(this, R.plurals.pow_mining_job, it.difficulty, stringRes(this, powKindLabelResId(it.kind)), it.difficulty)
} ?: stringRes(this, R.string.pow_mining_title)
val text =
if (expectedSec != null && elapsedSec != null) {
@@ -26,11 +26,21 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_my_inbox
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_my_own_relay
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_notify_inbox
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_other
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_post_venue
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_read_outbox
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_read_venue
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_send_dm
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_thread
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import org.jetbrains.compose.resources.StringResource
/**
* Loads [pubkey] from the local cache, get-or-creating (and subscribing) if absent, then hands the
@@ -58,15 +68,15 @@ internal fun LoadRelayAuthUser(
* deciding right now ("It won't serve … to readers it can't identify"); these caption a past login in
* the settings log, where a full sentence per row would be the wall of text this screen just lost.
*/
internal fun relayAuthPurposeLabelRes(kind: AuthPurposeKind): Int =
internal fun relayAuthPurposeLabelRes(kind: AuthPurposeKind): StringResource =
when (kind) {
AuthPurposeKind.SEND_DM -> R.string.relay_auth_purpose_send_dm
AuthPurposeKind.NOTIFY_INBOX -> R.string.relay_auth_purpose_notify_inbox
AuthPurposeKind.READ_OUTBOX -> R.string.relay_auth_purpose_read_outbox
AuthPurposeKind.POST_VENUE -> R.string.relay_auth_purpose_post_venue
AuthPurposeKind.READ_VENUE -> R.string.relay_auth_purpose_read_venue
AuthPurposeKind.MY_INBOX -> R.string.relay_auth_purpose_my_inbox
AuthPurposeKind.THREAD -> R.string.relay_auth_purpose_thread
AuthPurposeKind.MY_OWN_RELAY -> R.string.relay_auth_purpose_my_own_relay
AuthPurposeKind.OTHER -> R.string.relay_auth_purpose_other
AuthPurposeKind.SEND_DM -> Res.string.relay_auth_purpose_send_dm
AuthPurposeKind.NOTIFY_INBOX -> Res.string.relay_auth_purpose_notify_inbox
AuthPurposeKind.READ_OUTBOX -> Res.string.relay_auth_purpose_read_outbox
AuthPurposeKind.POST_VENUE -> Res.string.relay_auth_purpose_post_venue
AuthPurposeKind.READ_VENUE -> Res.string.relay_auth_purpose_read_venue
AuthPurposeKind.MY_INBOX -> Res.string.relay_auth_purpose_my_inbox
AuthPurposeKind.THREAD -> Res.string.relay_auth_purpose_thread
AuthPurposeKind.MY_OWN_RELAY -> Res.string.relay_auth_purpose_my_own_relay
AuthPurposeKind.OTHER -> Res.string.relay_auth_purpose_other
}
@@ -51,7 +51,6 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.Placeholder
import androidx.compose.ui.text.PlaceholderVerticalAlign
@@ -63,7 +62,6 @@ import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.Channel
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthPrompt
@@ -72,8 +70,19 @@ import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.cancel
import com.vitorpamplona.amethyst.commons.resources.relay_auth_also_holding_back
import com.vitorpamplona.amethyst.commons.resources.relay_auth_always_allow_everywhere
import com.vitorpamplona.amethyst.commons.resources.relay_auth_always_everywhere_body
import com.vitorpamplona.amethyst.commons.resources.relay_auth_always_everywhere_title
import com.vitorpamplona.amethyst.commons.resources.relay_auth_always_log_in
import com.vitorpamplona.amethyst.commons.resources.relay_auth_log_in
import com.vitorpamplona.amethyst.commons.resources.relay_auth_login_as
import com.vitorpamplona.amethyst.commons.resources.relay_auth_name_and_n_others
import com.vitorpamplona.amethyst.commons.resources.relay_auth_never
import com.vitorpamplona.amethyst.commons.resources.relay_auth_never_allow_everywhere
import com.vitorpamplona.amethyst.commons.resources.relay_auth_never_everywhere_body
import com.vitorpamplona.amethyst.commons.resources.relay_auth_never_everywhere_title
import com.vitorpamplona.amethyst.commons.resources.relay_auth_not_now
import com.vitorpamplona.amethyst.commons.resources.relay_auth_relay_asks
import com.vitorpamplona.amethyst.commons.resources.relay_auth_remember_relay
@@ -93,6 +102,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
@@ -300,11 +310,11 @@ private fun RelayAuthPromptDialog(
} else {
ButtonDefaults.outlinedButtonColors()
},
) { Text(if (rememberRelay) stringRes(R.string.relay_auth_never) else stringRes(Res.string.relay_auth_not_now)) }
) { Text(if (rememberRelay) stringRes(Res.string.relay_auth_never) else stringRes(Res.string.relay_auth_not_now)) }
Button(
onClick = { onChoice(if (rememberRelay) UserAuthChoice.ALWAYS_ALLOW else UserAuthChoice.ALLOW_ONCE) },
modifier = Modifier.weight(1f),
) { Text(if (rememberRelay) stringRes(R.string.relay_auth_always_log_in) else stringRes(Res.string.relay_auth_log_in)) }
) { Text(if (rememberRelay) stringRes(Res.string.relay_auth_always_log_in) else stringRes(Res.string.relay_auth_log_in)) }
}
Row(
modifier = Modifier.fillMaxWidth(),
@@ -313,10 +323,10 @@ private fun RelayAuthPromptDialog(
TextButton(
onClick = { confirming = UserAuthChoice.NEVER_ALLOW_EVERYWHERE },
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
) { Text(stringRes(R.string.relay_auth_never_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
) { Text(stringRes(Res.string.relay_auth_never_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
TextButton(
onClick = { confirming = UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE },
) { Text(stringRes(R.string.relay_auth_always_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
) { Text(stringRes(Res.string.relay_auth_always_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
}
}
},
@@ -343,12 +353,12 @@ private fun PolicyEverywhereConfirmation(
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text(stringRes(if (always) R.string.relay_auth_always_everywhere_title else R.string.relay_auth_never_everywhere_title))
Text(stringRes(if (always) Res.string.relay_auth_always_everywhere_title else Res.string.relay_auth_never_everywhere_title))
},
text = {
Text(
stringRes(
if (always) R.string.relay_auth_always_everywhere_body else R.string.relay_auth_never_everywhere_body,
if (always) Res.string.relay_auth_always_everywhere_body else Res.string.relay_auth_never_everywhere_body,
accountName,
),
)
@@ -370,10 +380,10 @@ private fun PolicyEverywhereConfirmation(
)
},
) {
Text(stringRes(if (always) R.string.relay_auth_always_allow_everywhere else R.string.relay_auth_never_allow_everywhere))
Text(stringRes(if (always) Res.string.relay_auth_always_allow_everywhere else Res.string.relay_auth_never_allow_everywhere))
}
},
dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } },
dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } },
)
}
@@ -558,7 +568,7 @@ private fun secondaryLine(
): String? {
val others = purposes.filter { it !== primary }
if (others.isEmpty()) return null
return pluralStringResource(R.plurals.relay_auth_also_holding_back, others.size, others.size)
return pluralStringRes(Res.plurals.relay_auth_also_holding_back, others.size, others.size)
}
/** A short label for a set of counterparties: the first person's name, or "Alice and 4 others". */
@@ -571,7 +581,7 @@ private fun counterpartyLabel(
val name = rememberCounterpartyName(first, accountViewModel)
if (pubkeys.size == 1) return name
val others = pubkeys.size - 1
return pluralStringResource(R.plurals.relay_auth_name_and_n_others, others, name, others)
return pluralStringRes(Res.plurals.relay_auth_name_and_n_others, others, name, others)
}
/**
@@ -22,8 +22,9 @@ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose
import androidx.compose.runtime.Composable
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.relayClient.notify.NotifyRequestsCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.payment_required_title
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
@@ -50,7 +51,7 @@ fun DisplayNotifyMessages(
NotifyRequestDialog(
title =
stringRes(
id = R.string.payment_required_title,
id = Res.string.payment_required_title,
request.relayUrl.displayUrl(),
),
textContent = request.description,
@@ -36,12 +36,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.error_dialog_button_ok
import com.vitorpamplona.amethyst.commons.resources.notify_block_relay
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
@@ -119,7 +119,7 @@ fun NotifyRequestDialog(
)
Spacer(StdHorzSpacer)
Text(
text = stringRes(R.string.notify_block_relay),
text = stringRes(Res.string.notify_block_relay),
color = MaterialTheme.colorScheme.error,
)
}
@@ -59,6 +59,21 @@ fun observeNote(
return flow.collectAsStateWithLifecycle()
}
/**
* [observeNote] without the relay half: watches LocalCache and asks no relay for the note.
*
* For a NIP-17 rumor, which has no fetchable id — putting one in a REQ would tell relays the
* private event's identity, the leak [com.vitorpamplona.amethyst.commons.model.Note.isPrivateRumor]
* guards everywhere else. Nothing is lost by not asking: a rumor only ever reaches the cache by
* unwrapping the envelope that carried it, and the always-on gift-wrap tail re-fetches a week of
* those on every cold start, so this flow fires on its own once the envelope lands.
*/
@Composable
fun observeNoteLocally(note: Note): State<NoteState> {
val flow = remember(note) { note.flow().metadata.stateFlow }
return flow.collectAsStateWithLifecycle()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
inline fun <reified T : Event> observeNoteEvent(
@@ -30,19 +30,24 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.pluralStringResource
import com.vitorpamplona.amethyst.Amethyst
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.collectMemorySnapshot
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_message
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_not_now
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_opt_out
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_send
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_title
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_bg_data
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_churn
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_conn_time
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_reconnects
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_wakelock
import com.vitorpamplona.amethyst.commons.service.crashreports.DEV_REPORT_PUBKEY
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
import com.vitorpamplona.amethyst.ui.pluralStringRes
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -111,7 +116,7 @@ fun DisplayResourceUsageAlert(
text = {
Text(
stringRes(
R.string.resource_usage_alert_message,
Res.string.resource_usage_alert_message,
reasonDescription(found),
),
)
@@ -163,32 +168,32 @@ private fun reasonDescription(alert: ResourceUsageAlerts.Alert): String =
when (alert.reason) {
ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_DATA ->
stringRes(
R.string.resource_usage_reason_bg_data,
Res.string.resource_usage_reason_bg_data,
ResourceUsageReportAssembler.formatBytes(alert.value),
)
ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_CONNECTION_TIME ->
stringRes(
R.string.resource_usage_reason_conn_time,
Res.string.resource_usage_reason_conn_time,
ResourceUsageReportAssembler.formatConnHours(alert.value),
)
ResourceUsageAlerts.Reason.WAKELOCK_TIME ->
stringRes(
R.string.resource_usage_reason_wakelock,
Res.string.resource_usage_reason_wakelock,
ResourceUsageReportAssembler.formatDurationMs(alert.value),
)
ResourceUsageAlerts.Reason.PROCESS_CHURN ->
pluralStringResource(
R.plurals.resource_usage_reason_churn,
pluralStringRes(
Res.plurals.resource_usage_reason_churn,
alert.value.toInt(),
alert.value.toInt(),
)
ResourceUsageAlerts.Reason.RECONNECT_CHURN ->
pluralStringResource(
R.plurals.resource_usage_reason_reconnects,
pluralStringRes(
Res.plurals.resource_usage_reason_reconnects,
alert.value.toInt(),
alert.value.toInt(),
)
@@ -28,11 +28,18 @@ import android.content.Intent
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_description
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_id
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_name
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_error_prefix
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_notification_failed_title
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_notification_sent_title
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostNotifier
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.ui.MainActivity
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.extractContentPreview
import com.vitorpamplona.amethyst.ui.stringRes
/**
* Android [ScheduledPostNotifier]: posts a user-visible system notification when a
@@ -43,17 +50,17 @@ import com.vitorpamplona.amethyst.ui.stringRes
class AndroidScheduledPostNotifier(
private val context: Context,
) : ScheduledPostNotifier {
override fun notifySent(post: ScheduledPost) {
override suspend fun notifySent(post: ScheduledPost) {
ensureChannel(context)
post(
context = context,
notId = idFor(post.id),
title = stringRes(context, R.string.scheduled_posts_notification_sent_title),
title = loadStringRes(Res.string.scheduled_posts_notification_sent_title),
body = extractContentPreview(post, 120),
)
}
override fun notifyFailed(
override suspend fun notifyFailed(
post: ScheduledPost,
error: String?,
) {
@@ -63,23 +70,23 @@ class AndroidScheduledPostNotifier(
if (error.isNullOrBlank()) {
snippet
} else {
"$snippet\n${stringRes(context, R.string.scheduled_posts_error_prefix, error)}"
"$snippet\n${loadStringRes(Res.string.scheduled_posts_error_prefix, error)}"
}
post(
context = context,
notId = idFor(post.id),
title = stringRes(context, R.string.scheduled_posts_notification_failed_title),
title = loadStringRes(Res.string.scheduled_posts_notification_failed_title),
body = body,
)
}
private fun post(
private suspend fun post(
context: Context,
notId: Int,
title: String,
body: String,
) {
val channelId = stringRes(context, R.string.app_notification_scheduled_posts_channel_id)
val channelId = loadStringRes(Res.string.app_notification_scheduled_posts_channel_id)
val tapIntent =
Intent(context, MainActivity::class.java).apply {
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
@@ -126,15 +133,15 @@ class AndroidScheduledPostNotifier(
private var channel: NotificationChannel? = null
private const val SCHEDULED_POST_NOT_ID_BASE = 0x70000
fun ensureChannel(context: Context) {
suspend fun ensureChannel(context: Context) {
if (channel != null) return
channel =
NotificationChannel(
stringRes(context, R.string.app_notification_scheduled_posts_channel_id),
stringRes(context, R.string.app_notification_scheduled_posts_channel_name),
loadStringRes(Res.string.app_notification_scheduled_posts_channel_id),
loadStringRes(Res.string.app_notification_scheduled_posts_channel_name),
NotificationManager.IMPORTANCE_DEFAULT,
).apply {
description = stringRes(context, R.string.app_notification_scheduled_posts_channel_description)
description = loadStringRes(Res.string.app_notification_scheduled_posts_channel_description)
}
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.createNotificationChannel(channel!!)
@@ -23,7 +23,17 @@ 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.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
@@ -44,6 +54,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import okhttp3.OkHttpClient
import org.jetbrains.compose.resources.StringResource
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
@@ -67,7 +78,7 @@ sealed class UploadingState {
) : UploadingFinalState()
class Error(
val errorResource: Int,
val errorResource: StringResource,
val params: Array<out String>,
) : UploadingFinalState()
}
@@ -82,7 +93,7 @@ class UploadOrchestrator {
}
fun error(
resId: Int,
resId: StringResource,
vararg params: String,
) = UploadingState.Error(resId, params).also { updateState(0.0, it) }
@@ -115,7 +126,7 @@ class UploadOrchestrator {
if (bytes != null) {
if (bytes.size > 80000) {
return error(R.string.media_too_big_for_nip95)
return error(Res.string.media_too_big_for_nip95)
}
updateState(0.8, UploadingState.Hashing)
@@ -132,11 +143,11 @@ class UploadOrchestrator {
return finish(OrchestratorResult.NIP95Result(it, bytes, originalContentType, originalHash))
},
onFailure = {
return error(R.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName)
return error(Res.string.could_not_check_downloaded_file, it.message ?: it.javaClass.simpleName)
},
)
} else {
return error(R.string.could_not_open_the_compressed_file)
return error(Res.string.could_not_open_the_compressed_file)
}
}
@@ -184,10 +195,10 @@ class UploadOrchestrator {
okHttpClient = Amethyst.instance.roleBasedHttpClientBuilder::okHttpClientForUploads,
)
} catch (_: SignerExceptions.ReadOnlyException) {
error(R.string.login_with_a_private_key_to_be_able_to_upload)
error(Res.string.login_with_a_private_key_to_be_able_to_upload)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(R.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
error(Res.string.failed_to_upload_media, e.message ?: e.javaClass.simpleName)
}
}
@@ -260,13 +271,13 @@ class UploadOrchestrator {
finalState
} catch (_: SignerExceptions.ReadOnlyException) {
error(R.string.login_with_a_private_key_to_be_able_to_upload)
error(Res.string.login_with_a_private_key_to_be_able_to_upload)
} catch (e: BlossomPaymentException) {
// BUD-07: the server wants payment before it will store the blob.
error(R.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl)
error(Res.string.blossom_payment_required, e.payment.reason ?: serverBaseUrl)
} catch (e: Exception) {
if (e is CancellationException) throw e
error(R.string.failed_to_upload_media, e.message?.ifBlank { null } ?: e.javaClass.simpleName)
error(Res.string.failed_to_upload_media, e.message?.ifBlank { null } ?: e.javaClass.simpleName)
}
}
@@ -318,7 +329,7 @@ class UploadOrchestrator {
okHttpClient: (String) -> OkHttpClient,
): UploadingFinalState {
if (uploadResult.url.isNullOrBlank()) {
return error(R.string.server_did_not_provide_a_url_after_uploading)
return error(Res.string.server_did_not_provide_a_url_after_uploading)
}
updateState(0.6, UploadingState.Downloading)
@@ -326,7 +337,7 @@ class UploadOrchestrator {
// Use streaming verification for memory efficiency with large files
val verification =
ImageDownloader().waitAndVerifyStream(uploadResult.url, okHttpClient)
?: return error(R.string.could_not_download_from_the_server)
?: return error(Res.string.could_not_download_from_the_server)
updateState(0.8, UploadingState.Hashing)
@@ -458,10 +469,10 @@ class UploadOrchestrator {
try {
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
} catch (e: AvifMetadataNotVerifiableException) {
return error(R.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName).also {
return error(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName).also {
deleteTempUri(compressed.uri, uri)
}
} ?: return error(R.string.upload_cancelled).also {
} ?: return error(Res.string.upload_cancelled).also {
deleteTempUri(compressed.uri, uri)
}
@@ -500,10 +511,10 @@ class UploadOrchestrator {
try {
stripAfterCompression(uri, compressed, mimeType, compressionQuality, stripMetadata, onStrippingFailed, context)
} catch (e: AvifMetadataNotVerifiableException) {
return error(R.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName).also {
return error(Res.string.avif_metadata_strip_failed, e.message ?: e.javaClass.simpleName).also {
deleteTempUri(compressed.uri, uri)
}
} ?: return error(R.string.upload_cancelled).also {
} ?: return error(Res.string.upload_cancelled).also {
deleteTempUri(compressed.uri, uri)
}
@@ -25,14 +25,16 @@ import android.content.Context
import android.net.Uri
import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_delete_with_message
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_to_server_with_message
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
import com.vitorpamplona.amethyst.service.uploads.extensionFromMimeType
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
@@ -215,11 +217,11 @@ class BlossomUploader {
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (errorMessage != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage))
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage))
} else if (explanation != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), stringRes(context, explanation)))
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), loadStringRes(explanation)))
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString()))
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString()))
}
}
}
@@ -274,9 +276,9 @@ class BlossomUploader {
} else {
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (explanation != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation)))
throw RuntimeException(loadStringRes(Res.string.failed_to_delete_with_message, loadStringRes(explanation)))
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code))
throw RuntimeException(loadStringRes(Res.string.failed_to_delete_with_message, response.code))
}
}
}
@@ -23,13 +23,16 @@ package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.no_blossom_apps_found_description
import com.vitorpamplona.amethyst.commons.resources.no_blossom_apps_found_title
import org.jetbrains.compose.resources.StringResource
import kotlin.coroutines.cancellation.CancellationException
fun openBlossomUriAsIntent(
context: Context,
blossomUri: String,
onError: (Int, Int) -> Unit,
onError: (StringResource, StringResource) -> Unit,
) {
try {
val intent = Intent(Intent.ACTION_VIEW, blossomUri.toUri())
@@ -38,6 +41,6 @@ fun openBlossomUriAsIntent(
context.startActivity(intent)
} catch (e: Exception) {
if (e is CancellationException) throw e
onError(R.string.no_blossom_apps_found_title, R.string.no_blossom_apps_found_description)
onError(Res.string.no_blossom_apps_found_title, Res.string.no_blossom_apps_found_description)
}
}
@@ -27,14 +27,16 @@ import android.provider.OpenableColumns
import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import com.fasterxml.jackson.databind.ObjectMapper
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.ui.loadStringRes
import com.vitorpamplona.amethyst.service.HttpStatusMessages
import com.vitorpamplona.amethyst.service.checkNotInMainThread
import com.vitorpamplona.amethyst.service.uploads.AVIF_EXTENSION
import com.vitorpamplona.amethyst.service.uploads.AVIF_MIME
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
import com.vitorpamplona.quartz.nip36SensitiveContent.ContentWarningTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
@@ -198,10 +200,10 @@ class Nip96Uploader {
if (event != null) {
convertToMediaResult(event)
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message))
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message))
}
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message))
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), result.message))
}
}
} else {
@@ -223,11 +225,11 @@ class Nip96Uploader {
val explanation = HttpStatusMessages.resourceIdFor(response.code)
if (errorMessage != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), errorMessage))
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), errorMessage))
} else if (explanation != null) {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), stringRes(context, explanation)))
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), loadStringRes(explanation)))
} else {
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), response.code.toString()))
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, server.apiUrl.displayUrl(), response.code.toString()))
}
}
}
@@ -315,9 +317,9 @@ class Nip96Uploader {
} 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))
}
}
}
@@ -34,16 +34,20 @@ import com.vitorpamplona.amethyst.debugState
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.lang.LanguageTranslatorService
import com.vitorpamplona.amethyst.service.notifications.NotificationRelayService
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
import com.vitorpamplona.amethyst.service.playback.composable.DEFAULT_MUTED_SETTING
import com.vitorpamplona.amethyst.service.playback.pip.BackgroundMedia
import com.vitorpamplona.amethyst.ui.navigation.findParameterValue
import com.vitorpamplona.amethyst.ui.navigation.findQueryParameterValue
import com.vitorpamplona.amethyst.ui.navigation.routes.Route
import com.vitorpamplona.amethyst.ui.navigation.routes.routeFor
import com.vitorpamplona.amethyst.ui.navigation.routes.routeForPointer
import com.vitorpamplona.amethyst.ui.note.elements.NowProvider
import com.vitorpamplona.amethyst.ui.screen.AccountScreen
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
import com.vitorpamplona.quartz.buzz.invite.BuzzInviteLink
import com.vitorpamplona.quartz.nip01Core.core.AddressableEvent
import com.vitorpamplona.quartz.nip17Dm.base.ChatroomKey
import com.vitorpamplona.quartz.nip19Bech32.Nip19Parser
import com.vitorpamplona.quartz.nip19Bech32.entities.NAddress
import com.vitorpamplona.quartz.nip19Bech32.entities.NEmbed
@@ -86,7 +90,6 @@ class MainActivity : AppCompatActivity() {
Log.d("ActivityLifecycle") { "MainActivity.onCreate $this" }
setContent {
StringResSetup()
AmethystTheme {
NowProvider {
AccountScreen(Amethyst.instance.sessionManager)
@@ -172,6 +175,56 @@ fun fragmentHashtagOrNull(uri: String): String? {
fun isUrlRoute(uri: String) = uri.startsWith("url?id=") || uri.startsWith("nostr:url?id=")
/**
* A private chatroom, addressed by its participants rather than by a message.
* Posted by DM notifications — see [NotificationRoutes.chatroomUri] for why a DM
* cannot deep-link through its own note.
*/
fun isChatroomRoute(uri: String) = uri.startsWith("chatroom?id=") || uri.startsWith("nostr:chatroom?id=")
/**
* Note what this deliberately does *not* do: register the room on the account.
*
* `routeToMessage` would, and that is right for the in-app callers, but wrong here twice over.
* `uriToRoute` runs against whichever account is current, *before* `?account=` is read and the
* switch happens (`AppNavigation.NavigateIfIntentRequested`), so a DM notification for account B
* tapped while A is on screen would insert an empty conversation into **A**'s message list. And
* `nostr:` is an exported, browsable scheme, so any web page could post
* `nostr:chatroom?id=<hex>&account=…` and inject a room of its choosing.
*
* Nothing needs it: `ChatroomFeedFilter.chatroom()` calls `getOrCreatePrivateChatroom` when the
* screen actually opens, by which point the switch has happened and the room lands on the right
* account.
*
* The ids are still checked here — a room key is a set of pubkeys, so anything that isn't one is
* not a room, and a bad deep link should resolve to nothing rather than to an unopenable screen.
*/
fun chatroomRoute(uri: String): Route? {
val users =
uri
.findQueryParameterValue("id")
?.split(',')
?.map { it.trim() }
?.takeIf { it.isNotEmpty() && it.all(::isPubKeyHex) }
?.toSet() ?: return null
return Route.Room(ChatroomKey(users))
}
/** A bare 32-byte lowercase-hex pubkey, the only thing a chatroom key is made of. */
private fun isPubKeyHex(value: String) = value.length == 64 && value.all { it in '0'..'9' || it in 'a'..'f' }
/**
* A NIP-17 private note or reply, addressed by the rumor's own id because its `nevent`
* names the undeliverable gift wrap instead — see [NotificationRoutes.privateNoteUri].
*/
fun isPrivateNoteRoute(uri: String) = uri.startsWith("privatenote?id=") || uri.startsWith("nostr:privatenote?id=")
fun privateNoteRoute(uri: String): Route? {
val id = uri.findQueryParameterValue("id") ?: return null
return Route.EventRedirect(id, isPrivate = true)
}
fun isConnectedAppRoute(uri: String) = uri.startsWith("connectedapp?coordinate=") || uri.startsWith("nostr:connectedapp?coordinate=")
/**
@@ -232,7 +285,7 @@ fun uriToRoute(
account: Account,
): Route? {
if (isNotificationRoute(uri)) {
val scrollTo = runCatching { java.net.URI(uri.removePrefix(NOSTR_URI_PREFIX)).findParameterValue("scrollTo") }.getOrNull()
val scrollTo = uri.findQueryParameterValue("scrollTo")
return Route.Notification(scrollToEventId = scrollTo)
}
if (isActiveSubscriptionsRoute(uri)) {
@@ -250,6 +303,12 @@ fun uriToRoute(
if (isUrlRoute(uri)) {
return urlRoute(uri)
}
if (isChatroomRoute(uri)) {
return chatroomRoute(uri)
}
if (isPrivateNoteRoute(uri)) {
return privateNoteRoute(uri)
}
if (isConnectedAppRoute(uri)) {
return connectedAppRoute(uri)
}
@@ -284,10 +343,15 @@ fun uriToRoute(
}
is NEvent -> {
routeFor(
note = LocalCache.getOrCreateNote(nip19.hex),
loggedIn = account,
) ?: Route.EventRedirect(nip19.hex)
val note = LocalCache.getOrCreateNote(nip19.hex)
// Only fall back to the pointer's own kind while the body is missing: once the
// event is cached it may belong somewhere the kind alone can't name (a channel,
// a chatroom), and routeFor knows that.
if (note.event == null) {
routeForPointer(nip19.kind, nip19.hex) ?: Route.EventRedirect(nip19.hex)
} else {
routeFor(note = note, loggedIn = account) ?: Route.EventRedirect(nip19.hex)
}
}
is NAddress -> {
@@ -24,142 +24,85 @@ import android.content.Context
import android.util.LruCache
import androidx.annotation.DrawableRes
import androidx.annotation.PluralsRes
import androidx.annotation.StringRes
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.painter.Painter
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.lifecycle.compose.LifecycleResumeEffect
import org.jetbrains.compose.resources.PluralStringResource
import org.jetbrains.compose.resources.StringResource
import com.vitorpamplona.amethyst.commons.ui.pluralStringRes as commonsPluralStringRes
import com.vitorpamplona.amethyst.commons.ui.stringRes as commonsStringRes
/**
* Cache for stringResource because it seems to be > 1ms function in some phones
*/
private val resourceCache = LruCache<Int, String>(300)
private var resourceCacheLanguage: String? = null
// Every user-facing string now lives in the commons Compose catalog
// (com.vitorpamplona.amethyst.commons.resources.Res.string.*), so these are thin
// aliases over the commons bridge. They exist only so the ~500 files that import
// `com.vitorpamplona.amethyst.ui.stringRes` did not all have to change their import
// in the same commit; a file moving to commons swaps this import for
// `com.vitorpamplona.amethyst.commons.ui.stringRes` and nothing else.
//
// compose-resources parses each locale file once into a process-wide cache, so
// repeat lookups are map hits and need no cache of their own. The Android
// `R.string` overloads further down are a separate, deliberately small tier - see
// the comment above them.
// Caches most common icons in the app to avoid using disk
private val iconCache = LruCache<Int, LruCache<Int, Painter>>(30)
fun resourceCacheInit() {
resourceCache
resourceCacheLanguage
iconCache
}
fun checkLanguage(currentLanguage: String) {
if (resourceCacheLanguage == null) {
resourceCacheLanguage = currentLanguage
} else {
if (resourceCacheLanguage != currentLanguage) {
resourceCacheLanguage = currentLanguage
resourceCache.evictAll()
}
}
}
@Composable
fun StringResSetup() {
val config = LocalConfiguration.current
if (!config.locales.isEmpty) {
val language = config.locales.get(0).language
LifecycleResumeEffect(language) {
checkLanguage(language)
onPauseOrDispose { }
}
}
}
@Composable
fun stringRes(id: Int): String = resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) }
// Overloads for keys already migrated to commons Compose resources
// (com.vitorpamplona.amethyst.commons.resources.Res.string.*). They let a file
// mix migrated and unmigrated keys under this one import — overload resolution
// picks by argument type — so migrating a key is just R.string.x -> Res.string.x.
// When a whole file moves to commons, swap this import for
// com.vitorpamplona.amethyst.commons.ui.stringRes.
@Composable
fun stringRes(id: StringResource): String = commonsStringRes(id)
@Composable
fun stringRes(
id: StringResource,
vararg args: Any,
vararg args: Any?,
): String = commonsStringRes(id, *args)
@Composable
fun pluralStringRes(
id: PluralStringResource,
count: Int,
vararg args: Any,
vararg args: Any?,
): String = if (args.isEmpty()) commonsPluralStringRes(id, count) else commonsPluralStringRes(id, count, *args)
@Composable
fun stringRes(
id: Int,
vararg args: String,
): String =
String
.format(
LocalConfiguration.current.locales.get(0),
resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) },
*args,
)
// Android-resource accessors. A handful of strings deliberately stay in res/:
// anything a synchronous platform API resolves. A foreground-service notification
// is built inside Android's startForeground() timeout, so its text cannot come
// from the Compose catalog, whose only non-composable accessor is suspend. The
// manifest-referenced keys are here for the same reason.
//
// The LruCache is why these exist as helpers at all: Resources.getString measured
// >1ms on some phones, and a feed re-reads the same label per card.
private val resourceCache = LruCache<Int, String>(300)
@Composable
fun stringRes(
id: Int,
vararg args: Int?,
): String =
String
.format(
LocalConfiguration.current.locales.get(0),
resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) },
*args,
)
@StringRes id: Int,
): String = resourceCache.get(id) ?: stringResource(id).also { resourceCache.put(id, it) }
@Composable
fun stringRes(
@StringRes id: Int,
vararg args: Any?,
): String = stringResource(id, *Array<Any>(args.size) { args[it] ?: "" })
fun stringRes(
ctx: Context,
id: Int,
@StringRes id: Int,
): String = resourceCache.get(id) ?: ctx.getString(id).also { resourceCache.put(id, it) }
fun stringRes(
ctx: Context,
id: Int,
vararg args: String?,
@StringRes id: Int,
vararg args: Any?,
): String {
val res = ctx.resources
return String
.format(
res.configuration.locales.get(0),
resourceCache.get(id) ?: res.getString(id).also { resourceCache.put(id, it) },
*args,
)
return String.format(
res.configuration.locales.get(0),
resourceCache.get(id) ?: res.getString(id).also { resourceCache.put(id, it) },
*args,
)
}
fun stringRes(
ctx: Context,
id: Int,
vararg args: Int?,
): String {
val res = ctx.resources
return String
.format(
res.configuration.locales.get(0),
resourceCache.get(id) ?: res.getString(id).also { resourceCache.put(id, it) },
*args,
)
}
// Plural resolver for non-composable scope (e.g. onClick callbacks). Not cached:
// the resolved string varies by `count` quantity and the resourceCache is keyed by id.
/** Plural resolver for non-composable scope. Not cached: the resolved string
* varies by quantity and resourceCache is keyed by id alone. */
fun pluralStringRes(
ctx: Context,
@PluralsRes id: Int,
@@ -167,6 +110,14 @@ fun pluralStringRes(
vararg formatArgs: Any?,
): String = ctx.resources.getQuantityString(id, count, *formatArgs)
// Caches most common icons in the app to avoid using disk. Drawables are still
// Android resources - only strings moved to the Compose catalog.
private val iconCache = LruCache<Int, LruCache<Int, Painter>>(30)
fun resourceCacheInit() {
iconCache
}
/**
* This cache can only be used if the painter is the only copy on the screen
* It should store a separate Painter for each size. It's safe to just assume
@@ -68,7 +68,6 @@ import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import androidx.lifecycle.viewmodel.compose.viewModel
import coil3.compose.AsyncImage
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.Note
@@ -77,6 +76,7 @@ import com.vitorpamplona.amethyst.commons.resources.add_bitcoin_invoice
import com.vitorpamplona.amethyst.commons.resources.cancel_bitcoin_invoice
import com.vitorpamplona.amethyst.commons.resources.lightning_create_and_add_invoice
import com.vitorpamplona.amethyst.commons.resources.lightning_invoice
import com.vitorpamplona.amethyst.commons.resources.what_s_on_your_mind
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.service.playback.composable.VideoView
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
@@ -409,7 +409,7 @@ private fun MessageField(postViewModel: EditPostViewModel) {
},
placeholder = {
Text(
text = stringRes(R.string.what_s_on_your_mind),
text = stringRes(Res.string.what_s_on_your_mind),
color = MaterialTheme.colorScheme.placeholderText,
)
},
@@ -29,10 +29,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.text.input.TextFieldValue
import androidx.lifecycle.ViewModel
import com.vitorpamplona.amethyst.R
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.Note
import com.vitorpamplona.amethyst.commons.model.User
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_sign_events
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.commons.ui.text.currentWord
import com.vitorpamplona.amethyst.commons.ui.text.insertUrlAtCursor
import com.vitorpamplona.amethyst.commons.ui.text.onUiThread
@@ -48,7 +53,6 @@ import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMediaProcessing
import com.vitorpamplona.amethyst.ui.note.creators.userSuggestions.UserSuggestionState
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.screen.loggedIn.home.UserSuggestionAnchor
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.experimental.nip95.data.FileStorageEvent
import com.vitorpamplona.quartz.experimental.nip95.header.FileStorageHeaderEvent
import com.vitorpamplona.quartz.nip01Core.core.Event
@@ -66,6 +70,7 @@ import com.vitorpamplona.quartz.nip94FileMetadata.sensitiveContent
import com.vitorpamplona.quartz.nip94FileMetadata.size
import com.vitorpamplona.quartz.nip94FileMetadata.thumbhash
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.launch
@Stable
open class EditPostViewModel : ViewModel() {
@@ -167,13 +172,17 @@ open class EditPostViewModel : ViewModel() {
onError: (String, String) -> Unit,
context: Context,
stripMetadata: Boolean = true,
) = try {
uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context, stripMetadata)
} catch (e: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
)
) {
try {
uploadUnsafe(alt, sensitiveContent, mediaQuality, isPrivate, server, onError, context, stripMetadata)
} catch (e: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
loadStringRes(Res.string.read_only_user),
loadStringRes(Res.string.login_with_a_private_key_to_be_able_to_sign_events),
)
}
}
}
fun uploadUnsafe(
@@ -256,9 +265,9 @@ open class EditPostViewModel : ViewModel() {
this@EditPostViewModel.multiOrchestrator = null
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
val errorMessages = results.errors.map { loadStringRes(it.errorResource, *it.params) }.distinct()
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
} finally {
mediaUploadTracker.finishUpload()
@@ -28,9 +28,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.R
import com.vitorpamplona.amethyst.commons.model.VideoPostKind
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_media_no_details
import com.vitorpamplona.amethyst.commons.resources.login_with_a_private_key_to_be_able_to_sign_events
import com.vitorpamplona.amethyst.commons.resources.read_only_user
import com.vitorpamplona.amethyst.commons.richtext.RichTextParser
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.service.uploads.MediaCompressor
import com.vitorpamplona.amethyst.service.uploads.MultiOrchestrator
@@ -41,7 +45,6 @@ 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.amethyst.ui.screen.loggedIn.AccountViewModel
import com.vitorpamplona.amethyst.ui.stringRes
import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.Dispatchers
@@ -101,13 +104,17 @@ open class NewMediaModel : ViewModel() {
accountViewModel: AccountViewModel,
onSucess: () -> Unit,
onError: (String, String) -> Unit,
) = try {
uploadUnsafe(context, accountViewModel, onSucess, onError)
} catch (e: SignerExceptions.ReadOnlyException) {
onError(
stringRes(context, R.string.read_only_user),
stringRes(context, R.string.login_with_a_private_key_to_be_able_to_sign_events),
)
) {
try {
uploadUnsafe(context, accountViewModel, onSucess, onError)
} catch (e: SignerExceptions.ReadOnlyException) {
viewModelScope.launch {
onError(
loadStringRes(Res.string.read_only_user),
loadStringRes(Res.string.login_with_a_private_key_to_be_able_to_sign_events),
)
}
}
}
fun uploadUnsafe(
@@ -215,9 +222,9 @@ open class NewMediaModel : ViewModel() {
onceUploaded()
cancelModel()
} else {
val errorMessages = results.errors.map { stringRes(context, it.errorResource, *it.params) }.distinct()
val errorMessages = results.errors.map { loadStringRes(it.errorResource, *it.params) }.distinct()
onError(stringRes(context, R.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), errorMessages.joinToString(".\n"))
}
}
}
@@ -52,17 +52,24 @@ import androidx.compose.ui.text.style.TextOverflow
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.model.VideoPostKind
import com.vitorpamplona.amethyst.commons.resources.Res
import com.vitorpamplona.amethyst.commons.resources.add_caption
import com.vitorpamplona.amethyst.commons.resources.add_caption_example
import com.vitorpamplona.amethyst.commons.resources.add_sensitive_content_description
import com.vitorpamplona.amethyst.commons.resources.add_sensitive_content_label
import com.vitorpamplona.amethyst.commons.resources.file_server
import com.vitorpamplona.amethyst.commons.resources.file_server_description
import com.vitorpamplona.amethyst.commons.resources.media_compression_quality_explainer
import com.vitorpamplona.amethyst.commons.resources.media_compression_quality_high
import com.vitorpamplona.amethyst.commons.resources.media_compression_quality_label
import com.vitorpamplona.amethyst.commons.resources.media_compression_quality_low
import com.vitorpamplona.amethyst.commons.resources.media_compression_quality_medium
import com.vitorpamplona.amethyst.commons.resources.media_compression_quality_uncompressed
import com.vitorpamplona.amethyst.commons.resources.strip_metadata_description
import com.vitorpamplona.amethyst.commons.resources.strip_metadata_label
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_description
import com.vitorpamplona.amethyst.commons.resources.video_codec_h265_label
import com.vitorpamplona.amethyst.ui.actions.mediaServers.DEFAULT_MEDIA_SERVERS
import com.vitorpamplona.amethyst.ui.actions.uploads.SelectedMedia
import com.vitorpamplona.amethyst.ui.actions.uploads.ShowImageUploadGallery
@@ -203,8 +210,8 @@ fun ImageVideoPost(
)
SettingSwitchItem(
title = R.string.add_sensitive_content_label,
description = R.string.add_sensitive_content_description,
title = Res.string.add_sensitive_content_label,
description = Res.string.add_sensitive_content_description,
modifier =
Modifier
.fillMaxWidth()
@@ -213,7 +220,7 @@ fun ImageVideoPost(
onCheckedChange = { postViewModel.sensitiveContent = it },
)
SettingsRow(R.string.file_server, R.string.file_server_description) {
SettingsRow(Res.string.file_server, Res.string.file_server_description) {
TextSpinner(
label = "",
placeholder =
@@ -274,8 +281,8 @@ fun ImageVideoPost(
// Only show H.265 codec option if there are videos in the upload
if (postViewModel.multiOrchestrator?.hasVideo() == true) {
SettingSwitchItem(
title = R.string.video_codec_h265_label,
description = R.string.video_codec_h265_description,
title = Res.string.video_codec_h265_label,
description = Res.string.video_codec_h265_description,
modifier =
Modifier
.fillMaxWidth()
@@ -286,8 +293,8 @@ fun ImageVideoPost(
}
SettingSwitchItem(
title = R.string.strip_metadata_label,
description = R.string.strip_metadata_description,
title = Res.string.strip_metadata_label,
description = Res.string.strip_metadata_description,
modifier =
Modifier
.fillMaxWidth()
@@ -51,7 +51,6 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardCapitalization
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel
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
@@ -60,15 +59,19 @@ import com.vitorpamplona.amethyst.commons.resources.avatar_url
import com.vitorpamplona.amethyst.commons.resources.banner_url
import com.vitorpamplona.amethyst.commons.resources.clink_offer_label
import com.vitorpamplona.amethyst.commons.resources.display_name
import com.vitorpamplona.amethyst.commons.resources.github
import com.vitorpamplona.amethyst.commons.resources.github_proof_url_template
import com.vitorpamplona.amethyst.commons.resources.lightning_address
import com.vitorpamplona.amethyst.commons.resources.lnurl
import com.vitorpamplona.amethyst.commons.resources.mastodon
import com.vitorpamplona.amethyst.commons.resources.mastodon_proof_url_template
import com.vitorpamplona.amethyst.commons.resources.my_display_name
import com.vitorpamplona.amethyst.commons.resources.my_name
import com.vitorpamplona.amethyst.commons.resources.nip_05
import com.vitorpamplona.amethyst.commons.resources.profile
import com.vitorpamplona.amethyst.commons.resources.pronouns
import com.vitorpamplona.amethyst.commons.resources.social_proof
import com.vitorpamplona.amethyst.commons.resources.twitter
import com.vitorpamplona.amethyst.commons.resources.twitter_proof_url_template
import com.vitorpamplona.amethyst.commons.resources.username
import com.vitorpamplona.amethyst.commons.resources.website_url
@@ -104,7 +107,7 @@ fun NewUserMetadataScreen(
Scaffold(
topBar = {
SavingTopBar(
titleRes = R.string.profile,
titleRes = Res.string.profile,
onCancel = {
nav.popBack()
},
@@ -343,7 +346,7 @@ fun NewUserMetadataScreen(
expanded = socialExpanded,
) {
OutlinedTextField(
label = { Text(text = stringRes(R.string.twitter)) },
label = { Text(text = stringRes(Res.string.twitter)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.twitter.value,
onValueChange = { postViewModel.twitter.value = it },
@@ -358,7 +361,7 @@ fun NewUserMetadataScreen(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringRes(R.string.mastodon)) },
label = { Text(text = stringRes(Res.string.mastodon)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.mastodon.value,
onValueChange = { postViewModel.mastodon.value = it },
@@ -373,7 +376,7 @@ fun NewUserMetadataScreen(
Spacer(modifier = Modifier.height(10.dp))
OutlinedTextField(
label = { Text(text = stringRes(R.string.github)) },
label = { Text(text = stringRes(Res.string.github)) },
modifier = Modifier.fillMaxWidth(),
value = postViewModel.github.value,
onValueChange = { postViewModel.github.value = it },

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