fix: audit of the one-UI migration branch

Bugs:
- Single-image uploads (avatar, banner, emoji packs, relay group,
  long-form cover) report a metadata-strip failure as "Metadata could not
  be removed" again instead of a generic upload error, and long-form
  covers are not stripped, as before the move to uploadToDefaultServer.
- AndroidMediaUploader moves to Dispatchers.IO itself: the calendar event
  composer called it from a Main-bound scope, hashing and decoding images
  on the UI thread.
- Chat "retry without encryption" did nothing in hold mode: the retry
  state was only captured for send.
- Pickers (gallery, file, document) keep their launched flag across an
  activity recreation, and the Concord and geocache pickers keep their
  open flag, so a picked image is no longer dropped or the picker
  reopened after a rotation.
- Android's DateSkeletonFormatter keys its cache on the time zone, and the
  wallet rows format month, day and time as one locale pattern again.
- Deep-link query lookup drops a #fragment, as java.net.URI did.
- Pasting an empty clipboard no longer wipes a typed invoice.
- BuzzDmListViewModel decides "new channel" with one atomic insert.
- StringMediaUri compares by value (temp-file cleanup compares URIs) and
  lastPathSegmentOrNull matches Android (decoded, null without a path).
- Cordn attachments no longer log a false "could not delete" warning.
- HiddenAccountsFeedViewModel.Factory accepts a supertype request again.

Performance:
- rememberViewModel's lambda overload builds its factory once per call
  site instead of per recomposition.
- The upload video thumbnail decodes a scaled frame and releases its
  MediaMetadataRetriever.
- Active subscriptions polling runs in the screen's effect, so revisits
  no longer stack loops.
- Zap button configs are remembered and marked @Immutable.

