mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 11:18:24 +00:00
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:
+7
-4
@@ -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(
|
||||
|
||||
+4
-32
@@ -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(
|
||||
|
||||
+7
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-2
@@ -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()
|
||||
}
|
||||
|
||||
-1
@@ -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)) {
|
||||
|
||||
-2
@@ -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,
|
||||
|
||||
+3
-1
@@ -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?,
|
||||
|
||||
-1
@@ -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)
|
||||
|
||||
+1
-4
@@ -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,
|
||||
|
||||
+1
-1
@@ -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(
|
||||
|
||||
+9
-10
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+44
@@ -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'
|
||||
|
||||
+7
-7
@@ -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())
|
||||
|
||||
+7
-7
@@ -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())
|
||||
|
||||
+52
@@ -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"))
|
||||
}
|
||||
}
|
||||
+18
-7
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-8
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+15
-7
@@ -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
|
||||
}
|
||||
|
||||
+42
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-7
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+23
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-1
@@ -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,
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+7
-10
@@ -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
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+7
-2
@@ -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.
|
||||
|
||||
+3
-2
@@ -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
|
||||
|
||||
+9
-7
@@ -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,
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
+4
-5
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-5
@@ -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()
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+4
-5
@@ -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 =
|
||||
|
||||
+19
-5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+1
-13
@@ -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))
|
||||
|
||||
+30
@@ -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
|
||||
+1
-14
@@ -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))
|
||||
|
||||
+30
@@ -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
|
||||
+53
@@ -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"))
|
||||
}
|
||||
}
|
||||
+11
@@ -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)))
|
||||
|
||||
Reference in New Issue
Block a user