mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
Merge remote-tracking branch 'origin/main' into claude/epic-ptolemy-48n37b
# Conflicts: # commonsUI/src/commonMain/composeResources/values/strings.xml
This commit is contained in:
@@ -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` |
|
||||
|
||||
+11
-7
@@ -40,9 +40,14 @@ import androidx.media3.common.PlaybackException
|
||||
import androidx.media3.common.Player
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_video_open_in_browser
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed_description
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -107,7 +112,7 @@ class PlaybackErrorOverlayFitTest {
|
||||
|
||||
private fun assertButtonUsable() {
|
||||
rule
|
||||
.onNodeWithText(stringRes(targetContext, R.string.error_video_open_in_browser))
|
||||
.onNodeWithText(runBlocking { loadStringRes(Res.string.error_video_open_in_browser) })
|
||||
.assertIsDisplayed()
|
||||
.assertHeightIsAtLeast(ButtonDefaults.MinHeight)
|
||||
}
|
||||
@@ -117,7 +122,7 @@ class PlaybackErrorOverlayFitTest {
|
||||
renderInBox(width = 322.dp, height = 181.dp)
|
||||
assertButtonUsable()
|
||||
rule
|
||||
.onNodeWithText(stringRes(targetContext, R.string.error_video_playback_failed))
|
||||
.onNodeWithText(runBlocking { loadStringRes(Res.string.error_video_playback_failed) })
|
||||
.assertIsDisplayed()
|
||||
}
|
||||
|
||||
@@ -137,8 +142,7 @@ class PlaybackErrorOverlayFitTest {
|
||||
rule
|
||||
.onNodeWithText(
|
||||
stringRes(
|
||||
targetContext,
|
||||
R.string.error_video_playback_failed_description,
|
||||
Res.string.error_video_playback_failed_description,
|
||||
"ERROR_CODE_PARSING_MANIFEST_MALFORMED",
|
||||
),
|
||||
).assertDoesNotExist()
|
||||
@@ -225,7 +229,7 @@ class PlaybackErrorOverlayFitTest {
|
||||
private fun titleHeight(): Dp {
|
||||
val bounds =
|
||||
rule
|
||||
.onNodeWithText(stringRes(targetContext, R.string.error_video_playback_failed))
|
||||
.onNodeWithText(runBlocking { loadStringRes(Res.string.error_video_playback_failed) })
|
||||
.getUnclippedBoundsInRoot()
|
||||
return bounds.bottom - bounds.top
|
||||
}
|
||||
@@ -233,7 +237,7 @@ class PlaybackErrorOverlayFitTest {
|
||||
private fun buttonHeight(): Dp {
|
||||
val bounds =
|
||||
rule
|
||||
.onNodeWithText(stringRes(targetContext, R.string.error_video_open_in_browser))
|
||||
.onNodeWithText(runBlocking { loadStringRes(Res.string.error_video_open_in_browser) })
|
||||
.getUnclippedBoundsInRoot()
|
||||
return bounds.bottom - bounds.top
|
||||
}
|
||||
|
||||
+2
-3
@@ -52,12 +52,10 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
@@ -66,6 +64,7 @@ import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_block
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_button
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_connect_how_handle
|
||||
@@ -272,7 +271,7 @@ private fun SignerConnectScreen(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
OutlinedButton(onClick = onCancel, modifier = Modifier.weight(1f)) {
|
||||
Text(stringResource(R.string.cancel))
|
||||
Text(stringRes(Res.string.cancel))
|
||||
}
|
||||
Button(onClick = { onConnect(selected) }, modifier = Modifier.weight(1f)) {
|
||||
Text(stringRes(Res.string.napplet_connect_button))
|
||||
|
||||
+3
-2
@@ -22,8 +22,9 @@ package com.vitorpamplona.amethyst.connectedApps.consent
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_connect_title
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
@@ -88,7 +89,7 @@ object SignerConnectCoordinator {
|
||||
activityClass = SignerConnectActivity::class.java,
|
||||
extraKey = EXTRA_TOKEN,
|
||||
token = token,
|
||||
titleRes = R.string.nip46_signer_notif_connect_title,
|
||||
titleRes = Res.string.nip46_signer_notif_connect_title,
|
||||
)
|
||||
|
||||
return try {
|
||||
|
||||
+7
-6
@@ -60,8 +60,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -69,7 +67,6 @@ import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.window.Dialog
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon
|
||||
@@ -93,12 +90,16 @@ import com.vitorpamplona.amethyst.commons.resources.napplet_signer_deny_op
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_allow
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_deny
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_remember
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_select_all
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_select_none
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_signing_as
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_batch_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_messages_with
|
||||
import com.vitorpamplona.amethyst.service.call.CallSessionBridge
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav
|
||||
import com.vitorpamplona.amethyst.ui.note.NoteCompose
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.AmethystTheme
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -483,7 +484,7 @@ private fun BatchedConsentDialog(
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
pluralStringResource(R.plurals.nip46_signer_batch_title, pending.size, pending.size),
|
||||
pluralStringRes(Res.plurals.nip46_signer_batch_title, pending.size, pending.size),
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
account.accountName?.let { name ->
|
||||
@@ -504,8 +505,8 @@ private fun BatchedConsentDialog(
|
||||
contentPadding = PaddingValues(horizontal = 20.dp, vertical = 2.dp),
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (selected.size == pending.size) R.string.nip46_signer_batch_select_none else R.string.nip46_signer_batch_select_all,
|
||||
stringRes(
|
||||
if (selected.size == pending.size) Res.string.nip46_signer_batch_select_none else Res.string.nip46_signer_batch_select_all,
|
||||
),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
|
||||
+3
-2
@@ -22,9 +22,10 @@ package com.vitorpamplona.amethyst.connectedApps.consent
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_sign_title
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
@@ -140,7 +141,7 @@ object SignerConsentCoordinator {
|
||||
activityClass = SignerConsentActivity::class.java,
|
||||
extraKey = EXTRA_TOKEN,
|
||||
token = "nip46-signer-consent",
|
||||
titleRes = R.string.nip46_signer_notif_sign_title,
|
||||
titleRes = Res.string.nip46_signer_notif_sign_title,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+13
-8
@@ -28,7 +28,12 @@ import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_channel_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_notif_tap
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/**
|
||||
* Surfaces a signer consent/connect [android.app.Activity] from the **background**.
|
||||
@@ -51,17 +56,17 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
object SignerConsentNotifier {
|
||||
private const val CHANNEL_ID = "com.vitorpamplona.amethyst.SIGNER_CONSENT_CHANNEL"
|
||||
|
||||
private fun ensureChannel(context: Context): NotificationChannel {
|
||||
private suspend fun ensureChannel(context: Context): NotificationChannel {
|
||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.getNotificationChannel(CHANNEL_ID)?.let { return it }
|
||||
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
stringRes(context, R.string.nip46_signer_notif_channel_name),
|
||||
loadStringRes(Res.string.nip46_signer_notif_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = stringRes(context, R.string.nip46_signer_notif_channel_desc)
|
||||
description = loadStringRes(Res.string.nip46_signer_notif_channel_desc)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
return channel
|
||||
@@ -72,12 +77,12 @@ object SignerConsentNotifier {
|
||||
* [activityClass] carrying [token]. Returns the notification id to pass to [cancel] once the
|
||||
* request resolves.
|
||||
*/
|
||||
fun show(
|
||||
suspend fun show(
|
||||
context: Context,
|
||||
activityClass: Class<*>,
|
||||
extraKey: String,
|
||||
token: String,
|
||||
titleRes: Int,
|
||||
titleRes: StringResource,
|
||||
): Int {
|
||||
// When Amethyst already owns the foreground the direct startActivity opens the dialog, so a
|
||||
// heads-up notification would just be redundant noise on top of it. Only fall back to the
|
||||
@@ -104,8 +109,8 @@ object SignerConsentNotifier {
|
||||
NotificationCompat
|
||||
.Builder(context, channel.id)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(stringRes(context, titleRes))
|
||||
.setContentText(stringRes(context, R.string.nip46_signer_notif_tap))
|
||||
.setContentTitle(loadStringRes(titleRes))
|
||||
.setContentText(loadStringRes(Res.string.nip46_signer_notif_tap))
|
||||
.setContentIntent(pendingIntent)
|
||||
.setFullScreenIntent(pendingIntent, true)
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
|
||||
@@ -26,7 +26,6 @@ import android.content.res.Configuration
|
||||
import android.os.Bundle
|
||||
import android.widget.Toast
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.model.ThemeType
|
||||
@@ -57,13 +56,20 @@ import com.vitorpamplona.quartz.utils.Log
|
||||
* failing silently.
|
||||
*/
|
||||
object FavoriteAppLauncher {
|
||||
/**
|
||||
* [stillLoading] is the toast shown when a NostrApp's defining event hasn't
|
||||
* arrived yet. It is passed in already resolved because the launcher runs from
|
||||
* plain onClick lambdas, where neither string accessor - composable or
|
||||
* suspend - can be called.
|
||||
*/
|
||||
fun launch(
|
||||
context: Context,
|
||||
app: FavoriteApp,
|
||||
stillLoading: String,
|
||||
) {
|
||||
when (app) {
|
||||
is FavoriteApp.WebApp -> launchUrl(context, app.url)
|
||||
is FavoriteApp.NostrApp -> launchNostrApp(context, app.coordinate)
|
||||
is FavoriteApp.NostrApp -> launchNostrApp(context, app.coordinate, stillLoading)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,6 +119,7 @@ object FavoriteAppLauncher {
|
||||
private fun launchNostrApp(
|
||||
context: Context,
|
||||
coordinate: String,
|
||||
stillLoading: String,
|
||||
) {
|
||||
val event = LocalCache.getAddressableNoteIfExists(coordinate)?.event
|
||||
when (event) {
|
||||
@@ -146,7 +153,7 @@ object FavoriteAppLauncher {
|
||||
)
|
||||
else -> {
|
||||
Log.w("FavoriteAppLauncher") { "Favorited app not resolvable yet: $coordinate" }
|
||||
Toast.makeText(context, R.string.favorite_app_still_loading, Toast.LENGTH_SHORT).show()
|
||||
Toast.makeText(context, stillLoading, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
@@ -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 =
|
||||
|
||||
+49
-99
@@ -20,6 +20,7 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.model.marmot
|
||||
|
||||
import com.vitorpamplona.amethyst.commons.marmot.EncryptedAppendLog
|
||||
import com.vitorpamplona.amethyst.model.preferences.KeyStoreEncryption
|
||||
import com.vitorpamplona.quartz.marmot.mls.group.MarmotMessageStore
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
@@ -38,24 +39,16 @@ import java.io.File
|
||||
* <rootDir>/mls_groups/<nostrGroupId>/messages — encrypted message log
|
||||
* ```
|
||||
*
|
||||
* The on-disk format (after decryption) is a sequence of length-prefixed
|
||||
* UTF-8 entries:
|
||||
* ```
|
||||
* uint32 count
|
||||
* for each entry:
|
||||
* uint32 length
|
||||
* byte[length] utf8
|
||||
* ```
|
||||
*
|
||||
* The whole blob is rewritten on each append (via atomic rename) — this
|
||||
* keeps encryption simple (one GCM nonce per write) and is acceptable for
|
||||
* conversation-scale histories.
|
||||
* Every file here is an [EncryptedAppendLog], which owns the on-disk format and
|
||||
* the migration from the original whole-blob one. Recording a message appends a
|
||||
* small encrypted segment instead of rewriting the conversation, which is what
|
||||
* keeps the cost of a send flat as the history grows.
|
||||
*/
|
||||
class AndroidMarmotMessageStore(
|
||||
private val rootDir: File,
|
||||
private val encryption: KeyStoreEncryption = KeyStoreEncryption(),
|
||||
) : MarmotMessageStore {
|
||||
private val writeMutex = Mutex()
|
||||
private val logMutex = Mutex()
|
||||
|
||||
init {
|
||||
Log.d(TAG) {
|
||||
@@ -76,17 +69,16 @@ class AndroidMarmotMessageStore(
|
||||
nostrGroupId: String,
|
||||
innerEventJson: String,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
writeMutex.withLock {
|
||||
logMutex.withLock {
|
||||
try {
|
||||
val existing = readAll(nostrGroupId).toMutableList()
|
||||
if (innerEventJson in existing) {
|
||||
val file = messagesFile(nostrGroupId)
|
||||
if (log.contains(file, innerEventJson)) {
|
||||
Log.d(TAG) { "appendMessage($nostrGroupId): duplicate entry skipped" }
|
||||
return@withLock
|
||||
}
|
||||
existing.add(innerEventJson)
|
||||
writeAll(nostrGroupId, existing)
|
||||
log.append(file, innerEventJson)
|
||||
Log.d(TAG) {
|
||||
"appendMessage($nostrGroupId): now ${existing.size} message(s) persisted"
|
||||
"appendMessage($nostrGroupId): now ${log.readAll(file).size} message(s) persisted"
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "appendMessage($nostrGroupId) FAILED: ${e.message}", e)
|
||||
@@ -98,7 +90,7 @@ class AndroidMarmotMessageStore(
|
||||
override suspend fun loadMessages(nostrGroupId: String): List<String> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val messages = readAll(nostrGroupId)
|
||||
val messages = logMutex.withLock { readAll(nostrGroupId) }
|
||||
Log.d(TAG) {
|
||||
"loadMessages($nostrGroupId): loaded ${messages.size} message(s)"
|
||||
}
|
||||
@@ -111,8 +103,9 @@ class AndroidMarmotMessageStore(
|
||||
|
||||
override suspend fun delete(nostrGroupId: String) {
|
||||
withContext(Dispatchers.IO) {
|
||||
writeMutex.withLock {
|
||||
logMutex.withLock {
|
||||
for (file in listOf(messagesFile(nostrGroupId), epochsFile(nostrGroupId), snapshotFile(nostrGroupId), expiriesFile(nostrGroupId), epochRetentionsFile(nostrGroupId))) {
|
||||
log.forget(file)
|
||||
if (file.exists() && !file.delete()) {
|
||||
Log.w(TAG) { "delete($nostrGroupId): failed to remove ${file.absolutePath}" }
|
||||
}
|
||||
@@ -138,7 +131,7 @@ class AndroidMarmotMessageStore(
|
||||
innerEventId: String,
|
||||
epoch: Long,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
writeMutex.withLock {
|
||||
logMutex.withLock {
|
||||
try {
|
||||
val line = "$innerEventId $epoch"
|
||||
val existing = readAllFrom(epochsFile(nostrGroupId)).toMutableList()
|
||||
@@ -154,7 +147,8 @@ class AndroidMarmotMessageStore(
|
||||
override suspend fun loadEpochs(nostrGroupId: String): Map<String, Long> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
readAllFrom(epochsFile(nostrGroupId))
|
||||
logMutex
|
||||
.withLock { readAllFrom(epochsFile(nostrGroupId)) }
|
||||
.mapNotNull { line ->
|
||||
val parts = line.trim().split(' ')
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
@@ -185,7 +179,7 @@ class AndroidMarmotMessageStore(
|
||||
innerEventId: String,
|
||||
expiresAtSecs: Long,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
writeMutex.withLock {
|
||||
logMutex.withLock {
|
||||
try {
|
||||
val existing = readAllFrom(expiriesFile(nostrGroupId)).toMutableList()
|
||||
if (existing.any { it.substringBefore(' ') == innerEventId }) return@withLock
|
||||
@@ -200,7 +194,8 @@ class AndroidMarmotMessageStore(
|
||||
override suspend fun loadExpiries(nostrGroupId: String): Map<String, Long> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
readAllFrom(expiriesFile(nostrGroupId))
|
||||
logMutex
|
||||
.withLock { readAllFrom(expiriesFile(nostrGroupId)) }
|
||||
.mapNotNull { line ->
|
||||
val parts = line.trim().split(' ')
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
@@ -225,7 +220,7 @@ class AndroidMarmotMessageStore(
|
||||
innerEventIds: Set<String>,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
if (innerEventIds.isEmpty()) return@withContext
|
||||
writeMutex.withLock {
|
||||
logMutex.withLock {
|
||||
try {
|
||||
val kept =
|
||||
readAll(nostrGroupId).filter { json ->
|
||||
@@ -257,7 +252,7 @@ class AndroidMarmotMessageStore(
|
||||
epoch: Long,
|
||||
retentionSecs: Long,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
writeMutex.withLock {
|
||||
logMutex.withLock {
|
||||
try {
|
||||
val existing = readAllFrom(epochRetentionsFile(nostrGroupId)).toMutableList()
|
||||
if (existing.any { it.substringBefore(' ') == epoch.toString() }) return@withLock
|
||||
@@ -272,7 +267,8 @@ class AndroidMarmotMessageStore(
|
||||
override suspend fun loadEpochRetentions(nostrGroupId: String): Map<Long, Long> =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
readAllFrom(epochRetentionsFile(nostrGroupId))
|
||||
logMutex
|
||||
.withLock { readAllFrom(epochRetentionsFile(nostrGroupId)) }
|
||||
.mapNotNull { line ->
|
||||
val parts = line.trim().split(' ')
|
||||
if (parts.size != 2) return@mapNotNull null
|
||||
@@ -302,7 +298,7 @@ class AndroidMarmotMessageStore(
|
||||
nostrGroupId: String,
|
||||
snapshotJson: String,
|
||||
) = withContext(Dispatchers.IO) {
|
||||
writeMutex.withLock {
|
||||
logMutex.withLock {
|
||||
try {
|
||||
writeAllTo(snapshotFile(nostrGroupId), listOf(snapshotJson))
|
||||
} catch (e: Exception) {
|
||||
@@ -314,42 +310,37 @@ class AndroidMarmotMessageStore(
|
||||
override suspend fun loadGroupSnapshot(nostrGroupId: String): String? =
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
readAllFrom(snapshotFile(nostrGroupId)).firstOrNull()
|
||||
logMutex.withLock { readAllFrom(snapshotFile(nostrGroupId)) }.firstOrNull()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "loadGroupSnapshot($nostrGroupId) FAILED: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// The segmented, constant-time-append log every file here is stored as.
|
||||
// Guarded by [logMutex]: it caches decrypted entries so an append never has
|
||||
// to read the log back, and that cache assumes a single owner.
|
||||
private val log =
|
||||
EncryptedAppendLog(
|
||||
encrypt = { encryption.encrypt(it) },
|
||||
// EncryptedAppendLog requires null, not a throw, for a segment it
|
||||
// cannot open — KeyStoreEncryption.decrypt rethrows. Without this
|
||||
// one bad segment would abort the whole read, and a caller that
|
||||
// then sees an empty log can overwrite a history that was merely
|
||||
// unreadable.
|
||||
decrypt = {
|
||||
try {
|
||||
encryption.decrypt(it)
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "a log segment could not be decrypted and was skipped: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
private fun readAll(nostrGroupId: String): List<String> = readAllFrom(messagesFile(nostrGroupId))
|
||||
|
||||
private fun readAllFrom(file: File): List<String> {
|
||||
if (!file.exists()) return emptyList()
|
||||
val encrypted = file.readBytes()
|
||||
val plain = encryption.decrypt(encrypted) ?: return emptyList()
|
||||
if (plain.size < 4) return emptyList()
|
||||
|
||||
var offset = 0
|
||||
val count =
|
||||
((plain[offset++].toInt() and 0xFF) shl 24) or
|
||||
((plain[offset++].toInt() and 0xFF) shl 16) or
|
||||
((plain[offset++].toInt() and 0xFF) shl 8) or
|
||||
(plain[offset++].toInt() and 0xFF)
|
||||
|
||||
val result = ArrayList<String>(count.coerceAtMost(MAX_MESSAGES))
|
||||
for (i in 0 until count) {
|
||||
if (offset + 4 > plain.size) break
|
||||
val len =
|
||||
((plain[offset++].toInt() and 0xFF) shl 24) or
|
||||
((plain[offset++].toInt() and 0xFF) shl 16) or
|
||||
((plain[offset++].toInt() and 0xFF) shl 8) or
|
||||
(plain[offset++].toInt() and 0xFF)
|
||||
if (len < 0 || offset + len > plain.size) break
|
||||
result.add(plain.copyOfRange(offset, offset + len).decodeToString())
|
||||
offset += len
|
||||
}
|
||||
return result
|
||||
}
|
||||
private fun readAllFrom(file: File): List<String> = log.readAll(file)
|
||||
|
||||
private fun writeAll(
|
||||
nostrGroupId: String,
|
||||
@@ -359,51 +350,10 @@ class AndroidMarmotMessageStore(
|
||||
private fun writeAllTo(
|
||||
file: File,
|
||||
messages: List<String>,
|
||||
) {
|
||||
file.parentFile?.mkdirs()
|
||||
|
||||
val encodedEntries = messages.map { it.encodeToByteArray() }
|
||||
val totalSize = 4 + encodedEntries.sumOf { 4 + it.size }
|
||||
val buffer = ByteArray(totalSize)
|
||||
var offset = 0
|
||||
|
||||
val count = encodedEntries.size
|
||||
buffer[offset++] = (count shr 24).toByte()
|
||||
buffer[offset++] = (count shr 16).toByte()
|
||||
buffer[offset++] = (count shr 8).toByte()
|
||||
buffer[offset++] = count.toByte()
|
||||
|
||||
for (entry in encodedEntries) {
|
||||
val len = entry.size
|
||||
buffer[offset++] = (len shr 24).toByte()
|
||||
buffer[offset++] = (len shr 16).toByte()
|
||||
buffer[offset++] = (len shr 8).toByte()
|
||||
buffer[offset++] = len.toByte()
|
||||
entry.copyInto(buffer, offset)
|
||||
offset += len
|
||||
}
|
||||
|
||||
val encrypted = encryption.encrypt(buffer)
|
||||
atomicWrite(file, encrypted)
|
||||
}
|
||||
|
||||
private fun atomicWrite(
|
||||
target: File,
|
||||
data: ByteArray,
|
||||
) {
|
||||
val tempFile = File(target.parentFile, "${target.name}.tmp")
|
||||
tempFile.writeBytes(data)
|
||||
if (!tempFile.renameTo(target)) {
|
||||
tempFile.copyTo(target, overwrite = true)
|
||||
if (!tempFile.delete()) {
|
||||
Log.w(TAG) { "Failed to delete temp file after copy fallback: ${tempFile.absolutePath}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
) = log.rewrite(file, messages)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AndroidMarmotMessageStore"
|
||||
private const val MAX_MESSAGES = 1_000_000
|
||||
private val HEX_PATTERN = Regex("^[0-9a-fA-F]+$")
|
||||
}
|
||||
}
|
||||
|
||||
+65
-1
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.utils.Log
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
|
||||
/**
|
||||
* Android implementation of [MlsGroupStateStore] using file-based encrypted storage.
|
||||
@@ -35,8 +36,9 @@ import java.io.File
|
||||
*
|
||||
* Storage layout:
|
||||
* ```
|
||||
* <rootDir>/mls_groups/<nostrGroupId>/state — encrypted MlsGroupState
|
||||
* <rootDir>/mls_groups/<nostrGroupId>/state — encrypted MlsGroupState
|
||||
* <rootDir>/mls_groups/<nostrGroupId>/retained — encrypted retained epoch secrets
|
||||
* <rootDir>/mls_groups/<nostrGroupId>/ratchet — encrypted OwnSenderRatchet
|
||||
* ```
|
||||
*/
|
||||
class AndroidMlsGroupStateStore(
|
||||
@@ -67,6 +69,8 @@ class AndroidMlsGroupStateStore(
|
||||
|
||||
private fun retainedFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "retained")
|
||||
|
||||
private fun ratchetFile(nostrGroupId: String): File = File(groupDir(nostrGroupId), "ratchet")
|
||||
|
||||
override suspend fun save(
|
||||
nostrGroupId: String,
|
||||
state: ByteArray,
|
||||
@@ -166,6 +170,66 @@ class AndroidMlsGroupStateStore(
|
||||
atomicWrite(file, encryption.encrypt(buffer))
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-send ratchet position, written on its own so a message does not
|
||||
* re-encrypt the whole group state to record ~72 bytes.
|
||||
*
|
||||
* Written atomically, through a temp file and a rename. An in-place write
|
||||
* looked adequate — the record is small and written whole — but the failure
|
||||
* it allows is the one this record exists to prevent. A torn record is
|
||||
* discarded on load, which falls back to the position in the full state:
|
||||
* that is the position as of the last COMMIT, behind by every send since.
|
||||
* The next send would then re-emit every generation in between, reusing
|
||||
* AEAD key+nonce pairs. With a rename, a crash leaves the previous complete
|
||||
* record instead, which is at worst one send behind — and that send's
|
||||
* ciphertext was never published, because publishing waits for this write.
|
||||
*/
|
||||
override suspend fun saveSenderRatchet(
|
||||
nostrGroupId: String,
|
||||
state: ByteArray,
|
||||
): Boolean =
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = ratchetFile(nostrGroupId)
|
||||
try {
|
||||
file.parentFile?.mkdirs()
|
||||
val encrypted = encryption.encrypt(state)
|
||||
val tempFile = File(file.parentFile, "${file.name}.tmp")
|
||||
FileOutputStream(tempFile).use { out ->
|
||||
out.write(encrypted)
|
||||
out.fd.sync()
|
||||
}
|
||||
if (!tempFile.renameTo(file)) {
|
||||
tempFile.copyTo(file, overwrite = true)
|
||||
if (!tempFile.delete()) {
|
||||
Log.w(TAG) { "Failed to delete the temp ratchet file: ${tempFile.absolutePath}" }
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
// Returning false sends the caller to a full state write, which
|
||||
// is slower but carries the same guarantee. Swallowing this and
|
||||
// returning true would drop the position on the floor.
|
||||
Log.w(TAG, "saveSenderRatchet($nostrGroupId) FAILED, falling back to a full state write: ${e.message}", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadSenderRatchet(nostrGroupId: String): ByteArray? =
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = ratchetFile(nostrGroupId)
|
||||
if (!file.exists()) return@withContext null
|
||||
try {
|
||||
encryption.decrypt(file.readBytes())
|
||||
} catch (e: Exception) {
|
||||
// Serious: the fallback is the full state's position, which is
|
||||
// BEHIND, and a behind position re-emits used generations. The
|
||||
// atomic write above is what should make this unreachable, so
|
||||
// if it ever fires the group's key material is suspect.
|
||||
Log.e(TAG, "loadSenderRatchet($nostrGroupId) unreadable — the ratchet may rewind: ${e.message}", e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun loadRetainedEpochs(nostrGroupId: String): List<ByteArray> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val file = retainedFile(nostrGroupId)
|
||||
|
||||
+10
-6
@@ -21,12 +21,16 @@
|
||||
package com.vitorpamplona.amethyst.model.nip46Signer
|
||||
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_for
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_decrypt_failed
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_remote_app
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
|
||||
@@ -65,7 +69,7 @@ object Nip46ConsentBridge {
|
||||
): AppConnectResult {
|
||||
val context = Amethyst.instance.appContext
|
||||
val meta = request.clientMetadata
|
||||
val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
|
||||
val title = meta?.name?.ifBlank { null } ?: loadStringRes(Res.string.nip46_signer_remote_app)
|
||||
val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "…")
|
||||
// The identity being connected to lives in the coordinate; show it as an avatar + name.
|
||||
val face = accountFace(coordinate)
|
||||
@@ -100,7 +104,7 @@ object Nip46ConsentBridge {
|
||||
requestedOps: List<NostrSignerOp>,
|
||||
): AppConnectResult {
|
||||
val context = Amethyst.instance.appContext
|
||||
val title = name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
|
||||
val title = name?.ifBlank { null } ?: loadStringRes(Res.string.nip46_signer_remote_app)
|
||||
val domain = url?.ifBlank { null } ?: (Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)?.take(12)?.plus("…") ?: "")
|
||||
val face = accountFace(coordinate)
|
||||
val info =
|
||||
@@ -138,7 +142,7 @@ object Nip46ConsentBridge {
|
||||
): SignerOpGrant {
|
||||
val context = Amethyst.instance.appContext
|
||||
val info = runCatching { Amethyst.instance.nip46ClientStore.load(coordinate) }.getOrNull()
|
||||
val title = info?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app)
|
||||
val title = info?.name?.ifBlank { null } ?: loadStringRes(Res.string.nip46_signer_remote_app)
|
||||
|
||||
val consentInfo =
|
||||
Nip46ConsentInfoBuilder.build(
|
||||
@@ -152,8 +156,8 @@ object Nip46ConsentBridge {
|
||||
strings =
|
||||
Nip46ConsentStrings(
|
||||
opLabel = { it.label(context) },
|
||||
allowAlwaysFor = { context.getString(R.string.nip46_signer_allow_always_for, it) },
|
||||
decryptFailed = context.getString(R.string.nip46_signer_decrypt_failed),
|
||||
allowAlwaysFor = { loadStringRes(Res.string.nip46_signer_allow_always_for, it) },
|
||||
decryptFailed = loadStringRes(Res.string.nip46_signer_decrypt_failed),
|
||||
),
|
||||
decrypt = { decryptWithAccountSigner(signer, it) },
|
||||
)
|
||||
|
||||
+2
-2
@@ -46,9 +46,9 @@ data class SignerFace(
|
||||
*/
|
||||
class Nip46ConsentStrings(
|
||||
/** Human-readable label for an op, e.g. "read your private messages with Alice". */
|
||||
val opLabel: (NostrSignerOp) -> String,
|
||||
val opLabel: suspend (NostrSignerOp) -> String,
|
||||
/** Button text for the counterparty-scoped grant; the argument is the counterparty's name. */
|
||||
val allowAlwaysFor: (String) -> String,
|
||||
val allowAlwaysFor: suspend (String) -> String,
|
||||
/** Shown as the preview when Amethyst itself could not decrypt the message. */
|
||||
val decryptFailed: String,
|
||||
)
|
||||
|
||||
+64
-3
@@ -22,6 +22,7 @@ package com.vitorpamplona.amethyst.model.preferences
|
||||
|
||||
import android.os.Build
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyInfo
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.security.keystore.StrongBoxUnavailableException
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
@@ -29,6 +30,7 @@ import java.security.KeyStore
|
||||
import javax.crypto.Cipher
|
||||
import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.SecretKeyFactory
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
|
||||
class KeyStoreEncryption {
|
||||
@@ -45,12 +47,65 @@ class KeyStoreEncryption {
|
||||
private const val GCM_TAG_LENGTH_BITS = 128
|
||||
}
|
||||
|
||||
private val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
// One Cipher per thread rather than one shared instance. A Cipher carries
|
||||
// the state of the operation in progress, so two coroutines encrypting on
|
||||
// different Dispatchers.IO threads through the same object would corrupt
|
||||
// each other's output.
|
||||
private val ciphers = ThreadLocal.withInitial { Cipher.getInstance(TRANSFORMATION) }
|
||||
|
||||
private val keyStore = KeyStore.getInstance(ANDROID_KEY_STORE).apply { load(null) }
|
||||
|
||||
private fun getKey(): SecretKey {
|
||||
// The key handle never changes for the life of the alias, but fetching it
|
||||
// is a round trip to the keystore daemon. Every encrypted store here reads
|
||||
// and writes through this class, so doing that per operation put an IPC in
|
||||
// front of every Marmot group-state write and every message appended.
|
||||
@Volatile
|
||||
private var cachedKey: SecretKey? = null
|
||||
|
||||
private fun getKey(): SecretKey =
|
||||
cachedKey ?: synchronized(this) {
|
||||
cachedKey ?: loadOrCreateKey().also { cachedKey = it }
|
||||
}
|
||||
|
||||
private fun loadOrCreateKey(): SecretKey {
|
||||
val existingKey = keyStore.getEntry(KEY_ALIAS, null) as? KeyStore.SecretKeyEntry
|
||||
return existingKey?.secretKey ?: createKey()
|
||||
return (existingKey?.secretKey ?: createKey()).also { logSecurityLevel(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Say once, per process, where this key actually lives.
|
||||
*
|
||||
* It decides the cost of everything encrypted at rest here, and it is not
|
||||
* knowable from the code: [createKeyStrongBoxIfAvailable] is tried first, so
|
||||
* a device with a secure element gets one, and `getEntry` then returns
|
||||
* whatever that device created — possibly years ago, under different code.
|
||||
*
|
||||
* The difference is not small. A secure element runs AES-GCM at roughly
|
||||
* 68 KB/s (1 MiB in ~15s on a Pixel 8), against tens of MB/s for the TEE.
|
||||
* Anything bulk that shows up slow on one device and fine on another is
|
||||
* explained by this line.
|
||||
*/
|
||||
private fun logSecurityLevel(key: SecretKey) {
|
||||
try {
|
||||
val factory = SecretKeyFactory.getInstance(key.algorithm, ANDROID_KEY_STORE)
|
||||
val info = factory.getKeySpec(key, KeyInfo::class.java) as KeyInfo
|
||||
val level =
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
when (info.securityLevel) {
|
||||
KeyProperties.SECURITY_LEVEL_STRONGBOX -> "STRONGBOX (bulk crypto here is ~68 KB/s)"
|
||||
KeyProperties.SECURITY_LEVEL_TRUSTED_ENVIRONMENT -> "TEE"
|
||||
KeyProperties.SECURITY_LEVEL_SOFTWARE -> "SOFTWARE"
|
||||
else -> "UNKNOWN(${info.securityLevel})"
|
||||
}
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
if (info.isInsideSecureHardware) "SECURE_HARDWARE (TEE or StrongBox)" else "SOFTWARE"
|
||||
}
|
||||
Log.i(TAG) { "$KEY_ALIAS security level: $level" }
|
||||
} catch (e: Exception) {
|
||||
// Purely diagnostic — never let it interfere with having a key.
|
||||
Log.d(TAG) { "Could not determine the security level of $KEY_ALIAS: ${e.message}" }
|
||||
}
|
||||
}
|
||||
|
||||
private fun createKeyStrongBoxIfAvailable(): SecretKey? =
|
||||
@@ -95,11 +150,15 @@ class KeyStoreEncryption {
|
||||
fun encrypt(bytes: ByteArray): ByteArray {
|
||||
try {
|
||||
// Initializes the cipher in encrypt mode and encrypts data
|
||||
val cipher = ciphers.get()
|
||||
cipher.init(Cipher.ENCRYPT_MODE, getKey())
|
||||
val iv = cipher.iv
|
||||
val encrypted = cipher.doFinal(bytes)
|
||||
return iv + encrypted
|
||||
} catch (e: Exception) {
|
||||
// A key the system has retired (a wipe, a credential reset) keeps
|
||||
// failing until it is re-read, so the cached handle goes with it.
|
||||
cachedKey = null
|
||||
Log.e(TAG, "encrypt() failed: ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
@@ -112,9 +171,11 @@ class KeyStoreEncryption {
|
||||
// IvParameterSpec), so we must pass the 128-bit auth tag length.
|
||||
val iv = bytes.copyOfRange(0, GCM_IV_LENGTH)
|
||||
val data = bytes.copyOfRange(GCM_IV_LENGTH, bytes.size)
|
||||
val cipher = ciphers.get()
|
||||
cipher.init(Cipher.DECRYPT_MODE, getKey(), GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv))
|
||||
return cipher.doFinal(data)
|
||||
} catch (e: Exception) {
|
||||
cachedKey = null
|
||||
Log.e(TAG, "decrypt() failed (input ${bytes.size} bytes): ${e.message}", e)
|
||||
throw e
|
||||
}
|
||||
|
||||
+63
-20
@@ -20,13 +20,73 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_identity
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_identity_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_inc
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_inc_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_keys
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_keys_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_notify
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_notify_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_relay
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_relay_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_resource
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_resource_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_signer
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_signer_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_storage
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_storage_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_theme
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_theme_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_upload
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_upload_desc
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_value
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_value_desc
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/** Localized display name for a capability, shared by the consent dialog and the permissions screen. */
|
||||
@StringRes
|
||||
fun NappletCapability.labelRes(): Int =
|
||||
fun NappletCapability.labelRes(): StringResource =
|
||||
when (this) {
|
||||
NappletCapability.IDENTITY -> Res.string.napplet_cap_identity
|
||||
NappletCapability.KEYS -> Res.string.napplet_cap_keys
|
||||
NappletCapability.RELAY -> Res.string.napplet_cap_relay
|
||||
NappletCapability.SIGNER -> Res.string.napplet_cap_signer
|
||||
NappletCapability.STORAGE -> Res.string.napplet_cap_storage
|
||||
NappletCapability.VALUE -> Res.string.napplet_cap_value
|
||||
NappletCapability.RESOURCE -> Res.string.napplet_cap_resource
|
||||
NappletCapability.UPLOAD -> Res.string.napplet_cap_upload
|
||||
NappletCapability.THEME -> Res.string.napplet_cap_theme
|
||||
NappletCapability.NOTIFY -> Res.string.napplet_cap_notify
|
||||
NappletCapability.INC -> Res.string.napplet_cap_inc
|
||||
}
|
||||
|
||||
/** Localized one-line description of what a capability lets a napplet do. */
|
||||
fun NappletCapability.descriptionRes(): StringResource =
|
||||
when (this) {
|
||||
NappletCapability.IDENTITY -> Res.string.napplet_cap_identity_desc
|
||||
NappletCapability.KEYS -> Res.string.napplet_cap_keys_desc
|
||||
NappletCapability.RELAY -> Res.string.napplet_cap_relay_desc
|
||||
NappletCapability.SIGNER -> Res.string.napplet_cap_signer_desc
|
||||
NappletCapability.STORAGE -> Res.string.napplet_cap_storage_desc
|
||||
NappletCapability.VALUE -> Res.string.napplet_cap_value_desc
|
||||
NappletCapability.RESOURCE -> Res.string.napplet_cap_resource_desc
|
||||
NappletCapability.UPLOAD -> Res.string.napplet_cap_upload_desc
|
||||
NappletCapability.THEME -> Res.string.napplet_cap_theme_desc
|
||||
NappletCapability.NOTIFY -> Res.string.napplet_cap_notify_desc
|
||||
NappletCapability.INC -> Res.string.napplet_cap_inc_desc
|
||||
}
|
||||
|
||||
/**
|
||||
* Android-resource twin of [labelRes], for [NappletLauncher].
|
||||
*
|
||||
* Launch parameters are minted from a plain onClick, which can call neither the
|
||||
* composable nor the suspend accessor, and the sandbox process has no resources
|
||||
* of its own - so the labels have to be resolved here and passed across.
|
||||
*/
|
||||
fun NappletCapability.labelResId(): Int =
|
||||
when (this) {
|
||||
NappletCapability.IDENTITY -> R.string.napplet_cap_identity
|
||||
NappletCapability.KEYS -> R.string.napplet_cap_keys
|
||||
@@ -40,20 +100,3 @@ fun NappletCapability.labelRes(): Int =
|
||||
NappletCapability.NOTIFY -> R.string.napplet_cap_notify
|
||||
NappletCapability.INC -> R.string.napplet_cap_inc
|
||||
}
|
||||
|
||||
/** Localized one-line description of what a capability lets a napplet do. */
|
||||
@StringRes
|
||||
fun NappletCapability.descriptionRes(): Int =
|
||||
when (this) {
|
||||
NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc
|
||||
NappletCapability.KEYS -> R.string.napplet_cap_keys_desc
|
||||
NappletCapability.RELAY -> R.string.napplet_cap_relay_desc
|
||||
NappletCapability.SIGNER -> R.string.napplet_cap_signer_desc
|
||||
NappletCapability.STORAGE -> R.string.napplet_cap_storage_desc
|
||||
NappletCapability.VALUE -> R.string.napplet_cap_value_desc
|
||||
NappletCapability.RESOURCE -> R.string.napplet_cap_resource_desc
|
||||
NappletCapability.UPLOAD -> R.string.napplet_cap_upload_desc
|
||||
NappletCapability.THEME -> R.string.napplet_cap_theme_desc
|
||||
NappletCapability.NOTIFY -> R.string.napplet_cap_notify_desc
|
||||
NappletCapability.INC -> R.string.napplet_cap_inc_desc
|
||||
}
|
||||
|
||||
+92
-57
@@ -21,22 +21,57 @@
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.PluralsRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletCapability
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_follow_added
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_follow_one
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_follow_removed
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_follows
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_joiner
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_mute_added
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_mute_one
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_mute_removed
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_mutes
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_no_baseline
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_none
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_relay_added
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_relay_removed
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_relays
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_unfollow_one
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_diff_unmute_one
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_effect_deletes
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_effect_tags
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_get_pubkey
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_identity_read
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_nip44_decrypt
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_nip44_encrypt
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_notify
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_pay_amount
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_pay_no_amount
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_publish
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_publish_encrypted
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_publish_preview
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_query
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_resource
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_sign
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_storage
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_consent_upload
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_fallback_title
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.nip01Core.core.fastForEach
|
||||
import com.vitorpamplona.quartz.nip02FollowList.ContactListEvent
|
||||
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
|
||||
import com.vitorpamplona.quartz.nip51Lists.muteList.MuteListEvent
|
||||
import com.vitorpamplona.quartz.nip65RelayList.AdvertisedRelayListEvent
|
||||
import org.jetbrains.compose.resources.PluralStringResource
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/**
|
||||
* Turns a pending [NappletRequest] into the human-readable [NappletConsentInfo] the consent dialog
|
||||
@@ -51,12 +86,12 @@ class NappletConsentSummary(
|
||||
private val context: Context,
|
||||
private val account: Account,
|
||||
) {
|
||||
fun info(
|
||||
suspend fun info(
|
||||
identity: NappletIdentity,
|
||||
capability: NappletCapability,
|
||||
request: NappletRequest,
|
||||
): NappletConsentInfo {
|
||||
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val untitled = loadStringRes(Res.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
|
||||
@@ -68,7 +103,7 @@ class NappletConsentSummary(
|
||||
return NappletConsentInfo(
|
||||
appletTitle = title,
|
||||
coordinate = identity.coordinate,
|
||||
capabilityLabel = context.getString(capability.labelRes()),
|
||||
capabilityLabel = loadStringRes(capability.labelRes()),
|
||||
operationSummary = listOfNotNull(summaryFor(request).ifBlank { null }, consequence?.text).joinToString("\n\n"),
|
||||
allowAlways = capability.canGrantAlways,
|
||||
iconUrl = iconUrl,
|
||||
@@ -118,14 +153,14 @@ class NappletConsentSummary(
|
||||
* the dialog reads "publish a kind 3 event" while the user is actually about to replace their
|
||||
* whole social graph — the summary would be technically true and practically useless.
|
||||
*/
|
||||
private fun consequenceFor(request: NappletRequest): Consequence? =
|
||||
private suspend fun consequenceFor(request: NappletRequest): Consequence? =
|
||||
when (request) {
|
||||
is NappletRequest.Publish -> consequenceFor(request.kind, request.tags)
|
||||
is NappletRequest.SignEvent -> consequenceFor(request.kind, request.tags)
|
||||
else -> null
|
||||
}
|
||||
|
||||
private fun consequenceFor(
|
||||
private suspend fun consequenceFor(
|
||||
kind: Int,
|
||||
tags: Array<Array<String>>,
|
||||
): Consequence? =
|
||||
@@ -135,20 +170,20 @@ class NappletConsentSummary(
|
||||
current = account.kind3FollowList.getFollowListEvent()?.tags,
|
||||
proposed = tags,
|
||||
tagName = "p",
|
||||
template = R.string.napplet_consent_diff_follows,
|
||||
added = R.plurals.napplet_consent_diff_follow_added,
|
||||
removed = R.plurals.napplet_consent_diff_follow_removed,
|
||||
oneAdded = R.string.napplet_consent_diff_follow_one,
|
||||
oneRemoved = R.string.napplet_consent_diff_unfollow_one,
|
||||
template = Res.string.napplet_consent_diff_follows,
|
||||
added = Res.plurals.napplet_consent_diff_follow_added,
|
||||
removed = Res.plurals.napplet_consent_diff_follow_removed,
|
||||
oneAdded = Res.string.napplet_consent_diff_follow_one,
|
||||
oneRemoved = Res.string.napplet_consent_diff_unfollow_one,
|
||||
)
|
||||
AdvertisedRelayListEvent.KIND ->
|
||||
diffOf(
|
||||
current = account.nip65RelayList.getNIP65RelayList()?.tags,
|
||||
proposed = tags,
|
||||
tagName = "r",
|
||||
template = R.string.napplet_consent_diff_relays,
|
||||
added = R.plurals.napplet_consent_diff_relay_added,
|
||||
removed = R.plurals.napplet_consent_diff_relay_removed,
|
||||
template = Res.string.napplet_consent_diff_relays,
|
||||
added = Res.plurals.napplet_consent_diff_relay_added,
|
||||
removed = Res.plurals.napplet_consent_diff_relay_removed,
|
||||
)
|
||||
// Public entries only: a mute list also carries encrypted ones, which are not in `tags`
|
||||
// and so cannot be diffed here.
|
||||
@@ -157,21 +192,21 @@ class NappletConsentSummary(
|
||||
current = account.muteList.getMuteList()?.tags,
|
||||
proposed = tags,
|
||||
tagName = "p",
|
||||
template = R.string.napplet_consent_diff_mutes,
|
||||
added = R.plurals.napplet_consent_diff_mute_added,
|
||||
removed = R.plurals.napplet_consent_diff_mute_removed,
|
||||
oneAdded = R.string.napplet_consent_diff_mute_one,
|
||||
oneRemoved = R.string.napplet_consent_diff_unmute_one,
|
||||
template = Res.string.napplet_consent_diff_mutes,
|
||||
added = Res.plurals.napplet_consent_diff_mute_added,
|
||||
removed = Res.plurals.napplet_consent_diff_mute_removed,
|
||||
oneAdded = Res.string.napplet_consent_diff_mute_one,
|
||||
oneRemoved = Res.string.napplet_consent_diff_unmute_one,
|
||||
)
|
||||
// Deletions have no prior version to compare against — the tags are the whole request.
|
||||
DeletionEvent.KIND ->
|
||||
pluralFor(R.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
|
||||
pluralFor(Res.plurals.napplet_consent_effect_deletes, countTag(tags, "e") + countTag(tags, "a"))
|
||||
?.let { Consequence(it) }
|
||||
// Any other kind: at least tell the user tags exist and can be inspected, so an empty
|
||||
// content preview never reads as "there is nothing else here".
|
||||
else ->
|
||||
if (tags.isNotEmpty()) {
|
||||
pluralFor(R.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) }
|
||||
pluralFor(Res.plurals.napplet_consent_effect_tags, tags.size)?.let { Consequence(it) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@@ -183,45 +218,45 @@ class NappletConsentSummary(
|
||||
* that silently drops 130 follows, and only a diff surfaces that. Falls back to the total when
|
||||
* nothing is cached to compare against.
|
||||
*/
|
||||
private fun diffOf(
|
||||
private suspend fun diffOf(
|
||||
current: Array<Array<String>>?,
|
||||
proposed: Array<Array<String>>,
|
||||
tagName: String,
|
||||
@StringRes template: Int,
|
||||
@PluralsRes added: Int,
|
||||
@PluralsRes removed: Int,
|
||||
@StringRes oneAdded: Int? = null,
|
||||
@StringRes oneRemoved: Int? = null,
|
||||
template: StringResource,
|
||||
added: PluralStringResource,
|
||||
removed: PluralStringResource,
|
||||
oneAdded: StringResource? = null,
|
||||
oneRemoved: StringResource? = null,
|
||||
): Consequence {
|
||||
val next = valuesOf(proposed, tagName)
|
||||
val previous =
|
||||
current?.let { valuesOf(it, tagName) }
|
||||
?: return Consequence(pluralStringRes(context, R.plurals.napplet_consent_diff_no_baseline, next.size, next.size))
|
||||
?: return Consequence(loadPluralStringRes(Res.plurals.napplet_consent_diff_no_baseline, next.size, next.size))
|
||||
|
||||
val addedKeys = next.filter { it !in previous }
|
||||
val removedKeys = previous.filter { it !in next }
|
||||
if (addedKeys.isEmpty() && removedKeys.isEmpty()) {
|
||||
return Consequence(context.getString(R.string.napplet_consent_diff_none))
|
||||
return Consequence(loadStringRes(Res.string.napplet_consent_diff_none))
|
||||
}
|
||||
|
||||
// The overwhelmingly common edit is a single follow/unfollow. Naming and picturing that one
|
||||
// account is far more use than "follows 1 new account" — the user can tell at a glance
|
||||
// whether it is who they expected.
|
||||
if (oneAdded != null && addedKeys.size == 1 && removedKeys.isEmpty()) {
|
||||
subjectOf(addedKeys.first())?.let { return Consequence(context.getString(oneAdded, it.name), it) }
|
||||
subjectOf(addedKeys.first())?.let { return Consequence(loadStringRes(oneAdded, it.name), it) }
|
||||
}
|
||||
if (oneRemoved != null && removedKeys.size == 1 && addedKeys.isEmpty()) {
|
||||
subjectOf(removedKeys.first())?.let { return Consequence(context.getString(oneRemoved, it.name), it) }
|
||||
subjectOf(removedKeys.first())?.let { return Consequence(loadStringRes(oneRemoved, it.name), it) }
|
||||
}
|
||||
|
||||
val parts = listOfNotNull(pluralFor(added, addedKeys.size), pluralFor(removed, removedKeys.size))
|
||||
val summary =
|
||||
if (parts.size == 2) {
|
||||
context.getString(R.string.napplet_consent_diff_joiner, parts[0], parts[1])
|
||||
loadStringRes(Res.string.napplet_consent_diff_joiner, parts[0], parts[1])
|
||||
} else {
|
||||
parts.first()
|
||||
}
|
||||
return Consequence(context.getString(template, summary))
|
||||
return Consequence(loadStringRes(template, summary))
|
||||
}
|
||||
|
||||
/** Resolves a pubkey to a name + picture for the dialog, or null when the user isn't cached. */
|
||||
@@ -244,67 +279,67 @@ class NappletConsentSummary(
|
||||
return out
|
||||
}
|
||||
|
||||
private fun pluralFor(
|
||||
resId: Int,
|
||||
private suspend fun pluralFor(
|
||||
resId: PluralStringResource,
|
||||
count: Int,
|
||||
): String? = if (count <= 0) null else pluralStringRes(context, resId, count, count)
|
||||
): String? = if (count <= 0) null else loadPluralStringRes(resId, count, count)
|
||||
|
||||
private fun countTag(
|
||||
tags: Array<Array<String>>,
|
||||
name: String,
|
||||
): Int = tags.count { it.isNotEmpty() && it[0] == name }
|
||||
|
||||
private fun summaryFor(request: NappletRequest): String =
|
||||
private suspend fun summaryFor(request: NappletRequest): String =
|
||||
when (request) {
|
||||
is NappletRequest.GetPublicKey -> context.getString(R.string.napplet_consent_get_pubkey)
|
||||
is NappletRequest.IdentityRead -> context.getString(R.string.napplet_consent_identity_read)
|
||||
is NappletRequest.GetPublicKey -> loadStringRes(Res.string.napplet_consent_get_pubkey)
|
||||
is NappletRequest.IdentityRead -> loadStringRes(Res.string.napplet_consent_identity_read)
|
||||
is NappletRequest.Publish -> {
|
||||
val preview = request.content.take(160).trim()
|
||||
if (preview.isEmpty()) {
|
||||
context.getString(R.string.napplet_consent_publish, request.kind)
|
||||
loadStringRes(Res.string.napplet_consent_publish, request.kind)
|
||||
} else {
|
||||
context.getString(R.string.napplet_consent_publish_preview, request.kind) + "\n“$preview”"
|
||||
loadStringRes(Res.string.napplet_consent_publish_preview, request.kind) + "\n“$preview”"
|
||||
}
|
||||
}
|
||||
is NappletRequest.SignEvent -> {
|
||||
val preview = request.content.take(160).trim()
|
||||
if (preview.isEmpty()) {
|
||||
context.getString(R.string.napplet_consent_sign, request.kind)
|
||||
loadStringRes(Res.string.napplet_consent_sign, request.kind)
|
||||
} else {
|
||||
context.getString(R.string.napplet_consent_sign, request.kind) + "\n“$preview”"
|
||||
loadStringRes(Res.string.napplet_consent_sign, request.kind) + "\n“$preview”"
|
||||
}
|
||||
}
|
||||
is NappletRequest.PublishEncrypted -> context.getString(R.string.napplet_consent_publish_encrypted)
|
||||
is NappletRequest.PublishEncrypted -> loadStringRes(Res.string.napplet_consent_publish_encrypted)
|
||||
is NappletRequest.Nip44Encrypt -> {
|
||||
val preview = request.plaintext.take(160).trim()
|
||||
val summary = context.getString(R.string.napplet_consent_nip44_encrypt, counterpartyLabel(request.peer))
|
||||
val summary = loadStringRes(Res.string.napplet_consent_nip44_encrypt, counterpartyLabel(request.peer))
|
||||
if (preview.isEmpty()) summary else summary + "\n\u201C$preview\u201D"
|
||||
}
|
||||
// The ciphertext is meaningless to show, so name the counterparty instead — that is
|
||||
// the decision the user is actually making ("let this page read messages from X").
|
||||
is NappletRequest.Nip44Decrypt -> context.getString(R.string.napplet_consent_nip44_decrypt, counterpartyLabel(request.peer))
|
||||
is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> context.getString(R.string.napplet_consent_query)
|
||||
is NappletRequest.Nip44Decrypt -> loadStringRes(Res.string.napplet_consent_nip44_decrypt, counterpartyLabel(request.peer))
|
||||
is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> loadStringRes(Res.string.napplet_consent_query)
|
||||
is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove, is NappletRequest.StorageKeys ->
|
||||
context.getString(R.string.napplet_consent_storage)
|
||||
loadStringRes(Res.string.napplet_consent_storage)
|
||||
is NappletRequest.NotifyCreate -> {
|
||||
val preview = request.title.take(80).trim()
|
||||
if (preview.isEmpty()) context.getString(R.string.napplet_consent_notify) else context.getString(R.string.napplet_consent_notify) + "\n“$preview”"
|
||||
if (preview.isEmpty()) loadStringRes(Res.string.napplet_consent_notify) else loadStringRes(Res.string.napplet_consent_notify) + "\n“$preview”"
|
||||
}
|
||||
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> context.getString(R.string.napplet_consent_notify)
|
||||
is NappletRequest.NotifyList, is NappletRequest.NotifyDismiss -> loadStringRes(Res.string.napplet_consent_notify)
|
||||
is NappletRequest.PayInvoice -> {
|
||||
// getAmountInSats returns ZERO (not null, not a throw) for an amountless BOLT11, so a
|
||||
// naive read renders "pay 0 sats" — telling the user a payment is free when the amount
|
||||
// is in fact unspecified and decided by the payee. Treat non-positive as "no amount".
|
||||
val sats = runCatching { LnInvoiceUtil.getAmountInSats(request.invoice).toLong() }.getOrNull()
|
||||
if (sats != null && sats > 0) {
|
||||
pluralStringRes(context, R.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
|
||||
loadPluralStringRes(Res.plurals.napplet_consent_pay_amount, sats.toInt(), sats)
|
||||
} else {
|
||||
context.getString(R.string.napplet_consent_pay_no_amount)
|
||||
loadStringRes(Res.string.napplet_consent_pay_no_amount)
|
||||
}
|
||||
}
|
||||
NappletRequest.ResourceInfo, is NappletRequest.ResourceBytes, is NappletRequest.ResourceBytesMany ->
|
||||
context.getString(R.string.napplet_consent_resource)
|
||||
is NappletRequest.UploadBlob -> context.getString(R.string.napplet_consent_upload)
|
||||
loadStringRes(Res.string.napplet_consent_resource)
|
||||
is NappletRequest.UploadBlob -> loadStringRes(Res.string.napplet_consent_upload)
|
||||
// Resolved in the broker before consent (negotiation / shell-mediated / cosmetic); never shown.
|
||||
is NappletRequest.RegisterAction, is NappletRequest.UnregisterAction, is NappletRequest.ThemeGet -> ""
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import com.vitorpamplona.amethyst.model.ThemeType
|
||||
import com.vitorpamplona.amethyst.napplethost.HostProfile
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostActivity
|
||||
import com.vitorpamplona.amethyst.napplethost.NappletHostContract
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.verify
|
||||
@@ -176,7 +177,7 @@ object NappletLauncher {
|
||||
val useTor = if (profile.exposesNetwork) NappletNetworkRegistry.useTor(identity.coordinate) else true
|
||||
|
||||
// Resolve capability labels here (the app has the resources) so the sandbox module needs none.
|
||||
val capLabels = declared.map { context.getString(it.labelRes()) }
|
||||
val capLabels = declared.map { stringRes(context, it.labelResId()) }
|
||||
|
||||
val themeType = Amethyst.instance.uiPrefs.value.theme.value
|
||||
val theme =
|
||||
|
||||
@@ -21,7 +21,6 @@
|
||||
package com.vitorpamplona.amethyst.napplet
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.browser.OmniboxInput
|
||||
import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
@@ -30,6 +29,14 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.counterpartyPubKey
|
||||
import com.vitorpamplona.amethyst.commons.napplet.protocol.toNarrowSignerOp
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_fallback_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_op_decrypt_from
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_op_encrypt
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_op_sign_kind_named
|
||||
import com.vitorpamplona.amethyst.commons.resources.nip46_signer_allow_always_for
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo
|
||||
import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry
|
||||
@@ -42,12 +49,12 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
/** Human-readable label for a [NostrSignerOp]. */
|
||||
fun NostrSignerOp.label(context: Context): String =
|
||||
suspend fun NostrSignerOp.label(context: Context): String =
|
||||
when (this) {
|
||||
is NostrSignerOp.SignKind -> context.getString(R.string.napplet_op_sign_kind_named, kindNameFor(context, kind), kind)
|
||||
NostrSignerOp.Encrypt -> context.getString(R.string.napplet_op_encrypt)
|
||||
NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt)
|
||||
is NostrSignerOp.DecryptFrom -> context.getString(R.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
|
||||
is NostrSignerOp.SignKind -> loadStringRes(Res.string.napplet_op_sign_kind_named, kindNameFor(kind), kind)
|
||||
NostrSignerOp.Encrypt -> loadStringRes(Res.string.napplet_op_encrypt)
|
||||
NostrSignerOp.Decrypt -> loadStringRes(Res.string.napplet_op_decrypt)
|
||||
is NostrSignerOp.DecryptFrom -> loadStringRes(Res.string.napplet_op_decrypt_from, counterpartyLabel(counterparty))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,13 +73,13 @@ fun counterpartyLabel(pubKeyHex: HexKey): String {
|
||||
}
|
||||
|
||||
/** Builds the [SignerConsentInfo] needed by the per-op consent dialog. */
|
||||
fun buildSignerConsentInfo(
|
||||
suspend fun buildSignerConsentInfo(
|
||||
context: Context,
|
||||
identity: NappletIdentity,
|
||||
op: NostrSignerOp,
|
||||
request: NappletRequest,
|
||||
): SignerConsentInfo {
|
||||
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val untitled = loadStringRes(Res.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
|
||||
@@ -156,7 +163,7 @@ fun buildSignerConsentInfo(
|
||||
// are present, so deriving them from one value keeps them from disagreeing.
|
||||
narrowOpLabel =
|
||||
(narrowOp as? NostrSignerOp.DecryptFrom)?.let {
|
||||
context.getString(R.string.nip46_signer_allow_always_for, counterpartyLabel(it.counterparty))
|
||||
loadStringRes(Res.string.nip46_signer_allow_always_for, counterpartyLabel(it.counterparty))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -167,12 +174,12 @@ fun buildSignerConsentInfo(
|
||||
* [SignerConnectInfo.requestedPermissions] — otherwise the dialog would be asking the user to
|
||||
* approve a set it never showed them.
|
||||
*/
|
||||
fun buildConnectInfo(
|
||||
suspend fun buildConnectInfo(
|
||||
context: Context,
|
||||
identity: NappletIdentity,
|
||||
declared: Set<NappletCapability> = emptySet(),
|
||||
): SignerConnectInfo {
|
||||
val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val untitled = loadStringRes(Res.string.napplet_fallback_title, identity.authorPubKey.take(8))
|
||||
val (title, iconUrl) =
|
||||
if (identity.authorPubKey == "browser") {
|
||||
val host = OmniboxInput.hostOf(identity.identifier) ?: identity.identifier
|
||||
@@ -191,7 +198,7 @@ fun buildConnectInfo(
|
||||
val preGranted =
|
||||
declared
|
||||
.filter { it.requiresConsent && !it.requiresPerUseConsent }
|
||||
.map { context.getString(it.labelRes()) }
|
||||
.map { loadStringRes(it.labelRes()) }
|
||||
.sorted()
|
||||
return SignerConnectInfo(
|
||||
appletTitle = title,
|
||||
|
||||
+6
-3
@@ -46,6 +46,9 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult
|
||||
import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway
|
||||
import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.napplet_cap_notify
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator
|
||||
import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
@@ -168,7 +171,7 @@ class AccountNappletGateways(
|
||||
* grant just no-ops). The in-app registry in [NappletNotificationStore] is the source of truth for
|
||||
* the napplet's own `notify.list`/`dismiss`; this is the user-visible surface.
|
||||
*/
|
||||
private fun postSystemNotification(
|
||||
private suspend fun postSystemNotification(
|
||||
id: String,
|
||||
title: String,
|
||||
body: String,
|
||||
@@ -179,14 +182,14 @@ class AccountNappletGateways(
|
||||
val channel =
|
||||
NotificationChannelCompat
|
||||
.Builder(NOTIFY_CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_DEFAULT)
|
||||
.setName(context.getString(R.string.napplet_cap_notify))
|
||||
.setName(loadStringRes(Res.string.napplet_cap_notify))
|
||||
.build()
|
||||
manager.createNotificationChannel(channel)
|
||||
val notification =
|
||||
NotificationCompat
|
||||
.Builder(context, NOTIFY_CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(title.ifBlank { context.getString(R.string.napplet_cap_notify) })
|
||||
.setContentTitle(title.ifBlank { loadStringRes(Res.string.napplet_cap_notify) })
|
||||
.setContentText(body)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
@@ -20,41 +20,71 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_400
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_401
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_402
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_403
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_404
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_405
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_406
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_407
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_408
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_409
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_410
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_411
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_412
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_413
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_414
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_415
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_416
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_417
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_426
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_500
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_501
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_502
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_503
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_504
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_505
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_506
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_507
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_508
|
||||
import com.vitorpamplona.amethyst.commons.resources.http_status_511
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
class HttpStatusMessages {
|
||||
companion object {
|
||||
fun resourceIdFor(statusCode: Int = 0): Int? =
|
||||
fun resourceIdFor(statusCode: Int = 0): StringResource? =
|
||||
when (statusCode) {
|
||||
400 -> R.string.http_status_400
|
||||
401 -> R.string.http_status_401
|
||||
402 -> R.string.http_status_402
|
||||
403 -> R.string.http_status_403
|
||||
404 -> R.string.http_status_404
|
||||
405 -> R.string.http_status_405
|
||||
406 -> R.string.http_status_406
|
||||
407 -> R.string.http_status_407
|
||||
408 -> R.string.http_status_408
|
||||
409 -> R.string.http_status_409
|
||||
410 -> R.string.http_status_410
|
||||
411 -> R.string.http_status_411
|
||||
412 -> R.string.http_status_412
|
||||
413 -> R.string.http_status_413
|
||||
414 -> R.string.http_status_414
|
||||
415 -> R.string.http_status_415
|
||||
416 -> R.string.http_status_416
|
||||
417 -> R.string.http_status_417
|
||||
426 -> R.string.http_status_426
|
||||
500 -> R.string.http_status_500
|
||||
501 -> R.string.http_status_501
|
||||
502 -> R.string.http_status_502
|
||||
503 -> R.string.http_status_503
|
||||
504 -> R.string.http_status_504
|
||||
505 -> R.string.http_status_505
|
||||
506 -> R.string.http_status_506
|
||||
507 -> R.string.http_status_507
|
||||
508 -> R.string.http_status_508
|
||||
511 -> R.string.http_status_511
|
||||
400 -> Res.string.http_status_400
|
||||
401 -> Res.string.http_status_401
|
||||
402 -> Res.string.http_status_402
|
||||
403 -> Res.string.http_status_403
|
||||
404 -> Res.string.http_status_404
|
||||
405 -> Res.string.http_status_405
|
||||
406 -> Res.string.http_status_406
|
||||
407 -> Res.string.http_status_407
|
||||
408 -> Res.string.http_status_408
|
||||
409 -> Res.string.http_status_409
|
||||
410 -> Res.string.http_status_410
|
||||
411 -> Res.string.http_status_411
|
||||
412 -> Res.string.http_status_412
|
||||
413 -> Res.string.http_status_413
|
||||
414 -> Res.string.http_status_414
|
||||
415 -> Res.string.http_status_415
|
||||
416 -> Res.string.http_status_416
|
||||
417 -> Res.string.http_status_417
|
||||
426 -> Res.string.http_status_426
|
||||
500 -> Res.string.http_status_500
|
||||
501 -> Res.string.http_status_501
|
||||
502 -> Res.string.http_status_502
|
||||
503 -> Res.string.http_status_503
|
||||
504 -> Res.string.http_status_504
|
||||
505 -> Res.string.http_status_505
|
||||
506 -> Res.string.http_status_506
|
||||
507 -> Res.string.http_status_507
|
||||
508 -> Res.string.http_status_508
|
||||
511 -> Res.string.http_status_511
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,14 +21,21 @@
|
||||
package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.clink_debit_no_response
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_dialog_pay_invoice_error
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_parsing_error_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_unable_to_fetch_invoice
|
||||
import com.vitorpamplona.amethyst.commons.resources.podcast_value_error_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.podcast_value_keysend_requires_nwc
|
||||
import com.vitorpamplona.amethyst.commons.resources.podcast_value_no_recipients
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
|
||||
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.PayKeysendMethod
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.Response
|
||||
@@ -41,6 +48,7 @@ import com.vitorpamplona.quartz.podcasts.PodcastValueShare
|
||||
import com.vitorpamplona.quartz.utils.mapNotNullAsync
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
@@ -88,8 +96,8 @@ class V4VPaymentHandler(
|
||||
val shares = value.computeShares(totalMilliSats)
|
||||
if (shares.isEmpty()) {
|
||||
onError(
|
||||
stringRes(context, R.string.podcast_value_error_title),
|
||||
stringRes(context, R.string.podcast_value_no_recipients),
|
||||
loadStringRes(Res.string.podcast_value_error_title),
|
||||
loadStringRes(Res.string.podcast_value_no_recipients),
|
||||
)
|
||||
return@withContext
|
||||
}
|
||||
@@ -105,8 +113,8 @@ class V4VPaymentHandler(
|
||||
payNodeSharesViaKeysend(nodeShares, boostagram, context, onError)
|
||||
} else {
|
||||
onError(
|
||||
stringRes(context, R.string.podcast_value_error_title),
|
||||
stringRes(context, R.string.podcast_value_keysend_requires_nwc),
|
||||
loadStringRes(Res.string.podcast_value_error_title),
|
||||
loadStringRes(Res.string.podcast_value_keysend_requires_nwc),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -133,7 +141,7 @@ class V4VPaymentHandler(
|
||||
}
|
||||
|
||||
/** Hex-encodes a TLV value string as NIP-47 `pay_keysend` requires (UTF-8 bytes → hex). */
|
||||
private fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey()
|
||||
private suspend fun hexTlv(value: String): String = value.encodeToByteArray().toHexKey()
|
||||
|
||||
private suspend fun payNodeSharesViaKeysend(
|
||||
shares: List<PodcastValueShare>,
|
||||
@@ -163,12 +171,16 @@ class V4VPaymentHandler(
|
||||
account.zaps.sendNwcRequest(
|
||||
request = request,
|
||||
onResponse = { response: Response? ->
|
||||
response.nwcFailureDetail(context)?.let { detail ->
|
||||
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), detail)
|
||||
account.scope.launch {
|
||||
response.nwcFailureDetail()?.let { detail ->
|
||||
onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), detail)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTimeout = {
|
||||
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context))
|
||||
account.scope.launch {
|
||||
onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), nwcTimeoutMessage())
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -231,8 +243,8 @@ class V4VPaymentHandler(
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
e.message ?: stringRes(context, R.string.error_parsing_error_message),
|
||||
loadStringRes(Res.string.error_unable_to_fetch_invoice),
|
||||
e.message ?: loadStringRes(Res.string.error_parsing_error_message),
|
||||
)
|
||||
null
|
||||
}
|
||||
@@ -257,12 +269,16 @@ class V4VPaymentHandler(
|
||||
bolt11 = payable.invoice,
|
||||
zappedNote = zappedNote,
|
||||
onResponse = { response ->
|
||||
response.nwcFailureDetail(context)?.let { detail ->
|
||||
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), detail)
|
||||
account.scope.launch {
|
||||
response.nwcFailureDetail()?.let { detail ->
|
||||
onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), detail)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTimeout = {
|
||||
onError(stringRes(context, R.string.error_dialog_pay_invoice_error), nwcTimeoutMessage(context))
|
||||
account.scope.launch {
|
||||
onError(loadStringRes(Res.string.error_dialog_pay_invoice_error), nwcTimeoutMessage())
|
||||
}
|
||||
},
|
||||
)
|
||||
done++
|
||||
@@ -276,9 +292,9 @@ class V4VPaymentHandler(
|
||||
val response = ClinkDebitPayer.payInvoice(account, source.wallet.pointer, payable.invoice)
|
||||
if (response?.isOk() != true) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
loadStringRes(Res.string.error_dialog_pay_invoice_error),
|
||||
response?.failureDetail()
|
||||
?: stringRes(context, R.string.clink_debit_no_response),
|
||||
?: loadStringRes(Res.string.clink_debit_no_response),
|
||||
)
|
||||
}
|
||||
done++
|
||||
|
||||
@@ -22,16 +22,27 @@ package com.vitorpamplona.amethyst.service
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.payments.PaymentSource
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.bolt12_payment_failed
|
||||
import com.vitorpamplona.amethyst.commons.resources.bolt12_zap_error
|
||||
import com.vitorpamplona.amethyst.commons.resources.clink_debit_no_response
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_dialog_pay_invoice_error
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_dialog_zap_error
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_unable_to_fetch_invoice
|
||||
import com.vitorpamplona.amethyst.commons.resources.missing_lud16
|
||||
import com.vitorpamplona.amethyst.commons.resources.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error
|
||||
import com.vitorpamplona.amethyst.commons.resources.user_does_not_have_a_lightning_address_setup_to_receive_sats
|
||||
import com.vitorpamplona.amethyst.commons.resources.user_x_does_not_have_a_lightning_address_setup_to_receive_sats
|
||||
import com.vitorpamplona.amethyst.commons.resources.wallet_connect_pay_invoice_error_error
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.nwc.nwcFailureDetail
|
||||
import com.vitorpamplona.amethyst.ui.nwc.nwcTimeoutMessage
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.experimental.clink.pointers.NDebit
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcErrorCode
|
||||
@@ -53,6 +64,7 @@ import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.math.round
|
||||
|
||||
@@ -210,17 +222,16 @@ class ZapPaymentHandler(
|
||||
errors.forEach {
|
||||
val message =
|
||||
if (it.user != null) {
|
||||
stringRes(
|
||||
context,
|
||||
R.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
loadStringRes(
|
||||
Res.string.user_x_does_not_have_a_lightning_address_setup_to_receive_sats,
|
||||
it.user.toBestDisplayName(),
|
||||
)
|
||||
} else {
|
||||
stringRes(context, R.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)
|
||||
loadStringRes(Res.string.user_does_not_have_a_lightning_address_setup_to_receive_sats)
|
||||
}
|
||||
|
||||
onError(
|
||||
stringRes(context, R.string.missing_lud16),
|
||||
loadStringRes(Res.string.missing_lud16),
|
||||
message,
|
||||
it.user,
|
||||
)
|
||||
@@ -337,7 +348,7 @@ class ZapPaymentHandler(
|
||||
}
|
||||
}
|
||||
|
||||
private fun calculateZapValue(
|
||||
private suspend fun calculateZapValue(
|
||||
amountMilliSats: Long,
|
||||
weight: Double,
|
||||
totalWeight: Double,
|
||||
@@ -430,13 +441,11 @@ class ZapPaymentHandler(
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(
|
||||
stringRes(
|
||||
context,
|
||||
R.string.error_unable_to_fetch_invoice,
|
||||
loadStringRes(
|
||||
Res.string.error_unable_to_fetch_invoice,
|
||||
),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
|
||||
loadStringRes(
|
||||
Res.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error,
|
||||
e.message,
|
||||
),
|
||||
null,
|
||||
@@ -474,21 +483,25 @@ class ZapPaymentHandler(
|
||||
comment = payable.message,
|
||||
),
|
||||
onResponse = { response ->
|
||||
progress.step()
|
||||
response.nwcFailureDetail(context)?.let { detail ->
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
stringRes(context, R.string.wallet_connect_pay_invoice_error_error, detail),
|
||||
payable.info.user,
|
||||
)
|
||||
account.scope.launch {
|
||||
progress.step()
|
||||
response.nwcFailureDetail()?.let { detail ->
|
||||
onError(
|
||||
loadStringRes(Res.string.error_dialog_pay_invoice_error),
|
||||
loadStringRes(Res.string.wallet_connect_pay_invoice_error_error, detail),
|
||||
payable.info.user,
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onTimeout = {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
nwcTimeoutMessage(context),
|
||||
payable.info.user,
|
||||
)
|
||||
account.scope.launch {
|
||||
onError(
|
||||
loadStringRes(Res.string.error_dialog_pay_invoice_error),
|
||||
nwcTimeoutMessage(),
|
||||
payable.info.user,
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
@@ -532,12 +545,12 @@ class ZapPaymentHandler(
|
||||
val progress = PaymentProgress(recipients.size, onProgress)
|
||||
|
||||
mapNotNullAsync(recipients) { recipient: Bolt12Recipient ->
|
||||
fun reportBolt12Error(
|
||||
msgRes: Int,
|
||||
suspend fun reportBolt12Error(
|
||||
msgRes: StringResource,
|
||||
detail: String?,
|
||||
) {
|
||||
val msg = if (detail != null) stringRes(context, msgRes, detail) else stringRes(context, msgRes)
|
||||
onError(stringRes(context, R.string.bolt12_zap_error), msg, recipient.user)
|
||||
val msg = if (detail != null) loadStringRes(msgRes, detail) else loadStringRes(msgRes)
|
||||
onError(loadStringRes(Res.string.bolt12_zap_error), msg, recipient.user)
|
||||
}
|
||||
|
||||
account.zaps.sendBolt12Zap(
|
||||
@@ -547,7 +560,7 @@ class ZapPaymentHandler(
|
||||
amountMillisats = calculateZapValue(totalAmountMilliSats, recipient.weight, totalWeight),
|
||||
message = message,
|
||||
zapType = zapType,
|
||||
onError = ::reportBolt12Error,
|
||||
onError = { msgRes, detail -> account.scope.launch { reportBolt12Error(msgRes, detail) } },
|
||||
onNotPaid = { code, detail ->
|
||||
val lnAddress = recipient.lnAddress
|
||||
if (lnAddress != null && Bolt12LightningFallback.shouldRetry(code)) {
|
||||
@@ -575,18 +588,20 @@ class ZapPaymentHandler(
|
||||
// Nothing was paid on either rail. Report it as the lightning failure it
|
||||
// is, rather than letting [sendBolt12Zap]'s catch call it "paid, no receipt".
|
||||
Log.w("ZapPaymentHandler", "BOLT11 fallback failed after a refused BOLT12 offer", e)
|
||||
onError(stringRes(context, R.string.error_dialog_zap_error), e.message ?: e.toString(), recipient.user)
|
||||
onError(loadStringRes(Res.string.error_dialog_zap_error), e.message ?: e.toString(), recipient.user)
|
||||
}
|
||||
} else {
|
||||
// bolt12_payment_failed always formats a detail; a wallet may send neither
|
||||
// message nor a recognised code.
|
||||
reportBolt12Error(R.string.bolt12_payment_failed, detail ?: (code ?: NwcErrorCode.OTHER).name)
|
||||
reportBolt12Error(Res.string.bolt12_payment_failed, detail ?: (code ?: NwcErrorCode.OTHER).name)
|
||||
}
|
||||
},
|
||||
onTimeout = {
|
||||
// No response callback will fire, so account for the settlement step here.
|
||||
reportBolt12Error(R.string.bolt12_payment_failed, nwcTimeoutMessage(context))
|
||||
progress.step()
|
||||
account.scope.launch {
|
||||
// No response callback will fire, so account for the settlement step here.
|
||||
reportBolt12Error(Res.string.bolt12_payment_failed, nwcTimeoutMessage())
|
||||
progress.step()
|
||||
}
|
||||
},
|
||||
onProcessed = { progress.step() },
|
||||
)
|
||||
@@ -639,9 +654,9 @@ class ZapPaymentHandler(
|
||||
progress.step()
|
||||
if (response?.isOk() != true) {
|
||||
onError(
|
||||
stringRes(context, R.string.error_dialog_pay_invoice_error),
|
||||
loadStringRes(Res.string.error_dialog_pay_invoice_error),
|
||||
response?.failureDetail()
|
||||
?: stringRes(context, R.string.clink_debit_no_response),
|
||||
?: loadStringRes(Res.string.clink_debit_no_response),
|
||||
payable.info.user,
|
||||
)
|
||||
}
|
||||
|
||||
+11
-7
@@ -29,8 +29,12 @@ import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* Posts user-visible "starting soon" notifications for NIP-52 appointments the user has RSVP'd
|
||||
@@ -52,7 +56,7 @@ object CalendarReminderNotifier {
|
||||
* calendar detail screen (CalendarTimeSlotEvent / CalendarDateSlotEvent
|
||||
* branches in RouteMaker resolve to Route.CalendarEventDetail).
|
||||
*/
|
||||
fun notifyReminder(
|
||||
suspend fun notifyReminder(
|
||||
context: Context,
|
||||
eventId: String,
|
||||
title: String,
|
||||
@@ -61,7 +65,7 @@ object CalendarReminderNotifier {
|
||||
) {
|
||||
ensureChannel(context)
|
||||
val notId = idFor(eventId)
|
||||
val channelId = stringRes(context, R.string.calendar_reminder_channel_id)
|
||||
val channelId = loadStringRes(Res.string.calendar_reminder_channel_id)
|
||||
val tapIntent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
action = Intent.ACTION_VIEW
|
||||
@@ -100,15 +104,15 @@ object CalendarReminderNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
suspend fun ensureChannel(context: Context) {
|
||||
if (channel != null) return
|
||||
channel =
|
||||
NotificationChannel(
|
||||
stringRes(context, R.string.calendar_reminder_channel_id),
|
||||
stringRes(context, R.string.calendar_reminder_channel_name),
|
||||
loadStringRes(Res.string.calendar_reminder_channel_id),
|
||||
loadStringRes(Res.string.calendar_reminder_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = stringRes(context, R.string.calendar_reminder_channel_description)
|
||||
description = loadStringRes(Res.string.calendar_reminder_channel_description)
|
||||
}
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.createNotificationChannel(channel!!)
|
||||
|
||||
+8
-7
@@ -27,12 +27,14 @@ import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.nip52Calendar.appointmentView
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.calendar_reminder_default_title
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.service.resourceusage.UsageKeys
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.tags.RSVPStatusTag
|
||||
import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
|
||||
@@ -98,12 +100,11 @@ class CalendarReminderWorker(
|
||||
// enters the lead window — the old notification stays valid in the system tray.
|
||||
if (store.wasNotified(eventId, start)) return@forEach
|
||||
|
||||
val title = view.title ?: stringRes(applicationContext, R.string.calendar_reminder_default_title)
|
||||
val title = view.title ?: loadStringRes(Res.string.calendar_reminder_default_title)
|
||||
val minutesAway = ((start - now).coerceAtLeast(0L) / 60L).toInt()
|
||||
val body =
|
||||
pluralStringRes(
|
||||
applicationContext,
|
||||
R.plurals.calendar_reminder_body,
|
||||
loadPluralStringRes(
|
||||
Res.plurals.calendar_reminder_body,
|
||||
minutesAway,
|
||||
minutesAway,
|
||||
)
|
||||
|
||||
+14
-8
@@ -33,9 +33,15 @@ import coil3.request.ImageRequest
|
||||
import coil3.request.allowHardware
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_calls_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_calls_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.call_accept
|
||||
import com.vitorpamplona.amethyst.commons.resources.call_incoming
|
||||
import com.vitorpamplona.amethyst.commons.resources.call_reject
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.service.call.CallNotificationReceiver
|
||||
import com.vitorpamplona.amethyst.ui.call.CallActivity
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip19Bech32.toNpub
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -63,16 +69,16 @@ object CallNotifier {
|
||||
const val CALL_CHANNEL_ID = "com.vitorpamplona.amethyst.CALL_CHANNEL"
|
||||
private const val CALL_NOTIFICATION_ID = 0x50000
|
||||
|
||||
fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
|
||||
suspend fun getOrCreateCallChannel(applicationContext: Context): NotificationChannel {
|
||||
callChannel?.let { return it }
|
||||
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CALL_CHANNEL_ID,
|
||||
stringRes(applicationContext, R.string.app_notification_calls_channel_name),
|
||||
loadStringRes(Res.string.app_notification_calls_channel_name),
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = stringRes(applicationContext, R.string.app_notification_calls_channel_description)
|
||||
description = loadStringRes(Res.string.app_notification_calls_channel_description)
|
||||
// Silence the notification sound — CallAudioManager plays the ringtone
|
||||
setSound(null, null)
|
||||
enableVibration(false)
|
||||
@@ -91,7 +97,7 @@ object CallNotifier {
|
||||
* full-screen intent that opens [CallActivity] when the device is
|
||||
* unlocked, and provides Accept / Reject actions.
|
||||
*/
|
||||
fun send(
|
||||
suspend fun send(
|
||||
callerName: String,
|
||||
callerBitmap: Bitmap?,
|
||||
applicationContext: Context,
|
||||
@@ -148,7 +154,7 @@ object CallNotifier {
|
||||
NotificationCompat
|
||||
.Builder(applicationContext, channel.id)
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setContentTitle(stringRes(applicationContext, R.string.call_incoming))
|
||||
.setContentTitle(loadStringRes(Res.string.call_incoming))
|
||||
.setContentText(callerName)
|
||||
.setLargeIcon(callerBitmap)
|
||||
.setContentIntent(contentPendingIntent)
|
||||
@@ -159,8 +165,8 @@ object CallNotifier {
|
||||
.setOngoing(true)
|
||||
.setTimeoutAfter(60_000)
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_reject), rejectPendingIntent)
|
||||
.addAction(R.drawable.amethyst, stringRes(applicationContext, R.string.call_accept), acceptPendingIntent)
|
||||
.addAction(R.drawable.amethyst, loadStringRes(Res.string.call_reject), rejectPendingIntent)
|
||||
.addAction(R.drawable.amethyst, loadStringRes(Res.string.call_accept), acceptPendingIntent)
|
||||
|
||||
notificationManager.notify("call", CALL_NOTIFICATION_ID, builder.build())
|
||||
}
|
||||
|
||||
+13
-10
@@ -21,10 +21,14 @@
|
||||
package com.vitorpamplona.amethyst.service.cashu.melt
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.cashu.melt.MeltResult
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.cashu_failed_redemption
|
||||
import com.vitorpamplona.amethyst.commons.resources.cashu_failed_redemption_explainer_error_msg
|
||||
import com.vitorpamplona.amethyst.commons.resources.cashu_unsafe_mint_url
|
||||
import com.vitorpamplona.amethyst.commons.resources.cashu_unsafe_mint_url_explainer
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.service.lnurl.LightningAddressResolver
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.CashuMintOperations
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
|
||||
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintUrlException
|
||||
@@ -85,10 +89,9 @@ class MeltProcessor {
|
||||
val sendable = token.totalAmount - fees
|
||||
if (sendable <= 0) {
|
||||
throw LightningAddressResolver.LightningAddressError(
|
||||
stringRes(context, R.string.cashu_failed_redemption),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.cashu_failed_redemption_explainer_error_msg,
|
||||
loadStringRes(Res.string.cashu_failed_redemption),
|
||||
loadStringRes(
|
||||
Res.string.cashu_failed_redemption_explainer_error_msg,
|
||||
"Token value ${token.totalAmount} does not cover fees $fees",
|
||||
),
|
||||
)
|
||||
@@ -121,13 +124,13 @@ class MeltProcessor {
|
||||
// message, not the mint's, so don't dress it up as "the mint said".
|
||||
if (e is MintUrlException) {
|
||||
throw LightningAddressResolver.LightningAddressError(
|
||||
stringRes(context, R.string.cashu_unsafe_mint_url),
|
||||
stringRes(context, R.string.cashu_unsafe_mint_url_explainer, e.message),
|
||||
loadStringRes(Res.string.cashu_unsafe_mint_url),
|
||||
loadStringRes(Res.string.cashu_unsafe_mint_url_explainer, e.message),
|
||||
)
|
||||
}
|
||||
throw LightningAddressResolver.LightningAddressError(
|
||||
stringRes(context, R.string.cashu_failed_redemption),
|
||||
stringRes(context, R.string.cashu_failed_redemption_explainer_error_msg, e.message),
|
||||
loadStringRes(Res.string.cashu_failed_redemption),
|
||||
loadStringRes(Res.string.cashu_failed_redemption_explainer_error_msg, e.message),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -34,11 +34,11 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||
import com.vitorpamplona.amethyst.commons.resources.crashreport_found
|
||||
import com.vitorpamplona.amethyst.commons.resources.crashreport_found_send
|
||||
import com.vitorpamplona.amethyst.commons.resources.would_you_like_to_send_the_recent_crash_report_to_amethyst_in_a_dm_no_personal_information_will_be_shared
|
||||
@@ -76,7 +76,7 @@ fun DisplayCrashMessages(
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { stackTrace.value = null }) {
|
||||
Text(stringRes(R.string.cancel))
|
||||
Text(stringRes(Res.string.cancel))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
|
||||
+43
-45
@@ -22,9 +22,20 @@ package com.vitorpamplona.amethyst.service.lnurl
|
||||
|
||||
import android.content.Context
|
||||
import com.fasterxml.jackson.databind.ObjectMapper
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user
|
||||
import com.vitorpamplona.amethyst.commons.resources.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup
|
||||
import com.vitorpamplona.amethyst.commons.resources.could_not_fetch_invoice_from_details
|
||||
import com.vitorpamplona.amethyst.commons.resources.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_unable_to_fetch_invoice
|
||||
import com.vitorpamplona.amethyst.commons.resources.incorrect_invoice_amount_sats_from_it_should_have_been
|
||||
import com.vitorpamplona.amethyst.commons.resources.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct
|
||||
import com.vitorpamplona.amethyst.commons.resources.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user
|
||||
import com.vitorpamplona.amethyst.commons.resources.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.service.HttpStatusMessages
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.lightning.LnInvoiceUtil
|
||||
import com.vitorpamplona.quartz.lightning.Lud06
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
@@ -69,8 +80,8 @@ class LightningAddressResolver {
|
||||
): String {
|
||||
val url =
|
||||
assembleUrl(lnAddress) ?: throw LightningAddressError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(context, R.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, lnAddress),
|
||||
loadStringRes(Res.string.error_unable_to_fetch_invoice),
|
||||
loadStringRes(Res.string.could_not_assemble_lnurl_from_lightning_address_check_the_user_s_setup, lnAddress),
|
||||
)
|
||||
|
||||
val client = okHttpClient(url)
|
||||
@@ -88,11 +99,9 @@ class LightningAddressResolver {
|
||||
response.body.string()
|
||||
} else {
|
||||
throw LightningAddressError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string
|
||||
.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
|
||||
loadStringRes(Res.string.error_unable_to_fetch_invoice),
|
||||
loadStringRes(
|
||||
Res.string.the_receiver_s_lightning_service_at_is_not_available_it_was_calculated_from_the_lightning_address_error_check_if_the_server_is_up_and_if_the_lightning_address_is_correct,
|
||||
url,
|
||||
lnAddress,
|
||||
errorMessage(response, context),
|
||||
@@ -104,11 +113,9 @@ class LightningAddressResolver {
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
throw LightningAddressError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string
|
||||
.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
|
||||
loadStringRes(Res.string.error_unable_to_fetch_invoice),
|
||||
loadStringRes(
|
||||
Res.string.could_not_resolve_check_if_you_are_connected_if_the_server_is_up_and_if_the_lightning_address_is_correct_exception,
|
||||
url,
|
||||
lnAddress,
|
||||
e.suppressedExceptions.getOrNull(0)?.message ?: e.cause?.message ?: e.message,
|
||||
@@ -151,15 +158,15 @@ class LightningAddressResolver {
|
||||
response.body.string()
|
||||
} else {
|
||||
throw LightningAddressError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(context, R.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(response, context)),
|
||||
loadStringRes(Res.string.error_unable_to_fetch_invoice),
|
||||
loadStringRes(Res.string.could_not_fetch_invoice_from_details, lnCallback, errorMessage(response, context)),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun errorMessage(
|
||||
suspend fun errorMessage(
|
||||
response: Response,
|
||||
context: Context,
|
||||
): String {
|
||||
@@ -196,7 +203,7 @@ class LightningAddressResolver {
|
||||
}
|
||||
|
||||
return errorMessage
|
||||
?: HttpStatusMessages.resourceIdFor(response.code)?.let { stringRes(context, it) }
|
||||
?: HttpStatusMessages.resourceIdFor(response.code)?.let { loadStringRes(it) }
|
||||
?: response.message.ifBlank { null }
|
||||
?: response.code.toString()
|
||||
}
|
||||
@@ -237,10 +244,9 @@ class LightningAddressResolver {
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
throw LightningAddressError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
|
||||
loadStringRes(Res.string.error_unable_to_fetch_invoice),
|
||||
loadStringRes(
|
||||
Res.string.error_parsing_json_from_lightning_address_check_the_user_s_lightning_setup_with_user,
|
||||
lnAddress,
|
||||
),
|
||||
)
|
||||
@@ -250,10 +256,9 @@ class LightningAddressResolver {
|
||||
|
||||
if (callbackUrl == null) {
|
||||
throw LightningAddressError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
|
||||
loadStringRes(Res.string.error_unable_to_fetch_invoice),
|
||||
loadStringRes(
|
||||
Res.string.callback_url_not_found_in_the_user_s_lightning_address_server_configuration_with_user,
|
||||
lnAddress,
|
||||
),
|
||||
)
|
||||
@@ -297,11 +302,9 @@ class LightningAddressResolver {
|
||||
} catch (t: Throwable) {
|
||||
if (t is CancellationException) throw t
|
||||
throw LightningAddressError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string
|
||||
.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
|
||||
loadStringRes(Res.string.error_unable_to_fetch_invoice),
|
||||
loadStringRes(
|
||||
Res.string.error_parsing_json_from_lightning_address_s_invoice_fetch_check_the_user_s_lightning_setup_with_user,
|
||||
lnAddress,
|
||||
),
|
||||
)
|
||||
@@ -315,22 +318,18 @@ class LightningAddressResolver {
|
||||
|
||||
if (reason != null) {
|
||||
throw LightningAddressError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
|
||||
loadStringRes(Res.string.error_unable_to_fetch_invoice),
|
||||
loadStringRes(
|
||||
Res.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_the_receiver_s_lightning_wallet_sent_the_following_error_with_user,
|
||||
lnAddress,
|
||||
reason,
|
||||
),
|
||||
)
|
||||
} else {
|
||||
throw LightningAddressError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string
|
||||
.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
|
||||
loadStringRes(Res.string.error_unable_to_fetch_invoice),
|
||||
loadStringRes(
|
||||
Res.string.unable_to_create_a_lightning_invoice_before_sending_the_zap_element_pr_not_found_in_the_resulting_json_with_user,
|
||||
lnAddress,
|
||||
),
|
||||
)
|
||||
@@ -346,10 +345,9 @@ class LightningAddressResolver {
|
||||
if (invoiceAmount.toLong() != expectedAmountInSats) {
|
||||
onProgress(0.0f)
|
||||
throw LightningAddressError(
|
||||
stringRes(context, R.string.error_unable_to_fetch_invoice),
|
||||
stringRes(
|
||||
context,
|
||||
R.string.incorrect_invoice_amount_sats_from_it_should_have_been,
|
||||
loadStringRes(Res.string.error_unable_to_fetch_invoice),
|
||||
loadStringRes(
|
||||
Res.string.incorrect_invoice_amount_sats_from_it_should_have_been,
|
||||
invoiceAmount.toLong().toString(),
|
||||
lnAddress,
|
||||
expectedAmountInSats.toString(),
|
||||
|
||||
+7
-6
@@ -45,6 +45,7 @@ import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusBus
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestAudioFocusState
|
||||
import com.vitorpamplona.amethyst.commons.viewmodels.NestNetworkChangeBus
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
/**
|
||||
@@ -389,19 +390,19 @@ class NestForegroundService : Service() {
|
||||
|
||||
val title =
|
||||
if (promoted) {
|
||||
getString(R.string.nest_notification_broadcasting)
|
||||
stringRes(this, R.string.nest_notification_broadcasting)
|
||||
} else {
|
||||
getString(R.string.nest_notification_listening)
|
||||
stringRes(this, R.string.nest_notification_listening)
|
||||
}
|
||||
|
||||
return NotificationCompat
|
||||
.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(getString(R.string.nest_notification_text))
|
||||
.setContentText(stringRes(this, R.string.nest_notification_text))
|
||||
.setSmallIcon(R.drawable.amethyst)
|
||||
.setOngoing(true)
|
||||
.setContentIntent(openIntent)
|
||||
.addAction(0, getString(R.string.nest_notification_stop), stopIntent)
|
||||
.addAction(0, stringRes(this, R.string.nest_notification_stop), stopIntent)
|
||||
.setCategory(NotificationCompat.CATEGORY_CALL)
|
||||
.build()
|
||||
}
|
||||
@@ -412,10 +413,10 @@ class NestForegroundService : Service() {
|
||||
mgr.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
getString(R.string.nest_notification_channel),
|
||||
stringRes(this, R.string.nest_notification_channel),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = getString(R.string.nest_notification_channel_description)
|
||||
description = stringRes(this@NestForegroundService, R.string.nest_notification_channel_description)
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
|
||||
+181
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+7
-4
@@ -30,13 +30,16 @@ import coil3.request.ImageRequest
|
||||
import coil3.request.allowHardware
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.LocalPreferences
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.model.isMutedPublicChatMessage
|
||||
import com.vitorpamplona.amethyst.commons.nipACWebRtcCalls.CallManager
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.event.EventFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.user.UserFinderQueryState
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_challenge_accepted
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_your_turn
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_poll_channel_message
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
|
||||
import com.vitorpamplona.amethyst.service.notifications.renderers.ArticleNotification
|
||||
@@ -275,7 +278,7 @@ class EventNotificationConsumer(
|
||||
is VideoVerticalEvent,
|
||||
-> MediaNotification.notify(applicationContext, account, event)
|
||||
|
||||
is PollEvent -> MentionNotification.notify(applicationContext, account, event, titleRes = R.string.app_notification_poll_channel_message)
|
||||
is PollEvent -> MentionNotification.notify(applicationContext, account, event, titleRes = Res.string.app_notification_poll_channel_message)
|
||||
|
||||
is HighlightEvent,
|
||||
is LongTextNoteEvent,
|
||||
@@ -292,8 +295,8 @@ class EventNotificationConsumer(
|
||||
is GitStatusClosedEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
is GitStatusDraftEvent -> CodeNotification.notify(applicationContext, account, event)
|
||||
|
||||
is LiveChessGameAcceptEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_challenge_accepted)
|
||||
is LiveChessMoveEvent -> ChessNotification.notify(applicationContext, account, event, R.string.app_notification_chess_your_turn)
|
||||
is LiveChessGameAcceptEvent -> ChessNotification.notify(applicationContext, account, event, Res.string.app_notification_chess_challenge_accepted)
|
||||
is LiveChessMoveEvent -> ChessNotification.notify(applicationContext, account, event, Res.string.app_notification_chess_your_turn)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+176
@@ -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
|
||||
}
|
||||
+126
-66
@@ -25,11 +25,66 @@ import android.app.NotificationChannelGroup
|
||||
import android.app.NotificationManager
|
||||
import android.content.Context
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.StringRes
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_summary
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_summary
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_summary
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_summary
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_dms_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_dms_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_dms_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_dms_summary
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_content
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_developer
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_games
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_messages
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_payments
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_group_social
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_summary
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_summary
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_summary
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_summary
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_replies_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_replies_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_replies_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_replies_summary
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_summary
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_summary
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/**
|
||||
* Logical grouping of notification channels, shown as a section header in the
|
||||
@@ -39,14 +94,14 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
*/
|
||||
enum class NotifChannelGroup(
|
||||
val id: String,
|
||||
@param:StringRes val nameRes: Int,
|
||||
val nameRes: StringResource,
|
||||
) {
|
||||
MESSAGES("com.vitorpamplona.amethyst.group.messages", R.string.app_notification_group_messages),
|
||||
SOCIAL("com.vitorpamplona.amethyst.group.social", R.string.app_notification_group_social),
|
||||
PAYMENTS("com.vitorpamplona.amethyst.group.payments", R.string.app_notification_group_payments),
|
||||
CONTENT("com.vitorpamplona.amethyst.group.content", R.string.app_notification_group_content),
|
||||
DEVELOPER("com.vitorpamplona.amethyst.group.developer", R.string.app_notification_group_developer),
|
||||
GAMES("com.vitorpamplona.amethyst.group.games", R.string.app_notification_group_games),
|
||||
MESSAGES("com.vitorpamplona.amethyst.group.messages", Res.string.app_notification_group_messages),
|
||||
SOCIAL("com.vitorpamplona.amethyst.group.social", Res.string.app_notification_group_social),
|
||||
PAYMENTS("com.vitorpamplona.amethyst.group.payments", Res.string.app_notification_group_payments),
|
||||
CONTENT("com.vitorpamplona.amethyst.group.content", Res.string.app_notification_group_content),
|
||||
DEVELOPER("com.vitorpamplona.amethyst.group.developer", Res.string.app_notification_group_developer),
|
||||
GAMES("com.vitorpamplona.amethyst.group.games", Res.string.app_notification_group_games),
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -66,10 +121,10 @@ enum class NotifChannelGroup(
|
||||
* table-driven definition every renderer reads from.
|
||||
*/
|
||||
enum class NotificationCategory(
|
||||
@param:StringRes val channelIdRes: Int,
|
||||
@param:StringRes val channelNameRes: Int,
|
||||
@param:StringRes val channelDescriptionRes: Int,
|
||||
@param:StringRes val summaryTextRes: Int,
|
||||
val channelIdRes: StringResource,
|
||||
val channelNameRes: StringResource,
|
||||
val channelDescriptionRes: StringResource,
|
||||
val summaryTextRes: StringResource,
|
||||
val importance: Int,
|
||||
val color: Int,
|
||||
@param:DrawableRes val smallIcon: Int,
|
||||
@@ -81,10 +136,10 @@ enum class NotificationCategory(
|
||||
val colorized: Boolean = false,
|
||||
) {
|
||||
DIRECT_MESSAGE(
|
||||
channelIdRes = R.string.app_notification_dms_channel_id,
|
||||
channelNameRes = R.string.app_notification_dms_channel_name,
|
||||
channelDescriptionRes = R.string.app_notification_dms_channel_description,
|
||||
summaryTextRes = R.string.app_notification_dms_summary,
|
||||
channelIdRes = Res.string.app_notification_dms_channel_id,
|
||||
channelNameRes = Res.string.app_notification_dms_channel_name,
|
||||
channelDescriptionRes = Res.string.app_notification_dms_channel_description,
|
||||
summaryTextRes = Res.string.app_notification_dms_summary,
|
||||
importance = NotificationManager.IMPORTANCE_HIGH,
|
||||
color = 0xFF2196F3.toInt(), // blue
|
||||
smallIcon = R.drawable.ic_notif_message,
|
||||
@@ -94,10 +149,10 @@ enum class NotificationCategory(
|
||||
summaryId = 0x10000,
|
||||
),
|
||||
REPLY(
|
||||
channelIdRes = R.string.app_notification_replies_channel_id,
|
||||
channelNameRes = R.string.app_notification_replies_channel_name,
|
||||
channelDescriptionRes = R.string.app_notification_replies_channel_description,
|
||||
summaryTextRes = R.string.app_notification_replies_summary,
|
||||
channelIdRes = Res.string.app_notification_replies_channel_id,
|
||||
channelNameRes = Res.string.app_notification_replies_channel_name,
|
||||
channelDescriptionRes = Res.string.app_notification_replies_channel_description,
|
||||
summaryTextRes = Res.string.app_notification_replies_summary,
|
||||
importance = NotificationManager.IMPORTANCE_DEFAULT,
|
||||
color = 0xFF7C4DFF.toInt(), // deep purple
|
||||
smallIcon = R.drawable.ic_notif_reply,
|
||||
@@ -109,10 +164,10 @@ enum class NotificationCategory(
|
||||
summaryId = 0x50000,
|
||||
),
|
||||
MENTION(
|
||||
channelIdRes = R.string.app_notification_mentions_channel_id,
|
||||
channelNameRes = R.string.app_notification_mentions_channel_name,
|
||||
channelDescriptionRes = R.string.app_notification_mentions_channel_description,
|
||||
summaryTextRes = R.string.app_notification_mentions_summary,
|
||||
channelIdRes = Res.string.app_notification_mentions_channel_id,
|
||||
channelNameRes = Res.string.app_notification_mentions_channel_name,
|
||||
channelDescriptionRes = Res.string.app_notification_mentions_channel_description,
|
||||
summaryTextRes = Res.string.app_notification_mentions_summary,
|
||||
importance = NotificationManager.IMPORTANCE_DEFAULT,
|
||||
color = 0xFF9C27B0.toInt(), // purple
|
||||
smallIcon = R.drawable.ic_notif_mention,
|
||||
@@ -122,10 +177,10 @@ enum class NotificationCategory(
|
||||
summaryId = 0x60000,
|
||||
),
|
||||
REACTION(
|
||||
channelIdRes = R.string.app_notification_reactions_channel_id,
|
||||
channelNameRes = R.string.app_notification_reactions_channel_name,
|
||||
channelDescriptionRes = R.string.app_notification_reactions_channel_description,
|
||||
summaryTextRes = R.string.app_notification_reactions_summary,
|
||||
channelIdRes = Res.string.app_notification_reactions_channel_id,
|
||||
channelNameRes = Res.string.app_notification_reactions_channel_name,
|
||||
channelDescriptionRes = Res.string.app_notification_reactions_channel_description,
|
||||
summaryTextRes = Res.string.app_notification_reactions_summary,
|
||||
importance = NotificationManager.IMPORTANCE_LOW,
|
||||
color = 0xFFE91E63.toInt(), // pink / heart
|
||||
smallIcon = R.drawable.ic_notif_reaction,
|
||||
@@ -135,10 +190,10 @@ enum class NotificationCategory(
|
||||
summaryId = 0x40000,
|
||||
),
|
||||
REPOST(
|
||||
channelIdRes = R.string.app_notification_reposts_channel_id,
|
||||
channelNameRes = R.string.app_notification_reposts_channel_name,
|
||||
channelDescriptionRes = R.string.app_notification_reposts_channel_description,
|
||||
summaryTextRes = R.string.app_notification_reposts_summary,
|
||||
channelIdRes = Res.string.app_notification_reposts_channel_id,
|
||||
channelNameRes = Res.string.app_notification_reposts_channel_name,
|
||||
channelDescriptionRes = Res.string.app_notification_reposts_channel_description,
|
||||
summaryTextRes = Res.string.app_notification_reposts_summary,
|
||||
importance = NotificationManager.IMPORTANCE_LOW,
|
||||
color = 0xFF4CAF50.toInt(), // green
|
||||
smallIcon = R.drawable.ic_notif_repost,
|
||||
@@ -148,10 +203,10 @@ enum class NotificationCategory(
|
||||
summaryId = 0x70000,
|
||||
),
|
||||
ZAP(
|
||||
channelIdRes = R.string.app_notification_zaps_channel_id,
|
||||
channelNameRes = R.string.app_notification_zaps_channel_name,
|
||||
channelDescriptionRes = R.string.app_notification_zaps_channel_description,
|
||||
summaryTextRes = R.string.app_notification_zaps_summary,
|
||||
channelIdRes = Res.string.app_notification_zaps_channel_id,
|
||||
channelNameRes = Res.string.app_notification_zaps_channel_name,
|
||||
channelDescriptionRes = Res.string.app_notification_zaps_channel_description,
|
||||
summaryTextRes = Res.string.app_notification_zaps_summary,
|
||||
importance = NotificationManager.IMPORTANCE_DEFAULT,
|
||||
color = 0xFFF7931A.toInt(), // bitcoin orange
|
||||
smallIcon = R.drawable.ic_notif_zap,
|
||||
@@ -162,10 +217,10 @@ enum class NotificationCategory(
|
||||
colorized = true,
|
||||
),
|
||||
MEDIA(
|
||||
channelIdRes = R.string.app_notification_media_channel_id,
|
||||
channelNameRes = R.string.app_notification_media_channel_name,
|
||||
channelDescriptionRes = R.string.app_notification_media_channel_description,
|
||||
summaryTextRes = R.string.app_notification_media_summary,
|
||||
channelIdRes = Res.string.app_notification_media_channel_id,
|
||||
channelNameRes = Res.string.app_notification_media_channel_name,
|
||||
channelDescriptionRes = Res.string.app_notification_media_channel_description,
|
||||
summaryTextRes = Res.string.app_notification_media_summary,
|
||||
importance = NotificationManager.IMPORTANCE_DEFAULT,
|
||||
color = 0xFF00BCD4.toInt(), // cyan
|
||||
smallIcon = R.drawable.ic_notif_media,
|
||||
@@ -175,10 +230,10 @@ enum class NotificationCategory(
|
||||
summaryId = 0x80000,
|
||||
),
|
||||
ARTICLE(
|
||||
channelIdRes = R.string.app_notification_articles_channel_id,
|
||||
channelNameRes = R.string.app_notification_articles_channel_name,
|
||||
channelDescriptionRes = R.string.app_notification_articles_channel_description,
|
||||
summaryTextRes = R.string.app_notification_articles_summary,
|
||||
channelIdRes = Res.string.app_notification_articles_channel_id,
|
||||
channelNameRes = Res.string.app_notification_articles_channel_name,
|
||||
channelDescriptionRes = Res.string.app_notification_articles_channel_description,
|
||||
summaryTextRes = Res.string.app_notification_articles_summary,
|
||||
importance = NotificationManager.IMPORTANCE_DEFAULT,
|
||||
color = 0xFF3F51B5.toInt(), // indigo
|
||||
smallIcon = R.drawable.ic_notif_article,
|
||||
@@ -188,10 +243,10 @@ enum class NotificationCategory(
|
||||
summaryId = 0x90000,
|
||||
),
|
||||
CODE(
|
||||
channelIdRes = R.string.app_notification_code_channel_id,
|
||||
channelNameRes = R.string.app_notification_code_channel_name,
|
||||
channelDescriptionRes = R.string.app_notification_code_channel_description,
|
||||
summaryTextRes = R.string.app_notification_code_summary,
|
||||
channelIdRes = Res.string.app_notification_code_channel_id,
|
||||
channelNameRes = Res.string.app_notification_code_channel_name,
|
||||
channelDescriptionRes = Res.string.app_notification_code_channel_description,
|
||||
summaryTextRes = Res.string.app_notification_code_summary,
|
||||
importance = NotificationManager.IMPORTANCE_DEFAULT,
|
||||
color = 0xFF607D8B.toInt(), // slate
|
||||
smallIcon = R.drawable.ic_notif_code,
|
||||
@@ -201,10 +256,10 @@ enum class NotificationCategory(
|
||||
summaryId = 0xA0000,
|
||||
),
|
||||
BADGE(
|
||||
channelIdRes = R.string.app_notification_badges_channel_id,
|
||||
channelNameRes = R.string.app_notification_badges_channel_name,
|
||||
channelDescriptionRes = R.string.app_notification_badges_channel_description,
|
||||
summaryTextRes = R.string.app_notification_badges_summary,
|
||||
channelIdRes = Res.string.app_notification_badges_channel_id,
|
||||
channelNameRes = Res.string.app_notification_badges_channel_name,
|
||||
channelDescriptionRes = Res.string.app_notification_badges_channel_description,
|
||||
summaryTextRes = Res.string.app_notification_badges_summary,
|
||||
importance = NotificationManager.IMPORTANCE_DEFAULT,
|
||||
color = 0xFFFFC107.toInt(), // amber / gold
|
||||
smallIcon = R.drawable.ic_notif_badge,
|
||||
@@ -214,10 +269,10 @@ enum class NotificationCategory(
|
||||
summaryId = 0xB0000,
|
||||
),
|
||||
CHESS(
|
||||
channelIdRes = R.string.app_notification_chess_channel_id,
|
||||
channelNameRes = R.string.app_notification_chess_channel_name,
|
||||
channelDescriptionRes = R.string.app_notification_chess_channel_description,
|
||||
summaryTextRes = R.string.app_notification_chess_summary,
|
||||
channelIdRes = Res.string.app_notification_chess_channel_id,
|
||||
channelNameRes = Res.string.app_notification_chess_channel_name,
|
||||
channelDescriptionRes = Res.string.app_notification_chess_channel_description,
|
||||
summaryTextRes = Res.string.app_notification_chess_summary,
|
||||
importance = NotificationManager.IMPORTANCE_DEFAULT,
|
||||
color = 0xFF795548.toInt(), // brown
|
||||
smallIcon = R.drawable.ic_notif_chess,
|
||||
@@ -227,10 +282,10 @@ enum class NotificationCategory(
|
||||
summaryId = 0x30000,
|
||||
),
|
||||
PAYMENT_RECEIVED(
|
||||
channelIdRes = R.string.app_notification_payments_channel_id,
|
||||
channelNameRes = R.string.app_notification_payments_channel_name,
|
||||
channelDescriptionRes = R.string.app_notification_payments_channel_description,
|
||||
summaryTextRes = R.string.app_notification_payments_summary,
|
||||
channelIdRes = Res.string.app_notification_payments_channel_id,
|
||||
channelNameRes = Res.string.app_notification_payments_channel_name,
|
||||
channelDescriptionRes = Res.string.app_notification_payments_channel_description,
|
||||
summaryTextRes = Res.string.app_notification_payments_summary,
|
||||
importance = NotificationManager.IMPORTANCE_DEFAULT,
|
||||
color = 0xFF16B979.toInt(), // lightning green — distinct from the gold zap channel
|
||||
smallIcon = R.drawable.ic_notif_zap,
|
||||
@@ -241,7 +296,7 @@ enum class NotificationCategory(
|
||||
),
|
||||
;
|
||||
|
||||
fun channelId(context: Context): String = stringRes(context, channelIdRes)
|
||||
suspend fun channelId(context: Context): String = loadStringRes(channelIdRes)
|
||||
|
||||
@Volatile private var channelEnsured = false
|
||||
|
||||
@@ -252,18 +307,23 @@ enum class NotificationCategory(
|
||||
* successful creation instead of paying two IPCs on every post and re-render.
|
||||
* Returns the channel id to post on.
|
||||
*/
|
||||
fun ensureChannel(context: Context): String {
|
||||
suspend fun ensureChannel(context: Context): String {
|
||||
val id = channelId(context)
|
||||
if (channelEnsured) return id
|
||||
// Resolved before the monitor: reading the catalog suspends, and a suspension
|
||||
// point inside a synchronized block would park the thread holding the lock.
|
||||
val groupName = loadStringRes(channelGroup.nameRes)
|
||||
val channelName = loadStringRes(channelNameRes)
|
||||
val channelDescription = loadStringRes(channelDescriptionRes)
|
||||
synchronized(this) {
|
||||
if (!channelEnsured) {
|
||||
val nm = context.getSystemService(NotificationManager::class.java)
|
||||
nm.createNotificationChannelGroup(
|
||||
NotificationChannelGroup(channelGroup.id, stringRes(context, channelGroup.nameRes)),
|
||||
NotificationChannelGroup(channelGroup.id, groupName),
|
||||
)
|
||||
val channel =
|
||||
NotificationChannel(id, stringRes(context, channelNameRes), importance).apply {
|
||||
description = stringRes(context, channelDescriptionRes)
|
||||
NotificationChannel(id, channelName, importance).apply {
|
||||
description = channelDescription
|
||||
group = channelGroup.id
|
||||
}
|
||||
nm.createNotificationChannel(channel)
|
||||
|
||||
+12
-8
@@ -25,14 +25,18 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.Settings
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_calls_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.service.call.notification.CallNotifier
|
||||
import com.vitorpamplona.amethyst.service.scheduledposts.AndroidScheduledPostNotifier
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/**
|
||||
* Registry of user-facing notification channels and helpers to read their
|
||||
@@ -57,10 +61,10 @@ object NotificationChannels {
|
||||
* settings page has something to open even before the first notification fires.
|
||||
*/
|
||||
data class Entry(
|
||||
val nameRes: Int,
|
||||
val nameRes: StringResource,
|
||||
val icon: MaterialSymbol,
|
||||
val channelId: (Context) -> String,
|
||||
val ensure: (Context) -> Unit,
|
||||
val channelId: suspend (Context) -> String,
|
||||
val ensure: suspend (Context) -> Unit,
|
||||
)
|
||||
|
||||
/** The per-kind content channels, derived from [NotificationCategory], in a
|
||||
@@ -81,13 +85,13 @@ object NotificationChannels {
|
||||
} +
|
||||
listOf(
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_scheduled_posts_channel_name,
|
||||
nameRes = Res.string.app_notification_scheduled_posts_channel_name,
|
||||
icon = MaterialSymbols.Schedule,
|
||||
channelId = { stringRes(it, R.string.app_notification_scheduled_posts_channel_id) },
|
||||
channelId = { loadStringRes(Res.string.app_notification_scheduled_posts_channel_id) },
|
||||
ensure = { AndroidScheduledPostNotifier.ensureChannel(it) },
|
||||
),
|
||||
Entry(
|
||||
nameRes = R.string.app_notification_calls_channel_name,
|
||||
nameRes = Res.string.app_notification_calls_channel_name,
|
||||
icon = MaterialSymbols.Call,
|
||||
channelId = { CallNotifier.CALL_CHANNEL_ID },
|
||||
ensure = { CallNotifier.getOrCreateCallChannel(it) },
|
||||
|
||||
+17
-8
@@ -349,6 +349,22 @@ class NotificationRelayService : Service() {
|
||||
}
|
||||
|
||||
private fun updateNotification(connectedRelays: Int) {
|
||||
postNotification(connectedRelays)
|
||||
// The breakdown names each job from the shared string catalog, whose only
|
||||
// non-composable accessor suspends - so it is recomputed off this path and
|
||||
// the card reposted once the text actually changes.
|
||||
if (detailsExpanded) {
|
||||
scope.launch {
|
||||
val fresh = RelayPurposeSummary.lines()
|
||||
if (fresh.isNotEmpty() && fresh != lastBreakdown) {
|
||||
lastBreakdown = fresh
|
||||
postNotification(connectedRelays)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun postNotification(connectedRelays: Int) {
|
||||
val notification = buildNotification(connectedRelays)
|
||||
val notificationManager = getSystemService(NOTIFICATION_SERVICE) as NotificationManager
|
||||
notificationManager.notify(NOTIFICATION_ID, notification)
|
||||
@@ -398,14 +414,7 @@ class NotificationRelayService : Service() {
|
||||
//
|
||||
// Computed only while expanded: walking every connected relay's active requests once a
|
||||
// second is wasted work when nobody has asked to see the result.
|
||||
val breakdown =
|
||||
if (detailsExpanded) {
|
||||
val fresh = RelayPurposeSummary.lines(this)
|
||||
if (fresh.isNotEmpty()) lastBreakdown = fresh
|
||||
lastBreakdown.takeIf { it.isNotEmpty() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val breakdown = if (detailsExpanded) lastBreakdown.takeIf { it.isNotEmpty() } else null
|
||||
|
||||
val detailsIntent =
|
||||
Intent(this, NotificationRelayService::class.java).apply {
|
||||
|
||||
+120
-27
@@ -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(
|
||||
|
||||
+87
-3
@@ -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,
|
||||
|
||||
+66
-15
@@ -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())
|
||||
|
||||
+8
-6
@@ -20,12 +20,14 @@
|
||||
*/
|
||||
package com.vitorpamplona.amethyst.service.notifications
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.purposes
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_purpose_browsing
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_purpose_line
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadPluralStringRes
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.common.SubPurposeLabels
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
|
||||
@@ -51,7 +53,7 @@ object RelayPurposeSummary {
|
||||
* Lines for the expanded notification, busiest first. Empty when nothing is attributed yet —
|
||||
* the caller must then fall back to the bare count rather than render an empty section.
|
||||
*/
|
||||
fun lines(ctx: Context): List<String> {
|
||||
suspend fun lines(): List<String> {
|
||||
val client = Amethyst.instance.client
|
||||
val named = mutableMapOf<SubPurpose, MutableSet<NormalizedRelayUrl>>()
|
||||
val browsing = mutableSetOf<NormalizedRelayUrl>()
|
||||
@@ -75,11 +77,11 @@ object RelayPurposeSummary {
|
||||
named.entries
|
||||
.sortedWith(compareByDescending<Map.Entry<SubPurpose, Set<NormalizedRelayUrl>>> { it.value.size }.thenBy { it.key.name })
|
||||
.map { (purpose, relays) ->
|
||||
pluralStringRes(ctx, R.plurals.relay_purpose_line, relays.size, ctx.getString(SubPurposeLabels.labelOf(purpose)), relays.size)
|
||||
loadPluralStringRes(Res.plurals.relay_purpose_line, relays.size, loadStringRes(SubPurposeLabels.labelOf(purpose)), relays.size)
|
||||
}.toMutableList()
|
||||
|
||||
if (browsing.isNotEmpty()) {
|
||||
lines.add(pluralStringRes(ctx, R.plurals.relay_purpose_line, browsing.size, ctx.getString(R.string.relay_purpose_browsing), browsing.size))
|
||||
lines.add(loadPluralStringRes(Res.plurals.relay_purpose_line, browsing.size, loadStringRes(Res.string.relay_purpose_browsing), browsing.size))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
+7
-5
@@ -21,8 +21,11 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications.renderers
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_articles_channel_message_highlight
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
|
||||
@@ -30,7 +33,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip84Highlights.HighlightEvent
|
||||
|
||||
@@ -56,9 +58,9 @@ object ArticleNotification {
|
||||
val isHighlight = event is HighlightEvent
|
||||
val titleRes =
|
||||
if (isHighlight) {
|
||||
R.string.app_notification_articles_channel_message_highlight
|
||||
Res.string.app_notification_articles_channel_message_highlight
|
||||
} else {
|
||||
R.string.app_notification_articles_channel_message
|
||||
Res.string.app_notification_articles_channel_message
|
||||
}
|
||||
val bodySource = if (event is HighlightEvent) event.quote() else event.content
|
||||
val rendered = NotificationContent.renderNoteText(bodySource)
|
||||
@@ -80,7 +82,7 @@ object ArticleNotification {
|
||||
nm.postStandard(
|
||||
category = NotificationCategory.ARTICLE,
|
||||
id = event.id,
|
||||
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
|
||||
messageTitle = loadStringRes(titleRes, author.toBestDisplayName()),
|
||||
messageBody = body.text,
|
||||
time = event.createdAt,
|
||||
pictureUrl = author.profilePicture(),
|
||||
|
||||
+6
-4
@@ -21,15 +21,17 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications.renderers
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_badges_channel_message_from
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip58Badges.award.BadgeAwardEvent
|
||||
|
||||
/**
|
||||
@@ -61,8 +63,8 @@ object BadgeNotification {
|
||||
nm.postStandard(
|
||||
category = NotificationCategory.BADGE,
|
||||
id = event.id,
|
||||
messageTitle = stringRes(context, R.string.app_notification_badges_channel_message),
|
||||
messageBody = stringRes(context, R.string.app_notification_badges_channel_message_from, issuer.toBestDisplayName()),
|
||||
messageTitle = loadStringRes(Res.string.app_notification_badges_channel_message),
|
||||
messageBody = loadStringRes(Res.string.app_notification_badges_channel_message_from, issuer.toBestDisplayName()),
|
||||
time = event.createdAt,
|
||||
pictureUrl = issuer.profilePicture(),
|
||||
uri = uri,
|
||||
|
||||
+20
-4
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -21,9 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications.renderers
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_chess_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.isDebug
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
@@ -31,8 +32,8 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip64Chess.baseEvent.BaseChessEvent
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/**
|
||||
* Chess game notifications — NIP-64 challenge-accepted and move events. Rendered
|
||||
@@ -44,7 +45,7 @@ object ChessNotification {
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: BaseChessEvent,
|
||||
@StringRes contentRes: Int,
|
||||
contentRes: StringResource,
|
||||
) {
|
||||
// NIP-64 chess is a debug-only feature for now; don't notify in release.
|
||||
if (!isDebug) return
|
||||
@@ -65,8 +66,8 @@ object ChessNotification {
|
||||
nm.postStandard(
|
||||
category = NotificationCategory.CHESS,
|
||||
id = event.id,
|
||||
messageTitle = stringRes(context, R.string.app_notification_chess_channel_name),
|
||||
messageBody = stringRes(context, contentRes, author.toBestDisplayName()),
|
||||
messageTitle = loadStringRes(Res.string.app_notification_chess_channel_name),
|
||||
messageBody = loadStringRes(contentRes, author.toBestDisplayName()),
|
||||
time = event.createdAt,
|
||||
pictureUrl = author.profilePicture(),
|
||||
uri = uri,
|
||||
|
||||
+40
-24
@@ -21,8 +21,24 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications.renderers
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_issue
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_patch
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_pr
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_pr_update
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_reply
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_applied
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_applied_issue
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_applied_patch
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_applied_pr
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_closed
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_closed_issue
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_closed_patch
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_closed_pr
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_draft
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_code_channel_message_status_open
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
|
||||
@@ -30,7 +46,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip34Git.issue.GitIssueEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.patch.GitPatchEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.pr.GitPullRequestEvent
|
||||
@@ -40,6 +55,7 @@ import com.vitorpamplona.quartz.nip34Git.status.GitStatusAppliedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusClosedEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusDraftEvent
|
||||
import com.vitorpamplona.quartz.nip34Git.status.GitStatusOpenEvent
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/**
|
||||
* Git / code notifications — NIP-34 issues (1621), patches (1617), pull requests
|
||||
@@ -61,25 +77,25 @@ object CodeNotification {
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitIssueEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_issue, event.subject() ?: event.content)
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_issue, event.subject() ?: event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitPatchEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_patch, event.subject() ?: event.content)
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_patch, event.subject() ?: event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitPullRequestEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr, event.subject() ?: event.content)
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_pr, event.subject() ?: event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitPullRequestUpdateEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_pr_update, event.content)
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_pr_update, event.content)
|
||||
|
||||
// GitReplyEvent (kind 1622) is deprecated in favour of NIP-22 comments, but
|
||||
// events already on relays still arrive and still have to be rendered.
|
||||
@@ -88,13 +104,13 @@ object CodeNotification {
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitReplyEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_reply, event.content)
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_reply, event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusOpenEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_open, event.content)
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_status_open, event.content)
|
||||
|
||||
suspend fun notify(
|
||||
context: Context,
|
||||
@@ -109,10 +125,10 @@ object CodeNotification {
|
||||
titleRes =
|
||||
titleForStatusOnTarget(
|
||||
event.rootEventId(),
|
||||
pr = R.string.app_notification_code_channel_message_status_applied_pr,
|
||||
patch = R.string.app_notification_code_channel_message_status_applied_patch,
|
||||
issue = R.string.app_notification_code_channel_message_status_applied_issue,
|
||||
fallback = R.string.app_notification_code_channel_message_status_applied,
|
||||
pr = Res.string.app_notification_code_channel_message_status_applied_pr,
|
||||
patch = Res.string.app_notification_code_channel_message_status_applied_patch,
|
||||
issue = Res.string.app_notification_code_channel_message_status_applied_issue,
|
||||
fallback = Res.string.app_notification_code_channel_message_status_applied,
|
||||
),
|
||||
subject = event.content,
|
||||
)
|
||||
@@ -130,10 +146,10 @@ object CodeNotification {
|
||||
titleRes =
|
||||
titleForStatusOnTarget(
|
||||
event.rootEventId(),
|
||||
pr = R.string.app_notification_code_channel_message_status_closed_pr,
|
||||
patch = R.string.app_notification_code_channel_message_status_closed_patch,
|
||||
issue = R.string.app_notification_code_channel_message_status_closed_issue,
|
||||
fallback = R.string.app_notification_code_channel_message_status_closed,
|
||||
pr = Res.string.app_notification_code_channel_message_status_closed_pr,
|
||||
patch = Res.string.app_notification_code_channel_message_status_closed_patch,
|
||||
issue = Res.string.app_notification_code_channel_message_status_closed_issue,
|
||||
fallback = Res.string.app_notification_code_channel_message_status_closed,
|
||||
),
|
||||
subject = event.content,
|
||||
)
|
||||
@@ -142,7 +158,7 @@ object CodeNotification {
|
||||
context: Context,
|
||||
account: Account,
|
||||
event: GitStatusDraftEvent,
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, R.string.app_notification_code_channel_message_status_draft, event.content)
|
||||
) = post(context, account, event.id, event.createdAt, event.pubKey, Res.string.app_notification_code_channel_message_status_draft, event.content)
|
||||
|
||||
/**
|
||||
* Pick a title string for a status event based on the *target*'s kind, so
|
||||
@@ -153,11 +169,11 @@ object CodeNotification {
|
||||
*/
|
||||
private fun titleForStatusOnTarget(
|
||||
rootId: String?,
|
||||
pr: Int,
|
||||
patch: Int,
|
||||
issue: Int,
|
||||
fallback: Int,
|
||||
): Int {
|
||||
pr: StringResource,
|
||||
patch: StringResource,
|
||||
issue: StringResource,
|
||||
fallback: StringResource,
|
||||
): StringResource {
|
||||
val targetKind = rootId?.let { LocalCache.getNoteIfExists(it)?.event?.kind } ?: return fallback
|
||||
return when (targetKind) {
|
||||
GitPullRequestEvent.KIND -> pr
|
||||
@@ -173,7 +189,7 @@ object CodeNotification {
|
||||
id: String,
|
||||
createdAt: Long,
|
||||
authorPubkey: String,
|
||||
titleRes: Int,
|
||||
titleRes: StringResource,
|
||||
subject: String?,
|
||||
) {
|
||||
val note = LocalCache.getNoteIfExists(id) ?: return
|
||||
@@ -196,7 +212,7 @@ object CodeNotification {
|
||||
nm.postStandard(
|
||||
category = NotificationCategory.CODE,
|
||||
id = id,
|
||||
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
|
||||
messageTitle = loadStringRes(titleRes, author.toBestDisplayName()),
|
||||
messageBody = body,
|
||||
time = createdAt,
|
||||
pictureUrl = author.profilePicture(),
|
||||
|
||||
+34
-1
@@ -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() }
|
||||
}
|
||||
}
|
||||
|
||||
+22
-4
@@ -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,
|
||||
|
||||
+7
-5
@@ -21,8 +21,11 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications.renderers
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_message_photo
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_media_channel_message_video
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
|
||||
@@ -30,7 +33,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip71Video.VideoEvent
|
||||
|
||||
@@ -72,14 +74,14 @@ object MediaNotification {
|
||||
val user = author.toBestDisplayName()
|
||||
val titleRes =
|
||||
if (isVideo) {
|
||||
R.string.app_notification_media_channel_message_video
|
||||
Res.string.app_notification_media_channel_message_video
|
||||
} else {
|
||||
R.string.app_notification_media_channel_message_photo
|
||||
Res.string.app_notification_media_channel_message_photo
|
||||
}
|
||||
nm.postStandard(
|
||||
category = NotificationCategory.MEDIA,
|
||||
id = event.id,
|
||||
messageTitle = stringRes(context, titleRes, user),
|
||||
messageTitle = loadStringRes(titleRes, user),
|
||||
messageBody = NotificationContent.resolveMentions(event.content, 140).text,
|
||||
time = event.createdAt,
|
||||
pictureUrl = author.profilePicture(),
|
||||
|
||||
+6
-5
@@ -21,9 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications.renderers
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.StringRes
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_mentions_channel_message
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
|
||||
@@ -31,8 +32,8 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/**
|
||||
* Text-mention notifications — someone mentioned, quoted, or cited you in a note
|
||||
@@ -49,7 +50,7 @@ object MentionNotification {
|
||||
account: Account,
|
||||
event: Event,
|
||||
category: NotificationCategory = NotificationCategory.MENTION,
|
||||
@StringRes titleRes: Int = R.string.app_notification_mentions_channel_message,
|
||||
titleRes: StringResource = Res.string.app_notification_mentions_channel_message,
|
||||
) {
|
||||
val note = LocalCache.getNoteIfExists(event.id) ?: return
|
||||
if (!account.isAcceptable(note)) return
|
||||
@@ -80,7 +81,7 @@ object MentionNotification {
|
||||
nm.postStandard(
|
||||
category = category,
|
||||
id = event.id,
|
||||
messageTitle = stringRes(context, titleRes, author.toBestDisplayName()),
|
||||
messageTitle = loadStringRes(titleRes, author.toBestDisplayName()),
|
||||
messageBody = body.text,
|
||||
time = event.createdAt,
|
||||
pictureUrl = author.profilePicture(),
|
||||
|
||||
+4
-3
@@ -21,14 +21,15 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications.renderers
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_payments_channel_message
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip47WalletConnect.rpc.NwcTransaction
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -57,7 +58,7 @@ object NwcPaymentNotifier {
|
||||
val id = tx.payment_hash ?: tx.invoice ?: tx.created_at?.toString() ?: return
|
||||
val time = tx.settled_at ?: tx.created_at ?: TimeUtils.now()
|
||||
|
||||
val title = stringRes(context, R.string.app_notification_payments_channel_message, amount)
|
||||
val title = loadStringRes(Res.string.app_notification_payments_channel_message, amount)
|
||||
val comment = tx.parsedMetadata()?.displayComment() ?: tx.displayDescription()
|
||||
val body = comment ?: title
|
||||
|
||||
|
||||
+6
-4
@@ -21,8 +21,11 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications.renderers
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_reactions_channel_message_for
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
|
||||
@@ -30,7 +33,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip25Reactions.ReactionEvent
|
||||
import com.vitorpamplona.quartz.nip30CustomEmoji.CustomEmoji
|
||||
|
||||
@@ -81,9 +83,9 @@ object ReactionNotification {
|
||||
val reactedContent = NotificationContent.excerpt(reactedNote?.event?.content, 140)
|
||||
val body =
|
||||
if (reactedContent.isNotBlank()) {
|
||||
stringRes(context, R.string.app_notification_reactions_channel_message_for, reactedContent)
|
||||
loadStringRes(Res.string.app_notification_reactions_channel_message_for, reactedContent)
|
||||
} else {
|
||||
stringRes(context, R.string.app_notification_reactions_channel_message, user)
|
||||
loadStringRes(Res.string.app_notification_reactions_channel_message, user)
|
||||
}
|
||||
nm.postStandard(
|
||||
category = NotificationCategory.REACTION,
|
||||
|
||||
+4
-3
@@ -21,9 +21,11 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications.renderers
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_me
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
|
||||
@@ -35,7 +37,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postCo
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.replyGroupKeyFor
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.replySummaryIdFor
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
|
||||
/**
|
||||
@@ -95,7 +96,7 @@ object ReplyNotification {
|
||||
ParentMessage(
|
||||
senderName =
|
||||
if (parentIsFromMe || parentAuthor == null) {
|
||||
stringRes(context, R.string.app_notification_me)
|
||||
loadStringRes(Res.string.app_notification_me)
|
||||
} else {
|
||||
parentAuthor.toBestDisplayName()
|
||||
},
|
||||
|
||||
+4
-3
@@ -21,8 +21,10 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications.renderers
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_reposts_channel_message
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
|
||||
@@ -30,7 +32,6 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationEnricher
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent
|
||||
import com.vitorpamplona.quartz.nip18Reposts.RepostEvent
|
||||
|
||||
@@ -79,7 +80,7 @@ object RepostNotification {
|
||||
nm.postStandard(
|
||||
category = NotificationCategory.REPOST,
|
||||
id = id,
|
||||
messageTitle = stringRes(context, R.string.app_notification_reposts_channel_message, booster.toBestDisplayName()),
|
||||
messageTitle = loadStringRes(Res.string.app_notification_reposts_channel_message, booster.toBestDisplayName()),
|
||||
messageBody = NotificationContent.excerpt(boostedNote?.event?.content, 140),
|
||||
time = createdAt,
|
||||
pictureUrl = booster.profilePicture(),
|
||||
|
||||
+21
-15
@@ -21,10 +21,16 @@
|
||||
package com.vitorpamplona.amethyst.service.notifications.renderers
|
||||
|
||||
import android.content.Context
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Note
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_nutzap_channel_message_from
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_onchain_channel_message_from
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_message_for
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_zaps_channel_message_from
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.model.Account
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationCategory
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationContent
|
||||
@@ -33,11 +39,11 @@ import com.vitorpamplona.amethyst.service.notifications.NotificationRoutes
|
||||
import com.vitorpamplona.amethyst.service.notifications.NotificationUtils.postStandard
|
||||
import com.vitorpamplona.amethyst.service.notifications.notificationManager
|
||||
import com.vitorpamplona.amethyst.ui.note.showAmount
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapEvent
|
||||
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
|
||||
import com.vitorpamplona.quartz.nip61Nutzaps.nutzap.NutzapEvent
|
||||
import com.vitorpamplona.quartz.nipBCOnchainZaps.zap.OnchainZapEvent
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import java.math.BigDecimal
|
||||
|
||||
/**
|
||||
@@ -76,7 +82,7 @@ object ZapNotification {
|
||||
sender = sender,
|
||||
zappedNote = zappedNote,
|
||||
title = { _ -> zapTitle(context, amount, comment) },
|
||||
body = { user, excerpt -> fromLine(context, R.string.app_notification_zaps_channel_message_from, user, excerpt) },
|
||||
body = { user, excerpt -> fromLine(context, Res.string.app_notification_zaps_channel_message_from, user, excerpt) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,7 +102,7 @@ object ZapNotification {
|
||||
createdAt = event.createdAt,
|
||||
sender = sender,
|
||||
zappedNote = zappedNote,
|
||||
title = { user -> stringRes(context, R.string.app_notification_nutzap_channel_message_from, user) },
|
||||
title = { user -> loadStringRes(Res.string.app_notification_nutzap_channel_message_from, user) },
|
||||
body = { user, excerpt -> excerpt.ifBlank { user } },
|
||||
)
|
||||
}
|
||||
@@ -120,12 +126,12 @@ object ZapNotification {
|
||||
zappedNote = zappedNote,
|
||||
title = { user ->
|
||||
if (sats != null) {
|
||||
stringRes(context, R.string.app_notification_zaps_channel_message, showAmount(sats.toBigDecimal()))
|
||||
loadStringRes(Res.string.app_notification_zaps_channel_message, showAmount(sats.toBigDecimal()))
|
||||
} else {
|
||||
stringRes(context, R.string.app_notification_onchain_channel_message_from, user)
|
||||
loadStringRes(Res.string.app_notification_onchain_channel_message_from, user)
|
||||
}
|
||||
},
|
||||
body = { user, excerpt -> fromLine(context, R.string.app_notification_onchain_channel_message_from, user, excerpt) },
|
||||
body = { user, excerpt -> fromLine(context, Res.string.app_notification_onchain_channel_message_from, user, excerpt) },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -136,8 +142,8 @@ object ZapNotification {
|
||||
createdAt: Long,
|
||||
sender: User,
|
||||
zappedNote: Note?,
|
||||
title: (String) -> String,
|
||||
body: (String, String) -> String,
|
||||
title: suspend (String) -> String,
|
||||
body: suspend (String, String) -> String,
|
||||
) {
|
||||
val accountNpub = NotificationRoutes.accountNpub(account)
|
||||
val uri = NotificationRoutes.notificationsUri(accountNpub, id)
|
||||
@@ -167,24 +173,24 @@ object ZapNotification {
|
||||
}
|
||||
}
|
||||
|
||||
private fun zapTitle(
|
||||
private suspend fun zapTitle(
|
||||
context: Context,
|
||||
amount: String,
|
||||
comment: String?,
|
||||
): String {
|
||||
val base = stringRes(context, R.string.app_notification_zaps_channel_message, amount)
|
||||
val base = loadStringRes(Res.string.app_notification_zaps_channel_message, amount)
|
||||
return if (comment != null) "$base ($comment)" else base
|
||||
}
|
||||
|
||||
private fun fromLine(
|
||||
private suspend fun fromLine(
|
||||
context: Context,
|
||||
fromRes: Int,
|
||||
fromRes: StringResource,
|
||||
user: String,
|
||||
excerpt: String,
|
||||
): String {
|
||||
var content = stringRes(context, fromRes, user)
|
||||
var content = loadStringRes(fromRes, user)
|
||||
if (excerpt.isNotBlank()) {
|
||||
content += " " + stringRes(context, R.string.app_notification_zaps_channel_message_for, excerpt)
|
||||
content += " " + loadStringRes(Res.string.app_notification_zaps_channel_message_for, excerpt)
|
||||
}
|
||||
return content
|
||||
}
|
||||
|
||||
+25
@@ -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()
|
||||
|
||||
+7
-4
@@ -43,9 +43,12 @@ import androidx.compose.ui.platform.LocalUriHandler
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_video_open_in_browser
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_video_playback_failed_description
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.Log
|
||||
|
||||
@@ -109,7 +112,7 @@ fun RenderPlaybackError(
|
||||
}
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.error_video_playback_failed),
|
||||
text = stringRes(Res.string.error_video_playback_failed),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -119,7 +122,7 @@ fun RenderPlaybackError(
|
||||
Spacer(Modifier.height(4.dp))
|
||||
|
||||
Text(
|
||||
text = stringRes(R.string.error_video_playback_failed_description, errorCodeName),
|
||||
text = stringRes(Res.string.error_video_playback_failed_description, errorCodeName),
|
||||
color = Color.White.copy(alpha = 0.85f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.Center,
|
||||
@@ -143,7 +146,7 @@ fun RenderPlaybackError(
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
Spacer(Modifier.size(8.dp))
|
||||
Text(stringRes(R.string.error_video_open_in_browser))
|
||||
Text(stringRes(Res.string.error_video_open_in_browser))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+10
@@ -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,
|
||||
|
||||
+8
-1
@@ -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,
|
||||
)
|
||||
|
||||
+5
@@ -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,
|
||||
|
||||
+160
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+5
-3
@@ -38,9 +38,11 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.mute_button
|
||||
import com.vitorpamplona.amethyst.commons.resources.muted_button
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.Size30Modifier
|
||||
@@ -123,7 +125,7 @@ fun MuteButton(
|
||||
fun MutedIcon() {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.VolumeOff,
|
||||
contentDescription = stringRes(id = R.string.muted_button),
|
||||
contentDescription = stringRes(id = Res.string.muted_button),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Size30Modifier,
|
||||
)
|
||||
@@ -133,7 +135,7 @@ fun MutedIcon() {
|
||||
fun MuteIcon() {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.AutoMirrored.VolumeUp,
|
||||
contentDescription = stringRes(id = R.string.mute_button),
|
||||
contentDescription = stringRes(id = Res.string.mute_button),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
modifier = Size30Modifier,
|
||||
)
|
||||
|
||||
+31
-5
@@ -37,14 +37,18 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.call_settings_video_quality
|
||||
import com.vitorpamplona.amethyst.commons.resources.download_to_phone
|
||||
import com.vitorpamplona.amethyst.commons.resources.more_options
|
||||
import com.vitorpamplona.amethyst.commons.resources.mute_button
|
||||
import com.vitorpamplona.amethyst.commons.resources.muted_button
|
||||
import com.vitorpamplona.amethyst.commons.resources.picture_in_picture
|
||||
import com.vitorpamplona.amethyst.commons.resources.playback_actions_dialog_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.share_or_save
|
||||
import com.vitorpamplona.amethyst.commons.resources.video_player_settings_action_fullscreen
|
||||
import com.vitorpamplona.amethyst.model.VideoPlayerAction
|
||||
import com.vitorpamplona.amethyst.ui.components.M3ActionDialog
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
-3
@@ -39,9 +39,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.pause
|
||||
import com.vitorpamplona.amethyst.commons.resources.play
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.PlayIconSize
|
||||
@@ -117,13 +119,13 @@ fun PlayPauseButton(
|
||||
Icon(
|
||||
symbol = MaterialSymbols.PlayArrow,
|
||||
modifier = Size50Modifier,
|
||||
contentDescription = stringRes(R.string.play),
|
||||
contentDescription = stringRes(Res.string.play),
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Pause,
|
||||
modifier = Size50Modifier,
|
||||
contentDescription = stringRes(R.string.pause),
|
||||
contentDescription = stringRes(Res.string.pause),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+103
@@ -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)
|
||||
+96
-6
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+6
-3
@@ -46,9 +46,12 @@ import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.google.accompanist.permissions.ExperimentalPermissionsApi
|
||||
import com.google.accompanist.permissions.isGranted
|
||||
import com.google.accompanist.permissions.rememberPermissionState
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.download_to_phone
|
||||
import com.vitorpamplona.amethyst.commons.resources.video_download_has_started_toast
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
@@ -100,7 +103,7 @@ fun rememberSaveMediaAction(onSaveClick: (Context) -> Unit): () -> Unit {
|
||||
Toast
|
||||
.makeText(
|
||||
context,
|
||||
stringRes(context, R.string.video_download_has_started_toast),
|
||||
loadStringRes(Res.string.video_download_has_started_toast),
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
@@ -131,7 +134,7 @@ fun SaveMediaButton(onSaveClick: (localContext: Context) -> Unit) {
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Download,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.download_to_phone),
|
||||
contentDescription = stringRes(Res.string.download_to_phone),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -38,9 +38,10 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.share_or_save
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
import com.vitorpamplona.amethyst.ui.theme.PinBottomIconSize
|
||||
@@ -99,7 +100,7 @@ fun ShareButton(innerAction: @Composable (MutableState<Boolean>, () -> Unit) ->
|
||||
Icon(
|
||||
symbol = MaterialSymbols.Share,
|
||||
modifier = Size20Modifier,
|
||||
contentDescription = stringRes(R.string.share_or_save),
|
||||
contentDescription = stringRes(Res.string.share_or_save),
|
||||
)
|
||||
|
||||
innerAction(popupExpanded) { popupExpanded.value = false }
|
||||
|
||||
+4
-2
@@ -33,9 +33,11 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.skip_back
|
||||
import com.vitorpamplona.amethyst.commons.resources.skip_forward
|
||||
import com.vitorpamplona.amethyst.service.playback.composable.SKIP_SECONDS
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.BitcoinOrange
|
||||
@@ -89,7 +91,7 @@ fun SkipButton(
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val icon = if (isForward) MaterialSymbols.Forward10 else MaterialSymbols.Replay10
|
||||
val label = if (isForward) stringRes(R.string.skip_forward, SKIP_SECONDS) else stringRes(R.string.skip_back, SKIP_SECONDS)
|
||||
val label = if (isForward) stringRes(Res.string.skip_forward, SKIP_SECONDS) else stringRes(Res.string.skip_back, SKIP_SECONDS)
|
||||
val tint = if (enabled) Color.White else Color.White.copy(alpha = 0.38f)
|
||||
IconButton(onClick = onClick, enabled = enabled, modifier = Modifier.size(48.dp)) {
|
||||
Icon(symbol = icon, contentDescription = label, tint = tint, modifier = Modifier.size(32.dp))
|
||||
|
||||
+31
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+30
-1
@@ -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 })
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+2
-2
@@ -61,7 +61,7 @@ fun createMuteAction(
|
||||
} else {
|
||||
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_volume_off)
|
||||
}
|
||||
val title = if (isMuted) stringRes(context, R.string.muted_button) else stringRes(context, R.string.mute_button)
|
||||
val title = stringRes(context, if (isMuted) R.string.muted_button else R.string.mute_button)
|
||||
|
||||
val intent =
|
||||
PendingIntent.getBroadcast(
|
||||
@@ -83,7 +83,7 @@ fun createPlayPauseAction(
|
||||
} else {
|
||||
Icon.createWithResource(context, androidx.media3.session.R.drawable.media3_icon_pause)
|
||||
}
|
||||
val title = if (!isPlaying) stringRes(context, R.string.play) else stringRes(context, R.string.pause)
|
||||
val title = stringRes(context, if (!isPlaying) R.string.play else R.string.pause)
|
||||
val intent =
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
|
||||
+55
-4
@@ -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
|
||||
|
||||
+1
-1
@@ -116,7 +116,7 @@ class PowMiningForegroundService : FlowProgressForegroundService<ImmutableList<P
|
||||
|
||||
val base =
|
||||
current?.let {
|
||||
pluralStringRes(this, R.plurals.pow_mining_job, it.difficulty, stringRes(this, powKindLabelRes(it.kind)), it.difficulty)
|
||||
pluralStringRes(this, R.plurals.pow_mining_job, it.difficulty, stringRes(this, powKindLabelResId(it.kind)), it.difficulty)
|
||||
} ?: stringRes(this, R.string.pow_mining_title)
|
||||
val text =
|
||||
if (expectedSec != null && elapsedSec != null) {
|
||||
|
||||
+21
-11
@@ -26,11 +26,21 @@ import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.User
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_my_inbox
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_my_own_relay
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_notify_inbox
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_other
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_post_venue
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_read_outbox
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_read_venue
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_send_dm
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_purpose_thread
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
|
||||
/**
|
||||
* Loads [pubkey] from the local cache, get-or-creating (and subscribing) if absent, then hands the
|
||||
@@ -58,15 +68,15 @@ internal fun LoadRelayAuthUser(
|
||||
* deciding right now ("It won't serve … to readers it can't identify"); these caption a past login in
|
||||
* the settings log, where a full sentence per row would be the wall of text this screen just lost.
|
||||
*/
|
||||
internal fun relayAuthPurposeLabelRes(kind: AuthPurposeKind): Int =
|
||||
internal fun relayAuthPurposeLabelRes(kind: AuthPurposeKind): StringResource =
|
||||
when (kind) {
|
||||
AuthPurposeKind.SEND_DM -> R.string.relay_auth_purpose_send_dm
|
||||
AuthPurposeKind.NOTIFY_INBOX -> R.string.relay_auth_purpose_notify_inbox
|
||||
AuthPurposeKind.READ_OUTBOX -> R.string.relay_auth_purpose_read_outbox
|
||||
AuthPurposeKind.POST_VENUE -> R.string.relay_auth_purpose_post_venue
|
||||
AuthPurposeKind.READ_VENUE -> R.string.relay_auth_purpose_read_venue
|
||||
AuthPurposeKind.MY_INBOX -> R.string.relay_auth_purpose_my_inbox
|
||||
AuthPurposeKind.THREAD -> R.string.relay_auth_purpose_thread
|
||||
AuthPurposeKind.MY_OWN_RELAY -> R.string.relay_auth_purpose_my_own_relay
|
||||
AuthPurposeKind.OTHER -> R.string.relay_auth_purpose_other
|
||||
AuthPurposeKind.SEND_DM -> Res.string.relay_auth_purpose_send_dm
|
||||
AuthPurposeKind.NOTIFY_INBOX -> Res.string.relay_auth_purpose_notify_inbox
|
||||
AuthPurposeKind.READ_OUTBOX -> Res.string.relay_auth_purpose_read_outbox
|
||||
AuthPurposeKind.POST_VENUE -> Res.string.relay_auth_purpose_post_venue
|
||||
AuthPurposeKind.READ_VENUE -> Res.string.relay_auth_purpose_read_venue
|
||||
AuthPurposeKind.MY_INBOX -> Res.string.relay_auth_purpose_my_inbox
|
||||
AuthPurposeKind.THREAD -> Res.string.relay_auth_purpose_thread
|
||||
AuthPurposeKind.MY_OWN_RELAY -> Res.string.relay_auth_purpose_my_own_relay
|
||||
AuthPurposeKind.OTHER -> Res.string.relay_auth_purpose_other
|
||||
}
|
||||
|
||||
+22
-12
@@ -51,7 +51,6 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.Placeholder
|
||||
import androidx.compose.ui.text.PlaceholderVerticalAlign
|
||||
@@ -63,7 +62,6 @@ import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.model.Channel
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.auth.RelayAuthPrompt
|
||||
@@ -72,8 +70,19 @@ import com.vitorpamplona.amethyst.commons.relayauth.AuthPurpose
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.AuthPurposeKind
|
||||
import com.vitorpamplona.amethyst.commons.relayauth.RelayAuthPolicy
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.cancel
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_also_holding_back
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_always_allow_everywhere
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_always_everywhere_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_always_everywhere_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_always_log_in
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_log_in
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_login_as
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_name_and_n_others
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_never
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_never_allow_everywhere
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_never_everywhere_body
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_never_everywhere_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_not_now
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_relay_asks
|
||||
import com.vitorpamplona.amethyst.commons.resources.relay_auth_remember_relay
|
||||
@@ -93,6 +102,7 @@ import com.vitorpamplona.amethyst.service.relayClient.reqCommand.channel.observe
|
||||
import com.vitorpamplona.amethyst.service.relayClient.reqCommand.user.observeUserInfo
|
||||
import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage
|
||||
import com.vitorpamplona.amethyst.ui.note.ClickableUserPicture
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.amethyst.ui.theme.RelayIconFilter
|
||||
@@ -300,11 +310,11 @@ private fun RelayAuthPromptDialog(
|
||||
} else {
|
||||
ButtonDefaults.outlinedButtonColors()
|
||||
},
|
||||
) { Text(if (rememberRelay) stringRes(R.string.relay_auth_never) else stringRes(Res.string.relay_auth_not_now)) }
|
||||
) { Text(if (rememberRelay) stringRes(Res.string.relay_auth_never) else stringRes(Res.string.relay_auth_not_now)) }
|
||||
Button(
|
||||
onClick = { onChoice(if (rememberRelay) UserAuthChoice.ALWAYS_ALLOW else UserAuthChoice.ALLOW_ONCE) },
|
||||
modifier = Modifier.weight(1f),
|
||||
) { Text(if (rememberRelay) stringRes(R.string.relay_auth_always_log_in) else stringRes(Res.string.relay_auth_log_in)) }
|
||||
) { Text(if (rememberRelay) stringRes(Res.string.relay_auth_always_log_in) else stringRes(Res.string.relay_auth_log_in)) }
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -313,10 +323,10 @@ private fun RelayAuthPromptDialog(
|
||||
TextButton(
|
||||
onClick = { confirming = UserAuthChoice.NEVER_ALLOW_EVERYWHERE },
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = MaterialTheme.colorScheme.error),
|
||||
) { Text(stringRes(R.string.relay_auth_never_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
|
||||
) { Text(stringRes(Res.string.relay_auth_never_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
|
||||
TextButton(
|
||||
onClick = { confirming = UserAuthChoice.ALWAYS_ALLOW_EVERYWHERE },
|
||||
) { Text(stringRes(R.string.relay_auth_always_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
|
||||
) { Text(stringRes(Res.string.relay_auth_always_allow_everywhere), style = MaterialTheme.typography.labelMedium) }
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -343,12 +353,12 @@ private fun PolicyEverywhereConfirmation(
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Text(stringRes(if (always) R.string.relay_auth_always_everywhere_title else R.string.relay_auth_never_everywhere_title))
|
||||
Text(stringRes(if (always) Res.string.relay_auth_always_everywhere_title else Res.string.relay_auth_never_everywhere_title))
|
||||
},
|
||||
text = {
|
||||
Text(
|
||||
stringRes(
|
||||
if (always) R.string.relay_auth_always_everywhere_body else R.string.relay_auth_never_everywhere_body,
|
||||
if (always) Res.string.relay_auth_always_everywhere_body else Res.string.relay_auth_never_everywhere_body,
|
||||
accountName,
|
||||
),
|
||||
)
|
||||
@@ -370,10 +380,10 @@ private fun PolicyEverywhereConfirmation(
|
||||
)
|
||||
},
|
||||
) {
|
||||
Text(stringRes(if (always) R.string.relay_auth_always_allow_everywhere else R.string.relay_auth_never_allow_everywhere))
|
||||
Text(stringRes(if (always) Res.string.relay_auth_always_allow_everywhere else Res.string.relay_auth_never_allow_everywhere))
|
||||
}
|
||||
},
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(R.string.cancel)) } },
|
||||
dismissButton = { TextButton(onClick = onDismiss) { Text(stringRes(Res.string.cancel)) } },
|
||||
)
|
||||
}
|
||||
|
||||
@@ -558,7 +568,7 @@ private fun secondaryLine(
|
||||
): String? {
|
||||
val others = purposes.filter { it !== primary }
|
||||
if (others.isEmpty()) return null
|
||||
return pluralStringResource(R.plurals.relay_auth_also_holding_back, others.size, others.size)
|
||||
return pluralStringRes(Res.plurals.relay_auth_also_holding_back, others.size, others.size)
|
||||
}
|
||||
|
||||
/** A short label for a set of counterparties: the first person's name, or "Alice and 4 others". */
|
||||
@@ -571,7 +581,7 @@ private fun counterpartyLabel(
|
||||
val name = rememberCounterpartyName(first, accountViewModel)
|
||||
if (pubkeys.size == 1) return name
|
||||
val others = pubkeys.size - 1
|
||||
return pluralStringResource(R.plurals.relay_auth_name_and_n_others, others, name, others)
|
||||
return pluralStringRes(Res.plurals.relay_auth_name_and_n_others, others, name, others)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-2
@@ -22,8 +22,9 @@ package com.vitorpamplona.amethyst.service.relayClient.notifyCommand.compose
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.relayClient.notify.NotifyRequestsCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.payment_required_title
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
@@ -50,7 +51,7 @@ fun DisplayNotifyMessages(
|
||||
NotifyRequestDialog(
|
||||
title =
|
||||
stringRes(
|
||||
id = R.string.payment_required_title,
|
||||
id = Res.string.payment_required_title,
|
||||
request.relayUrl.displayUrl(),
|
||||
),
|
||||
textContent = request.description,
|
||||
|
||||
+2
-2
@@ -36,12 +36,12 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.Icon
|
||||
import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols
|
||||
import com.vitorpamplona.amethyst.commons.model.EmptyTagList
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.error_dialog_button_ok
|
||||
import com.vitorpamplona.amethyst.commons.resources.notify_block_relay
|
||||
import com.vitorpamplona.amethyst.ui.components.TranslatableRichTextViewer
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.rememberExtendedNav
|
||||
@@ -119,7 +119,7 @@ fun NotifyRequestDialog(
|
||||
)
|
||||
Spacer(StdHorzSpacer)
|
||||
Text(
|
||||
text = stringRes(R.string.notify_block_relay),
|
||||
text = stringRes(Res.string.notify_block_relay),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
|
||||
+15
@@ -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(
|
||||
|
||||
+15
-10
@@ -30,19 +30,24 @@ import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import com.vitorpamplona.amethyst.Amethyst
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.collectMemorySnapshot
|
||||
import com.vitorpamplona.amethyst.commons.model.cache.LocalCache
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_not_now
|
||||
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_opt_out
|
||||
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_send
|
||||
import com.vitorpamplona.amethyst.commons.resources.resource_usage_alert_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_bg_data
|
||||
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_churn
|
||||
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_conn_time
|
||||
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_reconnects
|
||||
import com.vitorpamplona.amethyst.commons.resources.resource_usage_reason_wakelock
|
||||
import com.vitorpamplona.amethyst.commons.service.crashreports.DEV_REPORT_PUBKEY
|
||||
import com.vitorpamplona.amethyst.ui.navigation.navs.INav
|
||||
import com.vitorpamplona.amethyst.ui.navigation.routes.routeToMessage
|
||||
import com.vitorpamplona.amethyst.ui.pluralStringRes
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -111,7 +116,7 @@ fun DisplayResourceUsageAlert(
|
||||
text = {
|
||||
Text(
|
||||
stringRes(
|
||||
R.string.resource_usage_alert_message,
|
||||
Res.string.resource_usage_alert_message,
|
||||
reasonDescription(found),
|
||||
),
|
||||
)
|
||||
@@ -163,32 +168,32 @@ private fun reasonDescription(alert: ResourceUsageAlerts.Alert): String =
|
||||
when (alert.reason) {
|
||||
ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_DATA ->
|
||||
stringRes(
|
||||
R.string.resource_usage_reason_bg_data,
|
||||
Res.string.resource_usage_reason_bg_data,
|
||||
ResourceUsageReportAssembler.formatBytes(alert.value),
|
||||
)
|
||||
|
||||
ResourceUsageAlerts.Reason.BACKGROUND_MOBILE_CONNECTION_TIME ->
|
||||
stringRes(
|
||||
R.string.resource_usage_reason_conn_time,
|
||||
Res.string.resource_usage_reason_conn_time,
|
||||
ResourceUsageReportAssembler.formatConnHours(alert.value),
|
||||
)
|
||||
|
||||
ResourceUsageAlerts.Reason.WAKELOCK_TIME ->
|
||||
stringRes(
|
||||
R.string.resource_usage_reason_wakelock,
|
||||
Res.string.resource_usage_reason_wakelock,
|
||||
ResourceUsageReportAssembler.formatDurationMs(alert.value),
|
||||
)
|
||||
|
||||
ResourceUsageAlerts.Reason.PROCESS_CHURN ->
|
||||
pluralStringResource(
|
||||
R.plurals.resource_usage_reason_churn,
|
||||
pluralStringRes(
|
||||
Res.plurals.resource_usage_reason_churn,
|
||||
alert.value.toInt(),
|
||||
alert.value.toInt(),
|
||||
)
|
||||
|
||||
ResourceUsageAlerts.Reason.RECONNECT_CHURN ->
|
||||
pluralStringResource(
|
||||
R.plurals.resource_usage_reason_reconnects,
|
||||
pluralStringRes(
|
||||
Res.plurals.resource_usage_reason_reconnects,
|
||||
alert.value.toInt(),
|
||||
alert.value.toInt(),
|
||||
)
|
||||
|
||||
+19
-12
@@ -28,11 +28,18 @@ import android.content.Intent
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_id
|
||||
import com.vitorpamplona.amethyst.commons.resources.app_notification_scheduled_posts_channel_name
|
||||
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_error_prefix
|
||||
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_notification_failed_title
|
||||
import com.vitorpamplona.amethyst.commons.resources.scheduled_posts_notification_sent_title
|
||||
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPost
|
||||
import com.vitorpamplona.amethyst.commons.scheduledposts.ScheduledPostNotifier
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.ui.MainActivity
|
||||
import com.vitorpamplona.amethyst.ui.screen.loggedIn.scheduledposts.extractContentPreview
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
|
||||
/**
|
||||
* Android [ScheduledPostNotifier]: posts a user-visible system notification when a
|
||||
@@ -43,17 +50,17 @@ import com.vitorpamplona.amethyst.ui.stringRes
|
||||
class AndroidScheduledPostNotifier(
|
||||
private val context: Context,
|
||||
) : ScheduledPostNotifier {
|
||||
override fun notifySent(post: ScheduledPost) {
|
||||
override suspend fun notifySent(post: ScheduledPost) {
|
||||
ensureChannel(context)
|
||||
post(
|
||||
context = context,
|
||||
notId = idFor(post.id),
|
||||
title = stringRes(context, R.string.scheduled_posts_notification_sent_title),
|
||||
title = loadStringRes(Res.string.scheduled_posts_notification_sent_title),
|
||||
body = extractContentPreview(post, 120),
|
||||
)
|
||||
}
|
||||
|
||||
override fun notifyFailed(
|
||||
override suspend fun notifyFailed(
|
||||
post: ScheduledPost,
|
||||
error: String?,
|
||||
) {
|
||||
@@ -63,23 +70,23 @@ class AndroidScheduledPostNotifier(
|
||||
if (error.isNullOrBlank()) {
|
||||
snippet
|
||||
} else {
|
||||
"$snippet\n${stringRes(context, R.string.scheduled_posts_error_prefix, error)}"
|
||||
"$snippet\n${loadStringRes(Res.string.scheduled_posts_error_prefix, error)}"
|
||||
}
|
||||
post(
|
||||
context = context,
|
||||
notId = idFor(post.id),
|
||||
title = stringRes(context, R.string.scheduled_posts_notification_failed_title),
|
||||
title = loadStringRes(Res.string.scheduled_posts_notification_failed_title),
|
||||
body = body,
|
||||
)
|
||||
}
|
||||
|
||||
private fun post(
|
||||
private suspend fun post(
|
||||
context: Context,
|
||||
notId: Int,
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
val channelId = stringRes(context, R.string.app_notification_scheduled_posts_channel_id)
|
||||
val channelId = loadStringRes(Res.string.app_notification_scheduled_posts_channel_id)
|
||||
val tapIntent =
|
||||
Intent(context, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
@@ -126,15 +133,15 @@ class AndroidScheduledPostNotifier(
|
||||
private var channel: NotificationChannel? = null
|
||||
private const val SCHEDULED_POST_NOT_ID_BASE = 0x70000
|
||||
|
||||
fun ensureChannel(context: Context) {
|
||||
suspend fun ensureChannel(context: Context) {
|
||||
if (channel != null) return
|
||||
channel =
|
||||
NotificationChannel(
|
||||
stringRes(context, R.string.app_notification_scheduled_posts_channel_id),
|
||||
stringRes(context, R.string.app_notification_scheduled_posts_channel_name),
|
||||
loadStringRes(Res.string.app_notification_scheduled_posts_channel_id),
|
||||
loadStringRes(Res.string.app_notification_scheduled_posts_channel_name),
|
||||
NotificationManager.IMPORTANCE_DEFAULT,
|
||||
).apply {
|
||||
description = stringRes(context, R.string.app_notification_scheduled_posts_channel_description)
|
||||
description = loadStringRes(Res.string.app_notification_scheduled_posts_channel_description)
|
||||
}
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.createNotificationChannel(channel!!)
|
||||
|
||||
+28
-17
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+9
-7
@@ -25,14 +25,16 @@ import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.provider.OpenableColumns
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.failed_to_delete_with_message
|
||||
import com.vitorpamplona.amethyst.commons.resources.failed_to_upload_to_server_with_message
|
||||
import com.vitorpamplona.amethyst.commons.service.upload.BlossomPaymentException
|
||||
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
|
||||
import com.vitorpamplona.amethyst.service.HttpStatusMessages
|
||||
import com.vitorpamplona.amethyst.service.checkNotInMainThread
|
||||
import com.vitorpamplona.amethyst.service.uploads.MediaUploadResult
|
||||
import com.vitorpamplona.amethyst.service.uploads.PreviewMetadataCalculator
|
||||
import com.vitorpamplona.amethyst.service.uploads.extensionFromMimeType
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.JsonMapper
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
@@ -215,11 +217,11 @@ class BlossomUploader {
|
||||
|
||||
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||
if (errorMessage != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage))
|
||||
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), errorMessage))
|
||||
} else if (explanation != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), stringRes(context, explanation)))
|
||||
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), loadStringRes(explanation)))
|
||||
} else {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString()))
|
||||
throw RuntimeException(loadStringRes(Res.string.failed_to_upload_to_server_with_message, serverBaseUrl.displayUrl(), response.code.toString()))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -274,9 +276,9 @@ class BlossomUploader {
|
||||
} else {
|
||||
val explanation = HttpStatusMessages.resourceIdFor(response.code)
|
||||
if (explanation != null) {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, stringRes(context, explanation)))
|
||||
throw RuntimeException(loadStringRes(Res.string.failed_to_delete_with_message, loadStringRes(explanation)))
|
||||
} else {
|
||||
throw RuntimeException(stringRes(context, R.string.failed_to_delete_with_message, response.code))
|
||||
throw RuntimeException(loadStringRes(Res.string.failed_to_delete_with_message, response.code))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -23,13 +23,16 @@ package com.vitorpamplona.amethyst.service.uploads.blossom.bud10
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.net.toUri
|
||||
import com.vitorpamplona.amethyst.R
|
||||
import com.vitorpamplona.amethyst.commons.resources.Res
|
||||
import com.vitorpamplona.amethyst.commons.resources.no_blossom_apps_found_description
|
||||
import com.vitorpamplona.amethyst.commons.resources.no_blossom_apps_found_title
|
||||
import org.jetbrains.compose.resources.StringResource
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
fun openBlossomUriAsIntent(
|
||||
context: Context,
|
||||
blossomUri: String,
|
||||
onError: (Int, Int) -> Unit,
|
||||
onError: (StringResource, StringResource) -> Unit,
|
||||
) {
|
||||
try {
|
||||
val intent = Intent(Intent.ACTION_VIEW, blossomUri.toUri())
|
||||
@@ -38,6 +41,6 @@ fun openBlossomUriAsIntent(
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
if (e is CancellationException) throw e
|
||||
onError(R.string.no_blossom_apps_found_title, R.string.no_blossom_apps_found_description)
|
||||
onError(Res.string.no_blossom_apps_found_title, Res.string.no_blossom_apps_found_description)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-9
@@ -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()
|
||||
|
||||
+8
-5
@@ -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
Reference in New Issue
Block a user