From 25fa0c4901a3def5d0f06834c839de70d6f53791 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 29 Sep 2026 21:00:10 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01S7FuNBSKiyVecARSoE4B9P --- .../service/uploads/AndroidMediaUploader.kt | 11 ++-- .../ui/note/LocalizedDateTimeFormat.kt | 36 ++----------- .../chats/cordnGroup/CordnGroupChatScreen.kt | 9 +++- .../privateDM/send/ChatNewMessageViewModel.kt | 10 +++- .../chats/privateDM/send/NewGroupDMScreen.kt | 1 - .../nip23LongForm/LongFormPostScreen.kt | 2 - .../nip23LongForm/LongFormPostViewModel.kt | 4 +- .../loggedIn/home/ShortNotePostViewModel.kt | 1 - .../notifications/donations/ZapTheDevsCard.kt | 5 +- .../ActiveSubscriptionsScreen.kt | 2 +- .../ActiveSubscriptionsViewModel.kt | 19 ++++--- .../commons/service/uploads/MediaUri.kt | 44 +++++++++++++++ .../commons/service/uploads/MediaUri.ios.kt | 14 ++--- .../commons/service/uploads/MediaUri.jvm.kt | 14 ++--- .../commons/service/uploads/MediaUriTest.kt | 52 ++++++++++++++++++ .../ui/actions/uploads/FileSelect.android.kt | 25 ++++++--- .../actions/uploads/GallerySelect.android.kt | 20 ++++--- .../actions/uploads/VideoThumbnail.android.kt | 22 +++++--- .../commons/ui/note/MonthDayTime.android.kt | 42 +++++++++++++++ .../ui/note/PlatformDateFormat.android.kt | 24 ++++++--- .../ui/components/RememberViewModel.kt | 23 ++++++++ .../commons/ui/components/ZapButtonConfig.kt | 5 +- .../commons/ui/navigation/QueryParameters.kt | 5 +- .../amethyst/commons/ui/note/MonthDayTime.kt | 17 +++--- .../commons/ui/note/PlatformDateFormat.kt | 4 +- .../loggedIn/buzz/BuzzDmListViewModel.kt | 9 +++- .../concord/ConcordMetadataForm.kt | 5 +- .../dvms/DvmContentDiscoveryScreen.kt | 16 +++--- .../geocaches/log/LogGeocacheFindScreen.kt | 3 +- .../dal/HiddenAccountsFeedViewModel.kt | 9 ++-- .../wallet/OnchainTransactionsScreen.kt | 9 ++-- .../loggedIn/wallet/WalletSendScreen.kt | 2 +- .../wallet/WalletTransactionsScreen.kt | 9 ++-- .../ui/uploads/UploadToDefaultServer.kt | 24 +++++++-- .../ui/components/RememberViewModel.ios.kt | 14 +---- .../commons/ui/note/MonthDayTime.ios.kt | 30 +++++++++++ .../RememberViewModel.jvmAndroid.kt | 15 +----- .../commons/ui/note/MonthDayTime.jvm.kt | 30 +++++++++++ .../ui/navigation/QueryParametersTest.kt | 53 +++++++++++++++++++ .../ui/note/DateSkeletonFormatterTest.kt | 11 ++++ 40 files changed, 472 insertions(+), 178 deletions(-) create mode 100644 commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUriTest.kt create mode 100644 commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.android.kt create mode 100644 commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.ios.kt create mode 100644 commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.jvm.kt create mode 100644 commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/QueryParametersTest.kt diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt index 293b53d998..2e670493a4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/uploads/AndroidMediaUploader.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt index 70ecc2dc71..e58e919b6b 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/note/LocalizedDateTimeFormat.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt index d6cd50aaa4..f5815f5152 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/cordnGroup/CordnGroupChatScreen.kt @@ -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) + } } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt index 783f35dacd..5059cfe763 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/ChatNewMessageViewModel.kt @@ -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() } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt index c6e13b30be..93d0c369e0 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/chats/privateDM/send/NewGroupDMScreen.kt @@ -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)) { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt index 838601fdc4..14a038c7db 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostScreen.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt index 600be530c4..527a64cf98 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/discover/nip23LongForm/LongFormPostViewModel.kt @@ -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?, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt index b689be1da0..629fb3cc2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/home/ShortNotePostViewModel.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt index 8177855b07..980a70bba4 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/notifications/donations/ZapTheDevsCard.kt @@ -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, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt index 6c79da8714..488797bcfc 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsScreen.kt @@ -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( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt index b12cbe3667..5e79ff23c3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/relays/subscriptions/ActiveSubscriptionsViewModel.kt @@ -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 = _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) } } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.kt index daef1f6d3a..b4de3123e9 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.kt @@ -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() + 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' diff --git a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.ios.kt b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.ios.kt index e367e7b8f9..a8e33c608e 100644 --- a/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.ios.kt +++ b/commons/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.ios.kt @@ -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()) diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.jvm.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.jvm.kt index e367e7b8f9..a8e33c608e 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.jvm.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUri.jvm.kt @@ -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()) diff --git a/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUriTest.kt b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUriTest.kt new file mode 100644 index 0000000000..752605bc66 --- /dev/null +++ b/commons/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/service/uploads/MediaUriTest.kt @@ -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")) + } +} diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/FileSelect.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/FileSelect.android.kt index dccb258f2a..6c87ff3900 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/FileSelect.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/FileSelect.android.kt @@ -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) -> 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) -> 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, 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()) + } + } } diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/GallerySelect.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/GallerySelect.android.kt index a2561364ac..28ea8b58d4 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/GallerySelect.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/GallerySelect.android.kt @@ -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) -> 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) -> 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)) } } diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/VideoThumbnail.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/VideoThumbnail.android.kt index 57b3a1d9ce..a746baa6a4 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/VideoThumbnail.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/actions/uploads/VideoThumbnail.android.kt @@ -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 } diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.android.kt new file mode 100644 index 0000000000..3e5b081006 --- /dev/null +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.android.kt @@ -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) + } + } +} diff --git a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt index 385e7335e5..f4c13f3478 100644 --- a/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt +++ b/commonsUI/src/androidMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.android.kt @@ -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>() + private class Cached( + val locale: Locale, + val zoneId: String, + val formatter: SimpleDateFormat, + ) + + private val cache = ThreadLocal() 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 } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.kt index 5902d14fb1..5542f13703 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.kt @@ -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 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 rememberLambdaFactory(factory: () -> VM): ViewModelProvider.Factory { + val currentFactory by rememberUpdatedState(factory) + return remember { + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create( + modelClass: KClass, + extras: CreationExtras, + ): T = currentFactory() as T + } + } +} diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ZapButtonConfig.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ZapButtonConfig.kt index a1d2260b25..340404608d 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ZapButtonConfig.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/ZapButtonConfig.kt @@ -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, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/QueryParameters.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/QueryParameters.kt index 88f3856290..1faf16cf74 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/QueryParameters.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/QueryParameters.kt @@ -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 diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.kt index 313eb57144..5eb37d3c10 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.kt @@ -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 diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt index 94711c83d9..4b88cbbbff 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/PlatformDateFormat.kt @@ -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, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt index de520c976d..f76b6b9ab6 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/buzz/BuzzDmListViewModel.kt @@ -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. diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt index ebb6b3fbf2..5a69e72520 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/chats/publicChannels/concord/ConcordMetadataForm.kt @@ -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 diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt index ceb76c6f5d..403708c1cc 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/dvms/DvmContentDiscoveryScreen.kt @@ -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, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/log/LogGeocacheFindScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/log/LogGeocacheFindScreen.kt index ad5b20eccf..a74c53ffda 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/log/LogGeocacheFindScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/geocaches/log/LogGeocacheFindScreen.kt @@ -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 diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedViewModel.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedViewModel.kt index 26c897aabb..8b92bc28de 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedViewModel.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/settings/dal/HiddenAccountsFeedViewModel.kt @@ -37,11 +37,10 @@ class HiddenAccountsFeedViewModel( modelClass: KClass, 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 } } } diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt index 80bf3a1f50..5887f121c3 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/wallet/OnchainTransactionsScreen.kt @@ -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() diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/wallet/WalletSendScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/wallet/WalletSendScreen.kt index 9687610c0d..0e8cf144d0 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/wallet/WalletSendScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/wallet/WalletSendScreen.kt @@ -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, diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt index f2e3d61133..90cc9e89ea 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/screen/loggedIn/wallet/WalletTransactionsScreen.kt @@ -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 = diff --git a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/uploads/UploadToDefaultServer.kt b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/uploads/UploadToDefaultServer.kt index 3b6fa75390..4e14ab5b59 100644 --- a/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/uploads/UploadToDefaultServer.kt +++ b/commonsUI/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/ui/uploads/UploadToDefaultServer.kt @@ -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 } } diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.ios.kt index 089cdea849..9b52253b2f 100644 --- a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.ios.kt +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.ios.kt @@ -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 rememberViewModel( modelClass: KClass, key: String?, factory: () -> VM, -): VM = - rememberViewModel( - modelClass, - key, - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create( - modelClass: KClass, - extras: CreationExtras, - ): T = factory() as T - }, - ) +): VM = rememberViewModel(modelClass, key, rememberLambdaFactory(factory)) diff --git a/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.ios.kt b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.ios.kt new file mode 100644 index 0000000000..f03895ab70 --- /dev/null +++ b/commonsUI/src/iosMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.ios.kt @@ -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 diff --git a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.jvmAndroid.kt b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.jvmAndroid.kt index 7faaa0e1ee..e09bb2ea16 100644 --- a/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.jvmAndroid.kt +++ b/commonsUI/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/ui/components/RememberViewModel.jvmAndroid.kt @@ -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 rememberViewModel( modelClass: KClass, key: String?, factory: () -> VM, -): VM = - viewModel( - modelClass = modelClass, - key = key, - factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create( - modelClass: KClass, - extras: CreationExtras, - ): T = factory() as T - }, - ) +): VM = viewModel(modelClass = modelClass, key = key, factory = rememberLambdaFactory(factory)) diff --git a/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.jvm.kt b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.jvm.kt new file mode 100644 index 0000000000..f03895ab70 --- /dev/null +++ b/commonsUI/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/ui/note/MonthDayTime.jvm.kt @@ -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 diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/QueryParametersTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/QueryParametersTest.kt new file mode 100644 index 0000000000..b3b57ae7ca --- /dev/null +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/navigation/QueryParametersTest.kt @@ -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")) + } +} diff --git a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/DateSkeletonFormatterTest.kt b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/DateSkeletonFormatterTest.kt index e95be9295e..3d3f110330 100644 --- a/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/DateSkeletonFormatterTest.kt +++ b/commonsUI/src/jvmTest/kotlin/com/vitorpamplona/amethyst/commons/ui/note/DateSkeletonFormatterTest.kt @@ -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)))