Also drops dead code (an unused formatter and Context locals) and adds
tests for the query lookup, MediaUri and the month/day/time skeleton.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P
This commit is contained in:
Claude
2026-09-29 21:00:10 +00:00
parent d313a69e0a
commit 25fa0c4901
40 changed files with 472 additions and 178 deletions
@@ -49,6 +49,7 @@ import com.vitorpamplona.quartz.utils.Log
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import java.io.File
import kotlin.coroutines.cancellation.CancellationException
@@ -56,7 +57,9 @@ import kotlin.coroutines.cancellation.CancellationException
/**
* Android's [MediaUploader]: compresses with LightCompressor/Compressor, strips metadata, encrypts,
* and uploads over the app's upload-role OkHttp clients. Reads files through the application
* [appContext]'s content resolver, so it needs no Activity.
* [appContext]'s content resolver, so it needs no Activity. Every entry point moves to
* [Dispatchers.IO] itself: hashing, bitmap decoding and stripping block, and shared callers may
* launch from a Main-bound scope.
*/
class AndroidMediaUploader(
private val appContext: Context,
@@ -75,7 +78,7 @@ class AndroidMediaUploader(
onStrippingFailed: suspend () -> Boolean,
convertGifToMp4: Boolean,
forcedSigner: NostrSigner?,
): UploadingFinalState = Job(progress).upload(uri, mimeType, alt, contentWarningReason, compressionQuality, server, account, appContext, useH265, stripMetadata, onStrippingFailed, convertGifToMp4, forcedSigner)
): UploadingFinalState = withContext(Dispatchers.IO) { Job(progress).upload(uri, mimeType, alt, contentWarningReason, compressionQuality, server, account, appContext, useH265, stripMetadata, onStrippingFailed, convertGifToMp4, forcedSigner) }
override suspend fun uploadEncrypted(
progress: UploadOrchestrator,
@@ -92,7 +95,7 @@ class AndroidMediaUploader(
onStrippingFailed: suspend () -> Boolean,
convertGifToMp4: Boolean,
forcedSigner: NostrSigner?,
): UploadingFinalState = Job(progress).uploadEncrypted(uri, mimeType, alt, contentWarningReason, compressionQuality, encrypt, server, account, appContext, useH265, stripMetadata, onStrippingFailed, convertGifToMp4, forcedSigner)
): UploadingFinalState = withContext(Dispatchers.IO) { Job(progress).uploadEncrypted(uri, mimeType, alt, contentWarningReason, compressionQuality, encrypt, server, account, appContext, useH265, stripMetadata, onStrippingFailed, convertGifToMp4, forcedSigner) }
override suspend fun compressIfNeeded(
progress: UploadOrchestrator,
@@ -101,7 +104,7 @@ class AndroidMediaUploader(
compressionQuality: CompressorQuality,
useH265: Boolean,
convertGifToMp4: Boolean,
): MediaCompressorResult = Job(progress).compressIfNeeded(uri, mimeType, compressionQuality, appContext, useH265, convertGifToMp4)
): MediaCompressorResult = withContext(Dispatchers.IO) { Job(progress).compressIfNeeded(uri, mimeType, compressionQuality, appContext, useH265, convertGifToMp4) }
/** One upload's run: the pipeline, reporting into [progress]. */
private class Job(
@@ -22,40 +22,12 @@ package com.vitorpamplona.amethyst.ui.note
import android.content.Context
import android.text.format.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Date/time formatters that respect the user's Android system settings:
* - Date order (dd/mm/yyyy, mm/dd/yyyy, yyyy-mm-dd…) is derived from the
* active [Locale] via [DateFormat.getBestDateTimePattern].
* - 12-hour vs 24-hour clock follows the system setting via
* [DateFormat.is24HourFormat] (which is the user's manual override on
* top of the locale default).
*
* All formatters here use Unicode LDML skeletons. `j` and `jm` are intentionally
* avoided — `j` only picks 12/24 hour from the *locale*, not the user override.
* We pick the time skeleton explicitly based on [DateFormat.is24HourFormat].
*/
private fun timeSkeleton(context: Context): String = if (DateFormat.is24HourFormat(context)) "Hm" else "hma"
private fun bestPattern(
context: Context,
skeletonBase: String,
includeTime: Boolean,
): SimpleDateFormat {
val locale = Locale.getDefault()
val skeleton = if (includeTime) skeletonBase + timeSkeleton(context) else skeletonBase
return SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale)
}
/** Locale-aware month + day + short time (e.g. "May 28, 14:32" / "28 May, 2:32 PM"). */
fun formatMonthDayTime(
epochSeconds: Long,
context: Context,
): String = bestPattern(context, "MMMd", includeTime = true).format(Date(epochSeconds * 1000L))
// Date/time formatters that respect the user's Android system settings: the date order comes
// from the active locale and the clock follows DateFormat.is24HourFormat (the user's manual
// override on top of the locale default). The shared month/day/time formatter lives in
// commonsUI (rememberMonthDayTimeFormatter).
/** Locale-aware medium date (e.g. "May 28, 2026" / "28 May 2026" / "28.05.2026"). */
fun formatMediumDate(
@@ -1096,7 +1096,7 @@ private suspend fun sendAttachment(
// same reason the voice path deletes its recording. Both calls no-op on the
// user's own file.
deleteTempFile(finalUri, uri)
deleteTempFile(compressed.uri, uri)
if (compressed.uri != finalUri) deleteTempFile(compressed.uri, uri)
}
} finally {
// Always, not just on success: a failure leaves the dialog up to retry from,
@@ -1342,5 +1342,10 @@ private fun deleteTempFile(
) {
if (tempUri == originalUri) return
val path = tempUri.path ?: return
if (!File(path).delete()) Log.w("CordnGroupChat") { "Could not delete temp file $path" }
try {
val file = File(path)
if (file.exists() && !file.delete()) Log.w("CordnGroupChat") { "Could not delete temp file $path" }
} catch (e: Exception) {
Log.w("CordnGroupChat", "Failed to delete temp file $path", e)
}
}
@@ -486,6 +486,9 @@ class ChatNewMessageViewModel :
encryptedUploadErrorTitle = title
encryptedUploadErrorMessage = message
pendingRetryMode = RetryMode.HOLD
pendingRetryOnError = onError
pendingRetryUploader = uploader
pendingRetryOnceUploaded = onceUploaded
},
uploader,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
@@ -556,7 +559,9 @@ class ChatNewMessageViewModel :
dismissEncryptedUploadError()
if (room == null || uploadState == null || uploader == null) return
// HOLD only uploads; SEND also needs the room to send into.
if (uploadState == null || uploader == null) return
if (mode == RetryMode.SEND && room == null) return
uploadState.encryptFiles = false
@@ -576,13 +581,14 @@ class ChatNewMessageViewModel :
}
RetryMode.SEND -> {
val sendRoom = room ?: return@launchSigner
ChatFileUploader(account).justUploadNIP17Unencrypted(
uploadState,
onError ?: accountViewModel.toastManager::toast,
uploader,
onStrippingFailed = strippingFailureConfirmation::awaitConfirmation,
) {
ChatFileSender(room, account).sendNIP17(it)
ChatFileSender(sendRoom, account).sendNIP17(it)
draftTag.newVersion()
onceUploaded?.invoke()
}
@@ -236,7 +236,6 @@ fun GroupDMScreenContent(
nav: INav,
) {
val scrollState = rememberScrollState()
val context = LocalContext.current
Column(Modifier.fillMaxSize()) {
Row(Modifier.fillMaxWidth().padding(horizontal = Size10dp).weight(1f)) {
@@ -69,7 +69,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardCapitalization
@@ -490,7 +489,6 @@ private fun MarkdownPostScreenBody(
verticalAlignment = CenterVertically,
modifier = Modifier.padding(vertical = Size10dp, horizontal = Size10dp),
) {
val uploadContext = LocalContext.current
ImageVideoDescription(
it,
accountViewModel.account.settings.defaultFileServer,
@@ -486,7 +486,9 @@ class LongFormPostViewModel :
galleryUri: SelectedMedia,
uploader: MediaUploader,
onError: (String, String) -> Unit,
): String? = uploadToDefaultServer(galleryUri, account, uploader, onError)
): String? =
// Cover images were never stripped: keep them as picked, only compressed.
uploadToDefaultServer(galleryUri, account, uploader, onError, stripMetadata = false)
fun upload(
alt: String?,
@@ -1937,7 +1937,6 @@ open class ShortNotePostViewModel :
val recording = voiceRecording ?: return
val fileToUpload = activeFile ?: recording.file
val waveform = activeWaveform ?: recording.amplitudes
val appContext = Amethyst.instance.appContext
val uploadErrorTitle = loadStringRes(Res.string.upload_error_title)
val uploadVoiceNip95NotSupported = loadStringRes(Res.string.upload_error_voice_message_nip95_not_supported)
val uploadVoiceFailed = loadStringRes(Res.string.upload_error_voice_message_failed)
@@ -267,10 +267,7 @@ fun ZapDonationButton(
accountViewModel: AccountViewModel,
nav: INav,
) {
val config =
ZapButtonConfig(
grayTint = grayTint,
)
val config = remember(grayTint) { ZapButtonConfig(grayTint = grayTint) }
ReusableZapButton(
baseNote = baseNote,
@@ -127,7 +127,7 @@ fun ActiveSubscriptionsScreen(
nav: INav,
viewModel: ActiveSubscriptionsViewModel = viewModel(),
) {
LaunchedEffect(Unit) { viewModel.startPolling(accountViewModel.account.client) }
LaunchedEffect(Unit) { viewModel.pollWhileShown(accountViewModel.account.client) }
val state by viewModel.state.collectAsStateWithLifecycle()
Scaffold(
@@ -22,7 +22,6 @@ package com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.subscriptions
import androidx.compose.runtime.Immutable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.vitorpamplona.amethyst.commons.model.topNavFeeds.IFeedTopNavPerRelayFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.ExplainedFilter
import com.vitorpamplona.amethyst.commons.relayClient.subscriptions.SubPurpose
@@ -35,8 +34,6 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
/**
@@ -143,13 +140,15 @@ class ActiveSubscriptionsViewModel : ViewModel() {
private val _state = MutableStateFlow(ActiveSubscriptionsState())
val state: StateFlow<ActiveSubscriptionsState> = _state.asStateFlow()
/** Polls while the screen is on. [REFRESH_MS] is slow enough to be free, fast enough to feel live. */
fun startPolling(client: INostrClient) {
viewModelScope.launch(Dispatchers.Default) {
while (isActive) {
_state.value = snapshot(client)
delay(REFRESH_MS)
}
/**
* Polls until the caller's scope is cancelled. Run it from the screen's effect, so leaving the
* screen stops it; a loop in `viewModelScope` would outlive the screen, and each return to it
* would stack another. [REFRESH_MS] is slow enough to be free, fast enough to feel live.
*/
suspend fun pollWhileShown(client: INostrClient) {
while (true) {
_state.value = snapshot(client)
delay(REFRESH_MS)
}
}
@@ -32,3 +32,47 @@ expect abstract class MediaUri {
/** The last path segment of this address, if it has one (on Android, `Uri.lastPathSegment`). */
expect fun MediaUri.lastPathSegmentOrNull(): String?
/**
* The last path segment of a string address, percent-decoded, or null when it has no path
* (`content://host`, `https://host/`). Accepts backslash separators, so a Windows path works too. The
* string-backed platforms use it to match Android's `Uri.lastPathSegment`.
*/
internal fun lastPathSegmentOf(address: String): String? {
var path = address.substringBefore('#').substringBefore('?')
val schemeEnd = path.indexOf("://")
if (schemeEnd >= 0) {
val pathStart = path.indexOf('/', schemeEnd + 3)
if (pathStart < 0) return null
path = path.substring(pathStart)
}
val segment = path.replace('\\', '/').trimEnd('/').substringAfterLast('/')
return if (segment.isEmpty()) null else percentDecodeUtf8(segment)
}
/** Decodes `%XX` escapes as UTF-8 bytes; malformed escapes are kept as written. */
private fun percentDecodeUtf8(input: String): String {
if ('%' !in input) return input
val out = StringBuilder(input.length)
val bytes = ArrayList<Byte>()
var i = 0
while (i < input.length) {
val c = input[i]
val hex = if (c == '%' && i + 2 < input.length && isHex(input[i + 1]) && isHex(input[i + 2])) input.substring(i + 1, i + 3).toInt(16) else null
if (hex != null) {
bytes.add(hex.toByte())
i += 3
} else {
if (bytes.isNotEmpty()) {
out.append(bytes.toByteArray().decodeToString())
bytes.clear()
}
out.append(c)
i++
}
}
if (bytes.isNotEmpty()) out.append(bytes.toByteArray().decodeToString())
return out.toString()
}
private fun isHex(c: Char) = c in '0'..'9' || c in 'a'..'f' || c in 'A'..'F'
@@ -29,12 +29,12 @@ class StringMediaUri(
val value: String,
) : MediaUri() {
override fun toString(): String = value
// Value equality, like android.net.Uri: the upload pipeline tells a temp file from the
// user's own by comparing addresses, and deletes the temp one.
override fun equals(other: Any?): Boolean = other is StringMediaUri && other.value == value
override fun hashCode(): Int = value.hashCode()
}
actual fun MediaUri.lastPathSegmentOrNull(): String? =
toString()
.substringBefore('?')
.substringBefore('#')
.trimEnd('/')
.substringAfterLast('/')
.ifBlank { null }
actual fun MediaUri.lastPathSegmentOrNull(): String? = lastPathSegmentOf(toString())
@@ -29,12 +29,12 @@ class StringMediaUri(
val value: String,
) : MediaUri() {
override fun toString(): String = value
// Value equality, like android.net.Uri: the upload pipeline tells a temp file from the
// user's own by comparing addresses, and deletes the temp one.
override fun equals(other: Any?): Boolean = other is StringMediaUri && other.value == value
override fun hashCode(): Int = value.hashCode()
}
actual fun MediaUri.lastPathSegmentOrNull(): String? =
toString()
.substringBefore('?')
.substringBefore('#')
.trimEnd('/')
.substringAfterLast('/')
.ifBlank { null }
actual fun MediaUri.lastPathSegmentOrNull(): String? = lastPathSegmentOf(toString())
@@ -0,0 +1,52 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.service.uploads
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
import kotlin.test.assertNull
class MediaUriTest {
@Test
fun lastSegmentOfPathsAndUrls() {
assertEquals("song.mp3", StringMediaUri("/home/me/music/song.mp3").lastPathSegmentOrNull())
assertEquals("My Song.mp3", StringMediaUri("file:///home/me/My%20Song.mp3").lastPathSegmentOrNull())
assertEquals("café.png", StringMediaUri("https://host/a/caf%C3%A9.png?x=1#y").lastPathSegmentOrNull())
assertEquals("clip.mp4", StringMediaUri("C:\\Users\\me\\clip.mp4").lastPathSegmentOrNull())
assertEquals("dir", StringMediaUri("https://host/dir/").lastPathSegmentOrNull())
assertEquals("100%", StringMediaUri("/tmp/100%").lastPathSegmentOrNull())
}
@Test
fun noPathIsNull() {
assertNull(StringMediaUri("content://host").lastPathSegmentOrNull())
assertNull(StringMediaUri("https://host/").lastPathSegmentOrNull())
assertNull(StringMediaUri("").lastPathSegmentOrNull())
}
@Test
fun comparesByValue() {
assertEquals(StringMediaUri("/a/b.png"), StringMediaUri("/a/b.png"))
assertEquals(StringMediaUri("/a/b.png").hashCode(), StringMediaUri("/a/b.png").hashCode())
assertNotEquals(StringMediaUri("/a/b.png"), StringMediaUri("/a/c.png"))
}
}
@@ -28,16 +28,17 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.concurrent.atomic.AtomicBoolean
@Composable
actual fun FileSelect(onFilesSelected: (ImmutableList<SelectedMedia>) -> Unit) {
val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
// Saveable: a picker open across an activity recreation must not be launched a second time.
var hasLaunched by rememberSaveable { mutableStateOf(false) }
val resolver = LocalContext.current.contentResolver
val launcher =
@@ -50,14 +51,15 @@ actual fun FileSelect(onFilesSelected: (ImmutableList<SelectedMedia>) -> Unit) {
SelectedMedia(it, resolver.getType(it))
}.toImmutableList(),
)
hasLaunched.set(false)
hasLaunched = false
},
)
@Composable
fun LaunchFilePicker() {
SideEffect {
if (!hasLaunched.getAndSet(true)) {
if (!hasLaunched) {
hasLaunched = true
launcher.launch(
arrayOf(
"audio/*",
@@ -76,11 +78,20 @@ actual fun DocumentSelectSingle(
mimeTypes: List<String>,
onPicked: (SelectedMedia?) -> Unit,
) {
var hasLaunched by rememberSaveable { mutableStateOf(false) }
val resolver = LocalContext.current.contentResolver
val launcher =
rememberLauncherForActivityResult(
contract = ActivityResultContracts.OpenDocument(),
onResult = { uri: Uri? -> onPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) }) },
onResult = { uri: Uri? ->
hasLaunched = false
onPicked(uri?.let { SelectedMedia(it, resolver.getType(it)) })
},
)
LaunchedEffect(Unit) { launcher.launch(mimeTypes.toTypedArray()) }
LaunchedEffect(Unit) {
if (!hasLaunched) {
hasLaunched = true
launcher.launch(mimeTypes.toTypedArray())
}
}
}
@@ -28,16 +28,17 @@ 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 androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import java.util.concurrent.atomic.AtomicBoolean
@Composable
actual fun GallerySelect(onImageUri: (ImmutableList<SelectedMedia>) -> Unit) {
val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
// Saveable: a picker open across an activity recreation must not be launched a second time.
var hasLaunched by rememberSaveable { mutableStateOf(false) }
val resolver = LocalContext.current.contentResolver
val launcher =
@@ -50,14 +51,15 @@ actual fun GallerySelect(onImageUri: (ImmutableList<SelectedMedia>) -> Unit) {
SelectedMedia(it, resolver.getType(it))
}.toImmutableList(),
)
hasLaunched.set(false)
hasLaunched = false
},
)
@Composable
fun LaunchGallery() {
SideEffect {
if (!hasLaunched.getAndSet(true)) {
if (!hasLaunched) {
hasLaunched = true
launcher.launch(PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageAndVideo))
}
}
@@ -71,7 +73,8 @@ actual fun GallerySelectSingle(
imagesOnly: Boolean,
onImageUri: (SelectedMedia?) -> Unit,
) {
val hasLaunched by remember { mutableStateOf(AtomicBoolean(false)) }
// Saveable: a picker open across an activity recreation must not be launched a second time.
var hasLaunched by rememberSaveable { mutableStateOf(false) }
val resolver = LocalContext.current.contentResolver
val launcher =
@@ -84,14 +87,15 @@ actual fun GallerySelectSingle(
onImageUri(null)
}
hasLaunched.set(false)
hasLaunched = false
},
)
@Composable
fun LaunchGallery() {
SideEffect {
if (!hasLaunched.getAndSet(true)) {
if (!hasLaunched) {
hasLaunched = true
launcher.launch(PickVisualMediaRequest(if (imagesOnly) ActivityResultContracts.PickVisualMedia.ImageOnly else ActivityResultContracts.PickVisualMedia.ImageAndVideo))
}
}
@@ -25,6 +25,7 @@ import android.graphics.Bitmap
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.os.Build
import androidx.annotation.RequiresApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
@@ -56,19 +57,26 @@ actual fun rememberVideoThumbnail(media: SelectedMedia): ImageBitmap? {
return thumb
}
// A preview tile: a frame this size is plenty, where a full 4K frame would be ~33 MB.
private const val THUMB_MAX_SIDE = 1024
/**
* Creates a bitmap thumbnail from video uri of the scheme type content://
* Creates a bitmap thumbnail, at most [THUMB_MAX_SIDE] on its longer side, from a video uri of the
* scheme type content://
*/
fun createVideoThumb(
@RequiresApi(Build.VERSION_CODES.O_MR1)
private fun createVideoThumb(
context: Context,
uri: Uri,
): Bitmap? {
try {
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(context, uri)
return mediaMetadataRetriever.frameAtTime
val retriever = MediaMetadataRetriever()
return try {
retriever.setDataSource(context, uri)
retriever.getScaledFrameAtTime(-1, MediaMetadataRetriever.OPTION_CLOSEST_SYNC, THUMB_MAX_SIDE, THUMB_MAX_SIDE)
} catch (ex: Exception) {
Log.w("VideoThumbnail", "Couldn't create thumbnail, but the video can be uploaded", ex)
null
} finally {
retriever.release()
}
return null
}
@@ -0,0 +1,42 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.note
import android.text.format.DateFormat
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalContext
// Explicit hour skeletons, not `j`: `j` follows the locale, the user's 12/24-hour override does not.
private val monthDayTime24 = DateSkeletonFormatter("MMMdHm")
private val monthDayTime12 = DateSkeletonFormatter("MMMdhma")
/** Reads the 12/24-hour setting on every call: it can change under a running app. */
@Composable
actual fun rememberMonthDayTimeFormatter(): (epochSeconds: Long) -> String {
val context = LocalContext.current
return remember(context) {
{ epochSeconds ->
val formatter = if (DateFormat.is24HourFormat(context)) monthDayTime24 else monthDayTime12
formatter.format(epochSeconds * 1000L)
}
}
}
@@ -28,25 +28,35 @@ import androidx.compose.ui.platform.LocalContext
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
/**
* Per-thread cached [SimpleDateFormat] keyed off the current default [Locale].
* Per-thread cached [SimpleDateFormat] keyed off the current default [Locale] and time zone.
*
* `SimpleDateFormat` is mutable and not thread-safe, and these formatters are read from both
* the UI thread (composition) and background coroutines. `ThreadLocal` gives each thread its
* own instance: no locks, no allocation per call, and a lazy rebuild on locale change.
* own instance: no locks, and a lazy rebuild on a locale or time-zone change. A formatter fixes
* its zone when built, so without the zone in the key a trip across zones would keep printing
* dates in the old one.
*/
actual class DateSkeletonFormatter actual constructor(
private val skeleton: String,
) {
private val cache = ThreadLocal<Pair<Locale, SimpleDateFormat>>()
private class Cached(
val locale: Locale,
val zoneId: String,
val formatter: SimpleDateFormat,
)
private val cache = ThreadLocal<Cached>()
private fun get(): SimpleDateFormat {
val current = Locale.getDefault()
val locale = Locale.getDefault()
val zoneId = TimeZone.getDefault().id
val cached = cache.get()
if (cached != null && cached.first == current) return cached.second
val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(current, skeleton), current)
cache.set(current to fresh)
if (cached != null && cached.locale == locale && cached.zoneId == zoneId) return cached.formatter
val fresh = SimpleDateFormat(DateFormat.getBestDateTimePattern(locale, skeleton), locale)
cache.set(Cached(locale, zoneId, fresh))
return fresh
}
@@ -21,8 +21,12 @@
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import kotlin.reflect.KClass
/**
@@ -56,3 +60,22 @@ inline fun <reified VM : ViewModel> rememberViewModel(
key: String? = null,
noinline factory: () -> VM,
): VM = rememberViewModel(VM::class, key, factory)
/**
* One [ViewModelProvider.Factory] per call site for the lambda overloads of [rememberViewModel],
* instead of a new object on every recomposition. It calls the latest [factory], which only runs
* when the store has no view model yet.
*/
@Composable
internal fun <VM : ViewModel> rememberLambdaFactory(factory: () -> VM): ViewModelProvider.Factory {
val currentFactory by rememberUpdatedState(factory)
return remember {
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
modelClass: KClass<T>,
extras: CreationExtras,
): T = currentFactory() as T
}
}
}
@@ -20,6 +20,7 @@
*/
package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp
@@ -28,8 +29,10 @@ import com.vitorpamplona.amethyst.commons.ui.theme.Size20Modifier
import com.vitorpamplona.amethyst.commons.ui.theme.Size35dp
/**
* Configuration for zap button behavior and appearance
* Configuration for zap button behavior and appearance. Never mutated after construction, so it
* is marked immutable for Compose (its `Modifier` and `List` fields would otherwise make it unstable).
*/
@Immutable
data class ZapButtonConfig(
val grayTint: Color = Color.Gray,
val iconSize: Dp = Size35dp,
@@ -32,7 +32,7 @@ package com.vitorpamplona.amethyst.commons.ui.navigation
* the group under whichever account happened to be current instead of switching first.
*
* Splitting on the first `?` gets the same answer for both shapes, and returns null for a
* bare `nevent1…` with no query at all.
* bare `nevent1…` with no query at all. A `#fragment` is dropped; a value may itself contain `=`.
*
* `UriParser` reads an opaque query correctly too, and is the
* right tool when a URI is already known to be well-formed. It is not this one: it builds a
@@ -41,7 +41,8 @@ package com.vitorpamplona.amethyst.commons.ui.navigation
* deep-link path treats an unreadable uri as "no route" rather than as a crash.
*/
fun String.findQueryParameterValue(parameterName: String): String? {
val query = substringAfter('?', "")
// A raw `#` starts the fragment, which is not part of the query (as `java.net.URI` read it).
val query = substringBefore('#').substringAfter('?', "")
if (query.isEmpty()) return null
return query
@@ -20,16 +20,13 @@
*/
package com.vitorpamplona.amethyst.commons.ui.note
private val monthDay = DateSkeletonFormatter("MMMd")
import androidx.compose.runtime.Composable
/**
* Locale-ordered month and day followed by [timeOfDay] ("May 28 14:32" / "28 May 2:32 PM"). Pass
* [rememberTimeOfDayFormatter] so the clock follows the system 12/24-hour setting.
* Formats an instant (epoch seconds) as the locale's month, day and short time in one pattern
* ("May 28, 14:32" / "28 May, 2:32 PM"), in the current time zone. Android follows the system
* 12/24-hour setting; the JVM and iOS follow the locale. Identity-stable, so it can key a
* `remember`.
*/
fun formatMonthDayTime(
epochSeconds: Long,
timeOfDay: (epochMillis: Long) -> String,
): String {
val millis = epochSeconds * 1000L
return "${monthDay.format(millis)} ${timeOfDay(millis)}"
}
@Composable
expect fun rememberMonthDayTimeFormatter(): (epochSeconds: Long) -> String
@@ -25,8 +25,8 @@ import androidx.compose.runtime.Composable
/**
* Formats instants with a Unicode LDML date skeleton ("yMMMd", "MMMd", "yMMM"), letting the
* platform pick the default locale's field order ("MMM d, y" in en-US, "d MMM y" in en-GB).
* Safe to call from any thread. Rebuilds itself when the default locale changes (the JVM actual
* also on a time-zone change; Android and iOS keep the zone they were built with).
* Safe to call from any thread. Rebuilds itself when the default locale or time zone changes (iOS
* keeps the zone it was built with).
*/
expect class DateSkeletonFormatter(
skeleton: String,
@@ -329,8 +329,13 @@ class BuzzDmListViewModel : ViewModel() {
.filterValues { it in scoped }
var changed = false
memberships.forEach { (channelId, relay) ->
if (memberChannels[channelId] == null) changed = true
memberChannels[channelId] = relay
// One atomic insert decides "new", so a concurrent seed of the same
// channel can't make this pass skip its row rebuild.
if (memberChannels.putIfAbsent(channelId, relay) == null) {
changed = true
} else {
memberChannels[channelId] = relay
}
}
// A kind-44101 takes the membership away: drop the row rather than leaving a
// conversation the relay no longer lets us read.
@@ -47,6 +47,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -159,7 +160,7 @@ private fun ConcordIconHero(
var uploading by remember { mutableStateOf(false) }
val iconModel = rememberConcordImageModel(icon.value, accountViewModel)
var picking by remember { mutableStateOf(false) }
var picking by rememberSaveable { mutableStateOf(false) }
if (picking) {
GallerySelectSingle(imagesOnly = true) { media ->
picking = false
@@ -232,7 +233,7 @@ private fun ConcordBannerHero(
var uploading by remember { mutableStateOf(false) }
val bannerModel = rememberConcordImageModel(banner.value, accountViewModel)
var picking by remember { mutableStateOf(false) }
var picking by rememberSaveable { mutableStateOf(false) }
if (picking) {
GallerySelectSingle(imagesOnly = true) { media ->
picking = false
@@ -487,13 +487,15 @@ fun ZapDVMButton(
nav: INav,
) {
val config =
ZapButtonConfig(
grayTint = grayTint,
iconSize = iconSize,
showUserFinderSubscription = true,
zapAmountChoices = listOf(amount / 1000),
buttonText = "Zap ${(amount / 1000)} sats to the DVM",
)
remember(grayTint, iconSize, amount) {
ZapButtonConfig(
grayTint = grayTint,
iconSize = iconSize,
showUserFinderSubscription = true,
zapAmountChoices = listOf(amount / 1000),
buttonText = "Zap ${(amount / 1000)} sats to the DVM",
)
}
ReusableZapButton(
baseNote = baseNote,
@@ -54,6 +54,7 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@@ -130,7 +131,7 @@ fun LogGeocacheFindScreen(
val listing = model.listing()
var scanning by remember { mutableStateOf(false) }
var pickingPhoto by remember { mutableStateOf(false) }
var pickingPhoto by rememberSaveable { mutableStateOf(false) }
if (pickingPhoto) {
GallerySelectSingle(imagesOnly = true) { media ->
pickingPhoto = false
@@ -37,11 +37,10 @@ class HiddenAccountsFeedViewModel(
modelClass: KClass<T>,
extras: CreationExtras,
): T {
if (modelClass == HiddenAccountsFeedViewModel::class) {
@Suppress("UNCHECKED_CAST")
return HiddenAccountsFeedViewModel(account) as T
}
throw IllegalArgumentException("Unknown ViewModel class: ${modelClass.simpleName}")
// Like the sibling feed factories: a caller may ask for a supertype
// (`UserFeedViewModel`), which a class-equality check would reject.
@Suppress("UNCHECKED_CAST")
return HiddenAccountsFeedViewModel(account) as T
}
}
}
@@ -80,8 +80,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.note.formatMonthDayTime
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.note.rememberMonthDayTimeFormatter
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.TransactionFilter
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.datasource.OnchainZapsFilterAssemblerSubscription
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -312,11 +311,11 @@ private fun OnchainTransactionItem(
(if (isIncoming) "+" else "-") + formatGrouped(amountSats)
}
val timeOfDay = rememberTimeOfDayFormatter()
val monthDayTime = rememberMonthDayTimeFormatter()
val dateText =
remember(view.tx.blockTime, view.tx.confirmations, timeOfDay) {
remember(view.tx.blockTime, view.tx.confirmations, monthDayTime) {
val ts = view.tx.blockTime
if (ts != null) formatMonthDayTime(ts, timeOfDay) else ""
if (ts != null) monthDayTime(ts) else ""
}
val counterpartyPubkeyHex = view.counterpartyPubkeyHex()
@@ -130,7 +130,7 @@ fun WalletSendScreen(
maxLines = 5,
trailingIcon = {
IconButton(onClick = {
clipboardScope.launch { invoiceText = clipboard.getText() ?: "" }
clipboardScope.launch { clipboard.getText()?.let { invoiceText = it } }
}) {
Icon(
symbol = MaterialSymbols.ContentPaste,
@@ -77,8 +77,7 @@ import com.vitorpamplona.amethyst.commons.ui.navigation.navs.INav
import com.vitorpamplona.amethyst.commons.ui.note.LoadUser
import com.vitorpamplona.amethyst.commons.ui.note.UserPicture
import com.vitorpamplona.amethyst.commons.ui.note.UsernameDisplay
import com.vitorpamplona.amethyst.commons.ui.note.formatMonthDayTime
import com.vitorpamplona.amethyst.commons.ui.note.rememberTimeOfDayFormatter
import com.vitorpamplona.amethyst.commons.ui.note.rememberMonthDayTimeFormatter
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.TransactionFilter
import com.vitorpamplona.amethyst.commons.ui.screen.loggedIn.wallet.WalletViewModel
import com.vitorpamplona.amethyst.commons.ui.stringRes
@@ -281,10 +280,10 @@ private fun TransactionItem(
(if (isIncoming) "+" else "-") + formatGrouped(amountSats)
}
val timeOfDay = rememberTimeOfDayFormatter()
val monthDayTime = rememberMonthDayTimeFormatter()
val dateText =
remember(tx.created_at, timeOfDay) {
tx.created_at?.let { formatMonthDayTime(it, timeOfDay) } ?: ""
remember(tx.created_at, monthDayTime) {
tx.created_at?.let { monthDayTime(it) } ?: ""
}
val directionLabel =
@@ -24,10 +24,13 @@ import com.vitorpamplona.amethyst.commons.model.Account
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_upload
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_title
import com.vitorpamplona.amethyst.commons.resources.metadata_strip_failed_upload_cancelled
import com.vitorpamplona.amethyst.commons.resources.server_did_not_provide_a_url_after_uploading
import com.vitorpamplona.amethyst.commons.service.uploads.CompressorQuality
import com.vitorpamplona.amethyst.commons.service.uploads.MediaUploader
import com.vitorpamplona.amethyst.commons.service.uploads.SelectedMedia
import com.vitorpamplona.amethyst.commons.service.uploads.UploadError
import com.vitorpamplona.amethyst.commons.service.uploads.UploadOrchestrator
import com.vitorpamplona.amethyst.commons.service.uploads.UploadingState
import com.vitorpamplona.amethyst.commons.ui.loadStringRes
@@ -35,9 +38,10 @@ import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions
import kotlinx.coroutines.CancellationException
/**
* Compresses, strips (when the account asks for it) and uploads one picked file to the account's
* default media server, returning the hosted URL. Every failure, a metadata-stripping one
* included, is reported through [onError] with a user-facing title and message, and yields null.
* Compresses, strips (when [stripMetadata] and the account ask for it) and uploads one picked file
* to the account's default media server, returning the hosted URL. Every failure, a
* metadata-stripping one included, is reported through [onError] with a user-facing title and
* message, and yields null.
*
* For single-image fields (avatars, banners, pack and cover images) that only need a URL back.
*/
@@ -47,6 +51,7 @@ suspend fun uploadToDefaultServer(
uploader: MediaUploader,
onError: (title: String, message: String) -> Unit,
quality: CompressorQuality = CompressorQuality.MEDIUM,
stripMetadata: Boolean = true,
): String? {
val state =
try {
@@ -59,7 +64,7 @@ suspend fun uploadToDefaultServer(
server = account.settings.defaultFileServer,
account = account,
uploader = uploader,
stripMetadata = account.settings.stripLocationOnUpload,
stripMetadata = stripMetadata && account.settings.stripLocationOnUpload,
onStrippingFailed = { false },
)
} catch (_: SignerExceptions.ReadOnlyException) {
@@ -83,7 +88,16 @@ suspend fun uploadToDefaultServer(
}
is UploadingState.Error -> {
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(state.errorResource, *state.params))
when (state.error) {
// The only cancel on this path is a metadata strip that failed: say so, since the
// user turned stripping on and would otherwise not know why nothing uploaded.
UploadError.UPLOAD_CANCELLED ->
onError(loadStringRes(Res.string.metadata_strip_failed_title), loadStringRes(Res.string.metadata_strip_failed_upload_cancelled))
UploadError.AVIF_METADATA_STRIP_FAILED ->
onError(loadStringRes(Res.string.metadata_strip_failed_title), loadStringRes(state.errorResource, *state.params))
else ->
onError(loadStringRes(Res.string.failed_to_upload_media_no_details), loadStringRes(state.errorResource, *state.params))
}
null
}
}
@@ -26,7 +26,6 @@ import androidx.compose.runtime.remember
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.viewmodel.CreationExtras
import kotlin.reflect.KClass
/**
@@ -50,15 +49,4 @@ actual fun <VM : ViewModel> rememberViewModel(
modelClass: KClass<VM>,
key: String?,
factory: () -> VM,
): VM =
rememberViewModel(
modelClass,
key,
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
modelClass: KClass<T>,
extras: CreationExtras,
): T = factory() as T
},
)
): VM = rememberViewModel(modelClass, key, rememberLambdaFactory(factory))
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.runtime.Composable
private val monthDayTime = DateSkeletonFormatter("MMMdjm")
private val formatMonthDayTime: (Long) -> String = { epochSeconds -> monthDayTime.format(epochSeconds * 1000L) }
@Composable
actual fun rememberMonthDayTimeFormatter(): (epochSeconds: Long) -> String = formatMonthDayTime
@@ -23,7 +23,6 @@ package com.vitorpamplona.amethyst.commons.ui.components
import androidx.compose.runtime.Composable
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewmodel.CreationExtras
import androidx.lifecycle.viewmodel.compose.viewModel
import kotlin.reflect.KClass
@@ -39,16 +38,4 @@ actual fun <VM : ViewModel> rememberViewModel(
modelClass: KClass<VM>,
key: String?,
factory: () -> VM,
): VM =
viewModel(
modelClass = modelClass,
key = key,
factory =
object : ViewModelProvider.Factory {
@Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
modelClass: KClass<T>,
extras: CreationExtras,
): T = factory() as T
},
)
): VM = viewModel(modelClass = modelClass, key = key, factory = rememberLambdaFactory(factory))
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.note
import androidx.compose.runtime.Composable
private val monthDayTime = DateSkeletonFormatter("MMMdjm")
private val formatMonthDayTime: (Long) -> String = { epochSeconds -> monthDayTime.format(epochSeconds * 1000L) }
@Composable
actual fun rememberMonthDayTimeFormatter(): (epochSeconds: Long) -> String = formatMonthDayTime
@@ -0,0 +1,53 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.commons.ui.navigation
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class QueryParametersTest {
@Test
fun readsOpaqueAndHierarchicalQueries() {
assertEquals("npub1abc", "marmot:0011?account=npub1abc".findQueryParameterValue("account"))
assertEquals("30078:ab:x", "connectedapp?coordinate=30078:ab:x&y=1".findQueryParameterValue("coordinate"))
assertEquals("1", "https://h/p?a=0&y=1".findQueryParameterValue("y"))
}
@Test
fun dropsTheFragment() {
assertEquals("abc", "connectedapp?coordinate=abc#x".findQueryParameterValue("coordinate"))
assertNull("connectedapp#x?coordinate=abc".findQueryParameterValue("coordinate"))
}
@Test
fun keepsEqualsInsideValues() {
assertEquals("https://h/p?q=1", "url?id=https://h/p?q=1".findQueryParameterValue("id"))
assertEquals("a=b", "x?k=a=b".findQueryParameterValue("k"))
}
@Test
fun missingOrEmptyIsNull() {
assertNull("nevent1xyz".findQueryParameterValue("id"))
assertNull("x?id=".findQueryParameterValue("id"))
assertNull("x?other=1".findQueryParameterValue("id"))
}
}
@@ -75,6 +75,17 @@ class DateSkeletonFormatterTest {
assertEquals("Jan 2024", DateSkeletonFormatter("yMMM").format(millis(2024, 1, 5)))
}
@Test
fun monthDayTimeSkeletonIsOnePattern() {
// rememberMonthDayTimeFormatter's JVM skeleton: `j` must be accepted, and the locale's
// own separator kept between the date and the time.
Locale.setDefault(Locale.US)
val us = DateSkeletonFormatter("MMMdjm").format(millis(2024, 5, 28, hour = 14))
assertTrue(us, us.startsWith("May 28,") && us.contains("2:00") && us.contains("PM"))
Locale.setDefault(Locale.UK)
assertTrue(DateSkeletonFormatter("MMMdjm").format(millis(2024, 5, 28, hour = 14)).contains("14:00"))
}
@Test
fun calendarDayDistinguishesDaysAndYears() {
assertEquals(calendarYearAndDay(millis(2024, 3, 1, hour = 1)), calendarYearAndDay(millis(2024, 3, 1, hour = 23)))